diff --git a/docs/_src/content.py b/docs/_src/content.py index 138902ad..fbab22ee 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -324,7 +324,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ ''', ''' migration.Delete.Index("IX_Users_Name").FromTable("Users"); ''')), - section("Provider options", '

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle retains its limited non-unique, key-column expression emulation and cannot read those expressions back as FilterItems; non-key filters and unique filtered indexes use the chosen unsupported behavior. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

'), + section("Provider options", '

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle supports filtered unique indexes through functional expressions: every key column uses CASE WHEN with the complete filter predicate, so non-matching rows produce all-NULL keys and are omitted. Filters may reference non-key columns. Oracle retains its limited non-unique, key-column expression emulation and cannot read functional expressions back as FilterItems. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

'), section("Unique index or unique constraint?", '

Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys.

'), source="src/Migrator/Framework/Index.cs") page("Schema basics", "constraints", "Keys and constraints", "Declare table invariants independently of column attributes.", diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index e901ce84..53fa4cb9 100644 --- a/docs/assets/search-index.json +++ b/docs/assets/search-index.json @@ -81,7 +81,7 @@ "group": "Schema basics", "summary": "An index is a separate schema object, even when it enforces uniqueness.", "url": "guide/indexes.html", - "text": " Use an explicit name so the index can be inspected or removed later. Fluent Create.Index(name).OnTable(table).WithColumns(...) names each part explicitly and preserves column order. Append Unique(), Clustered(), IncludeColumns(...) or WithFilter(...). For an existing Index definition, use Create.Index(definition).OnTable(table); fully qualify the model type if System.Index is also in scope. Index a user name Database.AddIndex(\"Users\", new DotNetProjects.Migrator.Framework.Index\n{\n Name = \"IX_Users_Name\", KeyColumns = new[] { \"Name\" }, Unique = false\n}); migration.Create.Index(\"IX_Users_Name\").OnTable(\"Users\").WithColumns(\"Name\"); Drop an index Database.RemoveIndex(\"Users\", \"IX_Users_Name\"); migration.Delete.Index(\"IX_Users_Name\").FromTable(\"Users\"); Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values. UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures. Oracle retains its limited non-unique, key-column expression emulation and cannot read those expressions back as FilterItems; non-key filters and unique filtered indexes use the chosen unsupported behavior. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses. The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode. Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys. " + "text": " Use an explicit name so the index can be inspected or removed later. Fluent Create.Index(name).OnTable(table).WithColumns(...) names each part explicitly and preserves column order. Append Unique(), Clustered(), IncludeColumns(...) or WithFilter(...). For an existing Index definition, use Create.Index(definition).OnTable(table); fully qualify the model type if System.Index is also in scope. Index a user name Database.AddIndex(\"Users\", new DotNetProjects.Migrator.Framework.Index\n{\n Name = \"IX_Users_Name\", KeyColumns = new[] { \"Name\" }, Unique = false\n}); migration.Create.Index(\"IX_Users_Name\").OnTable(\"Users\").WithColumns(\"Name\"); Drop an index Database.RemoveIndex(\"Users\", \"IX_Users_Name\"); migration.Delete.Index(\"IX_Users_Name\").FromTable(\"Users\"); Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values. UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures. Oracle supports filtered unique indexes through functional expressions: every key column uses CASE WHEN with the complete filter predicate, so non-matching rows produce all-NULL keys and are omitted. Filters may reference non-key columns. Oracle retains its limited non-unique, key-column expression emulation and cannot read functional expressions back as FilterItems. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses. The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode. Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys. " }, { "title": "Keys and constraints", diff --git a/docs/guide/indexes.html b/docs/guide/indexes.html index 05e90c39..009f6369 100644 --- a/docs/guide/indexes.html +++ b/docs/guide/indexes.html @@ -16,4 +16,4 @@

Classic

Database.RemoveIndex("Users", "IX_Users_Name");

Fluent

-
migration.Delete.Index("IX_Users_Name").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle retains its limited non-unique, key-column expression emulation and cannot read those expressions back as FilterItems; non-key filters and unique filtered indexes use the chosen unsupported behavior. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

Unique index or unique constraint?

Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys.

+
migration.Delete.Index("IX_Users_Name").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle supports filtered unique indexes through functional expressions: every key column uses CASE WHEN with the complete filter predicate, so non-matching rows produce all-NULL keys and are omitted. Filters may reference non-key columns. Oracle retains its limited non-unique, key-column expression emulation and cannot read functional expressions back as FilterItems. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

Unique index or unique constraint?

Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys.

diff --git a/src/Migrator.Tests/FilteredIndexTests.cs b/src/Migrator.Tests/FilteredIndexTests.cs index 2ee81485..364f3dc6 100644 --- a/src/Migrator.Tests/FilteredIndexTests.cs +++ b/src/Migrator.Tests/FilteredIndexTests.cs @@ -6,8 +6,8 @@ using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Framework.Fluent; using DotNetProjects.Migrator.Providers; -using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; using DotNetProjects.Migrator.Providers.Impl.SqlServer; using DotNetProjects.Migrator.Providers.Models.Indexes; using DotNetProjects.Migrator.Providers.Models.Indexes.Enums; @@ -23,7 +23,9 @@ public class FilteredIndexTests private static Column[] Columns() => [new("IpaUserIdentifier", DbType.String, 80), new("Archive", DbType.Int32), new("select", DbType.String, 80)]; private static Index Definition() => new() { - Name = "UX_ActiveUsers", Unique = true, KeyColumns = ["IpaUserIdentifier"], + Name = "UX_ActiveUsers", + Unique = true, + KeyColumns = ["IpaUserIdentifier"], FilterItems = [ new() { ColumnName = "IpaUserIdentifier", Filter = FilterType.NotEqualTo, Value = null }, new() { ColumnName = "Archive", Filter = FilterType.EqualTo, Value = 0 }] @@ -89,10 +91,24 @@ public void InvalidFiltersStillFailInIgnoreMode(bool missingColumn) using var provider = new SqlServerProvider(Connection(out var command), new SqlServerDialect()); var index = Definition(); index.UnsupportedFilterBehavior = UnsupportedIndexFilterBehavior.Ignore; - if (missingColumn) index.FilterItems[0].ColumnName = "Missing"; - else index.FilterItems[0].Filter = FilterType.GreaterThan; - if (missingColumn) Assert.Throws(() => provider.AddIndex("Users", index)); - else Assert.Throws(() => provider.AddIndex("Users", index)); + if (missingColumn) + { + index.FilterItems[0].ColumnName = "Missing"; + } + else + { + index.FilterItems[0].Filter = FilterType.GreaterThan; + } + + if (missingColumn) + { + Assert.Throws(() => provider.AddIndex("Users", index)); + } + else + { + Assert.Throws(() => provider.AddIndex("Users", index)); + } + command.DidNotReceive().ExecuteNonQuery(); } @@ -104,7 +120,6 @@ public void InvalidFiltersStillFailInIgnoreMode(bool missingColumn) [TestCase(ProviderTypes.Sybase)] [TestCase(ProviderTypes.Hana)] [TestCase(ProviderTypes.SqlServer2005)] - [TestCase(ProviderTypes.Oracle)] public void UnsupportedFiltersThrowByDefaultAndCanBeIgnoredThroughFluent(ProviderTypes type) { var connection = Connection(out var command); @@ -127,7 +142,11 @@ public void UnsupportedFiltersThrowByDefaultAndCanBeIgnoredThroughFluent(Provide command.ClearReceivedCalls(); index.UnsupportedFilterBehavior = UnsupportedIndexFilterBehavior.Ignore; index.IncludeColumns = ["select"]; - if (type == ProviderTypes.SqlServer2005) return; // INCLUDE is supported by this dialect. + if (type == ProviderTypes.SqlServer2005) + { + return; // INCLUDE is supported by this dialect. + } + Assert.Throws(() => provider.AddIndex("Users", index)); command.DidNotReceive().ExecuteNonQuery(); } @@ -164,6 +183,71 @@ public void OracleEmulationDoesNotMutateCallerKeysOrFilters() command.Received(1).ExecuteNonQuery(); } + [TestCase(false)] + [TestCase(true)] + public void OracleUniqueFiltersApplyTheCompletePredicateToEveryKey(bool fluent) + { + using var provider = new OracleProvider(Connection(out var command)); + var index = Definition(); + index.KeyColumns = ["select", "IpaUserIdentifier"]; + var expected = "CREATE UNIQUE INDEX UX_ActiveUsers ON Users (CASE WHEN IpaUserIdentifier IS NOT NULL AND \"Archive\" = 0 THEN \"select\" ELSE NULL END, " + + "CASE WHEN IpaUserIdentifier IS NOT NULL AND \"Archive\" = 0 THEN IpaUserIdentifier ELSE NULL END)"; + if (fluent) + { + var builder = new MigrationBuilder(); + builder.Create.Index(index).OnTable("Users"); + builder.Apply(provider); + Assert.That(command.CommandText, Is.EqualTo(expected)); + } + else + { + Assert.That(provider.AddIndex("Users", index), Is.EqualTo(expected)); + } + + Assert.That(index.KeyColumns, Is.EqualTo(new[] { "select", "IpaUserIdentifier" })); + Assert.That(index.FilterItems.Count, Is.EqualTo(2)); + command.Received(1).ExecuteNonQuery(); + } + + [TestCase(false)] + [TestCase(true)] + public void OracleUniqueFiltersValidateColumnsAndOperators(bool missingColumn) + { + using var provider = new OracleProvider(Connection(out var command)); + var index = Definition(); + if (missingColumn) + { + index.FilterItems[1].ColumnName = "Missing"; + } + else + { + index.FilterItems[0].Filter = FilterType.GreaterThan; + } + + if (missingColumn) + { + Assert.Throws(() => provider.AddIndex("Users", index)); + } + else + { + Assert.Throws(() => provider.AddIndex("Users", index)); + } + + command.DidNotReceive().ExecuteNonQuery(); + } + + [Test] + public void OracleNonUniqueNonKeyFiltersStillUseTheUnsupportedPolicy() + { + using var provider = new OracleProvider(Connection(out var command)); + var index = Definition(); + index.Unique = false; + Assert.Throws(() => provider.AddIndex("Users", index)); + index.UnsupportedFilterBehavior = UnsupportedIndexFilterBehavior.Ignore; + Assert.That(provider.AddIndex("Users", index), Is.EqualTo("CREATE INDEX UX_ActiveUsers ON Users (IpaUserIdentifier)")); + command.Received(1).ExecuteNonQuery(); + } + [Test] public void FluentCanReuseCatalogDefinitionsWithNullIncludedColumns() { @@ -203,16 +287,26 @@ public void ReadsEscapedStringsWithoutSplittingTheirContents(string sql) public void NullAndDbNullHaveTheSameSql(FilterType type, string expected) { foreach (var value in new[] { null, DBNull.Value }) + { Assert.That(IndexFilterSql.Format(new SqlServerDialect(), new FilterItem { ColumnName = "select", Filter = type, Value = value }, true), Is.EqualTo("[select] " + expected)); + } } [Test] public void SqlServerGetIndexesReturnsAllDefinitionFieldsFromCatalog() { using var data = new DataTable(); - foreach (var name in new[] { "SchemaName", "TableName", "IndexName", "IndexType", "ColumnName", "FilterDefinition" }) data.Columns.Add(name); + foreach (var name in new[] { "SchemaName", "TableName", "IndexName", "IndexType", "ColumnName", "FilterDefinition" }) + { + data.Columns.Add(name); + } + data.Columns.Add("ColumnOrder", typeof(int)); - foreach (var name in new[] { "IsUnique", "IsPrimaryKey", "IsUniqueConstraint", "IsDescending", "IsIncludedColumn", "IsFilteredIndex" }) data.Columns.Add(name, typeof(bool)); + foreach (var name in new[] { "IsUnique", "IsPrimaryKey", "IsUniqueConstraint", "IsDescending", "IsIncludedColumn", "IsFilteredIndex" }) + { + data.Columns.Add(name, typeof(bool)); + } + foreach (var (name, order, included) in new[] { ("Archive", 2, false), ("select", 3, true), ("IpaUserIdentifier", 1, false) }) { var row = data.NewRow(); @@ -233,8 +327,16 @@ public void SqlServerGetIndexesReturnsAllDefinitionFieldsFromCatalog() public void PostgresGetIndexesReturnsAllDefinitionFieldsFromCatalog() { using var data = new DataTable(); - foreach (var name in new[] { "schema_name", "table_name", "index_name", "index_definition", "index_columns", "include_columns", "partial_filter" }) data.Columns.Add(name); - foreach (var name in new[] { "is_unique", "is_clustered", "is_unique_constraint", "is_primary_constraint" }) data.Columns.Add(name, typeof(bool)); + foreach (var name in new[] { "schema_name", "table_name", "index_name", "index_definition", "index_columns", "include_columns", "partial_filter" }) + { + data.Columns.Add(name); + } + + foreach (var name in new[] { "is_unique", "is_clustered", "is_unique_constraint", "is_primary_constraint" }) + { + data.Columns.Add(name, typeof(bool)); + } + var row = data.NewRow(); row["schema_name"] = "audit"; row["table_name"] = "Users"; row["index_name"] = "UX_ActiveUsers"; row["index_definition"] = "CREATE UNIQUE INDEX ..."; @@ -304,17 +406,33 @@ internal static void VerifyRoundTrip(ITransformationProvider provider, bool flue { provider.AddTable("FilteredUsers", Columns()); var definition = Definition(); - if (includeColumns) definition.IncludeColumns = ["select"]; + if (includeColumns) + { + definition.IncludeColumns = ["select"]; + } + if (fluent) { var builder = new MigrationBuilder(); var options = builder.Create.Index(definition.Name).OnTable("FilteredUsers").WithColumns(definition.KeyColumns).Unique() .WithFilter(definition.FilterItems.ToArray()).OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Throw); - if (includeColumns) options.IncludeColumns(definition.IncludeColumns); + if (includeColumns) + { + options.IncludeColumns(definition.IncludeColumns); + } + builder.Apply(provider); } - else provider.AddIndex("FilteredUsers", definition); - if (rebuild) provider.ChangeColumn("FilteredUsers", new Column("select", DbType.String, 120)); + else + { + provider.AddIndex("FilteredUsers", definition); + } + + if (rebuild) + { + provider.ChangeColumn("FilteredUsers", new Column("select", DbType.String, 120)); + } + var actual = provider.GetIndexes("FilteredUsers").Single(i => i.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase)); Assert.That(actual.Name, Is.EqualTo(definition.Name).IgnoreCase); Assert.That(actual.KeyColumns, Is.EqualTo(definition.KeyColumns).IgnoreCase); @@ -338,7 +456,11 @@ internal static void VerifyRoundTrip(ITransformationProvider provider, bool flue builder.Create.Index(actual).OnTable("FilteredUsers"); builder.Apply(provider); } - else provider.AddIndex("FilteredUsers", actual); + else + { + provider.AddIndex("FilteredUsers", actual); + } + provider.Insert("FilteredUsers", ["IpaUserIdentifier", "Archive"], [null, 0]); provider.Insert("FilteredUsers", ["IpaUserIdentifier", "Archive"], [null, 0]); provider.Insert("FilteredUsers", ["IpaUserIdentifier", "Archive"], ["same", 1]); diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs index 1531eca3..55ec232f 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddIndexTests.cs @@ -1,183 +1,224 @@ -using System; -using System.Collections.Generic; -using System.Data; -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 +using System; +using System.Collections.Generic; +using System.Data; +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's 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)); + } + + /// + /// A filtered unique index enforces uniqueness only for rows matching the complete predicate. + /// + [Test] + public void AddIndex_FilterItemsCombinedWithUnique_Success() + { + // Arrange + const string tableName = "TestTable"; + const string columnName1 = "TestColumn1"; + const string indexName = "TestIndexName"; + + Provider.AddTable(tableName, + new Column(columnName1, DbType.Int16), + new Column("Archive", DbType.Int64) + ); + + List filterItems = [ + new() { Filter = FilterType.NotEqualTo, ColumnName = columnName1, Value = null }, + new() { Filter = FilterType.EqualTo, ColumnName = "Archive", Value = 0 }, + ]; + + Provider.AddIndex(tableName, + new Index + { + Name = indexName, + KeyColumns = [ + columnName1 + ], + Unique = true, + FilterItems = filterItems + }); + + Provider.Insert(tableName, [columnName1, "Archive"], [null, 0L]); + Provider.Insert(tableName, [columnName1, "Archive"], [null, 0L]); + Provider.Insert(tableName, [columnName1, "Archive"], [1, 1L]); + Provider.Insert(tableName, [columnName1, "Archive"], [1, 1L]); + Provider.Insert(tableName, [columnName1, "Archive"], [1, 0L]); + var duplicate = Assert.Throws(() => Provider.Insert(tableName, [columnName1, "Archive"], [1, 0L])); + Assert.That(duplicate.Number, Is.EqualTo(1)); + Assert.That(Provider.GetIndexes(tableName).Single().Unique, Is.True); + } + + [Test] + public void AddIndex_FilteredCompositeUniqueIndex_ExcludesEveryNonMatchingRow() + { + Provider.AddTable("FilteredUsers", new Column("Code", DbType.String, 80), new Column("Tenant", DbType.Int32), + new Column("Archive", DbType.Int64), new Column("Enabled", DbType.Boolean)); + Provider.AddIndex("FilteredUsers", new Index + { + Name = "UX_FilteredUsers", + KeyColumns = ["Code", "Tenant"], + Unique = true, + FilterItems = + [ + new() { ColumnName = "Archive", Filter = FilterType.EqualTo, Value = 0 }, + new() { ColumnName = "Enabled", Filter = FilterType.EqualTo, Value = true } + ] + }); + + void Insert(string code, int tenant, object archive, bool enabled) => + Provider.Insert("FilteredUsers", ["Code", "Tenant", "Archive", "Enabled"], [code, tenant, archive, enabled]); + Insert("same", 1, 0L, true); + Insert("same", 2, 0L, true); + foreach (var archive in new object[] { 1L, null }) + { + Insert("same", 1, archive, true); + Insert("same", 1, archive, true); + } + Insert("same", 1, 0L, false); + Insert("same", 1, 0L, false); + var duplicate = Assert.Throws(() => Insert("same", 1, 0L, true)); + Assert.That(duplicate.Number, Is.EqualTo(1)); + } +} diff --git a/src/Migrator/Framework/Index.cs b/src/Migrator/Framework/Index.cs index de4f26f7..905f0d5b 100644 --- a/src/Migrator/Framework/Index.cs +++ b/src/Migrator/Framework/Index.cs @@ -1,47 +1,48 @@ -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; } - - /// - /// 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. - /// Filter columns need not be key columns on SQL Server, PostgreSQL and SQLite. +using System.Collections.Generic; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Models.Indexes; + +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; } + + /// + /// 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. + /// Filter columns need not be key columns on SQL Server, PostgreSQL, SQLite, or Oracle unique indexes. + /// Oracle unique indexes apply the complete predicate to every key through functional expressions. /// EqualTo/NotEqualTo with null or DBNull.Value generate IS NULL/IS NOT NULL. - /// - public List FilterItems { get; set; } = []; + /// + public List FilterItems { get; set; } = []; /// /// Controls unsupported filters. The default is to throw. Ignore creates an unfiltered @@ -49,4 +50,4 @@ public class Index : IDbField /// This option does not suppress invalid definitions or database errors. /// public UnsupportedIndexFilterBehavior UnsupportedFilterBehavior { get; set; } = UnsupportedIndexFilterBehavior.Throw; -} +} diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index e16f39d6..aaf54fa8 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -1,418 +1,449 @@ -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.Models; -using DotNetProjects.Migrator.Providers.Impl.Oracle.Data; -using DotNetProjects.Migrator.Providers.Impl.Oracle.Data.Interfaces; -using DotNetProjects.Migrator.Providers.Impl.Oracle.Interfaces; -using DotNetProjects.Migrator.Providers.Impl.Oracle.Models; -using DotNetProjects.Migrator.Providers.Models.Indexes; -using System; -using System.Collections.Generic; -using System.Data; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; -using Index = DotNetProjects.Migrator.Framework.Index; - -namespace DotNetProjects.Migrator.Providers.Impl.Oracle; - -public class OracleTransformationProvider : TransformationProvider, IOracleTransformationProvider -{ - private IOracleSystemDataLoader _oracleSystemDataLoader; - - public const string TemporaryColumnName = "TEMPCOL"; - - public OracleTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) - : base(dialect, connectionString, defaultSchema, scope) - { - CreateConnection(providerName); - Initialize(); - } - - public OracleTransformationProvider(Dialect dialect, IDbConnection connection, string defaultSchema, string scope, string providerName) - : base(dialect, connection, defaultSchema, scope) - { - Initialize(); - } - - protected virtual void CreateConnection(string providerName) - { - if (string.IsNullOrEmpty(providerName)) - { - providerName = "Oracle.DataAccess.Client"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); // new OracleConnection(); - _connection.ConnectionString = _connectionString; - _connection.Open(); - } - - public override void DropDatabases(string databaseName) - { - if (string.IsNullOrEmpty(databaseName)) - { - ExecuteNonQuery(string.Format("DROP DATABASE")); - } - } - - public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) => - ForeignKeyMetadataReader.Read(this, table); - - public override void AddForeignKey(string name, string primaryTable, string[] primaryColumns, string refTable, - string[] refColumns, ForeignKeyConstraintType constraint) - { - GuardAgainstMaximumIdentifierLengthForOracle(name); - - AddForeignKey(name, primaryTable, primaryColumns, refTable, refColumns, constraint, ForeignKeyConstraintType.NoAction); - } - - public override string AddIndex(string table, Index index) - { - var hasFilterItems = ShouldApplyIndexFilters(index, - supported: !index.Unique && (index.FilterItems == null || index.FilterItems.All(f => - index.KeyColumns.Any(c => c.Equals(f.ColumnName, StringComparison.OrdinalIgnoreCase)))), - reason: "Oracle filter emulation requires a non-unique index and filters on key columns only."); - ValidateIndex(table, index, validateFilters: hasFilterItems); - - if (index.IncludeColumns?.Length > 0 || index.Clustered) - throw new NotSupportedException("Oracle does not support included columns or SQL Server-style clustered indexes. Use an explicit Oracle operation."); - - var relation = CatalogRelation(table, true); - var name = (relation.Schema == null ? "" : _dialect.QuoteIdentifier(relation.Schema) + ".") + QuoteConstraintNameIfRequired(index.Name); - table = QuoteTableNameIfRequired(table); - - var keyColumns = index.KeyColumns; - List singleFilterStrings = []; - if (hasFilterItems) - { - keyColumns = keyColumns.Where(c => !index.FilterItems.Any(f => c.Equals(f.ColumnName, StringComparison.OrdinalIgnoreCase))).ToArray(); - foreach (var filter in index.FilterItems) - singleFilterStrings.Add($"CASE WHEN {IndexFilterSql.Format(_dialect, filter, numericBooleans: false)} THEN {QuoteColumnNameIfRequired(filter.ColumnName)} ELSE NULL END"); - } - - var mixedColumnNamesAndFilters = QuoteColumnNamesIfRequired(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); - - if (utf8Bytes.Length > 128) - { - throw new MigrationException($"The name '{name}' is {utf8Bytes.Length} bytes in length, but maximum length for Oracle identifiers is 128 bytes for Oracle versions 12.1+."); - } - } - - protected override string GetPrimaryKeyname(string tableName) - { - return tableName.Length > 27 ? "PK_" + tableName.Substring(0, 27) : "PK_" + tableName; - } - - public override void ChangeColumn(string table, Column column) - { - var existing = GetColumnByName(table, column.Name); - var definition = column.CopyDefinition(); - if (definition.DefaultValue == null) RemoveColumnDefaultValue(table, definition.Name); - // Oracle rejects restating an existing NOT NULL constraint. Render type/default - // separately and change nullability only when its value actually changes. - definition.IsNullable = true; - var mapper = _dialect.GetAndMapColumnProperties(definition); - var sql = mapper.ColumnSql; - if (sql.EndsWith(" NULL", StringComparison.Ordinal)) sql = sql[..^5]; - if (existing.IsNullable != column.IsNullable) - sql += column.IsNullable ? " NULL" : " NOT NULL"; - ChangeColumn(table, sql); - } - - private void CopyDataFromOneColumnToAnother(string table, string fromColumn, string toColumn) - { - table = QuoteTableNameIfRequired(table); - fromColumn = QuoteColumnNameIfRequired(fromColumn); - toColumn = QuoteColumnNameIfRequired(toColumn); - - ExecuteNonQuery(string.Format("UPDATE {0} SET {1} = {2}", table, toColumn, fromColumn)); - } - - public override void RenameTable(string oldName, string newName) - { - var oldRelation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(oldName), true); - var newRelation = SqlIdentifier.Catalog(_dialect.QuoteTableNameIfRequired(newName), true); - if (newRelation.Schema != null && newRelation.Schema != oldRelation.Schema) - throw new NotSupportedException("Oracle RENAME does not move a table between schemas."); - GuardAgainstMaximumIdentifierLengthForOracle(newRelation.Name); - var target = (oldRelation.Schema == null ? "" : _dialect.QuoteIdentifier(oldRelation.Schema) + ".") + _dialect.QuoteIdentifier(newRelation.Name); - GuardAgainstExistingTableWithSameName(target, oldName); - oldName = QuoteTableNameIfRequired(oldName); - newName = _dialect.QuoteIdentifier(newRelation.Name); - - ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME TO {1}", oldName, newName)); - } - - private void GuardAgainstExistingTableWithSameName(string newName, string oldName) - { - if (TableExists(newName)) - { - throw new MigrationException(string.Format("Can not rename table \"{0}\" to \"{1}\", a table with that name already exists", oldName, newName)); - } - } - - public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) - { - GuardAgainstMaximumIdentifierLengthForOracle(newColumnName); - GuardAgainstExistingColumnWithSameName(newColumnName, tableName); - - tableName = QuoteTableNameIfRequired(tableName); - oldColumnName = QuoteColumnNameIfRequired(oldColumnName); - newColumnName = QuoteColumnNameIfRequired(newColumnName); - - ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME COLUMN {1} TO {2}", tableName, oldColumnName, newColumnName)); - } - - private void GuardAgainstExistingColumnWithSameName(string newColumnName, string tableName) - { - if (ColumnExists(tableName, newColumnName)) - { - throw new MigrationException(string.Format("A column with the name \"{0}\" already exists in the table \"{1}\"", newColumnName, tableName)); - } - } - - public override void ChangeColumn(string table, string sqlColumn) - { - if (string.IsNullOrEmpty(table)) - { - throw new ArgumentNullException(nameof(table)); - } - - if (string.IsNullOrEmpty(table)) - { - throw new ArgumentNullException(nameof(sqlColumn)); - } - - table = QuoteTableNameIfRequired(table); - +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Models; +using DotNetProjects.Migrator.Providers.Impl.Oracle.Data; +using DotNetProjects.Migrator.Providers.Impl.Oracle.Data.Interfaces; +using DotNetProjects.Migrator.Providers.Impl.Oracle.Interfaces; +using DotNetProjects.Migrator.Providers.Impl.Oracle.Models; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace DotNetProjects.Migrator.Providers.Impl.Oracle; + +public class OracleTransformationProvider : TransformationProvider, IOracleTransformationProvider +{ + private IOracleSystemDataLoader _oracleSystemDataLoader; + + public const string TemporaryColumnName = "TEMPCOL"; + + public OracleTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) + : base(dialect, connectionString, defaultSchema, scope) + { + CreateConnection(providerName); + Initialize(); + } + + public OracleTransformationProvider(Dialect dialect, IDbConnection connection, string defaultSchema, string scope, string providerName) + : base(dialect, connection, defaultSchema, scope) + { + Initialize(); + } + + protected virtual void CreateConnection(string providerName) + { + if (string.IsNullOrEmpty(providerName)) + { + providerName = "Oracle.DataAccess.Client"; + } + + var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); + _connection = fac.CreateConnection(); // new OracleConnection(); + _connection.ConnectionString = _connectionString; + _connection.Open(); + } + + public override void DropDatabases(string databaseName) + { + if (string.IsNullOrEmpty(databaseName)) + { + ExecuteNonQuery(string.Format("DROP DATABASE")); + } + } + + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) => + ForeignKeyMetadataReader.Read(this, table); + + public override void AddForeignKey(string name, string primaryTable, string[] primaryColumns, string refTable, + string[] refColumns, ForeignKeyConstraintType constraint) + { + GuardAgainstMaximumIdentifierLengthForOracle(name); + + AddForeignKey(name, primaryTable, primaryColumns, refTable, refColumns, constraint, ForeignKeyConstraintType.NoAction); + } + + public override string AddIndex(string table, Index index) + { + var hasFilterItems = ShouldApplyIndexFilters(index, + supported: index.Unique || (index.FilterItems == null || index.FilterItems.All(f => + index.KeyColumns.Any(c => c.Equals(f.ColumnName, StringComparison.OrdinalIgnoreCase)))), + reason: "Oracle non-unique filter emulation requires filters on key columns only."); + ValidateIndex(table, index, validateFilters: hasFilterItems); + + if (index.IncludeColumns?.Length > 0 || index.Clustered) + { + throw new NotSupportedException("Oracle does not support included columns or SQL Server-style clustered indexes. Use an explicit Oracle operation."); + } + + var relation = CatalogRelation(table, true); + var name = (relation.Schema == null ? "" : _dialect.QuoteIdentifier(relation.Schema) + ".") + QuoteConstraintNameIfRequired(index.Name); + table = QuoteTableNameIfRequired(table); + + var keyColumns = index.KeyColumns; + List singleFilterStrings = []; + if (hasFilterItems && index.Unique) + { + // Every key must use the complete predicate: excluded rows then have all-NULL keys, + // while included rows retain the original composite uniqueness and key order. + var predicate = string.Join(" AND ", index.FilterItems.Select(filter => IndexFilterSql.Format(_dialect, filter, numericBooleans: true))); + foreach (var keyColumn in keyColumns) + { + singleFilterStrings.Add($"CASE WHEN {predicate} THEN {QuoteColumnNameIfRequired(keyColumn)} ELSE NULL END"); + } + + keyColumns = []; + } + else if (hasFilterItems) + { + keyColumns = keyColumns.Where(c => !index.FilterItems.Any(f => c.Equals(f.ColumnName, StringComparison.OrdinalIgnoreCase))).ToArray(); + foreach (var filter in index.FilterItems) + { + singleFilterStrings.Add($"CASE WHEN {IndexFilterSql.Format(_dialect, filter, numericBooleans: false)} THEN {QuoteColumnNameIfRequired(filter.ColumnName)} ELSE NULL END"); + } + } + + var mixedColumnNamesAndFilters = QuoteColumnNamesIfRequired(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); + + if (utf8Bytes.Length > 128) + { + throw new MigrationException($"The name '{name}' is {utf8Bytes.Length} bytes in length, but maximum length for Oracle identifiers is 128 bytes for Oracle versions 12.1+."); + } + } + + protected override string GetPrimaryKeyname(string tableName) + { + return tableName.Length > 27 ? "PK_" + tableName.Substring(0, 27) : "PK_" + tableName; + } + + public override void ChangeColumn(string table, Column column) + { + var existing = GetColumnByName(table, column.Name); + var definition = column.CopyDefinition(); + if (definition.DefaultValue == null) + { + RemoveColumnDefaultValue(table, definition.Name); + } + // Oracle rejects restating an existing NOT NULL constraint. Render type/default + // separately and change nullability only when its value actually changes. + definition.IsNullable = true; + var mapper = _dialect.GetAndMapColumnProperties(definition); + var sql = mapper.ColumnSql; + if (sql.EndsWith(" NULL", StringComparison.Ordinal)) + { + sql = sql[..^5]; + } + + if (existing.IsNullable != column.IsNullable) + { + sql += column.IsNullable ? " NULL" : " NOT NULL"; + } + + ChangeColumn(table, sql); + } + + private void CopyDataFromOneColumnToAnother(string table, string fromColumn, string toColumn) + { + table = QuoteTableNameIfRequired(table); + fromColumn = QuoteColumnNameIfRequired(fromColumn); + toColumn = QuoteColumnNameIfRequired(toColumn); + + ExecuteNonQuery(string.Format("UPDATE {0} SET {1} = {2}", table, toColumn, fromColumn)); + } + + public override void RenameTable(string oldName, string newName) + { + var oldRelation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(oldName), true); + var newRelation = SqlIdentifier.Catalog(_dialect.QuoteTableNameIfRequired(newName), true); + if (newRelation.Schema != null && newRelation.Schema != oldRelation.Schema) + { + throw new NotSupportedException("Oracle RENAME does not move a table between schemas."); + } + + GuardAgainstMaximumIdentifierLengthForOracle(newRelation.Name); + var target = (oldRelation.Schema == null ? "" : _dialect.QuoteIdentifier(oldRelation.Schema) + ".") + _dialect.QuoteIdentifier(newRelation.Name); + GuardAgainstExistingTableWithSameName(target, oldName); + oldName = QuoteTableNameIfRequired(oldName); + newName = _dialect.QuoteIdentifier(newRelation.Name); + + ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME TO {1}", oldName, newName)); + } + + private void GuardAgainstExistingTableWithSameName(string newName, string oldName) + { + if (TableExists(newName)) + { + throw new MigrationException(string.Format("Can not rename table \"{0}\" to \"{1}\", a table with that name already exists", oldName, newName)); + } + } + + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) + { + GuardAgainstMaximumIdentifierLengthForOracle(newColumnName); + GuardAgainstExistingColumnWithSameName(newColumnName, tableName); + + tableName = QuoteTableNameIfRequired(tableName); + oldColumnName = QuoteColumnNameIfRequired(oldColumnName); + newColumnName = QuoteColumnNameIfRequired(newColumnName); + + ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME COLUMN {1} TO {2}", tableName, oldColumnName, newColumnName)); + } + + private void GuardAgainstExistingColumnWithSameName(string newColumnName, string tableName) + { + if (ColumnExists(tableName, newColumnName)) + { + throw new MigrationException(string.Format("A column with the name \"{0}\" already exists in the table \"{1}\"", newColumnName, tableName)); + } + } + + public override void ChangeColumn(string table, string sqlColumn) + { + if (string.IsNullOrEmpty(table)) + { + throw new ArgumentNullException(nameof(table)); + } + + if (string.IsNullOrEmpty(table)) + { + throw new ArgumentNullException(nameof(sqlColumn)); + } + + table = QuoteTableNameIfRequired(table); + ExecuteNonQuery(string.Format("ALTER TABLE {0} MODIFY ({1})", table, sqlColumn)); - } - - public override void AddColumn(string table, string sqlColumn) - { - foreach (var part in SqlIdentifier.Parse(table)) GuardAgainstMaximumIdentifierLengthForOracle(part.Value); - table = QuoteTableNameIfRequired(table); - - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); - } - - public override string[] GetConstraints(string table) => ExecuteStringQuery( - "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE " + OracleCatalog.Predicate(this, table)).ToArray(); - - protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( - "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE CONSTRAINT_TYPE='P' AND " + OracleCatalog.Predicate(this, table)).FirstOrDefault(); - - public override bool ConstraintExists(string table, string name) => - GetConstraints(table).Any(actual => actual == name || actual == name.ToUpperInvariant()); - - public override bool ColumnExists(string table, string column) => Convert.ToInt32(ExecuteScalar( - "SELECT COUNT(*) FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + - " AND COLUMN_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteColumnNameIfRequired(column), true).Name))) > 0; - - public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - "SELECT COUNT(*) FROM ALL_TABLES WHERE " + OracleCatalog.Predicate(this, table))) > 0; - - public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - "SELECT COUNT(*) FROM ALL_VIEWS WHERE " + OracleCatalog.Predicate(this, view, "VIEW_NAME"))) > 0; - - public override List GetDatabases() - { - throw new NotImplementedException(); - } - - public override string[] GetTables() => base.GetTables(); - - public override Column[] GetColumns(string table) - { - var stringBuilder = new StringBuilder(); - stringBuilder.AppendLine("SELECT"); - stringBuilder.AppendLine(" COLUMN_NAME,"); - stringBuilder.AppendLine(" NULLABLE,"); - stringBuilder.AppendLine(" DATA_DEFAULT,"); - stringBuilder.AppendLine(" DATA_TYPE,"); - stringBuilder.AppendLine(" DATA_LENGTH,"); - stringBuilder.AppendLine(" DATA_PRECISION,"); - stringBuilder.AppendLine(" DATA_SCALE,"); - stringBuilder.AppendLine(" CHAR_COL_DECL_LENGTH"); - stringBuilder.AppendLine("FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + " ORDER BY COLUMN_ID"); - - var stringBuilder2 = new StringBuilder(); - stringBuilder2.AppendLine("SELECT x.column_name, x.data_default"); - stringBuilder2.AppendLine("FROM XMLTABLE("); - stringBuilder2.AppendLine(" '/ROWSET/ROW'"); - stringBuilder2.AppendLine(" PASSING DBMS_XMLGEN.GETXMLTYPE("); - var defaultQuery = "SELECT column_name, data_default FROM all_tab_columns WHERE " + OracleCatalog.Predicate(this, table); - stringBuilder2.AppendLine(" " + OracleCatalog.Literal(defaultQuery)); - stringBuilder2.AppendLine(" )"); - stringBuilder2.AppendLine(" COLUMNS"); - stringBuilder2.AppendLine(" column_name VARCHAR2(4000) PATH 'COLUMN_NAME',"); - stringBuilder2.AppendLine(" data_default VARCHAR2(4000) PATH 'DATA_DEFAULT'"); - stringBuilder2.AppendLine(") x"); - - var userTabIdentityCols = _oracleSystemDataLoader.GetUserTabIdentityCols(tableName: table); - var primaryKeyItems = _oracleSystemDataLoader.GetPrimaryKeyItems(tableName: table); - - - List userTabColumns = []; - - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, stringBuilder2.ToString())) - { - while (reader.Read()) - { - var columnNameOrdinal = reader.GetOrdinal("COLUMN_NAME"); - var dataDefaultOrdinal = reader.GetOrdinal("DATA_DEFAULT"); - - var userTabColumnsItem = new UserTabColumns - { - ColumnName = reader.IsDBNull(columnNameOrdinal) ? null : reader.GetString(columnNameOrdinal), - DataDefault = reader.IsDBNull(dataDefaultOrdinal) ? null : reader.GetString(dataDefaultOrdinal).Trim() - }; - - userTabColumns.Add(userTabColumnsItem); - } - } - - var columns = new List(); - - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, stringBuilder.ToString())) - { - while (reader.Read()) - { - var columnNameOrdinal = reader.GetOrdinal("COLUMN_NAME"); - var nullableOrdinal = reader.GetOrdinal("NULLABLE"); - var dataTypeOrdinal = reader.GetOrdinal("DATA_TYPE"); - var dataLengthOrdinal = reader.GetOrdinal("DATA_LENGTH"); - var dataPrecisionOrdinal = reader.GetOrdinal("DATA_PRECISION"); - var dataScaleOrdinal = reader.GetOrdinal("DATA_SCALE"); - var charColDeclLengthOrdinal = reader.GetOrdinal("CHAR_COL_DECL_LENGTH"); - - var columnName = reader.GetString(columnNameOrdinal); - var isNullable = reader.GetString(nullableOrdinal) == "Y"; - var dataTypeString = reader.GetString(dataTypeOrdinal).ToUpperInvariant(); - var dataLength = reader.IsDBNull(dataLengthOrdinal) ? (int?)null : reader.GetInt32(dataLengthOrdinal); - var dataPrecision = reader.IsDBNull(dataPrecisionOrdinal) ? (int?)null : reader.GetInt32(dataPrecisionOrdinal); - var dataScale = reader.IsDBNull(dataScaleOrdinal) ? (int?)null : reader.GetInt32(dataScaleOrdinal); - var charColDeclLength = reader.IsDBNull(charColDeclLengthOrdinal) ? (int?)null : reader.GetInt32(charColDeclLengthOrdinal); - var dataDefaultString = userTabColumns.FirstOrDefault(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase))?.DataDefault; - - var column = new Column(columnName, DbType.String) - { - IsNullable = isNullable - }; - - var isIdentity = userTabIdentityCols.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); - var isPrimaryKey = primaryKeyItems.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); - - if (isIdentity && isPrimaryKey) - { - column.IsIdentity = true; - } - else if (isIdentity) - { - column.IsIdentity = true; - } - else if (isPrimaryKey) - { - - } - - // Oracle does not have unsigned types. All NUMBER types can hold positive or negative values so we do not return DbType.UIntX types. - if (dataTypeString.StartsWith("NUMBER") || dataTypeString.StartsWith("FLOAT")) - { - column.Precision = dataPrecision; - - if (dataScale > 0) - { - // Could also be Double - column.MigratorDbType = MigratorDbType.Decimal; - column.Scale = dataScale; - } - else - { - if (dataPrecision.HasValue && dataPrecision == 1) - { - column.MigratorDbType = MigratorDbType.Boolean; - } - else if (dataPrecision.HasValue && (dataPrecision == 0 || (2 <= dataPrecision && dataPrecision <= 5))) - { - column.MigratorDbType = MigratorDbType.Int16; - } - else if (dataPrecision.HasValue && 6 <= dataPrecision && dataPrecision <= 10) - { - column.MigratorDbType = MigratorDbType.Int32; - } - else if (dataPrecision == null || 11 <= dataPrecision) - { - // Oracle allows up to 38 digits but in C# the maximum is Int64 and in Oracle there is no unsigned data type. - column.MigratorDbType = MigratorDbType.Int64; - } - else - { - throw new NotSupportedException(); - } - } - } - else if (dataTypeString.StartsWith("TIMESTAMP")) - { - var timestampNumberRegex = new Regex(@"(?<=^Timestamp\()[\d]+(?=\)$)", RegexOptions.IgnoreCase); - var timestampNumberMatch = timestampNumberRegex.Match(dataTypeString); - - if (timestampNumberMatch.Success) - { - // n in TIMESTAMP(n) is not retrievable using system tables so we need to extract it via regex. - column.Precision = int.Parse(timestampNumberMatch.Value); - column.MigratorDbType = column.Precision < 3 ? MigratorDbType.DateTime : MigratorDbType.DateTime2; - } - else - { - // 6 is the standard if we use TIMESTAMP without n like in TIMESTAMP(n) - column.Precision = 6; - column.MigratorDbType = MigratorDbType.DateTime2; - } - } - else if (dataTypeString == "DATE") - { - column.MigratorDbType = MigratorDbType.Date; - } - else if (dataTypeString == "RAW" && dataLength == 16) - { - // ambiguity - cannot distinguish between guid and binary - column.MigratorDbType = MigratorDbType.Guid; - } - else if (dataTypeString.StartsWith("RAW") || dataTypeString == "BLOB") - { - column.MigratorDbType = MigratorDbType.Binary; - } + } + + public override void AddColumn(string table, string sqlColumn) + { + foreach (var part in SqlIdentifier.Parse(table)) + { + GuardAgainstMaximumIdentifierLengthForOracle(part.Value); + } + + table = QuoteTableNameIfRequired(table); + + ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); + } + + public override string[] GetConstraints(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE " + OracleCatalog.Predicate(this, table)).ToArray(); + + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE CONSTRAINT_TYPE='P' AND " + OracleCatalog.Predicate(this, table)).FirstOrDefault(); + + public override bool ConstraintExists(string table, string name) => + GetConstraints(table).Any(actual => actual == name || actual == name.ToUpperInvariant()); + + public override bool ColumnExists(string table, string column) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + + " AND COLUMN_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteColumnNameIfRequired(column), true).Name))) > 0; + + public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_TABLES WHERE " + OracleCatalog.Predicate(this, table))) > 0; + + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_VIEWS WHERE " + OracleCatalog.Predicate(this, view, "VIEW_NAME"))) > 0; + + public override List GetDatabases() + { + throw new NotImplementedException(); + } + + public override string[] GetTables() => base.GetTables(); + + public override Column[] GetColumns(string table) + { + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine("SELECT"); + stringBuilder.AppendLine(" COLUMN_NAME,"); + stringBuilder.AppendLine(" NULLABLE,"); + stringBuilder.AppendLine(" DATA_DEFAULT,"); + stringBuilder.AppendLine(" DATA_TYPE,"); + stringBuilder.AppendLine(" DATA_LENGTH,"); + stringBuilder.AppendLine(" DATA_PRECISION,"); + stringBuilder.AppendLine(" DATA_SCALE,"); + stringBuilder.AppendLine(" CHAR_COL_DECL_LENGTH"); + stringBuilder.AppendLine("FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + " ORDER BY COLUMN_ID"); + + var stringBuilder2 = new StringBuilder(); + stringBuilder2.AppendLine("SELECT x.column_name, x.data_default"); + stringBuilder2.AppendLine("FROM XMLTABLE("); + stringBuilder2.AppendLine(" '/ROWSET/ROW'"); + stringBuilder2.AppendLine(" PASSING DBMS_XMLGEN.GETXMLTYPE("); + var defaultQuery = "SELECT column_name, data_default FROM all_tab_columns WHERE " + OracleCatalog.Predicate(this, table); + stringBuilder2.AppendLine(" " + OracleCatalog.Literal(defaultQuery)); + stringBuilder2.AppendLine(" )"); + stringBuilder2.AppendLine(" COLUMNS"); + stringBuilder2.AppendLine(" column_name VARCHAR2(4000) PATH 'COLUMN_NAME',"); + stringBuilder2.AppendLine(" data_default VARCHAR2(4000) PATH 'DATA_DEFAULT'"); + stringBuilder2.AppendLine(") x"); + + var userTabIdentityCols = _oracleSystemDataLoader.GetUserTabIdentityCols(tableName: table); + var primaryKeyItems = _oracleSystemDataLoader.GetPrimaryKeyItems(tableName: table); + + + List userTabColumns = []; + + using (var cmd = CreateCommand()) + using (var reader = ExecuteQuery(cmd, stringBuilder2.ToString())) + { + while (reader.Read()) + { + var columnNameOrdinal = reader.GetOrdinal("COLUMN_NAME"); + var dataDefaultOrdinal = reader.GetOrdinal("DATA_DEFAULT"); + + var userTabColumnsItem = new UserTabColumns + { + ColumnName = reader.IsDBNull(columnNameOrdinal) ? null : reader.GetString(columnNameOrdinal), + DataDefault = reader.IsDBNull(dataDefaultOrdinal) ? null : reader.GetString(dataDefaultOrdinal).Trim() + }; + + userTabColumns.Add(userTabColumnsItem); + } + } + + var columns = new List(); + + using (var cmd = CreateCommand()) + using (var reader = ExecuteQuery(cmd, stringBuilder.ToString())) + { + while (reader.Read()) + { + var columnNameOrdinal = reader.GetOrdinal("COLUMN_NAME"); + var nullableOrdinal = reader.GetOrdinal("NULLABLE"); + var dataTypeOrdinal = reader.GetOrdinal("DATA_TYPE"); + var dataLengthOrdinal = reader.GetOrdinal("DATA_LENGTH"); + var dataPrecisionOrdinal = reader.GetOrdinal("DATA_PRECISION"); + var dataScaleOrdinal = reader.GetOrdinal("DATA_SCALE"); + var charColDeclLengthOrdinal = reader.GetOrdinal("CHAR_COL_DECL_LENGTH"); + + var columnName = reader.GetString(columnNameOrdinal); + var isNullable = reader.GetString(nullableOrdinal) == "Y"; + var dataTypeString = reader.GetString(dataTypeOrdinal).ToUpperInvariant(); + var dataLength = reader.IsDBNull(dataLengthOrdinal) ? (int?)null : reader.GetInt32(dataLengthOrdinal); + var dataPrecision = reader.IsDBNull(dataPrecisionOrdinal) ? (int?)null : reader.GetInt32(dataPrecisionOrdinal); + var dataScale = reader.IsDBNull(dataScaleOrdinal) ? (int?)null : reader.GetInt32(dataScaleOrdinal); + var charColDeclLength = reader.IsDBNull(charColDeclLengthOrdinal) ? (int?)null : reader.GetInt32(charColDeclLengthOrdinal); + var dataDefaultString = userTabColumns.FirstOrDefault(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase))?.DataDefault; + + var column = new Column(columnName, DbType.String) + { + IsNullable = isNullable + }; + + var isIdentity = userTabIdentityCols.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + var isPrimaryKey = primaryKeyItems.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + + if (isIdentity && isPrimaryKey) + { + column.IsIdentity = true; + } + else if (isIdentity) + { + column.IsIdentity = true; + } + else if (isPrimaryKey) + { + + } + + // Oracle does not have unsigned types. All NUMBER types can hold positive or negative values so we do not return DbType.UIntX types. + if (dataTypeString.StartsWith("NUMBER") || dataTypeString.StartsWith("FLOAT")) + { + column.Precision = dataPrecision; + + if (dataScale > 0) + { + // Could also be Double + column.MigratorDbType = MigratorDbType.Decimal; + column.Scale = dataScale; + } + else + { + if (dataPrecision.HasValue && dataPrecision == 1) + { + column.MigratorDbType = MigratorDbType.Boolean; + } + else if (dataPrecision.HasValue && (dataPrecision == 0 || (2 <= dataPrecision && dataPrecision <= 5))) + { + column.MigratorDbType = MigratorDbType.Int16; + } + else if (dataPrecision.HasValue && 6 <= dataPrecision && dataPrecision <= 10) + { + column.MigratorDbType = MigratorDbType.Int32; + } + else if (dataPrecision == null || 11 <= dataPrecision) + { + // Oracle allows up to 38 digits but in C# the maximum is Int64 and in Oracle there is no unsigned data type. + column.MigratorDbType = MigratorDbType.Int64; + } + else + { + throw new NotSupportedException(); + } + } + } + else if (dataTypeString.StartsWith("TIMESTAMP")) + { + var timestampNumberRegex = new Regex(@"(?<=^Timestamp\()[\d]+(?=\)$)", RegexOptions.IgnoreCase); + var timestampNumberMatch = timestampNumberRegex.Match(dataTypeString); + + if (timestampNumberMatch.Success) + { + // n in TIMESTAMP(n) is not retrievable using system tables so we need to extract it via regex. + column.Precision = int.Parse(timestampNumberMatch.Value); + column.MigratorDbType = column.Precision < 3 ? MigratorDbType.DateTime : MigratorDbType.DateTime2; + } + else + { + // 6 is the standard if we use TIMESTAMP without n like in TIMESTAMP(n) + column.Precision = 6; + column.MigratorDbType = MigratorDbType.DateTime2; + } + } + else if (dataTypeString == "DATE") + { + column.MigratorDbType = MigratorDbType.Date; + } + else if (dataTypeString == "RAW" && dataLength == 16) + { + // ambiguity - cannot distinguish between guid and binary + column.MigratorDbType = MigratorDbType.Guid; + } + else if (dataTypeString.StartsWith("RAW") || dataTypeString == "BLOB") + { + column.MigratorDbType = MigratorDbType.Binary; + } else if (dataTypeString == "NVARCHAR2") { column.MigratorDbType = MigratorDbType.String; @@ -429,57 +460,65 @@ public override Column[] GetColumns(string table) { column.MigratorDbType = MigratorDbType.StringFixedLength; } - else if (dataTypeString == "BINARY_FLOAT") - { - column.MigratorDbType = MigratorDbType.Single; - } - else if (dataTypeString == "BINARY_DOUBLE") - { - column.MigratorDbType = MigratorDbType.Double; - } - else if (dataTypeString == "BOOLEAN") - { - column.MigratorDbType = MigratorDbType.Boolean; - } - else if (dataTypeString == "NCLOB") - { - column.MigratorDbType = MigratorDbType.String; - } - else if (dataTypeString.StartsWith("INTERVAL")) - { - column.MigratorDbType = MigratorDbType.Interval; - } - else - { - throw new NotImplementedException($"The data type '{dataTypeString}' is not implemented yet. Please file an issue."); - } - - if (dataTypeString is "CLOB" or "NCLOB" or "BLOB") column.Size = int.MaxValue; + else if (dataTypeString == "BINARY_FLOAT") + { + column.MigratorDbType = MigratorDbType.Single; + } + else if (dataTypeString == "BINARY_DOUBLE") + { + column.MigratorDbType = MigratorDbType.Double; + } + else if (dataTypeString == "BOOLEAN") + { + column.MigratorDbType = MigratorDbType.Boolean; + } + else if (dataTypeString == "NCLOB") + { + column.MigratorDbType = MigratorDbType.String; + } + else if (dataTypeString.StartsWith("INTERVAL")) + { + column.MigratorDbType = MigratorDbType.Interval; + } + else + { + throw new NotImplementedException($"The data type '{dataTypeString}' is not implemented yet. Please file an issue."); + } + + if (dataTypeString is "CLOB" or "NCLOB" or "BLOB") + { + column.Size = int.MaxValue; + } else if (dataTypeString is "VARCHAR2" or "NVARCHAR2" or "CHAR" or "NCHAR") + { column.Size = charColDeclLength ?? dataLength ?? 0; - else if (dataTypeString == "RAW") column.Size = dataLength ?? 0; + } + else if (dataTypeString == "RAW") + { + column.Size = dataLength ?? 0; + } OracleColumnDefault.Apply(column, dataDefaultString); - - columns.Add(column); - } - } - - return columns.ToArray(); - } - - public override string GenerateParameterNameParameter(int index) - { - return "p" + index; - } - - public override string GenerateParameterName(int index) - { - return ":p" + index; - } - - protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) - { + + columns.Add(column); + } + } + + return columns.ToArray(); + } + + public override string GenerateParameterNameParameter(int index) + { + return "p" + index; + } + + public override string GenerateParameterName(int index) + { + return ":p" + index; + } + + protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) + { if (value is float single) { base.ConfigureParameterWithValue(parameter, index, value); @@ -489,7 +528,9 @@ protected override void ConfigureParameterWithValue(IDbDataParameter parameter, var oracleType = parameter.GetType().GetProperty("OracleDbType"); if (oracleType?.CanWrite == true && oracleType.PropertyType.IsEnum && Enum.IsDefined(oracleType.PropertyType, "BinaryFloat")) + { oracleType.SetValue(parameter, Enum.Parse(oracleType.PropertyType, "BinaryFloat")); + } else { parameter.DbType = DbType.Double; @@ -497,285 +538,310 @@ protected override void ConfigureParameterWithValue(IDbDataParameter parameter, } } else if (value is TimeOnly time) - { - parameter.DbType = DbType.Date; - parameter.Value = OracleDialect.TimeValue(time); - } - else if (value is TimeSpan interval) - { - // ODP.NET infers IntervalDS from a TimeSpan value. - parameter.Value = interval; - } - else if (value is Guid || value is Guid?) - { - parameter.DbType = DbType.Binary; - - if (value is Guid? && !((Guid?)value).HasValue) - { - return; - } - - parameter.Value = ((Guid)value).ToByteArray(); - } - else if (value is bool || value is bool?) - { - parameter.DbType = DbType.Int32; - parameter.Value = ((bool)value) ? 1 : 0; - } - else if (value is ushort) - { - parameter.DbType = DbType.Decimal; - parameter.Value = value; - } - else if (value is uint) - { - parameter.DbType = DbType.Decimal; - parameter.Value = value; - } - else if (value is ulong) - { - parameter.DbType = DbType.Decimal; - parameter.Value = value; - } - else - { - base.ConfigureParameterWithValue(parameter, index, value); - } - } - - public override void CopyDataFromTableToTable(string sourceTableName, List sourceColumnNames, string targetTableName, List targetColumnNames, List orderBySourceColumns = null) - { - orderBySourceColumns ??= []; - - if (!TableExists(sourceTableName)) - { - throw new Exception($"Source table '{QuoteTableNameIfRequired(sourceTableName)}' does not exist"); - } - - if (!TableExists(targetTableName)) - { - throw new Exception($"Target table '{QuoteTableNameIfRequired(targetTableName)}' does not exist"); - } - - var sourceColumnsConcatenated = sourceColumnNames.Concat(orderBySourceColumns); - - foreach (var column in sourceColumnsConcatenated) - { - if (!ColumnExists(sourceTableName, column)) - { - throw new Exception($"Column {column} in source table does not exist."); - } - } - - foreach (var column in targetColumnNames) - { - if (!ColumnExists(targetTableName, column)) - { - throw new Exception($"Column {column} in target table does not exist."); - } - } - - if (!orderBySourceColumns.All(x => sourceColumnNames.Contains(x))) - { - throw new Exception($"All columns in {nameof(orderBySourceColumns)} must be in {nameof(sourceColumnNames)}"); - } - - var sourceTableNameQuoted = QuoteTableNameIfRequired(sourceTableName); - var targetTableNameQuoted = QuoteTableNameIfRequired(targetTableName); - - var sourceColumnNamesQuoted = sourceColumnNames.Select(QuoteColumnNameIfRequired).ToList(); - var targetColumnNamesQuoted = targetColumnNames.Select(QuoteColumnNameIfRequired).ToList(); - var orderBySourceColumnsQuoted = orderBySourceColumns.Select(QuoteColumnNameIfRequired).ToList(); - - var sourceColumnsJoined = string.Join(", ", sourceColumnNamesQuoted); - var targetColumnsJoined = string.Join(", ", targetColumnNamesQuoted); - var orderBySourceColumnsJoined = string.Join(", ", orderBySourceColumnsQuoted); - - var orderByComponent = !string.IsNullOrWhiteSpace(orderBySourceColumnsJoined) ? $"ORDER BY {orderBySourceColumnsJoined}" : null; - - List sqlComponents = - [ - $"INSERT INTO {targetTableNameQuoted} ({targetColumnsJoined}) SELECT {sourceColumnsJoined} FROM {sourceTableNameQuoted}", - orderByComponent - ]; - - var sql = string.Join(" ", sqlComponents.Where(x => x != null)); - ExecuteNonQuery(sql); - } - - public override void RemoveColumnDefaultValue(string table, string column) - { + { + parameter.DbType = DbType.Date; + parameter.Value = OracleDialect.TimeValue(time); + } + else if (value is TimeSpan interval) + { + // ODP.NET infers IntervalDS from a TimeSpan value. + parameter.Value = interval; + } + else if (value is Guid || value is Guid?) + { + parameter.DbType = DbType.Binary; + + if (value is Guid? && !((Guid?)value).HasValue) + { + return; + } + + parameter.Value = ((Guid)value).ToByteArray(); + } + else if (value is bool || value is bool?) + { + parameter.DbType = DbType.Int32; + parameter.Value = ((bool)value) ? 1 : 0; + } + else if (value is ushort) + { + parameter.DbType = DbType.Decimal; + parameter.Value = value; + } + else if (value is uint) + { + parameter.DbType = DbType.Decimal; + parameter.Value = value; + } + else if (value is ulong) + { + parameter.DbType = DbType.Decimal; + parameter.Value = value; + } + else + { + base.ConfigureParameterWithValue(parameter, index, value); + } + } + + public override void CopyDataFromTableToTable(string sourceTableName, List sourceColumnNames, string targetTableName, List targetColumnNames, List orderBySourceColumns = null) + { + orderBySourceColumns ??= []; + + if (!TableExists(sourceTableName)) + { + throw new Exception($"Source table '{QuoteTableNameIfRequired(sourceTableName)}' does not exist"); + } + + if (!TableExists(targetTableName)) + { + throw new Exception($"Target table '{QuoteTableNameIfRequired(targetTableName)}' does not exist"); + } + + var sourceColumnsConcatenated = sourceColumnNames.Concat(orderBySourceColumns); + + foreach (var column in sourceColumnsConcatenated) + { + if (!ColumnExists(sourceTableName, column)) + { + throw new Exception($"Column {column} in source table does not exist."); + } + } + + foreach (var column in targetColumnNames) + { + if (!ColumnExists(targetTableName, column)) + { + throw new Exception($"Column {column} in target table does not exist."); + } + } + + if (!orderBySourceColumns.All(x => sourceColumnNames.Contains(x))) + { + throw new Exception($"All columns in {nameof(orderBySourceColumns)} must be in {nameof(sourceColumnNames)}"); + } + + var sourceTableNameQuoted = QuoteTableNameIfRequired(sourceTableName); + var targetTableNameQuoted = QuoteTableNameIfRequired(targetTableName); + + var sourceColumnNamesQuoted = sourceColumnNames.Select(QuoteColumnNameIfRequired).ToList(); + var targetColumnNamesQuoted = targetColumnNames.Select(QuoteColumnNameIfRequired).ToList(); + var orderBySourceColumnsQuoted = orderBySourceColumns.Select(QuoteColumnNameIfRequired).ToList(); + + var sourceColumnsJoined = string.Join(", ", sourceColumnNamesQuoted); + var targetColumnsJoined = string.Join(", ", targetColumnNamesQuoted); + var orderBySourceColumnsJoined = string.Join(", ", orderBySourceColumnsQuoted); + + var orderByComponent = !string.IsNullOrWhiteSpace(orderBySourceColumnsJoined) ? $"ORDER BY {orderBySourceColumnsJoined}" : null; + + List sqlComponents = + [ + $"INSERT INTO {targetTableNameQuoted} ({targetColumnsJoined}) SELECT {sourceColumnsJoined} FROM {sourceTableNameQuoted}", + orderByComponent + ]; + + var sql = string.Join(" ", sqlComponents.Where(x => x != null)); + ExecuteNonQuery(sql); + } + + public override void RemoveColumnDefaultValue(string table, string column) + { var sql = string.Format("ALTER TABLE {0} MODIFY ({1} DEFAULT NULL)", QuoteTableNameIfRequired(table), QuoteColumnNameIfRequired(column)); - ExecuteNonQuery(sql); - } - - public override void AddTable(string name, params IDbField[] fields) - { - foreach (var part in SqlIdentifier.Parse(name)) GuardAgainstMaximumIdentifierLengthForOracle(part.Value); - var columns = fields.OfType().ToArray(); - GuardAgainstMaximumColumnNameLengthForOracle(name, columns); - foreach (var identity in columns.Where(c => c.IsIdentity)) - if (identity.Type is not (DbType.Int16 or DbType.Int32 or DbType.Int64 or DbType.UInt16 or DbType.UInt32 or DbType.UInt64)) - throw new MigrationException("Oracle identity columns require an integer type."); - base.AddTable(name, fields); - } - - public override void RemoveTable(string name) - { - // Oracle drops table-owned triggers and native identity sequences itself. - // A legacy-looking sequence name is not evidence of ownership. - base.RemoveTable(name); - } - - /// Drop a table and explicitly identified, unquoted legacy sequence names. - /// The caller must own these sequences. Oracle DDL is not transactional. - public void RemoveTableWithOwnedSequences(string name, params string[] ownedSequenceNames) - { - ArgumentNullException.ThrowIfNull(ownedSequenceNames); - var sequences = ownedSequenceNames.Select(sequence => - { - GuardAgainstMaximumIdentifierLengthForOracle(sequence); - if (!System.Text.RegularExpressions.Regex.IsMatch(sequence, @"^[A-Za-z][A-Za-z0-9_$#]*$")) - throw new ArgumentException("Legacy sequence cleanup requires simple unquoted sequence names.", nameof(ownedSequenceNames)); - return sequence.ToUpperInvariant(); - }).Distinct(StringComparer.Ordinal).ToArray(); - foreach (var sequence in sequences) - { - using var command = CreateCommand(); - command.CommandText = "SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME = :sequenceName"; - var parameter = command.CreateParameter(); parameter.ParameterName = "sequenceName"; parameter.Value = sequence; - command.Parameters.Add(parameter); - if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Owned legacy sequence was not found: " + sequence); - } - if (!TableExists(name)) throw new MigrationException("Table was not found: " + name); - base.RemoveTable(name); - foreach (var sequence in sequences) ExecuteNonQuery("DROP SEQUENCE " + _dialect.Quote(sequence)); - } - - private void GuardAgainstMaximumColumnNameLengthForOracle(string name, Column[] columns) - { - foreach (var column in columns) GuardAgainstMaximumIdentifierLengthForOracle(column.Name); - } - - public override string Encode(Guid guid) - { - var bytes = guid.ToByteArray(); - var hex = new StringBuilder(bytes.Length * 2); - foreach (var b in bytes) - { - hex.AppendFormat("{0:X2}", b); - } - - return hex.ToString(); - } - - public override bool IndexExists(string table, string name) - { - var sql = "SELECT COUNT(*) FROM ALL_INDEXES WHERE " + OracleCatalog.Predicate(this, table, "TABLE_NAME", "TABLE_OWNER") - + " AND INDEX_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteConstraintNameIfRequired(name), true).Name); - return Convert.ToInt32(ExecuteScalar(sql)) == 1; - } - - public override void UpdateTargetFromSource(string tableSourceNotQuoted, string tableTargetNotQuoted, ColumnPair[] fromSourceToTargetColumnPairs, ColumnPair[] conditionColumnPairs) - { - if (!TableExists(tableSourceNotQuoted)) - { - throw new Exception($"Table '{tableSourceNotQuoted}' given in '{nameof(tableSourceNotQuoted)}' does not exist"); - } - - if (!TableExists(tableTargetNotQuoted)) - { - throw new Exception($"Table '{tableTargetNotQuoted}' given in '{nameof(tableTargetNotQuoted)}' does not exist"); - } - - if (fromSourceToTargetColumnPairs.Length == 0) - { - throw new Exception($"{nameof(fromSourceToTargetColumnPairs)} is empty."); - } - - if (fromSourceToTargetColumnPairs.Any(x => string.IsNullOrWhiteSpace(x.ColumnNameSource) || string.IsNullOrWhiteSpace(x.ColumnNameTarget))) - { - throw new Exception($"One of the strings in {nameof(fromSourceToTargetColumnPairs)} is null or empty"); - } - - if (conditionColumnPairs.Length == 0) - { - throw new Exception($"{nameof(conditionColumnPairs)} is empty."); - } - - if (conditionColumnPairs.Any(x => string.IsNullOrWhiteSpace(x.ColumnNameSource) || string.IsNullOrWhiteSpace(x.ColumnNameTarget))) - { - throw new Exception($"One of the strings in {nameof(conditionColumnPairs)} is null or empty"); - } - - var tableNameSource = QuoteTableNameIfRequired(tableSourceNotQuoted); - var tableNameTarget = QuoteTableNameIfRequired(tableTargetNotQuoted); - - var conditionStrings = conditionColumnPairs.Select(x => $"t.{QuoteColumnNameIfRequired(x.ColumnNameTarget)} = s.{QuoteColumnNameIfRequired(x.ColumnNameSource)}"); - - var assignStrings = fromSourceToTargetColumnPairs.Select(x => $"{QuoteColumnNameIfRequired(x.ColumnNameTarget)} = s.{QuoteColumnNameIfRequired(x.ColumnNameSource)}").ToList(); - - var conditionStringsJoined = string.Join(" AND ", conditionStrings); - var assignStringsJoined = string.Join(", ", assignStrings); - - var sql = $"MERGE INTO {tableNameTarget} t USING {tableNameSource} s ON ({conditionStringsJoined}) WHEN MATCHED THEN UPDATE SET {assignStringsJoined}"; - ExecuteNonQuery(sql); - } - - private string SchemaInfoTableName - { - get - { - if (_defaultSchema == null) - { - return "SchemaInfo"; - } - - return string.Format("{0}.{1}", _defaultSchema, "SchemaInfo"); - } - } - - public override Index[] GetIndexes(string table) - { - var indexItems = _oracleSystemDataLoader.GetIndexItems(table); - - var indexGroups = indexItems.GroupBy(x => new { x.SchemaName, x.TableName, x.Name }); - List indexes = []; - - foreach (var indexGroup in indexGroups) - { - var first = indexGroup.First(); - - var index = new Index - { - 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(); - } - - public override string Concatenate(params string[] strings) - { - return string.Join(" || ", strings); - } - - private void Initialize() - { - _oracleSystemDataLoader = new OracleSystemDataLoader(this); - } -} + ExecuteNonQuery(sql); + } + + public override void AddTable(string name, params IDbField[] fields) + { + foreach (var part in SqlIdentifier.Parse(name)) + { + GuardAgainstMaximumIdentifierLengthForOracle(part.Value); + } + + var columns = fields.OfType().ToArray(); + GuardAgainstMaximumColumnNameLengthForOracle(columns); + foreach (var identity in columns.Where(c => c.IsIdentity)) + { + if (identity.Type is not (DbType.Int16 or DbType.Int32 or DbType.Int64 or DbType.UInt16 or DbType.UInt32 or DbType.UInt64)) + { + throw new MigrationException("Oracle identity columns require an integer type."); + } + } + + base.AddTable(name, fields); + } + + public override void RemoveTable(string name) + { + // Oracle drops table-owned triggers and native identity sequences itself. + // A legacy-looking sequence name is not evidence of ownership. + base.RemoveTable(name); + } + + /// Drop a table and explicitly identified, unquoted legacy sequence names. + /// The caller must own these sequences. Oracle DDL is not transactional. + public void RemoveTableWithOwnedSequences(string name, params string[] ownedSequenceNames) + { + ArgumentNullException.ThrowIfNull(ownedSequenceNames); + var sequences = ownedSequenceNames.Select(sequence => + { + GuardAgainstMaximumIdentifierLengthForOracle(sequence); + if (!System.Text.RegularExpressions.Regex.IsMatch(sequence, @"^[A-Za-z][A-Za-z0-9_$#]*$")) + { + throw new ArgumentException("Legacy sequence cleanup requires simple unquoted sequence names.", nameof(ownedSequenceNames)); + } + + return sequence.ToUpperInvariant(); + }).Distinct(StringComparer.Ordinal).ToArray(); + foreach (var sequence in sequences) + { + using var command = CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME = :sequenceName"; + var parameter = command.CreateParameter(); parameter.ParameterName = "sequenceName"; parameter.Value = sequence; + command.Parameters.Add(parameter); + if (Convert.ToInt32(command.ExecuteScalar()) != 1) + { + throw new MigrationException("Owned legacy sequence was not found: " + sequence); + } + } + if (!TableExists(name)) + { + throw new MigrationException("Table was not found: " + name); + } + + base.RemoveTable(name); + foreach (var sequence in sequences) + { + ExecuteNonQuery("DROP SEQUENCE " + _dialect.Quote(sequence)); + } + } + + private void GuardAgainstMaximumColumnNameLengthForOracle(Column[] columns) + { + foreach (var column in columns) + { + GuardAgainstMaximumIdentifierLengthForOracle(column.Name); + } + } + + public override string Encode(Guid guid) + { + var bytes = guid.ToByteArray(); + var hex = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + hex.AppendFormat("{0:X2}", b); + } + + return hex.ToString(); + } + + public override bool IndexExists(string table, string name) + { + var sql = "SELECT COUNT(*) FROM ALL_INDEXES WHERE " + OracleCatalog.Predicate(this, table, "TABLE_NAME", "TABLE_OWNER") + + " AND INDEX_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteConstraintNameIfRequired(name), true).Name); + return Convert.ToInt32(ExecuteScalar(sql)) == 1; + } + + public override void UpdateTargetFromSource(string tableSourceNotQuoted, string tableTargetNotQuoted, ColumnPair[] fromSourceToTargetColumnPairs, ColumnPair[] conditionColumnPairs) + { + if (!TableExists(tableSourceNotQuoted)) + { + throw new Exception($"Table '{tableSourceNotQuoted}' given in '{nameof(tableSourceNotQuoted)}' does not exist"); + } + + if (!TableExists(tableTargetNotQuoted)) + { + throw new Exception($"Table '{tableTargetNotQuoted}' given in '{nameof(tableTargetNotQuoted)}' does not exist"); + } + + if (fromSourceToTargetColumnPairs.Length == 0) + { + throw new Exception($"{nameof(fromSourceToTargetColumnPairs)} is empty."); + } + + if (fromSourceToTargetColumnPairs.Any(x => string.IsNullOrWhiteSpace(x.ColumnNameSource) || string.IsNullOrWhiteSpace(x.ColumnNameTarget))) + { + throw new Exception($"One of the strings in {nameof(fromSourceToTargetColumnPairs)} is null or empty"); + } + + if (conditionColumnPairs.Length == 0) + { + throw new Exception($"{nameof(conditionColumnPairs)} is empty."); + } + + if (conditionColumnPairs.Any(x => string.IsNullOrWhiteSpace(x.ColumnNameSource) || string.IsNullOrWhiteSpace(x.ColumnNameTarget))) + { + throw new Exception($"One of the strings in {nameof(conditionColumnPairs)} is null or empty"); + } + + var tableNameSource = QuoteTableNameIfRequired(tableSourceNotQuoted); + var tableNameTarget = QuoteTableNameIfRequired(tableTargetNotQuoted); + + var conditionStrings = conditionColumnPairs.Select(x => $"t.{QuoteColumnNameIfRequired(x.ColumnNameTarget)} = s.{QuoteColumnNameIfRequired(x.ColumnNameSource)}"); + + var assignStrings = fromSourceToTargetColumnPairs.Select(x => $"{QuoteColumnNameIfRequired(x.ColumnNameTarget)} = s.{QuoteColumnNameIfRequired(x.ColumnNameSource)}").ToList(); + + var conditionStringsJoined = string.Join(" AND ", conditionStrings); + var assignStringsJoined = string.Join(", ", assignStrings); + + var sql = $"MERGE INTO {tableNameTarget} t USING {tableNameSource} s ON ({conditionStringsJoined}) WHEN MATCHED THEN UPDATE SET {assignStringsJoined}"; + ExecuteNonQuery(sql); + } + + private string SchemaInfoTableName + { + get + { + if (_defaultSchema == null) + { + return "SchemaInfo"; + } + + return string.Format("{0}.{1}", _defaultSchema, "SchemaInfo"); + } + } + + public override Index[] GetIndexes(string table) + { + var indexItems = _oracleSystemDataLoader.GetIndexItems(table); + + var indexGroups = indexItems.GroupBy(x => new { x.SchemaName, x.TableName, x.Name }); + List indexes = []; + + foreach (var indexGroup in indexGroups) + { + var first = indexGroup.First(); + + var index = new Index + { + 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(); + } + + public override string Concatenate(params string[] strings) + { + return string.Join(" || ", strings); + } + + private void Initialize() + { + _oracleSystemDataLoader = new OracleSystemDataLoader(this); + } +}