From 3ad8243758edf811fb28dbad260273de77a43871 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 19:21:11 +0200 Subject: [PATCH 1/6] Fix namespace qualification across transformation providers Resolve qualified table names consistently in native catalogs and honor the factory defaultSchema for every provider. Scope table enumeration to the selected namespace instead of hardcoding public/current-user or assuming ADO.NET GetSchema restriction positions. Fix MySQL/MariaDB and Db2 catalogs, Informix/Sybase owner qualification, SQL Server column lookups and renames, Oracle/HANA index namespaces, and namespace-preserving renames. Add native Ingres table/view/column and constraint/index-existence queries. Explicitly reject namespaces for the Firebird 5 provider. Keep SQLite attached-database metadata, rebuild temporary tables, indexes, triggers and AUTOINCREMENT sequence state in the original namespace. Scope dependent-table scans and check FK integrity across attached databases; reject cross-database SQLite foreign keys before creating a table. Add contracts for all 16 public provider selectors, live lifecycle cases in four naming modes for all 11 CI engines, seven-engine duplicate-name isolation cases, and SQLite/MonoSQLite attached-database regression tests. Reuse the existing disposable-provider fixture and document engine/verification limits. Validation: 1,231 unit/SQLite tests passed locally, zero failures or skips. Server database validation runs in PR CI; Docker is unavailable locally. Ingres has no configured live engine job and retains its pre-existing unsupported structured index/FK metadata operations. Refs #48 --- docs/namespace-support.md | 59 +++++++ .../MySqlMetadataContractTests.cs | 22 +-- .../NamespaceProviderContractTests.cs | 81 +++++++++ .../Providers/Live/DataBoundaryTests.cs | 74 +------- .../Providers/Live/LiveProviderFixture.cs | 91 ++++++++++ .../Providers/Live/NamespaceIsolationTests.cs | 88 ++++++++++ .../Providers/Live/NamespaceLifecycleTests.cs | 130 ++++++++++++++ src/Migrator.Tests/SQLiteNamespaceTests.cs | 69 ++++++++ .../SchemaCatalogContractTests.cs | 36 ++-- .../Providers/ConstraintMetadataReader.cs | 7 +- src/Migrator/Providers/Dialect.cs | 10 +- .../Providers/ForeignKeyMetadataReader.cs | 11 +- .../Impl/DB2/DB2TransformationProvider.cs | 32 ++-- .../FirebirdTransformationProvider.cs | 14 +- .../Impl/Hana/HanaTransformationProvider.cs | 21 +-- .../InformixTransformationProvider.cs | 47 +++-- .../Ingres/IngresTransformationProvider.cs | 49 +++++- .../Impl/Mysql/MySqlTransformationProvider.cs | 165 +++++------------- .../Oracle/OracleTransformationProvider.cs | 19 +- .../PostgreSQLTransformationProvider.cs | 14 +- .../Providers/Impl/SQLite/SQLiteTableSql.cs | 6 +- .../SQLite/SQLiteTransformationProvider.cs | 119 +++++++------ .../SqlServerTransformationProvider.cs | 46 +---- .../Sybase/SybaseTransformationProvider.cs | 51 +++--- src/Migrator/Providers/NamespaceCatalog.cs | 45 +++++ .../Providers/TransformationProvider.cs | 104 ++++++----- 26 files changed, 918 insertions(+), 492 deletions(-) create mode 100644 docs/namespace-support.md create mode 100644 src/Migrator.Tests/NamespaceProviderContractTests.cs create mode 100644 src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs create mode 100644 src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs create mode 100644 src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs create mode 100644 src/Migrator.Tests/SQLiteNamespaceTests.cs create mode 100644 src/Migrator/Providers/NamespaceCatalog.cs diff --git a/docs/namespace-support.md b/docs/namespace-support.md new file mode 100644 index 00000000..1a2217a8 --- /dev/null +++ b/docs/namespace-support.md @@ -0,0 +1,59 @@ +# Namespace qualification + +A table argument can be unqualified (Orders) or qualified (sales.Orders). +ProviderFactory.Create with defaultSchema applies the namespace to unqualified +names only. Explicit qualification wins. Quote each identifier component +separately when it contains dots, spaces or reserved words. + +GetTables() enumerates the configured namespace, or the connection's current +namespace when none is configured. GetTables(schema) explicitly selects one +namespace. GetColumns(schema, table) uses the same native catalog path as +GetColumns(qualifiedTable), rather than assuming an ADO.NET driver's GetSchema +restriction layout. Table enumeration returns local names. + +| Provider | Namespace model | +| --- | --- | +| SQL Server / SQL Server 2005 | Database schema; metadata follows qualified object identity | +| PostgreSQL / PostgreSQL 8.2 selector | Database schema; unqualified lookup follows the search path | +| Oracle / MsOracle | Schema owner; unquoted names fold to uppercase | +| Db2 | Schema; unquoted names fold to uppercase | +| Informix | Object owner; unquoted names fold to lowercase | +| Sybase ASE | Object owner; enable QUOTED_IDENTIFIER for quoted SQL identifiers | +| HANA | Database schema; preserves the provider's case-sensitive quoted-name convention | +| MySQL / MariaDB | Database (SCHEMA is a synonym for DATABASE) | +| SQLite / MonoSQLite | main, temp, or a connection-local ATTACH alias | +| Firebird | The provider targets Firebird 5: namespaces are explicitly rejected, including configured defaults | +| Ingres | Owner-qualified DDL and native table/view/column/constraint/index-existence catalogs; the legacy provider still lacks structured index/FK metadata and live qualification infrastructure | + +Renaming keeps the source namespace when the new name is unqualified. It is not +a portable API for moving tables between namespaces. SQLite foreign keys cannot +reference another attached database; that request fails before table creation. +SQLite reconstruction keeps its temporary table, indexes, triggers and +AUTOINCREMENT sequence in the original database and checks foreign keys across +attached databases. + +## Tests and evidence + +- NamespaceProviderContractTests exercises every public provider selector, + including aliases and Ingres, with unqualified, qualified and default namespace + DDL, table/view lookup and enumeration. These are command contracts, not proof + of execution on an engine. +- NamespaceLifecycleTests runs in all eleven existing database CI jobs. Each + engine gets unqualified, qualified, quoted-qualified and default-namespace + cases covering table creation/removal, enumeration, columns/defaults, inserts, + updates, views, column changes/renames/removal, PK/FK/unique/index metadata and + removal, table rename and data preservation. Firebird tests explicit rejection + for the three namespace modes. +- NamespaceIsolationTests creates two namespaces with identical table, key + and index names on SQLite, SQL Server, PostgreSQL, MySQL, MariaDB, Db2 and HANA. + The provider default points at the second while changes target the first. +- SQLiteNamespaceTests exercises both selectors with dotted ATTACH names, + reconstruction, triggers, index isolation, sequence high-water preservation, + qualified renames and rejection of cross-database foreign keys. +- Ingres has no engine/driver job in this repository. Its command contracts do + not certify full live provider support. Firebird 6 schema support likewise + requires separate version qualification. + +Related issue: [#48](https://github.com/dotnetprojects/Migrator.NET/issues/48). +This matrix describes the tests added here, not a claim that every possible +identifier, database version or schema-changing operation is covered. diff --git a/src/Migrator.Tests/MySqlMetadataContractTests.cs b/src/Migrator.Tests/MySqlMetadataContractTests.cs index 5ae36423..1c0c1817 100644 --- a/src/Migrator.Tests/MySqlMetadataContractTests.cs +++ b/src/Migrator.Tests/MySqlMetadataContractTests.cs @@ -87,34 +87,36 @@ public void MissingTableDoesNotAttemptForeignKeyMetadataQuery() [TestCase("ParentId")] public void BulkForeignKeyRemovalClosesEnumerationBeforeIssuingDrops(string column) { - var metadata = Data(new[] { "TABLE_NAME", "CONSTRAINT_NAME" }, new object[] { "Items", "FK_Parent" }, new object[] { "Children", "FK_Items" }); + var metadata = Data(new[] { "TABLE_SCHEMA", "TABLE_NAME", "CONSTRAINT_NAME" }, new object[] { "Example", "Items", "FK_Parent" }, new object[] { "Other", "Children", "FK_Items" }); var names = Data(new[] { "CONSTRAINT_NAME" }, new object[] { "FK_Parent" }, new object[] { "FK_Items" }); using var enumeration = metadata.CreateDataReader(); provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(c => - ((string)c[1]).Contains("SELECT k.TABLE_NAME") ? enumeration : names.CreateDataReader()); + ((string)c[1]).Contains("SELECT DISTINCT k.TABLE_SCHEMA") ? enumeration : names.CreateDataReader()); provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(_ => { Assert.That(enumeration.IsClosed, Is.True); return 1; }); provider.RemoveAllForeignKeys("Items", column); - provider.Received(1).ExecuteNonQuery("ALTER TABLE Items DROP FOREIGN KEY `FK_Parent`"); - provider.Received(1).ExecuteNonQuery("ALTER TABLE Children DROP FOREIGN KEY `FK_Items`"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE `Example`.`Items` DROP FOREIGN KEY `FK_Parent`"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE `Other`.`Children` DROP FOREIGN KEY `FK_Items`"); } [Test] public void IndexCleanupDistinguishesPrimaryUniqueAndForeignKeys() { provider.Configure().IndexExists("Items", "UQ_Code").Returns(true); - var metadata = Data(new[] { "TABLE_NAME", "CONSTRAINT_NAME", "CONSTRAINT_TYPE" }, - new object[] { "Items", "PRIMARY", "PRIMARY KEY" }, - new object[] { "Items", "UQ_Code", "UNIQUE" }, - new object[] { "Children", "FK_Items", "FOREIGN KEY" }); + provider.Configure().GetIndexes("Items").Returns(new[] { + new DotNetProjects.Migrator.Framework.Index { Name = "PRIMARY", PrimaryKey = true }, + new DotNetProjects.Migrator.Framework.Index { Name = "UQ_Code", Unique = true } }); + provider.Configure().ExecuteScalar(Arg.Any()).Returns("PRIMARY KEY"); + var metadata = Data(new[] { "TABLE_SCHEMA", "TABLE_NAME", "CONSTRAINT_NAME" }, + new object[] { "Other", "Children", "FK_Items" }); var names = Data(new[] { "CONSTRAINT_NAME" }, new object[] { "FK_Items" }); using var enumeration = metadata.CreateDataReader(); provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(c => - ((string)c[1]).Contains("SELECT k.TABLE_NAME") ? enumeration : names.CreateDataReader()); + ((string)c[1]).Contains("SELECT DISTINCT k.TABLE_SCHEMA") ? enumeration : names.CreateDataReader()); provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(_ => { Assert.That(enumeration.IsClosed, Is.True); return 1; }); provider.RemoveAllIndexes("Items"); provider.Received(1).ExecuteNonQuery("ALTER TABLE Items DROP PRIMARY KEY"); provider.Received(1).ExecuteNonQuery("DROP INDEX `UQ_Code` ON Items"); - provider.Received(1).ExecuteNonQuery("ALTER TABLE Children DROP FOREIGN KEY `FK_Items`"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE `Other`.`Children` DROP FOREIGN KEY `FK_Items`"); } [Test, SetCulture("de-DE")] diff --git a/src/Migrator.Tests/NamespaceProviderContractTests.cs b/src/Migrator.Tests/NamespaceProviderContractTests.cs new file mode 100644 index 00000000..50d0c9ec --- /dev/null +++ b/src/Migrator.Tests/NamespaceProviderContractTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +// Covers every public provider selector, including legacy aliases and Ingres. +// The live suite separately verifies that emitted commands work on real engines. +public class NamespaceProviderContractTests +{ + public static IEnumerable Providers => Enum.GetValues().Where(t => t != ProviderTypes.none); + + [TestCaseSource(nameof(Providers))] + public void NamespaceContractForDdlAndCatalogLookups(ProviderTypes type) + { + foreach (var mode in new[] { "unqualified", "qualified", "default" }) + { + var commands = new List(); + var connection = Substitute.For(); + connection.State.Returns(ConnectionState.Open); + connection.CreateCommand().Returns(_ => + { + var command = Substitute.For(); + var parameters = new List(); + var collection = Substitute.For(); + collection.Add(Arg.Any()).Returns(c => { parameters.Add((IDbDataParameter)c[0]); return parameters.Count - 1; }); + command.Parameters.Returns(collection); + command.CreateParameter().Returns(_ => Substitute.For()); + void Record() => commands.Add(command.CommandText + " " + string.Join(" ", parameters.Select(p => p.Value))); + command.ExecuteNonQuery().Returns(_ => { Record(); return 1; }); + command.ExecuteScalar().Returns(_ => { Record(); return 0; }); + command.ExecuteReader().Returns(_ => { Record(); return new DataTable().CreateDataReader(); }); + command.ExecuteReader(Arg.Any()).Returns(_ => { Record(); return new DataTable().CreateDataReader(); }); + return command; + }); + using var provider = ProviderFactory.Create(type, connection, mode == "default" ? "tenant" : null); + var table = mode == "qualified" ? "tenant.items" : "items"; + if (type == ProviderTypes.Firebird && mode != "unqualified") + { + Assert.Throws(() => provider.AddTable(table, new Column("id", DbType.Int32))); + Assert.Throws(() => provider.TableExists(table)); + Assert.That(commands, Is.Empty); + continue; + } + provider.AddTable(table, new Column("id", DbType.Int32)); + Assert.That(commands.Single().ToLowerInvariant(), Does.Contain("items"), type + "/" + mode); + if (mode != "unqualified") Assert.That(commands.Single().ToLowerInvariant(), Does.Contain("tenant")); + commands.Clear(); + provider.TableExists(table); + Assert.That(commands, Is.Not.Empty, type + "/TableExists"); + if (mode != "unqualified") Assert.That(string.Join(" ", commands).ToLowerInvariant(), Does.Contain("tenant"), type + "/TableExists"); + commands.Clear(); + provider.ViewExists(table); + if (mode != "unqualified") Assert.That(string.Join(" ", commands).ToLowerInvariant(), Does.Contain("tenant"), type + "/ViewExists"); + commands.Clear(); + provider.GetTables(mode == "unqualified" ? null : "tenant").ToArray(); + if (mode != "unqualified") Assert.That(string.Join(" ", commands).ToLowerInvariant(), Does.Contain("tenant"), type + "/GetTables"); + } + } + + [TestCaseSource(nameof(Providers))] + public void DefaultNamespaceDoesNotOverrideExplicitNamespace(ProviderTypes type) + { + var connection = Substitute.For(); + connection.State.Returns(ConnectionState.Open); + using var provider = ProviderFactory.Create(type, connection, "first"); + if (type == ProviderTypes.Firebird) + Assert.Throws(() => provider.QuoteTableNameIfRequired("second.items")); + else + { + var parts = SqlIdentifier.Parse(provider.QuoteTableNameIfRequired("second.items")); + Assert.That(parts.Select(p => p.Value), Is.EqualTo(new[] { "second", "items" })); + } + } +} diff --git a/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs b/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs index c127427b..d74c7c33 100644 --- a/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs +++ b/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs @@ -27,80 +27,8 @@ namespace Migrator.Tests.Providers.Live; [TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] [TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] [NonParallelizable] -public class DataBoundaryTests(string database, ProviderTypes providerType) : TransformationProviderBase +public class DataBoundaryTests(string database, ProviderTypes providerType) : LiveProviderFixture(database, providerType) { - private LiveDatabaseTests live; - private HanaConnection hana; - private string schema; - - [SetUp] - public async Task SetUp() - { - switch (database) - { - case "SQLite": await BeginSQLiteTransactionAsync(); break; - case "SQLServer": await BeginSQLServerTransactionAsync(); break; - case "PostgreSQL": await BeginPostgreSQLTransactionAsync(); break; - case "Oracle": await BeginOracleTransactionAsync(); break; - case "Hana": - hana = new HanaConnection(Environment.GetEnvironmentVariable("MIGRATOR_HANA") - ?? "Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2"); - hana.Open(); - var name = "BOUNDARY_" + Guid.NewGuid().ToString("N").ToUpperInvariant(); - using (var command = hana.CreateCommand()) - { - command.CommandText = "CREATE SCHEMA " + name; - command.ExecuteNonQuery(); - schema = name; - command.CommandText = "SET SCHEMA " + schema; - command.ExecuteNonQuery(); - } - Provider = ProviderFactory.Create(providerType, hana, schema, "boundary-tests"); - break; - default: - live = new LiveDatabaseTests(database, providerType); - live.SetUp(); - Provider = live.Provider; - break; - } - // A too-long value must not silently truncate on engines with configurable modes. - if (database is "MySQL" or "MariaDB") Provider.ExecuteNonQuery("SET SESSION sql_mode='STRICT_ALL_TABLES'"); - if (database == "Sybase") - { - Provider.ExecuteNonQuery("SET STRING_RTRUNCATION ON"); - Provider.ExecuteNonQuery("SET TEXTSIZE 2147483647"); - } - } - - [TearDown] - public override void TearDown() - { - try - { - if (live != null) live.TearDown(); - else if (hana != null) - { - Provider?.Dispose(); - if (schema != null && hana.State == ConnectionState.Open) - { - using var command = hana.CreateCommand(); - command.CommandText = "DROP SCHEMA " + schema + " CASCADE"; - command.ExecuteNonQuery(); - } - } - else base.TearDown(); - } - finally - { - if (live == null) Provider?.Dispose(); - hana?.Dispose(); - Provider = null; - live = null; - hana = null; - schema = null; - } - } - private string Table => Provider.QuoteTableNameIfRequired("Test"); private string ValueColumn => Provider.QuoteColumnNameIfRequired("payload"); private string IdColumn => Provider.QuoteColumnNameIfRequired("id"); diff --git a/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs b/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs new file mode 100644 index 00000000..caef2cfb --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Sap.Data.Hana; + +namespace Migrator.Tests.Providers.Live; + +public abstract class LiveProviderFixture(string database, ProviderTypes providerType) : TransformationProviderBase +{ + protected LiveDatabaseTests live; + private HanaConnection hana; + private string schema; + + [SetUp] + public async Task SetUp() + { + switch (database) + { + case "SQLite": await BeginSQLiteTransactionAsync(); break; + case "SQLServer": await BeginSQLServerTransactionAsync(); break; + case "PostgreSQL": await BeginPostgreSQLTransactionAsync(); break; + case "Oracle": await BeginOracleTransactionAsync(); break; + case "Hana": + hana = new HanaConnection(Environment.GetEnvironmentVariable("MIGRATOR_HANA") + ?? "Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2"); + hana.Open(); + var name = "BOUNDARY_" + Guid.NewGuid().ToString("N").ToUpperInvariant(); + using (var command = hana.CreateCommand()) + { + command.CommandText = "CREATE SCHEMA " + name; + command.ExecuteNonQuery(); + schema = name; + command.CommandText = "SET SCHEMA " + schema; + command.ExecuteNonQuery(); + } + Provider = ProviderFactory.Create(providerType, hana, schema, "boundary-tests"); + break; + default: + live = new LiveDatabaseTests(database, providerType); + live.SetUp(); + Provider = live.Provider; + break; + } + // A too-long value must not silently truncate on engines with configurable modes. + if (database is "MySQL" or "MariaDB") Provider.ExecuteNonQuery("SET SESSION sql_mode='STRICT_ALL_TABLES'"); + if (database == "Sybase") + { + Provider.ExecuteNonQuery("SET STRING_RTRUNCATION ON"); + Provider.ExecuteNonQuery("SET TEXTSIZE 2147483647"); + } + } + + [TearDown] + public override void TearDown() + { + try + { + if (live != null) live.TearDown(); + else if (hana != null) + { + Provider?.Dispose(); + if (schema != null && hana.State == ConnectionState.Open) + { + using var command = hana.CreateCommand(); + command.CommandText = "DROP SCHEMA " + schema + " CASCADE"; + command.ExecuteNonQuery(); + } + } + else base.TearDown(); + } + finally + { + if (live == null) Provider?.Dispose(); + hana?.Dispose(); + Provider = null; + live = null; + hana = null; + schema = null; + } + } + +} diff --git a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs new file mode 100644 index 00000000..4e6223fb --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Sap.Data.Hana; + +namespace Migrator.Tests.Providers.Live; + +[TestFixture("SQLite", ProviderTypes.SQLite, Category = "SQLite")] +[TestFixture("SQLServer", ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture("PostgreSQL", ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture("MySQL", ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] +[TestFixture("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] +[TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] +[NonParallelizable] +public class NamespaceIsolationTests(string database, ProviderTypes providerType) : LiveProviderFixture(database, providerType) +{ + [Test] + public void SameNamedObjectsRemainIsolatedWhenDefaultPointsElsewhere() + { + var provider = (TransformationProvider)Provider; + if (database == "SQLite") Provider.Rollback(); + var first = "NS_" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(); + var second = first + "B"; + string Qualified(string schema, string table) => Provider.Dialect.QuoteIdentifier(schema) + "." + table; + void Create(string schema) + { + var quoted = Provider.Dialect.QuoteIdentifier(schema); + Provider.ExecuteNonQuery(database switch { + "SQLite" => "ATTACH ':memory:' AS " + quoted, + "MySQL" or "MariaDB" => "CREATE DATABASE " + quoted, + _ => "CREATE SCHEMA " + quoted }); + } + var created = new List(); + try + { + Create(first); created.Add(first); + Create(second); created.Add(second); + provider.SetDefaultSchema(Provider.Dialect.QuoteIdentifier(second)); + foreach (var schema in created) + { + var table = Qualified(schema, "ns_items"); + Provider.AddTable(table, new Column("id", DbType.Int32) { IsNullable = false }, new Column("payload", DbType.String, 30), + new PrimaryKeyConstraint("pk_same", "id")); + Provider.AddIndex(table, new DotNetProjects.Migrator.Framework.Index { Name = "ix_same", KeyColumns = new[] { "payload" } }); + Provider.Insert(table, new[] { "id", "payload" }, new object[] { 1, schema }); + } + var target = Qualified(first, "ns_items"); + Assert.That(Provider.TableExists(target), Is.True); + Assert.That(Provider.GetTables(Provider.Dialect.QuoteIdentifier(first)).Select(n => n.ToLowerInvariant()), Is.EqualTo(new[] { "ns_items" })); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); + Provider.ChangeColumn(target, new Column("payload", DbType.String, 60)); + Provider.AddColumn(target, new Column("only_first", DbType.Int32)); + Assert.That(Provider.ColumnExists(target, "only_first"), Is.True); + Assert.That(Provider.ColumnExists("ns_items", "only_first"), Is.False); + Provider.RemoveIndex(target, "ix_same"); + Assert.That(Provider.IndexExists(target, "ix_same"), Is.False); + Assert.That(Provider.IndexExists("ns_items", "ix_same"), Is.True); + Provider.RenameTable(target, "ns_renamed"); + Assert.That(Provider.TableExists(target), Is.False); + Assert.That(Provider.TableExists(Qualified(first, "ns_renamed")), Is.True); + Provider.RemoveTable(Qualified(first, "ns_renamed")); + Assert.That(Provider.TableExists("ns_items"), Is.True); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); + } + finally + { + foreach (var schema in created.AsEnumerable().Reverse()) + { + foreach (var table in new[] { "ns_renamed", "ns_items" }) + if (Provider.TableExists(Qualified(schema, table))) Provider.RemoveTable(Qualified(schema, table)); + if (database != "SQLite") + Provider.ExecuteNonQuery((database is "MySQL" or "MariaDB" ? "DROP DATABASE " : "DROP SCHEMA ") + + Provider.Dialect.QuoteIdentifier(schema) + (database == "Db2" ? " RESTRICT" : "")); + } + provider.SetDefaultSchema(null); + } + } +} diff --git a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs new file mode 100644 index 00000000..83ac5c6a --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Sap.Data.Hana; + +namespace Migrator.Tests.Providers.Live; + +// Every case is assigned to exactly one existing CI database job. No catch-and-skip. +[TestFixture("SQLite", ProviderTypes.SQLite, Category = "SQLite")] +[TestFixture("SQLServer", ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture("PostgreSQL", ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture("Oracle", ProviderTypes.Oracle, Category = "Oracle")] +[TestFixture("MySQL", ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] +[TestFixture("Firebird", ProviderTypes.Firebird, Category = "Firebird")] +[TestFixture("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] +[TestFixture("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] +[TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] +[TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] +[NonParallelizable] +public class NamespaceLifecycleTests(string database, ProviderTypes providerType) : LiveProviderFixture(database, providerType) +{ + private string CurrentNamespace() => database switch + { + "SQLite" => "main", + "SQLServer" => Convert.ToString(Provider.ExecuteScalar("SELECT SCHEMA_NAME()")), + "PostgreSQL" => Convert.ToString(Provider.ExecuteScalar("SELECT current_schema()")), + "Oracle" => Convert.ToString(Provider.ExecuteScalar("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') FROM DUAL")), + "MySQL" or "MariaDB" => Convert.ToString(Provider.ExecuteScalar("SELECT DATABASE()")), + "Db2" => Convert.ToString(Provider.ExecuteScalar("VALUES CURRENT SCHEMA")).Trim(), + "Informix" => Convert.ToString(Provider.ExecuteScalar("SELECT USER FROM systables WHERE tabid=1")).Trim(), + "Sybase" => Convert.ToString(Provider.ExecuteScalar("SELECT user_name()")), + "Hana" => Convert.ToString(Provider.ExecuteScalar("SELECT CURRENT_SCHEMA FROM DUMMY")), + _ => null + }; + + [TestCase("unqualified")] + [TestCase("qualified")] + [TestCase("quoted")] + [TestCase("default")] + public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) + { + var provider = (TransformationProvider)Provider; + if (database == "Firebird" && mode != "unqualified") + { + if (mode == "default") provider.SetDefaultSchema("unsupported"); + Assert.Throws(() => Provider.AddTable("unsupported.items", new Column("id", DbType.Int32))); + Assert.Throws(() => Provider.TableExists("unsupported.items")); + Assert.Throws(() => Provider.GetTables("unsupported").ToArray()); + return; + } + // Rebuilds own their transaction; SQLite cannot disable FK enforcement inside one. + if (database == "SQLite") { Provider.Rollback(); Provider.ExecuteNonQuery("PRAGMA foreign_keys=ON"); } + if (database == "Sybase") Provider.ExecuteNonQuery("SET QUOTED_IDENTIFIER ON"); + var ns = CurrentNamespace(); + if (mode == "default") provider.SetDefaultSchema(Provider.Dialect.QuoteIdentifier(ns)); + string Table(string name) => mode switch + { + "qualified" => ns + "." + name, + "quoted" => Provider.Dialect.QuoteIdentifier(ns) + "." + Provider.Dialect.QuoteIdentifier(database is "Db2" or "Oracle" ? name.ToUpperInvariant() : name), + _ => name + }; + var parent = Table("ns_parent"); + var child = Table("ns_child"); + var renamed = Table("ns_renamed"); + try + { + Provider.AddTable(parent, new Column("id", DbType.Int32) { IsNullable = false }, new PrimaryKeyConstraint("pk_ns_parent", "id")); + Provider.AddTable(child, new Column("id", DbType.Int32) { IsNullable = false }, new Column("parent_id", DbType.Int32), + new Column("payload", DbType.String, 20), new PrimaryKeyConstraint("pk_ns_child", "id")); + Assert.That(Provider.TableExists(child), Is.True); + Assert.That(Provider.ColumnExists(child, "payload"), Is.True); + Assert.That(Provider.GetTables(ns).Select(n => n.ToLowerInvariant()), Does.Contain("ns_child")); + Assert.That(Provider.GetColumns(ns, database is "Db2" or "Oracle" ? "NS_CHILD" : "ns_child").Select(n => n.ToLowerInvariant()), Does.Contain("payload")); + var view = Table("ns_view"); + Provider.ExecuteNonQuery($"CREATE VIEW {Provider.QuoteTableNameIfRequired(view)} AS SELECT id FROM {Provider.QuoteTableNameIfRequired(child)}"); + Assert.That(Provider.ViewExists(view), Is.True); + Provider.ExecuteNonQuery("DROP VIEW " + Provider.QuoteTableNameIfRequired(view)); + Assert.That(Provider.ViewExists(view), Is.False); + Provider.AddColumn(child, new Column("extra", DbType.Int32) { DefaultValue = 7 }); + Provider.Insert(parent, new[] { "id" }, new object[] { 1 }); + Provider.Insert(child, new[] { "id", "parent_id", "payload" }, new object[] { 1, 1, "kept" }); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT extra FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo(7)); + Provider.Update(child, new[] { "payload" }, new object[] { "changed" }, new[] { "id" }, new object[] { 1 }); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo("changed")); + Provider.Update(child, new[] { "payload" }, new object[] { "kept" }); + Provider.ChangeColumn(child, new Column("payload", DbType.String, 40)); + Provider.RenameColumn(child, "payload", "message"); + Assert.That(Provider.ColumnExists(child, "message"), Is.True); + Assert.That(Provider.ColumnExists(child, "payload"), Is.False); + Provider.AddForeignKey("fk_ns_parent", child, new[] { "parent_id" }, parent, new[] { "id" }); + var fk = Provider.GetForeignKeyConstraints(child).Single(); + Assert.That(fk.ChildColumns.Select(c => c.ToLowerInvariant()), Is.EqualTo(new[] { "parent_id" })); + Assert.That(fk.ParentColumns.Select(c => c.ToLowerInvariant()), Is.EqualTo(new[] { "id" })); + Provider.AddUniqueConstraint("uq_ns_message", child, "message"); + Assert.That(Provider.ConstraintExists(child, "uq_ns_message"), Is.True); + Provider.AddIndex(child, new DotNetProjects.Migrator.Framework.Index { Name = "ix_ns_extra", KeyColumns = new[] { "extra" } }); + Assert.That(Provider.IndexExists(child, "ix_ns_extra"), Is.True); + Assert.That(Provider.GetIndexes(child).Any(i => i.Name.Equals("ix_ns_extra", StringComparison.OrdinalIgnoreCase)), Is.True); + Assert.That(Provider.GetTableConstraints(child).OfType(), Has.Exactly(1).Items); + Provider.RemoveIndex(child, "ix_ns_extra"); + Assert.That(Provider.IndexExists(child, "ix_ns_extra"), Is.False); + Provider.RemoveConstraint(child, "uq_ns_message"); + Provider.RemoveForeignKey(child, "fk_ns_parent"); + Assert.That(Provider.GetForeignKeyConstraints(child), Is.Empty); + Provider.RemoveColumn(child, "extra"); + Assert.That(Provider.ColumnExists(child, "extra"), Is.False); + Provider.RenameTable(child, renamed); + Assert.That(Provider.TableExists(child), Is.False); + Assert.That(Provider.TableExists(renamed), Is.True); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT message FROM " + Provider.QuoteTableNameIfRequired(renamed))), Is.EqualTo("kept")); + Provider.RemoveTable(renamed); + Assert.That(Provider.TableExists(renamed), Is.False); + } + finally + { + foreach (var table in new[] { renamed, child, parent }) + if (Provider.TableExists(table)) Provider.RemoveTable(table); + } + } +} diff --git a/src/Migrator.Tests/SQLiteNamespaceTests.cs b/src/Migrator.Tests/SQLiteNamespaceTests.cs new file mode 100644 index 00000000..7ea14725 --- /dev/null +++ b/src/Migrator.Tests/SQLiteNamespaceTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests; + +[TestFixture(ProviderTypes.SQLite)] +[TestFixture(ProviderTypes.MonoSQLite)] +[Category("SQLite")] +public class SQLiteNamespaceTests(ProviderTypes type) +{ + [TestCase(false)] + [TestCase(true)] + public void AttachedDatabaseRebuildPreservesDataIndexesTriggersAndIdentity(bool useDefault) + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(type, connection, useDefault ? "\"sales.data\"" : null); + provider.ExecuteNonQuery(""" + ATTACH ':memory:' AS "sales.data"; + CREATE TABLE main.items(id INTEGER PRIMARY KEY, wrong TEXT); + INSERT INTO main.items VALUES(99,'untouched'); + CREATE TABLE "sales.data".audit(value TEXT); + """); + var table = useDefault ? "items" : "\"sales.data\".items"; + provider.AddTable(table, new Column("id", DbType.Int64) { IsIdentity = true }, + new Column("value", DbType.String, 20), new PrimaryKeyConstraint("pk_items", "id")); + provider.Insert(table, new[] { "value" }, new object[] { "keep" }); + provider.Insert(table, new[] { "id", "value" }, new object[] { 100, "delete" }); + provider.ExecuteNonQuery("DELETE FROM \"sales.data\".items WHERE id=100"); + provider.AddIndex(table, new Index { Name = "ix_value", KeyColumns = new[] { "value" } }); + provider.ExecuteNonQuery("CREATE TRIGGER \"sales.data\".audit_items AFTER INSERT ON items BEGIN INSERT INTO audit VALUES(NEW.value); END"); + provider.ChangeColumn(table, new Column("value", DbType.String, 80)); + Assert.That(provider.IndexExists(table, "ix_value"), Is.True); + provider.Insert(table, new[] { "value" }, new object[] { "after" }); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT MAX(id) FROM \"sales.data\".items")), Is.EqualTo(101)); + Assert.That(provider.ExecuteScalar("SELECT value FROM \"sales.data\".audit"), Is.EqualTo("after")); + Assert.That(provider.ExecuteScalar("SELECT wrong FROM main.items"), Is.EqualTo("untouched")); + Assert.That(provider.GetColumns("\"sales.data\"", "items"), Is.EqualTo(new[] { "id", "value" })); + provider.RemoveIndex(table, "ix_value"); + provider.RenameTable(table, "renamed"); + Assert.That(provider.TableExists("\"sales.data\".renamed"), Is.True); + Assert.That(provider.TableExists("main.items"), Is.True); + provider.RemoveTable("\"sales.data\".renamed"); + Assert.That(provider.TableExists("main.items"), Is.True); + } + + [Test] + public void ForeignKeysUseTheirOwnDatabaseAndRejectCrossDatabaseReferencesBeforeDdl() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var provider = ProviderFactory.Create(type, connection, null); + provider.ExecuteNonQuery("ATTACH ':memory:' AS aux; CREATE TABLE main.parent(id INTEGER PRIMARY KEY); CREATE TABLE aux.parent(id INTEGER PRIMARY KEY)"); + provider.AddTable("aux.child", new Column("pid", DbType.Int32), + new DotNetProjects.Migrator.Framework.ForeignKeyConstraint("fk_parent", "aux.parent", new[] { "id" }, "aux.child", new[] { "pid" })); + Assert.That(provider.GetForeignKeyConstraints("aux.child").Single().ParentTable, Is.EqualTo("parent")); + Assert.Throws(() => provider.AddTable("aux.invalid", new Column("pid", DbType.Int32), + new DotNetProjects.Migrator.Framework.ForeignKeyConstraint("fk_cross", "main.parent", new[] { "id" }, "aux.invalid", new[] { "pid" }))); + Assert.That(provider.TableExists("aux.invalid"), Is.False); + } +} diff --git a/src/Migrator.Tests/SchemaCatalogContractTests.cs b/src/Migrator.Tests/SchemaCatalogContractTests.cs index 664631fe..79bf9d44 100644 --- a/src/Migrator.Tests/SchemaCatalogContractTests.cs +++ b/src/Migrator.Tests/SchemaCatalogContractTests.cs @@ -1,8 +1,8 @@ using System.Data; -using System.Data.Common; using System.Linq; -using DotNetProjects.Migrator.Providers.Impl.SQLite; -using NSubstitute; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; using NUnit.Framework; namespace Migrator.Tests; @@ -10,27 +10,15 @@ namespace Migrator.Tests; public class SchemaCatalogContractTests { [Test] - public void SchemaColumnEnumerationReturnsColumnNamesAndScopesTheRequest() + public void ExplicitAndDefaultNamespacesEnumerateOnlyTheirOwnObjects() { - using var table = new DataTable(); - table.Columns.Add("TABLE_NAME"); table.Columns.Add("COLUMN_NAME"); - table.Rows.Add("Orders", "Id"); table.Rows.Add("Orders", "Total"); - var connection = Substitute.For(); - connection.GetSchema("Columns", Arg.Any()).Returns(table); - using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); - Assert.That(provider.GetColumns("sales", "Orders").ToArray(), Is.EqualTo(new[] { "Id", "Total" })); - connection.Received(1).GetSchema("Columns", Arg.Is(x => x.Length == 4 && x[0] == null && x[1] == "sales" && x[2] == "Orders" && x[3] == null)); - } - - [Test] - public void SchemaTableEnumerationPreservesReturnedNamesAndSchemaRestriction() - { - using var table = new DataTable(); table.Columns.Add("TABLE_NAME"); - table.Rows.Add("Orders"); table.Rows.Add("Order Details"); - var connection = Substitute.For(); - connection.GetSchema("Tables", Arg.Any()).Returns(table); - using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); - Assert.That(provider.GetTables("sales").ToArray(), Is.EqualTo(new[] { "Orders", "Order Details" })); - connection.Received(1).GetSchema("Tables", Arg.Is(x => x.Length == 4 && x[1] == "sales" && x[2] == null)); + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, "sales"); + provider.ExecuteNonQuery("ATTACH ':memory:' AS sales; CREATE TABLE main.Orders(Wrong INTEGER); CREATE TABLE sales.Orders(Id INTEGER, Total INTEGER); CREATE TABLE sales.[Order Details](Id INTEGER)"); + Assert.That(provider.GetTables("sales"), Is.EquivalentTo(new[] { "Orders", "Order Details" })); + Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Orders", "Order Details" })); + Assert.That(provider.GetColumns("sales", "Orders"), Is.EqualTo(new[] { "Id", "Total" })); + Assert.That(provider.GetColumns("main", "Orders"), Is.EqualTo(new[] { "Wrong" })); } } diff --git a/src/Migrator/Providers/ConstraintMetadataReader.cs b/src/Migrator/Providers/ConstraintMetadataReader.cs index 017f9faf..4c5625e7 100644 --- a/src/Migrator/Providers/ConstraintMetadataReader.cs +++ b/src/Migrator/Providers/ConstraintMetadataReader.cs @@ -54,12 +54,13 @@ FROM pg_constraint c LEFT JOIN LATERAL unnest(c.conkey) WITH ORDINALITY k(attnum } else if (provider.Dialect is DB2Dialect) { - parameterTable = table.StartsWith('"') ? table.Trim('"').Replace("\"\"", "\"") : table.ToUpperInvariant(); + var relation = provider.CatalogRelation(table, true); + parameterTable = relation.Name; schema = relation.Schema; sql = @"SELECT c.CONSTNAME,c.TYPE,k.COLNAME,k.COLSEQ,ch.TEXT FROM SYSCAT.TABCONST c LEFT JOIN SYSCAT.KEYCOLUSE k ON k.TABSCHEMA=c.TABSCHEMA AND k.TABNAME=c.TABNAME AND k.CONSTNAME=c.CONSTNAME AND c.TYPE IN ('P','U') LEFT JOIN SYSCAT.CHECKS ch ON ch.TABSCHEMA=c.TABSCHEMA AND ch.TABNAME=c.TABNAME AND ch.CONSTNAME=c.CONSTNAME - WHERE c.TABSCHEMA=CURRENT SCHEMA AND c.TABNAME=@lookup_table AND c.TYPE IN ('P','U','K') + WHERE c.TABSCHEMA=COALESCE(@lookup_schema,CURRENT SCHEMA) AND c.TABNAME=@lookup_table AND c.TYPE IN ('P','U','K') ORDER BY c.CONSTNAME,k.COLSEQ"; } else if (provider.Dialect is FirebirdDialect) @@ -89,7 +90,7 @@ FROM information_schema.TABLE_CONSTRAINTS c LEFT JOIN information_schema.KEY_COL } else throw new NotSupportedException("Structured constraint inspection is not implemented for " + provider.Dialect.GetType().Name + "."); - return (sql, parameterTable, schema, oracle || provider.Dialect is MysqlDialect); + return (sql, parameterTable, schema, oracle || provider.Dialect is MysqlDialect or DB2Dialect); } private enum ConstraintKind { Primary, NonClusteredPrimary, Unique, Check } diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index 98374287..6cf21442 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -170,12 +170,18 @@ public virtual bool IsReservedWord(string reservedWord) public ITransformationProvider NewProviderForDialect(string connectionString, string defaultSchema, string scope, string providerName) { - return GetTransformationProvider(this, connectionString, defaultSchema, scope, providerName); + return ConfigureNamespace(GetTransformationProvider(this, connectionString, defaultSchema, scope, providerName), defaultSchema); } public ITransformationProvider NewProviderForDialect(IDbConnection connection, string defaultSchema, string scope, string providerName) { - return GetTransformationProvider(this, connection, defaultSchema, scope, providerName); + return ConfigureNamespace(GetTransformationProvider(this, connection, defaultSchema, scope, providerName), defaultSchema); + } + + private static ITransformationProvider ConfigureNamespace(ITransformationProvider provider, string schema) + { + if (provider is TransformationProvider transformation) transformation.SetDefaultSchema(schema); + return provider; } /// diff --git a/src/Migrator/Providers/ForeignKeyMetadataReader.cs b/src/Migrator/Providers/ForeignKeyMetadataReader.cs index 0a7d13e9..92b35631 100644 --- a/src/Migrator/Providers/ForeignKeyMetadataReader.cs +++ b/src/Migrator/Providers/ForeignKeyMetadataReader.cs @@ -41,7 +41,7 @@ CROSS JOIN LATERAL unnest(c.conkey,c.confkey) WITH ORDINALITY k(childnum,parentn { var relation = SqlIdentifier.Catalog(provider.QuoteTableNameIfRequired(table)); parameterTable = relation.Name; schema = relation.Schema; - sql = @"SELECT k.CONSTRAINT_NAME,k.REFERENCED_TABLE_NAME,k.COLUMN_NAME,k.REFERENCED_COLUMN_NAME,k.ORDINAL_POSITION,r.DELETE_RULE,r.UPDATE_RULE + sql = @"SELECT k.CONSTRAINT_NAME,CASE WHEN k.REFERENCED_TABLE_SCHEMA=k.TABLE_SCHEMA THEN k.REFERENCED_TABLE_NAME ELSE CONCAT(k.REFERENCED_TABLE_SCHEMA,'.',k.REFERENCED_TABLE_NAME) END,k.COLUMN_NAME,k.REFERENCED_COLUMN_NAME,k.ORDINAL_POSITION,r.DELETE_RULE,r.UPDATE_RULE FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS r ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.TABLE_NAME=k.TABLE_NAME AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.TABLE_NAME=@lookup_table AND k.TABLE_SCHEMA=COALESCE(@lookup_schema,DATABASE()) @@ -63,11 +63,12 @@ FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_C } else if (provider.Dialect is DB2Dialect) { - parameterTable = table.StartsWith('"') ? table.Trim('"') : table.ToUpperInvariant(); - sql = @"SELECT r.CONSTNAME,r.REFTABNAME,c.COLNAME,p.COLNAME,c.COLSEQ,r.DELETERULE,r.UPDATERULE + var relation = provider.CatalogRelation(table, true); + parameterTable = relation.Name; schema = relation.Schema; + sql = @"SELECT r.CONSTNAME,CASE WHEN r.REFTABSCHEMA=r.TABSCHEMA THEN r.REFTABNAME ELSE RTRIM(r.REFTABSCHEMA)||'.'||RTRIM(r.REFTABNAME) END,c.COLNAME,p.COLNAME,c.COLSEQ,r.DELETERULE,r.UPDATERULE FROM SYSCAT.REFERENCES r JOIN SYSCAT.KEYCOLUSE c ON c.TABSCHEMA=r.TABSCHEMA AND c.TABNAME=r.TABNAME AND c.CONSTNAME=r.CONSTNAME JOIN SYSCAT.KEYCOLUSE p ON p.TABSCHEMA=r.REFTABSCHEMA AND p.TABNAME=r.REFTABNAME AND p.CONSTNAME=r.REFKEYNAME AND p.COLSEQ=c.COLSEQ - WHERE r.TABSCHEMA=CURRENT SCHEMA AND r.TABNAME=@lookup_table ORDER BY r.CONSTNAME,c.COLSEQ"; + WHERE r.TABSCHEMA=COALESCE(@lookup_schema,CURRENT SCHEMA) AND r.TABNAME=@lookup_table ORDER BY r.CONSTNAME,c.COLSEQ"; } else if (provider.Dialect is FirebirdDialect) { @@ -83,7 +84,7 @@ FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_C else throw new NotSupportedException("Foreign-key metadata is unsupported by " + provider.Dialect.GetType().Name + "."); using var command = provider.CreateCommand(); AddParameter(command, "lookup_table", parameterTable); - if (provider.Dialect is MysqlDialect or OracleDialect) AddParameter(command, "lookup_schema", schema); + if (provider.Dialect is MysqlDialect or OracleDialect or DB2Dialect) AddParameter(command, "lookup_schema", schema); var rows = new List<(string Name, string Parent, string ChildColumn, string ParentColumn, string Delete, string Update)>(); using (var reader = provider.ExecuteQuery(command, sql)) while (reader.Read()) diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index c657fd41..82440f7e 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -44,18 +44,17 @@ public override void AddTable(string name, string engine, params IDbField[] fiel } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM SYSCAT.TABLES WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE='T'")) > 0; + $"SELECT COUNT(*) FROM SYSCAT.TABLES WHERE TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND TABNAME={ObjectSqlLiteral(table, true)} AND TYPE='T'")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM SYSCAT.VIEWS WHERE VIEWSCHEMA=CURRENT SCHEMA AND VIEWNAME='{Name(view)}'")) > 0; - public override string[] GetTables() => ExecuteStringQuery( - "SELECT TABNAME FROM SYSCAT.TABLES WHERE TABSCHEMA=CURRENT SCHEMA AND TYPE='T'").ToArray(); + $"SELECT COUNT(*) FROM SYSCAT.VIEWS WHERE VIEWSCHEMA={NamespaceSql(view, "CURRENT SCHEMA", true)} AND VIEWNAME={ObjectSqlLiteral(view, true)}")) > 0; + public override string[] GetTables() => base.GetTables(); // SQL exposes the current database, not the client's local database directory. public override List GetDatabases() => [Convert.ToString(ExecuteScalar("VALUES CURRENT SERVER")).Trim()]; public override string[] GetConstraints(string table) => ExecuteStringQuery( - $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}'").ToArray(); + $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND TABNAME={ObjectSqlLiteral(table, true)}").ToArray(); public override bool ConstraintExists(string table, string name) => GetConstraints(table).Any(n => n == name || n == Name(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( - $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE='P'").FirstOrDefault(); + $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND TABNAME={ObjectSqlLiteral(table, true)} AND TYPE='P'").FirstOrDefault(); public override Column[] GetColumns(string table) { @@ -63,7 +62,7 @@ public override Column[] GetColumns(string table) using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" SELECT COLNAME, TYPENAME, NULLS, DEFAULT, LENGTH, IDENTITY, KEYSEQ, SCALE - FROM SYSCAT.COLUMNS WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' ORDER BY COLNO + FROM SYSCAT.COLUMNS WHERE TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND TABNAME={ObjectSqlLiteral(table, true)} ORDER BY COLNO """); while (reader.Read()) { @@ -100,7 +99,7 @@ public override Index[] GetIndexes(string table) SELECT i.INDNAME, i.UNIQUERULE, c.COLNAME, d.CONSTNAME FROM SYSCAT.INDEXES i JOIN SYSCAT.INDEXCOLUSE c ON c.INDSCHEMA=i.INDSCHEMA AND c.INDNAME=i.INDNAME LEFT JOIN SYSCAT.CONSTDEP d ON d.BSCHEMA=i.INDSCHEMA AND d.BNAME=i.INDNAME AND d.BTYPE='I' AND d.TABSCHEMA=i.TABSCHEMA AND d.TABNAME=i.TABNAME - WHERE i.TABSCHEMA=CURRENT SCHEMA AND i.TABNAME='{Name(table)}' + WHERE i.TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND i.TABNAME={ObjectSqlLiteral(table, true)} ORDER BY i.INDNAME, c.COLSEQ """); while (reader.Read()) @@ -119,7 +118,7 @@ public override Index[] GetIndexes(string table) public override void RemoveAllIndexes(string table) { // Constraint and backing-index names need not match in Db2. - var constraints = ExecuteStringQuery($"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE IN ('P','U')"); + var constraints = ExecuteStringQuery($"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA={NamespaceSql(table, "CURRENT SCHEMA", true)} AND TABNAME={ObjectSqlLiteral(table, true)} AND TYPE IN ('P','U')"); foreach (var name in constraints) RemoveConstraint(table, name); foreach (var index in GetIndexes(table)) RemoveIndex(table, index.Name); } @@ -131,14 +130,14 @@ public override string AddIndex(string table, Index index) if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) throw new NotSupportedException("This Db2 provider supports ordinary and unique indexes without INCLUDE, filters or clustering."); var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {Identifier(name)} ON {Identifier(table)} ({string.Join(", ", index.KeyColumns.Select(Identifier))})"); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {QualifyInSameNamespace(table, Name(name).Replace("''", "'"))} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(Identifier))})"); return name; } public override void ChangeColumn(string table, Column column) { - var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; + var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER COLUMN {Identifier(column.Name)}"; var type = _dialect.GetColumnMapper(column).Type; ExecuteNonQuery($"{prefix} SET DATA TYPE {type}"); if (column.DefaultValue != null || GetColumns(table).Single(c => c.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)).DefaultValue != null) @@ -147,6 +146,9 @@ public override void ChangeColumn(string table, Column column) Reorganize(table); } + public override void RenameTable(string oldName, string newName) => + ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(oldName)} TO {RenameTarget(oldName, newName)}"); + public override void RemoveColumn(string tableName, string column) { base.RemoveColumn(tableName, column); @@ -155,8 +157,10 @@ public override void RemoveColumn(string tableName, string column) private void Reorganize(string table) { - var schema = Convert.ToString(ExecuteScalar("VALUES CURRENT SCHEMA")).Trim(); - ExecuteNonQuery($"CALL SYSPROC.ADMIN_CMD('REORG TABLE {schema}.{Identifier(table).Replace("'", "''")}')"); + var relation = CatalogRelation(table, true); + var schema = relation.Schema ?? Convert.ToString(ExecuteScalar("VALUES CURRENT SCHEMA")).Trim(); + var qualified = _dialect.QuoteIdentifier(schema) + "." + _dialect.QuoteIdentifier(relation.Name); + ExecuteNonQuery($"CALL SYSPROC.ADMIN_CMD('REORG TABLE {qualified.Replace("'", "''")}')"); } public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, @@ -178,6 +182,6 @@ public override void AddForeignKey(string name, string childTable, string[] chil ForeignKeyConstraintType.Restrict => "RESTRICT", _ => throw new NotSupportedException("This referential action is not supported by Db2.") }; - ExecuteNonQuery($"ALTER TABLE {Identifier(childTable)} ADD CONSTRAINT {Identifier(name)} FOREIGN KEY ({string.Join(", ", childColumns.Select(Identifier))}) REFERENCES {Identifier(parentTable)} ({string.Join(", ", parentColumns.Select(Identifier))}) ON DELETE {delete} ON UPDATE NO ACTION"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {Identifier(name)} FOREIGN KEY ({string.Join(", ", childColumns.Select(Identifier))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", parentColumns.Select(Identifier))}) ON DELETE {delete} ON UPDATE NO ACTION"); } } diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index ac1ddbb5..5980469b 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -23,8 +23,8 @@ public FirebirdTransformationProvider(Dialect dialect, string connectionString, public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) : base(dialect, connection, null, scope) { } - private static string CatalogName(string name) => - (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + private string CatalogName(string name) => + (QuoteTableNameIfRequired(name).StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); public override void AddColumn(string table, Column column) => AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); @@ -34,14 +34,20 @@ public override void AddTable(string name, string engine, params IDbField[] fiel base.AddTable(name, engine, fields); } + public override string QuoteTableNameIfRequired(string name) + { + if (_defaultSchema != null || SqlIdentifier.Parse(name).Length != 1) + throw new NotSupportedException("The Firebird provider targets Firebird 5 and does not support namespaces."); + return base.QuoteTableNameIfRequired(name); + } + public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$VIEW_BLR IS NULL")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(view)}' AND RDB$VIEW_BLR IS NOT NULL")) > 0; - public override string[] GetTables() => ExecuteStringQuery( - "SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE COALESCE(RDB$SYSTEM_FLAG,0)=0 AND RDB$VIEW_BLR IS NULL").ToArray(); + public override string[] GetTables() => base.GetTables(); // Firebird has no server-wide SQL database catalog; only the attached database is visible. public override List GetDatabases() => [_connection.Database]; diff --git a/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs index 7c534561..7d6970ba 100644 --- a/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs @@ -30,10 +30,8 @@ public HanaTransformationProvider(Dialect dialect, IDbConnection connection, str public override string GenerateParameterNameParameter(int index) => "p" + index; private (string Schema, string Table) Name(string table) { - var parts = table.Split('.'); - if (parts.Length > 2 || parts.Any(string.IsNullOrWhiteSpace) || parts.Any(x => x.Contains('"'))) - throw new NotSupportedException("HANA names must be unquoted table or schema.table names. Embedded dots/quotes require explicit SQL."); - return (parts.Length == 2 ? parts[0] : _defaultSchema, parts[^1]); + var relation = SqlIdentifier.Catalog(table); + return (relation.Schema ?? (_defaultSchema == null ? null : SqlIdentifier.Parse(_defaultSchema).Single().Value), relation.Name); } public override string QuoteTableNameIfRequired(string table) { @@ -61,12 +59,7 @@ private bool Exists(string view, string table) } public override bool TableExists(string table) => Exists("TABLES", table); public override bool ViewExists(string table) => Exists("VIEWS", table); - public override string[] GetTables() - { - using var command = Catalog("SELECT TABLE_NAME FROM SYS.TABLES WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) ORDER BY TABLE_NAME", _defaultSchema); - using var reader = command.ExecuteReader(); - var names = new List(); while (reader.Read()) names.Add(reader.GetString(0)); return names.ToArray(); - } + public override string[] GetTables() => base.GetTables(); public override List GetDatabases() => [Convert.ToString(ExecuteScalar("SELECT DATABASE_NAME FROM SYS.M_DATABASE"))]; public override void SwitchDatabase(string databaseName) => throw new NotSupportedException("Connect to the target HANA tenant explicitly."); public override void CreateDatabases(string databaseName) => throw new NotSupportedException("HANA tenant administration requires an explicit SYSTEMDB connection and operation."); @@ -87,7 +80,11 @@ public override void ChangeColumn(string table, Column column) } public override void RemoveColumn(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} DROP ({QuoteColumnNameIfRequired(column)})"); public override void RemoveTable(string table) => ExecuteNonQuery("DROP TABLE " + QuoteTableNameIfRequired(table)); - public override void RenameTable(string table, string name) => ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(table)} TO {QuoteTableNameIfRequired(name)}"); + public override void RenameTable(string table, string name) + { + RenameTarget(table, name); + ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(table)} TO {QualifyInSameNamespace(table, SqlIdentifier.Parse(name).Last().Value)}"); + } public override void RenameColumn(string table, string column, string name) => ExecuteNonQuery($"RENAME COLUMN {QuoteTableNameIfRequired(table)}.{QuoteColumnNameIfRequired(column)} TO {QuoteColumnNameIfRequired(name)}"); public override void RemoveColumnDefaultValue(string table, string column) => AddColumnDefaultValue(table, column, RawSql.Insert("NULL")); public override void AddColumnDefaultValue(string table, string column, object value) @@ -170,7 +167,7 @@ public override string AddIndex(string table, Index index) throw new NotSupportedException("HANA index INCLUDE, clustered and filtered options are not supported by this provider."); if (index.KeyColumns?.Length is not > 0) throw new ArgumentException("Index key columns are required.", nameof(index)); var name = index.Name ?? "IX_" + Name(table).Table + "_" + string.Join("_", index.KeyColumns); - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {Dialect.QuoteIdentifier(name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(QuoteColumnNameIfRequired))})"); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {QualifyInSameNamespace(table, name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(QuoteColumnNameIfRequired))})"); return name; } public override Index[] GetIndexes(string table) diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 4e8f471d..e0599a4c 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -77,17 +77,16 @@ public override void AddTable(string name, string engine, params IDbField[] fiel } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM systables WHERE tabname='{Name(table)}' AND owner=USER AND tabtype='T'")) > 0; + $"SELECT COUNT(*) FROM systables WHERE tabname={ObjectSqlLiteral(table)} AND owner={NamespaceSql(table, "USER")} AND tabtype='T'")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM systables WHERE tabname='{Name(view)}' AND owner=USER AND tabtype='V'")) > 0; - public override string[] GetTables() => ExecuteStringQuery( - "SELECT tabname FROM systables WHERE owner=USER AND tabid>=100 AND tabtype='T'").Select(n => n.Trim()).ToArray(); + $"SELECT COUNT(*) FROM systables WHERE tabname={ObjectSqlLiteral(view)} AND owner={NamespaceSql(view, "USER")} AND tabtype='V'")) > 0; + public override string[] GetTables() => base.GetTables(); public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM sysmaster:sysdatabases"); public override string[] GetConstraints(string table) => ExecuteStringQuery( - $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}'").Select(n => n.Trim()).ToArray(); + $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)}").Select(n => n.Trim()).ToArray(); public override bool ConstraintExists(string table, string name) => GetConstraints(table).Any(n => n == name || n == Name(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( - $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype='P'").FirstOrDefault()?.Trim(); + $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} AND c.constrtype='P'").FirstOrDefault()?.Trim(); public override Column[] GetColumns(string table) { @@ -99,7 +98,7 @@ public override Column[] GetColumns(string table) FROM syscolumns c JOIN systables t ON c.tabid=t.tabid LEFT JOIN sysdefaults d ON d.tabid=c.tabid AND d.colno=c.colno AND d.class='T' LEFT JOIN sysxtdtypes x ON x.extended_id=c.extended_id - WHERE t.owner=USER AND t.tabname='{Name(table)}' ORDER BY c.colno + WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} ORDER BY c.colno """); while (reader.Read()) { @@ -144,7 +143,7 @@ public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) { var rows = new List<(string Name, string Parent, string ChildIndex, string ParentIndex, string Delete)>(); using (var command = CreateCommand()) - using (var reader = ExecuteQuery(command, $"SELECT c.constrname,t2.tabname,c.idxname,p.idxname,r.delrule FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN sysreferences r ON r.constrid=c.constrid JOIN sysconstraints p ON p.constrid=r.primary JOIN systables t2 ON t2.tabid=r.ptabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND t2.owner=USER ORDER BY c.constrname")) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,TRIM(t2.owner) || '.' || TRIM(t2.tabname),c.idxname,p.idxname,r.delrule FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN sysreferences r ON r.constrid=c.constrid JOIN sysconstraints p ON p.constrid=r.primary JOIN systables t2 ON t2.tabid=r.ptabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} ORDER BY c.constrname")) while (reader.Read()) rows.Add((reader.GetString(0).Trim(), reader.GetString(1).Trim(), reader.GetString(2).Trim(), reader.GetString(3).Trim(), reader.GetString(4).Trim())); var childIndexes = GetIndexes(table).ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); @@ -159,7 +158,7 @@ public override TableConstraint[] GetTableConstraints(string table) var indexes = GetIndexes(table).ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); var constraints = new List(); using (var command = CreateCommand()) - using (var reader = ExecuteQuery(command, $"SELECT c.constrname,c.constrtype,c.idxname FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype IN ('P','U') ORDER BY c.constrname")) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,c.constrtype,c.idxname FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} AND c.constrtype IN ('P','U') ORDER BY c.constrname")) { while (reader.Read()) { @@ -172,7 +171,7 @@ public override TableConstraint[] GetTableConstraints(string table) } var checks = new Dictionary(); using (var command = CreateCommand()) - using (var reader = ExecuteQuery(command, $"SELECT c.constrname,ch.checktext FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN syschecks ch ON ch.constrid=c.constrid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype='C' AND ch.type='T' ORDER BY c.constrname,ch.seqno")) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,ch.checktext FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN syschecks ch ON ch.constrid=c.constrid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} AND c.constrtype='C' AND ch.type='T' ORDER BY c.constrname,ch.seqno")) while (reader.Read()) { var name = reader.GetString(0).Trim(); @@ -225,7 +224,7 @@ public override Index[] GetIndexes(string table) using var reader = ExecuteQuery(cmd, $""" SELECT i.*, c.constrtype FROM sysindexes i JOIN systables t ON t.tabid=i.tabid LEFT JOIN sysconstraints c ON c.tabid=i.tabid AND c.idxname=i.idxname AND c.constrtype IN ('P','U') - WHERE t.owner=USER AND t.tabname='{Name(table)}' + WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} """); while (reader.Read()) { @@ -249,14 +248,14 @@ private Dictionary GetColumnsForIndex(string table) { var columns = new Dictionary(); using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $"SELECT c.colno,c.colname FROM syscolumns c JOIN systables t ON t.tabid=c.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}'"); + using var reader = ExecuteQuery(cmd, $"SELECT c.colno,c.colname FROM syscolumns c JOIN systables t ON t.tabid=c.tabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)}"); while (reader.Read()) columns[Convert.ToInt32(reader.GetValue(0))] = reader.GetString(1).Trim(); return columns; } public override void RemoveAllIndexes(string table) { - var constraints = ExecuteStringQuery($"SELECT c.constrname FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype IN ('P','U')"); + var constraints = ExecuteStringQuery($"SELECT c.constrname FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid WHERE t.owner={NamespaceSql(table, "USER")} AND t.tabname={ObjectSqlLiteral(table)} AND c.constrtype IN ('P','U')"); foreach (var name in constraints) RemoveConstraint(table, name.Trim()); foreach (var index in GetIndexes(table)) RemoveIndex(table, index.Name); } @@ -267,21 +266,21 @@ public override string AddIndex(string table, Index index) if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) throw new NotSupportedException("This Informix provider supports ordinary and unique indexes without INCLUDE, filters or clustering."); - var name = index.Name ?? $"ix_{table}_{string.Join("_", index.KeyColumns)}"; - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {name} ON {table} ({string.Join(", ", index.KeyColumns)})"); + var name = index.Name ?? $"ix_{QuoteTableNameIfRequired(table)}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {QualifyInSameNamespace(table, name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns)})"); return name; } - public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} ADD ({sqlColumn})"); - public override void ChangeColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} MODIFY ({sqlColumn})"); - public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {tableName} DROP ({column})"); + public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD ({sqlColumn})"); + public override void ChangeColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} MODIFY ({sqlColumn})"); + public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP ({column})"); public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) { if (!ColumnExists(tableName, oldColumnName) || ColumnExists(tableName, newColumnName)) throw new MigrationException("Source column must exist and destination column must not exist."); - ExecuteNonQuery($"RENAME COLUMN {tableName}.{oldColumnName} TO {newColumnName}"); + ExecuteNonQuery($"RENAME COLUMN {QuoteTableNameIfRequired(tableName)}.{oldColumnName} TO {newColumnName}"); } - public override void RenameTable(string oldName, string newName) => ExecuteNonQuery($"RENAME TABLE {oldName} TO {newName}"); + public override void RenameTable(string oldName, string newName) => ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(oldName)} TO {RenameTarget(oldName, newName)}"); public override void RemoveColumnDefaultValue(string table, string column) { var existing = GetColumns(table).Single(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); @@ -290,11 +289,11 @@ public override void RemoveColumnDefaultValue(string table, string column) } public override void AddPrimaryKey(string name, string table, params string[] columns) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddUniqueConstraint(string name, string table, params string[] columns) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD CONSTRAINT UNIQUE ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddCheckConstraint(string name, string table, string checkSql) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) @@ -312,6 +311,6 @@ public override void AddForeignKey(string name, string childTable, string[] chil ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict => "", _ => throw new NotSupportedException("Informix supports cascading deletes or its default restrictive referential action.") }; - ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))}){action} CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))}){action} CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); } } diff --git a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs index ac69a823..3f433436 100644 --- a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; namespace DotNetProjects.Migrator.Providers.Impl.Ingres; @@ -30,13 +32,48 @@ public override List GetDatabases() throw new NotImplementedException(); } - public override bool ConstraintExists(string table, string name) - { - throw new NotImplementedException(); - } + private string Predicate(string table, string owner = "table_owner", string name = "table_name") => + $"{owner}={NamespaceSql(table, "DBMSINFO('username')")} AND {name}={ObjectSqlLiteral(table)}"; + + public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM iitables WHERE {Predicate(table)} AND table_type='T'")) > 0; + + public override bool ViewExists(string table) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM iitables WHERE {Predicate(table)} AND table_type='V'")) > 0; + + public override string[] GetConstraints(string table) => + ExecuteStringQuery($"SELECT DISTINCT constraint_name FROM iiconstraints WHERE {Predicate(table, "schema_name")}").Select(n => n.TrimEnd()).ToArray(); - public override bool IndexExists(string table, string name) + public override bool ConstraintExists(string table, string name) => + GetConstraints(table).Contains(name, StringComparer.OrdinalIgnoreCase); + + protected override string GetPrimaryKeyConstraintName(string table) => + ExecuteStringQuery($"SELECT DISTINCT constraint_name FROM iiconstraints WHERE {Predicate(table, "schema_name")} AND constraint_type='P'").FirstOrDefault()?.TrimEnd(); + + public override bool IndexExists(string table, string name) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM iiindexes WHERE {Predicate(table, "base_owner", "base_name")} AND index_name={SqlLiteral(name)}")) > 0; + + public override Column[] GetColumns(string table) { - throw new NotImplementedException(); + var columns = new List(); + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $"SELECT column_name,column_datatype,column_length,column_scale,column_nulls,column_default_val FROM iicolumns WHERE {Predicate(table)} ORDER BY column_sequence"); + while (reader.Read()) + { + var type = reader.GetString(1).Trim().ToLowerInvariant() switch + { + "integer" or "int" => DbType.Int32, "smallint" => DbType.Int16, "bigint" => DbType.Int64, + "decimal" or "numeric" or "money" => DbType.Decimal, "float" or "double precision" => DbType.Double, + "real" => DbType.Single, "date" or "ansidate" => DbType.Date, + "timestamp without time zone" or "timestamp" or "ingresdate" => DbType.DateTime, + "boolean" => DbType.Boolean, _ => DbType.String + }; + var column = new Column(reader.GetString(0).TrimEnd(), type) { IsNullable = reader.GetString(4).Trim() == "Y" }; + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)); + if (type == DbType.Decimal) { column.Precision = Convert.ToInt32(reader.GetValue(2)); column.Scale = Convert.ToInt32(reader.GetValue(3)); } + if (!reader.IsDBNull(5)) column.DefaultValue = CatalogDefaultValue.Parse(reader.GetString(5), type); + columns.Add(column); + } + return columns.ToArray(); } } diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 9162e48a..08b6d4a4 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -43,88 +43,39 @@ public override void RemoveForeignKey(string table, string name) { if (ForeignKeyExists(table, name)) { - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP FOREIGN KEY {1}", table, _dialect.QuoteIdentifier(name))); + ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP FOREIGN KEY {1}", QuoteTableNameIfRequired(table), _dialect.QuoteIdentifier(name))); } } public override void RemoveAllIndexes(string table) { - var qry = string.Format(@"SELECT k.TABLE_NAME, i.CONSTRAINT_NAME, i.CONSTRAINT_TYPE - FROM information_schema.KEY_COLUMN_USAGE k - INNER JOIN information_schema.TABLE_CONSTRAINTS i - ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND i.TABLE_NAME = k.TABLE_NAME - WHERE k.REFERENCED_TABLE_SCHEMA='{0}' AND - (k.REFERENCED_TABLE_NAME='{1}') OR (k.TABLE_NAME='{1}')", GetDatabase(), table); - - var l = new List>(); - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, qry)) - { - while (reader.Read()) - { - l.Add(new Tuple(reader.GetString(0), reader.GetString(1), reader.GetString(2))); - } - } - - foreach (var tuple in l) + RemoveAllForeignKeys(table, null); + foreach (var index in GetIndexes(table)) { - if (tuple.Item3 == "FOREIGN KEY") - { - RemoveForeignKey(tuple.Item1, tuple.Item2); - } - else if (tuple.Item3 == "PRIMARY KEY") - { - try - { - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP PRIMARY KEY", table)); - } - catch (Exception) - { } - } - else if (tuple.Item3 == "UNIQUE") - { - RemoveIndex(tuple.Item1, tuple.Item2); - } + if (index.PrimaryKey) RemoveConstraint(table, "PRIMARY"); + else RemoveIndex(table, index.Name); } } public override void RemoveAllForeignKeys(string tableName, string columnName) { - var qry = string.Format(@"SELECT k.TABLE_NAME, i.CONSTRAINT_NAME - FROM information_schema.KEY_COLUMN_USAGE k - INNER JOIN information_schema.TABLE_CONSTRAINTS i - ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND i.TABLE_NAME = k.TABLE_NAME - WHERE k.REFERENCED_TABLE_SCHEMA='{0}' AND i.CONSTRAINT_TYPE = 'FOREIGN KEY' AND - (k.REFERENCED_TABLE_NAME='{1}' AND REFERENCED_COLUMN_NAME='{2}') OR (k.TABLE_NAME='{1}' AND COLUMN_NAME='{2}')", GetDatabase(), tableName, columnName); - - if (string.IsNullOrEmpty(columnName)) - { - qry = string.Format(@"SELECT k.TABLE_NAME, i.CONSTRAINT_NAME - FROM information_schema.KEY_COLUMN_USAGE k - INNER JOIN information_schema.TABLE_CONSTRAINTS i - ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND i.TABLE_NAME = k.TABLE_NAME - WHERE k.REFERENCED_TABLE_SCHEMA='{0}' AND i.CONSTRAINT_TYPE = 'FOREIGN KEY' AND - (k.REFERENCED_TABLE_NAME='{1}') OR (k.TABLE_NAME='{1}')", GetDatabase(), tableName); - } - var l = new List>(); - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, qry)) - { - while (reader.Read()) - { - l.Add(new Tuple(reader.GetString(0), reader.GetString(1))); - } - } - - foreach (var tuple in l) - { - RemoveForeignKey(tuple.Item1, tuple.Item2); - } + var scope = NamespaceSql(tableName, "DATABASE()"); + var name = ObjectSqlLiteral(tableName); + var column = string.IsNullOrEmpty(columnName) ? "" : " AND k.COLUMN_NAME=" + SqlLiteral(columnName); + var parentColumn = string.IsNullOrEmpty(columnName) ? "" : " AND k.REFERENCED_COLUMN_NAME=" + SqlLiteral(columnName); + var keys = new List<(string Table, string Name)>(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $@"SELECT DISTINCT k.TABLE_SCHEMA,k.TABLE_NAME,k.CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE k WHERE k.REFERENCED_TABLE_NAME IS NOT NULL AND + ((k.TABLE_SCHEMA={scope} AND k.TABLE_NAME={name}{column}) OR + (k.REFERENCED_TABLE_SCHEMA={scope} AND k.REFERENCED_TABLE_NAME={name}{parentColumn}))")) + while (reader.Read()) keys.Add((_dialect.QuoteIdentifier(reader.GetString(0)) + "." + _dialect.QuoteIdentifier(reader.GetString(1)), reader.GetString(2))); + foreach (var key in keys) RemoveForeignKey(key.Table, key.Name); } public override void RemoveConstraint(string table, string name) { - var type = Convert.ToString(ExecuteScalar($"SELECT CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")); + var type = Convert.ToString(ExecuteScalar($"SELECT CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)} AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")); var action = type switch { "PRIMARY KEY" => "DROP PRIMARY KEY", @@ -133,50 +84,24 @@ public override void RemoveConstraint(string table, string name) "CHECK" => (_dialect is MariaDBDialect ? "DROP CONSTRAINT " : "DROP CHECK ") + _dialect.QuoteIdentifier(name), _ => throw new MigrationException($"Constraint '{name}' does not exist") }; - ExecuteNonQuery($"ALTER TABLE {_dialect.Quote(table)} {action}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} {action}"); } public override bool ConstraintExists(string table, string name) { - return Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")) > 0; + return Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)} AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")) > 0; } - public bool ForeignKeyExists(string table, string name) - { - if (!TableExists(table)) - { - return false; - } - - var sqlConstraint = string.Format(@"SELECT distinct i.CONSTRAINT_NAME - FROM information_schema.TABLE_CONSTRAINTS i - INNER JOIN information_schema.KEY_COLUMN_USAGE k - ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME - WHERE i.CONSTRAINT_TYPE = 'FOREIGN KEY' - AND i.TABLE_SCHEMA = '{1}' - AND i.TABLE_NAME = '{0}';", table, GetDatabase()); - - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, sqlConstraint); - - while (reader.Read()) - { - if (reader["CONSTRAINT_NAME"].ToString().ToLower() == name.ToLower()) - { - return true; - } - } - - return false; - } + public bool ForeignKeyExists(string table, string name) => + TableExists(table) && ExecuteStringQuery($"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)} AND CONSTRAINT_TYPE='FOREIGN KEY'").Any(key => key.Equals(name, StringComparison.OrdinalIgnoreCase)); public override Index[] GetIndexes(string table) { if (!TableExists(table)) return []; - var constraints = ExecuteStringQuery($"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_TYPE='UNIQUE'").ToHashSet(StringComparer.OrdinalIgnoreCase); + var constraints = ExecuteStringQuery($"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)} AND CONSTRAINT_TYPE='UNIQUE'").ToHashSet(StringComparer.OrdinalIgnoreCase); var indexes = new Dictionary(); using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $"SHOW INDEX FROM {_dialect.Quote(table)}"); + using var reader = ExecuteQuery(cmd, $"SHOW INDEX FROM {QuoteTableNameIfRequired(table)}"); var columns = new Dictionary>(); while (reader.Read()) { @@ -192,6 +117,9 @@ public override Index[] GetIndexes(string table) return indexes.Values.ToArray(); } + public override string[] GetConstraints(string table) => + ExecuteStringQuery($"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)}").ToArray(); + public override bool PrimaryKeyExists(string table, string name) { return ConstraintExists(table, "PRIMARY"); @@ -201,7 +129,7 @@ public override Column[] GetColumns(string table) { var columns = new List(); using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY, COLUMN_TYPE, NUMERIC_PRECISION, NUMERIC_SCALE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' ORDER BY ORDINAL_POSITION"); + using var reader = ExecuteQuery(cmd, $"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY, COLUMN_TYPE, NUMERIC_PRECISION, NUMERIC_SCALE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(table)} ORDER BY ORDINAL_POSITION"); while (reader.Read()) { var type = reader.GetString(1) switch @@ -261,24 +189,11 @@ private object ReadDefault(string value, DbType type, string extra) }; } - public override string[] GetTables() - { - var tables = new List(); - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, "SHOW TABLES")) - { - while (reader.Read()) - { - tables.Add((string)reader[0]); - } - } - - return tables.ToArray(); - } + public override string[] GetTables() => base.GetTables(); public override void ChangeColumn(string table, string sqlColumn) { - ExecuteNonQuery(string.Format("ALTER TABLE {0} MODIFY {1}", table, sqlColumn)); + ExecuteNonQuery(string.Format("ALTER TABLE {0} MODIFY {1}", QuoteTableNameIfRequired(table), sqlColumn)); } public override void AddTable(string name, params IDbField[] columns) @@ -288,15 +203,23 @@ public override void AddTable(string name, params IDbField[] columns) public override void AddTable(string name, string engine, string columns) { - var sqlCreate = string.Format("CREATE TABLE {0} ({1}) ENGINE = {2}", name, columns, engine); + var sqlCreate = string.Format("CREATE TABLE {0} ({1}) ENGINE = {2}", QuoteTableNameIfRequired(name), columns, engine ?? "INNODB"); ExecuteNonQuery(sqlCreate); } + public override void RenameTable(string oldName, string newName) + { + var target = SqlIdentifier.Parse(newName).Length == 1 + ? QualifyInSameNamespace(oldName, SqlIdentifier.Parse(newName)[0].Value) + : QuoteTableNameIfRequired(newName); + ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(oldName)} TO {target}"); + } + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) { if (!ColumnExists(tableName, oldColumnName) || ColumnExists(tableName, newColumnName)) throw new MigrationException("Source column must exist and destination column must not exist."); - ExecuteNonQuery($"ALTER TABLE {_dialect.Quote(tableName)} RENAME COLUMN {_dialect.Quote(oldColumnName)} TO {_dialect.Quote(newColumnName)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} RENAME COLUMN {_dialect.Quote(oldColumnName)} TO {_dialect.Quote(newColumnName)}"); } public string GetDatabase() @@ -308,7 +231,7 @@ public override void RemoveIndex(string table, string name) { if (IndexExists(table, name)) { - ExecuteNonQuery(string.Format("DROP INDEX {1} ON {0}", table, _dialect.QuoteIdentifier(name))); + ExecuteNonQuery(string.Format("DROP INDEX {1} ON {0}", QuoteTableNameIfRequired(table), _dialect.QuoteIdentifier(name))); } } @@ -327,10 +250,10 @@ public override string Concatenate(params string[] strings) return "CONCAT(" + string.Join(", ", strings) + ")"; } public override bool TableExists(string table) => - Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE' AND TABLE_NAME='{table.Replace("'", "''")}'")) > 0; + Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA={NamespaceSql(table, "DATABASE()")} AND TABLE_TYPE='BASE TABLE' AND TABLE_NAME={ObjectSqlLiteral(table)}")) > 0; public override bool ViewExists(string view) => - Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{view.Replace("'", "''")}'")) > 0; + Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA={NamespaceSql(view, "DATABASE()")} AND TABLE_NAME={ObjectSqlLiteral(view)}")) > 0; public override string AddIndex(string table, Index index) { @@ -338,7 +261,7 @@ public override string AddIndex(string table, Index index) if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) throw new NotSupportedException("MySQL and MariaDB do not support included columns, filtered indexes or explicit clustered indexes."); var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {_dialect.QuoteIdentifier(name)} ON {_dialect.Quote(table)} ({string.Join(", ", index.KeyColumns.Select(_dialect.Quote))})"); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {_dialect.QuoteIdentifier(name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(_dialect.Quote))})"); return name; } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 5c123a28..ed6bbcf3 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -81,7 +81,7 @@ public override string AddIndex(string table, Index index) throw new MigrationException($"You cannot use unique together with functional expressions in Oracle ({nameof(FilterItem)})."); } - var name = QuoteConstraintNameIfRequired(index.Name); + var name = QualifyInSameNamespace(table, index.Name); table = QuoteTableNameIfRequired(table); List singleFilterStrings = []; @@ -283,22 +283,7 @@ public override List GetDatabases() throw new NotImplementedException(); } - public override string[] GetTables() - { - var tables = new List(); - - using (var cmd = CreateCommand()) - using (var reader = - ExecuteQuery(cmd, "SELECT table_name FROM user_tables")) - { - while (reader.Read()) - { - tables.Add(reader[0].ToString()); - } - } - - return tables.ToArray(); - } + public override string[] GetTables() => base.GetTables(); public override Column[] GetColumns(string table) { diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 687e7aa9..bdcd5051 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -435,19 +435,7 @@ public override void DropDatabases(string databaseName) ExecuteNonQuery(string.Format("DROP DATABASE {0}", _dialect.Quote(databaseName))); } - public override string[] GetTables() - { - var tables = new List(); - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) - { - while (reader.Read()) - { - tables.Add((string)reader[0]); - } - } - return [.. tables]; - } + public override string[] GetTables() => base.GetTables(); public override int GetColumnContentSize(string table, string columnName) { diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs index 3ba2ff1e..f2ec6814 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs @@ -80,7 +80,11 @@ public static string Generate(Dialect dialect, string quotedTable, IDbField[] fi var match = ValidateMatch(fk.Match); var sourceColumnNamesQuotedString = string.Join(", ", fk.ChildColumns.Select(dialect.QuoteColumnNameIfRequired)); var parentColumnNamesQuotedString = string.Join(", ", fk.ParentColumns.Select(dialect.QuoteColumnNameIfRequired)); - var parentTableNameQuoted = dialect.QuoteTableNameIfRequired(fk.ParentTable); + var childRelation = SqlIdentifier.Catalog(quotedTable); + var parentRelation = SqlIdentifier.Catalog(fk.ParentTable); + if (parentRelation.Schema != null && !string.Equals(parentRelation.Schema, childRelation.Schema ?? "main", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("SQLite foreign keys cannot reference another database namespace."); + var parentTableNameQuoted = dialect.QuoteIdentifier(parentRelation.Name); var foreignKeySql = (fk.Name == null ? "" : $"CONSTRAINT {dialect.QuoteIdentifier(fk.Name)} ") + $"FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}" + diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 923f98bb..e10b8907 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -35,6 +35,19 @@ public SQLiteTransformationProvider(Dialect dialect, IDbConnection connection, s { } + private string Namespace(string table) => CatalogRelation(table).Schema ?? "main"; + private string Catalog(string table) => _dialect.QuoteIdentifier(Namespace(table)) + ".sqlite_master"; + private string Pragma(string operation, string table) => + $"PRAGMA {_dialect.QuoteIdentifier(Namespace(table))}.{operation}({SqlLiteral(CatalogRelation(table).Name)})"; + private string QualifiedSibling(string table, string name) => + _dialect.QuoteIdentifier(Namespace(table)) + "." + _dialect.QuoteIdentifier(name); + + private IEnumerable NamespaceTables(string table) => + GetTables(_dialect.QuoteIdentifier(Namespace(table))).Select(n => QualifiedSibling(table, n)); + private bool SameTable(string candidate, string table) => + string.Equals(SqlIdentifier.Catalog(candidate).Name, CatalogRelation(table).Name, StringComparison.OrdinalIgnoreCase) + && (SqlIdentifier.Catalog(candidate).Schema == null || string.Equals(Namespace(candidate), Namespace(table), StringComparison.OrdinalIgnoreCase)); + protected virtual void CreateConnection(string providerName) { if (string.IsNullOrEmpty(providerName)) @@ -132,8 +145,8 @@ public string GetSqlCreateTableScript(string table) string sqlCreateTableScript = null; using var cmd = CreateCommand(); - var parameter = cmd.CreateParameter(); parameter.ParameterName = "@name"; parameter.Value = table; cmd.Parameters.Add(parameter); - using var reader = ExecuteQuery(cmd, "SELECT sql FROM sqlite_master WHERE type='table' AND name=@name COLLATE NOCASE"); + var parameter = cmd.CreateParameter(); parameter.ParameterName = "@name"; parameter.Value = CatalogRelation(table).Name; cmd.Parameters.Add(parameter); + using var reader = ExecuteQuery(cmd, $"SELECT sql FROM {Catalog(table)} WHERE type='table' AND name=@name COLLATE NOCASE"); if (reader.Read()) sqlCreateTableScript = reader.IsDBNull(0) ? null : reader.GetString(0); return sqlCreateTableScript; @@ -248,7 +261,7 @@ private List GetForeignKeyListItems(string tableNameNo List pragmaForeignKeyListItems = []; using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, $"PRAGMA foreign_key_list('{QuoteTableNameIfRequired(tableNameNotQuoted)}')")) + using (var reader = ExecuteQuery(cmd, Pragma("foreign_key_list", tableNameNotQuoted))) { while (reader.Read()) { @@ -395,10 +408,10 @@ public override void RemoveForeignKey(string table, string name) public override void RemoveAllForeignKeys(string tableName, string columnName) { - bool Matches(string name) => string.Equals(name, tableName, StringComparison.OrdinalIgnoreCase); + bool Matches(string name) => SameTable(name, tableName); bool Includes(string[] columns) => string.IsNullOrEmpty(columnName) || columns.Contains(columnName, StringComparer.OrdinalIgnoreCase); var tables = new List(); - foreach (var table in GetTables()) + foreach (var table in NamespaceTables(tableName)) { var info = GetSQLiteTableInfo(table); var removed = info.ForeignKeys.RemoveAll(f => (Matches(table) && Includes(f.ChildColumns)) @@ -441,7 +454,7 @@ public string[] GetCreateIndexSqlStrings(string table) var sqlStrings = new List(); using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, string.Format("SELECT sql FROM sqlite_master WHERE type='index' AND sql NOT NULL AND lower(tbl_name)=lower('{0}')", table))) + using (var reader = ExecuteQuery(cmd, $"SELECT sql FROM {Catalog(table)} WHERE type='index' AND sql NOT NULL AND tbl_name={ObjectSqlLiteral(table)} COLLATE NOCASE")) { while (reader.Read()) { @@ -477,7 +490,7 @@ public override void RemoveColumn(string tableName, string column) && TableExists(tableName) && CanDropColumnNatively(tableName, column)) { // Native SQLite validates trigger and view dependencies atomically. - ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} DROP COLUMN {Dialect.QuoteIdentifier(column)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP COLUMN {Dialect.QuoteIdentifier(column)}"); return; } if (IsPragmaForeignKeysOn()) throw new Exception($"{nameof(RemoveColumn)} requires foreign keys off."); @@ -487,12 +500,12 @@ public override void RemoveColumn(string tableName, string column) var sqliteInfoMainTable = GetSQLiteTableInfo(tableName); ValidateColumnRemoval(sqliteInfoMainTable, column); var affected = new List(); - foreach (var name in GetTables()) + foreach (var name in NamespaceTables(tableName)) { - var info = string.Equals(name, tableName, StringComparison.OrdinalIgnoreCase) + var info = SameTable(name, tableName) ? sqliteInfoMainTable : GetSQLiteTableInfo(name); var references = info.ForeignKeys.Where(f => - string.Equals(f.ParentTable, tableName, StringComparison.OrdinalIgnoreCase) + SameTable(f.ParentTable, tableName) && f.ParentColumns.Contains(column, StringComparer.OrdinalIgnoreCase)).ToArray(); if (references.Any(f => f.ParentColumns.Length > 1)) throw new MigrationException($"You need to delete/adjust the FK in table {name} pointing to {tableName}."); @@ -519,7 +532,7 @@ private bool CanDropColumnNatively(string tableName, string column) || info.Uniques.Any(u => u.KeyColumns.Any(Matches)) || info.Indexes.Any(i => i.KeyColumns.Any(Matches) || i.FilterItems.Count != 0) || info.ForeignKeys.Any(f => f.ChildColumns.Any(Matches)) - || GetTables().Any(t => GetForeignKeyConstraints(t).Any(f => f.ParentTable.Equals(tableName, StringComparison.OrdinalIgnoreCase) && f.ParentColumns.Any(Matches))); + || NamespaceTables(tableName).Any(t => GetForeignKeyConstraints(t).Any(f => SameTable(f.ParentTable, tableName) && f.ParentColumns.Any(Matches))); return !dependent; } @@ -622,7 +635,7 @@ public override void RenameColumn(string tableName, string oldColumnName, string if (Version.Parse(Convert.ToString(ExecuteScalar("SELECT sqlite_version()"))) >= new Version(3, 26, 0)) { if (string.IsNullOrWhiteSpace(newColumnName)) throw new ArgumentException("A column name is required."); - ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} RENAME COLUMN {Dialect.QuoteIdentifier(oldColumnName)} TO {Dialect.QuoteIdentifier(newColumnName)}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} RENAME COLUMN {Dialect.QuoteIdentifier(oldColumnName)} TO {Dialect.QuoteIdentifier(newColumnName)}"); return; } @@ -682,12 +695,12 @@ public override void RenameColumn(string tableName, string oldColumnName, string try { RecreateTable(sqliteTableInfo); - foreach (var otherTable in GetTables()) + foreach (var otherTable in NamespaceTables(tableName)) { - if (string.Equals(otherTable, tableName, StringComparison.OrdinalIgnoreCase)) continue; + if (SameTable(otherTable, tableName)) continue; var otherInfo = GetSQLiteTableInfo(otherTable); var references = otherInfo.ForeignKeys.Where(f => - string.Equals(f.ParentTable, tableName, StringComparison.OrdinalIgnoreCase) + SameTable(f.ParentTable, tableName) && f.ParentColumns.Contains(oldColumnName, StringComparer.OrdinalIgnoreCase)).ToArray(); if (references.Length == 0) continue; foreach (var foreignKey in references) @@ -816,15 +829,16 @@ public SQLiteTableInfo GetSQLiteTableInfo(string tableName) public bool CheckForeignKeyIntegrity() { - - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, "PRAGMA foreign_key_check"); - - if (reader.Read()) + var schemas = new List(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, "PRAGMA database_list")) + while (reader.Read()) schemas.Add(reader.GetString(1)); + foreach (var schema in schemas) { - return false; + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $"PRAGMA {_dialect.QuoteIdentifier(schema)}.foreign_key_check"); + if (reader.Read()) return false; } - return true; } @@ -863,14 +877,14 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) throw new NotSupportedException("This table contains SQLite features that cannot be reconstructed faithfully. Use native SQL."); if (GetCreateIndexSqlStrings(oldName).Any(sql => SQLiteConstraintParser.HasKeyword(sql, "COLLATE"))) throw new NotSupportedException("Rebuilding indexes with explicit collations requires native SQL."); - var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND lower(tbl_name)=lower('{0}')", oldName.Replace("'", "''")); + var triggers = ExecuteStringQuery($"SELECT sql FROM {Catalog(oldName)} WHERE type='trigger' AND tbl_name={ObjectSqlLiteral(oldName)} COLLATE NOCASE"); if (triggers.Count > 0 && (oldName != sqliteTableInfo.TableNameMapping.NewName || sqliteTableInfo.ColumnMappings.Any(m => m.OldName != null && m.OldName != m.NewName))) throw new NotSupportedException("Use native SQLite rename when triggers reference renamed objects."); var originalColumns = GetColumns(oldName); if (triggers.Count > 0 && originalColumns.Any(c => !sqliteTableInfo.Columns.Any(n => n.Name.Equals(c.Name, StringComparison.OrdinalIgnoreCase)))) throw new NotSupportedException("Removing columns from a table with triggers requires native SQLite alteration or explicit trigger recreation."); - var sequence = TableExists("sqlite_sequence") - ? ExecuteScalar("SELECT seq FROM sqlite_sequence WHERE name='" + oldName.Replace("'", "''") + "'") : null; + var sequence = TableExists(QualifiedSibling(oldName, "sqlite_sequence")) + ? ExecuteScalar($"SELECT seq FROM {QualifiedSibling(oldName, "sqlite_sequence")} WHERE name={ObjectSqlLiteral(oldName)}") : null; var highWater = sequence == null || sequence == DBNull.Value ? (long?)null : Convert.ToInt64(sequence); var foreignKeys = IsPragmaForeignKeysOn(); if (HasActiveTransaction && foreignKeys) @@ -887,12 +901,17 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) RecreateTableCore(sqliteTableInfo); if (highWater.HasValue && sqliteTableInfo.Columns.Any(c => c.IsIdentity)) { - var sequenceName = sqliteTableInfo.TableNameMapping.NewName.Replace("'", "''"); + var sequenceName = CatalogRelation(sqliteTableInfo.TableNameMapping.NewName).Name.Replace("'", "''"); + var sequenceTable = QualifiedSibling(oldName, "sqlite_sequence"); var sequenceValue = highWater.Value.ToString(CultureInfo.InvariantCulture); - ExecuteNonQuery($"UPDATE sqlite_sequence SET seq=MAX(seq, {sequenceValue}) WHERE name='{sequenceName}'"); - ExecuteNonQuery($"INSERT INTO sqlite_sequence(name, seq) SELECT '{sequenceName}', {sequenceValue} WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name='{sequenceName}')"); + ExecuteNonQuery($"UPDATE {sequenceTable} SET seq=MAX(seq, {sequenceValue}) WHERE name='{sequenceName}'"); + ExecuteNonQuery($"INSERT INTO {sequenceTable}(name, seq) SELECT '{sequenceName}', {sequenceValue} WHERE NOT EXISTS (SELECT 1 FROM {sequenceTable} WHERE name='{sequenceName}')"); + } + foreach (var trigger in triggers) + { + var qualifiedTrigger = Regex.Replace(trigger, @"^(CREATE\s+(?:TEMP(?:ORARY)?\s+)?TRIGGER\s+(?:IF\s+NOT\s+EXISTS\s+)?)", "$1" + _dialect.QuoteIdentifier(Namespace(oldName)) + ".", RegexOptions.IgnoreCase); + ExecuteNonQuery(qualifiedTrigger); } - foreach (var trigger in triggers) ExecuteNonQuery(trigger); if (ownsTransaction && !CheckForeignKeyIntegrity()) throw new MigrationException("SQLite rebuild would leave invalid foreign keys."); if (ownsTransaction) Commit(); } @@ -915,8 +934,8 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) private void RecreateTableCore(SQLiteTableInfo sqliteTableInfo) { var sourceTableQuoted = QuoteTableNameIfRequired(sqliteTableInfo.TableNameMapping.OldName); - var targetIntermediateTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}{IntermediateTableSuffix}"); - var targetTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}"); + var targetIntermediateTableQuoted = QualifiedSibling(sqliteTableInfo.TableNameMapping.NewName, CatalogRelation(sqliteTableInfo.TableNameMapping.NewName).Name + IntermediateTableSuffix); + var targetTableQuoted = RenameTarget(sqliteTableInfo.TableNameMapping.OldName, sqliteTableInfo.TableNameMapping.NewName); var columns = sqliteTableInfo.Columns.Select(c => c.CopyDefinition()).ToArray(); var columnDbFields = columns.Cast(); @@ -1114,7 +1133,7 @@ public override int TruncateTable(string table) public override bool TableExists(string table) { using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, string.Format("SELECT name FROM sqlite_master WHERE type='table' and lower(name)=lower('{0}')", table)); + using var reader = ExecuteQuery(cmd, $"SELECT name FROM {Catalog(table)} WHERE type='table' AND name={ObjectSqlLiteral(table)} COLLATE NOCASE"); return reader.Read(); } @@ -1122,7 +1141,7 @@ public override bool TableExists(string table) public override bool ViewExists(string view) { using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, string.Format("SELECT name FROM sqlite_master WHERE type='view' and lower(name)=lower('{0}')", view)); + using var reader = ExecuteQuery(cmd, $"SELECT name FROM {Catalog(view)} WHERE type='view' AND name={ObjectSqlLiteral(view)} COLLATE NOCASE"); return reader.Read(); } @@ -1154,21 +1173,7 @@ public override string[] GetConstraints(string table) return names; } - public override string[] GetTables() - { - var tables = new List(); - - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")) - { - while (reader.Read()) - { - tables.Add((string)reader[0]); - } - } - - return [.. tables]; - } + public override string[] GetTables() => base.GetTables(); public override Column[] GetColumns(string tableName) { @@ -1236,7 +1241,7 @@ public bool ColumnMatch(string column, string columnDef) public override bool IndexExists(string table, string name) { using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, string.Format("SELECT name FROM sqlite_master WHERE type='index' and lower(name)=lower('{0}')", name)); + using var reader = ExecuteQuery(cmd, $"SELECT name FROM {Catalog(table)} WHERE type='index' AND name={SqlLiteral(name)} COLLATE NOCASE AND tbl_name={ObjectSqlLiteral(table)} COLLATE NOCASE"); return reader.Read(); } @@ -1254,7 +1259,7 @@ public override Index[] GetIndexes(string table) foreach (var pragmaIndexListItem in pragmaIndexListItems) { - var indexInfos = GetPragmaIndexInfo(pragmaIndexListItem.Name); + var indexInfos = GetPragmaIndexInfo(QualifiedSibling(table, pragmaIndexListItem.Name)); var columnNames = indexInfos.OrderBy(x => x.SeqNo) .Select(x => x.Name) @@ -1345,7 +1350,7 @@ public override Index[] GetIndexes(string table) public override void AddTable(string name, string engine, params IDbField[] fields) { if (engine != null) throw new NotSupportedException("SQLite does not support table engines."); - var table = _dialect.TableNameNeedsQuote ? _dialect.Quote(name) : QuoteTableNameIfRequired(name); + var table = QuoteTableNameIfRequired(name); ExecuteNonQuery(SQLiteTableSql.Generate(_dialect, table, fields)); foreach (var index in fields.OfType()) AddIndex(name, index); } @@ -1367,8 +1372,10 @@ public override string AddIndex(string table, Index index) throw new MigrationException($"For SQLite this migrator does not support clustered indexes at this point in time, sorry. File an issue if needed. Use 'if(Provider is {nameof(SQLiteTransformationProvider)}' if necessary."); } - var name = QuoteConstraintNameIfRequired(index.Name); - table = QuoteTableNameIfRequired(table); + var relation = CatalogRelation(table); + var name = relation.Schema == null ? QuoteConstraintNameIfRequired(index.Name) : QualifiedSibling(table, index.Name); + table = _dialect.QuoteTableNameIfRequired(SqlIdentifier.Parse(QuoteTableNameIfRequired(table)).Last().Quoted + ? _dialect.QuoteIdentifier(relation.Name) : relation.Name); var columns = QuoteColumnNamesIfRequired(index.KeyColumns); var uniqueString = index.Unique ? "UNIQUE" : null; @@ -1466,7 +1473,7 @@ public List GetPragmaIndexInfo(string indexNameNotQuoted) var quotedIndexName = QuoteTableNameIfRequired(indexNameNotQuoted); using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, $"PRAGMA index_info({quotedIndexName})")) + using (var reader = ExecuteQuery(cmd, Pragma("index_info", indexNameNotQuoted))) { while (reader.Read()) { @@ -1489,7 +1496,7 @@ public List GetPragmaIndexListItems(string tableNameNotQuot List pragmaIndexListItems = []; using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, $"PRAGMA index_list({QuoteTableNameIfRequired(tableNameNotQuoted)})")) + using (var reader = ExecuteQuery(cmd, Pragma("index_list", tableNameNotQuoted))) { while (reader.Read()) { @@ -1514,7 +1521,7 @@ public List GetPragmaTableInfoItems(string tableNameNotQuot List pragmaTableInfoItems = []; using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, $"PRAGMA table_info({QuoteTableNameIfRequired(tableNameNotQuoted)})")) + using (var reader = ExecuteQuery(cmd, Pragma("table_info", tableNameNotQuoted))) { while (reader.Read()) { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index d3775b31..1e978649 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -158,7 +158,7 @@ public override string[] GetConstraints(string table) public override void AddColumn(string table, string sqlColumn) { - table = _dialect.TableNameNeedsQuote ? _dialect.Quote(table) : table; + table = QuoteTableNameIfRequired(table); ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); } @@ -265,28 +265,9 @@ private static void AddParameter(IDbCommand command, string name, object value) public override bool ColumnExists(string table, string column) { - string schema; - - if (!TableExists(table)) - { - return false; - } - - var firstIndex = table.IndexOf("."); - - if (firstIndex >= 0) - { - schema = table.Substring(0, firstIndex); - table = table.Substring(firstIndex + 1); - } - else - { - schema = _defaultSchema; - } - - using var cmd = CreateCommand(); - using var reader = base.ExecuteQuery(cmd, string.Format("SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '{0}' AND TABLE_NAME='{1}' AND COLUMN_NAME='{2}'", schema, table, column)); - return reader.Read(); + using var command = ObjectCommand(table, column); + command.CommandText = "SELECT COUNT(*) FROM sys.columns WHERE object_id=OBJECT_ID(@table) AND name=@name"; + return Convert.ToInt32(command.ExecuteScalar()) > 0; } public override void RemoveColumnDefaultValue(string table, string column) @@ -303,7 +284,7 @@ public override void RemoveColumnDefaultValue(string table, string column) public override Index[] GetIndexes(string table) { var relation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(table)); - var schemaName = relation.Schema ?? "dbo"; + var schemaName = relation.Schema ?? Convert.ToString(ExecuteScalar("SELECT OBJECT_SCHEMA_NAME(OBJECT_ID(" + SqlLiteral(QuoteTableNameIfRequired(table)) + "))")); table = relation.Name; var indexes = new List(); @@ -496,7 +477,7 @@ public override int GetColumnContentSize(string table, string columnName) public override Column[] GetColumns(string table) { var relation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(table)); - var schema = relation.Schema ?? "dbo"; + var schema = relation.Schema ?? Convert.ToString(ExecuteScalar("SELECT OBJECT_SCHEMA_NAME(OBJECT_ID(" + SqlLiteral(QuoteTableNameIfRequired(table)) + "))")); table = relation.Name; var tableLiteral = table.Replace("'", "''"); var schemaLiteral = schema.Replace("'", "''"); @@ -686,23 +667,14 @@ public override void RenameColumn(string tableName, string oldColumnName, string if (ColumnExists(tableName, oldColumnName)) { - ExecuteNonQuery(string.Format("EXEC sp_rename '{0}.{1}', '{2}', 'COLUMN'", tableName, oldColumnName, newColumnName)); + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(QuoteTableNameIfRequired(tableName) + "." + _dialect.QuoteIdentifier(oldColumnName))}, {SqlLiteral(newColumnName)}, 'COLUMN'"); } } public override void RenameTable(string oldName, string newName) { - if (TableExists(newName)) - { - throw new MigrationException(string.Format("Table with name '{0}' already exists", newName)); - } - - if (!TableExists(oldName)) - { - throw new MigrationException(string.Format("Table with name '{0}' does not exist to rename", oldName)); - } - - ExecuteNonQuery(string.Format("EXEC sp_rename '{0}', '{1}'", oldName, newName)); + var target = SqlIdentifier.Parse(RenameTarget(oldName, newName)).Single().Value; + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(QuoteTableNameIfRequired(oldName))}, {SqlLiteral(target)}"); } public override void UpdateTargetFromSource(string tableSourceNotQuoted, string tableTargetNotQuoted, ColumnPair[] fromSourceToTargetColumnPairs, ColumnPair[] conditionColumnPairs) diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 3c71d4f3..8c118afd 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -41,16 +41,16 @@ public override void AddTable(string name, string engine, params IDbField[] fiel } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(table)}') AND type='U'")) > 0; + $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND type='U'")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(view)}') AND type='V'")) > 0; - public override string[] GetTables() => ExecuteStringQuery("SELECT name FROM sysobjects WHERE type='U' AND uid=user_id()").ToArray(); + $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(QuoteTableNameIfRequired(view))}') AND type='V'")) > 0; + public override string[] GetTables() => base.GetTables(); public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM master..sysdatabases"); public override string[] GetConstraints(string table) => ExecuteStringQuery( - $"SELECT o.name FROM sysconstraints c JOIN sysobjects o ON o.id=c.constrid WHERE c.tableid=object_id('{Literal(table)}') UNION SELECT name FROM sysindexes WHERE id=object_id('{Literal(table)}') AND (status2 & 2)=2").ToArray(); + $"SELECT o.name FROM sysconstraints c JOIN sysobjects o ON o.id=c.constrid WHERE c.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') UNION SELECT name FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND (status2 & 2)=2").ToArray(); public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(name); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( - $"SELECT name FROM sysindexes WHERE id=object_id('{Literal(table)}') AND (status & 2048)=2048 AND (status & 2)=2").FirstOrDefault(); + $"SELECT name FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND (status & 2048)=2048 AND (status & 2)=2").FirstOrDefault(); public override Column[] GetColumns(string table) { @@ -60,7 +60,7 @@ public override Column[] GetColumns(string table) using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" SELECT c.name,t.name,c.status,c.length,c.prec,c.scale FROM syscolumns c JOIN systypes t ON t.usertype=c.usertype - WHERE c.id=object_id('{Literal(table)}') ORDER BY c.colid + WHERE c.id=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY c.colid """); while (reader.Read()) { @@ -96,7 +96,7 @@ public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) var columns = string.Join(",", Enumerable.Range(1, 16).Select(n => $"col_name(r.tableid,r.fokey{n}),col_name(r.reftabid,r.refkey{n})")); var result = new List(); using var command = CreateCommand(); - using var reader = ExecuteQuery(command, $"SELECT object_name(r.constrid),object_name(r.reftabid),r.keycnt,r.frgndbname,r.pmrydbname,{columns} FROM sysreferences r WHERE r.tableid=object_id('{Literal(table)}') ORDER BY r.constrid"); + using var reader = ExecuteQuery(command, $"SELECT object_name(r.constrid),object_name(r.reftabid),r.keycnt,r.frgndbname,r.pmrydbname,{columns} FROM sysreferences r WHERE r.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY r.constrid"); while (reader.Read()) { if (!reader.IsDBNull(3) || !reader.IsDBNull(4)) @@ -125,7 +125,7 @@ public override TableConstraint[] GetTableConstraints(string table) } var checks = new Dictionary(); using (var command = CreateCommand()) - using (var reader = ExecuteQuery(command, $"SELECT o.name,c.text FROM sysconstraints con JOIN sysobjects o ON o.id=con.constrid JOIN syscomments c ON c.id=o.id WHERE con.tableid=object_id('{Literal(table)}') AND o.type='C' ORDER BY o.name,c.colid2,c.colid")) + using (var reader = ExecuteQuery(command, $"SELECT o.name,c.text FROM sysconstraints con JOIN sysobjects o ON o.id=con.constrid JOIN syscomments c ON c.id=o.id WHERE con.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND o.type='C' ORDER BY o.name,c.colid2,c.colid")) while (reader.Read()) { var name = reader.GetString(0); @@ -141,7 +141,7 @@ private Dictionary GetColumnDefaults(string table) { var defaults = new Dictionary(); using var command = CreateCommand(); - using var reader = ExecuteQuery(command, $"SELECT c.name,d.text FROM syscolumns c JOIN syscomments d ON d.id=c.cdefault WHERE c.id=object_id('{Literal(table)}') ORDER BY c.colid,d.colid2,d.colid"); + using var reader = ExecuteQuery(command, $"SELECT c.name,d.text FROM syscolumns c JOIN syscomments d ON d.id=c.cdefault WHERE c.id=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY c.colid,d.colid2,d.colid"); while (reader.Read()) { var name = reader.GetString(0); @@ -163,7 +163,7 @@ public override Index[] GetIndexes(string table) { var indexes = new List(); using var cmd = CreateCommand(); - using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,status2 FROM sysindexes WHERE id=object_id('{Literal(table)}') AND indid BETWEEN 1 AND 254")) + using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,status2 FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND indid BETWEEN 1 AND 254")) { while (reader.Read()) { @@ -175,11 +175,11 @@ public override Index[] GetIndexes(string table) } foreach (var index in indexes) { - var id = Convert.ToInt32(ExecuteScalar($"SELECT indid FROM sysindexes WHERE id=object_id('{Literal(table)}') AND name='{Literal(index.Name)}'")); + var id = Convert.ToInt32(ExecuteScalar($"SELECT indid FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND name='{Literal(index.Name)}'")); var keys = new List(); for (var position = 1; position <= 31; position++) { - var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(table)}', {id}, {position})")); + var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(QuoteTableNameIfRequired(table))}', {id}, {position})")); if (string.IsNullOrEmpty(key)) break; keys.Add(key); } @@ -194,27 +194,30 @@ public override string AddIndex(string table, Index index) if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0) throw new NotSupportedException("ASE does not support this index's INCLUDE or filter options."); - var name = index.Name ?? $"ix_{table}_{string.Join("_", index.KeyColumns)}"; - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}{(index.Clustered ? "CLUSTERED " : "NONCLUSTERED ")}INDEX {name} ON {table} ({string.Join(", ", index.KeyColumns)})"); + var name = index.Name ?? $"ix_{QuoteTableNameIfRequired(table)}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}{(index.Clustered ? "CLUSTERED " : "NONCLUSTERED ")}INDEX {name} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns)})"); return name; } - public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} ADD {sqlColumn}"); + public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); - public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {table}.{name}"); + public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {QuoteTableNameIfRequired(table)}.{name}"); public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) => - ExecuteNonQuery($"EXEC sp_rename '{Literal(tableName)}.{Literal(oldColumnName)}', '{Literal(newColumnName)}'"); - public override void RenameTable(string oldName, string newName) => - ExecuteNonQuery($"EXEC sp_rename '{Literal(oldName)}', '{Literal(newName)}'"); - public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {tableName} DROP {column}"); - public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column} DEFAULT NULL"); + ExecuteNonQuery($"EXEC sp_rename '{Literal(QuoteTableNameIfRequired(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}'"); + public override void RenameTable(string oldName, string newName) + { + var target = SqlIdentifier.Parse(RenameTarget(oldName, newName)).Single().Value; + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(QuoteTableNameIfRequired(oldName))}, {SqlLiteral(target)}"); + } + public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP {column}"); + public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} REPLACE {column} DEFAULT NULL"); public override void ChangeColumn(string table, Column column) { var type = _dialect.GetColumnMapper(column).Type; var nullable = !column.IsNullable ? "NOT NULL" : "NULL"; - ExecuteNonQuery($"ALTER TABLE {table} MODIFY {column.Name} {type} {nullable}"); - ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column.Name} {(column.DefaultValue == null ? "DEFAULT NULL" : _dialect.Default(column.DefaultValue))}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} MODIFY {column.Name} {type} {nullable}"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} REPLACE {column.Name} {(column.DefaultValue == null ? "DEFAULT NULL" : _dialect.Default(column.DefaultValue))}"); } public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, @@ -229,6 +232,6 @@ public override void AddForeignKey(string name, string childTable, string[] chil { if (constraint is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) throw new NotSupportedException("ASE declarative foreign keys do not support cascading referential actions."); - ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"); } } diff --git a/src/Migrator/Providers/NamespaceCatalog.cs b/src/Migrator/Providers/NamespaceCatalog.cs new file mode 100644 index 00000000..0f8160e7 --- /dev/null +++ b/src/Migrator/Providers/NamespaceCatalog.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using DotNetProjects.Migrator.Providers.Impl.DB2; +using DotNetProjects.Migrator.Providers.Impl.Firebird; +using DotNetProjects.Migrator.Providers.Impl.Hana; +using DotNetProjects.Migrator.Providers.Impl.Informix; +using DotNetProjects.Migrator.Providers.Impl.Ingres; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using DotNetProjects.Migrator.Providers.Impl.Sybase; + +namespace DotNetProjects.Migrator.Providers; + +internal static class NamespaceCatalog +{ + internal static string[] Tables(TransformationProvider provider, string schema) + { + var part = schema == null ? (SqlIdentifier.Part?)null : SqlIdentifier.Parse(schema).Single(); + var name = part?.Value; + if (part is { Quoted: false } && provider.Dialect is OracleDialect or DB2Dialect) name = name.ToUpperInvariant(); + if (part is { Quoted: false } && provider.Dialect is PostgreSQLDialect or InformixDialect or IngresDialect) name = name.ToLowerInvariant(); + string Literal(string value) => "'" + value.Replace("'", "''") + "'"; + string Scope(string fallback) => name == null ? fallback : Literal(name); + var sql = provider.Dialect switch + { + SQLiteDialect => "SELECT name FROM " + provider.Dialect.QuoteIdentifier(name ?? "main") + ".sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + SqlServerDialect => $"SELECT t.name FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id WHERE s.name={Scope("SCHEMA_NAME()")} ORDER BY t.name", + PostgreSQLDialect => $"SELECT table_name FROM information_schema.tables WHERE table_schema={Scope("current_schema()")} AND table_type='BASE TABLE' ORDER BY table_name", + OracleDialect => $"SELECT TABLE_NAME FROM ALL_TABLES WHERE OWNER={Scope("SYS_CONTEXT('USERENV','CURRENT_SCHEMA')")} ORDER BY TABLE_NAME", + MysqlDialect => $"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA={Scope("DATABASE()")} AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", + DB2Dialect => $"SELECT TABNAME FROM SYSCAT.TABLES WHERE TABSCHEMA={Scope("CURRENT SCHEMA")} AND TYPE='T' ORDER BY TABNAME", + InformixDialect => $"SELECT tabname FROM systables WHERE owner={Scope("USER")} AND tabid>=100 AND tabtype='T' ORDER BY tabname", + SybaseDialect => $"SELECT name FROM sysobjects WHERE type='U' AND uid=user_id({(name == null ? "" : Literal(name))}) ORDER BY name", + HanaDialect => $"SELECT TABLE_NAME FROM SYS.TABLES WHERE SCHEMA_NAME={Scope("CURRENT_SCHEMA")} ORDER BY TABLE_NAME", + IngresDialect => $"SELECT table_name FROM iitables WHERE table_owner={Scope("DBMSINFO('username')")} AND table_type='T' ORDER BY table_name", + FirebirdDialect when name == null => "SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE RDB$SYSTEM_FLAG=0 AND RDB$VIEW_BLR IS NULL ORDER BY RDB$RELATION_NAME", + FirebirdDialect => throw new NotSupportedException("The Firebird provider targets Firebird 5 and does not support namespaces."), + _ => throw new NotSupportedException("Namespace enumeration is not implemented for " + provider.Dialect.GetType().Name) + }; + return provider.ExecuteStringQuery(sql).Select(n => n.TrimEnd()).ToArray(); + } +} diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index e1a9ca0d..f6f8fdbf 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -39,7 +39,7 @@ public abstract class TransformationProvider : ITransformationProvider, IMigrati { private string _scope; protected readonly string _connectionString; - protected readonly string _defaultSchema; + protected string _defaultSchema; private readonly ForeignKeyConstraintMapper constraintMapper = new(); protected List _appliedMigrations; protected IDbConnection _connection; @@ -191,19 +191,7 @@ public virtual int GetColumnContentSize(string table, string columnName) return Convert.ToInt32(result); } - public virtual string[] GetTables() - { - var tables = new List(); - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, "SELECT table_name FROM INFORMATION_SCHEMA.TABLES")) - { - while (reader.Read()) - { - tables.Add((string)reader[0]); - } - } - return tables.ToArray(); - } + public virtual string[] GetTables() => GetTables(_defaultSchema).ToArray(); public virtual void RemoveForeignKey(string table, string name) { @@ -377,25 +365,16 @@ public virtual void RemoveTable(string name) throw new MigrationException(string.Format("Table with name '{0}' does not exist to rename", name)); } - ExecuteNonQuery(string.Format("DROP TABLE {0}", name)); + ExecuteNonQuery(string.Format("DROP TABLE {0}", QuoteTableNameIfRequired(name))); } public virtual void RenameTable(string oldName, string newName) { - oldName = QuoteTableNameIfRequired(oldName); - newName = QuoteTableNameIfRequired(newName); - - if (TableExists(newName)) - { - throw new MigrationException(string.Format("Table with name '{0}' already exists", newName)); - } - - if (!TableExists(oldName)) - { - throw new MigrationException(string.Format("Table with name '{0}' does not exist to rename", oldName)); - } - - ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME TO {1}", oldName, newName)); + var target = RenameTarget(oldName, newName); + if (!TableExists(oldName)) throw new MigrationException("Table does not exist: " + oldName); + if (TableExists(QualifyInSameNamespace(oldName, SqlIdentifier.Parse(newName).Last().Value))) + throw new MigrationException("Table already exists: " + newName); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(oldName)} RENAME TO {target}"); } public virtual void RenameColumn(string tableName, string oldColumnName, string newColumnName) @@ -1597,6 +1576,49 @@ public virtual string QuoteColumnNameIfRequired(string name) return _dialect.QuoteColumnNameIfRequired(name); } + internal void SetDefaultSchema(string schema) + { + if (!string.IsNullOrWhiteSpace(schema) && SqlIdentifier.Parse(schema).Length != 1) + throw new ArgumentException("The default namespace must be one identifier.", nameof(schema)); + _defaultSchema = string.IsNullOrWhiteSpace(schema) ? null : schema; + } + + protected internal (string Schema, string Name) CatalogRelation(string table, bool upperCase = false) + { + var rendered = QuoteTableNameIfRequired(table); + var relation = SqlIdentifier.Catalog(rendered, upperCase); + if (_dialect is DotNetProjects.Migrator.Providers.Impl.Informix.InformixDialect or DotNetProjects.Migrator.Providers.Impl.Ingres.IngresDialect or DotNetProjects.Migrator.Providers.Impl.PostgreSQL.PostgreSQLDialect) + { + var parts = SqlIdentifier.Parse(rendered); + return (relation.Schema == null ? null : parts[0].Quoted ? relation.Schema : relation.Schema.ToLowerInvariant(), + parts[^1].Quoted ? relation.Name : relation.Name.ToLowerInvariant()); + } + return relation; + } + + protected static string SqlLiteral(string value) => value == null ? "NULL" : "'" + value.Replace("'", "''") + "'"; + + protected string NamespaceSql(string table, string fallback, bool upperCase = false) => + CatalogRelation(table, upperCase).Schema is string schema ? SqlLiteral(schema) : fallback; + + protected string ObjectSqlLiteral(string table, bool upperCase = false) => SqlLiteral(CatalogRelation(table, upperCase).Name); + + protected string QualifyInSameNamespace(string table, string name) + { + var relation = CatalogRelation(table, _dialect is DotNetProjects.Migrator.Providers.Impl.DB2.DB2Dialect or DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect); + return (relation.Schema == null ? "" : _dialect.QuoteIdentifier(relation.Schema) + ".") + _dialect.QuoteIdentifier(name); + } + + protected string RenameTarget(string oldName, string newName) + { + var upper = _dialect is DotNetProjects.Migrator.Providers.Impl.DB2.DB2Dialect or DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect; + var source = CatalogRelation(oldName, upper); + var target = SqlIdentifier.Catalog(newName, upper); + if (target.Schema != null && !string.Equals(source.Schema, target.Schema, StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("RenameTable does not move tables between namespaces."); + return _dialect.QuoteTableNameIfRequired(SqlIdentifier.Parse(newName).Last().Quoted ? _dialect.QuoteIdentifier(target.Name) : target.Name); + } + public virtual string QuoteTableNameIfRequired(string name) { if (!string.IsNullOrWhiteSpace(_defaultSchema) && SqlIdentifier.Parse(name).Length == 1) @@ -1938,7 +1960,8 @@ public virtual void RemoveIndex(string table, string name) { if (TableExists(table) && IndexExists(table, name)) { - name = QuoteConstraintNameIfRequired(name); + var actual = GetIndexes(table).Single(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).Name; + name = QualifyInSameNamespace(table, actual); ExecuteNonQuery(string.Format("DROP INDEX {0}", name)); } } @@ -2025,25 +2048,14 @@ public IDbConnection Connection get { return _connection; } } - public IEnumerable GetTables(string schema) - { - var tableRestrictions = new string[4]; - tableRestrictions[1] = schema; - - var c = _connection as DbConnection; - var tables = c.GetSchema("Tables", tableRestrictions); - return from DataRow row in tables.Rows select (row["TABLE_NAME"] as string); - } + public IEnumerable GetTables(string schema) => NamespaceCatalog.Tables(this, schema ?? _defaultSchema); public IEnumerable GetColumns(string schema, string table) { - var tableRestrictions = new string[4]; - tableRestrictions[1] = schema; - tableRestrictions[2] = table; - - var c = _connection as DbConnection; - var tables = c.GetSchema("Columns", tableRestrictions); - return from DataRow row in tables.Rows select (row["COLUMN_NAME"] as string); + if (!string.IsNullOrWhiteSpace(schema) && (SqlIdentifier.Parse(schema).Length != 1 || SqlIdentifier.Parse(table).Length != 1)) + throw new ArgumentException("Pass the namespace and table as separate identifier atoms."); + var name = string.IsNullOrWhiteSpace(schema) ? table : schema + "." + table; + return GetColumns(name).Select(column => column.Name).ToArray(); } protected void ValidateIndex(string tableName, Index index) From 28443ee37cb21b219e2939ec7f614b9349206b4d Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 19:26:19 +0200 Subject: [PATCH 2/6] Quote test query columns consistently across case-sensitive engines Use provider column rendering in namespace lifecycle views and scalar queries so HANA tests query the same identifiers created by the provider. Validation: all 1,231 unit and SQLite tests pass. --- .../Providers/Live/NamespaceIsolationTests.cs | 4 ++-- .../Providers/Live/NamespaceLifecycleTests.cs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs index 4e6223fb..f5b92543 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs @@ -57,7 +57,7 @@ void Create(string schema) var target = Qualified(first, "ns_items"); Assert.That(Provider.TableExists(target), Is.True); Assert.That(Provider.GetTables(Provider.Dialect.QuoteIdentifier(first)).Select(n => n.ToLowerInvariant()), Is.EqualTo(new[] { "ns_items" })); - Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("payload") + " FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); Provider.ChangeColumn(target, new Column("payload", DbType.String, 60)); Provider.AddColumn(target, new Column("only_first", DbType.Int32)); Assert.That(Provider.ColumnExists(target, "only_first"), Is.True); @@ -70,7 +70,7 @@ void Create(string schema) Assert.That(Provider.TableExists(Qualified(first, "ns_renamed")), Is.True); Provider.RemoveTable(Qualified(first, "ns_renamed")); Assert.That(Provider.TableExists("ns_items"), Is.True); - Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("payload") + " FROM " + Provider.QuoteTableNameIfRequired("ns_items"))), Is.EqualTo(second)); } finally { diff --git a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs index 83ac5c6a..a90567c2 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -82,16 +82,16 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) Assert.That(Provider.GetTables(ns).Select(n => n.ToLowerInvariant()), Does.Contain("ns_child")); Assert.That(Provider.GetColumns(ns, database is "Db2" or "Oracle" ? "NS_CHILD" : "ns_child").Select(n => n.ToLowerInvariant()), Does.Contain("payload")); var view = Table("ns_view"); - Provider.ExecuteNonQuery($"CREATE VIEW {Provider.QuoteTableNameIfRequired(view)} AS SELECT id FROM {Provider.QuoteTableNameIfRequired(child)}"); + Provider.ExecuteNonQuery($"CREATE VIEW {Provider.QuoteTableNameIfRequired(view)} AS SELECT {Provider.QuoteColumnNameIfRequired("id")} FROM {Provider.QuoteTableNameIfRequired(child)}"); Assert.That(Provider.ViewExists(view), Is.True); Provider.ExecuteNonQuery("DROP VIEW " + Provider.QuoteTableNameIfRequired(view)); Assert.That(Provider.ViewExists(view), Is.False); Provider.AddColumn(child, new Column("extra", DbType.Int32) { DefaultValue = 7 }); Provider.Insert(parent, new[] { "id" }, new object[] { 1 }); Provider.Insert(child, new[] { "id", "parent_id", "payload" }, new object[] { 1, 1, "kept" }); - Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT extra FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo(7)); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("extra") + " FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo(7)); Provider.Update(child, new[] { "payload" }, new object[] { "changed" }, new[] { "id" }, new object[] { 1 }); - Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT payload FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo("changed")); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("payload") + " FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo("changed")); Provider.Update(child, new[] { "payload" }, new object[] { "kept" }); Provider.ChangeColumn(child, new Column("payload", DbType.String, 40)); Provider.RenameColumn(child, "payload", "message"); @@ -117,7 +117,7 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) Provider.RenameTable(child, renamed); Assert.That(Provider.TableExists(child), Is.False); Assert.That(Provider.TableExists(renamed), Is.True); - Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT message FROM " + Provider.QuoteTableNameIfRequired(renamed))), Is.EqualTo("kept")); + Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("message") + " FROM " + Provider.QuoteTableNameIfRequired(renamed))), Is.EqualTo("kept")); Provider.RemoveTable(renamed); Assert.That(Provider.TableExists(renamed), Is.False); } From f1b4fc2b5e091b28328a8652edb9afc540407651 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 19:35:38 +0200 Subject: [PATCH 3/6] Resolve namespace regressions exposed by live provider tests Scope PostgreSQL index queries by relation identity; resolve quoted ASE owners through native catalogs; preserve Oracle index folding and automatically quoted namespace names. Route HANA single-action foreign keys through supported RESTRICT mapping. Keep Firebird constraint names separate from namespace validation and reject its unsupported native table rename explicitly. Make unique-key test columns non-null for Db2 and expose per-test CI progress. Validation: 1,231 local unit/SQLite tests pass; live matrix rerun required. --- .github/scripts/test.ps1 | 2 +- docs/namespace-support.md | 2 +- .../Providers/Live/NamespaceLifecycleTests.cs | 14 ++++++-- .../FirebirdTransformationProvider.cs | 21 +++++++----- .../Impl/Hana/HanaTransformationProvider.cs | 2 ++ .../Oracle/OracleTransformationProvider.cs | 3 +- .../PostgreSQLTransformationProvider.cs | 20 +++++------ .../Sybase/SybaseTransformationProvider.cs | 34 ++++++++++++------- src/Migrator/Providers/NamespaceCatalog.cs | 2 +- 9 files changed, 62 insertions(+), 38 deletions(-) diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 index aa4bc4a9..76143ee8 100644 --- a/.github/scripts/test.ps1 +++ b/.github/scripts/test.ps1 @@ -8,7 +8,7 @@ $databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Fir $filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" } $xmlDirectory = Join-Path (Get-Location) "TestResults/$Database" $coverageArguments = if ($Coverage) { @('--collect', 'XPlat Code Coverage', '--settings', "$PSScriptRoot/../coverage.runsettings") } else { @() } -dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults @coverageArguments -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory" +dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --logger "console;verbosity=normal" --results-directory TestResults @coverageArguments -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory" if ($LASTEXITCODE -ne 0) { throw "Tests failed for $Database" } [xml]$results = Get-Content "TestResults/$Database.trx" $counters = $results.TestRun.ResultSummary.Counters diff --git a/docs/namespace-support.md b/docs/namespace-support.md index 1a2217a8..b96db4f6 100644 --- a/docs/namespace-support.md +++ b/docs/namespace-support.md @@ -43,7 +43,7 @@ attached databases. cases covering table creation/removal, enumeration, columns/defaults, inserts, updates, views, column changes/renames/removal, PK/FK/unique/index metadata and removal, table rename and data preservation. Firebird tests explicit rejection - for the three namespace modes. + for the three namespace modes and table rename (unsupported by Firebird 5). - NamespaceIsolationTests creates two namespaces with identical table, key and index names on SQLite, SQL Server, PostgreSQL, MySQL, MariaDB, Db2 and HANA. The provider default points at the second while changes target the first. diff --git a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs index a90567c2..defd2b57 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -76,7 +76,7 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) { Provider.AddTable(parent, new Column("id", DbType.Int32) { IsNullable = false }, new PrimaryKeyConstraint("pk_ns_parent", "id")); Provider.AddTable(child, new Column("id", DbType.Int32) { IsNullable = false }, new Column("parent_id", DbType.Int32), - new Column("payload", DbType.String, 20), new PrimaryKeyConstraint("pk_ns_child", "id")); + new Column("payload", DbType.String, 20) { IsNullable = false }, new PrimaryKeyConstraint("pk_ns_child", "id")); Assert.That(Provider.TableExists(child), Is.True); Assert.That(Provider.ColumnExists(child, "payload"), Is.True); Assert.That(Provider.GetTables(ns).Select(n => n.ToLowerInvariant()), Does.Contain("ns_child")); @@ -93,7 +93,7 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) Provider.Update(child, new[] { "payload" }, new object[] { "changed" }, new[] { "id" }, new object[] { 1 }); Assert.That(Convert.ToString(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("payload") + " FROM " + Provider.QuoteTableNameIfRequired(child))), Is.EqualTo("changed")); Provider.Update(child, new[] { "payload" }, new object[] { "kept" }); - Provider.ChangeColumn(child, new Column("payload", DbType.String, 40)); + Provider.ChangeColumn(child, new Column("payload", DbType.String, 40) { IsNullable = false }); Provider.RenameColumn(child, "payload", "message"); Assert.That(Provider.ColumnExists(child, "message"), Is.True); Assert.That(Provider.ColumnExists(child, "payload"), Is.False); @@ -114,6 +114,13 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) Assert.That(Provider.GetForeignKeyConstraints(child), Is.Empty); Provider.RemoveColumn(child, "extra"); Assert.That(Provider.ColumnExists(child, "extra"), Is.False); + if (database == "Firebird") + { + Assert.Throws(() => Provider.RenameTable(child, renamed)); + Provider.RemoveTable(child); + Assert.That(Provider.TableExists(child), Is.False); + return; + } Provider.RenameTable(child, renamed); Assert.That(Provider.TableExists(child), Is.False); Assert.That(Provider.TableExists(renamed), Is.True); @@ -123,7 +130,8 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) } finally { - foreach (var table in new[] { renamed, child, parent }) + // Firebird fixture drops its isolated database; cleanup must not mask a DDL error. + foreach (var table in database == "Firebird" ? Array.Empty() : new[] { renamed, child, parent }) if (Provider.TableExists(table)) Provider.RemoveTable(table); } } diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 5980469b..ae31fb23 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -23,8 +23,13 @@ public FirebirdTransformationProvider(Dialect dialect, string connectionString, public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) : base(dialect, connection, null, scope) { } - private string CatalogName(string name) => - (QuoteTableNameIfRequired(name).StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + private static string CatalogName(string name) => + (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + + private string CatalogTable(string table) => CatalogName(QuoteTableNameIfRequired(table)); + + public override void RenameTable(string oldName, string newName) => + throw new NotSupportedException("Firebird 5 does not support renaming tables."); public override void AddColumn(string table, Column column) => AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); @@ -42,10 +47,10 @@ public override string QuoteTableNameIfRequired(string name) } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$VIEW_BLR IS NULL")) > 0; + $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogTable(table)}' AND RDB$VIEW_BLR IS NULL")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(view)}' AND RDB$VIEW_BLR IS NOT NULL")) > 0; + $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogTable(view)}' AND RDB$VIEW_BLR IS NOT NULL")) > 0; public override string[] GetTables() => base.GetTables(); @@ -67,13 +72,13 @@ public override void DropDatabases(string databaseName) } public override string[] GetConstraints(string table) => ExecuteStringQuery( - $"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}'").ToArray(); + $"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogTable(table)}'").ToArray(); public override bool ConstraintExists(string table, string name) => GetConstraints(table).Any(n => n == name || n == CatalogName(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => - ExecuteStringQuery($"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$CONSTRAINT_TYPE='PRIMARY KEY'").FirstOrDefault(); + ExecuteStringQuery($"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogTable(table)}' AND RDB$CONSTRAINT_TYPE='PRIMARY KEY'").FirstOrDefault(); public override bool PrimaryKeyExists(string table, string name) => string.Equals(GetPrimaryKeyConstraintName(table), CatalogName(name), StringComparison.Ordinal); @@ -89,7 +94,7 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, f.RDB$FIELD_SUB_TYPE, f.RDB$FIELD_PRECISION, f.RDB$FIELD_SCALE, f.RDB$CHARACTER_SET_ID FROM RDB$RELATION_FIELDS r JOIN RDB$FIELDS f ON f.RDB$FIELD_NAME=r.RDB$FIELD_SOURCE - WHERE r.RDB$RELATION_NAME='{CatalogName(table)}' ORDER BY r.RDB$FIELD_POSITION + WHERE r.RDB$RELATION_NAME='{CatalogTable(table)}' ORDER BY r.RDB$FIELD_POSITION """); while (reader.Read()) { @@ -194,7 +199,7 @@ SELECT TRIM(i.RDB$INDEX_NAME), COALESCE(i.RDB$UNIQUE_FLAG,0), TRIM(s.RDB$FIELD_N TRIM(c.RDB$CONSTRAINT_TYPE) FROM RDB$INDICES i JOIN RDB$INDEX_SEGMENTS s ON s.RDB$INDEX_NAME=i.RDB$INDEX_NAME LEFT JOIN RDB$RELATION_CONSTRAINTS c ON c.RDB$INDEX_NAME=i.RDB$INDEX_NAME - WHERE i.RDB$RELATION_NAME='{CatalogName(table)}' + WHERE i.RDB$RELATION_NAME='{CatalogTable(table)}' ORDER BY i.RDB$INDEX_NAME, s.RDB$FIELD_POSITION """); while (reader.Read()) diff --git a/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs index 7d6970ba..e95e8f13 100644 --- a/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs @@ -96,6 +96,8 @@ public override void AddColumnDefaultValue(string table, string column, object v ChangeColumn(table, definition); } public override int TruncateTable(string table) => ExecuteNonQuery("TRUNCATE TABLE " + QuoteTableNameIfRequired(table)); + public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType action) => + AddForeignKey(name, child, columns, parent, parentColumns, action, action); public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) => base.AddForeignKey(name, child, columns, parent, parentColumns, onDelete == ForeignKeyConstraintType.NoAction ? ForeignKeyConstraintType.Restrict : onDelete, diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index ed6bbcf3..6519c9cd 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -81,7 +81,8 @@ public override string AddIndex(string table, Index index) throw new MigrationException($"You cannot use unique together with functional expressions in Oracle ({nameof(FilterItem)})."); } - var name = QualifyInSameNamespace(table, index.Name); + var relation = CatalogRelation(table, true); + var name = (relation.Schema == null ? "" : _dialect.QuoteIdentifier(relation.Schema) + ".") + QuoteConstraintNameIfRequired(index.Name); table = QuoteTableNameIfRequired(table); List singleFilterStrings = []; diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index bdcd5051..d8604e8e 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -135,8 +135,6 @@ public override Index[] GetIndexes(string table) { var columns = GetColumns(table); - // Since the migrator does not support schemas at this point in time we set the schema to "public" - var schemaName = "public"; var indexes = new List(); @@ -173,11 +171,10 @@ FROM pg_index idx JOIN pg_namespace nsp ON nsp.oid = tbl.relnamespace LEFT JOIN pg_constraint con ON con.conindid = idx.indexrelid WHERE - lower(tbl.relname) = '{table.ToLowerInvariant()}' AND - nsp.nspname = '{schemaName}'"; + idx.indrelid = to_regclass(@relation)"; - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, string.Format(sql, table))) + using (var cmd = MetadataCommand(table)) + using (var reader = ExecuteQuery(cmd, sql)) { var includeColumnsOrdinal = reader.GetOrdinal("include_columns"); var indexColumnsOrdinal = reader.GetOrdinal("index_columns"); @@ -615,10 +612,13 @@ public override Column GetColumnByName(string table, string columnName) public override bool IndexExists(string table, string name) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT indexname FROM pg_catalog.pg_indexes WHERE indexname = lower('{0}')", name)); - + using var cmd = MetadataCommand(table); + var parameter = cmd.CreateParameter(); + parameter.ParameterName = "index_name"; + var identifier = SqlIdentifier.Parse(QuoteConstraintNameIfRequired(name)).Single(); + parameter.Value = identifier.Quoted ? identifier.Value : identifier.Value.ToLowerInvariant(); + cmd.Parameters.Add(parameter); + using var reader = ExecuteQuery(cmd, "SELECT 1 FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid WHERE i.indrelid=to_regclass(@relation) AND c.relname=@index_name"); return reader.Read(); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 8c118afd..bf272889 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -22,6 +22,14 @@ public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, s : base(dialect, connection, null, scope) { } private static string Literal(string name) => name.Replace("'", "''"); + private string ObjectId(string table) + { + var relation = CatalogRelation(table); + if (relation.Schema == null) return "object_id(" + SqlLiteral(relation.Name) + ")"; + return "(SELECT id FROM sysobjects WHERE name=" + SqlLiteral(relation.Name) + " AND uid=user_id(" + SqlLiteral(relation.Schema) + "))"; + } + private string CatalogObjectName(string table) => string.Join(".", SqlIdentifier.Parse(QuoteTableNameIfRequired(table)).Select(p => p.Value)); + public override void AddPrimaryKey(string name, string table, params string[] columns) { SybaseDialect.ValidateKeyConstraintName(name); @@ -41,16 +49,16 @@ public override void AddTable(string name, string engine, params IDbField[] fiel } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND type='U'")) > 0; + $"SELECT COUNT(*) FROM sysobjects WHERE id={ObjectId(table)} AND type='U'")) > 0; public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(QuoteTableNameIfRequired(view))}') AND type='V'")) > 0; + $"SELECT COUNT(*) FROM sysobjects WHERE id={ObjectId(view)} AND type='V'")) > 0; public override string[] GetTables() => base.GetTables(); public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM master..sysdatabases"); public override string[] GetConstraints(string table) => ExecuteStringQuery( - $"SELECT o.name FROM sysconstraints c JOIN sysobjects o ON o.id=c.constrid WHERE c.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') UNION SELECT name FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND (status2 & 2)=2").ToArray(); + $"SELECT o.name FROM sysconstraints c JOIN sysobjects o ON o.id=c.constrid WHERE c.tableid={ObjectId(table)} UNION SELECT name FROM sysindexes WHERE id={ObjectId(table)} AND (status2 & 2)=2").ToArray(); public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(name); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( - $"SELECT name FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND (status & 2048)=2048 AND (status & 2)=2").FirstOrDefault(); + $"SELECT name FROM sysindexes WHERE id={ObjectId(table)} AND (status & 2048)=2048 AND (status & 2)=2").FirstOrDefault(); public override Column[] GetColumns(string table) { @@ -60,7 +68,7 @@ public override Column[] GetColumns(string table) using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" SELECT c.name,t.name,c.status,c.length,c.prec,c.scale FROM syscolumns c JOIN systypes t ON t.usertype=c.usertype - WHERE c.id=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY c.colid + WHERE c.id={ObjectId(table)} ORDER BY c.colid """); while (reader.Read()) { @@ -96,7 +104,7 @@ public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) var columns = string.Join(",", Enumerable.Range(1, 16).Select(n => $"col_name(r.tableid,r.fokey{n}),col_name(r.reftabid,r.refkey{n})")); var result = new List(); using var command = CreateCommand(); - using var reader = ExecuteQuery(command, $"SELECT object_name(r.constrid),object_name(r.reftabid),r.keycnt,r.frgndbname,r.pmrydbname,{columns} FROM sysreferences r WHERE r.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY r.constrid"); + using var reader = ExecuteQuery(command, $"SELECT object_name(r.constrid),object_name(r.reftabid),r.keycnt,r.frgndbname,r.pmrydbname,{columns} FROM sysreferences r WHERE r.tableid={ObjectId(table)} ORDER BY r.constrid"); while (reader.Read()) { if (!reader.IsDBNull(3) || !reader.IsDBNull(4)) @@ -125,7 +133,7 @@ public override TableConstraint[] GetTableConstraints(string table) } var checks = new Dictionary(); using (var command = CreateCommand()) - using (var reader = ExecuteQuery(command, $"SELECT o.name,c.text FROM sysconstraints con JOIN sysobjects o ON o.id=con.constrid JOIN syscomments c ON c.id=o.id WHERE con.tableid=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND o.type='C' ORDER BY o.name,c.colid2,c.colid")) + using (var reader = ExecuteQuery(command, $"SELECT o.name,c.text FROM sysconstraints con JOIN sysobjects o ON o.id=con.constrid JOIN syscomments c ON c.id=o.id WHERE con.tableid={ObjectId(table)} AND o.type='C' ORDER BY o.name,c.colid2,c.colid")) while (reader.Read()) { var name = reader.GetString(0); @@ -141,7 +149,7 @@ private Dictionary GetColumnDefaults(string table) { var defaults = new Dictionary(); using var command = CreateCommand(); - using var reader = ExecuteQuery(command, $"SELECT c.name,d.text FROM syscolumns c JOIN syscomments d ON d.id=c.cdefault WHERE c.id=object_id('{Literal(QuoteTableNameIfRequired(table))}') ORDER BY c.colid,d.colid2,d.colid"); + using var reader = ExecuteQuery(command, $"SELECT c.name,d.text FROM syscolumns c JOIN syscomments d ON d.id=c.cdefault WHERE c.id={ObjectId(table)} ORDER BY c.colid,d.colid2,d.colid"); while (reader.Read()) { var name = reader.GetString(0); @@ -163,7 +171,7 @@ public override Index[] GetIndexes(string table) { var indexes = new List(); using var cmd = CreateCommand(); - using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,status2 FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND indid BETWEEN 1 AND 254")) + using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,status2 FROM sysindexes WHERE id={ObjectId(table)} AND indid BETWEEN 1 AND 254")) { while (reader.Read()) { @@ -175,11 +183,11 @@ public override Index[] GetIndexes(string table) } foreach (var index in indexes) { - var id = Convert.ToInt32(ExecuteScalar($"SELECT indid FROM sysindexes WHERE id=object_id('{Literal(QuoteTableNameIfRequired(table))}') AND name='{Literal(index.Name)}'")); + var id = Convert.ToInt32(ExecuteScalar($"SELECT indid FROM sysindexes WHERE id={ObjectId(table)} AND name='{Literal(index.Name)}'")); var keys = new List(); for (var position = 1; position <= 31; position++) { - var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(QuoteTableNameIfRequired(table))}', {id}, {position})")); + var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(CatalogObjectName(table))}', {id}, {position})")); if (string.IsNullOrEmpty(key)) break; keys.Add(key); } @@ -203,11 +211,11 @@ public override string AddIndex(string table, Index index) public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {QuoteTableNameIfRequired(table)}.{name}"); public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) => - ExecuteNonQuery($"EXEC sp_rename '{Literal(QuoteTableNameIfRequired(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}'"); + ExecuteNonQuery($"EXEC sp_rename '{Literal(CatalogObjectName(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}', 'column'"); public override void RenameTable(string oldName, string newName) { var target = SqlIdentifier.Parse(RenameTarget(oldName, newName)).Single().Value; - ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(QuoteTableNameIfRequired(oldName))}, {SqlLiteral(target)}"); + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(CatalogObjectName(oldName))}, {SqlLiteral(target)}"); } public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP {column}"); public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} REPLACE {column} DEFAULT NULL"); diff --git a/src/Migrator/Providers/NamespaceCatalog.cs b/src/Migrator/Providers/NamespaceCatalog.cs index 0f8160e7..f71e92d4 100644 --- a/src/Migrator/Providers/NamespaceCatalog.cs +++ b/src/Migrator/Providers/NamespaceCatalog.cs @@ -18,7 +18,7 @@ internal static class NamespaceCatalog { internal static string[] Tables(TransformationProvider provider, string schema) { - var part = schema == null ? (SqlIdentifier.Part?)null : SqlIdentifier.Parse(schema).Single(); + var part = schema == null ? (SqlIdentifier.Part?)null : SqlIdentifier.Parse(provider.Dialect.QuoteTableNameIfRequired(schema)).Single(); var name = part?.Value; if (part is { Quoted: false } && provider.Dialect is OracleDialect or DB2Dialect) name = name.ToUpperInvariant(); if (part is { Quoted: false } && provider.Dialect is PostgreSQLDialect or InformixDialect or IngresDialect) name = name.ToLowerInvariant(); From b1d073c9c67661f9dd8caa986b16ac89f67ee850 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 19:44:54 +0200 Subject: [PATCH 4/6] Qualify shared column mutations and respect ASE rename scope Render the resolved table name for shared RenameColumn and RemoveColumn commands, including default and automatically quoted namespaces. Extend duplicate-name tests to cover both operations. Validate ASE rename ownership before using the local name required by sp_rename. Reconnect the Firebird lifecycle fixture before final DROP to release compiled metadata dependencies after view and foreign-key changes. Validation: 1,231 unit/SQLite tests pass locally; previous server run passes SQL Server, PostgreSQL, MySQL, MariaDB, Db2, Informix and HANA. --- docs/namespace-support.md | 4 +++- .../Providers/Live/NamespaceIsolationTests.cs | 6 ++++++ .../Providers/Live/NamespaceLifecycleTests.cs | 3 +++ .../Impl/Sybase/SybaseTransformationProvider.cs | 12 ++++++++++-- src/Migrator/Providers/TransformationProvider.cs | 4 ++-- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/namespace-support.md b/docs/namespace-support.md index b96db4f6..34593e4b 100644 --- a/docs/namespace-support.md +++ b/docs/namespace-support.md @@ -26,7 +26,9 @@ restriction layout. Table enumeration returns local names. | Ingres | Owner-qualified DDL and native table/view/column/constraint/index-existence catalogs; the legacy provider still lacks structured index/FK metadata and live qualification infrastructure | Renaming keeps the source namespace when the new name is unqualified. It is not -a portable API for moving tables between namespaces. SQLite foreign keys cannot +a portable API for moving tables between namespaces. ASE's sp_rename requires +a connection in the object's owner namespace; an explicitly different owner is +rejected before renaming. SQLite foreign keys cannot reference another attached database; that request fails before table creation. SQLite reconstruction keeps its temporary table, indexes, triggers and AUTOINCREMENT sequence in the original database and checks foreign keys across diff --git a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs index f5b92543..fbe60cf4 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs @@ -62,6 +62,12 @@ void Create(string schema) Provider.AddColumn(target, new Column("only_first", DbType.Int32)); Assert.That(Provider.ColumnExists(target, "only_first"), Is.True); Assert.That(Provider.ColumnExists("ns_items", "only_first"), Is.False); + Provider.RemoveColumn(target, "only_first"); + Assert.That(Provider.ColumnExists(target, "only_first"), Is.False); + Provider.RenameColumn(target, "payload", "message"); + Assert.That(Provider.ColumnExists(target, "message"), Is.True); + Assert.That(Provider.ColumnExists("ns_items", "message"), Is.False); + Provider.RenameColumn(target, "message", "payload"); Provider.RemoveIndex(target, "ix_same"); Assert.That(Provider.IndexExists(target, "ix_same"), Is.False); Assert.That(Provider.IndexExists("ns_items", "ix_same"), Is.True); diff --git a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs index defd2b57..a5bb38f9 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -117,6 +117,9 @@ public void TableColumnDataConstraintIndexAndRenameLifecycle(string mode) if (database == "Firebird") { Assert.Throws(() => Provider.RenameTable(child, renamed)); + // Release Firebird compiled dependencies after the preceding view/FK changes. + Provider.Connection.Close(); + Provider.Connection.Open(); Provider.RemoveTable(child); Assert.That(Provider.TableExists(child), Is.False); return; diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index bf272889..0a7f731a 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -210,12 +210,20 @@ public override string AddIndex(string table, Index index) public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {QuoteTableNameIfRequired(table)}.{name}"); + private string RenameObjectName(string table) + { + var relation = CatalogRelation(table); + if (relation.Schema != null && !string.Equals(relation.Schema, Convert.ToString(ExecuteScalar("SELECT user_name()")), StringComparison.Ordinal)) + throw new NotSupportedException("ASE sp_rename requires a connection in the object's owner namespace."); + return System.Text.RegularExpressions.Regex.IsMatch(relation.Name, @"^[A-Za-z_][A-Za-z0-9_]*$") ? relation.Name : _dialect.QuoteIdentifier(relation.Name); + } + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) => - ExecuteNonQuery($"EXEC sp_rename '{Literal(CatalogObjectName(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}', 'column'"); + ExecuteNonQuery($"EXEC sp_rename '{Literal(RenameObjectName(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}', 'column'"); public override void RenameTable(string oldName, string newName) { var target = SqlIdentifier.Parse(RenameTarget(oldName, newName)).Single().Value; - ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(CatalogObjectName(oldName))}, {SqlLiteral(target)}"); + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(RenameObjectName(oldName))}, {SqlLiteral(target)}"); } public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP {column}"); public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} REPLACE {column} DEFAULT NULL"); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index f6f8fdbf..a85a5d95 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -393,7 +393,7 @@ public virtual void RenameColumn(string tableName, string oldColumnName, string var quotedNewColumnName = QuoteColumnNameIfRequired(newColumnName); - ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME COLUMN {1} TO {2}", tableName, Dialect.Quote(column.Name), quotedNewColumnName)); + ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME COLUMN {1} TO {2}", QuoteTableNameIfRequired(tableName), Dialect.Quote(column.Name), quotedNewColumnName)); } public virtual void RemoveColumn(string tableName, string column) @@ -410,7 +410,7 @@ public virtual void RemoveColumn(string tableName, string column) var existingColumn = GetColumnByName(tableName, column); - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP COLUMN {1} ", tableName, Dialect.Quote(existingColumn.Name))); + ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP COLUMN {1} ", QuoteTableNameIfRequired(tableName), Dialect.Quote(existingColumn.Name))); } public virtual bool ColumnExists(string table, string column) From cb021c45d2e101115e2c86d00a73efeb4f6b4082 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 19:52:26 +0200 Subject: [PATCH 5/6] Handle native owner restrictions and quoted rename targets Normalize rename destinations with the same dialect quoting rules as sources so Oracle numeric-leading mixed-case schema names retain identity. Use an owner-checked local ASE table for DROP INDEX, which rejects three-part names. Remove dependent indexes before column rename in the isolation fixture for Db2. Serialize disposable Oracle user cleanup to prevent concurrent DROP USER dictionary-lock conflicts observed in CI. Validation: all 1,231 unit/SQLite tests pass locally. --- docs/namespace-support.md | 2 +- .../OracleDatabaseIntegrationTestService.cs | 2 +- .../Providers/Live/NamespaceIsolationTests.cs | 7 ++++--- .../Impl/Oracle/OracleTransformationProvider.cs | 2 +- .../Impl/Sybase/SybaseTransformationProvider.cs | 10 +++++----- src/Migrator/Providers/TransformationProvider.cs | 2 +- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/namespace-support.md b/docs/namespace-support.md index 34593e4b..1a73e1bb 100644 --- a/docs/namespace-support.md +++ b/docs/namespace-support.md @@ -26,7 +26,7 @@ restriction layout. Table enumeration returns local names. | Ingres | Owner-qualified DDL and native table/view/column/constraint/index-existence catalogs; the legacy provider still lacks structured index/FK metadata and live qualification infrastructure | Renaming keeps the source namespace when the new name is unqualified. It is not -a portable API for moving tables between namespaces. ASE's sp_rename requires +a portable API for moving tables between namespaces. ASE index removal and sp_rename require a connection in the object's owner namespace; an explicitly different owner is rejected before renaming. SQLite foreign keys cannot reference another attached database; that request fails before table creation. diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs index 227df82b..c23ac081 100644 --- a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs @@ -99,7 +99,7 @@ public override async Task CreateTestDatabaseAsync(DatabaseConnect await Parallel.ForEachAsync( toBeDeletedUsers, - new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = 1, CancellationToken = cancellationToken }, async (x, cancellationTokenInner) => { var databaseInfoToBeDeleted = new DatabaseInfo diff --git a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs index fbe60cf4..b817cea6 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs @@ -64,13 +64,14 @@ void Create(string schema) Assert.That(Provider.ColumnExists("ns_items", "only_first"), Is.False); Provider.RemoveColumn(target, "only_first"); Assert.That(Provider.ColumnExists(target, "only_first"), Is.False); + Provider.RemoveIndex(target, "ix_same"); + Assert.That(Provider.IndexExists(target, "ix_same"), Is.False); + Assert.That(Provider.IndexExists("ns_items", "ix_same"), Is.True); + // Db2 forbids renaming a column while an index depends on it. Provider.RenameColumn(target, "payload", "message"); Assert.That(Provider.ColumnExists(target, "message"), Is.True); Assert.That(Provider.ColumnExists("ns_items", "message"), Is.False); Provider.RenameColumn(target, "message", "payload"); - Provider.RemoveIndex(target, "ix_same"); - Assert.That(Provider.IndexExists(target, "ix_same"), Is.False); - Assert.That(Provider.IndexExists("ns_items", "ix_same"), Is.True); Provider.RenameTable(target, "ns_renamed"); Assert.That(Provider.TableExists(target), Is.False); Assert.That(Provider.TableExists(Qualified(first, "ns_renamed")), Is.True); diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 6519c9cd..cd1333eb 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -195,7 +195,7 @@ private void CopyDataFromOneColumnToAnother(string table, string fromColumn, str public override void RenameTable(string oldName, string newName) { var oldRelation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(oldName), true); - var newRelation = SqlIdentifier.Catalog(newName, 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); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 0a7f731a..65eb20a5 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -209,21 +209,21 @@ public override string AddIndex(string table, Index index) public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); - public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {QuoteTableNameIfRequired(table)}.{name}"); - private string RenameObjectName(string table) + public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {OwnedLocalTableName(table)}.{QuoteConstraintNameIfRequired(name)}"); + private string OwnedLocalTableName(string table) { var relation = CatalogRelation(table); if (relation.Schema != null && !string.Equals(relation.Schema, Convert.ToString(ExecuteScalar("SELECT user_name()")), StringComparison.Ordinal)) - throw new NotSupportedException("ASE sp_rename requires a connection in the object's owner namespace."); + throw new NotSupportedException("ASE index removal and rename require a connection in the object's owner namespace."); return System.Text.RegularExpressions.Regex.IsMatch(relation.Name, @"^[A-Za-z_][A-Za-z0-9_]*$") ? relation.Name : _dialect.QuoteIdentifier(relation.Name); } public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) => - ExecuteNonQuery($"EXEC sp_rename '{Literal(RenameObjectName(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}', 'column'"); + ExecuteNonQuery($"EXEC sp_rename '{Literal(OwnedLocalTableName(tableName))}.{Literal(oldColumnName)}', '{Literal(newColumnName)}', 'column'"); public override void RenameTable(string oldName, string newName) { var target = SqlIdentifier.Parse(RenameTarget(oldName, newName)).Single().Value; - ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(RenameObjectName(oldName))}, {SqlLiteral(target)}"); + ExecuteNonQuery($"EXEC sp_rename {SqlLiteral(OwnedLocalTableName(oldName))}, {SqlLiteral(target)}"); } public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP {column}"); public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} REPLACE {column} DEFAULT NULL"); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index a85a5d95..c049055c 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1613,7 +1613,7 @@ protected string RenameTarget(string oldName, string newName) { var upper = _dialect is DotNetProjects.Migrator.Providers.Impl.DB2.DB2Dialect or DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect; var source = CatalogRelation(oldName, upper); - var target = SqlIdentifier.Catalog(newName, upper); + var target = SqlIdentifier.Catalog(_dialect.QuoteTableNameIfRequired(newName), upper); if (target.Schema != null && !string.Equals(source.Schema, target.Schema, StringComparison.OrdinalIgnoreCase)) throw new NotSupportedException("RenameTable does not move tables between namespaces."); return _dialect.QuoteTableNameIfRequired(SqlIdentifier.Parse(newName).Last().Quoted ? _dialect.QuoteIdentifier(target.Name) : target.Name); From 781b51e7eca5ef2c338b8434103e9777c00d6c3a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 25 Sep 2026 20:21:59 +0200 Subject: [PATCH 6/6] Complete owner-scoped Ingres index and constraint support Implement structured PK, unique, check and composite foreign-key catalogs with schema-qualified joins, exact parent identity, ordered key pairs and segmented definition parsing. Inspect and create secondary indexes, distinguish their backing constraints, and scope index/constraint/column and incoming/outgoing foreign-key removal to the requested owner. Add 19 executable catalog and DDL contract regressions covering all four namespace modes, duplicate names, included columns, quoted parents, CHAR padding and referential actions. Add an opt-in live Ingres lifecycle fixture. Local validation: 1,250 unit/SQLite tests pass with zero skips; live fixture compiles and discovers four additional cases. A live Ingres run remains unavailable because no licensed instance is available. --- docs/namespace-support.md | 36 ++- src/Migrator.Tests/IngresMetadataTests.cs | 207 ++++++++++++++++++ src/Migrator.Tests/Migrator.Tests.csproj | 5 + .../Providers/Live/LiveProviderFixture.cs | 20 +- .../Providers/Live/NamespaceLifecycleTests.cs | 4 + .../Impl/Ingres/IngresConstraintText.cs | 39 ++++ .../Ingres/IngresTransformationProvider.cs | 193 +++++++++++++++- 7 files changed, 494 insertions(+), 10 deletions(-) create mode 100644 src/Migrator.Tests/IngresMetadataTests.cs create mode 100644 src/Migrator/Providers/Impl/Ingres/IngresConstraintText.cs diff --git a/docs/namespace-support.md b/docs/namespace-support.md index 1a73e1bb..c9bc2f9d 100644 --- a/docs/namespace-support.md +++ b/docs/namespace-support.md @@ -23,7 +23,7 @@ restriction layout. Table enumeration returns local names. | MySQL / MariaDB | Database (SCHEMA is a synonym for DATABASE) | | SQLite / MonoSQLite | main, temp, or a connection-local ATTACH alias | | Firebird | The provider targets Firebird 5: namespaces are explicitly rejected, including configured defaults | -| Ingres | Owner-qualified DDL and native table/view/column/constraint/index-existence catalogs; the legacy provider still lacks structured index/FK metadata and live qualification infrastructure | +| Ingres | Owner-qualified DDL and native table/view/column/constraint/index catalogs, including composite foreign keys and cross-owner references | Renaming keeps the source namespace when the new name is unqualified. It is not a portable API for moving tables between namespaces. ASE index removal and sp_rename require @@ -52,10 +52,40 @@ attached databases. - SQLiteNamespaceTests exercises both selectors with dotted ATTACH names, reconstruction, triggers, index isolation, sequence high-water preservation, qualified renames and rejection of cross-database foreign keys. -- Ingres has no engine/driver job in this repository. Its command contracts do - not certify full live provider support. Firebird 6 schema support likewise +- IngresMetadataTests executes the actual catalog SELECTs against SQLite tables + shaped like the documented Ingres catalogs. It tests owner isolation, composite + key order, constraint-backed versus ordinary unique indexes, included columns, + split CHECK/FK definition text, referential actions and quoted cross-owner names. + DDL contracts cover index creation/removal and restrictive column/constraint + drops. This is executable catalog coverage, not an Ingres engine simulation. +- The Ingres lifecycle fixture can be enabled for a licensed instance as described + below. No live Ingres run has been performed. Firebird 6 schema support likewise requires separate version qualification. +## Running the Ingres lifecycle suite + +The [official Ingres image](https://hub.docker.com/r/actian/ingres) requires a +commercial license and license key. It is not added to the public CI matrix. +Use a licensed test installation with an empty, disposable owner namespace and +a current Actian .NET driver compatible with .NET 9. Set MIGRATOR_INGRES_DRIVER +to the driver's assembly path and MIGRATOR_INGRES to its connection string, then: + +```powershell +dotnet test src/Migrator.Tests/Migrator.Tests.csproj -p:LiveDatabase=Ingres --filter 'TestCategory=Ingres' +``` + +This enables the four existing lifecycle cases (unqualified, qualified, +quoted-qualified and default namespace). Missing configuration fails explicitly; +the suite does not turn missing connectivity into passing or skipped tests. +Setup refuses a nonempty owner namespace and each case rolls back its DDL. +The optional fixture is not compiled into normal CI discovery. + +The implementation uses Actian's documented [standard catalogs](https://docs.actian.com/ingres/11.0/DatabaseAdmin/Standard_Catalogs_for_All_Databases.htm). +Ingres secondary indexes support key and non-key columns; clustered and filtered +index requests are rejected explicitly. DROP COLUMN and DROP CONSTRAINT use +RESTRICT, so dependent objects must be removed by the caller first. Renaming is +also subject to the engine's dependency restrictions. + Related issue: [#48](https://github.com/dotnetprojects/Migrator.NET/issues/48). This matrix describes the tests added here, not a claim that every possible identifier, database version or schema-changing operation is covered. diff --git a/src/Migrator.Tests/IngresMetadataTests.cs b/src/Migrator.Tests/IngresMetadataTests.cs new file mode 100644 index 00000000..14ce73f3 --- /dev/null +++ b/src/Migrator.Tests/IngresMetadataTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.Ingres; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NSubstitute.Extensions; +using NUnit.Framework; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests; + +// Executes the actual catalog SELECTs against an independent catalog fixture. +// This checks joins, ordering, CHAR padding and namespace isolation, not Ingres DDL execution. +public class IngresMetadataTests +{ + private SqliteConnection connection; + private IngresTransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + connection.CreateFunction("DBMSINFO", key => key == "username" ? "tenant" : "testdb"); + Sql(""" + CREATE TABLE iiconstraints(schema_name TEXT COLLATE RTRIM,table_name TEXT COLLATE RTRIM,constraint_name TEXT COLLATE RTRIM,constraint_type TEXT,text_sequence INT,text_segment TEXT); + CREATE TABLE iikeys(schema_name TEXT COLLATE RTRIM,table_name TEXT COLLATE RTRIM,constraint_name TEXT COLLATE RTRIM,column_name TEXT COLLATE RTRIM,key_position INT); + CREATE TABLE iiref_constraints(ref_schema_name TEXT COLLATE RTRIM,ref_table_name TEXT COLLATE RTRIM,ref_constraint_name TEXT COLLATE RTRIM,unique_schema_name TEXT COLLATE RTRIM,unique_table_name TEXT COLLATE RTRIM,unique_constraint_name TEXT COLLATE RTRIM); + CREATE TABLE iiindexes(index_owner TEXT COLLATE RTRIM,index_name TEXT COLLATE RTRIM,base_owner TEXT COLLATE RTRIM,base_name TEXT COLLATE RTRIM,unique_rule TEXT); + CREATE TABLE iiindex_columns(index_owner TEXT COLLATE RTRIM,index_name TEXT COLLATE RTRIM,column_name TEXT COLLATE RTRIM,key_sequence INT); + CREATE TABLE iiconstraint_indexes(schema_name TEXT COLLATE RTRIM,constraint_name TEXT COLLATE RTRIM,index_name TEXT COLLATE RTRIM); + CREATE TABLE iicolumns(table_owner TEXT COLLATE RTRIM,table_name TEXT COLLATE RTRIM,column_name TEXT COLLATE RTRIM,column_sequence INT,column_datatype TEXT,column_length INT,column_scale INT,column_nulls TEXT,column_default_val TEXT); + """); + provider = Substitute.ForPartsOf(new IngresDialect(), connection, "default", null); + foreach (var owner in new[] { "tenant", "other", "Tenant.One" }) Seed(owner); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + private void Sql(string sql) { using var command = connection.CreateCommand(); command.CommandText = sql; command.ExecuteNonQuery(); } + private void Insert(string catalog, params object[] values) + { + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO " + catalog + " VALUES (" + string.Join(",", values.Select((_, i) => "@p" + i)) + ")"; + for (var i = 0; i < values.Length; i++) command.Parameters.AddWithValue("@p" + i, values[i] ?? DBNull.Value); + command.ExecuteNonQuery(); + } + + private void Seed(string owner) + { + var prefix = owner == "other" ? "wrong_" : ""; + // Deliberately insert segments and composite columns in reverse physical order. + Insert("iiconstraints", owner, "items", "pk_items", "P", 1, "PRIMARY KEY (b,a)"); + Insert("iiconstraints", owner, "items", "uq_items", "U", 1, "UNIQUE (code)"); + Insert("iiconstraints", owner, "items", "ck_items", "C", 2, " b' AND amount > 0)"); + Insert("iiconstraints", owner, "items", "ck_items", "C", 1, "CONSTRAINT \"CHECK\" CHECK (code <> 'a"); + Insert("iiconstraints", owner, "items", "fk_items", "R", 2, "CADE ON UPDATE SET NULL"); + Insert("iiconstraints", owner, "items", "fk_items", "R", 1, "FOREIGN KEY (b,a) REFERENCES parent (y,x) ON DELETE CAS"); + Insert("iikeys", owner, "items", "pk_items", prefix + "a ", 2); + Insert("iikeys", owner, "items", "pk_items", prefix + "b ", 1); + Insert("iikeys", owner, "items", "uq_items", prefix + "code", 1); + Insert("iikeys", owner, "items", "fk_items", prefix + "a", 2); + Insert("iikeys", owner, "items", "fk_items", prefix + "b", 1); + Insert("iikeys", owner, "parent", "pk_parent", prefix + "x", 2); + Insert("iikeys", owner, "parent", "pk_parent", prefix + "y", 1); + Insert("iiref_constraints", owner, "items", "fk_items", owner, "parent", "pk_parent"); + Insert("iiindexes", owner, "ix_items", owner, "items", "U"); + Insert("iiindexes", owner, "$pk_storage", owner, "items", "U"); + Insert("iiindexes", owner, "$uq_storage", owner, "items", "U"); + Insert("iiconstraint_indexes", owner, "pk_items", "$pk_storage"); + Insert("iiconstraint_indexes", owner, "uq_items", "$uq_storage"); + foreach (var index in new[] { "ix_items", "$pk_storage", "$uq_storage" }) + { + Insert("iiindex_columns", owner, index, prefix + "a", 2); + Insert("iiindex_columns", owner, index, prefix + "b", 1); + Insert("iicolumns", owner, index, prefix + "a", 1, "integer", 4, 0, "N", null); + Insert("iicolumns", owner, index, prefix + "b", 2, "integer", 4, 0, "N", null); + Insert("iicolumns", owner, index, "tidp", 3, "integer", 4, 0, "N", null); + } + Insert("iicolumns", owner, "ix_items", prefix + "payload", 4, "varchar", 30, 0, "Y", null); + Insert("iicolumns", owner, "items", "amount", 1, "decimal", 12, 2, "N", "123.45"); + } + + private string SelectMode(string mode) + { + provider.SetDefaultSchema(mode == "default" ? "tenant" : mode == "unqualified" ? null : "other"); + return mode switch { "qualified" => "TENANT.ITEMS", "quoted" => "\"Tenant.One\".\"items\"", _ => "items" }; + } + + [TestCase("unqualified")] + [TestCase("qualified")] + [TestCase("quoted")] + [TestCase("default")] + public void CatalogsKeepCompositeKeysConstraintsAndIndexesInsideTheSelectedNamespace(string mode) + { + var table = SelectMode(mode); + var constraints = provider.GetTableConstraints(table); + Assert.That(constraints, Has.Length.EqualTo(4)); + Assert.That(constraints.OfType().Single().KeyColumns, Is.EqualTo(new[] { "b", "a" })); + Assert.That(constraints.OfType().Single().KeyColumns, Is.EqualTo(new[] { "code" })); + Assert.That(constraints.OfType().Single().CheckConstraintString, Is.EqualTo("code <> 'a b' AND amount > 0")); + var fk = constraints.OfType().Single(); + Assert.That(fk.ChildColumns, Is.EqualTo(new[] { "b", "a" })); + Assert.That(fk.ParentColumns, Is.EqualTo(new[] { "y", "x" })); + Assert.That(fk.ParentTable, Is.EqualTo("\"parent\"")); + Assert.That(fk.OnDelete, Is.EqualTo("CASCADE")); + Assert.That(fk.OnUpdate, Is.EqualTo("SET NULL")); + var indexes = provider.GetIndexes(table); + Assert.That(indexes, Has.Length.EqualTo(3)); + Assert.That(indexes.Single(i => i.Name == "$pk_storage").PrimaryKey, Is.True); + Assert.That(indexes.Single(i => i.Name == "$uq_storage").UniqueConstraint, Is.True); + var userIndex = indexes.Single(i => i.Name == "ix_items"); + Assert.That(userIndex.Unique, Is.True); + Assert.That(userIndex.UniqueConstraint, Is.False); + Assert.That(userIndex.KeyColumns, Is.EqualTo(new[] { "b", "a" })); + Assert.That(userIndex.IncludeColumns, Is.EqualTo(new[] { "payload" })); + var column = provider.GetColumns(table).Single(); + Assert.That(column.Precision, Is.EqualTo(12)); + Assert.That(column.Scale, Is.EqualTo(2)); + Assert.That(column.DefaultValue, Is.EqualTo(123.45m)); + Assert.That(provider.GetForeignKeyConstraints(table), Has.Length.EqualTo(1)); + } + + [Test] + public void CrossOwnerReferencesPreserveQuotedParentIdentityAndDoNotJoinNamesakes() + { + Sql("UPDATE iiref_constraints SET unique_schema_name='Parent.Owner',unique_table_name='Parent\"Name' WHERE ref_schema_name='tenant'"); + Insert("iikeys", "Parent.Owner", "Parent\"Name", "pk_parent", "right", 2); + Insert("iikeys", "Parent.Owner", "Parent\"Name", "pk_parent", "left", 1); + var fk = provider.GetForeignKeyConstraints("tenant.items").Single(); + Assert.That(fk.ParentTable, Is.EqualTo("\"Parent.Owner\".\"Parent\"\"Name\"")); + Assert.That(fk.ParentColumns, Is.EqualTo(new[] { "left", "right" })); + } + + [Test] + public void MissingObjectsAndQuotedSqlPunctuationDoNotMatchAnotherOwner() + { + Assert.That(provider.GetIndexes("missing.items"), Is.Empty); + Assert.That(provider.GetTableConstraints("\"x' OR 1=1 --\".items"), Is.Empty); + Assert.That(provider.GetForeignKeyConstraints("tenant.missing"), Is.Empty); + } + + [TestCase("unqualified", "items", "\"new_index\"")] + [TestCase("qualified", "TENANT.ITEMS", "\"tenant\".\"new_index\"")] + [TestCase("quoted", "\"Tenant.One\".\"items\"", "\"Tenant.One\".\"new_index\"")] + [TestCase("default", "tenant.items", "\"tenant\".\"new_index\"")] + public void DdlUsesTheSameNamespaceAsTheCatalog(string mode, string qualifiedTable, string qualifiedIndex) + { + var table = SelectMode(mode); + var commands = new List(); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(c => { commands.Add((string)c[0]); return 1; }); + provider.AddIndex(table, new Index { Name = "new_index", Unique = true, KeyColumns = ["b", "a"], IncludeColumns = ["payload"] }); + provider.RemoveIndex(table, "ix_items"); + provider.RemoveConstraint(table, "ck_items"); + provider.RemoveColumn(table, "amount"); + Assert.That(commands[0], Is.EqualTo($"CREATE UNIQUE INDEX {qualifiedIndex} ON {qualifiedTable} (b, a, payload) WITH STRUCTURE=BTREE, KEY=(b, a), PERSISTENCE")); + Assert.That(commands[1], Is.EqualTo("DROP INDEX " + qualifiedIndex.Replace("new_index", "ix_items"))); + Assert.That(commands[2], Is.EqualTo($"ALTER TABLE {qualifiedTable} DROP CONSTRAINT \"ck_items\" RESTRICT")); + Assert.That(commands[3], Is.EqualTo($"ALTER TABLE {qualifiedTable} DROP COLUMN \"amount\" RESTRICT")); + } + + [Test] + public void SystemIndexRemovalUsesItsConstraintNameAndSelectedOwner() + { + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(1); + provider.RemoveIndex("tenant.items", "$pk_storage"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE tenant.items DROP CONSTRAINT \"pk_items\" RESTRICT"); + provider.DidNotReceive().ExecuteNonQuery(Arg.Is(s => s.Contains("DROP CONSTRAINT \"$pk_storage\""))); + } + + [TestCase(null, 2)] + [TestCase("b", 2)] + [TestCase("code", 0)] + public void BulkForeignKeyRemovalIncludesIncomingReferencesWithoutTouchingNamesakes(string column, int expected) + { + Insert("iiconstraints", "other", "child", "fk_incoming", "R", 1, "REFERENCES tenant.items(b,a)"); + Insert("iikeys", "other", "child", "fk_incoming", "child_b", 1); + Insert("iiref_constraints", "other", "child", "fk_incoming", "tenant", "items", "pk_items"); + var commands = new List(); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(c => { commands.Add((string)c[0]); return 1; }); + provider.SetDefaultSchema("other"); + provider.RemoveAllForeignKeys("tenant.items", column); + Assert.That(commands, Has.Count.EqualTo(expected)); + if (expected != 0) Assert.That(commands, Is.EquivalentTo(new[] { + "ALTER TABLE \"tenant\".\"items\" DROP CONSTRAINT \"fk_items\" RESTRICT", + "ALTER TABLE \"other\".\"child\" DROP CONSTRAINT \"fk_incoming\" RESTRICT" })); + } + + [TestCase("FOREIGN KEY (x) REFERENCES p (y)", "NO ACTION", "NO ACTION")] + [TestCase("FOREIGN KEY (\"ON DELETE CASCADE\") REFERENCES \"ON UPDATE SET NULL\" (y)", "NO ACTION", "NO ACTION")] + [TestCase("/* ON DELETE CASCADE */ REFERENCES p(y) ON UPDATE RESTRICT -- ON DELETE SET NULL\n", "NO ACTION", "RESTRICT")] + [TestCase("REFERENCES p(y) ON /* comment */ DELETE NO ACTION ON UPDATE CASCADE", "NO ACTION", "CASCADE")] + public void ActionsIgnoreQuotedTextAndComments(string definition, string delete, string update) + { + Assert.That(IngresConstraintText.Actions(definition), Is.EqualTo((delete, update))); + } + + [Test] + public void UnknownReferentialActionsAreNotSilentlyReplacedByNoAction() => + Assert.Throws(() => IngresConstraintText.Actions("REFERENCES p(y) ON DELETE SOMETHING")); +} diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index d39d419d..908e4416 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -5,6 +5,11 @@ false + + + $(DefineConstants);INGRES_LIVE + + all diff --git a/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs b/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs index caef2cfb..e03f4e9e 100644 --- a/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs +++ b/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs @@ -2,8 +2,9 @@ using System.Collections.Generic; using System.Data; using System.Data.Common; -using System.Globalization; using System.Linq; +using System.Reflection; +using System.Globalization; using System.Threading.Tasks; using DotNetProjects.Migrator; using DotNetProjects.Migrator.Framework; @@ -18,6 +19,7 @@ public abstract class LiveProviderFixture(string database, ProviderTypes provide { protected LiveDatabaseTests live; private HanaConnection hana; + private DbConnection ingres; private string schema; [SetUp] @@ -29,6 +31,19 @@ public async Task SetUp() case "SQLServer": await BeginSQLServerTransactionAsync(); break; case "PostgreSQL": await BeginPostgreSQLTransactionAsync(); break; case "Oracle": await BeginOracleTransactionAsync(); break; + case "Ingres": + var driver = Environment.GetEnvironmentVariable("MIGRATOR_INGRES_DRIVER") + ?? throw new InvalidOperationException("Set MIGRATOR_INGRES_DRIVER to a .NET-compatible Actian driver assembly path."); + var connectionString = Environment.GetEnvironmentVariable("MIGRATOR_INGRES") + ?? throw new InvalidOperationException("Set MIGRATOR_INGRES to a disposable Ingres database connection string."); + var connectionType = Assembly.LoadFrom(driver).GetTypes().Single(t => t.IsPublic && !t.IsAbstract && typeof(DbConnection).IsAssignableFrom(t)); + ingres = (DbConnection)Activator.CreateInstance(connectionType); + ingres.ConnectionString = connectionString; + ingres.Open(); + Provider = ProviderFactory.Create(providerType, ingres, null, "namespace-tests"); + if (Provider.GetTables().Length != 0) throw new InvalidOperationException("Ingres namespace tests require an empty, disposable owner namespace."); + Provider.BeginTransaction(); + break; case "Hana": hana = new HanaConnection(Environment.GetEnvironmentVariable("MIGRATOR_HANA") ?? "Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2"); @@ -65,6 +80,7 @@ public override void TearDown() try { if (live != null) live.TearDown(); + else if (ingres != null) Provider?.Rollback(); else if (hana != null) { Provider?.Dispose(); @@ -81,9 +97,11 @@ public override void TearDown() { if (live == null) Provider?.Dispose(); hana?.Dispose(); + ingres?.Dispose(); Provider = null; live = null; hana = null; + ingres = null; schema = null; } } diff --git a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs index a5bb38f9..6288c4fc 100644 --- a/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -26,6 +26,9 @@ namespace Migrator.Tests.Providers.Live; [TestFixture("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] [TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] [TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] +#if INGRES_LIVE +[TestFixture("Ingres", ProviderTypes.Ingres, Category = "Ingres")] +#endif [NonParallelizable] public class NamespaceLifecycleTests(string database, ProviderTypes providerType) : LiveProviderFixture(database, providerType) { @@ -40,6 +43,7 @@ public class NamespaceLifecycleTests(string database, ProviderTypes providerType "Informix" => Convert.ToString(Provider.ExecuteScalar("SELECT USER FROM systables WHERE tabid=1")).Trim(), "Sybase" => Convert.ToString(Provider.ExecuteScalar("SELECT user_name()")), "Hana" => Convert.ToString(Provider.ExecuteScalar("SELECT CURRENT_SCHEMA FROM DUMMY")), + "Ingres" => Convert.ToString(Provider.ExecuteScalar("SELECT DBMSINFO('username')")).TrimEnd(), _ => null }; diff --git a/src/Migrator/Providers/Impl/Ingres/IngresConstraintText.cs b/src/Migrator/Providers/Impl/Ingres/IngresConstraintText.cs new file mode 100644 index 00000000..3de16d32 --- /dev/null +++ b/src/Migrator/Providers/Impl/Ingres/IngresConstraintText.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using DotNetProjects.Migrator.Framework; + +namespace DotNetProjects.Migrator.Providers.Impl.Ingres; + +// iiref_constraints describes key pairs, but referential actions are recorded +// only in iiconstraints.text_segment. Never treat quoted names/literals as SQL. +internal static class IngresConstraintText +{ + private static Match[] Tokens(string text) => Regex.Matches(text, + "--[^\\r\\n]*|/\\*[\\s\\S]*?\\*/|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|[A-Za-z_][A-Za-z_0-9]*|\\S") + .Cast().Where(m => !m.Value.StartsWith("--") && !m.Value.StartsWith("/*")).ToArray(); + + internal static (string Delete, string Update) Actions(string text) + { + var tokens = Tokens(text).Select(m => m.Value.ToUpperInvariant()).ToArray(); + var delete = "NO ACTION"; + var update = "NO ACTION"; + for (var i = 0; i + 2 < tokens.Length; i++) + { + if (tokens[i] != "ON" || tokens[i + 1] is not ("DELETE" or "UPDATE")) continue; + var action = tokens[i + 2]; + if (action is "NO" or "SET" && i + 3 < tokens.Length) action += " " + tokens[i + 3]; + if (action is not ("CASCADE" or "RESTRICT" or "NO ACTION" or "SET NULL")) + throw new MigrationException("Unknown Ingres referential action: " + action); + if (tokens[i + 1] == "DELETE") delete = action; else update = action; + } + return (delete, update); + } + + internal static string CheckExpression(string text) + { + var check = Tokens(text).FirstOrDefault(m => m.Value.Equals("CHECK", StringComparison.OrdinalIgnoreCase)); + if (check == null) throw new MigrationException("Missing CHECK expression in Ingres catalog."); + return ConstraintMetadataReader.CheckExpression(text[check.Index..]); + } +} diff --git a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs index 3f433436..c104a8ea 100644 --- a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs @@ -2,7 +2,11 @@ using System.Collections.Generic; using System.Data; using System.Linq; +using System.Text; using DotNetProjects.Migrator.Framework; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; +using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Ingres; @@ -27,10 +31,7 @@ public IngresTransformationProvider(Dialect dialect, IDbConnection connection, s { } - public override List GetDatabases() - { - throw new NotImplementedException(); - } + public override List GetDatabases() => [Convert.ToString(ExecuteScalar("SELECT DBMSINFO('database')")).TrimEnd()]; private string Predicate(string table, string owner = "table_owner", string name = "table_name") => $"{owner}={NamespaceSql(table, "DBMSINFO('username')")} AND {name}={ObjectSqlLiteral(table)}"; @@ -50,8 +51,188 @@ public override bool ConstraintExists(string table, string name) => protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery($"SELECT DISTINCT constraint_name FROM iiconstraints WHERE {Predicate(table, "schema_name")} AND constraint_type='P'").FirstOrDefault()?.TrimEnd(); - public override bool IndexExists(string table, string name) => Convert.ToInt32(ExecuteScalar( - $"SELECT COUNT(*) FROM iiindexes WHERE {Predicate(table, "base_owner", "base_name")} AND index_name={SqlLiteral(name)}")) > 0; + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); + + // Standard catalogs are keyed by owner AND object name. Constraint text can span + // multiple rows; read it separately so it cannot multiply composite key columns. + private Dictionary ConstraintDefinitions(string table) + { + var result = new Dictionary(StringComparer.Ordinal); + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $"SELECT constraint_name,constraint_type,text_segment FROM iiconstraints WHERE {Predicate(table, "schema_name")} ORDER BY constraint_name,text_sequence"); + while (reader.Read()) + { + var name = reader.GetString(0).TrimEnd(); + if (!result.TryGetValue(name, out var definition)) + result.Add(name, definition = (reader.GetString(1).Trim(), new StringBuilder())); + // Do not trim or insert separators: a segment boundary may split a token/literal. + if (!reader.IsDBNull(2)) definition.Item2.Append(reader.GetString(2)); + } + return result; + } + + public override TableConstraint[] GetTableConstraints(string table) + { + var definitions = ConstraintDefinitions(table); + var keys = new Dictionary>(StringComparer.Ordinal); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $"SELECT constraint_name,column_name FROM iikeys WHERE {Predicate(table, "schema_name")} ORDER BY constraint_name,key_position")) + while (reader.Read()) + { + var name = reader.GetString(0).TrimEnd(); + if (!keys.TryGetValue(name, out var columns)) keys.Add(name, columns = []); + columns.Add(reader.GetString(1).TrimEnd()); + } + var constraints = new List(); + foreach (var (name, definition) in definitions) + { + if (definition.Kind == "R") continue; + keys.TryGetValue(name, out var columns); + constraints.Add(definition.Kind switch + { + "P" => new PrimaryKeyConstraint(name, columns?.ToArray() ?? []), + "U" => new UniqueConstraint(name, columns?.ToArray() ?? []), + "C" => new CheckConstraint(name, IngresConstraintText.CheckExpression(definition.Text.ToString())), + _ => throw new MigrationException("Unknown Ingres constraint type: " + definition.Kind) + }); + } + constraints.AddRange(ReadForeignKeys(table, definitions)); + return constraints.ToArray(); + } + + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) => ReadForeignKeys(table, ConstraintDefinitions(table)); + + private ForeignKeyConstraint[] ReadForeignKeys(string table, Dictionary definitions) + { + var rows = new List<(string Name, string Parent, string ChildColumn, string ParentColumn)>(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $""" + SELECT r.ref_constraint_name,r.ref_schema_name,r.unique_schema_name,r.unique_table_name,c.column_name,p.column_name + FROM iiref_constraints r + JOIN iikeys c ON c.schema_name=r.ref_schema_name AND c.table_name=r.ref_table_name AND c.constraint_name=r.ref_constraint_name + JOIN iikeys p ON p.schema_name=r.unique_schema_name AND p.table_name=r.unique_table_name AND p.constraint_name=r.unique_constraint_name AND p.key_position=c.key_position + WHERE {Predicate(table, "r.ref_schema_name", "r.ref_table_name")} + ORDER BY r.ref_constraint_name,c.key_position + """)) + while (reader.Read()) + { + var childOwner = reader.GetString(1).TrimEnd(); + var parentOwner = reader.GetString(2).TrimEnd(); + var parent = _dialect.QuoteIdentifier(reader.GetString(3).TrimEnd()); + if (parentOwner != childOwner) parent = _dialect.QuoteIdentifier(parentOwner) + "." + parent; + rows.Add((reader.GetString(0).TrimEnd(), parent, reader.GetString(4).TrimEnd(), reader.GetString(5).TrimEnd())); + } + return rows.GroupBy(r => r.Name).Select(group => + { + if (!definitions.TryGetValue(group.Key, out var definition) || definition.Kind != "R") + throw new MigrationException("Missing Ingres foreign-key definition: " + group.Key); + var actions = IngresConstraintText.Actions(definition.Text.ToString()); + return new ForeignKeyConstraint(group.Key, group.First().Parent, group.Select(r => r.ParentColumn).ToArray(), table, group.Select(r => r.ChildColumn).ToArray()) + { OnDelete = actions.Delete, OnUpdate = actions.Update }; + }).ToArray(); + } + + private List<(string Index, string Constraint, string Kind)> IndexConstraints(string table) + { + var result = new List<(string, string, string)>(); + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $""" + SELECT DISTINCT i.index_name,c.constraint_name,c.constraint_type + FROM iiconstraint_indexes i JOIN iiconstraints c ON c.schema_name=i.schema_name AND c.constraint_name=i.constraint_name + WHERE {Predicate(table, "c.schema_name", "c.table_name")} + """); + while (reader.Read()) result.Add((reader.GetString(0).TrimEnd(), reader.GetString(1).TrimEnd(), reader.GetString(2).Trim())); + return result; + } + + public override Index[] GetIndexes(string table) + { + var constraints = IndexConstraints(table); + var rows = new List<(string Name, bool Unique, string Column, int Key, int Position)>(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $""" + SELECT i.index_name,i.unique_rule,c.column_name,k.key_sequence,c.column_sequence + FROM iiindexes i JOIN iicolumns c ON c.table_owner=i.index_owner AND c.table_name=i.index_name + LEFT JOIN iiindex_columns k ON k.index_owner=i.index_owner AND k.index_name=i.index_name AND k.column_name=c.column_name + WHERE {Predicate(table, "i.base_owner", "i.base_name")} AND (c.column_name<>'tidp' OR k.key_sequence IS NOT NULL) + ORDER BY i.index_name,c.column_sequence + """)) + while (reader.Read()) rows.Add((reader.GetString(0).TrimEnd(), reader.GetString(1).Trim() == "U", reader.GetString(2).TrimEnd(), + reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), Convert.ToInt32(reader.GetValue(4)))); + return rows.GroupBy(r => r.Name).Select(group => new Index + { + Name = group.Key, Unique = group.First().Unique, + PrimaryKey = constraints.Any(c => c.Index == group.Key && c.Kind == "P"), + UniqueConstraint = constraints.Any(c => c.Index == group.Key && c.Kind == "U"), + KeyColumns = group.Where(r => r.Key > 0).OrderBy(r => r.Key).Select(r => r.Column).ToArray(), + IncludeColumns = group.Where(r => r.Key == 0).OrderBy(r => r.Position).Select(r => r.Column).ToArray() + }).ToArray(); + } + + public override string AddIndex(string table, Index index) + { + if (index.Clustered || index.FilterItems.Count != 0) + throw new NotSupportedException("Ingres secondary indexes do not support Clustered or FilterItems."); + if (index.KeyColumns == null || index.KeyColumns.Length == 0 || index.KeyColumns.Any(string.IsNullOrWhiteSpace)) + throw new MigrationException("An index requires nonempty key columns."); + var name = index.Name ?? "IX_" + CatalogRelation(table).Name + "_" + string.Join("_", index.KeyColumns); + var columns = index.KeyColumns.Concat(index.IncludeColumns).ToArray(); + if (columns.Distinct(StringComparer.OrdinalIgnoreCase).Count() != columns.Length) + throw new MigrationException("Index columns must not repeat."); + var keys = string.Join(", ", index.KeyColumns.Select(QuoteColumnNameIfRequired)); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {QualifyInSameNamespace(table, name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", columns.Select(QuoteColumnNameIfRequired))}) WITH STRUCTURE=BTREE, KEY=({keys}), PERSISTENCE"); + return name; + } + + public override void RemoveIndex(string table, string name) + { + var index = GetIndexes(table).SingleOrDefault(i => i.Name == name); + if (index == null) return; + // A system index name need not match the constraint that owns it. + foreach (var constraint in IndexConstraints(table).Where(c => c.Index == name)) RemoveConstraint(table, constraint.Constraint); + if (IndexExists(table, name)) ExecuteNonQuery("DROP INDEX " + QualifyInSameNamespace(table, name)); + } + + public override void RemoveAllIndexes(string table) + { + foreach (var index in GetIndexes(table)) RemoveIndex(table, index.Name); + } + + public override void RemoveAllForeignKeys(string tableName, string columnName) + { + var outgoing = Predicate(tableName, "r.ref_schema_name", "r.ref_table_name"); + var incoming = Predicate(tableName, "r.unique_schema_name", "r.unique_table_name"); + if (columnName != null) + { + outgoing += " AND c.column_name=" + SqlLiteral(columnName); + incoming += " AND p.column_name=" + SqlLiteral(columnName); + } + var references = new List<(string Table, string Constraint)>(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $""" + SELECT DISTINCT r.ref_schema_name,r.ref_table_name,r.ref_constraint_name + FROM iiref_constraints r + JOIN iikeys c ON c.schema_name=r.ref_schema_name AND c.table_name=r.ref_table_name AND c.constraint_name=r.ref_constraint_name + JOIN iikeys p ON p.schema_name=r.unique_schema_name AND p.table_name=r.unique_table_name AND p.constraint_name=r.unique_constraint_name AND p.key_position=c.key_position + WHERE ({outgoing}) OR ({incoming}) + """)) + while (reader.Read()) references.Add((_dialect.QuoteIdentifier(reader.GetString(0).TrimEnd()) + "." + _dialect.QuoteIdentifier(reader.GetString(1).TrimEnd()), reader.GetString(2).TrimEnd())); + foreach (var reference in references) RemoveConstraint(reference.Table, reference.Constraint); + } + + public override void RemoveConstraint(string table, string name) + { + var names = GetConstraints(table); + var actual = names.FirstOrDefault(n => n == name) ?? names.SingleOrDefault(n => n.Equals(name, StringComparison.OrdinalIgnoreCase)) + ?? throw new MigrationException("Constraint was not found in the requested table: " + name); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} DROP CONSTRAINT {_dialect.QuoteIdentifier(actual)} RESTRICT"); + } + + public override void RemoveColumn(string table, string name) + { + var column = GetColumnByName(table, name) ?? throw new MigrationException("Column was not found in the requested table: " + name); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} DROP COLUMN {_dialect.QuoteIdentifier(column.Name)} RESTRICT"); + } public override Column[] GetColumns(string table) {