diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index c6616cc2..2ed819de 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -5,11 +5,9 @@ on: branches: [master] pull_request: branches: [master] - jobs: build: runs-on: ubuntu-latest - services: sqlserver: image: mcr.microsoft.com/mssql/server:2019-latest @@ -23,7 +21,6 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=10 - postgres: image: postgres:13 ports: @@ -31,13 +28,13 @@ jobs: env: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass - POSTGRES_DB: testdb + # As of v16 we can use: + # POSTGRES_INITDB_ARGS: "-c max_connections=300" options: >- --health-cmd="pg_isready -U testuser" --health-interval=10s --health-timeout=5s --health-retries=5 - oracle: image: gvenzl/oracle-free:latest ports: @@ -49,7 +46,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 10 - mysql: image: mysql:8.0 ports: @@ -64,7 +60,6 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=10 - steps: - uses: actions/checkout@v4 - uses: gvenzl/setup-oracle-sqlcl@v1 diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs index 90c77c72..f8265016 100644 --- a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs @@ -54,9 +54,8 @@ public override async Task CreateTestDatabaseAsync(DatabaseConnect await context.ExecuteAsync($"CREATE DATABASE \"{newDatabaseName}\"", cancellationToken); } - var connectionStringBuilder2 = new NpgsqlConnectionStringBuilder + var connectionStringBuilder2 = new NpgsqlConnectionStringBuilder(clonedDatabaseConnectionConfig.ConnectionString) { - ConnectionString = clonedDatabaseConnectionConfig.ConnectionString, Database = newDatabaseName }; diff --git a/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs b/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs new file mode 100644 index 00000000..11951b71 --- /dev/null +++ b/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs @@ -0,0 +1,45 @@ +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using NUnit.Framework; + +namespace Migrator.Tests.Dialects; + +[TestFixture] +[Category("Postgre")] +public class PostgreDialectTests +{ + private PostgreSQLDialect _postgreSQLDialect; + + [SetUp] + public void SetUp() + { + // Since Dialect is abstract we use PostgreSQLDialect + _postgreSQLDialect = new PostgreSQLDialect(); + } + + [TestCase(FilterType.EqualTo, "=")] + [TestCase(FilterType.GreaterThanOrEqualTo, ">=")] + [TestCase(FilterType.SmallerThanOrEqualTo, "<=")] + [TestCase(FilterType.SmallerThan, "<")] + [TestCase(FilterType.GreaterThan, ">")] + [TestCase(FilterType.NotEqualTo, "<>")] + public void GetComparisonStringByFilterType_Success(FilterType filterType, string expectedString) + { + var result = _postgreSQLDialect.GetComparisonStringByFilterType(filterType); + + Assert.That(result, Is.EqualTo(expectedString)); + } + + [TestCase("=", FilterType.EqualTo)] + [TestCase(">=", FilterType.GreaterThanOrEqualTo)] + [TestCase("<=", FilterType.SmallerThanOrEqualTo)] + [TestCase("<", FilterType.SmallerThan)] + [TestCase(">", FilterType.GreaterThan)] + [TestCase("<>", FilterType.NotEqualTo)] + public void GetFilterTypeByComparisonString_Success(string comparisonString, FilterType expectedFilterType) + { + var result = _postgreSQLDialect.GetFilterTypeByComparisonString(comparisonString); + + Assert.That(result, Is.EqualTo(expectedFilterType)); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs b/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs index f16ff560..eae08dea 100644 --- a/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs +++ b/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs @@ -1,4 +1,5 @@ using System; +using System.Data; using System.Threading; using System.Threading.Tasks; using DotNetProjects.Migrator.Framework; @@ -13,6 +14,7 @@ using Migrator.Tests.Settings; using Migrator.Tests.Settings.Config; using Migrator.Tests.Settings.Models; +using Npgsql; using NUnit.Framework; namespace Migrator.Tests.Providers.Base; @@ -22,12 +24,24 @@ namespace Migrator.Tests.Providers.Base; /// public abstract class TransformationProviderBase { + private IDbConnection _dbConnection; + [TearDown] public virtual void TearDown() { DropTestTables(); Provider?.Rollback(); + + if (_dbConnection != null) + { + if (_dbConnection.State == ConnectionState.Open) + { + _dbConnection.Close(); + } + + _dbConnection.Dispose(); + } } protected void DropTestTables() @@ -108,7 +122,10 @@ protected async Task BeginPostgreSQLTransactionAsync() var postgreIntegrationTestService = databaseIntegrationTestServiceFactory.Create(DatabaseProviderType.Postgres); var databaseInfo = await postgreIntegrationTestService.CreateTestDatabaseAsync(databaseConnectionConfig, cts.Token); - Provider = new PostgreSQLTransformationProvider(new PostgreSQLDialect(), databaseInfo.DatabaseConnectionConfig.ConnectionString, null, "default", "Npgsql"); + + _dbConnection = new NpgsqlConnection(databaseInfo.DatabaseConnectionConfig.ConnectionString); + + Provider = new PostgreSQLTransformationProvider(new PostgreSQLDialect(), _dbConnection, null, "default", "Npgsql"); Provider.BeginTransaction(); await Task.CompletedTask; diff --git a/src/Migrator.Tests/Providers/Generic/Generic_AddIndexTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_AddIndexTestsBase.cs new file mode 100644 index 00000000..6f7c3de8 --- /dev/null +++ b/src/Migrator.Tests/Providers/Generic/Generic_AddIndexTestsBase.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.Generic; + +public abstract class Generic_AddIndexTestsBase : TransformationProviderBase +{ + [Test] + public void AddIndex_TableDoesNotExist() + { + // Act + Assert.Throws(() => Provider.AddIndex("NotExistingTable", new Index())); + Assert.Throws(() => Provider.AddIndex("NotExistingIndex", "NotExistingTable", "column")); + } + + [Test] + public void AddIndex_AddAlreadyExistingIndex_Throws() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32)); + Provider.AddIndex(tableName, new Index { Name = indexName, KeyColumns = [columnName] }); + + // Act/Assert + // Add already existing index + Assert.Throws(() => Provider.AddIndex(tableName, new Index { Name = indexName, KeyColumns = [columnName] })); + } + + [Test] + public void AddIndex_IncludeColumnsContainsColumnThatExistInKeyColumns_Throws() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName1, DbType.Int32)); + + Assert.Throws(() => Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1], + IncludeColumns = [columnName1] + })); + } + + [Test] + public void AddIndex_ColumnNameUsedInFilterItemDoesNotExistInKeyColumns_Throws() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int32), + new Column(columnName2, DbType.Int32) + ); + + Assert.Throws(() => Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1], + FilterItems = [new FilterItem { Filter = FilterType.GreaterThan, ColumnName = columnName2, Value = 12 }] + })); + } + + [Test] + public void AddIndex_UsingIndexInstanceOverload_NonUnique_ShouldBeReadable() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32)); + + // Act + Provider.AddIndex(tableName, new Index { Name = indexName, KeyColumns = [columnName] }); + + // Assert + var indexes = Provider.GetIndexes(tableName); + + var index = indexes.Single(); + + Assert.That(index.Name, Is.EqualTo(indexName).IgnoreCase); + Assert.That(index.KeyColumns.Single(), Is.EqualTo(columnName).IgnoreCase); + } + + [Test] + public void AddIndex_UsingNonIndexInstanceOverload_NonUnique_ShouldBeReadable() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32)); + + // Act + Provider.AddIndex(indexName, tableName, columnName); + + // Assert + var indexes = Provider.GetIndexes(tableName); + + var index = indexes.Single(); + + Assert.That(index.Name, Is.EqualTo(indexName).IgnoreCase); + Assert.That(index.KeyColumns.Single(), Is.EqualTo(columnName).IgnoreCase); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Generic/Generic_GetIndexesTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_GetIndexesTestsBase.cs new file mode 100644 index 00000000..ffceaf9d --- /dev/null +++ b/src/Migrator.Tests/Providers/Generic/Generic_GetIndexesTestsBase.cs @@ -0,0 +1,55 @@ +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.Generic; + +public abstract class Generic_GetIndexesTestsBase : TransformationProviderBase +{ + [Test] + public void AddIndex_FilteredIndexGreaterOrEqualThanNumber_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName, DbType.Int32), + new Column(columnName2, DbType.String), + new Column(columnName3, DbType.Int32) + ); + + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName, columnName2], + Unique = true, + FilterItems = [ + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName, Value = 100 }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName2, Value = "Hello" }, + ] + }); + + // Act + var indexes = Provider.GetIndexes(table: tableName); + + var index = indexes.Single(); + + var filterItem1 = index.FilterItems.Single(x => x.ColumnName == columnName); + var filterItem2 = index.FilterItems.Single(x => x.ColumnName == columnName2); + + Assert.That(filterItem1.Filter, Is.EqualTo(FilterType.GreaterThanOrEqualTo)); + Assert.That((long)filterItem1.Value, Is.EqualTo(100)); + + Assert.That(filterItem2.Filter, Is.EqualTo(FilterType.EqualTo)); + Assert.That((string)filterItem2.Value, Is.EqualTo("Hello")); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs index 84ca3c1d..d2d2a180 100644 --- a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs +++ b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs @@ -44,14 +44,7 @@ public void CanAddPrimaryKey() { AddPrimaryKey(); - if (Provider is SQLiteTransformationProvider) - { - Assert.Throws(() => Provider.PrimaryKeyExists("Test", "PK_Test")); - } - else - { - Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True); - } + Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True); } // [Test] diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs new file mode 100644 index 00000000..12ae1876 --- /dev/null +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; +using Oracle.ManagedDataAccess.Client; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.OracleProvider; + +[TestFixture] +[Category("Oracle")] +public class OracleTransformationProvider_AddIndex_Tests : Generic_AddIndexTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginOracleTransactionAsync(); + } + + [Test] + public void AddIndex_Unique_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + }); + + // Assert + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName, columnName2], [1, "Some other string"])); + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(ex.Number, Is.EqualTo(1)); + } + + /// + /// This test is located in the dedicated database type folder not in the base class since + /// cannot read filter items for Oracle and Oracle does not allow + /// Unique = true for indexes with functional expressions + /// + [Test] + public void AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string columnName4 = "TestColumn4"; + const string columnName5 = "TestColumn5"; + const string columnName6 = "TestColumn6"; + const string columnName7 = "TestColumn7"; + const string columnName8 = "TestColumn8"; + const string columnName9 = "TestColumn9"; + const string columnName10 = "TestColumn10"; + const string columnName11 = "TestColumn11"; + const string columnName12 = "TestColumn12"; + const string columnName13 = "TestColumn13"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16), + new Column(columnName2, DbType.Int32), + new Column(columnName3, DbType.Int64), + new Column(columnName4, DbType.UInt16), + new Column(columnName5, DbType.UInt32), + new Column(columnName6, DbType.UInt64), + new Column(columnName7, DbType.String), + new Column(columnName8, DbType.Int32), + new Column(columnName9, DbType.Int32), + new Column(columnName10, DbType.Int32), + new Column(columnName11, DbType.Int32), + new Column(columnName12, DbType.Int32), + new Column(columnName13, DbType.Int32) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName2, Value = 2 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName3, Value = 2323 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName4, Value = 3434 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName5, Value = -3434 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName6, Value = 3434345345 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName7, Value = "asdf" }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName8, Value = 11 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName9, Value = 22 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName10, Value = 33 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName11, Value = 44 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName12, Value = 55 }, + new() { Filter = FilterType.SmallerThanOrEqualTo, ColumnName = columnName13, Value = 66 } + ]; + + // Act + var addIndexSql = Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1, + columnName2, + columnName3, + columnName4, + columnName5, + columnName6, + columnName7, + columnName8, + columnName9, + columnName10, + columnName11, + columnName12, + columnName13 + ], + Unique = false, + FilterItems = filterItems + }); + + Provider.Insert(table: tableName, [columnName1], [1]); + + // Assert + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + + // In Oracle it seems that functional expressions are stored as column with generated column name. FilterItems are not + // implemented in Provider.GetIndexes() for Oracle. No further assert possible at this point in time. + Assert.That(indexesFromDatabase.Single().KeyColumns.Count, Is.EqualTo(13)); + + + var expectedSql = "CREATE INDEX TestIndexName ON TestTable (CASE WHEN TestColumn1 = 1 THEN TestColumn1 ELSE NULL END, CASE WHEN TestColumn2 > 2 THEN TestColumn2 ELSE NULL END, CASE WHEN TestColumn3 >= 2323 THEN TestColumn3 ELSE NULL END, CASE WHEN TestColumn4 <> 3434 THEN TestColumn4 ELSE NULL END, CASE WHEN TestColumn5 <> -3434 THEN TestColumn5 ELSE NULL END, CASE WHEN TestColumn6 < 3434345345 THEN TestColumn6 ELSE NULL END, CASE WHEN TestColumn7 <> 'asdf' THEN TestColumn7 ELSE NULL END, CASE WHEN TestColumn8 = 11 THEN TestColumn8 ELSE NULL END, CASE WHEN TestColumn9 > 22 THEN TestColumn9 ELSE NULL END, CASE WHEN TestColumn10 >= 33 THEN TestColumn10 ELSE NULL END, CASE WHEN TestColumn11 <> 44 THEN TestColumn11 ELSE NULL END, CASE WHEN TestColumn12 < 55 THEN TestColumn12 ELSE NULL END, CASE WHEN TestColumn13 <= 66 THEN TestColumn13 ELSE NULL END)"; + + Assert.That(addIndexSql, Is.EqualTo(expectedSql)); + } + + /// + /// Migrator throws if UNIQUE is used with functional expressions. + /// + [Test] + public void AddIndex_FilterItemsCombinedWithUnique_Throws() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + ]; + + // Act/Assert + Assert.Throws(() => Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1 + ], + Unique = true, + FilterItems = filterItems + })); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs new file mode 100644 index 00000000..097a986c --- /dev/null +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Generic; +using Npgsql; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.PostgreSQL; + +[TestFixture] +[Category("Postgre")] +public class PostgreSQLTransformationProvider_AddIndexTests : Generic_AddIndexTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginPostgreSQLTransactionAsync(); + } + + [Test] + public void AddTableWithCompoundPrimaryKey() + { + Provider.AddTable("Test", + new Column("PersonId", DbType.Int32, ColumnProperty.PrimaryKey), + new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey) + ); + + Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); + Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True, "Constraint doesn't exist"); + } + + [Test] + public void AddIndex_Unique_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + }); + + // Assert + var indexes = Provider.GetIndexes(tableName); + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName, columnName2], [1, "Some other string"])); + var index = indexes.Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(ex.SqlState, Is.EqualTo("23505")); + } + + [Test] + public void AddIndex_FilteredIndexGreaterOrEqualThanNumber_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName, columnName2], + Unique = true, + FilterItems = [ + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName, Value = 100 }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName2, Value = "Hello" }, + ] + }); + + // Assert + var index = Provider.GetIndexes(tableName).Single(); + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + // Unique but no exception is thrown since smaller than 100 + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + + Provider.Insert(tableName, [columnName, columnName2], [100, "Hello"]); + var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName, columnName2], [100, "Hello"])); + + Assert.That(index.Unique, Is.True); + Assert.That(ex.SqlState, Is.EqualTo("23505")); + } + + [Test] + public void AddIndex_IncludeColumnsSingle_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + IncludeColumns = [columnName2] + }); + + // Assert + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(index.KeyColumns.Single, Is.EqualTo(columnName).IgnoreCase); + Assert.That(index.IncludeColumns.Single, Is.EqualTo(columnName2).IgnoreCase); + } + + [Test] + public void AddIndex_IncludeColumnsMultiple_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String), new Column(columnName3, DbType.Boolean)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + IncludeColumns = [columnName2, columnName3] + }); + + // Assert + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(index.KeyColumns.Single, Is.EqualTo(columnName).IgnoreCase); + Assert.That(index.IncludeColumns, Is.EquivalentTo([columnName2, columnName3]) + .Using((x, y) => string.Compare(x, y, ignoreCase: true))); + } + + [Test] + public void AddIndex_FilteredIndexSingle_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + ]; + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1], + Unique = true, + FilterItems = filterItems + }); + + // Assert + + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + } + + /// + /// This test is located in the dedicated database type folder not in the base class since + /// cannot read filter items for Oracle. + /// + [Test] + public void AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string columnName4 = "TestColumn4"; + const string columnName5 = "TestColumn5"; + const string columnName6 = "TestColumn6"; + const string columnName7 = "TestColumn7"; + const string columnName8 = "TestColumn8"; + const string columnName9 = "TestColumn9"; + const string columnName10 = "TestColumn10"; + const string columnName11 = "TestColumn11"; + const string columnName12 = "TestColumn12"; + const string columnName13 = "TestColumn13"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16), + new Column(columnName2, DbType.Int32), + new Column(columnName3, DbType.Int64), + new Column(columnName4, DbType.UInt16), + new Column(columnName5, DbType.UInt32), + new Column(columnName6, DbType.UInt64), + new Column(columnName7, DbType.String), + new Column(columnName8, DbType.Int32), + new Column(columnName9, DbType.Int32), + new Column(columnName10, DbType.Int32), + new Column(columnName11, DbType.Int32), + new Column(columnName12, DbType.Int32), + new Column(columnName13, DbType.Int32) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName2, Value = 2 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName3, Value = 2323 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName4, Value = 3434 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName5, Value = -3434 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName6, Value = 3434345345 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName7, Value = "asdf" }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName8, Value = 11 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName9, Value = 22 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName10, Value = 33 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName11, Value = 44 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName12, Value = 55 }, + new() { Filter = FilterType.SmallerThanOrEqualTo, ColumnName = columnName13, Value = 66 } + ]; + + // Act + var addIndexSql = Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1, + columnName2, + columnName3, + columnName4, + columnName5, + columnName6, + columnName7, + columnName8, + columnName9, + columnName10, + columnName11, + columnName12, + columnName13 + ], + Unique = true, + FilterItems = filterItems + }); + + // Assert + + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + + var expectedSql = "CREATE UNIQUE INDEX TestIndexName ON TestTable (TestColumn1, TestColumn2, TestColumn3, TestColumn4, TestColumn5, TestColumn6, TestColumn7, TestColumn8, TestColumn9, TestColumn10, TestColumn11, TestColumn12, TestColumn13) WHERE TestColumn1 = 1 AND TestColumn2 > 2 AND TestColumn3 >= 2323 AND TestColumn4 <> 3434 AND TestColumn5 <> -3434 AND TestColumn6 < 3434345345 AND TestColumn7 <> 'asdf' AND TestColumn8 = 11 AND TestColumn9 > 22 AND TestColumn10 >= 33 AND TestColumn11 <> 44 AND TestColumn12 < 55 AND TestColumn13 <= 66"; + + Assert.That(addIndexSql, Is.EqualTo(expectedSql)); + } +} diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs new file mode 100644 index 00000000..91fb83f2 --- /dev/null +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.SQLServer; + +[TestFixture] +[Category("SqlServer")] +public class SQLServerTransformationProvider_AddIndexTests : Generic_AddIndexTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginSQLServerTransactionAsync(); + } + + [Test] + public void AddIndex_Unique_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + }); + + // Assert + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + var sqlException = Assert.Throws(() => Provider.Insert(tableName, [columnName, columnName2], [1, "Some other string"])); + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(sqlException.Number, Is.EqualTo(2601)); + } + + [Test] + public void AddIndex_FilteredIndexGreaterOrEqualThanNumber_PartialIndexThrowsOnConditionMet() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName, columnName2], + Unique = true, + FilterItems = [ + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName, Value = 100 }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName2, Value = "Hello" }, + ] + }); + + // Assert + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + // Unique but no exception is thrown since smaller than 100 + Provider.Insert(tableName, [columnName, columnName2], [1, "Hello"]); + + Provider.Insert(tableName, [columnName, columnName2], [100, "Hello"]); + var sqlException = Assert.Throws(() => Provider.Insert(tableName, [columnName, columnName2], [100, "Hello"])); + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(sqlException.Number, Is.EqualTo(2601)); + } + + [Test] + public void AddIndex_IncludeColumnsSingle_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + IncludeColumns = [columnName2] + }); + + // Assert + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(index.KeyColumns.Single, Is.EqualTo(columnName).IgnoreCase); + Assert.That(index.IncludeColumns.Single, Is.EqualTo(columnName2).IgnoreCase); + } + + [Test] + public void AddIndex_IncludeColumnsMultiple_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, new Column(columnName, DbType.Int32), new Column(columnName2, DbType.String), new Column(columnName3, DbType.Boolean)); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName], + Unique = true, + IncludeColumns = [columnName2, columnName3] + }); + + // Assert + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + Assert.That(index.KeyColumns.Single, Is.EqualTo(columnName).IgnoreCase); + Assert.That(index.IncludeColumns, Is.EquivalentTo([columnName2, columnName3]) + .Using((x, y) => string.Compare(x, y, ignoreCase: true))); + } + + [Test] + public void AddIndex_FilteredIndexSingle_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + ]; + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1], + Unique = true, + FilterItems = filterItems + }); + + // Assert + + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + } + + /// + /// This test is located in the dedicated database type folder not in the base class since + /// cannot read filter items for Oracle. + /// + [Test] + public void AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string columnName4 = "TestColumn4"; + const string columnName5 = "TestColumn5"; + const string columnName6 = "TestColumn6"; + const string columnName7 = "TestColumn7"; + const string columnName8 = "TestColumn8"; + const string columnName9 = "TestColumn9"; + const string columnName10 = "TestColumn10"; + const string columnName11 = "TestColumn11"; + const string columnName12 = "TestColumn12"; + const string columnName13 = "TestColumn13"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16), + new Column(columnName2, DbType.Int32), + new Column(columnName3, DbType.Int64), + new Column(columnName4, DbType.UInt16), + new Column(columnName5, DbType.UInt32), + new Column(columnName6, DbType.UInt64), + new Column(columnName7, DbType.String), + new Column(columnName8, DbType.Int32), + new Column(columnName9, DbType.Int32), + new Column(columnName10, DbType.Int32), + new Column(columnName11, DbType.Int32), + new Column(columnName12, DbType.Int32), + new Column(columnName13, DbType.Int32) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName2, Value = 2 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName3, Value = 2323 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName4, Value = 3434 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName5, Value = -3434 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName6, Value = 3434345345 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName7, Value = "asdf" }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName8, Value = 11 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName9, Value = 22 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName10, Value = 33 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName11, Value = 44 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName12, Value = 55 }, + new() { Filter = FilterType.SmallerThanOrEqualTo, ColumnName = columnName13, Value = 66 } + ]; + + // Act + var addIndexSql = Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1, + columnName2, + columnName3, + columnName4, + columnName5, + columnName6, + columnName7, + columnName8, + columnName9, + columnName10, + columnName11, + columnName12, + columnName13 + ], + Unique = true, + FilterItems = filterItems + }); + + // Assert + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + + var expectedSql = @"CREATE UNIQUE NONCLUSTERED INDEX [TestIndexName] ON [TestTable] ([TestColumn1], [TestColumn2], [TestColumn3], [TestColumn4], [TestColumn5], [TestColumn6], [TestColumn7], [TestColumn8], [TestColumn9], [TestColumn10], [TestColumn11], [TestColumn12], [TestColumn13]) WHERE [TestColumn1] = 1 AND [TestColumn2] > 2 AND [TestColumn3] >= 2323 AND [TestColumn4] <> 3434 AND [TestColumn5] <> -3434 AND [TestColumn6] < 3434345345 AND [TestColumn7] <> 'asdf' AND [TestColumn8] = 11 AND [TestColumn9] > 22 AND [TestColumn10] >= 33 AND [TestColumn11] <> 44 AND [TestColumn12] < 55 AND [TestColumn13] <= 66"; + + Assert.That(addIndexSql, Is.EqualTo(expectedSql)); + } +} diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddIndexTests.cs new file mode 100644 index 00000000..868a17b2 --- /dev/null +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddIndexTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SQLite; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_AddIndexTests : Generic_AddIndexTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginSQLiteTransactionAsync(); + } + + [Test] + public void AddIndex_Unique_Success() + { + // Arrange + const string columnName1 = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string indexName = "TestIndexName"; + const string tableName = "TestTable"; + + Provider.AddTable(tableName, new Column(columnName1, DbType.Int32), new Column(columnName2, DbType.String)); + + // Act + Provider.AddIndex(tableName, + new Index + { + KeyColumns = [columnName1], + Name = indexName, + Unique = true, + }); + + // Assert + Provider.Insert(tableName, [columnName1, columnName2], [1, "Hello"]); + var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, "Some other string"])); + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + + // Unique violation + Assert.That(ex.ErrorCode, Is.EqualTo(19)); + } + + [Test] + public void AddIndex_FilteredIndexGreaterOrEqualThanNumber_Success() + { + // Arrange + const string columnName1 = "TestColumn"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string columnName4 = "TestColumn4"; + const string indexName = "TestIndexName"; + const string tableName = "TestTable"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int32), + new Column(columnName2, DbType.String), + new Column(columnName3, DbType.Boolean), + new Column(columnName4, DbType.Int32) + ); + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1, columnName2, columnName3], + Unique = true, + FilterItems = [ + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName1, Value = 100 }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName2, Value = "Hello" }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName3, Value = true }, + ] + }); + + // We remove column to invoke a recreation of the table. + Provider.RemoveColumn(tableName, columnName4); + + // Assert + Provider.Insert(tableName, [columnName1, columnName2, columnName3], [1, "Hello", true]); + // Unique but no exception should be thrown since the integer value is smaller than 100 - not within the filter restriction. + Provider.Insert(tableName, [columnName1, columnName2, columnName3], [1, "Hello", true]); + + Provider.Insert(tableName, [columnName1, columnName2, columnName3], [100, "Hello", true]); + var sqliteException = Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2, columnName3], [100, "Hello", true])); + var index = Provider.GetIndexes(tableName).Single(); + + Assert.That(index.Unique, Is.True); + // Unique violation + Assert.That(sqliteException.ErrorCode, Is.EqualTo(19)); + + var indexScriptFromDatabase = GetCreateIndexSqlString(indexName); + + Assert.That(indexScriptFromDatabase, Is.EqualTo("CREATE UNIQUE INDEX TestIndexName ON TestTable (TestColumn, TestColumn2, TestColumn3) WHERE TestColumn >= 100 AND TestColumn2 = 'Hello' AND TestColumn3 = 1")); + } + + [Test] + public void AddIndex_FilteredIndexSingle_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + ]; + + // Act + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [columnName1], + Unique = true, + FilterItems = filterItems + }); + + // Assert + + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + } + + /// + /// This test is located in the dedicated database type folder not in the base class since + /// cannot read filter items for Oracle. + /// + [Test] + public void AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string columnName2 = "TestColumn2"; + const string columnName3 = "TestColumn3"; + const string columnName4 = "TestColumn4"; + const string columnName5 = "TestColumn5"; + const string columnName6 = "TestColumn6"; + const string columnName7 = "TestColumn7"; + const string columnName8 = "TestColumn8"; + const string columnName9 = "TestColumn9"; + const string columnName10 = "TestColumn10"; + const string columnName11 = "TestColumn11"; + const string columnName12 = "TestColumn12"; + const string columnName13 = "TestColumn13"; + + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16), + new Column(columnName2, DbType.Int32), + new Column(columnName3, DbType.Int64), + new Column(columnName4, DbType.UInt16), + new Column(columnName5, DbType.UInt32), + new Column(columnName6, DbType.UInt64), + new Column(columnName7, DbType.String), + new Column(columnName8, DbType.Int32), + new Column(columnName9, DbType.Int32), + new Column(columnName10, DbType.Int32), + new Column(columnName11, DbType.Int32), + new Column(columnName12, DbType.Int32), + new Column(columnName13, DbType.Int32) + ); + + List filterItems = [ + new() { Filter = FilterType.EqualTo, ColumnName = columnName1, Value = 1 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName2, Value = 2 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName3, Value = 2323 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName4, Value = 3434 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName5, Value = -3434 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName6, Value = 3434345345 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName7, Value = "asdf" }, + new() { Filter = FilterType.EqualTo, ColumnName = columnName8, Value = 11 }, + new() { Filter = FilterType.GreaterThan, ColumnName = columnName9, Value = 22 }, + new() { Filter = FilterType.GreaterThanOrEqualTo, ColumnName = columnName10, Value = 33 }, + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName11, Value = 44 }, + new() { Filter = FilterType.SmallerThan, ColumnName = columnName12, Value = 55 }, + new() { Filter = FilterType.SmallerThanOrEqualTo, ColumnName = columnName13, Value = 66 } + ]; + + // Act + var addIndexSql = Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1, + columnName2, + columnName3, + columnName4, + columnName5, + columnName6, + columnName7, + columnName8, + columnName9, + columnName10, + columnName11, + columnName12, + columnName13 + ], + Unique = true, + FilterItems = filterItems + }); + + // Assert + + var indexesFromDatabase = Provider.GetIndexes(table: tableName); + var filteredItemsFromDatabase = indexesFromDatabase.Single().FilterItems; + + // We cannot find out the exact DbType so we compare strings. + foreach (var filteredItemFromDatabase in filteredItemsFromDatabase) + { + var expected = filterItems.Single(x => x.ColumnName.Equals(filteredItemFromDatabase.ColumnName, StringComparison.OrdinalIgnoreCase)); + Assert.That(filteredItemFromDatabase.Filter, Is.EqualTo(expected.Filter)); + Assert.That(Convert.ToString(filteredItemFromDatabase.Value, CultureInfo.InvariantCulture), Is.EqualTo(Convert.ToString(expected.Value, CultureInfo.InvariantCulture))); + } + + Assert.That( + filteredItemsFromDatabase.Select(x => x.ColumnName.ToLowerInvariant()), + Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) + ); + + var expectedSql = "CREATE UNIQUE INDEX TestIndexName ON TestTable (TestColumn1, TestColumn2, TestColumn3, TestColumn4, TestColumn5, TestColumn6, TestColumn7, TestColumn8, TestColumn9, TestColumn10, TestColumn11, TestColumn12, TestColumn13) WHERE TestColumn1 = 1 AND TestColumn2 > 2 AND TestColumn3 >= 2323 AND TestColumn4 <> 3434 AND TestColumn5 <> -3434 AND TestColumn6 < 3434345345 AND TestColumn7 <> 'asdf' AND TestColumn8 = 11 AND TestColumn9 > 22 AND TestColumn10 >= 33 AND TestColumn11 <> 44 AND TestColumn12 < 55 AND TestColumn13 <= 66"; + + Assert.That(addIndexSql, Is.EqualTo(expectedSql)); + } + + private string GetCreateIndexSqlString(string indexName) + { + using var cmd = Provider.CreateCommand(); + using var reader = Provider.ExecuteQuery(cmd, $"SELECT sql FROM sqlite_master WHERE type='index' AND lower(name)=lower('{indexName}')"); + reader.Read(); + + return reader.IsDBNull(0) ? null : (string)reader[0]; + } +} diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs index 50fad73a..7d00f492 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs @@ -1,7 +1,9 @@ using System; +using System.Data.SQLite; using System.Linq; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; using Migrator.Tests.Providers.SQLite.Base; using NUnit.Framework; @@ -12,7 +14,7 @@ namespace Migrator.Tests.Providers.SQLite; public class SQLiteTransformationProvider_AddTableTests : SQLiteTransformationProviderTestBase { [Test] - public void AddTable_UniqueOnly_ContainsNull() + public void AddTable_UniqueOnlyOnColumnLevel_Obsolete_UniquesListIsEmpty() { const string tableName = "MyTableName"; const string columnName = "MyColumnName"; @@ -22,7 +24,7 @@ public void AddTable_UniqueOnly_ContainsNull() // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That("CREATE TABLE MyTableName (MyColumnName INTEGER NULL UNIQUE)", Is.EqualTo(createScript)); + Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (MyColumnName INTEGER NULL UNIQUE)")); var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); @@ -43,12 +45,12 @@ public void AddTable_CompositePrimaryKey_ContainsNull() new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.NotNull) ); - Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,1)"); - Assert.Throws(() => Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,1)")); + Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); + var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 1])); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That("CREATE TABLE MyTableName (Column1 INTEGER NULL, Column2 INTEGER NOT NULL, PRIMARY KEY (Column1, Column2))", Is.EqualTo(createScript)); + Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NULL, Column2 INTEGER NOT NULL, PRIMARY KEY (Column1, Column2))")); var pragmaTableInfos = ((SQLiteTransformationProvider)Provider).GetPragmaTableInfoItems(tableName); Assert.That(pragmaTableInfos.Single(x => x.Name == columnName1).NotNull, Is.False); @@ -57,6 +59,9 @@ public void AddTable_CompositePrimaryKey_ContainsNull() var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); Assert.That(sqliteInfo.Columns.First().Name, Is.EqualTo(columnName1)); Assert.That(sqliteInfo.Columns[1].Name, Is.EqualTo(columnName2)); + + // 19 = UNIQUE constraint failed + Assert.That(ex.ErrorCode, Is.EqualTo(19)); } [Test] @@ -72,12 +77,12 @@ public void AddTable_SinglePrimaryKey_ContainsNull() new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.NotNull) ); - Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,1)"); - Assert.Throws(() => Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,2)")); + Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 2])); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY, Column2 INTEGER NOT NULL)", Is.EqualTo(createScript)); + Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY, Column2 INTEGER NOT NULL)")); var pragmaTableInfos = ((SQLiteTransformationProvider)Provider).GetPragmaTableInfoItems(tableName); Assert.That(pragmaTableInfos.All(x => x.NotNull), Is.True); @@ -100,12 +105,12 @@ public void AddTable_MiscellaneousColumns_Succeeds() new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.Null | ColumnProperty.Unique) ); - Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,1)"); - Assert.Throws(() => Provider.ExecuteNonQuery($"INSERT INTO {tableName} ({columnName1}, {columnName2}) VALUES (1,1)")); + Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 1])); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Column2 INTEGER NULL UNIQUE)", Is.EqualTo(createScript)); + Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Column2 INTEGER NULL UNIQUE)")); var pragmaTableInfos = ((SQLiteTransformationProvider)Provider).GetPragmaTableInfoItems(tableName); Assert.That(pragmaTableInfos.First().NotNull, Is.True); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs index 3e7a048e..f8e720ae 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Linq; using System.Threading.Tasks; using DotNetProjects.Migrator.Framework; @@ -22,7 +23,7 @@ public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", System.Data.DbType.Int32, ColumnProperty.Unique)); + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); // Act var columns = Provider.GetColumns(tableName); @@ -36,7 +37,7 @@ public void GetColumns_PrimaryAndUnique_ReturnsFalse() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", System.Data.DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); // Act var columns = Provider.GetColumns(tableName); @@ -54,7 +55,7 @@ public void GetColumns_Primary_ColumnPropertyOk() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", System.Data.DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); Provider.GetColumns(tableName); // Act @@ -73,8 +74,8 @@ public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNot const string tableName = "GetColumnsTest"; Provider.AddTable(tableName, - new Column("Id", System.Data.DbType.Int32, ColumnProperty.PrimaryKey), - new Column("Id2", System.Data.DbType.Int32, ColumnProperty.PrimaryKey) + new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), + new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) ); // Act @@ -86,13 +87,17 @@ public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNot } [Test] - public void GetColumns_AddUniqueWithTwoColumns_NoUniqueOnColumnLevel() + public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Bla1", System.Data.DbType.Int32), new Column("Bla2", System.Data.DbType.Int32)); + const string column1Name = "Column1"; + const string column2Name = "Column2"; + const string constraintName = "ConstraintName"; - Provider.AddUniqueConstraint("IndexName", tableName, "Bla1", "Bla2"); + Provider.AddTable(tableName, new Column(column1Name, DbType.Int32), new Column(column2Name, DbType.Int32)); + + Provider.AddUniqueConstraint(constraintName, tableName, column1Name, column2Name); // Act var columns = Provider.GetColumns(tableName); @@ -106,7 +111,7 @@ public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumn { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Bla1", System.Data.DbType.Int32), new Column("Bla2", System.Data.DbType.Int32)); + Provider.AddTable(tableName, new Column("Bla1", DbType.Int32), new Column("Bla2", DbType.Int32)); Provider.AddIndex("IndexName", tableName, ["Bla1", "Bla2"]); // Act diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetIndexesTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetIndexesTests.cs new file mode 100644 index 00000000..f55ab749 --- /dev/null +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetIndexesTests.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_GetIndexesTests : Generic_GetIndexesTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginSQLiteTransactionAsync(); + } +} diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs index 34093be0..d4727474 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs @@ -23,6 +23,8 @@ public void GetUniques_Success() const string property5 = "Property5"; const string uniqueConstraintName1 = "UniqueConstraint1"; const string uniqueConstraintName2 = "UniqueConstraint2"; + const string uniqueIndexName1 = "IndexUnique1"; + const string nonUniqueIndexName1 = "IndexNonUnique1"; Provider.AddTable(tableNameA, new Column(property1, DbType.Int32, ColumnProperty.PrimaryKey), @@ -35,8 +37,13 @@ public void GetUniques_Success() Provider.AddUniqueConstraint(uniqueConstraintName1, tableNameA, property3); Provider.AddUniqueConstraint(uniqueConstraintName2, tableNameA, property4, property5); + // Add unique index in order to assert there is no interference with unique constraints + Provider.AddIndex(tableNameA, new Index { Name = nonUniqueIndexName1, KeyColumns = [property4], Unique = false }); + Provider.AddIndex(tableNameA, new Index { Name = uniqueIndexName1, KeyColumns = [property5], Unique = true }); + // Act var uniqueConstraints = ((SQLiteTransformationProvider)Provider).GetUniques(tableNameA); + var indexes = Provider.GetIndexes(tableNameA); // Assert var sql = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableNameA); @@ -48,5 +55,14 @@ public void GetUniques_Success() Assert.That(sql, Does.Contain("CONSTRAINT UniqueConstraint1 UNIQUE (Property3)")); Assert.That(sql, Does.Contain("CONSTRAINT UniqueConstraint2 UNIQUE (Property4, Property5)")); Assert.That(sql, Does.Contain("CONSTRAINT sqlite_autoindex_TableA_1 UNIQUE (Property2)")); + + var retrievedUniqueIndex1 = indexes.Single(x => x.Name == uniqueIndexName1); + + Assert.That(retrievedUniqueIndex1.Unique, Is.True); + Assert.That(retrievedUniqueIndex1.Name, Is.EqualTo(uniqueIndexName1)); + + var retrievedNonUniqueIndex1 = indexes.Single(x => x.Name == nonUniqueIndexName1); + + Assert.That(retrievedNonUniqueIndex1.Unique, Is.False); } } diff --git a/src/Migrator.Tests/appsettings.json b/src/Migrator.Tests/appsettings.json index 5bd3c423..d2710b6f 100644 --- a/src/Migrator.Tests/appsettings.json +++ b/src/Migrator.Tests/appsettings.json @@ -10,7 +10,7 @@ }, { "Id": "PostgreSQL", - "ConnectionString": "Server=localhost;Port=5432;Database=testdb;User Id=testuser;Password=testpass;" + "ConnectionString": "Server=localhost;Port=5432;Database=postgres;User Id=testuser;Password=testpass;Pooling=true;Minimum Pool Size=100" }, { "Id": "Oracle", diff --git a/src/Migrator/Framework/ForeignKeyConstraint.cs b/src/Migrator/Framework/ForeignKeyConstraint.cs index d12e8b2b..1a5af872 100644 --- a/src/Migrator/Framework/ForeignKeyConstraint.cs +++ b/src/Migrator/Framework/ForeignKeyConstraint.cs @@ -26,7 +26,7 @@ public ForeignKeyConstraint(string name, string parentTable, string[] parentcolu public string[] ChildColumns { get; set; } /// - /// Gets or sets the on update text. Currently only used for SQLite. + /// Gets or sets the on delete text. Currently only used for SQLite. /// public string OnDelete { get; set; } diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index 7d01117a..477d61a5 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -390,13 +390,20 @@ public interface ITransformationProvider : IDisposable List ExecuteStringQuery(string sql, params object[] args); + /// + /// Oracle: The retrieval of filter items is not supported in this migrator. If functional expressions are used: they seem to be stored as separate columns (with generated names). + /// + /// + /// Index[] GetIndexes(string table); /// - /// Get the information about the columns in a table + /// Get the information about the columns in a table. + /// and can in some cases only be guessed. Do not rely on them. Same for /// /// The table name that you want the columns for. /// + [Obsolete("We cannot resolve the DbType or MigratorDbType exactly so the result is just a guess. Also the default value in the result is depending on DbType and therefore also a guess. Do not use this method any more. Look up the type in your migration history.")] Column[] GetColumns(string table); /// @@ -408,11 +415,13 @@ public interface ITransformationProvider : IDisposable int GetColumnContentSize(string table, string columnName); /// - /// Get information about a single column in a table + /// Gets information about a single column in a table. + /// and can in some cases only be guessed. Do not rely on them. Same for /// /// The table name that you want the columns for. /// The column name for which you want information. /// + [Obsolete("We cannot resolve the DbType or MigratorDbType exactly so the result is just a guess. Also the default value in the result is depending on DbType and therefore also a guess. Do not use this method any more. Look up the type in your migration history.")] Column GetColumnByName(string table, string column); /// @@ -715,7 +724,7 @@ IDataReader SelectComplex(IDbCommand cmd, string table, string[] columns, string /// Name of the database to delete void DropDatabases(string databaseName); - void AddIndex(string table, Index index); + string AddIndex(string table, Index index); /// /// Add a multi-column index to a table @@ -723,7 +732,7 @@ IDataReader SelectComplex(IDbCommand cmd, string table, string[] columns, string /// The name of the index to add. /// The name of the table that will get the index. /// The name of the column or columns that are in the index. - void AddIndex(string name, string table, params string[] columns); + string AddIndex(string name, string table, params string[] columns); /// /// Check to see if an index exists diff --git a/src/Migrator/Framework/Index.cs b/src/Migrator/Framework/Index.cs index 92c2e1a4..5376a944 100644 --- a/src/Migrator/Framework/Index.cs +++ b/src/Migrator/Framework/Index.cs @@ -1,12 +1,44 @@ -namespace DotNetProjects.Migrator.Framework; +using System.Collections.Generic; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator.Framework; public class Index : IDbField { public string Name { get; set; } + public bool Unique { get; set; } + + /// + /// Indicates whether the index is clustered (false for NONCLUSTERED). + /// Please mind that this is ignored in Oracle and SQLite (supported in SQLite but not in this migrator) + /// public bool Clustered { get; set; } - public bool PrimaryKey { get; set; } - public bool UniqueConstraint { get; set; } - public string[] KeyColumns { get; set; } - public string[] IncludeColumns { get; set; } + + /// + /// Indicates whether it is a primary key constraint. If you want to set a primary key use in + /// + public bool PrimaryKey { get; internal set; } + + /// + /// Indicates whether it is a unique constraint. If you want to set a unique constraint use the method + /// + public bool UniqueConstraint { get; internal set; } + + /// + /// Gets or sets the column names in the index (not included columns). + /// + public string[] KeyColumns { get; set; } = []; + + /// + /// Gets or sets the included columns. Not supported in SQLite and Oracle. + /// + public string[] IncludeColumns { get; set; } = []; + + /// + /// Gets or sets items that represent filter expressions in filtered indexes. Currently string, integer and boolean values are supported. + /// Attention: In SQL Server the column used in the filter must be NOT NULL. + /// + public List FilterItems { get; set; } = []; } diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index 557426e7..fcf12c33 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -2,7 +2,10 @@ using System.Collections.Generic; using System.Data; using System.Globalization; +using System.Linq; using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; namespace DotNetProjects.Migrator.Providers; @@ -11,10 +14,19 @@ namespace DotNetProjects.Migrator.Providers; /// public abstract class Dialect : IDialect { - private readonly Dictionary propertyMap = []; - private readonly HashSet reservedWords = []; - private readonly TypeNames typeNames = new(); - private readonly List unsignedCompatibleTypes = []; + private readonly Dictionary _propertyMap = []; + private readonly HashSet _reservedWords = []; + private readonly TypeNames _typeNames = new(); + private readonly List _unsignedCompatibleTypes = []; + + private readonly List _filterTypeToStrings = [ + new() { FilterType = FilterType.EqualTo, FilterString = "=" }, + new() { FilterType = FilterType.GreaterThan, FilterString = ">" }, + new() { FilterType = FilterType.GreaterThanOrEqualTo, FilterString = ">=" }, + new() { FilterType = FilterType.SmallerThan, FilterString = "<" }, + new() { FilterType = FilterType.SmallerThanOrEqualTo, FilterString = "<=" }, + new() { FilterType = FilterType.NotEqualTo, FilterString = "<>"} + ]; protected Dialect() { @@ -81,7 +93,7 @@ public virtual bool NeedsNullForNullableWhenAlteringTable protected void AddReservedWord(string reservedWord) { - reservedWords.Add(reservedWord.ToUpperInvariant()); + _reservedWords.Add(reservedWord.ToUpperInvariant()); } protected void AddReservedWords(params string[] words) @@ -93,7 +105,7 @@ protected void AddReservedWords(params string[] words) foreach (var word in words) { - reservedWords.Add(word); + _reservedWords.Add(word); } } @@ -104,12 +116,12 @@ public virtual bool IsReservedWord(string reservedWord) throw new ArgumentNullException("reservedWord"); } - if (reservedWords == null) + if (_reservedWords == null) { return false; } - var isReserved = reservedWords.Contains(reservedWord.ToUpperInvariant()); + var isReserved = _reservedWords.Contains(reservedWord.ToUpperInvariant()); if (isReserved) { @@ -142,7 +154,7 @@ public ITransformationProvider NewProviderForDialect(IDbConnection connection, s /// The database type name protected void RegisterColumnType(DbType code, int capacity, string name) { - typeNames.Put(code, capacity, name); + _typeNames.Put(code, capacity, name); } /// @@ -155,7 +167,7 @@ protected void RegisterColumnType(DbType code, int capacity, string name) /// The database type name protected void RegisterColumnType(MigratorDbType code, int capacity, string name) { - typeNames.Put(code, capacity, name); + _typeNames.Put(code, capacity, name); } /// @@ -170,7 +182,7 @@ protected void RegisterColumnType(MigratorDbType code, int capacity, string name /// The database type name protected void RegisterColumnTypeWithPrecision(DbType code, string name) { - typeNames.Put(code, -1, name); + _typeNames.Put(code, -1, name); } /// @@ -181,7 +193,7 @@ protected void RegisterColumnTypeWithPrecision(DbType code, string name) /// The database type name protected void RegisterColumnType(MigratorDbType code, string name) { - typeNames.Put(code, name); + _typeNames.Put(code, name); } /// @@ -192,7 +204,7 @@ protected void RegisterColumnType(MigratorDbType code, string name) /// The database type name protected void RegisterColumnType(DbType code, string name) { - typeNames.Put(code, name); + _typeNames.Put(code, name); } /// @@ -204,13 +216,13 @@ protected void RegisterColumnType(DbType code, string name) /// The database type name protected void RegisterColumnTypeWithParameters(DbType code, string name) { - typeNames.PutParametrized(code, name); + _typeNames.PutParametrized(code, name); } protected void RegisterColumnTypeAlias(DbType code, string alias) { - typeNames.PutAlias(code, alias); + _typeNames.PutAlias(code, alias); } public virtual ColumnPropertiesMapper GetColumnMapper(Column column) @@ -232,7 +244,7 @@ public virtual ColumnPropertiesMapper GetColumnMapper(Column column) public virtual DbType GetDbTypeFromString(string type) { - return typeNames.GetDbType(type); + return _typeNames.GetDbType(type); } /// @@ -242,7 +254,7 @@ public virtual DbType GetDbTypeFromString(string type) /// The database type name used by ddl. public virtual string GetTypeName(DbType type) { - var result = typeNames.Get(type); + var result = _typeNames.Get(type); if (result == null) { @@ -273,7 +285,7 @@ public virtual string GetTypeName(DbType type, int length) /// public virtual string GetTypeName(DbType type, int length, int precision, int scale) { - var resultWithLength = typeNames.Get(type, length, precision, scale); + var resultWithLength = _typeNames.Get(type, length, precision, scale); if (resultWithLength != null) { return resultWithLength; @@ -292,7 +304,7 @@ public virtual string GetTypeName(DbType type, int length, int precision, int sc /// public virtual string GetTypeNameParametrized(DbType type, int length, int precision, int scale) { - var result = typeNames.GetParametrized(type); + var result = _typeNames.GetParametrized(type); if (result != null) { return result.Replace("{length}", length.ToString()) @@ -311,23 +323,23 @@ public virtual string GetTypeNameParametrized(DbType type, int length, int preci /// The . public virtual DbType GetDbType(string databaseTypeName) { - return typeNames.GetDbType(databaseTypeName); + return _typeNames.GetDbType(databaseTypeName); } public void RegisterProperty(ColumnProperty property, string sql) { - if (!propertyMap.ContainsKey(property)) + if (!_propertyMap.ContainsKey(property)) { - propertyMap.Add(property, sql); + _propertyMap.Add(property, sql); } - propertyMap[property] = sql; + _propertyMap[property] = sql; } public virtual string SqlForProperty(ColumnProperty property, Column column) { - if (propertyMap.ContainsKey(property)) + if (_propertyMap.ContainsKey(property)) { - return propertyMap[property]; + return _propertyMap[property]; } return string.Empty; } @@ -406,13 +418,40 @@ public ColumnPropertiesMapper GetAndMapColumnPropertiesWithoutDefault(Column col return mapper; } + public string GetComparisonStringByFilterType(FilterType filterType) + { + var exceptionString = $"The {nameof(FilterType)} '{filterType}' is not implemented."; + var result = _filterTypeToStrings.FirstOrDefault(x => x.FilterType == filterType) ?? throw new NotImplementedException(exceptionString); + + return result.FilterString; + } + + public string[] GetComparisonStrings() + { + return _filterTypeToStrings.Select(x => x.FilterString).ToArray(); + } + + /// + /// Resolves the comparison string for filtered indexes. + /// + /// + /// + /// + public FilterType GetFilterTypeByComparisonString(string comparisonString) + { + var exceptionString = $"The {comparisonString} cannot be resolved."; + var result = _filterTypeToStrings.FirstOrDefault(x => x.FilterString == comparisonString) ?? throw new Exception(exceptionString); + + return result.FilterType; + } + /// /// Subclasses register which DbTypes are unsigned-compatible (ie, available in signed and unsigned variants) /// /// protected void RegisterUnsignedCompatible(DbType type) { - unsignedCompatibleTypes.Add(type); + _unsignedCompatibleTypes.Add(type); } /// @@ -422,7 +461,7 @@ protected void RegisterUnsignedCompatible(DbType type) /// True if the database type has an unsigned variant, otherwise false public bool IsUnsignedCompatible(DbType type) { - return unsignedCompatibleTypes.Contains(type); + return _unsignedCompatibleTypes.Contains(type); } } diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index b8657821..4ab49fa7 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Data; -using DotNetProjects.Migrator.Providers; namespace DotNetProjects.Migrator.Providers.Impl.Informix; diff --git a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs index 80c50d04..ac69a823 100644 --- a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Data; -using DotNetProjects.Migrator.Providers; namespace DotNetProjects.Migrator.Providers.Impl.Ingres; diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 79ced05c..77b50ae3 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -1,6 +1,7 @@ using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers.Impl.Oracle.Models; using DotNetProjects.Migrator.Providers.Models; +using DotNetProjects.Migrator.Providers.Models.Indexes; using System; using System.Collections.Generic; using System.Data; @@ -135,6 +136,88 @@ public override void AddForeignKey(string name, string primaryTable, string[] pr ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} FOREIGN KEY ({2}) REFERENCES {3} ({4})", primaryTable, name, primaryColumnsSql, refTable, refColumnsSql)); } + public override string AddIndex(string table, Index index) + { + ValidateIndex(tableName: table, index: index); + var hasFilterItems = index.FilterItems != null && index.FilterItems.Count > 0; + + // Oracle does not support included columns and clustered indexes. We ignore the values given in the properties SILENTLY for backwards compatibility. + + if (index.Unique && hasFilterItems) + { + throw new MigrationException($"You cannot use unique together with functional expressions in Oracle ({nameof(FilterItem)})."); + } + + var name = QuoteConstraintNameIfRequired(index.Name); + table = QuoteTableNameIfRequired(table); + + List singleFilterStrings = []; + + + if (hasFilterItems) + { + // In Oracle functional expressions replace the normal columns so we need to remove them + if (index.KeyColumns != null && index.KeyColumns.Length > 0) + { + var keyColumnsList = index.KeyColumns.ToList(); + + for (var i = keyColumnsList.Count - 1; i >= 0; i--) + { + if (index.FilterItems.Any(x => keyColumnsList[i].Equals(x.ColumnName, StringComparison.OrdinalIgnoreCase))) + { + keyColumnsList.RemoveAt(i); + } + } + + index.KeyColumns = keyColumnsList.ToArray(); + } + + foreach (var filterItem in index.FilterItems) + { + var comparisonString = _dialect.GetComparisonStringByFilterType(filterItem.Filter); + + var filterColumnQuoted = QuoteColumnNameIfRequired(filterItem.ColumnName); + string value = null; + + value = filterItem.Value switch + { + bool booleanValue => booleanValue ? "TRUE" : "FALSE", + string stringValue => $"'{stringValue}'", + 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."), + }; + + var singleFilterString = $"CASE WHEN {filterColumnQuoted} {comparisonString} {value} THEN {filterColumnQuoted} ELSE NULL END"; + + singleFilterStrings.Add(singleFilterString); + } + } + + var mixedColumnNamesAndFilters = QuoteColumnNamesIfRequired(index.KeyColumns).ToList(); + mixedColumnNamesAndFilters.AddRange(singleFilterStrings); + var columnNamesAndFiltersString = $"({string.Join(", ", mixedColumnNamesAndFilters)})"; + + var uniqueString = index.Unique ? "UNIQUE" : null; + + List list = []; + list.Add("CREATE"); + list.Add(uniqueString); + list.Add("INDEX"); + list.Add(name); + list.Add("ON"); + list.Add(table); + list.Add(columnNamesAndFiltersString); + + list = [.. list.Where(x => !string.IsNullOrWhiteSpace(x))]; + + var sql = string.Join(" ", list); + + ExecuteNonQuery(sql); + + return sql; + } + private void GuardAgainstMaximumIdentifierLengthForOracle(string name) { var utf8Bytes = Encoding.UTF8.GetBytes(name); @@ -553,9 +636,13 @@ public override Column[] GetColumns(string table) { column.MigratorDbType = MigratorDbType.String; } + else if (dataTypeString.StartsWith("INTERVAL")) + { + column.MigratorDbType = MigratorDbType.Interval; + } else { - throw new NotImplementedException(); + throw new NotImplementedException($"The data type '{dataTypeString}' is not implemented yet. Please file an issue."); } if (!string.IsNullOrWhiteSpace(dataDefaultString)) @@ -835,55 +922,84 @@ private string SchemaInfoTableName public override Index[] GetIndexes(string table) { - var sql = "select user_indexes.index_name, constraint_type, uniqueness " + - "from user_indexes left outer join user_constraints on user_indexes.index_name = user_constraints.constraint_name " + - "where lower(user_indexes.table_name) = lower('{0}') and index_type = 'NORMAL'"; - - sql = string.Format(sql, table); - - var indexes = new List(); + var sql = @$"SELECT + i.table_name, + i.index_name, + i.uniqueness, + ic.column_position, + ic.column_name, + CASE WHEN c.constraint_type = 'P' THEN 'YES' ELSE 'NO' END AS is_primary_key, + CASE WHEN c.constraint_type = 'U' THEN 'YES' ELSE 'NO' END AS is_unique_key + FROM + user_indexes i + JOIN + user_ind_columns ic ON i.index_name = ic.index_name AND + i.table_name = ic.table_name + LEFT JOIN + user_constraints c ON i.index_name = c.index_name AND + i.table_name = c.table_name + WHERE + UPPER(i.table_name) = '{table.ToUpperInvariant()}' + -- AND + -- i.index_type = 'NORMAL' + ORDER BY + i.table_name, i.index_name, ic.column_position"; + + List indexItems = []; using (var cmd = CreateCommand()) using (var reader = ExecuteQuery(cmd, sql)) { while (reader.Read()) { - var index = new Index + var tableNameOrdinal = reader.GetOrdinal("table_name"); + var indexNameOrdinal = reader.GetOrdinal("index_name"); + var uniquenessOrdinal = reader.GetOrdinal("uniqueness"); + var columnPositionOrdinal = reader.GetOrdinal("column_position"); + var columnNameOrdinal = reader.GetOrdinal("column_name"); + var isPrimaryKeyOrdinal = reader.GetOrdinal("is_primary_key"); + var isUniqueConstraintOrdinal = reader.GetOrdinal("is_unique_key"); + + var indexItem = new IndexItem { - Name = reader.GetString(0), - Unique = reader.GetString(2) == "UNIQUE" ? true : false + ColumnName = reader.GetString(columnNameOrdinal), + ColumnOrder = reader.GetInt32(columnPositionOrdinal), + Name = reader.GetString(indexNameOrdinal), + PrimaryKey = reader.GetString(isPrimaryKeyOrdinal) == "YES", + TableName = reader.GetString(tableNameOrdinal), + Unique = reader.GetString(uniquenessOrdinal) == "UNIQUE", + UniqueConstraint = reader.GetString(isUniqueConstraintOrdinal) == "YES" }; - if (!reader.IsDBNull(1)) - { - index.PrimaryKey = reader.GetString(1) == "P" ? true : false; - index.UniqueConstraint = reader.GetString(1) == "C" ? true : false; - } - else - { - index.PrimaryKey = false; - } - - index.Clustered = false; //??? - - //if (!reader.IsDBNull(3)) index.KeyColumns = (reader.GetString(3).Split(',')); - //if (!reader.IsDBNull(4)) index.IncludeColumns = (reader.GetString(4).Split(',')); - - indexes.Add(index); + indexItems.Add(indexItem); } } - foreach (var idx in indexes) + var indexGroups = indexItems.GroupBy(x => new { x.SchemaName, x.TableName, x.Name }); + List indexes = []; + + foreach (var indexGroup in indexGroups) { - sql = "SELECT column_Name FROM all_ind_columns WHERE lower(table_name) = lower('" + table + "') and lower(index_name) = lower('" + idx.Name + "')"; - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, sql); - var columns = new List(); - while (reader.Read()) + var first = indexGroup.First(); + + var index = new Index { - columns.Add(reader.GetString(0)); - } - idx.KeyColumns = columns.ToArray(); + KeyColumns = [.. indexGroup.OrderBy(x => x.ColumnOrder).Select(x => x.ColumnName).Distinct()], + Name = first.Name, + PrimaryKey = first.PrimaryKey, + UniqueConstraint = first.UniqueConstraint, + Unique = first.Unique, + + // Oracle does not support clustered indexes at this point in time. + Clustered = false, + + // Oracle does not support include columns at this point in time. + IncludeColumns = null, + }; + + // FilterItems is not supported in this migrator at this point in time. + + indexes.Add(index); } return indexes.ToArray(); diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 34f04aee..11221c75 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -12,6 +12,8 @@ #endregion using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; using System; using System.Collections.Generic; using System.Data; @@ -54,64 +56,242 @@ 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)); + return reader.Read() ? reader.GetString(0) : null; } - public override Index[] GetIndexes(string table) + public override string AddIndex(string table, Index index) { - var retVal = new List(); - - var sql = @" -SELECT * FROM ( -SELECT i.relname as indname, - idx.indisprimary, - idx.indisunique, - idx.indisclustered, - i.relowner as indowner, - cast(idx.indrelid::regclass as varchar) as tablenm, - am.amname as indam, - idx.indkey, - ARRAY_TO_STRING(ARRAY( - SELECT pg_get_indexdef(idx.indexrelid, k + 1, true) - FROM generate_subscripts(idx.indkey, 1) as k - ORDER BY k - ), ',') as indkey_names, - idx.indexprs IS NOT NULL as indexprs, - idx.indpred IS NOT NULL as indpred -FROM pg_index as idx -JOIN pg_class as i -ON i.oid = idx.indexrelid -JOIN pg_am as am -ON i.relam = am.oid -JOIN pg_namespace as ns -ON ns.oid = i.relnamespace -AND ns.nspname = ANY(current_schemas(false))) AS t -WHERE lower(tablenm) = lower('{0}') -;"; + ValidateIndex(tableName: table, index: index); + + var hasIncludedColumns = index.IncludeColumns != null && index.IncludeColumns.Length > 0; + var name = QuoteConstraintNameIfRequired(index.Name); + table = QuoteTableNameIfRequired(table); + var columns = QuoteColumnNamesIfRequired(index.KeyColumns); + + var uniqueString = index.Unique ? "UNIQUE" : null; + var columnsString = $"({string.Join(", ", columns)})"; + var filterString = string.Empty; + var includeString = string.Empty; + + if (index.IncludeColumns != null && index.IncludeColumns.Length > 0) + { + includeString = $"INCLUDE ({string.Join(", ", index.IncludeColumns)})"; + } + + if (index.FilterItems != null && index.FilterItems.Count > 0) + { + List singleFilterStrings = []; + + foreach (var filterItem in index.FilterItems) + { + var comparisonString = _dialect.GetComparisonStringByFilterType(filterItem.Filter); + + var filterColumnQuoted = QuoteColumnNameIfRequired(filterItem.ColumnName); + string value = null; + + value = filterItem.Value switch + { + bool booleanValue => booleanValue ? "TRUE" : "FALSE", + string stringValue => $"'{stringValue}'", + 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."), + }; + var singleFilterString = $"{filterColumnQuoted} {comparisonString} {value}"; + + singleFilterStrings.Add(singleFilterString); + } + + filterString = $"WHERE {string.Join(" AND ", singleFilterStrings)}"; + } + + List list = []; + list.Add("CREATE"); + list.Add(uniqueString); + list.Add("INDEX"); + list.Add(name); + list.Add("ON"); + list.Add(table); + list.Add(columnsString); + list.Add(filterString); + list.Add(includeString); + + var sql = string.Join(" ", list.Where(x => !string.IsNullOrWhiteSpace(x))); + + ExecuteNonQuery(sql); + + return sql; + } + + public override Index[] GetIndexes(string table) + { + var columns = GetColumns(table); + + // Since the migrator does not support schemas at this point in time we set the schema to "public" + var schemaName = "public"; + + var indexes = new List(); + + var sql = @$" + SELECT + nsp.nspname AS schema_name, + tbl.relname AS table_name, + cls.relname AS index_name, + idx.indisunique AS is_unique, + idx.indisclustered AS is_clustered, + con.contype = 'u' AS is_unique_constraint, + con.contype = 'p' AS is_primary_constraint, + pg_get_indexdef(idx.indexrelid) AS index_definition, + ( + SELECT string_agg(att.attname, ', ') + FROM unnest(idx.indkey) WITH ORDINALITY AS cols(attnum, ord) + JOIN pg_attribute att + ON att.attrelid = idx.indrelid + AND att.attnum = cols.attnum + WHERE cols.ord <= idx.indnkeyatts + ) AS index_columns, + ( + SELECT string_agg(att.attname, ', ') + FROM unnest(idx.indkey) WITH ORDINALITY AS cols(attnum, ord) + JOIN pg_attribute att + ON att.attrelid = idx.indrelid + AND att.attnum = cols.attnum + WHERE cols.ord > idx.indnkeyatts + ) AS include_columns, + pg_get_expr(idx.indpred, idx.indrelid) AS partial_filter + FROM pg_index idx + JOIN pg_class cls ON cls.oid = idx.indexrelid + JOIN pg_class tbl ON tbl.oid = idx.indrelid + JOIN pg_namespace nsp ON nsp.oid = tbl.relnamespace + LEFT JOIN pg_constraint con ON con.conindid = idx.indexrelid + WHERE + lower(tbl.relname) = '{table.ToLowerInvariant()}' AND + nsp.nspname = '{schemaName}'"; using (var cmd = CreateCommand()) using (var reader = ExecuteQuery(cmd, string.Format(sql, table))) { + var includeColumnsOrdinal = reader.GetOrdinal("include_columns"); + var indexColumnsOrdinal = reader.GetOrdinal("index_columns"); + var indexDefinitionOrdinal = reader.GetOrdinal("index_definition"); + var indexNameOrdinal = reader.GetOrdinal("index_name"); + var isClusteredOrdinal = reader.GetOrdinal("is_clustered"); + var isPrimaryConstraintOrdinal = reader.GetOrdinal("is_primary_constraint"); + var isUniqueConstraintOrdinal = reader.GetOrdinal("is_unique_constraint"); + var isUniqueOrdinal = reader.GetOrdinal("is_unique"); + var partialFilterOrdinal = reader.GetOrdinal("partial_filter"); + var schemaNameOrdinal = reader.GetOrdinal("schema_name"); + var tableNameOrdinal = reader.GetOrdinal("table_name"); + while (reader.Read()) { if (!reader.IsDBNull(1)) { - var idx = new Index + var includeColumns = !reader.IsDBNull(includeColumnsOrdinal) ? reader.GetString(includeColumnsOrdinal) : null; + var indexColumns = !reader.IsDBNull(indexColumnsOrdinal) ? reader.GetString(indexColumnsOrdinal) : null; + var indexDefinition = reader.GetString(indexDefinitionOrdinal); + var partialColumns = !reader.IsDBNull(partialFilterOrdinal) ? reader.GetString(partialFilterOrdinal) : null; + List filterItems = []; + + if (!string.IsNullOrWhiteSpace(partialColumns)) + { + partialColumns = partialColumns.Substring(1, partialColumns.Length - 2); + var comparisonStrings = _dialect.GetComparisonStrings(); + var partialSplitted = Regex.Split(partialColumns, " AND ").Select(x => x.Trim()).ToList(); + + if (partialSplitted.Count > 1) + { + partialSplitted = partialSplitted.Select(x => x.Substring(1, x.Length - 2)).ToList(); + } + + foreach (var partialItemString in partialSplitted) + { + string[] splits = []; + var filterType = FilterType.None; + + foreach (var comparisonString in comparisonStrings.OrderByDescending(x => x)) + { + splits = Regex.Split(partialItemString, $" {comparisonString} "); + + if (splits.Length == 2) + { + filterType = _dialect.GetFilterTypeByComparisonString(comparisonString); + break; + } + } + + if (splits.Length != 2) + { + throw new NotImplementedException($"Comparison string not found in '{partialItemString}'"); + } + + var columnNameString = splits[0]; + var columnNameRegex = new Regex(@"(?<=^\().+(?=\)::(text|boolean|integer)$)"); + + if (columnNameRegex.Match(columnNameString) is Match matchColumnName && matchColumnName.Success) + { + columnNameString = matchColumnName.Value; + } + + var column = columns.First(x => columnNameString.Equals(x.Name, StringComparison.OrdinalIgnoreCase)); + var valueAsString = splits[1]; + var stringValueNumericRegex = new Regex(@"(?<=^\()[^\)]+(?=\)::numeric$)"); + + if (stringValueNumericRegex.Match(valueAsString) is Match valueNumericMatch && valueNumericMatch.Success) + { + valueAsString = valueNumericMatch.Value; + } + + var stringValueRegex = new Regex("(?<=^').+(?='::(text|boolean|integer|bigint)$)"); + + if (stringValueRegex.Match(valueAsString) is Match match && match.Success) + { + valueAsString = match.Value; + } + + var filterItem = new FilterItem + { + ColumnName = column.Name, + Filter = filterType, + Value = column.MigratorDbType switch + { + MigratorDbType.Int16 => short.Parse(valueAsString), + MigratorDbType.Int32 => int.Parse(valueAsString), + MigratorDbType.Int64 => long.Parse(valueAsString), + MigratorDbType.UInt16 => ushort.Parse(valueAsString), + MigratorDbType.UInt32 => uint.Parse(valueAsString), + MigratorDbType.UInt64 => ulong.Parse(valueAsString), + MigratorDbType.Decimal => decimal.Parse(valueAsString), + MigratorDbType.Boolean => valueAsString == "1" || valueAsString.Equals("true", StringComparison.OrdinalIgnoreCase), + MigratorDbType.String => valueAsString, + _ => throw new NotImplementedException($"Type '{column.MigratorDbType}' not yet supported - there are many variations. Please file an issue."), + } + }; + + filterItems.Add(filterItem); + } + } + + var index = new Index { - Name = reader.GetString(0), - PrimaryKey = reader.GetBoolean(1), - Unique = reader.GetBoolean(2), - Clustered = reader.GetBoolean(3), + Clustered = !reader.IsDBNull(isClusteredOrdinal) && reader.GetBoolean(isClusteredOrdinal), + FilterItems = filterItems, + IncludeColumns = !string.IsNullOrWhiteSpace(includeColumns) ? [.. includeColumns.Split(',').Select(x => x.Trim())] : null, + KeyColumns = !string.IsNullOrWhiteSpace(indexColumns) ? [.. indexColumns.Split(',').Select(x => x.Trim())] : null, + Name = reader.GetString(indexNameOrdinal), + PrimaryKey = !reader.IsDBNull(isPrimaryConstraintOrdinal) && reader.GetBoolean(isPrimaryConstraintOrdinal), + Unique = !reader.IsDBNull(isUniqueOrdinal) && reader.GetBoolean(isUniqueOrdinal), + UniqueConstraint = !reader.IsDBNull(isUniqueConstraintOrdinal) && reader.GetBoolean(isUniqueConstraintOrdinal), }; - var cols = reader.GetString(8); - idx.KeyColumns = cols.Split(','); - retVal.Add(idx); + + indexes.Add(index); } } } - return retVal.ToArray(); + return [.. indexes]; } public override void RemoveTable(string name) diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index fdb3a0be..dd5a9349 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -10,6 +10,8 @@ using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using Index = DotNetProjects.Migrator.Framework.Index; using DotNetProjects.Migrator.Framework.Extensions; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; namespace DotNetProjects.Migrator.Providers.Impl.SQLite; @@ -654,12 +656,29 @@ public override void AddPrimaryKey(string name, string tableName, params string[ public override bool PrimaryKeyExists(string table, string name) { - throw new NotSupportedException($"SQLite does not support named primary keys. You may wonder why there is a name in method '{nameof(AddPrimaryKey)}'. It is because of architectural decisions of the past. It is overridden in {nameof(SQLiteTransformationProvider)}."); + var sqliteTableInfo = GetSQLiteTableInfo(table); + + // SQLite does not offer named primary keys BUT since there can only be one primary key per table we return true if there is any primary key. + + var hasPrimaryKey = sqliteTableInfo.Columns.Any(x => x.ColumnProperty.IsSet(ColumnProperty.PrimaryKey)); + + return hasPrimaryKey; } public override void AddUniqueConstraint(string name, string table, params string[] columns) { + if (string.IsNullOrWhiteSpace(name)) + { + throw new MigrationException("Providing a constraint name is obligatory."); + } + var sqliteTableInfo = GetSQLiteTableInfo(table); + + if (sqliteTableInfo.Uniques.Any(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + throw new MigrationException("A unique constraint with the same name already exists."); + } + var uniqueConstraint = new Unique() { KeyColumns = columns, Name = name }; sqliteTableInfo.Uniques.Add(uniqueConstraint); @@ -1188,16 +1207,18 @@ public override bool IndexExists(string table, string name) public override Index[] GetIndexes(string table) { + var afterWhereRegex = new Regex("(?<= WHERE ).+"); List indexes = []; - var pragmaIndexListItems = GetPragmaIndexListItems(table); + var indexCreateScripts = GetCreateIndexSqlStrings(table); + + var pragmaIndexListItems = GetPragmaIndexListItems(table).Where(x => x.Origin == "c"); - // Since unique indexes are supported but only by using unique constraints or primary keys we filter them out here. See "GetUniques()" for unique constraints. - var pragmaIndexListItemsFiltered = pragmaIndexListItems.Where(x => !x.Unique).ToList(); + var columns = GetColumns(table); - foreach (var pragmaIndexListItemFiltered in pragmaIndexListItemsFiltered) + foreach (var pragmaIndexListItem in pragmaIndexListItems) { - var indexInfos = GetPragmaIndexInfo(pragmaIndexListItemFiltered.Name); + var indexInfos = GetPragmaIndexInfo(pragmaIndexListItem.Name); var columnNames = indexInfos.OrderBy(x => x.SeqNo) .Select(x => x.Name) @@ -1212,12 +1233,72 @@ public override Index[] GetIndexes(string table) // SQLite does not support include colums IncludeColumns = [], KeyColumns = columnNames, - Name = pragmaIndexListItemFiltered.Name, - - // See GetUniques() - Unique = false, + Name = pragmaIndexListItem.Name, + Unique = pragmaIndexListItem.Unique }; + var script = indexCreateScripts.FirstOrDefault(x => x.Contains(pragmaIndexListItem.Name, StringComparison.OrdinalIgnoreCase)); + + if (script != null) + { + if (afterWhereRegex.Match(script) is Match match && match.Success) + { + var andSplitted = Regex.Split(match.Value, " AND "); + + var filterSingleStrings = andSplitted + .Select(x => x.Trim()) + .ToList(); + + foreach (var filterSingleString in filterSingleStrings) + { + var splitted = filterSingleString.Split(' ') + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .ToList(); + + var filterItem = new FilterItem { ColumnName = splitted[0], Filter = _dialect.GetFilterTypeByComparisonString(splitted[1]) }; + + var column = columns.Single(x => x.Name.Equals(splitted[0], StringComparison.OrdinalIgnoreCase)); + + var sqliteIntegerDataTypes = new[] { + MigratorDbType.Int16, + MigratorDbType.Int32, + MigratorDbType.Int64, + MigratorDbType.UInt16, + MigratorDbType.UInt32, + MigratorDbType.UInt64 + }; + + if (sqliteIntegerDataTypes.Contains(column.MigratorDbType)) + { + if (long.TryParse(splitted[2], out var longValue)) + { + filterItem.Value = longValue; + } + else if (ulong.TryParse(splitted[2], out var uLongValue)) + { + filterItem.Value = uLongValue; + } + else + { + throw new Exception(); + } + } + else + { + filterItem.Value = column.MigratorDbType switch + { + MigratorDbType.Boolean => splitted[2] == "1" || splitted[2].Equals("true", StringComparison.OrdinalIgnoreCase), + MigratorDbType.String => splitted[2].Substring(1, splitted[2].Length - 2), + _ => throw new NotImplementedException("Type not yet supported. Please file an issue."), + }; + } + + index.FilterItems.Add(filterItem); + } + } + } + indexes.Add(index); } @@ -1334,6 +1415,73 @@ public override void AddTable(string name, string engine, params IDbField[] fiel } } + public override string AddIndex(string table, Index index) + { + ValidateIndex(table, index); + + var hasIncludedColumns = index.IncludeColumns != null && index.IncludeColumns.Length > 0; + + if (hasIncludedColumns) + { + // This will be actived in the future. + // throw new MigrationException($"SQLite does not support included columns. Use 'if(Provider is {nameof(SQLiteTransformationProvider)}' if necessary."); + } + + if (index.Clustered) + { + throw new MigrationException($"For SQLite this migrator does not support clustered indexes at this point in time, sorry. File an issue if needed. Use 'if(Provider is {nameof(SQLiteTransformationProvider)}' if necessary."); + } + + var name = QuoteConstraintNameIfRequired(index.Name); + table = QuoteTableNameIfRequired(table); + var columns = QuoteColumnNamesIfRequired(index.KeyColumns); + + var uniqueString = index.Unique ? "UNIQUE" : null; + var columnsString = $"({string.Join(", ", columns)})"; + var filterString = string.Empty; + + if (index.FilterItems != null && index.FilterItems.Count > 0) + { + List singleFilterStrings = []; + + foreach (var filterItem in index.FilterItems) + { + var comparisonString = _dialect.GetComparisonStringByFilterType(filterItem.Filter); + + var filterColumnQuoted = QuoteColumnNameIfRequired(filterItem.ColumnName); + string value = null; + + value = filterItem.Value switch + { + bool booleanValue => booleanValue ? "1" : "0", + string stringValue => $"'{stringValue}'", + 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."), + }; + + if ((filterItem.Value is string || filterItem.Value is bool) && filterItem.Filter != FilterType.EqualTo && filterItem.Filter != FilterType.NotEqualTo) + { + throw new MigrationException($"Bool and string in {nameof(FilterItem)} can only be used with '{nameof(FilterType.EqualTo)}' or '{nameof(FilterType.EqualTo)}'."); + } + + var singleFilterString = $"{filterColumnQuoted} {comparisonString} {value}"; + + singleFilterStrings.Add(singleFilterString); + } + + filterString = $"WHERE {string.Join(" AND ", singleFilterStrings)}"; + } + + List list = ["CREATE", uniqueString, "INDEX", name, "ON", table, columnsString, filterString]; + + var sql = string.Join(" ", list.Where(x => !string.IsNullOrWhiteSpace(x))); + + ExecuteNonQuery(sql); + + return sql; + } + protected override string GetPrimaryKeyConstraintName(string table) { throw new NotImplementedException(); @@ -1413,8 +1561,7 @@ public List GetUniques(string tableName) // Here we filter for origin u and unique while in "GetIndexes()" we exclude them. // If "pk" is set then it was added by using a primary key. If so this is handled by "GetColumns()". - // If "c" is set it was created by using CREATE INDEX. At this moment in time this migrator does not support UNIQUE indexes but only normal indexes - // so "u" should never be set 30.06.2025). + // If "c" is set it was created by using CREATE INDEX. var uniqueConstraints = pragmaIndexListItems.Where(x => x.Unique && x.Origin == "u") .ToList(); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index ab41111c..59b63ab5 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -12,6 +12,7 @@ #endregion using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models.Indexes; using System; using System.Collections.Generic; using System.Data; @@ -137,23 +138,80 @@ public override void AddPrimaryKeyNonClustered(string name, string table, params string.Join(",", QuoteColumnNamesIfRequired(columns)))); } - public override void AddIndex(string table, Index index) + public override string AddIndex(string table, Index index) { - var name = QuoteConstraintNameIfRequired(index.Name); + ValidateIndex(tableName: table, index: index); + var hasIncludedColumns = index.IncludeColumns != null && index.IncludeColumns.Length > 0; + var name = QuoteConstraintNameIfRequired(index.Name); table = QuoteTableNameIfRequired(table); - var columns = QuoteColumnNamesIfRequired(index.KeyColumns); - if (index.IncludeColumns != null && index.IncludeColumns.Length > 0) - { - var include = QuoteColumnNamesIfRequired(index.IncludeColumns); - ExecuteNonQuery(string.Format("CREATE {0}{1} INDEX {2} ON {3} ({4}) INCLUDE ({5})", (index.Unique ? "UNIQUE " : ""), (index.Clustered ? "CLUSTERED" : "NONCLUSTERED"), name, table, string.Join(", ", columns), string.Join(", ", include))); - } - else + var uniqueString = index.Unique ? "UNIQUE" : null; + var columnsString = $"({string.Join(", ", columns)})"; + var includeString = hasIncludedColumns ? $"INCLUDE ({string.Join(", ", index.IncludeColumns)})" : null; + var filterString = string.Empty; + var clusteredString = index.Clustered ? "CLUSTERED" : "NONCLUSTERED"; + + if (index.FilterItems != null && index.FilterItems.Count > 0) { - ExecuteNonQuery(string.Format("CREATE {0}{1} INDEX {2} ON {3} ({4})", (index.Unique ? "UNIQUE " : ""), (index.Clustered ? "CLUSTERED" : "NONCLUSTERED"), name, table, string.Join(", ", columns))); + List singleFilterStrings = []; + + foreach (var filterItem in index.FilterItems) + { + var comparisonString = _dialect.GetComparisonStringByFilterType(filterItem.Filter); + + var filterColumnQuoted = QuoteColumnNameIfRequired(filterItem.ColumnName); + string value = null; + + if (filterItem.Value is bool booleanValue) + { + value = booleanValue ? "1" : "0"; + } + else if (filterItem.Value is string stringValue) + { + value = $"'{stringValue}'"; + } + else if (filterItem.Value is byte || filterItem.Value is short || filterItem.Value is int || filterItem.Value is long) + { + value = Convert.ToInt64(filterItem.Value).ToString(); + } + else if (filterItem.Value is sbyte || filterItem.Value is ushort || filterItem.Value is uint || filterItem.Value is ulong) + { + value = Convert.ToUInt64(filterItem.Value).ToString(); + } + else + { + throw new NotImplementedException("Given type is not implemented. Please file an issue."); + } + + var singleFilterString = $"{filterColumnQuoted} {comparisonString} {value}"; + + singleFilterStrings.Add(singleFilterString); + } + + filterString = $"WHERE {string.Join(" AND ", singleFilterStrings)}"; } + + List list = []; + list.Add("CREATE"); + list.Add(uniqueString); + list.Add(clusteredString); + list.Add("INDEX"); + list.Add(name); + list.Add("ON"); + list.Add(table); + list.Add(columnsString); + list.Add(includeString); + list.Add(filterString); + + list = [.. list.Where(x => !string.IsNullOrWhiteSpace(x))]; + + var sql = string.Join(" ", list); + + ExecuteNonQuery(sql); + + return sql; } public override void ChangeColumn(string table, Column column) @@ -222,75 +280,184 @@ public override void RemoveColumnDefaultValue(string table, string column) public override Index[] GetIndexes(string table) { - var retVal = new List(); - - var sql = @"SELECT Tab.[name] AS TableName, - Ind.[name] AS IndexName, - Ind.[type_desc] AS IndexType, - Ind.[is_primary_key] AS IndexPrimary, - Ind.[is_unique] AS IndexUnique, - Ind.[is_unique_constraint] AS ConstraintUnique, - SUBSTRING(( SELECT ',' + AC.name - FROM sys.[tables] AS T - INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id] - INNER JOIN sys.[index_columns] IC ON I.[object_id] = IC.[object_id] - AND I.[index_id] = IC.[index_id] - INNER JOIN sys.[all_columns] AC ON T.[object_id] = AC.[object_id] - AND IC.[column_id] = AC.[column_id] - WHERE Ind.[object_id] = I.[object_id] - AND Ind.index_id = I.index_id - AND IC.is_included_column = 0 - ORDER BY IC.key_ordinal - FOR - XML PATH('') ), 2, 8000) AS KeyCols, - SUBSTRING(( SELECT ',' + AC.name - FROM sys.[tables] AS T - INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id] - INNER JOIN sys.[index_columns] IC ON I.[object_id] = IC.[object_id] - AND I.[index_id] = IC.[index_id] - INNER JOIN sys.[all_columns] AC ON T.[object_id] = AC.[object_id] - AND IC.[column_id] = AC.[column_id] - WHERE Ind.[object_id] = I.[object_id] - AND Ind.index_id = I.index_id - AND IC.is_included_column = 1 - ORDER BY IC.key_ordinal - FOR - XML PATH('') ), 2, 8000) AS IncludeCols -FROM sys.[indexes] Ind - INNER JOIN sys.[tables] AS Tab ON Tab.[object_id] = Ind.[object_id] - WHERE LOWER(Tab.[name]) = LOWER('{0}')"; + // This migrator does not support schemas so we fall back to dbo in SQL Server + var schemaName = "dbo"; + + var indexes = new List(); + + var sql = @$"SELECT + s.name AS SchemaName, + t.name AS TableName, + i.name AS IndexName, + i.type_desc AS IndexType, + i.is_unique AS IsUnique, + i.is_primary_key AS IsPrimaryKey, + i.is_unique_constraint AS IsUniqueConstraint, + ic.index_column_id AS ColumnOrder, + col.name AS ColumnName, + ic.is_descending_key AS IsDescending, + ic.is_included_column AS IsIncludedColumn, + i.has_filter AS IsFilteredIndex, + i.filter_definition AS FilterDefinition + FROM + sys.indexes i + JOIN sys.tables t ON i.object_id = t.object_id + JOIN sys.schemas s ON t.schema_id = s.schema_id + JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns col ON ic.object_id = col.object_id AND ic.column_id = col.column_id + WHERE + LOWER(t.name) = '{table.ToLowerInvariant()}' AND + LOWER(s.name) = '{schemaName.ToLowerInvariant()}' + ORDER BY + s.name, t.name, i.name, ic.index_column_id"; + + List indexItems = []; using (var cmd = CreateCommand()) using (var reader = ExecuteQuery(cmd, string.Format(sql, table))) { + var columnNameOrdinal = reader.GetOrdinal("ColumnName"); + var columnOrderOrdinal = reader.GetOrdinal("ColumnOrder"); + var filterDefinitionOrdinal = reader.GetOrdinal("FilterDefinition"); + var indexNameOrdinal = reader.GetOrdinal("IndexName"); + var indexTypeOrdinal = reader.GetOrdinal("IndexType"); + var isDescendingOrdinal = reader.GetOrdinal("IsDescending"); + var isFilteredIndexOrdinal = reader.GetOrdinal("IsFilteredIndex"); + var isIncludedColumnOrdinal = reader.GetOrdinal("IsIncludedColumn"); + var isPrimaryKeyOrdinal = reader.GetOrdinal("IsPrimaryKey"); + var isUniqueConstraintOrdinal = reader.GetOrdinal("IsUniqueConstraint"); + var isUniqueOrdinal = reader.GetOrdinal("IsUnique"); + var schemaNameOrdinal = reader.GetOrdinal("SchemaName"); + var tableNameOrdinal = reader.GetOrdinal("TableName"); + + + while (reader.Read()) { - if (!reader.IsDBNull(1)) + var indexItem = new IndexItem { - var idx = new Index - { - Name = reader.GetString(1), - Clustered = reader.GetString(2) == "CLUSTERED", - PrimaryKey = reader.GetBoolean(3), - Unique = reader.GetBoolean(4), - UniqueConstraint = reader.GetBoolean(5), - }; + Clustered = reader.GetString(indexTypeOrdinal) == "CLUSTERED", + ColumnName = reader.GetString(columnNameOrdinal), + ColumnOrder = reader.GetInt32(columnOrderOrdinal), + FilterString = !reader.IsDBNull(filterDefinitionOrdinal) ? reader.GetString(filterDefinitionOrdinal) : null, + IsFilteredIndex = reader.GetBoolean(isFilteredIndexOrdinal), + IsIncludedColumn = reader.GetBoolean(isIncludedColumnOrdinal), + Name = reader.GetString(indexNameOrdinal), + PrimaryKey = reader.GetBoolean(isPrimaryKeyOrdinal), + SchemaName = reader.GetString(schemaNameOrdinal), + TableName = reader.GetString(tableNameOrdinal), + Unique = reader.GetBoolean(isUniqueOrdinal), + UniqueConstraint = reader.GetBoolean(isUniqueConstraintOrdinal), + }; + + indexItems.Add(indexItem); + } + } + + var indexGroups = indexItems.GroupBy(x => new + { + x.Name, + x.SchemaName, + x.TableName, + }); + + foreach (var indexGroup in indexGroups) + { + var first = indexGroup.First(); + + List filterItems = []; + + if (!string.IsNullOrWhiteSpace(first.FilterString)) + { + const string unexpectedPatternString = "Unexpected pattern in filter string detected. Not implemented yet - please file an issue"; + var comparisonStrings = _dialect.GetComparisonStrings(); + var stripOuterBracesRegex = new Regex(@"(?<=^\().+(?=\)$)"); + var stripBracesMatch = stripOuterBracesRegex.Match(first.FilterString.Trim()); + + if (!stripBracesMatch.Success) + { + throw new NotImplementedException(unexpectedPatternString); + } - if (!reader.IsDBNull(6)) + var andSplitted = Regex.Split(stripBracesMatch.Value, @" AND (?=\[)") + .Select(x => x.Trim()) + .ToList(); + + var columns = GetColumns(table: table); + + foreach (var andSplittedItem in andSplitted) + { + var filterItem = new FilterItem(); + // We assume nobody uses column names with brackets in it. + var columnRegex = new Regex(@"(?<=^\[)[^\]]+"); + var columnMatch = columnRegex.Match(andSplittedItem); + + if (!columnMatch.Success) { - idx.KeyColumns = reader.GetString(6).Split(','); + throw new NotImplementedException(unexpectedPatternString); } - if (!reader.IsDBNull(7)) + + filterItem.ColumnName = columnMatch.Value; + var column = columns.OrderByDescending(x => x.Name).First(x => x.Name.Equals(filterItem.ColumnName, StringComparison.OrdinalIgnoreCase)); + + var remainingString = andSplittedItem.Substring(filterItem.ColumnName.Length + 2); + var comparisonString = comparisonStrings.OrderByDescending(x => x.Length) + .First(x => remainingString.StartsWith(x)); + + filterItem.Filter = _dialect.GetFilterTypeByComparisonString(comparisonString); + remainingString = remainingString.Substring(comparisonString.Length); + + var valueRegex = new Regex(@"(?<=^[\(|']).+(?=[\)|']$)"); + var valueStringMatch = valueRegex.Match(remainingString); + + if (!valueStringMatch.Success) { - idx.IncludeColumns = reader.GetString(7).Split(','); + throw new NotImplementedException(unexpectedPatternString); } - retVal.Add(idx); + var valueAsString = valueStringMatch.Value; + + filterItem.Value = column.MigratorDbType switch + { + MigratorDbType.Int16 => short.Parse(valueAsString), + MigratorDbType.Int32 => int.Parse(valueAsString), + MigratorDbType.Int64 => long.Parse(valueAsString), + MigratorDbType.UInt16 => ushort.Parse(valueAsString), + MigratorDbType.UInt32 => uint.Parse(valueAsString), + MigratorDbType.UInt64 => ulong.Parse(valueAsString), + MigratorDbType.Decimal => decimal.Parse(valueAsString), + MigratorDbType.Boolean => valueAsString == "1" || valueAsString.Equals("true", StringComparison.OrdinalIgnoreCase), + MigratorDbType.String => valueAsString, + _ => throw new NotImplementedException("Type not yet supported. Please file an issue."), + }; + + filterItems.Add(filterItem); } } - } - return retVal.ToArray(); + var index = new Index + { + Clustered = first.Clustered, + FilterItems = filterItems, + IncludeColumns = [.. indexGroup.Where(x => x.IsIncludedColumn) + .OrderBy(x => x.ColumnOrder) + .Select(x => x.ColumnName) + .Distinct()], + KeyColumns = [.. indexGroup.Where(x => !x.IsIncludedColumn) + .OrderBy(x => x.ColumnOrder) + .Select(x => x.ColumnName) + .Distinct()], + Name = first.Name, + PrimaryKey = first.PrimaryKey, + Unique = first.Unique, + UniqueConstraint = first.UniqueConstraint, + }; + + indexes.Add(index); + + } + + return [.. indexes]; } public override int GetColumnContentSize(string table, string columnName) diff --git a/src/Migrator/Providers/Models/FilterTypeToString.cs b/src/Migrator/Providers/Models/FilterTypeToString.cs new file mode 100644 index 00000000..8b4adb0e --- /dev/null +++ b/src/Migrator/Providers/Models/FilterTypeToString.cs @@ -0,0 +1,19 @@ +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; + +namespace DotNetProjects.Migrator.Providers.Models; + +/// +/// Model for filter type => filter string mapping. +/// +public class FilterTypeToString +{ + /// + /// Gets or sets the filter type + /// + public FilterType FilterType { get; set; } + + /// + /// Gets or sets the filter string like >, <, =, >= etc. + /// + public string FilterString { get; set; } +} \ No newline at end of file diff --git a/src/Migrator/Providers/Models/Indexes/Enums/FilterType.cs b/src/Migrator/Providers/Models/Indexes/Enums/FilterType.cs new file mode 100644 index 00000000..44ad2b5a --- /dev/null +++ b/src/Migrator/Providers/Models/Indexes/Enums/FilterType.cs @@ -0,0 +1,36 @@ +namespace DotNetProjects.Migrator.Providers.Models.Indexes.Enums; + +public enum FilterType +{ + None = 0, + + /// + /// Greater than + /// + GreaterThan, + + /// + /// Greater than or equal to + /// + GreaterThanOrEqualTo, + + /// + /// Equal to + /// + EqualTo, + + /// + /// Smaller than + /// + SmallerThan, + + /// + /// Smaller than or equal to + /// + SmallerThanOrEqualTo, + + /// + /// Not equal to + /// + NotEqualTo +} \ No newline at end of file diff --git a/src/Migrator/Providers/Models/Indexes/FilterItem.cs b/src/Migrator/Providers/Models/Indexes/FilterItem.cs new file mode 100644 index 00000000..6d034146 --- /dev/null +++ b/src/Migrator/Providers/Models/Indexes/FilterItem.cs @@ -0,0 +1,21 @@ +using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; + +namespace DotNetProjects.Migrator.Providers.Models.Indexes; + +public class FilterItem +{ + /// + /// Gets or sets the not quoted column name. If the column name is not a reserved word it will be converted to lower cased string in Postgre and to upper cased string in Oracle if you use the default settings. + /// + public string ColumnName { get; set; } + + /// + /// Gets or sets the filter. + /// + public FilterType Filter { get; set; } + + /// + /// Gets or sets the value used in the comparison. It needs to be a static not dynamic value. Currently we support bool, byte, short, int, long + /// + public object Value { get; set; } +} \ No newline at end of file diff --git a/src/Migrator/Providers/Models/Indexes/IndexItem.cs b/src/Migrator/Providers/Models/Indexes/IndexItem.cs new file mode 100644 index 00000000..73f64d5e --- /dev/null +++ b/src/Migrator/Providers/Models/Indexes/IndexItem.cs @@ -0,0 +1,66 @@ +namespace DotNetProjects.Migrator.Providers.Models.Indexes; + +public class IndexItem +{ + /// + /// Indicates whether the index is clustered (false for NONCLUSTERED). + /// + public bool Clustered { get; set; } + + /// + /// Gets or sets the column order. + /// + public int ColumnOrder { get; set; } + + /// + /// Gets or sets the index name. + /// + public string Name { get; set; } + + /// + /// Indicates whether the index is unique. + /// + public bool Unique { get; set; } + + /// + /// Indicates whether it is a primary key constraint. + /// + public bool PrimaryKey { get; internal set; } + + /// + /// Indicates whether it is a unique constraint. + /// + public bool UniqueConstraint { get; internal set; } + + /// + /// Gets or sets the column name. + /// + public string ColumnName { get; set; } + + /// + /// Gets or sets the included columns. Not supported in SQLite and Oracle. + /// + public bool IsIncludedColumn { get; set; } + + /// + /// Indicates whether the index is a filtered index. + /// + public bool IsFilteredIndex { get; set; } + + /// + /// Gets or sets items that represent filter expressions in filtered indexes. Currently string, integer and boolean values are supported. + /// Attention: In SQL Server the column used in the filter must be NOT NULL. + /// + public string FilterString { get; set; } + + /// + /// Gets or sets the schema name. + /// + public string SchemaName { get; set; } + + /// + /// Gets or sets the table name. + /// + public string TableName { get; set; } + +} \ No newline at end of file diff --git a/src/Migrator/Providers/NoOpTransformationProvider.cs b/src/Migrator/Providers/NoOpTransformationProvider.cs index b8c27e51..8da1b9b3 100644 --- a/src/Migrator/Providers/NoOpTransformationProvider.cs +++ b/src/Migrator/Providers/NoOpTransformationProvider.cs @@ -479,9 +479,11 @@ public void DropDatabases(string databaseName) } - public void AddIndex(string table, Index index) + public string AddIndex(string table, Index index) { + // Don't know what this is for... + return string.Empty; } public void Dispose() @@ -508,9 +510,13 @@ public void RemoveIndex(string table, string name) // No Op } - public void AddIndex(string name, string table, params string[] columns) + public string AddIndex(string name, string table, params string[] columns) { // No Op + + // Don't know what this is for... + + return string.Empty; } public bool IndexExists(string table, string name) diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index a9945917..465af5a5 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -39,7 +39,7 @@ public abstract class TransformationProvider : ITransformationProvider private string _scope; protected readonly string _connectionString; protected readonly string _defaultSchema; - private readonly ForeignKeyConstraintMapper constraintMapper = new ForeignKeyConstraintMapper(); + private readonly ForeignKeyConstraintMapper constraintMapper = new(); protected List _appliedMigrations; protected IDbConnection _connection; protected bool _outsideConnection = false; @@ -884,7 +884,7 @@ public virtual int ExecuteNonQuery(string sql) public virtual int ExecuteNonQuery(string sql, int timeout) { - return this.ExecuteNonQuery(sql, timeout, null); + return ExecuteNonQuery(sql, timeout, null); } public virtual int ExecuteNonQuery(string sql, int timeout, params object[] args) @@ -901,6 +901,7 @@ public virtual int ExecuteNonQuery(string sql, int timeout, params object[] args } using var cmd = BuildCommand(sql); + try { cmd.CommandTimeout = timeout; @@ -925,7 +926,7 @@ public virtual int ExecuteNonQuery(string sql, int timeout, params object[] args catch (Exception ex) { Logger.Warn(ex.Message); - throw new Exception(string.Format("Error occured executing sql: {0}, see inner exception for details, error: " + ex, sql), ex); + throw new MigrationException(string.Format("Error occured executing sql: {0}, see inner exception for details, error: " + ex, sql), ex); } } @@ -1349,7 +1350,7 @@ public virtual int Insert(string table, string[] columns, object[] values) if (columns.Length != values.Length) { - throw new Exception(string.Format("The number of columns: {0} does not match the number of supplied values: {1}", columns.Length, values.Length)); + throw new MigrationException(string.Format("The number of columns: {0} does not match the number of supplied values: {1}", columns.Length, values.Length)); } table = QuoteTableNameIfRequired(table); @@ -2087,20 +2088,16 @@ public virtual void RemoveIndex(string table, string name) } } - public virtual void AddIndex(string table, Index index) + public virtual string AddIndex(string table, Index index) { - AddIndex(index.Name, table, index.KeyColumns); + throw new NotImplementedException($"{nameof(AddIndex)} is not overridden for the provider."); } - public virtual void AddIndex(string name, string table, params string[] columns) + public virtual string AddIndex(string name, string table, params string[] columns) { - name = QuoteConstraintNameIfRequired(name); - - table = QuoteTableNameIfRequired(table); + var index = new Index { Name = name, KeyColumns = columns }; - columns = QuoteColumnNamesIfRequired(columns); - - ExecuteNonQuery(string.Format("CREATE INDEX {0} ON {1} ({2}) ", name, table, string.Join(", ", columns))); + return AddIndex(table, index); } protected string QuoteConstraintNameIfRequired(string name) @@ -2190,5 +2187,43 @@ public IEnumerable GetColumns(string schema, string table) return from DataRow row in tables.Rows select (row["TABLE_NAME"] as string); } + protected void ValidateIndex(string tableName, Index index) + { + var hasFilterItems = index.FilterItems != null && index.FilterItems.Count > 0; + var columns = GetColumns(table: tableName); + + if (!TableExists(tableName)) + { + throw new MigrationException($"Table '{tableName}' does not exist."); + } + + foreach (var keyColumn in index.KeyColumns) + { + if (!index.KeyColumns.All(x => columns.Any(y => y.Name.Equals(x, StringComparison.OrdinalIgnoreCase)))) + { + throw new MigrationException($"Column '{keyColumn}' does not exist."); + } + } + + if (hasFilterItems) + { + if (!index.FilterItems.All(x => index.KeyColumns.Any(y => x.ColumnName.Equals(y, StringComparison.OrdinalIgnoreCase)))) + { + throw new MigrationException($"All columns in the {nameof(index.FilterItems)} should exist in the {nameof(index.KeyColumns)}."); + } + } + + if (IndexExists(tableName, index.Name)) + { + throw new MigrationException($"Index '{index.Name}' in table {tableName} already exists."); + } + if (index.IncludeColumns != null && index.IncludeColumns.Length > 0) + { + if (index.IncludeColumns.Any(x => index.KeyColumns.Any(y => x.Equals(y, StringComparison.OrdinalIgnoreCase)))) + { + throw new MigrationException($"It is not allowed to use a column in {nameof(index.IncludeColumns)} that exist in {nameof(index.KeyColumns)}."); + } + } + } }