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 new file mode 100644 index 00000000..c9bc2f9d --- /dev/null +++ b/docs/namespace-support.md @@ -0,0 +1,91 @@ +# 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 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 +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 +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 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. +- 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. +- 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/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/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/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..e03f4e9e --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveProviderFixture.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Reflection; +using System.Globalization; +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 DbConnection ingres; + 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 "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"); + 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 (ingres != null) Provider?.Rollback(); + 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(); + ingres?.Dispose(); + Provider = null; + live = null; + hana = null; + ingres = 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..b817cea6 --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/NamespaceIsolationTests.cs @@ -0,0 +1,95 @@ +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 " + 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); + 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.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 " + Provider.QuoteColumnNameIfRequired("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..6288c4fc --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/NamespaceLifecycleTests.cs @@ -0,0 +1,145 @@ +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")] +#if INGRES_LIVE +[TestFixture("Ingres", ProviderTypes.Ingres, Category = "Ingres")] +#endif +[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")), + "Ingres" => Convert.ToString(Provider.ExecuteScalar("SELECT DBMSINFO('username')")).TrimEnd(), + _ => 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) { 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")); + 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 {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 " + 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 " + 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) { IsNullable = false }); + 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); + 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; + } + 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 " + Provider.QuoteColumnNameIfRequired("message") + " FROM " + Provider.QuoteTableNameIfRequired(renamed))), Is.EqualTo("kept")); + Provider.RemoveTable(renamed); + Assert.That(Provider.TableExists(renamed), Is.False); + } + finally + { + // 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.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..ae31fb23 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -26,6 +26,11 @@ public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, 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); @@ -34,14 +39,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; + $"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() => 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]; @@ -61,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); @@ -83,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()) { @@ -188,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 7c534561..e95e8f13 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) @@ -99,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, @@ -170,7 +169,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/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 ac69a823..c104a8ea 100644 --- a/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Ingres/IngresTransformationProvider.cs @@ -1,6 +1,12 @@ using System; 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; @@ -25,18 +31,230 @@ public IngresTransformationProvider(Dialect dialect, IDbConnection connection, s { } - public override List GetDatabases() + 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)}"; + + 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 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) => 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) { - throw new NotImplementedException(); + 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 bool ConstraintExists(string table, string name) + public override Index[] GetIndexes(string table) { - throw new NotImplementedException(); + 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 bool IndexExists(string table, string name) + public override void RemoveAllForeignKeys(string tableName, string columnName) { - throw new NotImplementedException(); + 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) + { + 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..cd1333eb 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 = QuoteConstraintNameIfRequired(index.Name); + var relation = CatalogRelation(table, true); + var name = (relation.Schema == null ? "" : _dialect.QuoteIdentifier(relation.Schema) + ".") + QuoteConstraintNameIfRequired(index.Name); table = QuoteTableNameIfRequired(table); List singleFilterStrings = []; @@ -194,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); @@ -283,22 +284,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..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"); @@ -435,19 +432,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) { @@ -627,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/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..65eb20a5 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(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(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={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(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={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(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(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(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(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(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(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(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(table)}', {id}, {position})")); + var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(CatalogObjectName(table))}', {id}, {position})")); if (string.IsNullOrEmpty(key)) break; keys.Add(key); } @@ -194,27 +202,38 @@ 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 {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 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 RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {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(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(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"); 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 +248,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..f71e92d4 --- /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(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(); + 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..c049055c 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) @@ -414,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) @@ -431,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) @@ -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(_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); + } + 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)