diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index d9fef12e..7dba5873 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,84 +1,84 @@ -name: .NET Pull Request -on: - push: - branches: [master] - pull_request: - branches: [master, "codex/**"] - workflow_dispatch: -permissions: - contents: read -concurrency: - group: live-databases-${{ github.ref }} - cancel-in-progress: true -jobs: - test: - name: Test (${{ matrix.database }}) - runs-on: ubuntu-22.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Start database - shell: bash - run: | - mkdir -p TestResults - bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log - timeout-minutes: 15 - - name: Build - run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} - - name: Configure native IBM drivers - if: matrix.database == 'Db2' || matrix.database == 'Informix' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 - output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" - if [ "${{ matrix.database }}" = Db2 ]; then - echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" - else - echo "DELIMIDENT=y" >> "$GITHUB_ENV" - echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" - fi - - name: Test - shell: pwsh - run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} - - name: Collect database logs - if: always() - run: | - mkdir -p TestResults - if docker inspect migrator-db >/dev/null 2>&1; then - docker logs migrator-db > TestResults/database.log 2>&1 - docker inspect migrator-db > TestResults/container.json - fi - - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-${{ matrix.database }} - path: TestResults/ - if-no-files-found: error - - name: Remove test container - if: always() - run: | - if docker inspect migrator-db >/dev/null 2>&1; then - docker rm -fv migrator-db - fi - coverage: - name: Verify complete test coverage - needs: test - runs-on: ubuntu-22.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - pattern: test-results-* - path: TestResults - - run: python3 .github/scripts/verify-test-coverage.py TestResults +name: .NET Pull Request +on: + push: + branches: [master] + pull_request: + branches: [master, "codex/**"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: Test (${{ matrix.database }}) + runs-on: ubuntu-22.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Start database + shell: bash + run: | + mkdir -p TestResults + bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log + timeout-minutes: 15 + - name: Build + run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} + - name: Configure native IBM drivers + if: matrix.database == 'Db2' || matrix.database == 'Informix' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 + output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" + if [ "${{ matrix.database }}" = Db2 ]; then + echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" + else + echo "DELIMIDENT=y" >> "$GITHUB_ENV" + echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" + fi + - name: Test + shell: pwsh + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + - name: Collect database logs + if: always() + run: | + mkdir -p TestResults + if docker inspect migrator-db >/dev/null 2>&1; then + docker logs migrator-db > TestResults/database.log 2>&1 + docker inspect migrator-db > TestResults/container.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.database }} + path: TestResults/ + if-no-files-found: error + - name: Remove test container + if: always() + run: | + if docker inspect migrator-db >/dev/null 2>&1; then + docker rm -fv migrator-db + fi + coverage: + name: Verify complete test coverage + needs: test + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: TestResults + - run: python3 .github/scripts/verify-test-coverage.py TestResults diff --git a/src/Migrator.Tests/MigrationLoaderTest.cs b/src/Migrator.Tests/MigrationLoaderTest.cs index e319dd78..b7c1d641 100644 --- a/src/Migrator.Tests/MigrationLoaderTest.cs +++ b/src/Migrator.Tests/MigrationLoaderTest.cs @@ -1,69 +1,69 @@ -using System.Reflection; -using DotNetProjects.Migrator; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.Loggers; -using NSubstitute; -using NUnit.Framework; - -namespace Migrator.Tests; - -[TestFixture] -public class MigrationLoaderTest -{ - #region Setup/Teardown - - [SetUp] - public void SetUp() - { - SetUpLoader(); - } - - #endregion - - private MigrationLoader _migrationLoader; - - private void SetUpLoader() - { - var providerMock = Substitute.For(); - - providerMock.Logger = new Logger(false); - _migrationLoader = new MigrationLoader(providerMock, Assembly.GetExecutingAssembly(), true); - _migrationLoader.MigrationsTypes.Clear(); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SecondMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ThirdMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ForthMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.BadMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SixthMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.NonIgnoredMigration)); - } - - [Test] - public void CheckForDuplicatedVersion() - { - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); - Assert.Throws(() => - { - _migrationLoader.CheckForDuplicatedVersion(); - }); - } - - [Test] - public void LastVersion() - { - Assert.That(7, Is.EqualTo(_migrationLoader.LastVersion)); - } - - [Test] - public void NullIfNoMigrationForVersion() - { - Assert.That(_migrationLoader.GetMigration(99999999), Is.Null); - } - - [Test] - public void ZeroIfNoMigrations() - { - _migrationLoader.MigrationsTypes.Clear(); - Assert.That(0, Is.EqualTo(_migrationLoader.LastVersion)); - } -} +using System.Reflection; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture] +public class MigrationLoaderTest +{ + #region Setup/Teardown + + [SetUp] + public void SetUp() + { + SetUpLoader(); + } + + #endregion + + private MigrationLoader _migrationLoader; + + private void SetUpLoader() + { + var providerMock = Substitute.For(); + + providerMock.Logger = new Logger(false); + _migrationLoader = new MigrationLoader(providerMock, Assembly.GetExecutingAssembly(), true); + _migrationLoader.MigrationsTypes.Clear(); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SecondMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ThirdMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ForthMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.BadMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SixthMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.NonIgnoredMigration)); + } + + [Test] + public void CheckForDuplicatedVersion() + { + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); + Assert.Throws(() => + { + _migrationLoader.CheckForDuplicatedVersion(); + }); + } + + [Test] + public void LastVersion() + { + Assert.That(7, Is.EqualTo(_migrationLoader.LastVersion)); + } + + [Test] + public void NullIfNoMigrationForVersion() + { + Assert.That(_migrationLoader.GetMigration(99999999), Is.Null); + } + + [Test] + public void ZeroIfNoMigrations() + { + _migrationLoader.MigrationsTypes.Clear(); + Assert.That(0, Is.EqualTo(_migrationLoader.LastVersion)); + } +} diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 457de6da..20a707f2 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -5,8 +5,8 @@ false - - + + @@ -31,25 +31,26 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + + + + + + + + + + - + diff --git a/src/Migrator.Tests/MigratorTest.cs b/src/Migrator.Tests/MigratorTest.cs index 88f362e0..281b630e 100644 --- a/src/Migrator.Tests/MigratorTest.cs +++ b/src/Migrator.Tests/MigratorTest.cs @@ -1,225 +1,225 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using DotNetProjects.Migrator; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.Loggers; -using NSubstitute; -using NUnit.Framework; - -namespace Migrator.Tests; - -[TestFixture] -public class MigratorTest -{ - #region Setup/Teardown - - [SetUp] - public void SetUp() - { - SetUpCurrentVersion(0); - } - - #endregion - - private DotNetProjects.Migrator.Migrator _migrator; - private ITransformationProvider _provider; - - // Collections that contain the version that are called migrating up and down - private static readonly List _upCalled = new List(); - private static readonly List _downCalled = new List(); - - private void SetUpCurrentVersion(long version) - { - SetUpCurrentVersion(version, false); - } - - private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled) - { - SetUpCurrentVersion(version, assertRollbackIsCalled, true); - } - - private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled, bool includeBad) - { - var providerMock = _provider = Substitute.For(); - - var appliedVersions = new List(); - - for (long i = 1; i <= version; i++) - { - appliedVersions.Add(i); - } - - providerMock.AppliedMigrations.Returns(appliedVersions); - providerMock.Logger.Returns(new Logger(false)); - - _migrator = new DotNetProjects.Migrator.Migrator((ITransformationProvider)providerMock, Assembly.GetExecutingAssembly(), false); - - _migrator.MigrationsTypes.Clear(); - _upCalled.Clear(); - _downCalled.Clear(); - - _migrator.MigrationsTypes.Add(typeof(FirstMigration)); - _migrator.MigrationsTypes.Add(typeof(SecondMigration)); - _migrator.MigrationsTypes.Add(typeof(ThirdMigration)); - _migrator.MigrationsTypes.Add(typeof(ForthMigration)); - _migrator.MigrationsTypes.Add(typeof(SixthMigration)); - - if (includeBad) - { - _migrator.MigrationsTypes.Add(typeof(BadMigration)); - } - } - - public class AbstractTestMigration : Migration - { - public override void Up() - { - _upCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); - } - - public override void Down() - { - _downCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); - } - } - - [Migration(1, Ignore = true)] - public class FirstMigration : AbstractTestMigration - { - } - - [Migration(2, Ignore = true)] - public class SecondMigration : AbstractTestMigration - { - } - - [Migration(3, Ignore = true)] - public class ThirdMigration : AbstractTestMigration - { - } - - [Migration(4, Ignore = true)] - public class ForthMigration : AbstractTestMigration - { - } - - [Migration(5, Ignore = true)] - public class BadMigration : AbstractTestMigration - { - public override void Up() - { - throw new Exception("oh uh!"); - } - - public override void Down() - { - throw new Exception("oh uh!"); - } - } - - [Migration(6, Ignore = true)] - public class SixthMigration : AbstractTestMigration - { - } - - [Migration(7)] - public class NonIgnoredMigration : AbstractTestMigration - { - } - - [Test] - public void MigrateBackward() - { - SetUpCurrentVersion(3); - _migrator.MigrateTo(1); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(2, Is.EqualTo(_downCalled.Count)); - - Assert.That(3, Is.EqualTo(_downCalled[0])); - Assert.That(2, Is.EqualTo(_downCalled[1])); - } - - [Test] - public void MigrateDownwardWithRollback() - { - SetUpCurrentVersion(6, true); - - Assert.Throws(() => _migrator.MigrateTo(3)); - _provider.Received(1).Rollback(); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(1, Is.EqualTo(_downCalled.Count)); - - Assert.That(6, Is.EqualTo(_downCalled[0])); - } - - [Test] - public void MigrateToCurrentVersion() - { - SetUpCurrentVersion(3); - - _migrator.MigrateTo(3); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - } - - [Test] - public void MigrateToLastVersion() - { - SetUpCurrentVersion(3, false, false); - - _migrator.MigrateToLastVersion(); - - Assert.That(2, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - } - - [Test] - public void MigrateUpward() - { - SetUpCurrentVersion(1); - _migrator.MigrateTo(3); - - Assert.That(2, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(2, Is.EqualTo(_upCalled[0])); - Assert.That(3, Is.EqualTo(_upCalled[1])); - } - - [Test] - public void MigrateUpwardFrom0() - { - _migrator.MigrateTo(3); - - Assert.That(3, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(1, Is.EqualTo(_upCalled[0])); - Assert.That(2, Is.EqualTo(_upCalled[1])); - Assert.That(3, Is.EqualTo(_upCalled[2])); - } - - [Test] - public void MigrateUpwardWithRollback() - { - SetUpCurrentVersion(3, true); - - Assert.Throws(() => _migrator.MigrateTo(6)); - _provider.Received(1).Rollback(); - - Assert.That(1, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(4, Is.EqualTo(_upCalled[0])); - } - - [Test] - public void ToHumanName() - { - Assert.That("Create a table", Is.EqualTo(StringUtils.ToHumanName("CreateATable"))); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture] +public class MigratorTest +{ + #region Setup/Teardown + + [SetUp] + public void SetUp() + { + SetUpCurrentVersion(0); + } + + #endregion + + private DotNetProjects.Migrator.Migrator _migrator; + private ITransformationProvider _provider; + + // Collections that contain the version that are called migrating up and down + private static readonly List _upCalled = new List(); + private static readonly List _downCalled = new List(); + + private void SetUpCurrentVersion(long version) + { + SetUpCurrentVersion(version, false); + } + + private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled) + { + SetUpCurrentVersion(version, assertRollbackIsCalled, true); + } + + private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled, bool includeBad) + { + var providerMock = _provider = Substitute.For(); + + var appliedVersions = new List(); + + for (long i = 1; i <= version; i++) + { + appliedVersions.Add(i); + } + + providerMock.AppliedMigrations.Returns(appliedVersions); + providerMock.Logger.Returns(new Logger(false)); + + _migrator = new DotNetProjects.Migrator.Migrator((ITransformationProvider)providerMock, Assembly.GetExecutingAssembly(), false); + + _migrator.MigrationsTypes.Clear(); + _upCalled.Clear(); + _downCalled.Clear(); + + _migrator.MigrationsTypes.Add(typeof(FirstMigration)); + _migrator.MigrationsTypes.Add(typeof(SecondMigration)); + _migrator.MigrationsTypes.Add(typeof(ThirdMigration)); + _migrator.MigrationsTypes.Add(typeof(ForthMigration)); + _migrator.MigrationsTypes.Add(typeof(SixthMigration)); + + if (includeBad) + { + _migrator.MigrationsTypes.Add(typeof(BadMigration)); + } + } + + public class AbstractTestMigration : Migration + { + public override void Up() + { + _upCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); + } + + public override void Down() + { + _downCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); + } + } + + [Migration(1, Ignore = true)] + public class FirstMigration : AbstractTestMigration + { + } + + [Migration(2, Ignore = true)] + public class SecondMigration : AbstractTestMigration + { + } + + [Migration(3, Ignore = true)] + public class ThirdMigration : AbstractTestMigration + { + } + + [Migration(4, Ignore = true)] + public class ForthMigration : AbstractTestMigration + { + } + + [Migration(5, Ignore = true)] + public class BadMigration : AbstractTestMigration + { + public override void Up() + { + throw new Exception("oh uh!"); + } + + public override void Down() + { + throw new Exception("oh uh!"); + } + } + + [Migration(6, Ignore = true)] + public class SixthMigration : AbstractTestMigration + { + } + + [Migration(7)] + public class NonIgnoredMigration : AbstractTestMigration + { + } + + [Test] + public void MigrateBackward() + { + SetUpCurrentVersion(3); + _migrator.MigrateTo(1); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(2, Is.EqualTo(_downCalled.Count)); + + Assert.That(3, Is.EqualTo(_downCalled[0])); + Assert.That(2, Is.EqualTo(_downCalled[1])); + } + + [Test] + public void MigrateDownwardWithRollback() + { + SetUpCurrentVersion(6, true); + + Assert.Throws(() => _migrator.MigrateTo(3)); + _provider.Received(1).Rollback(); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(1, Is.EqualTo(_downCalled.Count)); + + Assert.That(6, Is.EqualTo(_downCalled[0])); + } + + [Test] + public void MigrateToCurrentVersion() + { + SetUpCurrentVersion(3); + + _migrator.MigrateTo(3); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + } + + [Test] + public void MigrateToLastVersion() + { + SetUpCurrentVersion(3, false, false); + + _migrator.MigrateToLastVersion(); + + Assert.That(2, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + } + + [Test] + public void MigrateUpward() + { + SetUpCurrentVersion(1); + _migrator.MigrateTo(3); + + Assert.That(2, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(2, Is.EqualTo(_upCalled[0])); + Assert.That(3, Is.EqualTo(_upCalled[1])); + } + + [Test] + public void MigrateUpwardFrom0() + { + _migrator.MigrateTo(3); + + Assert.That(3, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(1, Is.EqualTo(_upCalled[0])); + Assert.That(2, Is.EqualTo(_upCalled[1])); + Assert.That(3, Is.EqualTo(_upCalled[2])); + } + + [Test] + public void MigrateUpwardWithRollback() + { + SetUpCurrentVersion(3, true); + + Assert.Throws(() => _migrator.MigrateTo(6)); + _provider.Received(1).Rollback(); + + Assert.That(1, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(4, Is.EqualTo(_upCalled[0])); + } + + [Test] + public void ToHumanName() + { + Assert.That("Create a table", Is.EqualTo(StringUtils.ToHumanName("CreateATable"))); + } +} diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs new file mode 100644 index 00000000..0e845eaf --- /dev/null +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +namespace Migrator.Tests; +[Category("SQLite")] +public class ProviderCorrectionTests +{ + [Test] public void InlineConstraintNamesAndReservedUniqueColumnsAreQuoted() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("QuotedConstraints", new Column("select", DbType.Int32), + new Unique { Name = "unique name", KeyColumns = new[] { "select" } }, + new CheckConstraint("check name", "\"select\" > 0")); + provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)"); + Assert.That(Assert.Throws(() => provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)")).InnerException, Is.TypeOf()); + Assert.That(Assert.Throws(() => provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (-1)")).InnerException, Is.TypeOf()); + } + [Test] public void NullableResultsDistinguishEmptyNullAndPopulatedData() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("NullableData", new Column("Id", DbType.Int32), new Column("Value", DbType.String)); + Assert.That(provider.ExecuteNullableScalar("SELECT MAX(Id) FROM NullableData"), Is.Null); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.Null); + provider.ExecuteNonQuery("INSERT INTO NullableData VALUES (NULL, NULL)"); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.Null); + provider.ExecuteNonQuery("INSERT INTO NullableData VALUES (7, 'hello')"); + Assert.That(provider.ExecuteNullableScalar("SELECT MAX(Id) FROM NullableData"), Is.EqualTo(7)); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.EqualTo(5)); + } + [Test] public void TableCreationRetainsCallerPrimaryKeyDefinitions() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var first = new Column("First", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); + var second = new Column("Second", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); + provider.AddTable("Composite", first, second); + Assert.That(first.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); + Assert.That(second.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); + provider.Insert("Composite", new[] { "First", "Second" }, new object[] { 1, null }); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Composite")), Is.EqualTo(1)); + provider.AddTable("Reused", first, second); + Assert.That(provider.GetColumns("Reused").Count(c => c.IsPrimaryKey), Is.EqualTo(2)); + } + [Test] public void RebuildPreservesTriggerAndUpdateAction() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER, ParentId INTEGER); CREATE TABLE Audit (Id INTEGER)"); + provider.AddForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade); + provider.ExecuteNonQuery("CREATE TRIGGER ChildAudit AFTER INSERT ON Child BEGIN INSERT INTO Audit VALUES (NEW.Id); END"); + provider.AddColumn("cHiLd", new Column("Name", DbType.String)); + provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child (Id, ParentId) VALUES (2, 1); UPDATE Parent SET Id=3 WHERE Id=1"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT ParentId FROM Child")), Is.EqualTo(3)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Audit")), Is.EqualTo(2)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + [TestCase("ExistingForeignKey")] + [TestCase("ExistingUnique")] + public void IndependentActionsRejectConstraintNameCollisions(string existingName) + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER, ParentId INTEGER, CONSTRAINT ExistingUnique UNIQUE(Id), CONSTRAINT ExistingForeignKey FOREIGN KEY(ParentId) REFERENCES Parent(Id))"); + Assert.Throws(() => provider.AddForeignKey(existingName.ToLowerInvariant(), "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade)); + Assert.That(provider.GetForeignKeyConstraints("Child").Length, Is.EqualTo(1)); + } + [Test] public void NativeDropPreservesTriggerAndForeignKeySetting() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY, Obsolete TEXT); CREATE TABLE Audit (Id INTEGER); CREATE TRIGGER OriginalAudit AFTER INSERT ON Original BEGIN INSERT INTO Audit VALUES (NEW.Id); END"); + provider.RemoveColumn("Original", "Obsolete"); + provider.ExecuteNonQuery("INSERT INTO Original VALUES (7)"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Audit")), Is.EqualTo(7)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + [Test] public void NativeDropRejectsDependentTriggerWithoutLosingData() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER, Obsolete TEXT); CREATE TABLE Audit (Value TEXT); CREATE TRIGGER OriginalAudit AFTER INSERT ON Original BEGIN INSERT INTO Audit VALUES (NEW.Obsolete); END; INSERT INTO Original VALUES (1, 'keep')"); + var error = Assert.Throws(() => provider.RemoveColumn("Original", "Obsolete")); + Assert.That(error.InnerException, Is.TypeOf()); + Assert.That(provider.ExecuteScalar("SELECT Obsolete FROM Original"), Is.EqualTo("keep")); + } + [Test] public void RebuildPreservesAutoincrementHighWaterAfterRowsWereDeleted() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT); INSERT INTO Original(Id) VALUES(100); DELETE FROM Original"); + provider.AddColumn("Original", new Column("Extra", DbType.String)); + provider.ExecuteNonQuery("INSERT INTO Original(Name) VALUES ('next')"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Original")), Is.EqualTo(101)); + } + [Test] public void UnsupportedRebuildLeavesTableIntact() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY) WITHOUT ROWID; INSERT INTO Original VALUES (1)"); + Assert.Throws(() => provider.AddColumn("Original", new Column("Name", DbType.String))); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Original")), Is.EqualTo(1)); + } +} diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs new file mode 100644 index 00000000..fe580d23 --- /dev/null +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Data; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using NUnit.Framework; +using NSubstitute; +namespace Migrator.Tests; +public class ProviderDefinitionTests +{ + private sealed class RecordingProvider() : TransformationProvider(new SqlServerDialect(), (IDbConnection)null, null, "default") + { + public string ColumnSql; + public (string Child, string[] ChildColumns, string Parent, string[] ParentColumns, ForeignKeyConstraintType Delete, ForeignKeyConstraintType Update) ForeignKey; + public override void ChangeColumn(string table, string sqlColumn) => ColumnSql = sqlColumn; + public override void AddUniqueConstraint(string name, string table, params string[] columns) { } + public override void AddColumn(string table, string sqlColumn) => ColumnSql = sqlColumn; + public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType delete, ForeignKeyConstraintType update) + => ForeignKey = (child, columns, parent, parentColumns, delete, update); + public override List GetDatabases() => new(); + public override bool ConstraintExists(string table, string name) => false; + public override bool IndexExists(string table, string name) => false; + } + [Test] public void NullableScalarRetainsTypedValuesAndHandlesNulls() + { + var provider = Substitute.For(); + var id = Guid.NewGuid(); + provider.ExecuteScalar("guid").Returns(id); + provider.ExecuteScalar("null").Returns(DBNull.Value); + provider.ExecuteScalar("number").Returns(12L); + Assert.That(provider.ExecuteNullableScalar("guid"), Is.EqualTo(id)); + Assert.That(provider.ExecuteNullableScalar("null"), Is.Null); + Assert.That(provider.ExecuteNullableScalar("number"), Is.EqualTo(12)); + } + [Test] public void ChangeColumnDoesNotClearCallerUniqueFlag() + { + using var provider = new RecordingProvider(); + var column = new Column("Value", DbType.Int32, ColumnProperty.Unique | ColumnProperty.NotNull); + provider.ChangeColumn("Example", column); + Assert.That(column.ColumnProperty, Is.EqualTo(ColumnProperty.Unique | ColumnProperty.NotNull)); + } + [Test] public void QuotingReturnsNewArrayWithoutChangingCallerNames() + { + using var provider = new RecordingProvider(); + var names = new[] { "select", "Normal" }; + var quoted = provider.QuoteColumnNamesIfRequired(names); + Assert.That(quoted, Is.Not.SameAs(names)); + Assert.That(names, Is.EqualTo(new[] { "select", "Normal" })); + Assert.That(quoted[0], Is.EqualTo("[select]")); + } + [Test] public void AddColumnCarriesPrecisionAndScaleIntoDialectMapping() + { + using var provider = new RecordingProvider(); + var column = new Column("Amount", DbType.Decimal) { Precision = 12, Scale = 4 }; + provider.AddColumn("Example", column); + Assert.That(provider.ColumnSql.Replace(" ", "").ToUpperInvariant(), Does.Contain("DECIMAL(12,4)")); + Assert.That(column.Precision, Is.EqualTo(12)); + Assert.That(column.Scale, Is.EqualTo(4)); + } + [Test] public void ForeignKeyDefinitionRetainsDirectionAndIndependentActions() + { + using var provider = new RecordingProvider(); + var fk = new ForeignKeyConstraint("fk", "Parent", new[] { "ParentId" }, "Child", new[] { "ParentReference" }) { OnDelete = "SET NULL", OnUpdate = "CASCADE" }; + provider.AddForeignKey("Child", fk); + Assert.That(provider.ForeignKey.Child, Is.EqualTo("Child")); + Assert.That(provider.ForeignKey.ChildColumns, Is.EqualTo(fk.ChildColumns)); + Assert.That(provider.ForeignKey.Parent, Is.EqualTo("Parent")); + Assert.That(provider.ForeignKey.ParentColumns, Is.EqualTo(fk.ParentColumns)); + Assert.That(provider.ForeignKey.Delete, Is.EqualTo(ForeignKeyConstraintType.SetNull)); + Assert.That(provider.ForeignKey.Update, Is.EqualTo(ForeignKeyConstraintType.Cascade)); + Assert.That(provider.ForeignKey.ChildColumns, Is.Not.SameAs(fk.ChildColumns)); + } +} + diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs index b20ad58b..a1902a26 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs @@ -10,6 +10,18 @@ namespace Migrator.Tests.Providers.Generic; public abstract class Generic_ChangeColumnTestsBase : TransformationProviderBase { + [Test] + public void AddColumn_PrecisionAndScale_PersistFractionalValue() + { + Provider.AddTable("PrecisionRoundTrip", new Column("Id", DbType.Int32)); + Provider.AddColumn("PrecisionRoundTrip", new Column("Amount", DbType.Decimal) { Precision = 12, Scale = 4 }); + Provider.Insert("PrecisionRoundTrip", new[] { "Id", "Amount" }, new object[] { 1, 12.3456m }); + using var command = Provider.CreateCommand(); + using var reader = Provider.Select(command, "PrecisionRoundTrip", new[] { "Amount" }); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetDecimal(0), Is.EqualTo(12.3456m)); + } + [Test] public void ChangeColumn_NotNullAndNullToNotNull_Success() { @@ -34,7 +46,7 @@ public void ChangeColumn_NotNullAndNullToNotNull_Success() Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); } - [Test, Ignore("Not yet implemented. See issue https://github.com/dotnetprojects/Migrator.NET/issues/139")] + [Test] public void ChangeColumn_RemoveDefaultValue_Success() { // Arrange @@ -59,11 +71,11 @@ public void ChangeColumn_RemoveDefaultValue_Success() using var cmd = Provider.CreateCommand(); using var reader = Provider.Select(cmd: cmd, table: tableName, columns: [column1Name, column2Name]); - List<(int, DateTime)> records = []; + List<(int, DateTime?)> records = []; while (reader.Read()) { - records.Add((reader.GetInt32(0), reader.GetDateTime(1))); + records.Add((reader.GetInt32(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1))); } Assert.That(records.Count, Is.EqualTo(2)); diff --git a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs index e51a6a07..22c3681f 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs @@ -1,7 +1,33 @@ using Migrator.Tests.Providers.Base; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; namespace Migrator.Tests.Providers.Generic; public abstract class Generic_GetColumnsTestsBase : TransformationProviderBase { + [Test] + public void CompositeUniqueDoesNotMarkItsIndividualColumnsUnique() + { + Provider.AddTable("CompositeUniqueMetadata", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); + Provider.AddUniqueConstraint("CompositeUniqueKey", "CompositeUniqueMetadata", "FirstId", "SecondId"); + Assert.That(Provider.GetColumns("CompositeUniqueMetadata").All(c => !c.ColumnProperty.HasFlag(ColumnProperty.Unique)), Is.True); + } + + [Test] + public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.Null | ColumnProperty.Unique)); + } + } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index e6d20358..40712dbb 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -1,4 +1,6 @@ using System.Data; +using System; +using DotNetProjects.Migrator.Providers.Impl.Oracle; using DotNetProjects.Migrator.Framework; using Migrator.Tests.Providers.OracleProvider.Base; using NUnit.Framework; @@ -9,6 +11,43 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase { + [Test] + public void LegacyForeignKeyOverloadHonorsCascadeDelete() + { + Provider.AddTable("CascadeParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable("CascadeChild", new Column("ParentId", DbType.Int32)); + Provider.AddForeignKey("CascadeForeignKey", "CascadeChild", new[] { "ParentId" }, "CascadeParent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); + Provider.Insert("CascadeParent", new[] { "Id" }, new object[] { 1 }); + Provider.Insert("CascadeChild", new[] { "ParentId" }, new object[] { 1 }); + Provider.ExecuteNonQuery("DELETE FROM CascadeParent"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM CascadeChild")), Is.Zero); + } + + [Test] + public void RemovingTableDoesNotGuessOwnershipOfLegacyNamedSequence() + { + Provider.AddTable("UnownedSequenceTable", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE UnownedSequenceTable_SEQUENCE"); + try + { + Provider.RemoveTable("UnownedSequenceTable"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='UNOWNEDSEQUENCETABLE_SEQUENCE'")), Is.EqualTo(1)); + } + finally { Provider.ExecuteNonQuery("DROP SEQUENCE UnownedSequenceTable_SEQUENCE"); } + } + + [Test] + public void ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger() + { + Provider.AddTable("LegacyOwned", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE LegacyOwned_SEQUENCE"); + Provider.ExecuteNonQuery("CREATE TRIGGER LegacyOwned_TRIGGER BEFORE INSERT ON LegacyOwned FOR EACH ROW BEGIN SELECT LegacyOwned_SEQUENCE.NEXTVAL INTO :new.Id FROM dual; END;"); + ((OracleTransformationProvider)Provider).RemoveTableWithOwnedSequences("LegacyOwned", "LegacyOwned_SEQUENCE"); + Assert.That(Provider.TableExists("LegacyOwned"), Is.False); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='LEGACYOWNED_SEQUENCE'")), Is.Zero); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_TRIGGERS WHERE TRIGGER_NAME='LEGACYOWNED_TRIGGER'")), Is.Zero); + } + [Test] public void TableExists_TableExists_Returns() { diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs new file mode 100644 index 00000000..4a0e1b03 --- /dev/null +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Migrator.Tests.Providers.PostgreSQL.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.PostgreSQL; + +public class PostgreSQLTransformationProvider_MetadataScopeTests : PostgreSQLTransformationProviderTestBase +{ + [Test] + public void QualifiedMetadataDoesNotMixSameNamedTablesOrConstraints() + { + Provider.ExecuteNonQuery("CREATE SCHEMA metadata_a; CREATE SCHEMA metadata_b"); + Provider.ExecuteNonQuery("CREATE TABLE metadata_a.sample (id integer CONSTRAINT same_name UNIQUE); CREATE TABLE metadata_b.sample (value text)"); + Provider.ExecuteNonQuery("CREATE VIEW metadata_b.sample_view AS SELECT value FROM metadata_b.sample"); + Assert.That(Provider.TableExists("metadata_a.sample"), Is.True); + Assert.That(Provider.TableExists("metadata_b.missing"), Is.False); + Assert.That(Provider.ViewExists("metadata_b.sample_view"), Is.True); + Assert.That(Provider.TableExists("metadata_b.sample_view"), Is.False); + Assert.That(Provider.ColumnExists("metadata_a.sample", "id"), Is.True); + Assert.That(Provider.ColumnExists("metadata_b.sample", "id"), Is.False); + Assert.That(Provider.ConstraintExists("metadata_a.sample", "same_name"), Is.True); + Assert.That(Provider.ConstraintExists("metadata_b.sample", "same_name"), Is.False); + Assert.That(Provider.GetColumns("metadata_a.sample").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetColumns("metadata_b.sample").Single().MigratorDbType, Is.EqualTo(MigratorDbType.String)); + Provider.ExecuteNonQuery("SET LOCAL search_path TO metadata_b"); + Assert.That(Provider.GetColumns("sample").Single().Name, Is.EqualTo("value")); + } + + [Test] + public void QuotedCatalogNamesRemainExactAndAreParameterized() + { + Provider.ExecuteNonQuery("CREATE TABLE \"Meta'Table\" (id integer CONSTRAINT \"Key'Name\" UNIQUE)"); + Assert.That(Provider.TableExists("\"Meta'Table\""), Is.True); + Assert.That(Provider.ConstraintExists("\"Meta'Table\"", "Key'Name"), Is.True); + Assert.That(Provider.GetColumns("\"Meta'Table\"").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + } + + [Test] + public void NativeTimeRoundTripsThroughMetadataDefaultsAndParameters() + { + var value = new TimeSpan(0, 12, 34, 56, 789); + Provider.AddTable("NativeTimeRoundTrip", new Column("Value", DbType.Time, value)); + var column = Provider.GetColumns("NativeTimeRoundTrip").Single(); + Assert.That(column.MigratorDbType, Is.EqualTo(MigratorDbType.Time)); + Assert.That(column.DefaultValue, Is.EqualTo(value)); + Provider.Insert("NativeTimeRoundTrip", new[] { "Value" }, new object[] { value }); + Assert.That(Provider.ExecuteScalar("SELECT * FROM NativeTimeRoundTrip"), Is.EqualTo(value)); + } +} diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs index e31ceaff..8712b4e3 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs @@ -3,6 +3,7 @@ using DotNetProjects.Migrator.Framework; using Migrator.Tests.Providers.Generic; using NUnit.Framework; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; namespace Migrator.Tests.Providers.SQLServer; @@ -16,6 +17,44 @@ public async Task SetUpAsync() await BeginSQLServerTransactionAsync(); } + [TestCase(false), TestCase(true)] + public void ChangeColumnRemovesOwnedUniqueFromTableOrColumnCreation(bool addColumn) + { + var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull | ColumnProperty.Unique); + if (addColumn) + { + Provider.AddTable("CreatedUnique", new Column("Id", DbType.Int32)); + Provider.AddColumn("CreatedUnique", definition); + } + else Provider.AddTable("CreatedUnique", definition); + Provider.ChangeColumn("CreatedUnique", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); + Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); + Assert.That(definition.ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetIndexes("CreatedUnique"), Is.Empty); + } + + [Test] + public void OwnershipAdoptionRejectsCompositeConstraints() + { + Provider.AddTable("CompositeOwned", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); + Provider.AddUniqueConstraint("UserComposite", "CompositeOwned", "FirstId", "SecondId"); + Assert.Throws(() => ((SqlServerTransformationProvider)Provider).AdoptColumnUniqueConstraint("CompositeOwned", "FirstId", "UserComposite")); + Assert.That(Provider.ConstraintExists("CompositeOwned", "UserComposite"), Is.True); + } + + [Test] + public void ExplicitOwnershipAdoptionAllowsLegacyUniqueRemoval() + { + Provider.AddTable("LegacyUnique", new Column("Value", DbType.Int32)); + Provider.AddUniqueConstraint("LegacyUniqueConstraint", "LegacyUnique", "Value"); + var sqlServer = (SqlServerTransformationProvider)Provider; + sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); + sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); + Provider.ChangeColumn("LegacyUnique", new Column("Value", DbType.Int32, ColumnProperty.Null)); + Assert.That(Provider.ConstraintExists("LegacyUnique", "LegacyUniqueConstraint"), Is.False); + } + [Test] public void ChangeColumn_DateTimeToDateTime2_Success() { @@ -36,7 +75,19 @@ public void ChangeColumn_DateTimeToDateTime2_Success() Assert.That(columnAfter.Type == DbType.DateTime2); } - [Test, Ignore("This issue is not yet fixed. See https://github.com/dotnetprojects/Migrator.NET/issues/132")] + [Test] + public void ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition() + { + Provider.AddTable("UserOwned", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + Provider.AddUniqueConstraint("UX_UserOwned_Value", "UserOwned", "Value"); + var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull, 3); + Provider.ChangeColumn("UserOwned", definition); + Assert.That(Provider.ConstraintExists("UserOwned", "UX_UserOwned_Value"), Is.True); + Assert.That(definition.DefaultValue, Is.EqualTo(3)); + Assert.That(definition.ColumnProperty, Is.EqualTo(ColumnProperty.NotNull)); + } + + [Test] public void ChangeColumn_WithUniqueThenReChangeToNonUnique_UniqueConstraintShouldBeRemoved() { // Arrange diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 9c0d0641..074730a8 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -1,4 +1,7 @@ using System.Data; +using System.Linq; +using System; +using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers; using DotNetProjects.Migrator.Providers.Impl.SqlServer; using Migrator.Tests.Providers.SQLServer.Base; @@ -10,6 +13,43 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void TimeTypeDefaultAndValueRoundTripThroughMetadata() + { + var time = new TimeSpan(0, 12, 34, 56, 789); + Provider.AddTable("ClockValues", new Column("Moment", DbType.Time, ColumnProperty.Null, time)); + var column = Provider.GetColumns("ClockValues").Single(); + Assert.That(column.Type, Is.EqualTo(DbType.Time)); + Assert.That(column.DefaultValue, Is.EqualTo(time)); + Provider.AddTable("CopiedClock", column); + Provider.ExecuteNonQuery("INSERT INTO CopiedClock DEFAULT VALUES"); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM CopiedClock"), Is.EqualTo(time)); + Provider.Insert("ClockValues", new[] { "Moment" }, new object[] { time }); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM ClockValues"), Is.EqualTo(time)); + } + + [Test] + public void ExplicitScriptSplitsGoWithoutSplittingMultilineValues() + { + Provider.ExecuteSqlScript("CREATE TABLE ScriptBatches (Value nvarchar(100));\nGO\nINSERT INTO ScriptBatches VALUES ('before\nGO\nafter');\nGO -- final batch\nINSERT INTO ScriptBatches VALUES ('last');"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM ScriptBatches")), Is.EqualTo(2)); + Assert.That(Provider.ExecuteScalar("SELECT Value FROM ScriptBatches WHERE Value LIKE 'before%'"), Does.Contain("GO")); + } + + [Test] + public void IndependentForeignKeyActionsCascadeUpdateAndSetNullOnDelete() + { + Provider.AddTable("ActionParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + Provider.AddTable("ActionChild", new Column("ParentId", DbType.Int32, ColumnProperty.Null)); + ((IForeignKeyActions)Provider).AddForeignKey("ActionForeignKey", "ActionChild", new[] { "ParentId" }, + "ActionParent", new[] { "Id" }, ForeignKeyConstraintType.SetNull, ForeignKeyConstraintType.Cascade); + Provider.ExecuteNonQuery("INSERT INTO ActionParent VALUES (1); INSERT INTO ActionChild VALUES (1); UPDATE ActionParent SET Id=2 WHERE Id=1"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT ParentId FROM ActionChild")), Is.EqualTo(2)); + Provider.ExecuteNonQuery("DELETE FROM ActionParent WHERE Id=2"); + Assert.That(Provider.ExecuteNullableScalar("SELECT ParentId FROM ActionChild"), Is.Null); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM ActionChild")), Is.EqualTo(1)); + } + [Test] public void ByteColumnWillBeCreatedAsBlob() { diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs index 2b4b3b3e..bd535a3f 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs @@ -38,8 +38,8 @@ public void AddForeignKey(ForeignKeyConstraintType constraint, string expectedAc Assert.That(foreignKeyConstraints.Single().OnDelete, Is.EqualTo(expectedAction)); var expectedClause = constraint == ForeignKeyConstraintType.NoAction ? "" : $" ON DELETE {expectedAction}"; - Assert.That(tableSQLCreateScript, Does.Contain("CREATE TABLE \"TestTwo\"")); - Assert.That(tableSQLCreateScript, Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); // Reading and rebuilding an existing foreign key must retain its action. Provider.RenameColumn("TestTwo", "TestId", "ParentId"); @@ -67,8 +67,8 @@ public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPoint var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); - Assert.That(tableSQLCreateScript, Does.Contain("CREATE TABLE \"TestTwo\"")); - Assert.That(tableSQLCreateScript, Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("IdNew")); var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs index 9b7c576a..6fcbb886 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs @@ -18,20 +18,6 @@ public async Task SetUpAsync() await BeginSQLiteTransactionAsync(); } - [Test] - public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.Null | ColumnProperty.Unique)); - } - [Test] public void GetColumns_PrimaryAndUnique_ReturnsFalse() { diff --git a/src/Migrator.Tests/ScriptResource.sql b/src/Migrator.Tests/ScriptResource.sql new file mode 100644 index 00000000..d577d313 --- /dev/null +++ b/src/Migrator.Tests/ScriptResource.sql @@ -0,0 +1 @@ +INSERT INTO ScriptData VALUES (2); diff --git a/src/Migrator.Tests/ScriptTests.cs b/src/Migrator.Tests/ScriptTests.cs new file mode 100644 index 00000000..f1cbb8da --- /dev/null +++ b/src/Migrator.Tests/ScriptTests.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; +namespace Migrator.Tests; +public class ScriptTests +{ + [Test] + public void GoInsideMultilineStringsCommentsAndQuotedIdentifiersDoesNotSplit() + { + var batches = SqlScriptBatches.SplitSqlServer("SELECT 'line\nGO\n''quoted''';\nGO -- next batch\n/* outer\n/* nested */\nGO\n*/ SELECT [line\nGO\n]]name];\ngo\nSELECT 3;"); + Assert.That(batches.Count, Is.EqualTo(3)); + Assert.That(batches[0], Does.Contain("GO")); + Assert.That(batches[1], Does.Contain("GO")); + Assert.That(batches[2].Trim(), Is.EqualTo("SELECT 3;")); + } + [TestCase("GO 2")] + [TestCase(":r other.sql")] + [TestCase("!! echo value")] + public void UnsupportedClientCommandsAreRejectedBeforeExecution(string command) + => Assert.Throws(() => SqlScriptBatches.SplitSqlServer("SELECT 1;\nGO\n" + command)); + + [Test, Category("SQLite")] + public void ExplicitFileAndEmbeddedResourceScriptsPersistData() + { + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(DotNetProjects.Migrator.Providers.ProviderTypes.SQLite, connection, null); + var file = Path.GetTempFileName(); + try + { + File.WriteAllText(file, "CREATE TABLE ScriptData (Id INTEGER); INSERT INTO ScriptData VALUES (1);"); + provider.ExecuteScript(file); + provider.ExecuteResourceScript(typeof(ScriptTests).Assembly, "Migrator.Tests.ScriptResource.sql"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT SUM(Id) FROM ScriptData")), Is.EqualTo(3)); + } + finally { File.Delete(file); } + } +} diff --git a/src/Migrator/Framework/Column.cs b/src/Migrator/Framework/Column.cs index 3a444a20..ca29f441 100644 --- a/src/Migrator/Framework/Column.cs +++ b/src/Migrator/Framework/Column.cs @@ -23,6 +23,8 @@ public class Column : IColumn, IDbField { private object _defaultValue; + internal Column CopyDefinition() => (Column)MemberwiseClone(); + public Column(string name) { Name = name; diff --git a/src/Migrator/Framework/IForeignKeyActions.cs b/src/Migrator/Framework/IForeignKeyActions.cs new file mode 100644 index 00000000..f7b774e2 --- /dev/null +++ b/src/Migrator/Framework/IForeignKeyActions.cs @@ -0,0 +1,6 @@ +namespace DotNetProjects.Migrator.Framework; +public interface IForeignKeyActions +{ + void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate); +} diff --git a/src/Migrator/Framework/ProviderExtensions.cs b/src/Migrator/Framework/ProviderExtensions.cs new file mode 100644 index 00000000..0c6c2707 --- /dev/null +++ b/src/Migrator/Framework/ProviderExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Data; +using System.IO; +using System.Reflection; +namespace DotNetProjects.Migrator.Framework; + +public static class ProviderExtensions +{ + public static T? ExecuteNullableScalar(this ITransformationProvider provider, string sql) where T : struct + { + var value = provider.ExecuteScalar(sql); + if (value == null || value == DBNull.Value) return null; + if (value is T typed) return typed; + return (T)Convert.ChangeType(value, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } + + public static int? GetNullableColumnContentSize(this ITransformationProvider provider, string table, string column) + { + // Preserve each dialect's length semantics, distinguishing empty/all-null input first. + var count = provider.ExecuteScalar($"SELECT COUNT({provider.QuoteColumnNameIfRequired(column)}) FROM {provider.QuoteTableNameIfRequired(table)}"); + return Convert.ToInt64(count) == 0 ? null : provider.GetColumnContentSize(table, column); + } + + /// Execute script text with optional provider batch handling. ExecuteNonQuery is never split. + public static void ExecuteSqlScript(this ITransformationProvider provider, string sql) + { + var batches = provider is IScriptBatchProvider splitter ? splitter.SplitScript(sql) : new[] { sql }; + foreach (var batch in batches) provider.ExecuteNonQuery(batch); + } + + public static void ExecuteScript(this ITransformationProvider provider, string fileName) + { + if (provider is Providers.TransformationProvider builtIn) { builtIn.ExecuteScript(fileName); return; } + if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("A script path is required.", nameof(fileName)); + provider.ExecuteSqlScript(File.ReadAllText(Path.IsPathRooted(fileName) ? fileName : Path.Combine(AppContext.BaseDirectory, fileName))); + } + + public static void ExecuteResourceScript(this ITransformationProvider provider, Assembly assembly, string name) + { + using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("SQL resource not found", name); + using var reader = new StreamReader(stream); + provider.ExecuteSqlScript(reader.ReadToEnd()); + } +} diff --git a/src/Migrator/Framework/SqlScriptBatches.cs b/src/Migrator/Framework/SqlScriptBatches.cs new file mode 100644 index 00000000..fd4d45f1 --- /dev/null +++ b/src/Migrator/Framework/SqlScriptBatches.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +namespace DotNetProjects.Migrator.Framework; + +/// Optional provider capability for client-side script separators. +public interface IScriptBatchProvider +{ + IReadOnlyList SplitScript(string sql); +} + +public static class SqlScriptBatches +{ + /// Split standalone SQL Server GO lines. SQLCMD directives and GO counts are unsupported. + public static IReadOnlyList SplitSqlServer(string sql) + { + ArgumentNullException.ThrowIfNull(sql); + var batches = new List(); + var batch = new StringBuilder(); + var quote = '\0'; + var comments = 0; + using var reader = new StringReader(sql); + string line; + while ((line = reader.ReadLine()) != null) + { + if (quote == '\0' && comments == 0) + { + if (Regex.IsMatch(line, @"^\s*GO\s*(?:--.*)?$", RegexOptions.IgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(batch.ToString())) batches.Add(batch.ToString()); + batch.Clear(); + continue; + } + if (Regex.IsMatch(line, @"^\s*GO(?:\s|;|$)", RegexOptions.IgnoreCase)) + throw new NotSupportedException("Only standalone GO with an optional -- comment is supported; counts and other suffixes are not."); + var command = line.TrimStart(); + if (command.StartsWith(":", StringComparison.Ordinal) || command.StartsWith("!!", StringComparison.Ordinal)) + throw new NotSupportedException("SQLCMD directives are not supported by script execution."); + } + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + var next = i + 1 < line.Length ? line[i + 1] : '\0'; + if (comments != 0) + { + if (ch == '/' && next == '*') { comments++; i++; } + else if (ch == '*' && next == '/') { comments--; i++; } + } + else if (quote != '\0') + { + if (ch == quote) + { + if (next == quote) i++; + else quote = '\0'; + } + } + else if (ch == '-' && next == '-') break; + else if (ch == '/' && next == '*') { comments++; i++; } + else if (ch == '\'' || ch == '"') quote = ch; + else if (ch == '[') quote = ']'; + } + batch.AppendLine(line); + } + if (quote != '\0' || comments != 0) throw new FormatException("The script contains an unterminated string, identifier or comment."); + if (!string.IsNullOrWhiteSpace(batch.ToString())) batches.Add(batch.ToString()); + return batches; + } +} diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 522b7b36..4be11204 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -1,175 +1,175 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers; - -namespace DotNetProjects.Migrator; - -/// -/// Handles inspecting code to find all of the Migrations in assemblies and reading -/// other metadata such as the last revision, etc. -/// -public class MigrationLoader -{ - private readonly List _migrationsTypes = new List(); - private readonly ITransformationProvider _provider; - - public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) - { - _provider = provider; - AddMigrations(migrationAssembly); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) - { - _provider = provider; - _migrationsTypes.AddRange(migrationTypes); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - /// - /// Returns registered migration types. - /// - public virtual List MigrationsTypes - { - get { return _migrationsTypes; } - } - - /// - /// Returns the last version of the migrations. - /// - public virtual long LastVersion - { - get - { - if (_migrationsTypes.Count == 0) - { - return 0; - } - - return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); - } - } - - public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - _provider is not IMigrationHistory history || - t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); - - public virtual void AddMigrations(Assembly migrationAssembly) - { - if (migrationAssembly != null) - { - _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); - } - } - - /// - /// Check for duplicated version in migrations. - /// - /// CheckForDuplicatedVersion - public virtual void CheckForDuplicatedVersion() - { - var versions = new List(); - foreach (var t in SelectedTypes) - { - var version = GetMigrationVersion(t); - - if (versions.Contains(version)) - { - throw new DuplicatedVersionException(version); - } - - versions.Add(version); - } - } - - /// - /// Collect migrations in one Assembly. - /// - /// The Assembly to browse. - /// The migrations collection - public static List GetMigrationTypes(Assembly asm) - { - var migrations = new List(); - foreach (var t in asm.GetExportedTypes()) - { - - -#if NETSTANDARD - var attrib = t.GetTypeInfo().GetCustomAttribute(); - if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#else - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#endif - - - } - - migrations.Sort(new MigrationTypeComparer(true)); - return migrations; - } - - /// - /// Returns the version of the migration - /// MigrationAttribute. - /// - /// Migration type. - /// Version number sepcified in the attribute - public static long GetMigrationVersion(Type t) - { - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); - } - - public List GetAvailableMigrations() - { - _migrationsTypes.Sort(new MigrationTypeComparer(true)); - return SelectedTypes.Select(GetMigrationVersion).ToList(); - } - - public virtual IMigration GetMigration(long version) - { - foreach (var t in SelectedTypes) - { - if (GetMigrationVersion(t) == version) - { - var migration = CreateInstance(t); - migration.Database = _provider; - return migration; - } - } - - return null; - } - - public virtual IMigration CreateInstance(Type migrationType) - { - return (IMigration)Activator.CreateInstance(migrationType); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator; + +/// +/// Handles inspecting code to find all of the Migrations in assemblies and reading +/// other metadata such as the last revision, etc. +/// +public class MigrationLoader +{ + private readonly List _migrationsTypes = new List(); + private readonly ITransformationProvider _provider; + + public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) + { + _provider = provider; + AddMigrations(migrationAssembly); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) + { + _provider = provider; + _migrationsTypes.AddRange(migrationTypes); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + /// + /// Returns registered migration types. + /// + public virtual List MigrationsTypes + { + get { return _migrationsTypes; } + } + + /// + /// Returns the last version of the migrations. + /// + public virtual long LastVersion + { + get + { + if (_migrationsTypes.Count == 0) + { + return 0; + } + + return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); + } + } + + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => + _provider is not IMigrationHistory history || + t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); + + public virtual void AddMigrations(Assembly migrationAssembly) + { + if (migrationAssembly != null) + { + _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); + } + } + + /// + /// Check for duplicated version in migrations. + /// + /// CheckForDuplicatedVersion + public virtual void CheckForDuplicatedVersion() + { + var versions = new List(); + foreach (var t in SelectedTypes) + { + var version = GetMigrationVersion(t); + + if (versions.Contains(version)) + { + throw new DuplicatedVersionException(version); + } + + versions.Add(version); + } + } + + /// + /// Collect migrations in one Assembly. + /// + /// The Assembly to browse. + /// The migrations collection + public static List GetMigrationTypes(Assembly asm) + { + var migrations = new List(); + foreach (var t in asm.GetExportedTypes()) + { + + +#if NETSTANDARD + var attrib = t.GetTypeInfo().GetCustomAttribute(); + if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) + { + migrations.Add(t); + } +#else + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) + { + migrations.Add(t); + } +#endif + + + } + + migrations.Sort(new MigrationTypeComparer(true)); + return migrations; + } + + /// + /// Returns the version of the migration + /// MigrationAttribute. + /// + /// Migration type. + /// Version number sepcified in the attribute + public static long GetMigrationVersion(Type t) + { + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); + } + + public List GetAvailableMigrations() + { + _migrationsTypes.Sort(new MigrationTypeComparer(true)); + return SelectedTypes.Select(GetMigrationVersion).ToList(); + } + + public virtual IMigration GetMigration(long version) + { + foreach (var t in SelectedTypes) + { + if (GetMigrationVersion(t) == version) + { + var migration = CreateInstance(t); + migration.Database = _provider; + return migration; + } + } + + return null; + } + + public virtual IMigration CreateInstance(Type migrationType) + { + return (IMigration)Activator.CreateInstance(migrationType); + } +} diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 1fb5ef08..11fed953 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -154,6 +154,14 @@ private void Reorganize(string table) ExecuteNonQuery($"CALL SYSPROC.ADMIN_CMD('REORG TABLE {schema}.{Identifier(table).Replace("'", "''")}')"); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("DB2 does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { // Db2 supports only NO ACTION/RESTRICT for ON UPDATE. diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 2711e38f..5b3a7c90 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -204,11 +204,19 @@ public override void RemoveColumnDefaultValue(string table, string column) } public override void AddPrimaryKey(string name, string table, params string[] columns) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", columns)}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddUniqueConstraint(string name, string table, params string[] columns) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", columns)}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddCheckConstraint(string name, string table, string checkSql) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); + + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("Informix does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { @@ -218,6 +226,6 @@ public override void AddForeignKey(string name, string childTable, string[] chil ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict => "", _ => throw new NotSupportedException("Informix supports cascading deletes or its default restrictive referential action.") }; - ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)}){action} CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))}){action} CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); } } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 6a5a35ed..1b3a7ddd 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -94,12 +94,7 @@ public override void AddForeignKey(string name, string primaryTable, string[] pr { GuardAgainstMaximumIdentifierLengthForOracle(name); - primaryTable = QuoteTableNameIfRequired(primaryTable); - refTable = QuoteTableNameIfRequired(refTable); - var primaryColumnsSql = string.Join(",", primaryColumns.Select(col => QuoteColumnNameIfRequired(col)).ToArray()); - var refColumnsSql = string.Join(",", refColumns.Select(col => QuoteColumnNameIfRequired(col)).ToArray()); - - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} FOREIGN KEY ({2}) REFERENCES {3} ({4})", primaryTable, name, primaryColumnsSql, refTable, refColumnsSql)); + AddForeignKey(name, primaryTable, primaryColumns, refTable, refColumns, constraint, ForeignKeyConstraintType.NoAction); } public override string AddIndex(string table, Index index) @@ -148,7 +143,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "TRUE" : "FALSE", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException($"Given type in '{nameof(FilterItem)}' is not implemented. Please file an issue."), @@ -201,6 +196,7 @@ protected override string GetPrimaryKeyname(string tableName) public override void ChangeColumn(string table, Column column) { + column = column.CopyDefinition(); var existingColumn = GetColumnByName(table, column.Name); if (column.Type == DbType.String) @@ -229,6 +225,9 @@ public override void ChangeColumn(string table, Column column) } else { + // String changes replace the column, which already removes its default. + // For in-place changes Oracle otherwise retains the existing default. + if (column.DefaultValue == null) RemoveColumnDefaultValue(table, column.Name); if (((existingColumn.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull) && ((column.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull)) { @@ -472,6 +471,7 @@ public override Column[] GetColumns(string table) var userTabIdentityCols = _oracleSystemDataLoader.GetUserTabIdentityCols(tableName: table); var primaryKeyItems = _oracleSystemDataLoader.GetPrimaryKeyItems(tableName: table); + var uniqueColumns = ExecuteStringQuery("SELECT MIN(cc.COLUMN_NAME) FROM USER_CONSTRAINTS c JOIN USER_CONS_COLUMNS cc ON c.CONSTRAINT_NAME=cc.CONSTRAINT_NAME WHERE c.CONSTRAINT_TYPE='U' AND LOWER(c.TABLE_NAME)=LOWER('{0}') GROUP BY c.CONSTRAINT_NAME HAVING COUNT(*)=1", table.Replace("'", "''")); List userTabColumns = []; @@ -522,6 +522,7 @@ public override Column[] GetColumns(string table) ColumnProperty = isNullable ? ColumnProperty.Null : ColumnProperty.NotNull }; + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; var isIdentity = userTabIdentityCols.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); var isPrimaryKey = primaryKeyItems.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); @@ -531,11 +532,11 @@ public override Column[] GetColumns(string table) } else if (isIdentity) { - column.ColumnProperty.Set(ColumnProperty.Identity); + column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Identity); } else if (isPrimaryKey) { - column.ColumnProperty.Set(ColumnProperty.PrimaryKey); + column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKey); } // Oracle does not have unsigned types. All NUMBER types can hold positive or negative values so we do not return DbType.UIntX types. @@ -919,30 +920,39 @@ public override void AddTable(string name, params IDbField[] fields) public override void RemoveTable(string name) { + // Oracle drops table-owned triggers and native identity sequences itself. + // A legacy-looking sequence name is not evidence of ownership. base.RemoveTable(name); + } - try + /// Drop a table and explicitly identified, unquoted legacy sequence names. + /// The caller must own these sequences. Oracle DDL is not transactional. + public void RemoveTableWithOwnedSequences(string name, params string[] ownedSequenceNames) + { + ArgumentNullException.ThrowIfNull(ownedSequenceNames); + var sequences = ownedSequenceNames.Select(sequence => { - using var cmd = CreateCommand(); - ExecuteQuery(cmd, string.Format(@"DROP SEQUENCE {0}_SEQUENCE", name)); - } - catch (Exception) + GuardAgainstMaximumIdentifierLengthForOracle(sequence); + if (!System.Text.RegularExpressions.Regex.IsMatch(sequence, @"^[A-Za-z][A-Za-z0-9_$#]*$")) + throw new ArgumentException("Legacy sequence cleanup requires simple unquoted sequence names.", nameof(ownedSequenceNames)); + return sequence.ToUpperInvariant(); + }).Distinct(StringComparer.Ordinal).ToArray(); + foreach (var sequence in sequences) { - // swallow this because sequence may not have existed. + using var command = CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME = :sequenceName"; + var parameter = command.CreateParameter(); parameter.ParameterName = "sequenceName"; parameter.Value = sequence; + command.Parameters.Add(parameter); + if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Owned legacy sequence was not found: " + sequence); } + if (!TableExists(name)) throw new MigrationException("Table was not found: " + name); + base.RemoveTable(name); + foreach (var sequence in sequences) ExecuteNonQuery("DROP SEQUENCE " + _dialect.Quote(sequence)); } private void GuardAgainstMaximumColumnNameLengthForOracle(string name, Column[] columns) { - foreach (var column in columns) - { - if (column.Name.Length > 30) - { - throw new ArgumentException( - string.Format("When adding table: \"{0}\", the column: \"{1}\", the name of the column is: {2} characters in length, but maximum length for an oracle identifier is 30 characters", name, - column.Name, column.Name.Length), "columns"); - } - } + foreach (var column in columns) GuardAgainstMaximumIdentifierLengthForOracle(column.Name); } public override string Encode(Guid guid) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs b/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs index a4cbfa0a..4090e668 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs @@ -11,9 +11,7 @@ public class PostgreSQLSystemDataLoader(IPostgreSQLTransformationProvider postgr public List GetTableConstraints(string tableName, string schemaName = "public") { - var quotedTableName = _postgreSQLTransformationProvider.QuoteTableNameIfRequired(tableName); - - var sql = $@" + var sql = @" SELECT tc.TABLE_SCHEMA, tc.TABLE_NAME, @@ -26,13 +24,21 @@ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND tc.TABLE_NAME = kcu.TABLE_NAME WHERE - LOWER(tc.table_name) = '{quotedTableName.ToLowerInvariant()}' - AND tc.TABLE_SCHEMA = '{schemaName}' + tc.table_name = @table + AND tc.TABLE_SCHEMA = @schema "; List tableConstraints = []; using var cmd = _postgreSQLTransformationProvider.CreateCommand(); + var tableParameter = cmd.CreateParameter(); + tableParameter.ParameterName = "table"; + tableParameter.Value = tableName; + cmd.Parameters.Add(tableParameter); + var schemaParameter = cmd.CreateParameter(); + schemaParameter.ParameterName = "schema"; + schemaParameter.Value = schemaName; + cmd.Parameters.Add(schemaParameter); using var reader = _postgreSQLTransformationProvider.ExecuteQuery(cmd, sql); while (reader.Read()) @@ -60,7 +66,7 @@ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu public List GetColumnInfos(string tableName, string schemaName = "public") { - var sql = $@" + var sql = @" SELECT c.CHARACTER_MAXIMUM_LENGTH, c.COLUMN_DEFAULT, @@ -77,13 +83,22 @@ public List GetColumnInfos(string tableName, string schemaName = "pu c.TABLE_NAME FROM information_schema.columns c WHERE - LOWER(c.table_name) = '{tableName.ToLowerInvariant()}' AND - c.TABLE_SCHEMA = '{schemaName}' + c.table_name = @table AND + c.TABLE_SCHEMA = @schema + ORDER BY c.ORDINAL_POSITION "; List columns = []; using var cmd = _postgreSQLTransformationProvider.CreateCommand(); + var tableParameter = cmd.CreateParameter(); + tableParameter.ParameterName = "table"; + tableParameter.Value = tableName; + cmd.Parameters.Add(tableParameter); + var schemaParameter = cmd.CreateParameter(); + schemaParameter.ParameterName = "schema"; + schemaParameter.Value = schemaName; + cmd.Parameters.Add(schemaParameter); using var reader = _postgreSQLTransformationProvider.ExecuteQuery(cmd, sql); while (reader.Read()) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 1f55e5c3..dd1714e3 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -60,10 +60,8 @@ public PostgreSQLTransformationProvider(Dialect dialect, IDbConnection connectio protected override string GetPrimaryKeyConstraintName(string table) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT conname FROM pg_constraint WHERE contype = 'p' AND conrelid = (SELECT oid FROM pg_class WHERE relname = lower('{0}'));", table)); - + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT conname FROM pg_constraint WHERE contype = 'p' AND conrelid = to_regclass(@relation)"); return reader.Read() ? reader.GetString(0) : null; } @@ -102,7 +100,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "TRUE" : "FALSE", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException($"Given type in '{nameof(FilterItem)}' is not implemented. Please file an issue."), @@ -313,45 +311,59 @@ public override void RemoveTable(string name) ExecuteNonQuery(string.Format("DROP TABLE IF EXISTS {0} CASCADE", name)); } - public override bool ConstraintExists(string table, string name) + private IDbCommand MetadataCommand(string relation, string name = null) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = 'public' AND constraint_name = lower('{0}')", name)); + var command = CreateCommand(); + var parameter = command.CreateParameter(); + parameter.ParameterName = "relation"; + parameter.Value = QuoteTableNameIfRequired(relation); + command.Parameters.Add(parameter); + if (name != null) + { + parameter = command.CreateParameter(); + parameter.ParameterName = "name"; + parameter.Value = name; + command.Parameters.Add(parameter); + } + return command; + } + public override bool ConstraintExists(string table, string name) + { + using var command = MetadataCommand(table, name); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass(@relation) AND (conname = @name OR conname = lower(@name))"); return reader.Read(); } public override bool ColumnExists(string table, string column) { - if (!TableExists(table)) - { - return false; - } - - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = lower('{0}') AND (column_name = lower('{1}') OR column_name = '{1}')", table, column)); + using var command = MetadataCommand(table, column); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass(@relation) AND attnum > 0 AND NOT attisdropped AND (attname = @name OR attname = lower(@name))"); return reader.Read(); } public override bool TableExists(string table) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = lower('{0}')", table)); + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_class WHERE oid = to_regclass(@relation) AND relkind IN ('r', 'p', 'f')"); return reader.Read(); } public override bool ViewExists(string view) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT table_name FROM information_schema.views WHERE table_schema = 'public' AND table_name = lower('{0}')", view)); - + using var command = MetadataCommand(view); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_class WHERE oid = to_regclass(@relation) AND relkind IN ('v', 'm')"); return reader.Read(); } + private (string Table, string Schema) ResolveRelation(string table) + { + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT c.relname, n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = to_regclass(@relation)"); + if (!reader.Read()) throw new MigrationException("Table does not exist: " + table); + return (reader.GetString(0), reader.GetString(1)); + } + public override List GetDatabases() { return ExecuteStringQuery("SELECT datname FROM pg_database WHERE datistemplate = false"); @@ -473,9 +485,13 @@ public override int GetColumnContentSize(string table, string columnName) public override Column[] GetColumns(string table) { - var columnInfos = _postgreSQLSystemDataLoader.GetColumnInfos(table, "public"); + var relation = ResolveRelation(table); + var columnInfos = _postgreSQLSystemDataLoader.GetColumnInfos(relation.Table, relation.Schema); var columns = new List(); - var tableConstraints = _postgreSQLSystemDataLoader.GetTableConstraints(table); + var tableConstraints = _postgreSQLSystemDataLoader.GetTableConstraints(relation.Table, relation.Schema); + var uniqueColumns = tableConstraints.Where(c => c.ConstraintType == "UNIQUE") + .GroupBy(c => new { c.TableSchema, c.ConstraintName }).Where(g => g.Count() == 1) + .Select(g => g.Single().ColumnName).ToHashSet(StringComparer.Ordinal); foreach (var columnInfo in columnInfos) { @@ -559,7 +575,7 @@ public override Column[] GetColumns(string table) { dbType = MigratorDbType.Xml; } - else if (columnInfo.DataType == "time") + else if (columnInfo.DataType == "time" || columnInfo.DataType == "time without time zone") { dbType = MigratorDbType.Time; } @@ -594,6 +610,7 @@ public override Column[] GetColumns(string table) }; column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; if (isPrimaryKey) { @@ -643,6 +660,13 @@ public override Column[] GetColumns(string table) column.DefaultValue = double.Parse(columnInfo.ColumnDefault.ToString(), CultureInfo.InvariantCulture); } } + else if (column.MigratorDbType == MigratorDbType.Time) + { + var match = stripSingleQuoteRegEx.Match(columnInfo.ColumnDefault); + if (!match.Success || !TimeSpan.TryParse(match.Value, CultureInfo.InvariantCulture, out var time)) + throw new NotSupportedException("Cannot parse PostgreSQL time default: " + columnInfo.ColumnDefault); + column.DefaultValue = time; + } else if (column.MigratorDbType == MigratorDbType.Interval) { if (columnInfo.ColumnDefault.StartsWith("'")) diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 381aeaba..6c3b5b6a 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -94,6 +94,21 @@ public override void AddForeignKey( RecreateTable(sqliteTableInfo); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + var info = GetSQLiteTableInfo(childTable) ?? throw new MigrationException("Child table does not exist."); + if (string.IsNullOrWhiteSpace(name) || info.ForeignKeys.Select(f => f.Name).Concat(info.Uniques.Select(u => u.Name)) + .Any(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))) + throw new MigrationException("A unique foreign key name is required."); + info.ForeignKeys.Add(new ForeignKeyConstraint(name, parentTable, (string[])parentColumns.Clone(), childTable, (string[])childColumns.Clone()) + { + OnDelete = new ForeignKeyConstraintMapper().SqlForConstraint(onDelete), + OnUpdate = new ForeignKeyConstraintMapper().SqlForConstraint(onUpdate) + }); + RecreateTable(info); + } + public string[] GetColumnDefs(string table, out string compositeDefSql) { return ParseSqlColumnDefs(GetSqlCreateTableScript(table), out compositeDefSql); @@ -444,6 +459,26 @@ public void MoveIndexesFromOriginalTable(string origTable, string newTable) public override void RemoveColumn(string tableName, string column) { + if (Version.Parse(Convert.ToString(ExecuteScalar("SELECT sqlite_version()"))) >= new Version(3, 35, 0) + && TableExists(tableName)) + { + var info = GetSQLiteTableInfo(tableName); + var definition = info.Columns.SingleOrDefault(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); + bool Matches(string name) => string.Equals(name, column, StringComparison.OrdinalIgnoreCase); + var dependent = definition == null || definition.IsPrimaryKey || definition.ColumnProperty.HasFlag(ColumnProperty.Unique) + || info.CheckConstraints.Count != 0 + || info.Uniques.Any(u => u.KeyColumns.Any(Matches)) + || info.Indexes.Any(i => i.KeyColumns.Any(Matches) || i.FilterItems.Count != 0) + || info.ForeignKeys.Any(f => f.ChildColumns.Any(Matches)) + || GetTables().Any(t => GetForeignKeyConstraints(t).Any(f => f.ParentTable.Equals(tableName, StringComparison.OrdinalIgnoreCase) && f.ParentColumns.Any(Matches))); + if (!dependent) + { + // SQLite itself validates trigger/view dependencies atomically. A rejection is + // surfaced rather than retrying with a potentially lossy reconstruction. + ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} DROP COLUMN {Dialect.Quote(definition.Name)}"); + return; + } + } // In SQLite we need to recreate the table even if we only want to add, alter or drop a foreign key. So we not only recreate the table given // as parameter but also the tables with FKs pointing to the column you want to remove. // In order to perform it smoothly, the PRAGMA foreign keys should be set off. @@ -586,6 +621,13 @@ public override void RenameColumn(string tableName, string oldColumnName, string throw new Exception($"Table {tableName} does not exist"); } + if (Version.Parse(Convert.ToString(ExecuteScalar("SELECT sqlite_version()"))) >= new Version(3, 26, 0)) + { + if (string.IsNullOrWhiteSpace(newColumnName)) throw new ArgumentException("A column name is required."); + ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} RENAME COLUMN {Dialect.Quote(oldColumnName)} TO {Dialect.Quote(newColumnName)}"); + return; + } + var isPragmaForeignKeysOn = IsPragmaForeignKeysOn(); if (isPragmaForeignKeysOn) @@ -817,7 +859,70 @@ public void SetPragmaForeignKeys(bool isOn) ExecuteNonQuery($"PRAGMA foreign_keys = {onOffString}"); } + private static string ValidateForeignKeyAction(string action) + { + var normalized = action.ToUpperInvariant(); + if (normalized is not ("CASCADE" or "RESTRICT" or "SET NULL" or "SET DEFAULT" or "NO ACTION")) + throw new MigrationException("Unsupported foreign key action: " + action); + return normalized; + } + public void RecreateTable(SQLiteTableInfo sqliteTableInfo) + { + var oldName = sqliteTableInfo.TableNameMapping.OldName; + var script = GetSqlCreateTableScript(oldName); + if (Regex.IsMatch(script, @"\b(STRICT|GENERATED|DEFERRABLE|COLLATE)\b|WITHOUT\s+ROWID|CREATE\s+VIRTUAL|ON\s+CONFLICT", RegexOptions.IgnoreCase)) + throw new NotSupportedException("This table contains SQLite features that cannot be reconstructed faithfully. Use native SQL."); + var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND lower(tbl_name)=lower('{0}')", oldName.Replace("'", "''")); + if (triggers.Count > 0 && (oldName != sqliteTableInfo.TableNameMapping.NewName || sqliteTableInfo.ColumnMappings.Any(m => m.OldName != null && m.OldName != m.NewName))) + throw new NotSupportedException("Use native SQLite rename when triggers reference renamed objects."); + var originalColumns = GetColumns(oldName); + if (triggers.Count > 0 && originalColumns.Any(c => !sqliteTableInfo.Columns.Any(n => n.Name.Equals(c.Name, StringComparison.OrdinalIgnoreCase)))) + throw new NotSupportedException("Removing columns from a table with triggers requires native SQLite alteration or explicit trigger recreation."); + var sequence = TableExists("sqlite_sequence") + ? ExecuteScalar("SELECT seq FROM sqlite_sequence WHERE name='" + oldName.Replace("'", "''") + "'") : null; + var highWater = sequence == null || sequence == DBNull.Value ? (long?)null : Convert.ToInt64(sequence); + var foreignKeys = IsPragmaForeignKeysOn(); + if (HasActiveTransaction && foreignKeys) + throw new MigrationException("SQLite rebuild requires foreign keys to be disabled before beginning the transaction. Use the migration runner."); + var ownsTransaction = !HasActiveTransaction; + Exception failure = null; + try + { + if (ownsTransaction) + { + if (foreignKeys) SetPragmaForeignKeys(false); + BeginTransaction(); + } + RecreateTableCore(sqliteTableInfo); + if (highWater.HasValue && sqliteTableInfo.Columns.Any(c => c.IsIdentity)) + { + var sequenceName = sqliteTableInfo.TableNameMapping.NewName.Replace("'", "''"); + var sequenceValue = highWater.Value.ToString(CultureInfo.InvariantCulture); + ExecuteNonQuery($"UPDATE sqlite_sequence SET seq=MAX(seq, {sequenceValue}) WHERE name='{sequenceName}'"); + ExecuteNonQuery($"INSERT INTO sqlite_sequence(name, seq) SELECT '{sequenceName}', {sequenceValue} WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name='{sequenceName}')"); + } + foreach (var trigger in triggers) ExecuteNonQuery(trigger); + if (ownsTransaction && !CheckForeignKeyIntegrity()) throw new MigrationException("SQLite rebuild would leave invalid foreign keys."); + if (ownsTransaction) Commit(); + } + catch (Exception ex) + { + failure = ex; + if (ownsTransaction) + { + try { Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } + } + throw; + } + finally + { + try { if (ownsTransaction && foreignKeys) SetPragmaForeignKeys(true); } + catch (Exception restore) { if (failure == null) throw; failure.Data["ConnectionRestoreException"] = restore; } + } + } + + private void RecreateTableCore(SQLiteTableInfo sqliteTableInfo) { var sourceTableQuoted = QuoteTableNameIfRequired(sqliteTableInfo.TableNameMapping.OldName); var targetIntermediateTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}{IntermediateTableSuffix}"); @@ -874,7 +979,7 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) using (var cmd = CreateCommand()) { var sql = $"INSERT INTO {targetIntermediateTableQuoted} ({targetColumnsQuotedString}) SELECT {sourceColumnsQuotedString} FROM {sourceTableQuoted}"; - ExecuteQuery(cmd, sql); + ExecuteNonQuery(sql); } RemoveTable(sourceTableQuoted); @@ -883,7 +988,7 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) { // Rename to original name var sql = $"ALTER TABLE {targetIntermediateTableQuoted} RENAME TO {targetTableQuoted}"; - ExecuteQuery(cmd, sql); + ExecuteNonQuery(sql); } foreach (var index in sqliteTableInfo.Indexes) @@ -1381,6 +1486,7 @@ public override void AddTable(string name, string engine, params IDbField[] fiel { var columns = fields.Where(x => x is Column) .Cast() + .Select(column => column.CopyDefinition()) .ToArray(); var pks = GetPrimaryKeys(columns); @@ -1430,14 +1536,14 @@ public override void AddTable(string name, string engine, params IDbField[] fiel { if (!string.IsNullOrEmpty(u.Name)) { - stringBuilder.Append($", CONSTRAINT {u.Name}"); + stringBuilder.Append($", CONSTRAINT {QuoteConstraintNameIfRequired(u.Name)}"); } else { stringBuilder.Append(", "); } - var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns); + var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns.Select(QuoteColumnNameIfRequired)); stringBuilder.Append($" UNIQUE ({uniqueColumnsCommaSeparated})"); } @@ -1457,12 +1563,13 @@ public override void AddTable(string name, string engine, params IDbField[] fiel throw new Exception("No foreign key constraint name given"); } - var foreignKeySql = $"CONSTRAINT {fk.Name} FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}({parentColumnNamesQuotedString})"; + var foreignKeySql = $"CONSTRAINT {QuoteConstraintNameIfRequired(fk.Name)} FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}({parentColumnNamesQuotedString})"; if (!string.IsNullOrWhiteSpace(fk.OnDelete) && !string.Equals(fk.OnDelete, "NO ACTION", StringComparison.OrdinalIgnoreCase)) { - foreignKeySql += $" ON DELETE {fk.OnDelete}"; + foreignKeySql += $" ON DELETE {ValidateForeignKeyAction(fk.OnDelete)}"; } + if (!string.IsNullOrWhiteSpace(fk.OnUpdate)) foreignKeySql += $" ON UPDATE {ValidateForeignKeyAction(fk.OnUpdate)}"; foreignKeyStrings.Add(foreignKeySql); } @@ -1478,7 +1585,7 @@ public override void AddTable(string name, string engine, params IDbField[] fiel foreach (var checkConstraint in checkConstraints) { - checkConstraintStrings.Add($"CONSTRAINT {checkConstraint.Name} CHECK ({checkConstraint.CheckConstraintString})"); + checkConstraintStrings.Add($"CONSTRAINT {QuoteConstraintNameIfRequired(checkConstraint.Name)} CHECK ({checkConstraint.CheckConstraintString})"); } if (checkConstraintStrings.Count > 0) @@ -1539,7 +1646,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "1" : "0", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException("Given type is not implemented. Please file an issue."), @@ -1587,8 +1694,8 @@ public override void RemoveAllConstraints(string table) column.ColumnProperty &= ~ColumnProperty.Unique; } - // TODO CHECK is not implemented yet - // https://github.com/dotnetprojects/Migrator.NET/issues/64 + sqliteTableInfo.ForeignKeys.Clear(); + sqliteTableInfo.CheckConstraints.Clear(); RecreateTable(sqliteTableInfo); } @@ -1936,3 +2043,4 @@ protected override void ConfigureParameterWithValue(IDbDataParameter parameter, } } } + diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs index c5bf9285..d104a167 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs @@ -7,6 +7,8 @@ public class SqlServer2005Dialect : SqlServerDialect { public SqlServer2005Dialect() { + // Keep the historical representation for the explicitly selected pre-TIME dialect. + RegisterColumnType(DbType.Time, "DATETIME"); RegisterColumnType(DbType.AnsiString, 2147483647, "VARCHAR(MAX)"); RegisterColumnType(DbType.Binary, 2147483647, "VARBINARY(MAX)"); RegisterColumnType(DbType.String, 1073741823, "NVARCHAR(MAX)"); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs index 2f2885a9..3968c1e4 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs @@ -48,7 +48,7 @@ public SqlServerDialect() RegisterColumnType(DbType.String, 4000, "NVARCHAR($l)"); RegisterColumnType(DbType.String, int.MaxValue, "NVARCHAR(max)"); //RegisterColumnType(DbType.String, 1073741823, "NTEXT"); - RegisterColumnType(DbType.Time, "DATETIME"); + RegisterColumnType(DbType.Time, "TIME"); RegisterColumnType(DbType.VarNumeric, "NUMERIC(18,0)"); RegisterColumnType(DbType.VarNumeric, 38, "NUMERIC($l,0)"); RegisterColumnType(MigratorDbType.Interval, "BIGINT"); @@ -112,6 +112,11 @@ public override string Quote(string value) public override string Default(object defaultValue) { + if (defaultValue is TimeSpan time) + { + if (time < TimeSpan.Zero || time >= TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(defaultValue), "SQL Server TIME must be within one day."); + return "DEFAULT '" + time.ToString("c", System.Globalization.CultureInfo.InvariantCulture) + "'"; + } if (defaultValue.GetType().Equals(typeof(bool))) { return string.Format("DEFAULT {0}", (bool)defaultValue ? "1" : "0"); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 871c2cb8..805d5fbd 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -27,8 +27,10 @@ namespace DotNetProjects.Migrator.Providers.Impl.SqlServer; /// /// Migration transformations provider for Microsoft SQL Server. /// -public class SqlServerTransformationProvider : TransformationProvider +public class SqlServerTransformationProvider : TransformationProvider, IScriptBatchProvider { + public virtual System.Collections.Generic.IReadOnlyList SplitScript(string sql) => SqlScriptBatches.SplitSqlServer(sql); + public SqlServerTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) : base(dialect, connectionString, defaultSchema, scope) { @@ -195,7 +197,7 @@ public override void AddPrimaryKeyNonClustered(string name, string table, params { var nonclusteredString = "NONCLUSTERED"; ExecuteNonQuery( - string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY {2} ({3}) ", table, name, nonclusteredString, + string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY {2} ({3}) ", QuoteTableNameIfRequired(table), QuoteConstraintNameIfRequired(name), nonclusteredString, string.Join(",", QuoteColumnNamesIfRequired(columns)))); } @@ -275,32 +277,84 @@ public override string AddIndex(string table, Index index) return sql; } - public override void ChangeColumn(string table, Column column) + public override void AddTable(string name, string engine, params IDbField[] fields) { - if (column.DefaultValue == null || column.DefaultValue == DBNull.Value) - { - base.ChangeColumn(table, column); - } - else - { - var def = column.DefaultValue; - var notNull = column.ColumnProperty.IsSet(ColumnProperty.NotNull); - column.DefaultValue = null; - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Null); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.NotNull); + var definitions = fields.Select(field => field is Column column ? column.CopyDefinition() : field).ToArray(); + var owned = definitions.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Unique)).ToArray(); + foreach (var column in owned) column.ColumnProperty &= ~ColumnProperty.Unique; + base.AddTable(name, engine, definitions); + foreach (var column in owned) AddOwnedColumnUnique(name, column.Name); + } - base.ChangeColumn(table, column); + public override void AddColumn(string table, Column column) + { + var definition = column.CopyDefinition(); + var owned = definition.ColumnProperty.HasFlag(ColumnProperty.Unique); + definition.ColumnProperty &= ~ColumnProperty.Unique; + base.AddColumn(table, definition); + if (owned) AddOwnedColumnUnique(table, column.Name); + } - var mapper = _dialect.GetAndMapColumnPropertiesWithoutDefault(column); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} {2} FOR {3}", this.QuoteTableNameIfRequired(table), "DF_" + table + "_" + column.Name, _dialect.Default(def), this.QuoteColumnNameIfRequired(column.Name))); + public override void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, object defaultValue) + { + base.AddColumn(table, column, type, size, property & ~ColumnProperty.Unique, defaultValue); + if (property.HasFlag(ColumnProperty.Unique)) AddOwnedColumnUnique(table, column); + } - if (notNull) - { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.NotNull); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Null); - base.ChangeColumn(table, column); - } + private void AddOwnedColumnUnique(string table, string column) + { + var name = "UX_" + Guid.NewGuid().ToString("N"); + AddUniqueConstraint(name, table, column); + MarkColumnUniqueOwned(table, column, name); + } + + /// Explicitly adopt a caller-owned, single-column legacy UNIQUE constraint. + /// No ownership is inferred from its name. Future ChangeColumn calls may remove it. + public void AdoptColumnUniqueConstraint(string table, string column, string constraint) + { + using var command = CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sys.key_constraints kc JOIN sys.index_columns ic ON ic.object_id=kc.parent_object_id AND ic.index_id=kc.unique_index_id JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND kc.name=@constraint AND ic.key_ordinal=1 AND c.name=@column AND NOT EXISTS (SELECT 1 FROM sys.index_columns more WHERE more.object_id=ic.object_id AND more.index_id=ic.index_id AND more.key_ordinal>1)"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); + if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Ownership requires an existing single-column UNIQUE constraint on the specified table and column."); + MarkColumnUniqueOwned(table, column, constraint); + } + + private void MarkColumnUniqueOwned(string table, string column, string constraint) + { + using var command = CreateCommand(); + command.CommandText = "DECLARE @schema sysname=OBJECT_SCHEMA_NAME(OBJECT_ID(@table)); DECLARE @name sysname=OBJECT_NAME(OBJECT_ID(@table)); IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.key_constraints kc ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.name=@constraint AND ep.name=N'Migrator.NET.ColumnUnique') EXEC sys.sp_updateextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint; ELSE EXEC sys.sp_addextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); + command.ExecuteNonQuery(); + } + + public override void ChangeColumn(string table, Column column) + { + var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) + { Precision = column.Precision, Scale = column.Scale }; + var unique = definition.ColumnProperty.IsSet(ColumnProperty.Unique); + definition.ColumnProperty = definition.ColumnProperty.Clear(ColumnProperty.Unique); + var owned = new List(); + using (var command = CreateCommand()) + { + command.CommandText = "SELECT kc.name FROM sys.key_constraints kc JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND ep.name=N'Migrator.NET.ColumnUnique' AND CONVERT(nvarchar(128),ep.value)=@column"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column.Name); + using var reader = command.ExecuteReader(); + while (reader.Read()) owned.Add(reader.GetString(0)); } + foreach (var constraint in owned) RemoveConstraint(table, constraint); + RemoveColumnDefaultValue(table, definition.Name); + var requestedDefault = definition.DefaultValue; + definition.DefaultValue = null; + base.ChangeColumn(table, definition); + if (requestedDefault != null && requestedDefault != DBNull.Value) + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD DEFAULT {_dialect.Default(requestedDefault)[8..]} FOR {QuoteColumnNameIfRequired(column.Name)}"); + if (unique) AddOwnedColumnUnique(table, column.Name); + } + + private static void AddParameter(IDbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.Value = value; + command.Parameters.Add(parameter); } public override bool ColumnExists(string table, string column) @@ -333,7 +387,7 @@ public override void RemoveColumnDefaultValue(string table, string column) { var sql = string.Format("SELECT name FROM sys.default_constraints WHERE parent_object_id = OBJECT_ID('{0}') AND parent_column_id = (SELECT column_id FROM sys.columns WHERE name = '{1}' AND object_id = OBJECT_ID('{0}'))", table, column); var constraintName = ExecuteScalar(sql); - if (constraintName != null) + if (constraintName != null && constraintName != DBNull.Value && !string.IsNullOrWhiteSpace(constraintName.ToString())) { RemoveConstraint(table, constraintName.ToString()); } @@ -546,28 +600,20 @@ public override Column[] GetColumns(string table) schema = _defaultSchema; } - var pkColumns = new List(); - try - { - pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu WHERE EXISTS ( SELECT tc.* FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc WHERE tc.TABLE_NAME = '{0}' AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.CONSTRAINT_NAME = cu.CONSTRAINT_NAME )", table); - } - catch (Exception) - { } - - var idtColumns = new List(); - try - { - idtColumns = ExecuteStringQuery("SELECT COLUMN_NAME from INFORMATION_SCHEMA.COLUMNS where TABLE_SCHEMA = '{1}' and TABLE_NAME = '{0}' and COLUMNPROPERTY(object_id(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1", table, schema); - } - catch (Exception) - { } + schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema.Trim('[', ']').Replace("''", "'"); + table = table.Trim('[', ']'); + var tableLiteral = table.Replace("'", "''"); + var schemaLiteral = schema.Replace("'", "''"); + var pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='PRIMARY KEY'", tableLiteral, schemaLiteral); + var uniqueColumns = ExecuteStringQuery("SELECT MIN(cu.COLUMN_NAME) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='UNIQUE' GROUP BY tc.CONSTRAINT_SCHEMA, tc.CONSTRAINT_NAME HAVING COUNT(*)=1", tableLiteral, schemaLiteral); + var idtColumns = ExecuteStringQuery("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='{1}' AND TABLE_NAME='{0}' AND COLUMNPROPERTY(OBJECT_ID(QUOTENAME(TABLE_SCHEMA)+'.'+QUOTENAME(TABLE_NAME)),COLUMN_NAME,'IsIdentity')=1", tableLiteral, schemaLiteral); var columns = new List(); using (var cmd = CreateCommand()) using ( var reader = ExecuteQuery(cmd, - string.Format("SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE, ISNULL(CHARACTER_MAXIMUM_LENGTH , NUMERIC_PRECISION), COLUMN_DEFAULT, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where table_name = '{0}'", table))) + string.Format("SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE, ISNULL(CHARACTER_MAXIMUM_LENGTH , NUMERIC_PRECISION), COLUMN_DEFAULT, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where table_name = '{0}' AND TABLE_SCHEMA = '{1}'", tableLiteral, schemaLiteral))) { while (reader.Read()) { @@ -580,6 +626,7 @@ public override Column[] GetColumns(string table) var defaultValueString = reader.IsDBNull(defaultValueOrdinal) ? null : reader.GetString(defaultValueOrdinal).Trim(); var characterMaximumLength = reader.IsDBNull(characterMaximumLengthOrdinal) ? (int?)null : reader.GetInt32(characterMaximumLengthOrdinal); + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; if (pkColumns.Contains(column.Name)) { column.ColumnProperty |= ColumnProperty.PrimaryKey; @@ -637,6 +684,10 @@ public override Column[] GetColumns(string table) { column.MigratorDbType = MigratorDbType.Decimal; } + else if (dataTypeString == "time") + { + column.MigratorDbType = MigratorDbType.Time; + } else if (dataTypeString == "datetime") { column.MigratorDbType = MigratorDbType.DateTime; @@ -688,6 +739,10 @@ public override Column[] GetColumns(string table) { column.DefaultValue = double.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); } + else if (column.Type == DbType.Time) + { + column.DefaultValue = TimeSpan.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); + } else if (column.Type == DbType.Boolean) { var truthy = new string[] { "'TRUE'", "1" }; diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 71f82029..7474054d 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -166,10 +166,18 @@ public override void ChangeColumn(string table, Column column) AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("Sybase does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { if (constraint is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) throw new NotSupportedException("ASE declarative foreign keys do not support cascading referential actions."); - ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT {name} FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)})"); + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"); } } diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 0d0fc72b..c6ee88f1 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -34,7 +34,7 @@ namespace DotNetProjects.Migrator.Providers; /// Base class for every transformation providers. /// A 'tranformation' is an operation that modifies the database. /// -public abstract class TransformationProvider : ITransformationProvider, IMigrationHistory +public abstract class TransformationProvider : ITransformationProvider, IMigrationHistory, IForeignKeyActions { private string _scope; protected readonly string _connectionString; @@ -534,6 +534,7 @@ public virtual bool ColumnExists(string table, string column, bool ignoreCase) public virtual void ChangeColumn(string table, Column column) { + column = column.CopyDefinition(); var isUniqueSet = column.ColumnProperty.IsSet(ColumnProperty.Unique); column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Unique); @@ -728,7 +729,7 @@ public virtual void AddPrimaryKey(string name, string table, params string[] col table = QuoteTableNameIfRequired(table); ExecuteNonQuery( - string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY ({2}) ", table, name, + string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY ({2}) ", table, QuoteConstraintNameIfRequired(name), string.Join(",", QuoteColumnNamesIfRequired(columns)))); } public virtual void AddPrimaryKeyNonClustered(string name, string table, params string[] columns) @@ -739,7 +740,7 @@ public virtual void AddUniqueConstraint(string name, string table, params string { table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} UNIQUE({2}) ", table, name, + ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} UNIQUE({2}) ", table, QuoteConstraintNameIfRequired(name), string.Join(", ", QuoteColumnNamesIfRequired(columns)))); } @@ -747,7 +748,7 @@ public virtual void AddCheckConstraint(string name, string table, string checkSq { table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} CHECK ({2}) ", table, name, checkSql)); + ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} CHECK ({2}) ", table, QuoteConstraintNameIfRequired(name), checkSql)); } /// @@ -800,7 +801,17 @@ public virtual void GenerateForeignKey( public virtual void AddForeignKey(string table, ForeignKeyConstraint fk) { - AddForeignKey(fk.Name, table, fk.ParentColumns, fk.ChildTable, fk.ChildColumns); + if (string.IsNullOrWhiteSpace(fk.OnDelete) && string.IsNullOrWhiteSpace(fk.OnUpdate)) + AddForeignKey(fk.Name, table, (string[])fk.ChildColumns.Clone(), fk.ParentTable, (string[])fk.ParentColumns.Clone()); + else + AddForeignKey(fk.Name, table, (string[])fk.ChildColumns.Clone(), fk.ParentTable, (string[])fk.ParentColumns.Clone(), ParseAction(fk.OnDelete), ParseAction(fk.OnUpdate)); + + static ForeignKeyConstraintType ParseAction(string action) + { + if (string.IsNullOrWhiteSpace(action)) return ForeignKeyConstraintType.NoAction; + return Enum.TryParse(action.Replace(" ", ""), true, out var parsed) && Enum.IsDefined(parsed) + ? parsed : throw new ArgumentException("Unsupported foreign-key action.", nameof(fk)); + } } public virtual void AddForeignKey(string name, string childTable, string childColumn, string parentTable, string parentColumn) @@ -835,18 +846,19 @@ public virtual void AddForeignKey( { childTable = QuoteTableNameIfRequired(childTable); parentTable = QuoteTableNameIfRequired(parentTable); - QuoteColumnNames(parentColumns); - QuoteColumnNames(childColumns); + parentColumns = QuoteColumnNamesIfRequired(parentColumns); + childColumns = QuoteColumnNamesIfRequired(childColumns); + name = QuoteConstraintNameIfRequired(name); var constraintResolved = constraintMapper.SqlForConstraint(constraint); - // TODO Issue #52 still unresolved + // Legacy overload preserves one action for both clauses; IForeignKeyActions provides independent actions. var childColumnsString = string.Join(", ", childColumns); var parentColumnsString = string.Join(", ", parentColumns); var stringBuilder = new StringBuilder(); stringBuilder.Append($"ALTER TABLE {childTable} ADD CONSTRAINT {name} FOREIGN KEY ({childColumnsString}) REFERENCES {parentTable} ({parentColumnsString})"); - stringBuilder.Append($"ON UPDATE {constraintResolved} ON DELETE {constraintResolved}"); + stringBuilder.Append($"ON DELETE {constraintResolved} ON UPDATE {constraintResolved}"); ExecuteNonQuery(stringBuilder.ToString()); } @@ -944,23 +956,18 @@ public List ExecuteStringQuery(string sql, params object[] args) public virtual void ExecuteScript(string fileName) { - if (CurrentMigration != null) - { -#if NETSTANDARD - var assembly = CurrentMigration.GetType().GetTypeInfo().Assembly; -#else - var assembly = CurrentMigration.GetType().Assembly; -#endif - - string sqlText; - var file = (new System.Uri(assembly.CodeBase)).AbsolutePath; - using (var reader = File.OpenText(file)) - { - sqlText = reader.ReadToEnd(); - } + if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("A script path is required.", nameof(fileName)); + var root = CurrentMigration == null ? AppContext.BaseDirectory : Path.GetDirectoryName(CurrentMigration.GetType().Assembly.Location); + var path = Path.IsPathRooted(fileName) ? fileName : Path.Combine(root ?? AppContext.BaseDirectory, fileName); + this.ExecuteSqlScript(File.ReadAllText(path)); + } - ExecuteNonQuery(sqlText); - } + public virtual void ExecuteResourceScript(System.Reflection.Assembly assembly, string resourceName) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException("Embedded SQL resource not found.", resourceName); + using var reader = new StreamReader(stream); + this.ExecuteSqlScript(reader.ReadToEnd()); } /// @@ -1115,7 +1122,7 @@ public virtual object SelectScalar(string what, string from, string where) public virtual object SelectScalar(string what, string from, string[] whereColumns, object[] whereValues) { - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1189,7 +1196,7 @@ public virtual int Update(string table, string[] columns, object[] values, strin builder.Append(GenerateParameterName(i)); } - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1267,7 +1274,7 @@ public virtual int Update(string table, string[] columns, object[] values, strin builder.Append(GenerateParameterName(i)); } - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1363,7 +1370,7 @@ public virtual int Insert(string table, string[] columns, object[] values) var parameterNames = builder.ToString(); - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1504,7 +1511,7 @@ public virtual int Delete(string table, string[] whereColumns = null, object[] w { table = QuoteTableNameIfRequired(table); - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1553,6 +1560,22 @@ public virtual int TruncateTable(string table) return ExecuteNonQuery(string.Format("TRUNCATE TABLE {0} ", table)); } + public virtual void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + var deleteAction = constraintMapper.SqlForConstraint(onDelete); + var updateAction = constraintMapper.SqlForConstraint(onUpdate); + var oracle = _dialect is DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect; + if (oracle && onUpdate != ForeignKeyConstraintType.NoAction) + throw new NotSupportedException("Oracle does not support ON UPDATE foreign key actions."); + if (oracle && onDelete is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict or ForeignKeyConstraintType.Cascade or ForeignKeyConstraintType.SetNull)) + throw new NotSupportedException("Oracle supports default restrictive, CASCADE or SET NULL deletion actions."); + var sql = $"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"; + if (!oracle || onDelete is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) sql += $" ON DELETE {deleteAction}"; + if (!oracle) sql += $" ON UPDATE {updateAction}"; + ExecuteNonQuery(sql); + } + /// /// Starts a transaction. Called by the migration mediator. /// @@ -1658,7 +1681,14 @@ public virtual void MigrationUnApplied(long version, string scope) public virtual void AddColumn(string table, Column column) { - AddColumn(table, column.Name, column.Type, column.Size, column.ColumnProperty, column.DefaultValue); + if (!column.Precision.HasValue && !column.Scale.HasValue) + { + AddColumn(table, column.Name, column.Type, column.Size, column.ColumnProperty, column.DefaultValue); + return; + } + var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) + { Precision = column.Precision, Scale = column.Scale }; + AddColumn(table, _dialect.GetAndMapColumnProperties(definition).ColumnSql); } public virtual void GenerateForeignKey(string primaryTable, string refTable) @@ -2020,6 +2050,11 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.DateTime; parameter.Value = value; } + else if (value is TimeSpan timeSpan) + { + parameter.DbType = DbType.Time; + parameter.Value = timeSpan; + } else if (value is DateTimeOffset dateTimeOffset) { parameter.DbType = DbType.DateTimeOffset; @@ -2087,7 +2122,11 @@ public virtual string AddIndex(string name, string table, params string[] column protected string QuoteConstraintNameIfRequired(string name) { - return _dialect.ConstraintNameNeedsQuote ? _dialect.Quote(name) : name; + if (!_dialect.ConstraintNameNeedsQuote && !_dialect.IsReservedWord(name) + && System.Text.RegularExpressions.Regex.IsMatch(name, @"^[A-Za-z_][A-Za-z0-9_$#]*$")) return name; + var template = _dialect.QuoteTemplate; + var closing = template[^1].ToString(); + return string.Format(template, name.Replace(closing, closing + closing)); } public abstract bool IndexExists(string table, string name);