From 903666fdf0bfa538cbb8e90d4d98b63c2258011a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 14:31:35 +0200 Subject: [PATCH 01/19] Preserve SQLite dependencies and correct provider SQL handling --- src/Migrator.Tests/ProviderCorrectionTests.cs | 35 +++ .../Generic/Generic_ChangeColumnTestsBase.cs | 6 +- ...ansformationProvider_AddForeignKeyTests.cs | 246 +++++++++--------- src/Migrator/Framework/IForeignKeyActions.cs | 6 + src/Migrator/Framework/ProviderExtensions.cs | 28 ++ .../Oracle/OracleTransformationProvider.cs | 12 +- .../PostgreSQLTransformationProvider.cs | 2 +- .../SQLite/SQLiteTransformationProvider.cs | 86 +++++- .../SqlServerTransformationProvider.cs | 24 +- .../Providers/TransformationProvider.cs | 64 +++-- 10 files changed, 322 insertions(+), 187 deletions(-) create mode 100644 src/Migrator.Tests/ProviderCorrectionTests.cs create mode 100644 src/Migrator/Framework/IForeignKeyActions.cs create mode 100644 src/Migrator/Framework/ProviderExtensions.cs diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs new file mode 100644 index 00000000..f96f4e5e --- /dev/null +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +namespace Migrator.Tests; +[Category("SQLite")] +public class ProviderCorrectionTests +{ + [Test] public void RebuildPreservesTriggerAndUpdateAction() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER, ParentId INTEGER); CREATE TABLE Audit (Id INTEGER)"); + provider.AddForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade); + provider.ExecuteNonQuery("CREATE TRIGGER ChildAudit AFTER INSERT ON Child BEGIN INSERT INTO Audit VALUES (NEW.Id); END"); + provider.AddColumn("Child", new Column("Name", DbType.String)); + provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child (Id, ParentId) VALUES (2, 1); UPDATE Parent SET Id=3 WHERE Id=1"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT ParentId FROM Child")), Is.EqualTo(3)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Audit")), Is.EqualTo(2)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + [Test] public void UnsupportedRebuildLeavesTableIntact() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY) WITHOUT ROWID; INSERT INTO Original VALUES (1)"); + Assert.Throws(() => provider.AddColumn("Original", new Column("Name", DbType.String))); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Original")), Is.EqualTo(1)); + } +} diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs index b20ad58b..8f5b667e 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs @@ -34,7 +34,7 @@ public void ChangeColumn_NotNullAndNullToNotNull_Success() Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); } - [Test, Ignore("Not yet implemented. See issue https://github.com/dotnetprojects/Migrator.NET/issues/139")] + [Test] public void ChangeColumn_RemoveDefaultValue_Success() { // Arrange @@ -59,11 +59,11 @@ public void ChangeColumn_RemoveDefaultValue_Success() using var cmd = Provider.CreateCommand(); using var reader = Provider.Select(cmd: cmd, table: tableName, columns: [column1Name, column2Name]); - List<(int, DateTime)> records = []; + List<(int, DateTime?)> records = []; while (reader.Read()) { - records.Add((reader.GetInt32(0), reader.GetDateTime(1))); + records.Add((reader.GetInt32(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1))); } Assert.That(records.Count, Is.EqualTo(2)); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs index 2b4b3b3e..477c5436 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs @@ -1,123 +1,123 @@ -using System.Data; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers.Impl.SQLite; -using Migrator.Tests.Providers.SQLite.Base; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.SQLite; - -[TestFixture] -[Category("SQLite")] -public class SQLiteTransformationProvider_AddForeignKeyTests : SQLiteTransformationProviderTestBase -{ - [TestCase(ForeignKeyConstraintType.Cascade, "CASCADE")] - [TestCase(ForeignKeyConstraintType.SetNull, "SET NULL")] - [TestCase(ForeignKeyConstraintType.SetDefault, "SET DEFAULT")] - [TestCase(ForeignKeyConstraintType.Restrict, "RESTRICT")] - [TestCase(ForeignKeyConstraintType.NoAction, "NO ACTION")] - public void AddForeignKey(ForeignKeyConstraintType constraint, string expectedAction) - { - // Arrange - AddTableWithPrimaryKey(); - Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); - Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); - - // Act - Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: constraint); - - // Assert - var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); - var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); - - Assert.That(foreignKeyConstraints.Single().Name, Is.EqualTo("FKName")); - Assert.That(foreignKeyConstraints.Single().ChildTable, Is.EqualTo("TestTwo")); - Assert.That(foreignKeyConstraints.Single().ParentTable, Is.EqualTo("Test")); - Assert.That(foreignKeyConstraints.Single().ChildColumns.Single(), Is.EqualTo("TestId")); - Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("Id")); - - Assert.That(foreignKeyConstraints.Single().OnDelete, Is.EqualTo(expectedAction)); - var expectedClause = constraint == ForeignKeyConstraintType.NoAction ? "" : $" ON DELETE {expectedAction}"; - Assert.That(tableSQLCreateScript, Does.Contain("CREATE TABLE \"TestTwo\"")); - Assert.That(tableSQLCreateScript, Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); - - // Reading and rebuilding an existing foreign key must retain its action. - Provider.RenameColumn("TestTwo", "TestId", "ParentId"); - Assert.That(Provider.GetForeignKeyConstraints("TestTwo").Single().OnDelete, Is.EqualTo(expectedAction)); - - var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); - Assert.That(result, Is.True); - } - - [Test] - public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn() - { - // Arrange - AddTableWithPrimaryKey(); - Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); - Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); - - // Act - Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: ForeignKeyConstraintType.Cascade); - - // Rename column in parent - Provider.RenameColumn("Test", "Id", "IdNew"); - - // Assert - var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); - var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); - - Assert.That(tableSQLCreateScript, Does.Contain("CREATE TABLE \"TestTwo\"")); - Assert.That(tableSQLCreateScript, Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); - Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("IdNew")); - - var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); - Assert.That(result, Is.True); - } - - [Test] - public void AddForeignKey_3_Success() - { - Provider.AddTable("Task", - new Column(name: "BinId", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), - new Column(name: "EstimatedPickTime", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "Item", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Order", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) - ); - - Provider.AddTable("TaskGroup", - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), - new Column(name: "Id", type: DbType.Int32) - ); - - // TODO CK add more columns. - Provider.AddForeignKey(name: "FK_Task_TaskGroup", childTable: "Task", childColumn: "TaskGroupId", parentTable: "TaskGroup", parentColumn: "Id"); - Assert.That(Provider.GetForeignKeyConstraints("Task").Single().OnDelete, Is.EqualTo("NO ACTION")); - Assert.That(((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Task"), Does.Not.Contain("ON DELETE")); - } - - [Test] - public void AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren() - { - // Enable enforcement before any transaction; SQLite ignores PRAGMA changes inside one. - using var connection = new System.Data.SQLite.SQLiteConnection("Data Source=:memory:;Foreign Keys=True"); - connection.Open(); - using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); - Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); - - provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - provider.AddTable("Child", new Column("ParentId", DbType.Int32)); - provider.ExecuteNonQuery("INSERT INTO Parent (Id) VALUES (1), (2)"); - provider.ExecuteNonQuery("INSERT INTO Child (ParentId) VALUES (1), (1), (2)"); - provider.AddForeignKey("FK_Child_Parent", "Child", "ParentId", "Parent", "Id", ForeignKeyConstraintType.Cascade); - - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.EqualTo(3)); - provider.ExecuteNonQuery("DELETE FROM Parent WHERE Id = 1"); - - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 1")), Is.Zero); - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 2")), Is.EqualTo(1)); - } -} +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Migrator.Tests.Providers.SQLite.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_AddForeignKeyTests : SQLiteTransformationProviderTestBase +{ + [TestCase(ForeignKeyConstraintType.Cascade, "CASCADE")] + [TestCase(ForeignKeyConstraintType.SetNull, "SET NULL")] + [TestCase(ForeignKeyConstraintType.SetDefault, "SET DEFAULT")] + [TestCase(ForeignKeyConstraintType.Restrict, "RESTRICT")] + [TestCase(ForeignKeyConstraintType.NoAction, "NO ACTION")] + public void AddForeignKey(ForeignKeyConstraintType constraint, string expectedAction) + { + // Arrange + AddTableWithPrimaryKey(); + Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); + Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); + + // Act + Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: constraint); + + // Assert + var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); + var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); + + Assert.That(foreignKeyConstraints.Single().Name, Is.EqualTo("FKName")); + Assert.That(foreignKeyConstraints.Single().ChildTable, Is.EqualTo("TestTwo")); + Assert.That(foreignKeyConstraints.Single().ParentTable, Is.EqualTo("Test")); + Assert.That(foreignKeyConstraints.Single().ChildColumns.Single(), Is.EqualTo("TestId")); + Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("Id")); + + Assert.That(foreignKeyConstraints.Single().OnDelete, Is.EqualTo(expectedAction)); + var expectedClause = constraint == ForeignKeyConstraintType.NoAction ? "" : $" ON DELETE {expectedAction}"; + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); + + // Reading and rebuilding an existing foreign key must retain its action. + Provider.RenameColumn("TestTwo", "TestId", "ParentId"); + Assert.That(Provider.GetForeignKeyConstraints("TestTwo").Single().OnDelete, Is.EqualTo(expectedAction)); + + var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); + Assert.That(result, Is.True); + } + + [Test] + public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn() + { + // Arrange + AddTableWithPrimaryKey(); + Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); + Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); + + // Act + Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: ForeignKeyConstraintType.Cascade); + + // Rename column in parent + Provider.RenameColumn("Test", "Id", "IdNew"); + + // Assert + var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); + var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); + + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); + Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("IdNew")); + + var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); + Assert.That(result, Is.True); + } + + [Test] + public void AddForeignKey_3_Success() + { + Provider.AddTable("Task", + new Column(name: "BinId", type: DbType.Int32, property: ColumnProperty.NotNull), + new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), + new Column(name: "EstimatedPickTime", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.NotNull), + new Column(name: "Item", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "Order", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) + ); + + Provider.AddTable("TaskGroup", + new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), + new Column(name: "Id", type: DbType.Int32) + ); + + // TODO CK add more columns. + Provider.AddForeignKey(name: "FK_Task_TaskGroup", childTable: "Task", childColumn: "TaskGroupId", parentTable: "TaskGroup", parentColumn: "Id"); + Assert.That(Provider.GetForeignKeyConstraints("Task").Single().OnDelete, Is.EqualTo("NO ACTION")); + Assert.That(((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Task"), Does.Not.Contain("ON DELETE")); + } + + [Test] + public void AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren() + { + // Enable enforcement before any transaction; SQLite ignores PRAGMA changes inside one. + using var connection = new System.Data.SQLite.SQLiteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + + provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + provider.AddTable("Child", new Column("ParentId", DbType.Int32)); + provider.ExecuteNonQuery("INSERT INTO Parent (Id) VALUES (1), (2)"); + provider.ExecuteNonQuery("INSERT INTO Child (ParentId) VALUES (1), (1), (2)"); + provider.AddForeignKey("FK_Child_Parent", "Child", "ParentId", "Parent", "Id", ForeignKeyConstraintType.Cascade); + + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.EqualTo(3)); + provider.ExecuteNonQuery("DELETE FROM Parent WHERE Id = 1"); + + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 1")), Is.Zero); + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 2")), Is.EqualTo(1)); + } +} diff --git a/src/Migrator/Framework/IForeignKeyActions.cs b/src/Migrator/Framework/IForeignKeyActions.cs new file mode 100644 index 00000000..f7b774e2 --- /dev/null +++ b/src/Migrator/Framework/IForeignKeyActions.cs @@ -0,0 +1,6 @@ +namespace DotNetProjects.Migrator.Framework; +public interface IForeignKeyActions +{ + void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate); +} diff --git a/src/Migrator/Framework/ProviderExtensions.cs b/src/Migrator/Framework/ProviderExtensions.cs new file mode 100644 index 00000000..7330ec94 --- /dev/null +++ b/src/Migrator/Framework/ProviderExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Data; +using System.IO; +using System.Reflection; +namespace DotNetProjects.Migrator.Framework; + +public static class ProviderExtensions +{ + public static T? ExecuteNullableScalar(this ITransformationProvider provider, string sql) where T : struct + { + var value = provider.ExecuteScalar(sql); + return value == null || value == DBNull.Value ? null : (T)Convert.ChangeType(value, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } + + public static int? GetNullableColumnContentSize(this ITransformationProvider provider, string table, string column) + { + // Preserve each dialect's length semantics, distinguishing empty/all-null input first. + var count = provider.ExecuteScalar($"SELECT COUNT({provider.QuoteColumnNameIfRequired(column)}) FROM {provider.QuoteTableNameIfRequired(table)}"); + return Convert.ToInt64(count) == 0 ? null : provider.GetColumnContentSize(table, column); + } + + public static void ExecuteResourceScript(this ITransformationProvider provider, Assembly assembly, string name) + { + using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("SQL resource not found", name); + using var reader = new StreamReader(stream); + provider.ExecuteNonQuery(reader.ReadToEnd()); + } +} diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 6a5a35ed..ec715549 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -148,7 +148,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "TRUE" : "FALSE", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException($"Given type in '{nameof(FilterItem)}' is not implemented. Please file an issue."), @@ -934,15 +934,7 @@ public override void RemoveTable(string name) private void GuardAgainstMaximumColumnNameLengthForOracle(string name, Column[] columns) { - foreach (var column in columns) - { - if (column.Name.Length > 30) - { - throw new ArgumentException( - string.Format("When adding table: \"{0}\", the column: \"{1}\", the name of the column is: {2} characters in length, but maximum length for an oracle identifier is 30 characters", name, - column.Name, column.Name.Length), "columns"); - } - } + foreach (var column in columns) GuardAgainstMaximumIdentifierLengthForOracle(column.Name); } public override string Encode(Guid guid) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 1f55e5c3..a6e2e0b1 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -102,7 +102,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "TRUE" : "FALSE", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException($"Given type in '{nameof(FilterItem)}' is not implemented. Please file an issue."), diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 381aeaba..7a99cba0 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -94,6 +94,20 @@ public override void AddForeignKey( RecreateTable(sqliteTableInfo); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + var info = GetSQLiteTableInfo(childTable) ?? throw new MigrationException("Child table does not exist."); + if (string.IsNullOrWhiteSpace(name) || info.ForeignKeys.Any(f => f.Name == name)) + throw new MigrationException("A unique foreign key name is required."); + info.ForeignKeys.Add(new ForeignKeyConstraint(name, parentTable, (string[])parentColumns.Clone(), childTable, (string[])childColumns.Clone()) + { + OnDelete = new ForeignKeyConstraintMapper().SqlForConstraint(onDelete), + OnUpdate = new ForeignKeyConstraintMapper().SqlForConstraint(onUpdate) + }); + RecreateTable(info); + } + public string[] GetColumnDefs(string table, out string compositeDefSql) { return ParseSqlColumnDefs(GetSqlCreateTableScript(table), out compositeDefSql); @@ -586,6 +600,13 @@ public override void RenameColumn(string tableName, string oldColumnName, string throw new Exception($"Table {tableName} does not exist"); } + if (Version.Parse(Convert.ToString(ExecuteScalar("SELECT sqlite_version()"))) >= new Version(3, 26, 0)) + { + if (string.IsNullOrWhiteSpace(newColumnName)) throw new ArgumentException("A column name is required."); + ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} RENAME COLUMN {Dialect.Quote(oldColumnName)} TO {Dialect.Quote(newColumnName)}"); + return; + } + var isPragmaForeignKeysOn = IsPragmaForeignKeysOn(); if (isPragmaForeignKeysOn) @@ -817,7 +838,57 @@ public void SetPragmaForeignKeys(bool isOn) ExecuteNonQuery($"PRAGMA foreign_keys = {onOffString}"); } + private static string ValidateForeignKeyAction(string action) + { + var normalized = action.ToUpperInvariant(); + if (normalized is not ("CASCADE" or "RESTRICT" or "SET NULL" or "SET DEFAULT" or "NO ACTION")) + throw new MigrationException("Unsupported foreign key action: " + action); + return normalized; + } + public void RecreateTable(SQLiteTableInfo sqliteTableInfo) + { + var oldName = sqliteTableInfo.TableNameMapping.OldName; + var script = GetSqlCreateTableScript(oldName); + if (Regex.IsMatch(script, @"\b(STRICT|GENERATED|DEFERRABLE|COLLATE)\b|WITHOUT\s+ROWID|CREATE\s+VIRTUAL|ON\s+CONFLICT", RegexOptions.IgnoreCase)) + throw new NotSupportedException("This table contains SQLite features that cannot be reconstructed faithfully. Use native SQL."); + var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND tbl_name='{0}'", oldName.Replace("'", "''")); + if (triggers.Count > 0 && (oldName != sqliteTableInfo.TableNameMapping.NewName || sqliteTableInfo.ColumnMappings.Any(m => m.OldName != null && m.OldName != m.NewName))) + throw new NotSupportedException("Use native SQLite rename when triggers reference renamed objects."); + var foreignKeys = IsPragmaForeignKeysOn(); + if (HasActiveTransaction && foreignKeys) + throw new MigrationException("SQLite rebuild requires foreign keys to be disabled before beginning the transaction. Use the migration runner."); + var ownsTransaction = !HasActiveTransaction; + Exception failure = null; + try + { + if (ownsTransaction) + { + if (foreignKeys) SetPragmaForeignKeys(false); + BeginTransaction(); + } + RecreateTableCore(sqliteTableInfo); + foreach (var trigger in triggers) ExecuteNonQuery(trigger); + if (ownsTransaction && !CheckForeignKeyIntegrity()) throw new MigrationException("SQLite rebuild would leave invalid foreign keys."); + if (ownsTransaction) Commit(); + } + catch (Exception ex) + { + failure = ex; + if (ownsTransaction) + { + try { Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } + } + throw; + } + finally + { + try { if (ownsTransaction && foreignKeys) SetPragmaForeignKeys(true); } + catch (Exception restore) { if (failure == null) throw; failure.Data["ConnectionRestoreException"] = restore; } + } + } + + private void RecreateTableCore(SQLiteTableInfo sqliteTableInfo) { var sourceTableQuoted = QuoteTableNameIfRequired(sqliteTableInfo.TableNameMapping.OldName); var targetIntermediateTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}{IntermediateTableSuffix}"); @@ -874,7 +945,7 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) using (var cmd = CreateCommand()) { var sql = $"INSERT INTO {targetIntermediateTableQuoted} ({targetColumnsQuotedString}) SELECT {sourceColumnsQuotedString} FROM {sourceTableQuoted}"; - ExecuteQuery(cmd, sql); + ExecuteNonQuery(sql); } RemoveTable(sourceTableQuoted); @@ -883,7 +954,7 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) { // Rename to original name var sql = $"ALTER TABLE {targetIntermediateTableQuoted} RENAME TO {targetTableQuoted}"; - ExecuteQuery(cmd, sql); + ExecuteNonQuery(sql); } foreach (var index in sqliteTableInfo.Indexes) @@ -1457,12 +1528,13 @@ public override void AddTable(string name, string engine, params IDbField[] fiel throw new Exception("No foreign key constraint name given"); } - var foreignKeySql = $"CONSTRAINT {fk.Name} FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}({parentColumnNamesQuotedString})"; + var foreignKeySql = $"CONSTRAINT {QuoteConstraintNameIfRequired(fk.Name)} FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}({parentColumnNamesQuotedString})"; if (!string.IsNullOrWhiteSpace(fk.OnDelete) && !string.Equals(fk.OnDelete, "NO ACTION", StringComparison.OrdinalIgnoreCase)) { - foreignKeySql += $" ON DELETE {fk.OnDelete}"; + foreignKeySql += $" ON DELETE {ValidateForeignKeyAction(fk.OnDelete)}"; } + if (!string.IsNullOrWhiteSpace(fk.OnUpdate)) foreignKeySql += $" ON UPDATE {ValidateForeignKeyAction(fk.OnUpdate)}"; foreignKeyStrings.Add(foreignKeySql); } @@ -1539,7 +1611,7 @@ public override string AddIndex(string table, Index index) value = filterItem.Value switch { bool booleanValue => booleanValue ? "1" : "0", - string stringValue => $"'{stringValue}'", + string stringValue => $"'{stringValue.Replace("'", "''")}'", byte or short or int or long => Convert.ToInt64(filterItem.Value).ToString(), sbyte or ushort or uint or ulong => Convert.ToUInt64(filterItem.Value).ToString(), _ => throw new NotImplementedException("Given type is not implemented. Please file an issue."), @@ -1587,8 +1659,8 @@ public override void RemoveAllConstraints(string table) column.ColumnProperty &= ~ColumnProperty.Unique; } - // TODO CHECK is not implemented yet - // https://github.com/dotnetprojects/Migrator.NET/issues/64 + sqliteTableInfo.ForeignKeys.Clear(); + sqliteTableInfo.CheckConstraints.Clear(); RecreateTable(sqliteTableInfo); } diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 871c2cb8..675935b9 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -279,6 +279,7 @@ public override void ChangeColumn(string table, Column column) { if (column.DefaultValue == null || column.DefaultValue == DBNull.Value) { + RemoveColumnDefaultValue(table, column.Name); base.ChangeColumn(table, column); } else @@ -546,28 +547,19 @@ public override Column[] GetColumns(string table) schema = _defaultSchema; } - var pkColumns = new List(); - try - { - pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu WHERE EXISTS ( SELECT tc.* FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc WHERE tc.TABLE_NAME = '{0}' AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.CONSTRAINT_NAME = cu.CONSTRAINT_NAME )", table); - } - catch (Exception) - { } - - var idtColumns = new List(); - try - { - idtColumns = ExecuteStringQuery("SELECT COLUMN_NAME from INFORMATION_SCHEMA.COLUMNS where TABLE_SCHEMA = '{1}' and TABLE_NAME = '{0}' and COLUMNPROPERTY(object_id(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1", table, schema); - } - catch (Exception) - { } + schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema.Trim('[', ']').Replace("''", "'"); + table = table.Trim('[', ']'); + var tableLiteral = table.Replace("'", "''"); + var schemaLiteral = schema.Replace("'", "''"); + var pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='PRIMARY KEY'", tableLiteral, schemaLiteral); + var idtColumns = ExecuteStringQuery("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='{1}' AND TABLE_NAME='{0}' AND COLUMNPROPERTY(OBJECT_ID(QUOTENAME(TABLE_SCHEMA)+'.'+QUOTENAME(TABLE_NAME)),COLUMN_NAME,'IsIdentity')=1", tableLiteral, schemaLiteral); var columns = new List(); using (var cmd = CreateCommand()) using ( var reader = ExecuteQuery(cmd, - string.Format("SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE, ISNULL(CHARACTER_MAXIMUM_LENGTH , NUMERIC_PRECISION), COLUMN_DEFAULT, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where table_name = '{0}'", table))) + string.Format("SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE, ISNULL(CHARACTER_MAXIMUM_LENGTH , NUMERIC_PRECISION), COLUMN_DEFAULT, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where table_name = '{0}' AND TABLE_SCHEMA = '{1}'", tableLiteral, schemaLiteral))) { while (reader.Read()) { diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 0d0fc72b..641101d9 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -34,7 +34,7 @@ namespace DotNetProjects.Migrator.Providers; /// Base class for every transformation providers. /// A 'tranformation' is an operation that modifies the database. /// -public abstract class TransformationProvider : ITransformationProvider, IMigrationHistory +public abstract class TransformationProvider : ITransformationProvider, IMigrationHistory, IForeignKeyActions { private string _scope; protected readonly string _connectionString; @@ -728,7 +728,7 @@ public virtual void AddPrimaryKey(string name, string table, params string[] col table = QuoteTableNameIfRequired(table); ExecuteNonQuery( - string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY ({2}) ", table, name, + string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY ({2}) ", table, QuoteConstraintNameIfRequired(name), string.Join(",", QuoteColumnNamesIfRequired(columns)))); } public virtual void AddPrimaryKeyNonClustered(string name, string table, params string[] columns) @@ -739,7 +739,7 @@ public virtual void AddUniqueConstraint(string name, string table, params string { table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} UNIQUE({2}) ", table, name, + ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} UNIQUE({2}) ", table, QuoteConstraintNameIfRequired(name), string.Join(", ", QuoteColumnNamesIfRequired(columns)))); } @@ -747,7 +747,7 @@ public virtual void AddCheckConstraint(string name, string table, string checkSq { table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} CHECK ({2}) ", table, name, checkSql)); + ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} CHECK ({2}) ", table, QuoteConstraintNameIfRequired(name), checkSql)); } /// @@ -835,8 +835,9 @@ public virtual void AddForeignKey( { childTable = QuoteTableNameIfRequired(childTable); parentTable = QuoteTableNameIfRequired(parentTable); - QuoteColumnNames(parentColumns); - QuoteColumnNames(childColumns); + parentColumns = QuoteColumnNamesIfRequired(parentColumns); + childColumns = QuoteColumnNamesIfRequired(childColumns); + name = QuoteConstraintNameIfRequired(name); var constraintResolved = constraintMapper.SqlForConstraint(constraint); @@ -944,23 +945,18 @@ public List ExecuteStringQuery(string sql, params object[] args) public virtual void ExecuteScript(string fileName) { - if (CurrentMigration != null) - { -#if NETSTANDARD - var assembly = CurrentMigration.GetType().GetTypeInfo().Assembly; -#else - var assembly = CurrentMigration.GetType().Assembly; -#endif - - string sqlText; - var file = (new System.Uri(assembly.CodeBase)).AbsolutePath; - using (var reader = File.OpenText(file)) - { - sqlText = reader.ReadToEnd(); - } + if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("A script path is required.", nameof(fileName)); + var root = CurrentMigration == null ? AppContext.BaseDirectory : Path.GetDirectoryName(CurrentMigration.GetType().Assembly.Location); + var path = Path.IsPathRooted(fileName) ? fileName : Path.Combine(root ?? AppContext.BaseDirectory, fileName); + ExecuteNonQuery(File.ReadAllText(path)); + } - ExecuteNonQuery(sqlText); - } + public virtual void ExecuteResourceScript(System.Reflection.Assembly assembly, string resourceName) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException("Embedded SQL resource not found.", resourceName); + using var reader = new StreamReader(stream); + ExecuteNonQuery(reader.ReadToEnd()); } /// @@ -1115,7 +1111,7 @@ public virtual object SelectScalar(string what, string from, string where) public virtual object SelectScalar(string what, string from, string[] whereColumns, object[] whereValues) { - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1189,7 +1185,7 @@ public virtual int Update(string table, string[] columns, object[] values, strin builder.Append(GenerateParameterName(i)); } - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1267,7 +1263,7 @@ public virtual int Update(string table, string[] columns, object[] values, strin builder.Append(GenerateParameterName(i)); } - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1363,7 +1359,7 @@ public virtual int Insert(string table, string[] columns, object[] values) var parameterNames = builder.ToString(); - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1504,7 +1500,7 @@ public virtual int Delete(string table, string[] whereColumns = null, object[] w { table = QuoteTableNameIfRequired(table); - using var command = _connection.CreateCommand(); + using var command = CreateCommand(); if (CommandTimeout.HasValue) { command.CommandTimeout = CommandTimeout.Value; @@ -1553,6 +1549,20 @@ public virtual int TruncateTable(string table) return ExecuteNonQuery(string.Format("TRUNCATE TABLE {0} ", table)); } + public virtual void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + var deleteAction = constraintMapper.SqlForConstraint(onDelete); + var updateAction = constraintMapper.SqlForConstraint(onUpdate); + var oracle = _dialect is DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect; + if (oracle && onUpdate != ForeignKeyConstraintType.NoAction) + throw new NotSupportedException("Oracle does not support ON UPDATE foreign key actions."); + var sql = $"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"; + if (!oracle) sql += $" ON UPDATE {updateAction}"; + if (!oracle || onDelete != ForeignKeyConstraintType.NoAction) sql += $" ON DELETE {deleteAction}"; + ExecuteNonQuery(sql); + } + /// /// Starts a transaction. Called by the migration mediator. /// From d54ab5f7463df30a7c9910f399c92d16006ec3d5 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 14:47:08 +0200 Subject: [PATCH 02/19] Fix reviewed provider default and constraint edge cases Remove Oracle defaults explicitly when changing a column with no requested default, and treat SQL Server DBNull constraint lookups as absent. Preserve SQLite triggers across case-insensitive table lookup and reject unique/FK name collisions consistently. Route independent FK actions through Db2, Informix, and Sybase overrides with explicit update-action validation. Validation: rebuilt solution; SQLite suite 146 passed including trigger casing and constraint collision regressions. Unit suite run separately. Live Oracle and server coverage remains required in PR CI. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 12 +++++++++++- .../Providers/Impl/DB2/DB2TransformationProvider.cs | 8 ++++++++ .../Impl/Informix/InformixTransformationProvider.cs | 8 ++++++++ .../Impl/Oracle/OracleTransformationProvider.cs | 1 + .../Impl/SQLite/SQLiteTransformationProvider.cs | 5 +++-- .../SqlServer/SqlServerTransformationProvider.cs | 2 +- .../Impl/Sybase/SybaseTransformationProvider.cs | 8 ++++++++ 7 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index f96f4e5e..b31f682e 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -18,12 +18,22 @@ [Test] public void RebuildPreservesTriggerAndUpdateAction() provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER, ParentId INTEGER); CREATE TABLE Audit (Id INTEGER)"); provider.AddForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade); provider.ExecuteNonQuery("CREATE TRIGGER ChildAudit AFTER INSERT ON Child BEGIN INSERT INTO Audit VALUES (NEW.Id); END"); - provider.AddColumn("Child", new Column("Name", DbType.String)); + provider.AddColumn("cHiLd", new Column("Name", DbType.String)); provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child (Id, ParentId) VALUES (2, 1); UPDATE Parent SET Id=3 WHERE Id=1"); Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT ParentId FROM Child")), Is.EqualTo(3)); Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Audit")), Is.EqualTo(2)); Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); } + [TestCase("ExistingForeignKey")] + [TestCase("ExistingUnique")] + public void IndependentActionsRejectConstraintNameCollisions(string existingName) + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER, ParentId INTEGER, CONSTRAINT ExistingUnique UNIQUE(Id), CONSTRAINT ExistingForeignKey FOREIGN KEY(ParentId) REFERENCES Parent(Id))"); + Assert.Throws(() => provider.AddForeignKey(existingName.ToLowerInvariant(), "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade)); + Assert.That(provider.GetForeignKeyConstraints("Child").Length, Is.EqualTo(1)); + } [Test] public void UnsupportedRebuildLeavesTableIntact() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 1fb5ef08..11fed953 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -154,6 +154,14 @@ private void Reorganize(string table) ExecuteNonQuery($"CALL SYSPROC.ADMIN_CMD('REORG TABLE {schema}.{Identifier(table).Replace("'", "''")}')"); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("DB2 does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { // Db2 supports only NO ACTION/RESTRICT for ON UPDATE. diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 2711e38f..c213ef9c 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -210,6 +210,14 @@ public override void AddUniqueConstraint(string name, string table, params strin public override void AddCheckConstraint(string name, string table, string checkSql) => ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {name}"); + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("Informix does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { var action = constraint switch diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index ec715549..7bb6bb6e 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -202,6 +202,7 @@ protected override string GetPrimaryKeyname(string tableName) public override void ChangeColumn(string table, Column column) { var existingColumn = GetColumnByName(table, column.Name); + if (column.DefaultValue == null) RemoveColumnDefaultValue(table, column.Name); if (column.Type == DbType.String) { diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 7a99cba0..674a6130 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -98,7 +98,8 @@ public override void AddForeignKey(string name, string childTable, string[] chil ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) { var info = GetSQLiteTableInfo(childTable) ?? throw new MigrationException("Child table does not exist."); - if (string.IsNullOrWhiteSpace(name) || info.ForeignKeys.Any(f => f.Name == name)) + if (string.IsNullOrWhiteSpace(name) || info.ForeignKeys.Select(f => f.Name).Concat(info.Uniques.Select(u => u.Name)) + .Any(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))) throw new MigrationException("A unique foreign key name is required."); info.ForeignKeys.Add(new ForeignKeyConstraint(name, parentTable, (string[])parentColumns.Clone(), childTable, (string[])childColumns.Clone()) { @@ -852,7 +853,7 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) var script = GetSqlCreateTableScript(oldName); if (Regex.IsMatch(script, @"\b(STRICT|GENERATED|DEFERRABLE|COLLATE)\b|WITHOUT\s+ROWID|CREATE\s+VIRTUAL|ON\s+CONFLICT", RegexOptions.IgnoreCase)) throw new NotSupportedException("This table contains SQLite features that cannot be reconstructed faithfully. Use native SQL."); - var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND tbl_name='{0}'", oldName.Replace("'", "''")); + var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND lower(tbl_name)=lower('{0}')", oldName.Replace("'", "''")); if (triggers.Count > 0 && (oldName != sqliteTableInfo.TableNameMapping.NewName || sqliteTableInfo.ColumnMappings.Any(m => m.OldName != null && m.OldName != m.NewName))) throw new NotSupportedException("Use native SQLite rename when triggers reference renamed objects."); var foreignKeys = IsPragmaForeignKeysOn(); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 675935b9..f0ab1739 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -334,7 +334,7 @@ public override void RemoveColumnDefaultValue(string table, string column) { var sql = string.Format("SELECT name FROM sys.default_constraints WHERE parent_object_id = OBJECT_ID('{0}') AND parent_column_id = (SELECT column_id FROM sys.columns WHERE name = '{1}' AND object_id = OBJECT_ID('{0}'))", table, column); var constraintName = ExecuteScalar(sql); - if (constraintName != null) + if (constraintName != null && constraintName != DBNull.Value && !string.IsNullOrWhiteSpace(constraintName.ToString())) { RemoveConstraint(table, constraintName.ToString()); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 71f82029..21b28fb6 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -166,6 +166,14 @@ public override void ChangeColumn(string table, Column column) AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, + ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) + { + if (onUpdate is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("Sybase does not support the requested ON UPDATE action."); + AddForeignKey(name, childTable, childColumns, parentTable, parentColumns, onDelete); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { if (constraint is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) From d84ba78b90982966e0da877d7e5219423aa29fe2 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 14:53:10 +0200 Subject: [PATCH 03/19] Limit Oracle default removal to in-place column changes Oracle string changes already rebuild the column without its prior default. Setting DEFAULT NULL before that rebuild exposed the legacy default parser on the temporary column and failed three live Oracle tests. Keep explicit default removal for non-string in-place modifications, which retain defaults otherwise. Validation evidence: PR 174 Oracle CI run 35729312158 identified all three failing string-change tests; the newly enabled default-removal test itself passed. New live Oracle CI must verify this correction. Informix failure in that run was an image registry timeout, not a test failure. --- .../Providers/Impl/Oracle/OracleTransformationProvider.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 7bb6bb6e..9689a18f 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -202,7 +202,6 @@ protected override string GetPrimaryKeyname(string tableName) public override void ChangeColumn(string table, Column column) { var existingColumn = GetColumnByName(table, column.Name); - if (column.DefaultValue == null) RemoveColumnDefaultValue(table, column.Name); if (column.Type == DbType.String) { @@ -230,6 +229,9 @@ public override void ChangeColumn(string table, Column column) } else { + // String changes replace the column, which already removes its default. + // For in-place changes Oracle otherwise retains the existing default. + if (column.DefaultValue == null) RemoveColumnDefaultValue(table, column.Name); if (((existingColumn.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull) && ((column.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull)) { From 669fb5080b78452ef6feeb9d08ddd5207feb3d29 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:17:53 +0200 Subject: [PATCH 04/19] Preserve numeric precision and FK direction in definition overloads Carry precision/scale from Column through dialect mapping instead of dropping them in the legacy scalar overload. Retain existing dispatch for definitions without precision. Correct ForeignKeyConstraint helper direction and preserve independently specified actions, rejecting unknown action strings and copying caller arrays. Validation: rebuilt solution; Unit 72 passed and SQLite 148 passed including decimal data round trips. Added mapping/direction unit regressions and a shared provider test for persisted fractional precision; live provider results remain required. Addresses PR 175 precision review and related normal-API definition defects. --- src/Migrator.Tests/ProviderDefinitionTests.cs | 46 +++++++++++++++++++ .../Generic/Generic_ChangeColumnTestsBase.cs | 12 +++++ .../Providers/TransformationProvider.cs | 21 ++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/Migrator.Tests/ProviderDefinitionTests.cs diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs new file mode 100644 index 00000000..292b66ab --- /dev/null +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Data; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using NUnit.Framework; +namespace Migrator.Tests; +public class ProviderDefinitionTests +{ + private sealed class RecordingProvider() : TransformationProvider(new SqlServerDialect(), (IDbConnection)null, null, "default") + { + public string ColumnSql; + public (string Child, string[] ChildColumns, string Parent, string[] ParentColumns, ForeignKeyConstraintType Delete, ForeignKeyConstraintType Update) ForeignKey; + public override void AddColumn(string table, string sqlColumn) => ColumnSql = sqlColumn; + public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType delete, ForeignKeyConstraintType update) + => ForeignKey = (child, columns, parent, parentColumns, delete, update); + public override List GetDatabases() => new(); + public override bool ConstraintExists(string table, string name) => false; + public override bool IndexExists(string table, string name) => false; + } + [Test] public void AddColumnCarriesPrecisionAndScaleIntoDialectMapping() + { + using var provider = new RecordingProvider(); + var column = new Column("Amount", DbType.Decimal) { Precision = 12, Scale = 4 }; + provider.AddColumn("Example", column); + Assert.That(provider.ColumnSql.Replace(" ", "").ToUpperInvariant(), Does.Contain("DECIMAL(12,4)")); + Assert.That(column.Precision, Is.EqualTo(12)); + Assert.That(column.Scale, Is.EqualTo(4)); + } + [Test] public void ForeignKeyDefinitionRetainsDirectionAndIndependentActions() + { + using var provider = new RecordingProvider(); + var fk = new ForeignKeyConstraint("fk", "Parent", new[] { "ParentId" }, "Child", new[] { "ParentReference" }) { OnDelete = "SET NULL", OnUpdate = "CASCADE" }; + provider.AddForeignKey("Child", fk); + Assert.That(provider.ForeignKey.Child, Is.EqualTo("Child")); + Assert.That(provider.ForeignKey.ChildColumns, Is.EqualTo(fk.ChildColumns)); + Assert.That(provider.ForeignKey.Parent, Is.EqualTo("Parent")); + Assert.That(provider.ForeignKey.ParentColumns, Is.EqualTo(fk.ParentColumns)); + Assert.That(provider.ForeignKey.Delete, Is.EqualTo(ForeignKeyConstraintType.SetNull)); + Assert.That(provider.ForeignKey.Update, Is.EqualTo(ForeignKeyConstraintType.Cascade)); + Assert.That(provider.ForeignKey.ChildColumns, Is.Not.SameAs(fk.ChildColumns)); + } +} + diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs index 8f5b667e..a1902a26 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs @@ -10,6 +10,18 @@ namespace Migrator.Tests.Providers.Generic; public abstract class Generic_ChangeColumnTestsBase : TransformationProviderBase { + [Test] + public void AddColumn_PrecisionAndScale_PersistFractionalValue() + { + Provider.AddTable("PrecisionRoundTrip", new Column("Id", DbType.Int32)); + Provider.AddColumn("PrecisionRoundTrip", new Column("Amount", DbType.Decimal) { Precision = 12, Scale = 4 }); + Provider.Insert("PrecisionRoundTrip", new[] { "Id", "Amount" }, new object[] { 1, 12.3456m }); + using var command = Provider.CreateCommand(); + using var reader = Provider.Select(command, "PrecisionRoundTrip", new[] { "Amount" }); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetDecimal(0), Is.EqualTo(12.3456m)); + } + [Test] public void ChangeColumn_NotNullAndNullToNotNull_Success() { diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 641101d9..788a4f28 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -800,7 +800,17 @@ public virtual void GenerateForeignKey( public virtual void AddForeignKey(string table, ForeignKeyConstraint fk) { - AddForeignKey(fk.Name, table, fk.ParentColumns, fk.ChildTable, fk.ChildColumns); + if (string.IsNullOrWhiteSpace(fk.OnDelete) && string.IsNullOrWhiteSpace(fk.OnUpdate)) + AddForeignKey(fk.Name, table, (string[])fk.ChildColumns.Clone(), fk.ParentTable, (string[])fk.ParentColumns.Clone()); + else + AddForeignKey(fk.Name, table, (string[])fk.ChildColumns.Clone(), fk.ParentTable, (string[])fk.ParentColumns.Clone(), ParseAction(fk.OnDelete), ParseAction(fk.OnUpdate)); + + static ForeignKeyConstraintType ParseAction(string action) + { + if (string.IsNullOrWhiteSpace(action)) return ForeignKeyConstraintType.NoAction; + return Enum.TryParse(action.Replace(" ", ""), true, out var parsed) && Enum.IsDefined(parsed) + ? parsed : throw new ArgumentException("Unsupported foreign-key action.", nameof(fk)); + } } public virtual void AddForeignKey(string name, string childTable, string childColumn, string parentTable, string parentColumn) @@ -1668,7 +1678,14 @@ public virtual void MigrationUnApplied(long version, string scope) public virtual void AddColumn(string table, Column column) { - AddColumn(table, column.Name, column.Type, column.Size, column.ColumnProperty, column.DefaultValue); + if (!column.Precision.HasValue && !column.Scale.HasValue) + { + AddColumn(table, column.Name, column.Type, column.Size, column.ColumnProperty, column.DefaultValue); + return; + } + var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) + { Precision = column.Precision, Scale = column.Scale }; + AddColumn(table, _dialect.GetAndMapColumnProperties(definition).ColumnSql); } public virtual void GenerateForeignKey(string primaryTable, string refTable) From 2f94516b2652efce936bb33cf8ef258a5d06a275 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:22:10 +0200 Subject: [PATCH 05/19] Track SQL Server column uniqueness with persistent ownership metadata Mark unique constraints created by ChangeColumn with an extended property and remove only those explicitly owned objects on subsequent changes. User-created constraints, including legacy-looking names, remain intact. Clone incoming column definitions, preserve precision and replace defaults without mutating caller objects. Validation: rebuilt solution and Unit 72 passed. Enabled the previously ignored issue 132 regression and added a live SQL Server preservation/non-mutation test. Live SQL Server CI must pass before this ownership change is considered verified; unmarked historical constraints are intentionally not inferred by name. --- ...ransformationProvider_ChangeColumnTests.cs | 14 ++++- .../SqlServerTransformationProvider.cs | 51 +++++++++++-------- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs index e31ceaff..a3469ef4 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs @@ -36,7 +36,19 @@ public void ChangeColumn_DateTimeToDateTime2_Success() Assert.That(columnAfter.Type == DbType.DateTime2); } - [Test, Ignore("This issue is not yet fixed. See https://github.com/dotnetprojects/Migrator.NET/issues/132")] + [Test] + public void ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition() + { + Provider.AddTable("UserOwned", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + Provider.AddUniqueConstraint("UX_UserOwned_Value", "UserOwned", "Value"); + var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull, 3); + Provider.ChangeColumn("UserOwned", definition); + Assert.That(Provider.ConstraintExists("UserOwned", "UX_UserOwned_Value"), Is.True); + Assert.That(definition.DefaultValue, Is.EqualTo(3)); + Assert.That(definition.ColumnProperty, Is.EqualTo(ColumnProperty.NotNull)); + } + + [Test] public void ChangeColumn_WithUniqueThenReChangeToNonUnique_UniqueConstraintShouldBeRemoved() { // Arrange diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index f0ab1739..9c6e99c3 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -277,33 +277,42 @@ public override string AddIndex(string table, Index index) public override void ChangeColumn(string table, Column column) { - if (column.DefaultValue == null || column.DefaultValue == DBNull.Value) + var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) + { Precision = column.Precision, Scale = column.Scale }; + var unique = definition.ColumnProperty.IsSet(ColumnProperty.Unique); + definition.ColumnProperty = definition.ColumnProperty.Clear(ColumnProperty.Unique); + var owned = new List(); + using (var command = CreateCommand()) { - RemoveColumnDefaultValue(table, column.Name); - base.ChangeColumn(table, column); + command.CommandText = "SELECT kc.name FROM sys.key_constraints kc JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND ep.name=N'Migrator.NET.ColumnUnique' AND CONVERT(nvarchar(128),ep.value)=@column"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column.Name); + using var reader = command.ExecuteReader(); + while (reader.Read()) owned.Add(reader.GetString(0)); } - else + foreach (var constraint in owned) RemoveConstraint(table, constraint); + RemoveColumnDefaultValue(table, definition.Name); + var requestedDefault = definition.DefaultValue; + definition.DefaultValue = null; + base.ChangeColumn(table, definition); + if (requestedDefault != null && requestedDefault != DBNull.Value) + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD DEFAULT {_dialect.Default(requestedDefault)[8..]} FOR {QuoteColumnNameIfRequired(column.Name)}"); + if (unique) { - var def = column.DefaultValue; - var notNull = column.ColumnProperty.IsSet(ColumnProperty.NotNull); - column.DefaultValue = null; - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Null); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.NotNull); - - base.ChangeColumn(table, column); - - var mapper = _dialect.GetAndMapColumnPropertiesWithoutDefault(column); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} {2} FOR {3}", this.QuoteTableNameIfRequired(table), "DF_" + table + "_" + column.Name, _dialect.Default(def), this.QuoteColumnNameIfRequired(column.Name))); - - if (notNull) - { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.NotNull); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Null); - base.ChangeColumn(table, column); - } + var name = "UX_" + Guid.NewGuid().ToString("N"); + AddUniqueConstraint(name, table, column.Name); + using var command = CreateCommand(); + command.CommandText = "DECLARE @schema sysname=OBJECT_SCHEMA_NAME(OBJECT_ID(@table)); DECLARE @name sysname=OBJECT_NAME(OBJECT_ID(@table)); EXEC sys.sp_addextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column.Name); AddParameter(command, "@constraint", name); + command.ExecuteNonQuery(); } } + private static void AddParameter(IDbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.Value = value; + command.Parameters.Add(parameter); + } + public override bool ColumnExists(string table, string column) { string schema; From 6881caec547bcb98ef0c317eb4a876c85165a38b Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:24:24 +0200 Subject: [PATCH 06/19] Prefer native SQLite drop-column when dependencies allow it Use native DROP COLUMN on SQLite 3.35+ for columns without represented key/index/check dependencies. Preserve the reconstruction path for legacy/dependent cases. Let SQLite reject trigger/view dependencies atomically and propagate the error instead of retrying a lossy rebuild. Validation: rebuilt solution; SQLite 149 passed. New Microsoft.Data.Sqlite regressions verify unrelated trigger preservation with foreign keys enabled and dependent-trigger rejection without data loss. Existing System.Data.SQLite removal regressions also pass. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 19 +++++++++++++++++ .../SQLite/SQLiteTransformationProvider.cs | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index b31f682e..3470ebb0 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -34,6 +34,25 @@ public void IndependentActionsRejectConstraintNameCollisions(string existingName Assert.Throws(() => provider.AddForeignKey(existingName.ToLowerInvariant(), "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Cascade)); Assert.That(provider.GetForeignKeyConstraints("Child").Length, Is.EqualTo(1)); } + [Test] public void NativeDropPreservesTriggerAndForeignKeySetting() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY, Obsolete TEXT); CREATE TABLE Audit (Id INTEGER); CREATE TRIGGER OriginalAudit AFTER INSERT ON Original BEGIN INSERT INTO Audit VALUES (NEW.Id); END"); + provider.RemoveColumn("Original", "Obsolete"); + provider.ExecuteNonQuery("INSERT INTO Original VALUES (7)"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Audit")), Is.EqualTo(7)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + [Test] public void NativeDropRejectsDependentTriggerWithoutLosingData() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER, Obsolete TEXT); CREATE TABLE Audit (Value TEXT); CREATE TRIGGER OriginalAudit AFTER INSERT ON Original BEGIN INSERT INTO Audit VALUES (NEW.Obsolete); END; INSERT INTO Original VALUES (1, 'keep')"); + var error = Assert.Throws(() => provider.RemoveColumn("Original", "Obsolete")); + Assert.That(error.InnerException, Is.TypeOf()); + Assert.That(provider.ExecuteScalar("SELECT Obsolete FROM Original"), Is.EqualTo("keep")); + } [Test] public void UnsupportedRebuildLeavesTableIntact() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 674a6130..af261168 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -459,6 +459,26 @@ public void MoveIndexesFromOriginalTable(string origTable, string newTable) public override void RemoveColumn(string tableName, string column) { + if (Version.Parse(Convert.ToString(ExecuteScalar("SELECT sqlite_version()"))) >= new Version(3, 35, 0) + && TableExists(tableName)) + { + var info = GetSQLiteTableInfo(tableName); + var definition = info.Columns.SingleOrDefault(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); + bool Matches(string name) => string.Equals(name, column, StringComparison.OrdinalIgnoreCase); + var dependent = definition == null || definition.IsPrimaryKey || definition.ColumnProperty.HasFlag(ColumnProperty.Unique) + || info.CheckConstraints.Count != 0 + || info.Uniques.Any(u => u.KeyColumns.Any(Matches)) + || info.Indexes.Any(i => i.KeyColumns.Any(Matches) || i.FilterItems.Count != 0) + || info.ForeignKeys.Any(f => f.ChildColumns.Any(Matches)) + || GetTables().Any(t => GetForeignKeyConstraints(t).Any(f => f.ParentTable.Equals(tableName, StringComparison.OrdinalIgnoreCase) && f.ParentColumns.Any(Matches))); + if (!dependent) + { + // SQLite itself validates trigger/view dependencies atomically. A rejection is + // surfaced rather than retrying with a potentially lossy reconstruction. + ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} DROP COLUMN {Dialect.Quote(definition.Name)}"); + return; + } + } // In SQLite we need to recreate the table even if we only want to add, alter or drop a foreign key. So we not only recreate the table given // as parameter but also the tables with FKs pointing to the column you want to remove. // In order to perform it smoothly, the PRAGMA foreign keys should be set off. @@ -2009,3 +2029,4 @@ protected override void ConfigureParameterWithValue(IDbDataParameter parameter, } } } + From 5ad9f034ea15e7502a112a2d6cd2851232c96e61 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:26:10 +0200 Subject: [PATCH 07/19] Preserve SQLite autoincrement state during reconstruction Capture sqlite_sequence high-water state before rebuilding and restore it for retained identity columns, including empty tables after deletion. Reject fallback column removal when trigger definitions would need rewriting, before mutating the original table. Validation: rebuilt solution; SQLite 150 passed. Regression inserts/deletes ID 100, rebuilds the empty table and verifies the next generated ID is 101. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 9 +++++++++ .../Impl/SQLite/SQLiteTransformationProvider.cs | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index 3470ebb0..2fa2959e 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -53,6 +53,15 @@ [Test] public void NativeDropRejectsDependentTriggerWithoutLosingData() Assert.That(error.InnerException, Is.TypeOf()); Assert.That(provider.ExecuteScalar("SELECT Obsolete FROM Original"), Is.EqualTo("keep")); } + [Test] public void RebuildPreservesAutoincrementHighWaterAfterRowsWereDeleted() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT); INSERT INTO Original(Id) VALUES(100); DELETE FROM Original"); + provider.AddColumn("Original", new Column("Extra", DbType.String)); + provider.ExecuteNonQuery("INSERT INTO Original(Name) VALUES ('next')"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM Original")), Is.EqualTo(101)); + } [Test] public void UnsupportedRebuildLeavesTableIntact() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index af261168..1286fefa 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -876,6 +876,12 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND lower(tbl_name)=lower('{0}')", oldName.Replace("'", "''")); if (triggers.Count > 0 && (oldName != sqliteTableInfo.TableNameMapping.NewName || sqliteTableInfo.ColumnMappings.Any(m => m.OldName != null && m.OldName != m.NewName))) throw new NotSupportedException("Use native SQLite rename when triggers reference renamed objects."); + var originalColumns = GetColumns(oldName); + if (triggers.Count > 0 && originalColumns.Any(c => !sqliteTableInfo.Columns.Any(n => n.Name.Equals(c.Name, StringComparison.OrdinalIgnoreCase)))) + throw new NotSupportedException("Removing columns from a table with triggers requires native SQLite alteration or explicit trigger recreation."); + var sequence = TableExists("sqlite_sequence") + ? ExecuteScalar("SELECT seq FROM sqlite_sequence WHERE name='" + oldName.Replace("'", "''") + "'") : null; + var highWater = sequence == null || sequence == DBNull.Value ? (long?)null : Convert.ToInt64(sequence); var foreignKeys = IsPragmaForeignKeysOn(); if (HasActiveTransaction && foreignKeys) throw new MigrationException("SQLite rebuild requires foreign keys to be disabled before beginning the transaction. Use the migration runner."); @@ -889,6 +895,13 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) BeginTransaction(); } RecreateTableCore(sqliteTableInfo); + if (highWater.HasValue && sqliteTableInfo.Columns.Any(c => c.IsIdentity)) + { + var sequenceName = sqliteTableInfo.TableNameMapping.NewName.Replace("'", "''"); + var sequenceValue = highWater.Value.ToString(CultureInfo.InvariantCulture); + ExecuteNonQuery($"UPDATE sqlite_sequence SET seq=MAX(seq, {sequenceValue}) WHERE name='{sequenceName}'"); + ExecuteNonQuery($"INSERT INTO sqlite_sequence(name, seq) SELECT '{sequenceName}', {sequenceValue} WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name='{sequenceName}')"); + } foreach (var trigger in triggers) ExecuteNonQuery(trigger); if (ownsTransaction && !CheckForeignKeyIntegrity()) throw new MigrationException("SQLite rebuild would leave invalid foreign keys."); if (ownsTransaction) Commit(); From 6e14a477f13ebb13671529adf041050ca248d3ac Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:45:41 +0200 Subject: [PATCH 08/19] Retain typed scalar results and order foreign key actions for SQL Server Return values already assignable to nullable scalar T before using Convert.ChangeType, covering Guid and other non-IConvertible structs. Emit ON DELETE before ON UPDATE in both generic foreign key paths. Add a live SQL Server regression that verifies cascade update and set-null delete independently. Validation: build, Unit 73 passed and SQLite 150 passed. SQL Server behavior is delegated to the PR live database matrix before its review threads are resolved. --- src/Migrator.Tests/ProviderDefinitionTests.cs | 12 ++++++++++++ .../SqlServerTransformationProviderTests.cs | 16 ++++++++++++++++ src/Migrator/Framework/ProviderExtensions.cs | 4 +++- src/Migrator/Providers/TransformationProvider.cs | 4 ++-- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs index 292b66ab..84efd79b 100644 --- a/src/Migrator.Tests/ProviderDefinitionTests.cs +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -6,6 +6,7 @@ using DotNetProjects.Migrator.Providers; using DotNetProjects.Migrator.Providers.Impl.SqlServer; using NUnit.Framework; +using NSubstitute; namespace Migrator.Tests; public class ProviderDefinitionTests { @@ -20,6 +21,17 @@ public override void AddForeignKey(string name, string child, string[] columns, public override bool ConstraintExists(string table, string name) => false; public override bool IndexExists(string table, string name) => false; } + [Test] public void NullableScalarRetainsTypedValuesAndHandlesNulls() + { + var provider = Substitute.For(); + var id = Guid.NewGuid(); + provider.ExecuteScalar("guid").Returns(id); + provider.ExecuteScalar("null").Returns(DBNull.Value); + provider.ExecuteScalar("number").Returns(12L); + Assert.That(provider.ExecuteNullableScalar("guid"), Is.EqualTo(id)); + Assert.That(provider.ExecuteNullableScalar("null"), Is.Null); + Assert.That(provider.ExecuteNullableScalar("number"), Is.EqualTo(12)); + } [Test] public void AddColumnCarriesPrecisionAndScaleIntoDialectMapping() { using var provider = new RecordingProvider(); diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 9c0d0641..a3388aff 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -1,4 +1,6 @@ using System.Data; +using System; +using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers; using DotNetProjects.Migrator.Providers.Impl.SqlServer; using Migrator.Tests.Providers.SQLServer.Base; @@ -10,6 +12,20 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void IndependentForeignKeyActionsCascadeUpdateAndSetNullOnDelete() + { + Provider.AddTable("ActionParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + Provider.AddTable("ActionChild", new Column("ParentId", DbType.Int32, ColumnProperty.Null)); + ((IForeignKeyActions)Provider).AddForeignKey("ActionForeignKey", "ActionChild", new[] { "ParentId" }, + "ActionParent", new[] { "Id" }, ForeignKeyConstraintType.SetNull, ForeignKeyConstraintType.Cascade); + Provider.ExecuteNonQuery("INSERT INTO ActionParent VALUES (1); INSERT INTO ActionChild VALUES (1); UPDATE ActionParent SET Id=2 WHERE Id=1"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT ParentId FROM ActionChild")), Is.EqualTo(2)); + Provider.ExecuteNonQuery("DELETE FROM ActionParent WHERE Id=2"); + Assert.That(Provider.ExecuteNullableScalar("SELECT ParentId FROM ActionChild"), Is.Null); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM ActionChild")), Is.EqualTo(1)); + } + [Test] public void ByteColumnWillBeCreatedAsBlob() { diff --git a/src/Migrator/Framework/ProviderExtensions.cs b/src/Migrator/Framework/ProviderExtensions.cs index 7330ec94..6618a550 100644 --- a/src/Migrator/Framework/ProviderExtensions.cs +++ b/src/Migrator/Framework/ProviderExtensions.cs @@ -9,7 +9,9 @@ public static class ProviderExtensions public static T? ExecuteNullableScalar(this ITransformationProvider provider, string sql) where T : struct { var value = provider.ExecuteScalar(sql); - return value == null || value == DBNull.Value ? null : (T)Convert.ChangeType(value, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + if (value == null || value == DBNull.Value) return null; + if (value is T typed) return typed; + return (T)Convert.ChangeType(value, typeof(T), System.Globalization.CultureInfo.InvariantCulture); } public static int? GetNullableColumnContentSize(this ITransformationProvider provider, string table, string column) diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 788a4f28..7f56dd42 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -857,7 +857,7 @@ public virtual void AddForeignKey( var stringBuilder = new StringBuilder(); stringBuilder.Append($"ALTER TABLE {childTable} ADD CONSTRAINT {name} FOREIGN KEY ({childColumnsString}) REFERENCES {parentTable} ({parentColumnsString})"); - stringBuilder.Append($"ON UPDATE {constraintResolved} ON DELETE {constraintResolved}"); + stringBuilder.Append($"ON DELETE {constraintResolved} ON UPDATE {constraintResolved}"); ExecuteNonQuery(stringBuilder.ToString()); } @@ -1568,8 +1568,8 @@ public virtual void AddForeignKey(string name, string childTable, string[] child if (oracle && onUpdate != ForeignKeyConstraintType.NoAction) throw new NotSupportedException("Oracle does not support ON UPDATE foreign key actions."); var sql = $"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"; - if (!oracle) sql += $" ON UPDATE {updateAction}"; if (!oracle || onDelete != ForeignKeyConstraintType.NoAction) sql += $" ON DELETE {deleteAction}"; + if (!oracle) sql += $" ON UPDATE {updateAction}"; ExecuteNonQuery(sql); } From 407c88e7e6116e3eafc9fb855c58fa03d612bece Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:51:20 +0200 Subject: [PATCH 09/19] Execute explicit SQL scripts with provider-specific batch handling Add optional IScriptBatchProvider and an explicit script execution path while keeping ExecuteNonQuery untouched. SQL Server splits standalone GO lines using quote, identifier and nested-comment state. Unsupported SQLCMD directives and GO repetition fail before execution. File and embedded-resource loaders use the same path; custom providers can implement their own splitter without changing ITransformationProvider. Validation: build, Unit 77 passed, SQLite 151 passed. Tests cover multiline strings/comments/identifiers, rejected directives, real file/resource data loading and a live SQL Server GO regression for PR CI. --- src/Migrator.Tests/Migrator.Tests.csproj | 29 ++++---- .../SqlServerTransformationProviderTests.cs | 8 +++ src/Migrator.Tests/ScriptResource.sql | 1 + src/Migrator.Tests/ScriptTests.cs | 39 +++++++++++ src/Migrator/Framework/ProviderExtensions.cs | 16 ++++- src/Migrator/Framework/SqlScriptBatches.cs | 70 +++++++++++++++++++ .../SqlServerTransformationProvider.cs | 4 +- .../Providers/TransformationProvider.cs | 4 +- 8 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 src/Migrator.Tests/ScriptResource.sql create mode 100644 src/Migrator.Tests/ScriptTests.cs create mode 100644 src/Migrator/Framework/SqlScriptBatches.cs diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 457de6da..20a707f2 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -5,8 +5,8 @@ false - - + + @@ -31,25 +31,26 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + + + + + + + + + + - + diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index a3388aff..6503163f 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -12,6 +12,14 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void ExplicitScriptSplitsGoWithoutSplittingMultilineValues() + { + Provider.ExecuteSqlScript("CREATE TABLE ScriptBatches (Value nvarchar(100));\nGO\nINSERT INTO ScriptBatches VALUES ('before\nGO\nafter');\nGO -- final batch\nINSERT INTO ScriptBatches VALUES ('last');"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM ScriptBatches")), Is.EqualTo(2)); + Assert.That(Provider.ExecuteScalar("SELECT Value FROM ScriptBatches WHERE Value LIKE 'before%'"), Does.Contain("GO")); + } + [Test] public void IndependentForeignKeyActionsCascadeUpdateAndSetNullOnDelete() { diff --git a/src/Migrator.Tests/ScriptResource.sql b/src/Migrator.Tests/ScriptResource.sql new file mode 100644 index 00000000..d577d313 --- /dev/null +++ b/src/Migrator.Tests/ScriptResource.sql @@ -0,0 +1 @@ +INSERT INTO ScriptData VALUES (2); diff --git a/src/Migrator.Tests/ScriptTests.cs b/src/Migrator.Tests/ScriptTests.cs new file mode 100644 index 00000000..f1cbb8da --- /dev/null +++ b/src/Migrator.Tests/ScriptTests.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; +namespace Migrator.Tests; +public class ScriptTests +{ + [Test] + public void GoInsideMultilineStringsCommentsAndQuotedIdentifiersDoesNotSplit() + { + var batches = SqlScriptBatches.SplitSqlServer("SELECT 'line\nGO\n''quoted''';\nGO -- next batch\n/* outer\n/* nested */\nGO\n*/ SELECT [line\nGO\n]]name];\ngo\nSELECT 3;"); + Assert.That(batches.Count, Is.EqualTo(3)); + Assert.That(batches[0], Does.Contain("GO")); + Assert.That(batches[1], Does.Contain("GO")); + Assert.That(batches[2].Trim(), Is.EqualTo("SELECT 3;")); + } + [TestCase("GO 2")] + [TestCase(":r other.sql")] + [TestCase("!! echo value")] + public void UnsupportedClientCommandsAreRejectedBeforeExecution(string command) + => Assert.Throws(() => SqlScriptBatches.SplitSqlServer("SELECT 1;\nGO\n" + command)); + + [Test, Category("SQLite")] + public void ExplicitFileAndEmbeddedResourceScriptsPersistData() + { + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(DotNetProjects.Migrator.Providers.ProviderTypes.SQLite, connection, null); + var file = Path.GetTempFileName(); + try + { + File.WriteAllText(file, "CREATE TABLE ScriptData (Id INTEGER); INSERT INTO ScriptData VALUES (1);"); + provider.ExecuteScript(file); + provider.ExecuteResourceScript(typeof(ScriptTests).Assembly, "Migrator.Tests.ScriptResource.sql"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT SUM(Id) FROM ScriptData")), Is.EqualTo(3)); + } + finally { File.Delete(file); } + } +} diff --git a/src/Migrator/Framework/ProviderExtensions.cs b/src/Migrator/Framework/ProviderExtensions.cs index 6618a550..0c6c2707 100644 --- a/src/Migrator/Framework/ProviderExtensions.cs +++ b/src/Migrator/Framework/ProviderExtensions.cs @@ -21,10 +21,24 @@ public static class ProviderExtensions return Convert.ToInt64(count) == 0 ? null : provider.GetColumnContentSize(table, column); } + /// Execute script text with optional provider batch handling. ExecuteNonQuery is never split. + public static void ExecuteSqlScript(this ITransformationProvider provider, string sql) + { + var batches = provider is IScriptBatchProvider splitter ? splitter.SplitScript(sql) : new[] { sql }; + foreach (var batch in batches) provider.ExecuteNonQuery(batch); + } + + public static void ExecuteScript(this ITransformationProvider provider, string fileName) + { + if (provider is Providers.TransformationProvider builtIn) { builtIn.ExecuteScript(fileName); return; } + if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("A script path is required.", nameof(fileName)); + provider.ExecuteSqlScript(File.ReadAllText(Path.IsPathRooted(fileName) ? fileName : Path.Combine(AppContext.BaseDirectory, fileName))); + } + public static void ExecuteResourceScript(this ITransformationProvider provider, Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("SQL resource not found", name); using var reader = new StreamReader(stream); - provider.ExecuteNonQuery(reader.ReadToEnd()); + provider.ExecuteSqlScript(reader.ReadToEnd()); } } diff --git a/src/Migrator/Framework/SqlScriptBatches.cs b/src/Migrator/Framework/SqlScriptBatches.cs new file mode 100644 index 00000000..fd4d45f1 --- /dev/null +++ b/src/Migrator/Framework/SqlScriptBatches.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +namespace DotNetProjects.Migrator.Framework; + +/// Optional provider capability for client-side script separators. +public interface IScriptBatchProvider +{ + IReadOnlyList SplitScript(string sql); +} + +public static class SqlScriptBatches +{ + /// Split standalone SQL Server GO lines. SQLCMD directives and GO counts are unsupported. + public static IReadOnlyList SplitSqlServer(string sql) + { + ArgumentNullException.ThrowIfNull(sql); + var batches = new List(); + var batch = new StringBuilder(); + var quote = '\0'; + var comments = 0; + using var reader = new StringReader(sql); + string line; + while ((line = reader.ReadLine()) != null) + { + if (quote == '\0' && comments == 0) + { + if (Regex.IsMatch(line, @"^\s*GO\s*(?:--.*)?$", RegexOptions.IgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(batch.ToString())) batches.Add(batch.ToString()); + batch.Clear(); + continue; + } + if (Regex.IsMatch(line, @"^\s*GO(?:\s|;|$)", RegexOptions.IgnoreCase)) + throw new NotSupportedException("Only standalone GO with an optional -- comment is supported; counts and other suffixes are not."); + var command = line.TrimStart(); + if (command.StartsWith(":", StringComparison.Ordinal) || command.StartsWith("!!", StringComparison.Ordinal)) + throw new NotSupportedException("SQLCMD directives are not supported by script execution."); + } + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + var next = i + 1 < line.Length ? line[i + 1] : '\0'; + if (comments != 0) + { + if (ch == '/' && next == '*') { comments++; i++; } + else if (ch == '*' && next == '/') { comments--; i++; } + } + else if (quote != '\0') + { + if (ch == quote) + { + if (next == quote) i++; + else quote = '\0'; + } + } + else if (ch == '-' && next == '-') break; + else if (ch == '/' && next == '*') { comments++; i++; } + else if (ch == '\'' || ch == '"') quote = ch; + else if (ch == '[') quote = ']'; + } + batch.AppendLine(line); + } + if (quote != '\0' || comments != 0) throw new FormatException("The script contains an unterminated string, identifier or comment."); + if (!string.IsNullOrWhiteSpace(batch.ToString())) batches.Add(batch.ToString()); + return batches; + } +} diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 9c6e99c3..cd927e5e 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -27,8 +27,10 @@ namespace DotNetProjects.Migrator.Providers.Impl.SqlServer; /// /// Migration transformations provider for Microsoft SQL Server. /// -public class SqlServerTransformationProvider : TransformationProvider +public class SqlServerTransformationProvider : TransformationProvider, IScriptBatchProvider { + public virtual System.Collections.Generic.IReadOnlyList SplitScript(string sql) => SqlScriptBatches.SplitSqlServer(sql); + public SqlServerTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) : base(dialect, connectionString, defaultSchema, scope) { diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 7f56dd42..9aec0fe5 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -958,7 +958,7 @@ public virtual void ExecuteScript(string fileName) if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("A script path is required.", nameof(fileName)); var root = CurrentMigration == null ? AppContext.BaseDirectory : Path.GetDirectoryName(CurrentMigration.GetType().Assembly.Location); var path = Path.IsPathRooted(fileName) ? fileName : Path.Combine(root ?? AppContext.BaseDirectory, fileName); - ExecuteNonQuery(File.ReadAllText(path)); + this.ExecuteSqlScript(File.ReadAllText(path)); } public virtual void ExecuteResourceScript(System.Reflection.Assembly assembly, string resourceName) @@ -966,7 +966,7 @@ public virtual void ExecuteResourceScript(System.Reflection.Assembly assembly, s using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException("Embedded SQL resource not found.", resourceName); using var reader = new StreamReader(stream); - ExecuteNonQuery(reader.ReadToEnd()); + this.ExecuteSqlScript(reader.ReadToEnd()); } /// From 8a359a93afd860947f89baf85b580c5b220b3bf9 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:55:43 +0200 Subject: [PATCH 10/19] Copy imperative column definitions before provider normalization Stop SQLite table creation from stripping primary-key flags on caller columns, and stop generic/Oracle changes from clearing caller uniqueness or nullability. Use an internal definition copy that retains subtype state. Regressions reuse a composite-key definition for a second table and verify nullable composite inserts plus unique-flag preservation. Validation: solution build, Unit 78 passed and SQLite 152 passed. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 15 +++++++++++++++ src/Migrator.Tests/ProviderDefinitionTests.cs | 9 +++++++++ src/Migrator/Framework/Column.cs | 2 ++ .../Impl/Oracle/OracleTransformationProvider.cs | 1 + .../Impl/SQLite/SQLiteTransformationProvider.cs | 1 + src/Migrator/Providers/TransformationProvider.cs | 1 + 6 files changed, 29 insertions(+) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index 2fa2959e..0345f587 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using System.Linq; using DotNetProjects.Migrator; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers; @@ -10,6 +11,20 @@ namespace Migrator.Tests; [Category("SQLite")] public class ProviderCorrectionTests { + [Test] public void TableCreationRetainsCallerPrimaryKeyDefinitions() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var first = new Column("First", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); + var second = new Column("Second", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); + provider.AddTable("Composite", first, second); + Assert.That(first.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); + Assert.That(second.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); + provider.Insert("Composite", new[] { "First", "Second" }, new object[] { 1, null }); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Composite")), Is.EqualTo(1)); + provider.AddTable("Reused", first, second); + Assert.That(provider.GetColumns("Reused").Count(c => c.IsPrimaryKey), Is.EqualTo(2)); + } [Test] public void RebuildPreservesTriggerAndUpdateAction() { using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs index 84efd79b..f655ce28 100644 --- a/src/Migrator.Tests/ProviderDefinitionTests.cs +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -14,6 +14,8 @@ private sealed class RecordingProvider() : TransformationProvider(new SqlServerD { public string ColumnSql; public (string Child, string[] ChildColumns, string Parent, string[] ParentColumns, ForeignKeyConstraintType Delete, ForeignKeyConstraintType Update) ForeignKey; + public override void ChangeColumn(string table, string sqlColumn) => ColumnSql = sqlColumn; + public override void AddUniqueConstraint(string name, string table, params string[] columns) { } public override void AddColumn(string table, string sqlColumn) => ColumnSql = sqlColumn; public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType delete, ForeignKeyConstraintType update) => ForeignKey = (child, columns, parent, parentColumns, delete, update); @@ -32,6 +34,13 @@ [Test] public void NullableScalarRetainsTypedValuesAndHandlesNulls() Assert.That(provider.ExecuteNullableScalar("null"), Is.Null); Assert.That(provider.ExecuteNullableScalar("number"), Is.EqualTo(12)); } + [Test] public void ChangeColumnDoesNotClearCallerUniqueFlag() + { + using var provider = new RecordingProvider(); + var column = new Column("Value", DbType.Int32, ColumnProperty.Unique | ColumnProperty.NotNull); + provider.ChangeColumn("Example", column); + Assert.That(column.ColumnProperty, Is.EqualTo(ColumnProperty.Unique | ColumnProperty.NotNull)); + } [Test] public void AddColumnCarriesPrecisionAndScaleIntoDialectMapping() { using var provider = new RecordingProvider(); diff --git a/src/Migrator/Framework/Column.cs b/src/Migrator/Framework/Column.cs index 3a444a20..ca29f441 100644 --- a/src/Migrator/Framework/Column.cs +++ b/src/Migrator/Framework/Column.cs @@ -23,6 +23,8 @@ public class Column : IColumn, IDbField { private object _defaultValue; + internal Column CopyDefinition() => (Column)MemberwiseClone(); + public Column(string name) { Name = name; diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 9689a18f..f0cbad3b 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -201,6 +201,7 @@ protected override string GetPrimaryKeyname(string tableName) public override void ChangeColumn(string table, Column column) { + column = column.CopyDefinition(); var existingColumn = GetColumnByName(table, column.Name); if (column.Type == DbType.String) diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 1286fefa..c6c0af2b 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -1486,6 +1486,7 @@ public override void AddTable(string name, string engine, params IDbField[] fiel { var columns = fields.Where(x => x is Column) .Cast() + .Select(column => column.CopyDefinition()) .ToArray(); var pks = GetPrimaryKeys(columns); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 9aec0fe5..c5d455ef 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -534,6 +534,7 @@ public virtual bool ColumnExists(string table, string column, bool ignoreCase) public virtual void ChangeColumn(string table, Column column) { + column = column.CopyDefinition(); var isUniqueSet = column.ColumnProperty.IsSet(ColumnProperty.Unique); column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Unique); From dc6de68abf31d8ca9cad3d9b29f5a41017a91603 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:56:58 +0200 Subject: [PATCH 11/19] Run nullable unique-column metadata regression across generic providers Move GetColumns_UniqueButNotPrimaryKey_ReturnsFalse from the SQLite-only fixture to the shared metadata fixture as requested by issue #102. Preserve its assertion that uniqueness is distinct from a primary key and does not imply NOT NULL. Validation: solution build and SQLite 152 passed. Other inherited provider fixtures run in PR CI before the issue is marked fixed. --- .../Generic/Generic_GetColumnsTestsBase.cs | 18 ++ ...eTransformationProvider_GetColumnsTests.cs | 236 ++++++++---------- 2 files changed, 129 insertions(+), 125 deletions(-) diff --git a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs index e51a6a07..3665562d 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs @@ -1,7 +1,25 @@ using Migrator.Tests.Providers.Base; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; namespace Migrator.Tests.Providers.Generic; public abstract class Generic_GetColumnsTestsBase : TransformationProviderBase { + [Test] + public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.Null | ColumnProperty.Unique)); + } + } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs index 9b7c576a..55bd48ba 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs @@ -1,125 +1,111 @@ -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers.Impl.SQLite; -using Migrator.Tests.Providers.Generic; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.SQLite; - -[TestFixture] -[Category("SQLite")] -public class SQLiteTransformationProvider_GetColumnsTests : Generic_GetColumnsTestsBase -{ - [SetUp] - public async Task SetUpAsync() - { - await BeginSQLiteTransactionAsync(); - } - - [Test] - public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.Null | ColumnProperty.Unique)); - } - - [Test] - public void GetColumns_PrimaryAndUnique_ReturnsFalse() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo( - ColumnProperty.NotNull | - ColumnProperty.Identity | - ColumnProperty.Unique | - ColumnProperty.PrimaryKey)); - } - - [Test] - public void GetColumns_Primary_ColumnPropertyOk() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - Provider.GetColumns(tableName); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.NotNull | - ColumnProperty.PrimaryKeyWithIdentity)); - } - - [Test] - public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNotNull() - { - // Arrange - const string tableName = "GetColumnsTest"; - - Provider.AddTable(tableName, - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) - ); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - Assert.That(columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - } - - [Test] - public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() - { - // Arrange - const string tableName = "GetColumnsTest"; - const string column1Name = "Column1"; - const string column2Name = "Column2"; - const string constraintName = "ConstraintName"; - - Provider.AddTable(tableName, new Column(column1Name, DbType.Int32), new Column(column2Name, DbType.Int32)); - - Provider.AddUniqueConstraint(constraintName, tableName, column1Name, column2Name); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - } - - [Test, Description("Add index. The index should be added and then being detected as index.")] - public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Bla1", DbType.Int32), new Column("Bla2", DbType.Int32)); - Provider.AddIndex("IndexName", tableName, ["Bla1", "Bla2"]); - - // Act - var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); - - // Assert - Assert.That(sqliteInfo.Columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - Assert.That(sqliteInfo.Columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - Assert.That(sqliteInfo.Uniques, Is.Empty); - Assert.That(sqliteInfo.Indexes.Single().Unique, Is.False); - } -} +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_GetColumnsTests : Generic_GetColumnsTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginSQLiteTransactionAsync(); + } + + [Test] + public void GetColumns_PrimaryAndUnique_ReturnsFalse() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo( + ColumnProperty.NotNull | + ColumnProperty.Identity | + ColumnProperty.Unique | + ColumnProperty.PrimaryKey)); + } + + [Test] + public void GetColumns_Primary_ColumnPropertyOk() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.GetColumns(tableName); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.NotNull | + ColumnProperty.PrimaryKeyWithIdentity)); + } + + [Test] + public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNotNull() + { + // Arrange + const string tableName = "GetColumnsTest"; + + Provider.AddTable(tableName, + new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), + new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) + ); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + Assert.That(columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + } + + [Test] + public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() + { + // Arrange + const string tableName = "GetColumnsTest"; + const string column1Name = "Column1"; + const string column2Name = "Column2"; + const string constraintName = "ConstraintName"; + + Provider.AddTable(tableName, new Column(column1Name, DbType.Int32), new Column(column2Name, DbType.Int32)); + + Provider.AddUniqueConstraint(constraintName, tableName, column1Name, column2Name); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + } + + [Test, Description("Add index. The index should be added and then being detected as index.")] + public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Bla1", DbType.Int32), new Column("Bla2", DbType.Int32)); + Provider.AddIndex("IndexName", tableName, ["Bla1", "Bla2"]); + + // Act + var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); + + // Assert + Assert.That(sqliteInfo.Columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(sqliteInfo.Columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(sqliteInfo.Uniques, Is.Empty); + Assert.That(sqliteInfo.Indexes.Single().Unique, Is.False); + } +} From 0bd0e09c6ccdd964e97b5386df478d3e4bd330d8 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:58:10 +0200 Subject: [PATCH 12/19] Round-trip SQL Server TIME columns, defaults and values Map DbType.Time to native TIME for the modern SQL Server dialect, parse time metadata and format bounded TimeSpan defaults invariantly. Preserve the explicitly selected SqlServer2005 dialect's historical DATETIME mapping. Add a live metadata-copy/default/parameter round-trip regression for issue #162. Validation: solution build and Unit 78 passed. The live SQL Server PR job must pass before claiming provider coverage. --- .../SqlServerTransformationProviderTests.cs | 16 ++++++++++++++++ .../Impl/SqlServer/SqlServer2005Dialect.cs | 2 ++ .../Providers/Impl/SqlServer/SqlServerDialect.cs | 7 ++++++- .../SqlServer/SqlServerTransformationProvider.cs | 8 ++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 6503163f..074730a8 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Linq; using System; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers; @@ -12,6 +13,21 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void TimeTypeDefaultAndValueRoundTripThroughMetadata() + { + var time = new TimeSpan(0, 12, 34, 56, 789); + Provider.AddTable("ClockValues", new Column("Moment", DbType.Time, ColumnProperty.Null, time)); + var column = Provider.GetColumns("ClockValues").Single(); + Assert.That(column.Type, Is.EqualTo(DbType.Time)); + Assert.That(column.DefaultValue, Is.EqualTo(time)); + Provider.AddTable("CopiedClock", column); + Provider.ExecuteNonQuery("INSERT INTO CopiedClock DEFAULT VALUES"); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM CopiedClock"), Is.EqualTo(time)); + Provider.Insert("ClockValues", new[] { "Moment" }, new object[] { time }); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM ClockValues"), Is.EqualTo(time)); + } + [Test] public void ExplicitScriptSplitsGoWithoutSplittingMultilineValues() { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs index c5bf9285..d104a167 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs @@ -7,6 +7,8 @@ public class SqlServer2005Dialect : SqlServerDialect { public SqlServer2005Dialect() { + // Keep the historical representation for the explicitly selected pre-TIME dialect. + RegisterColumnType(DbType.Time, "DATETIME"); RegisterColumnType(DbType.AnsiString, 2147483647, "VARCHAR(MAX)"); RegisterColumnType(DbType.Binary, 2147483647, "VARBINARY(MAX)"); RegisterColumnType(DbType.String, 1073741823, "NVARCHAR(MAX)"); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs index 2f2885a9..3968c1e4 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs @@ -48,7 +48,7 @@ public SqlServerDialect() RegisterColumnType(DbType.String, 4000, "NVARCHAR($l)"); RegisterColumnType(DbType.String, int.MaxValue, "NVARCHAR(max)"); //RegisterColumnType(DbType.String, 1073741823, "NTEXT"); - RegisterColumnType(DbType.Time, "DATETIME"); + RegisterColumnType(DbType.Time, "TIME"); RegisterColumnType(DbType.VarNumeric, "NUMERIC(18,0)"); RegisterColumnType(DbType.VarNumeric, 38, "NUMERIC($l,0)"); RegisterColumnType(MigratorDbType.Interval, "BIGINT"); @@ -112,6 +112,11 @@ public override string Quote(string value) public override string Default(object defaultValue) { + if (defaultValue is TimeSpan time) + { + if (time < TimeSpan.Zero || time >= TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(defaultValue), "SQL Server TIME must be within one day."); + return "DEFAULT '" + time.ToString("c", System.Globalization.CultureInfo.InvariantCulture) + "'"; + } if (defaultValue.GetType().Equals(typeof(bool))) { return string.Format("DEFAULT {0}", (bool)defaultValue ? "1" : "0"); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index cd927e5e..3e3acd0b 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -640,6 +640,10 @@ public override Column[] GetColumns(string table) { column.MigratorDbType = MigratorDbType.Decimal; } + else if (dataTypeString == "time") + { + column.MigratorDbType = MigratorDbType.Time; + } else if (dataTypeString == "datetime") { column.MigratorDbType = MigratorDbType.DateTime; @@ -691,6 +695,10 @@ public override Column[] GetColumns(string table) { column.DefaultValue = double.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); } + else if (column.Type == DbType.Time) + { + column.DefaultValue = TimeSpan.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); + } else if (column.Type == DbType.Boolean) { var truthy = new string[] { "'TRUE'", "1" }; From 6e790d784ee57499ece3e030c0f062af095d8190 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:01:53 +0200 Subject: [PATCH 13/19] Require explicit ownership for Oracle legacy sequence cleanup Remove the unconditional table-name-derived DROP SEQUENCE and swallowed exceptions. Oracle continues to clean up its table-owned triggers and native identity objects. Add RemoveTableWithOwnedSequences for caller-owned legacy sequences, validating names and existence before destructive DDL. Failures are observable and unrelated lookalike sequences remain intact. Validation: solution build and Unit 78 passed. New live Oracle regressions cover preserved unrelated sequences and explicit cleanup of a legacy sequence/trigger pair; provider CI must verify them. Addresses the unsafe legacy cleanup behind #140 and #141. --- ...TransformationProvider_TableExistsTests.cs | 111 +++++++++++------- .../Oracle/OracleTransformationProvider.cs | 29 ++++- 2 files changed, 92 insertions(+), 48 deletions(-) diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index e6d20358..bc66ecbd 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -1,42 +1,69 @@ -using System.Data; -using DotNetProjects.Migrator.Framework; -using Migrator.Tests.Providers.OracleProvider.Base; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.OracleProvider; - -[TestFixture] -[Category("Oracle")] -public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase -{ - [Test] - public void TableExists_TableExists_Returns() - { - // Arrange - const string testTableName = "MyDefaultTestTable"; - const string propertyName1 = "Color1"; - - Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32) - ); - - // Act - var tableExists = Provider.TableExists(testTableName); - - // Assert - Assert.That(tableExists, Is.True); - } - - [Test] - public void TableExists_TableDoesNotExist_ReturnsFalse() - { - // Arrange - const string myTableName = "MyTable"; - - // Act - var tableExists = Provider.TableExists(myTableName); - - // Assert - Assert.That(tableExists, Is.False); - } -} +using System.Data; +using System; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Framework; +using Migrator.Tests.Providers.OracleProvider.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.OracleProvider; + +[TestFixture] +[Category("Oracle")] +public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase +{ + [Test] + public void RemovingTableDoesNotGuessOwnershipOfLegacyNamedSequence() + { + Provider.AddTable("UnownedSequenceTable", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE UnownedSequenceTable_SEQUENCE"); + try + { + Provider.RemoveTable("UnownedSequenceTable"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='UNOWNEDSEQUENCETABLE_SEQUENCE'")), Is.EqualTo(1)); + } + finally { Provider.ExecuteNonQuery("DROP SEQUENCE UnownedSequenceTable_SEQUENCE"); } + } + + [Test] + public void ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger() + { + Provider.AddTable("LegacyOwned", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE LegacyOwned_SEQUENCE"); + Provider.ExecuteNonQuery("CREATE TRIGGER LegacyOwned_TRIGGER BEFORE INSERT ON LegacyOwned FOR EACH ROW BEGIN SELECT LegacyOwned_SEQUENCE.NEXTVAL INTO :new.Id FROM dual; END;"); + ((OracleTransformationProvider)Provider).RemoveTableWithOwnedSequences("LegacyOwned", "LegacyOwned_SEQUENCE"); + Assert.That(Provider.TableExists("LegacyOwned"), Is.False); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='LEGACYOWNED_SEQUENCE'")), Is.Zero); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_TRIGGERS WHERE TRIGGER_NAME='LEGACYOWNED_TRIGGER'")), Is.Zero); + } + + [Test] + public void TableExists_TableExists_Returns() + { + // Arrange + const string testTableName = "MyDefaultTestTable"; + const string propertyName1 = "Color1"; + + Provider.AddTable(testTableName, + new Column(propertyName1, DbType.Int32) + ); + + // Act + var tableExists = Provider.TableExists(testTableName); + + // Assert + Assert.That(tableExists, Is.True); + } + + [Test] + public void TableExists_TableDoesNotExist_ReturnsFalse() + { + // Arrange + const string myTableName = "MyTable"; + + // Act + var tableExists = Provider.TableExists(myTableName); + + // Assert + Assert.That(tableExists, Is.False); + } +} diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index f0cbad3b..e709d471 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -923,17 +923,34 @@ public override void AddTable(string name, params IDbField[] fields) public override void RemoveTable(string name) { + // Oracle drops table-owned triggers and native identity sequences itself. + // A legacy-looking sequence name is not evidence of ownership. base.RemoveTable(name); + } - try + /// Drop a table and explicitly identified, unquoted legacy sequence names. + /// The caller must own these sequences. Oracle DDL is not transactional. + public void RemoveTableWithOwnedSequences(string name, params string[] ownedSequenceNames) + { + ArgumentNullException.ThrowIfNull(ownedSequenceNames); + var sequences = ownedSequenceNames.Select(sequence => { - using var cmd = CreateCommand(); - ExecuteQuery(cmd, string.Format(@"DROP SEQUENCE {0}_SEQUENCE", name)); - } - catch (Exception) + GuardAgainstMaximumIdentifierLengthForOracle(sequence); + if (!System.Text.RegularExpressions.Regex.IsMatch(sequence, @"^[A-Za-z][A-Za-z0-9_$#]*$")) + throw new ArgumentException("Legacy sequence cleanup requires simple unquoted sequence names.", nameof(ownedSequenceNames)); + return sequence.ToUpperInvariant(); + }).Distinct(StringComparer.Ordinal).ToArray(); + foreach (var sequence in sequences) { - // swallow this because sequence may not have existed. + using var command = CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME = :sequenceName"; + var parameter = command.CreateParameter(); parameter.ParameterName = "sequenceName"; parameter.Value = sequence; + command.Parameters.Add(parameter); + if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Owned legacy sequence was not found: " + sequence); } + if (!TableExists(name)) throw new MigrationException("Table was not found: " + name); + base.RemoveTable(name); + foreach (var sequence in sequences) ExecuteNonQuery("DROP SEQUENCE " + _dialect.Quote(sequence)); } private void GuardAgainstMaximumColumnNameLengthForOracle(string name, Column[] columns) From b404da6bd1602a29442e7e0bceb7b2ba7aff19b8 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:04:45 +0200 Subject: [PATCH 14/19] Fix uniqueness metadata and TimeSpan parameters exposed by live regressions Read single-column UNIQUE constraints in SQL Server, PostgreSQL and Oracle metadata while leaving composite members individually non-unique. Preserve independent nullability and primary-key flags. Correct Oracle flag assignments and bind TimeSpan values with DbType.Time in centralized parameter construction. Reproduced failures in CI run 35737057890: shared uniqueness metadata failed on three engines and SQL Server TimeSpan insertion was rejected. Local validation: build, Unit 78 passed, SQLite 153 passed. Added a composite-uniqueness regression; live CI must verify the fixes. --- .../Providers/Generic/Generic_GetColumnsTestsBase.cs | 8 ++++++++ .../Providers/Impl/Oracle/OracleTransformationProvider.cs | 6 ++++-- .../Impl/PostgreSQL/PostgreSQLTransformationProvider.cs | 4 ++++ .../Impl/SqlServer/SqlServerTransformationProvider.cs | 2 ++ src/Migrator/Providers/TransformationProvider.cs | 5 +++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs index 3665562d..22c3681f 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs @@ -8,6 +8,14 @@ namespace Migrator.Tests.Providers.Generic; public abstract class Generic_GetColumnsTestsBase : TransformationProviderBase { + [Test] + public void CompositeUniqueDoesNotMarkItsIndividualColumnsUnique() + { + Provider.AddTable("CompositeUniqueMetadata", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); + Provider.AddUniqueConstraint("CompositeUniqueKey", "CompositeUniqueMetadata", "FirstId", "SecondId"); + Assert.That(Provider.GetColumns("CompositeUniqueMetadata").All(c => !c.ColumnProperty.HasFlag(ColumnProperty.Unique)), Is.True); + } + [Test] public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() { diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index e709d471..31316840 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -476,6 +476,7 @@ public override Column[] GetColumns(string table) var userTabIdentityCols = _oracleSystemDataLoader.GetUserTabIdentityCols(tableName: table); var primaryKeyItems = _oracleSystemDataLoader.GetPrimaryKeyItems(tableName: table); + var uniqueColumns = ExecuteStringQuery("SELECT MIN(cc.COLUMN_NAME) FROM USER_CONSTRAINTS c JOIN USER_CONS_COLUMNS cc ON c.CONSTRAINT_NAME=cc.CONSTRAINT_NAME WHERE c.CONSTRAINT_TYPE='U' AND LOWER(c.TABLE_NAME)=LOWER('{0}') GROUP BY c.CONSTRAINT_NAME HAVING COUNT(*)=1", table.Replace("'", "''")); List userTabColumns = []; @@ -526,6 +527,7 @@ public override Column[] GetColumns(string table) ColumnProperty = isNullable ? ColumnProperty.Null : ColumnProperty.NotNull }; + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; var isIdentity = userTabIdentityCols.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); var isPrimaryKey = primaryKeyItems.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); @@ -535,11 +537,11 @@ public override Column[] GetColumns(string table) } else if (isIdentity) { - column.ColumnProperty.Set(ColumnProperty.Identity); + column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Identity); } else if (isPrimaryKey) { - column.ColumnProperty.Set(ColumnProperty.PrimaryKey); + column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKey); } // Oracle does not have unsigned types. All NUMBER types can hold positive or negative values so we do not return DbType.UIntX types. diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index a6e2e0b1..b02fdedc 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -476,6 +476,9 @@ public override Column[] GetColumns(string table) var columnInfos = _postgreSQLSystemDataLoader.GetColumnInfos(table, "public"); var columns = new List(); var tableConstraints = _postgreSQLSystemDataLoader.GetTableConstraints(table); + var uniqueColumns = tableConstraints.Where(c => c.ConstraintType == "UNIQUE") + .GroupBy(c => new { c.TableSchema, c.ConstraintName }).Where(g => g.Count() == 1) + .Select(g => g.Single().ColumnName).ToHashSet(StringComparer.Ordinal); foreach (var columnInfo in columnInfos) { @@ -594,6 +597,7 @@ public override Column[] GetColumns(string table) }; column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; if (isPrimaryKey) { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 3e3acd0b..daf890e2 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -563,6 +563,7 @@ public override Column[] GetColumns(string table) var tableLiteral = table.Replace("'", "''"); var schemaLiteral = schema.Replace("'", "''"); var pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='PRIMARY KEY'", tableLiteral, schemaLiteral); + var uniqueColumns = ExecuteStringQuery("SELECT MIN(cu.COLUMN_NAME) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='UNIQUE' GROUP BY tc.CONSTRAINT_SCHEMA, tc.CONSTRAINT_NAME HAVING COUNT(*)=1", tableLiteral, schemaLiteral); var idtColumns = ExecuteStringQuery("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='{1}' AND TABLE_NAME='{0}' AND COLUMNPROPERTY(OBJECT_ID(QUOTENAME(TABLE_SCHEMA)+'.'+QUOTENAME(TABLE_NAME)),COLUMN_NAME,'IsIdentity')=1", tableLiteral, schemaLiteral); var columns = new List(); @@ -583,6 +584,7 @@ public override Column[] GetColumns(string table) var defaultValueString = reader.IsDBNull(defaultValueOrdinal) ? null : reader.GetString(defaultValueOrdinal).Trim(); var characterMaximumLength = reader.IsDBNull(characterMaximumLengthOrdinal) ? (int?)null : reader.GetInt32(characterMaximumLengthOrdinal); + if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; if (pkColumns.Contains(column.Name)) { column.ColumnProperty |= ColumnProperty.PrimaryKey; diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index c5d455ef..fb307dd8 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -2048,6 +2048,11 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.DateTime; parameter.Value = value; } + else if (value is TimeSpan timeSpan) + { + parameter.DbType = DbType.Time; + parameter.Value = timeSpan; + } else if (value is DateTimeOffset dateTimeOffset) { parameter.DbType = DbType.DateTimeOffset; From 29710efadc6778e410ecfdae4172f1ff3317b97e Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:14:19 +0200 Subject: [PATCH 15/19] Verify nullable result contracts and non-mutating identifier quoting Add real SQLite regressions for empty and all-NULL scalar/content-size queries, then populated values. Assert identifier quoting returns a separate array without changing caller input. Replace the obsolete foreign-key TODO with the compatibility distinction between legacy and independent-action overloads. Validation: solution build, Unit 79 passed and SQLite 154 passed. Covers #53, #98 and #145 without changing legacy return signatures. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 13 +++++++++++++ src/Migrator.Tests/ProviderDefinitionTests.cs | 9 +++++++++ src/Migrator/Providers/TransformationProvider.cs | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index 0345f587..7f0588c5 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -11,6 +11,19 @@ namespace Migrator.Tests; [Category("SQLite")] public class ProviderCorrectionTests { + [Test] public void NullableResultsDistinguishEmptyNullAndPopulatedData() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("NullableData", new Column("Id", DbType.Int32), new Column("Value", DbType.String)); + Assert.That(provider.ExecuteNullableScalar("SELECT MAX(Id) FROM NullableData"), Is.Null); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.Null); + provider.ExecuteNonQuery("INSERT INTO NullableData VALUES (NULL, NULL)"); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.Null); + provider.ExecuteNonQuery("INSERT INTO NullableData VALUES (7, 'hello')"); + Assert.That(provider.ExecuteNullableScalar("SELECT MAX(Id) FROM NullableData"), Is.EqualTo(7)); + Assert.That(provider.GetNullableColumnContentSize("NullableData", "Value"), Is.EqualTo(5)); + } [Test] public void TableCreationRetainsCallerPrimaryKeyDefinitions() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs index f655ce28..fe580d23 100644 --- a/src/Migrator.Tests/ProviderDefinitionTests.cs +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -41,6 +41,15 @@ [Test] public void ChangeColumnDoesNotClearCallerUniqueFlag() provider.ChangeColumn("Example", column); Assert.That(column.ColumnProperty, Is.EqualTo(ColumnProperty.Unique | ColumnProperty.NotNull)); } + [Test] public void QuotingReturnsNewArrayWithoutChangingCallerNames() + { + using var provider = new RecordingProvider(); + var names = new[] { "select", "Normal" }; + var quoted = provider.QuoteColumnNamesIfRequired(names); + Assert.That(quoted, Is.Not.SameAs(names)); + Assert.That(names, Is.EqualTo(new[] { "select", "Normal" })); + Assert.That(quoted[0], Is.EqualTo("[select]")); + } [Test] public void AddColumnCarriesPrecisionAndScaleIntoDialectMapping() { using var provider = new RecordingProvider(); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index fb307dd8..d30be76a 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -852,7 +852,7 @@ public virtual void AddForeignKey( var constraintResolved = constraintMapper.SqlForConstraint(constraint); - // TODO Issue #52 still unresolved + // Legacy overload preserves one action for both clauses; IForeignKeyActions provides independent actions. var childColumnsString = string.Join(", ", childColumns); var parentColumnsString = string.Join(", ", parentColumns); From 02a644b2329322961db25a54a1ab42eec068644f Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:16:49 +0200 Subject: [PATCH 16/19] Track SQL Server uniqueness ownership from creation and support explicit legacy adoption Mark column-level unique constraints created by AddTable and every property-bearing AddColumn path, not only ChangeColumn. Keep explicitly named user constraints independent. Add an explicit adoption API for caller-owned historical single-column constraints, validating their table/column shape and making repeated adoption idempotent. Validation: solution build and Unit 79 passed before the additional composite-adoption assertion. Live SQL Server tests cover create/add/remove uniqueness, legacy adoption, composite rejection and preserving unrelated user-owned constraints; CI must pass before resolving #132. --- ...ransformationProvider_ChangeColumnTests.cs | 39 ++++++++++++ .../SqlServerTransformationProvider.cs | 60 ++++++++++++++++--- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs index a3469ef4..8712b4e3 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs @@ -3,6 +3,7 @@ using DotNetProjects.Migrator.Framework; using Migrator.Tests.Providers.Generic; using NUnit.Framework; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; namespace Migrator.Tests.Providers.SQLServer; @@ -16,6 +17,44 @@ public async Task SetUpAsync() await BeginSQLServerTransactionAsync(); } + [TestCase(false), TestCase(true)] + public void ChangeColumnRemovesOwnedUniqueFromTableOrColumnCreation(bool addColumn) + { + var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull | ColumnProperty.Unique); + if (addColumn) + { + Provider.AddTable("CreatedUnique", new Column("Id", DbType.Int32)); + Provider.AddColumn("CreatedUnique", definition); + } + else Provider.AddTable("CreatedUnique", definition); + Provider.ChangeColumn("CreatedUnique", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); + Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); + Assert.That(definition.ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetIndexes("CreatedUnique"), Is.Empty); + } + + [Test] + public void OwnershipAdoptionRejectsCompositeConstraints() + { + Provider.AddTable("CompositeOwned", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); + Provider.AddUniqueConstraint("UserComposite", "CompositeOwned", "FirstId", "SecondId"); + Assert.Throws(() => ((SqlServerTransformationProvider)Provider).AdoptColumnUniqueConstraint("CompositeOwned", "FirstId", "UserComposite")); + Assert.That(Provider.ConstraintExists("CompositeOwned", "UserComposite"), Is.True); + } + + [Test] + public void ExplicitOwnershipAdoptionAllowsLegacyUniqueRemoval() + { + Provider.AddTable("LegacyUnique", new Column("Value", DbType.Int32)); + Provider.AddUniqueConstraint("LegacyUniqueConstraint", "LegacyUnique", "Value"); + var sqlServer = (SqlServerTransformationProvider)Provider; + sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); + sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); + Provider.ChangeColumn("LegacyUnique", new Column("Value", DbType.Int32, ColumnProperty.Null)); + Assert.That(Provider.ConstraintExists("LegacyUnique", "LegacyUniqueConstraint"), Is.False); + } + [Test] public void ChangeColumn_DateTimeToDateTime2_Success() { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index daf890e2..26457c94 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -277,6 +277,56 @@ public override string AddIndex(string table, Index index) return sql; } + public override void AddTable(string name, string engine, params IDbField[] fields) + { + var definitions = fields.Select(field => field is Column column ? column.CopyDefinition() : field).ToArray(); + var owned = definitions.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Unique)).ToArray(); + foreach (var column in owned) column.ColumnProperty &= ~ColumnProperty.Unique; + base.AddTable(name, engine, definitions); + foreach (var column in owned) AddOwnedColumnUnique(name, column.Name); + } + + public override void AddColumn(string table, Column column) + { + var definition = column.CopyDefinition(); + var owned = definition.ColumnProperty.HasFlag(ColumnProperty.Unique); + definition.ColumnProperty &= ~ColumnProperty.Unique; + base.AddColumn(table, definition); + if (owned) AddOwnedColumnUnique(table, column.Name); + } + + public override void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, object defaultValue) + { + base.AddColumn(table, column, type, size, property & ~ColumnProperty.Unique, defaultValue); + if (property.HasFlag(ColumnProperty.Unique)) AddOwnedColumnUnique(table, column); + } + + private void AddOwnedColumnUnique(string table, string column) + { + var name = "UX_" + Guid.NewGuid().ToString("N"); + AddUniqueConstraint(name, table, column); + MarkColumnUniqueOwned(table, column, name); + } + + /// Explicitly adopt a caller-owned, single-column legacy UNIQUE constraint. + /// No ownership is inferred from its name. Future ChangeColumn calls may remove it. + public void AdoptColumnUniqueConstraint(string table, string column, string constraint) + { + using var command = CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sys.key_constraints kc JOIN sys.index_columns ic ON ic.object_id=kc.parent_object_id AND ic.index_id=kc.unique_index_id JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND kc.name=@constraint AND ic.key_ordinal=1 AND c.name=@column AND NOT EXISTS (SELECT 1 FROM sys.index_columns more WHERE more.object_id=ic.object_id AND more.index_id=ic.index_id AND more.key_ordinal>1)"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); + if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Ownership requires an existing single-column UNIQUE constraint on the specified table and column."); + MarkColumnUniqueOwned(table, column, constraint); + } + + private void MarkColumnUniqueOwned(string table, string column, string constraint) + { + using var command = CreateCommand(); + command.CommandText = "DECLARE @schema sysname=OBJECT_SCHEMA_NAME(OBJECT_ID(@table)); DECLARE @name sysname=OBJECT_NAME(OBJECT_ID(@table)); IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.key_constraints kc ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.name=@constraint AND ep.name=N'Migrator.NET.ColumnUnique') EXEC sys.sp_updateextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint; ELSE EXEC sys.sp_addextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint"; + AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); + command.ExecuteNonQuery(); + } + public override void ChangeColumn(string table, Column column) { var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) @@ -298,15 +348,7 @@ public override void ChangeColumn(string table, Column column) base.ChangeColumn(table, definition); if (requestedDefault != null && requestedDefault != DBNull.Value) ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD DEFAULT {_dialect.Default(requestedDefault)[8..]} FOR {QuoteColumnNameIfRequired(column.Name)}"); - if (unique) - { - var name = "UX_" + Guid.NewGuid().ToString("N"); - AddUniqueConstraint(name, table, column.Name); - using var command = CreateCommand(); - command.CommandText = "DECLARE @schema sysname=OBJECT_SCHEMA_NAME(OBJECT_ID(@table)); DECLARE @name sysname=OBJECT_NAME(OBJECT_ID(@table)); EXEC sys.sp_addextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint"; - AddParameter(command, "@table", table); AddParameter(command, "@column", column.Name); AddParameter(command, "@constraint", name); - command.ExecuteNonQuery(); - } + if (unique) AddOwnedColumnUnique(table, column.Name); } private static void AddParameter(IDbCommand command, string name, object value) From a5774a7240dbca00f821851432f26d6cc906e99a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:19:40 +0200 Subject: [PATCH 17/19] Quote provider constraint paths and retain legacy Oracle deletion actions Quote reserved or non-simple constraint identifiers as single atoms with escaped closing delimiters. Route SQLite inline UNIQUE/CHECK, SQL Server nonclustered keys and Informix/Sybase constraints through identifier helpers. Delegate Oracle's legacy FK overload to the independent-action path so CASCADE is honored, and reject unsupported deletion actions explicitly. Validation: build, Unit 79 passed, SQLite 155 passed. SQLite regression verifies enforcement with spaced constraint names and reserved column names; live Oracle cascade deletion regression is included for CI. --- src/Migrator.Tests/ProviderCorrectionTests.cs | 11 +++++++++++ .../OracleTransformationProvider_TableExistsTests.cs | 12 ++++++++++++ .../Impl/Informix/InformixTransformationProvider.cs | 8 ++++---- .../Impl/Oracle/OracleTransformationProvider.cs | 7 +------ .../Impl/SQLite/SQLiteTransformationProvider.cs | 6 +++--- .../SqlServer/SqlServerTransformationProvider.cs | 2 +- .../Impl/Sybase/SybaseTransformationProvider.cs | 2 +- src/Migrator/Providers/TransformationProvider.cs | 10 ++++++++-- 8 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index 7f0588c5..0e845eaf 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -11,6 +11,17 @@ namespace Migrator.Tests; [Category("SQLite")] public class ProviderCorrectionTests { + [Test] public void InlineConstraintNamesAndReservedUniqueColumnsAreQuoted() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("QuotedConstraints", new Column("select", DbType.Int32), + new Unique { Name = "unique name", KeyColumns = new[] { "select" } }, + new CheckConstraint("check name", "\"select\" > 0")); + provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)"); + Assert.That(Assert.Throws(() => provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)")).InnerException, Is.TypeOf()); + Assert.That(Assert.Throws(() => provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (-1)")).InnerException, Is.TypeOf()); + } [Test] public void NullableResultsDistinguishEmptyNullAndPopulatedData() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index bc66ecbd..2b50360e 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -11,6 +11,18 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase { + [Test] + public void LegacyForeignKeyOverloadHonorsCascadeDelete() + { + Provider.AddTable("CascadeParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable("CascadeChild", new Column("ParentId", DbType.Int32)); + Provider.AddForeignKey("CascadeForeignKey", "CascadeChild", new[] { "ParentId" }, "CascadeParent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); + Provider.Insert("CascadeParent", new[] { "Id" }, new object[] { 1 }); + Provider.Insert("CascadeChild", new[] { "ParentId" }, new object[] { 1 }); + Provider.ExecuteNonQuery("DELETE FROM CascadeParent"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM CascadeChild")), Is.Zero); + } + [Test] public void RemovingTableDoesNotGuessOwnershipOfLegacyNamedSequence() { diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index c213ef9c..5b3a7c90 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -204,11 +204,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(", ", columns)}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddUniqueConstraint(string name, string table, params string[] columns) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", columns)}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", QuoteColumnNamesIfRequired(columns))}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddCheckConstraint(string name, string table, string checkSql) => - ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) @@ -226,6 +226,6 @@ public override void AddForeignKey(string name, string childTable, string[] chil ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict => "", _ => throw new NotSupportedException("Informix supports cascading deletes or its default restrictive referential action.") }; - ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)}){action} CONSTRAINT {name}"); + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))}){action} CONSTRAINT {QuoteConstraintNameIfRequired(name)}"); } } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 31316840..1b3a7ddd 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -94,12 +94,7 @@ public override void AddForeignKey(string name, string primaryTable, string[] pr { GuardAgainstMaximumIdentifierLengthForOracle(name); - primaryTable = QuoteTableNameIfRequired(primaryTable); - refTable = QuoteTableNameIfRequired(refTable); - var primaryColumnsSql = string.Join(",", primaryColumns.Select(col => QuoteColumnNameIfRequired(col)).ToArray()); - var refColumnsSql = string.Join(",", refColumns.Select(col => QuoteColumnNameIfRequired(col)).ToArray()); - - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} FOREIGN KEY ({2}) REFERENCES {3} ({4})", primaryTable, name, primaryColumnsSql, refTable, refColumnsSql)); + AddForeignKey(name, primaryTable, primaryColumns, refTable, refColumns, constraint, ForeignKeyConstraintType.NoAction); } public override string AddIndex(string table, Index index) diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index c6c0af2b..6c3b5b6a 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -1536,14 +1536,14 @@ public override void AddTable(string name, string engine, params IDbField[] fiel { if (!string.IsNullOrEmpty(u.Name)) { - stringBuilder.Append($", CONSTRAINT {u.Name}"); + stringBuilder.Append($", CONSTRAINT {QuoteConstraintNameIfRequired(u.Name)}"); } else { stringBuilder.Append(", "); } - var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns); + var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns.Select(QuoteColumnNameIfRequired)); stringBuilder.Append($" UNIQUE ({uniqueColumnsCommaSeparated})"); } @@ -1585,7 +1585,7 @@ public override void AddTable(string name, string engine, params IDbField[] fiel foreach (var checkConstraint in checkConstraints) { - checkConstraintStrings.Add($"CONSTRAINT {checkConstraint.Name} CHECK ({checkConstraint.CheckConstraintString})"); + checkConstraintStrings.Add($"CONSTRAINT {QuoteConstraintNameIfRequired(checkConstraint.Name)} CHECK ({checkConstraint.CheckConstraintString})"); } if (checkConstraintStrings.Count > 0) diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 26457c94..805d5fbd 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -197,7 +197,7 @@ public override void AddPrimaryKeyNonClustered(string name, string table, params { var nonclusteredString = "NONCLUSTERED"; ExecuteNonQuery( - string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY {2} ({3}) ", table, name, nonclusteredString, + string.Format("ALTER TABLE {0} ADD CONSTRAINT {1} PRIMARY KEY {2} ({3}) ", QuoteTableNameIfRequired(table), QuoteConstraintNameIfRequired(name), nonclusteredString, string.Join(",", QuoteColumnNamesIfRequired(columns)))); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 21b28fb6..7474054d 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -178,6 +178,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 {name} FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)})"); + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {parentTable} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"); } } diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index d30be76a..c6ee88f1 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1568,8 +1568,10 @@ public virtual void AddForeignKey(string name, string childTable, string[] child var oracle = _dialect is DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect; if (oracle && onUpdate != ForeignKeyConstraintType.NoAction) throw new NotSupportedException("Oracle does not support ON UPDATE foreign key actions."); + if (oracle && onDelete is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict or ForeignKeyConstraintType.Cascade or ForeignKeyConstraintType.SetNull)) + throw new NotSupportedException("Oracle supports default restrictive, CASCADE or SET NULL deletion actions."); var sql = $"ALTER TABLE {QuoteTableNameIfRequired(childTable)} ADD CONSTRAINT {QuoteConstraintNameIfRequired(name)} FOREIGN KEY ({string.Join(", ", QuoteColumnNamesIfRequired(childColumns))}) REFERENCES {QuoteTableNameIfRequired(parentTable)} ({string.Join(", ", QuoteColumnNamesIfRequired(parentColumns))})"; - if (!oracle || onDelete != ForeignKeyConstraintType.NoAction) sql += $" ON DELETE {deleteAction}"; + if (!oracle || onDelete is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) sql += $" ON DELETE {deleteAction}"; if (!oracle) sql += $" ON UPDATE {updateAction}"; ExecuteNonQuery(sql); } @@ -2120,7 +2122,11 @@ public virtual string AddIndex(string name, string table, params string[] column protected string QuoteConstraintNameIfRequired(string name) { - return _dialect.ConstraintNameNeedsQuote ? _dialect.Quote(name) : name; + if (!_dialect.ConstraintNameNeedsQuote && !_dialect.IsReservedWord(name) + && System.Text.RegularExpressions.Regex.IsMatch(name, @"^[A-Za-z_][A-Za-z0-9_$#]*$")) return name; + var template = _dialect.QuoteTemplate; + var closing = template[^1].ToString(); + return string.Format(template, name.Replace(closing, closing + closing)); } public abstract bool IndexExists(string table, string name); From 28316a8055382a85a6022e5fcf903b400f5a84b8 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:27:04 +0200 Subject: [PATCH 18/19] Preserve baseline line endings in touched provider and fixture files Normalize only files already changed by this stack to each file's master line-ending convention, reducing unrelated review noise without changing source behavior. --- .github/workflows/dotnetpull.yml | 168 +++---- src/Migrator.Tests/MigrationLoaderTest.cs | 138 +++--- src/Migrator.Tests/MigratorTest.cs | 450 +++++++++--------- ...TransformationProvider_TableExistsTests.cs | 162 +++---- ...ansformationProvider_AddForeignKeyTests.cs | 246 +++++----- ...eTransformationProvider_GetColumnsTests.cs | 222 ++++----- src/Migrator/MigrationLoader.cs | 350 +++++++------- 7 files changed, 868 insertions(+), 868 deletions(-) diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index d9fef12e..7dba5873 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,84 +1,84 @@ -name: .NET Pull Request -on: - push: - branches: [master] - pull_request: - branches: [master, "codex/**"] - workflow_dispatch: -permissions: - contents: read -concurrency: - group: live-databases-${{ github.ref }} - cancel-in-progress: true -jobs: - test: - name: Test (${{ matrix.database }}) - runs-on: ubuntu-22.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Start database - shell: bash - run: | - mkdir -p TestResults - bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log - timeout-minutes: 15 - - name: Build - run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} - - name: Configure native IBM drivers - if: matrix.database == 'Db2' || matrix.database == 'Informix' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 - output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" - if [ "${{ matrix.database }}" = Db2 ]; then - echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" - else - echo "DELIMIDENT=y" >> "$GITHUB_ENV" - echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" - fi - - name: Test - shell: pwsh - run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} - - name: Collect database logs - if: always() - run: | - mkdir -p TestResults - if docker inspect migrator-db >/dev/null 2>&1; then - docker logs migrator-db > TestResults/database.log 2>&1 - docker inspect migrator-db > TestResults/container.json - fi - - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-${{ matrix.database }} - path: TestResults/ - if-no-files-found: error - - name: Remove test container - if: always() - run: | - if docker inspect migrator-db >/dev/null 2>&1; then - docker rm -fv migrator-db - fi - coverage: - name: Verify complete test coverage - needs: test - runs-on: ubuntu-22.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - pattern: test-results-* - path: TestResults - - run: python3 .github/scripts/verify-test-coverage.py TestResults +name: .NET Pull Request +on: + push: + branches: [master] + pull_request: + branches: [master, "codex/**"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: Test (${{ matrix.database }}) + runs-on: ubuntu-22.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Start database + shell: bash + run: | + mkdir -p TestResults + bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log + timeout-minutes: 15 + - name: Build + run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} + - name: Configure native IBM drivers + if: matrix.database == 'Db2' || matrix.database == 'Informix' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 + output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" + if [ "${{ matrix.database }}" = Db2 ]; then + echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" + else + echo "DELIMIDENT=y" >> "$GITHUB_ENV" + echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" + fi + - name: Test + shell: pwsh + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + - name: Collect database logs + if: always() + run: | + mkdir -p TestResults + if docker inspect migrator-db >/dev/null 2>&1; then + docker logs migrator-db > TestResults/database.log 2>&1 + docker inspect migrator-db > TestResults/container.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.database }} + path: TestResults/ + if-no-files-found: error + - name: Remove test container + if: always() + run: | + if docker inspect migrator-db >/dev/null 2>&1; then + docker rm -fv migrator-db + fi + coverage: + name: Verify complete test coverage + needs: test + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: TestResults + - run: python3 .github/scripts/verify-test-coverage.py TestResults diff --git a/src/Migrator.Tests/MigrationLoaderTest.cs b/src/Migrator.Tests/MigrationLoaderTest.cs index e319dd78..b7c1d641 100644 --- a/src/Migrator.Tests/MigrationLoaderTest.cs +++ b/src/Migrator.Tests/MigrationLoaderTest.cs @@ -1,69 +1,69 @@ -using System.Reflection; -using DotNetProjects.Migrator; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.Loggers; -using NSubstitute; -using NUnit.Framework; - -namespace Migrator.Tests; - -[TestFixture] -public class MigrationLoaderTest -{ - #region Setup/Teardown - - [SetUp] - public void SetUp() - { - SetUpLoader(); - } - - #endregion - - private MigrationLoader _migrationLoader; - - private void SetUpLoader() - { - var providerMock = Substitute.For(); - - providerMock.Logger = new Logger(false); - _migrationLoader = new MigrationLoader(providerMock, Assembly.GetExecutingAssembly(), true); - _migrationLoader.MigrationsTypes.Clear(); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SecondMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ThirdMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ForthMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.BadMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SixthMigration)); - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.NonIgnoredMigration)); - } - - [Test] - public void CheckForDuplicatedVersion() - { - _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); - Assert.Throws(() => - { - _migrationLoader.CheckForDuplicatedVersion(); - }); - } - - [Test] - public void LastVersion() - { - Assert.That(7, Is.EqualTo(_migrationLoader.LastVersion)); - } - - [Test] - public void NullIfNoMigrationForVersion() - { - Assert.That(_migrationLoader.GetMigration(99999999), Is.Null); - } - - [Test] - public void ZeroIfNoMigrations() - { - _migrationLoader.MigrationsTypes.Clear(); - Assert.That(0, Is.EqualTo(_migrationLoader.LastVersion)); - } -} +using System.Reflection; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture] +public class MigrationLoaderTest +{ + #region Setup/Teardown + + [SetUp] + public void SetUp() + { + SetUpLoader(); + } + + #endregion + + private MigrationLoader _migrationLoader; + + private void SetUpLoader() + { + var providerMock = Substitute.For(); + + providerMock.Logger = new Logger(false); + _migrationLoader = new MigrationLoader(providerMock, Assembly.GetExecutingAssembly(), true); + _migrationLoader.MigrationsTypes.Clear(); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SecondMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ThirdMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.ForthMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.BadMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.SixthMigration)); + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.NonIgnoredMigration)); + } + + [Test] + public void CheckForDuplicatedVersion() + { + _migrationLoader.MigrationsTypes.Add(typeof(MigratorTest.FirstMigration)); + Assert.Throws(() => + { + _migrationLoader.CheckForDuplicatedVersion(); + }); + } + + [Test] + public void LastVersion() + { + Assert.That(7, Is.EqualTo(_migrationLoader.LastVersion)); + } + + [Test] + public void NullIfNoMigrationForVersion() + { + Assert.That(_migrationLoader.GetMigration(99999999), Is.Null); + } + + [Test] + public void ZeroIfNoMigrations() + { + _migrationLoader.MigrationsTypes.Clear(); + Assert.That(0, Is.EqualTo(_migrationLoader.LastVersion)); + } +} diff --git a/src/Migrator.Tests/MigratorTest.cs b/src/Migrator.Tests/MigratorTest.cs index 88f362e0..281b630e 100644 --- a/src/Migrator.Tests/MigratorTest.cs +++ b/src/Migrator.Tests/MigratorTest.cs @@ -1,225 +1,225 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using DotNetProjects.Migrator; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.Loggers; -using NSubstitute; -using NUnit.Framework; - -namespace Migrator.Tests; - -[TestFixture] -public class MigratorTest -{ - #region Setup/Teardown - - [SetUp] - public void SetUp() - { - SetUpCurrentVersion(0); - } - - #endregion - - private DotNetProjects.Migrator.Migrator _migrator; - private ITransformationProvider _provider; - - // Collections that contain the version that are called migrating up and down - private static readonly List _upCalled = new List(); - private static readonly List _downCalled = new List(); - - private void SetUpCurrentVersion(long version) - { - SetUpCurrentVersion(version, false); - } - - private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled) - { - SetUpCurrentVersion(version, assertRollbackIsCalled, true); - } - - private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled, bool includeBad) - { - var providerMock = _provider = Substitute.For(); - - var appliedVersions = new List(); - - for (long i = 1; i <= version; i++) - { - appliedVersions.Add(i); - } - - providerMock.AppliedMigrations.Returns(appliedVersions); - providerMock.Logger.Returns(new Logger(false)); - - _migrator = new DotNetProjects.Migrator.Migrator((ITransformationProvider)providerMock, Assembly.GetExecutingAssembly(), false); - - _migrator.MigrationsTypes.Clear(); - _upCalled.Clear(); - _downCalled.Clear(); - - _migrator.MigrationsTypes.Add(typeof(FirstMigration)); - _migrator.MigrationsTypes.Add(typeof(SecondMigration)); - _migrator.MigrationsTypes.Add(typeof(ThirdMigration)); - _migrator.MigrationsTypes.Add(typeof(ForthMigration)); - _migrator.MigrationsTypes.Add(typeof(SixthMigration)); - - if (includeBad) - { - _migrator.MigrationsTypes.Add(typeof(BadMigration)); - } - } - - public class AbstractTestMigration : Migration - { - public override void Up() - { - _upCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); - } - - public override void Down() - { - _downCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); - } - } - - [Migration(1, Ignore = true)] - public class FirstMigration : AbstractTestMigration - { - } - - [Migration(2, Ignore = true)] - public class SecondMigration : AbstractTestMigration - { - } - - [Migration(3, Ignore = true)] - public class ThirdMigration : AbstractTestMigration - { - } - - [Migration(4, Ignore = true)] - public class ForthMigration : AbstractTestMigration - { - } - - [Migration(5, Ignore = true)] - public class BadMigration : AbstractTestMigration - { - public override void Up() - { - throw new Exception("oh uh!"); - } - - public override void Down() - { - throw new Exception("oh uh!"); - } - } - - [Migration(6, Ignore = true)] - public class SixthMigration : AbstractTestMigration - { - } - - [Migration(7)] - public class NonIgnoredMigration : AbstractTestMigration - { - } - - [Test] - public void MigrateBackward() - { - SetUpCurrentVersion(3); - _migrator.MigrateTo(1); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(2, Is.EqualTo(_downCalled.Count)); - - Assert.That(3, Is.EqualTo(_downCalled[0])); - Assert.That(2, Is.EqualTo(_downCalled[1])); - } - - [Test] - public void MigrateDownwardWithRollback() - { - SetUpCurrentVersion(6, true); - - Assert.Throws(() => _migrator.MigrateTo(3)); - _provider.Received(1).Rollback(); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(1, Is.EqualTo(_downCalled.Count)); - - Assert.That(6, Is.EqualTo(_downCalled[0])); - } - - [Test] - public void MigrateToCurrentVersion() - { - SetUpCurrentVersion(3); - - _migrator.MigrateTo(3); - - Assert.That(0, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - } - - [Test] - public void MigrateToLastVersion() - { - SetUpCurrentVersion(3, false, false); - - _migrator.MigrateToLastVersion(); - - Assert.That(2, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - } - - [Test] - public void MigrateUpward() - { - SetUpCurrentVersion(1); - _migrator.MigrateTo(3); - - Assert.That(2, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(2, Is.EqualTo(_upCalled[0])); - Assert.That(3, Is.EqualTo(_upCalled[1])); - } - - [Test] - public void MigrateUpwardFrom0() - { - _migrator.MigrateTo(3); - - Assert.That(3, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(1, Is.EqualTo(_upCalled[0])); - Assert.That(2, Is.EqualTo(_upCalled[1])); - Assert.That(3, Is.EqualTo(_upCalled[2])); - } - - [Test] - public void MigrateUpwardWithRollback() - { - SetUpCurrentVersion(3, true); - - Assert.Throws(() => _migrator.MigrateTo(6)); - _provider.Received(1).Rollback(); - - Assert.That(1, Is.EqualTo(_upCalled.Count)); - Assert.That(0, Is.EqualTo(_downCalled.Count)); - - Assert.That(4, Is.EqualTo(_upCalled[0])); - } - - [Test] - public void ToHumanName() - { - Assert.That("Create a table", Is.EqualTo(StringUtils.ToHumanName("CreateATable"))); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture] +public class MigratorTest +{ + #region Setup/Teardown + + [SetUp] + public void SetUp() + { + SetUpCurrentVersion(0); + } + + #endregion + + private DotNetProjects.Migrator.Migrator _migrator; + private ITransformationProvider _provider; + + // Collections that contain the version that are called migrating up and down + private static readonly List _upCalled = new List(); + private static readonly List _downCalled = new List(); + + private void SetUpCurrentVersion(long version) + { + SetUpCurrentVersion(version, false); + } + + private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled) + { + SetUpCurrentVersion(version, assertRollbackIsCalled, true); + } + + private void SetUpCurrentVersion(long version, bool assertRollbackIsCalled, bool includeBad) + { + var providerMock = _provider = Substitute.For(); + + var appliedVersions = new List(); + + for (long i = 1; i <= version; i++) + { + appliedVersions.Add(i); + } + + providerMock.AppliedMigrations.Returns(appliedVersions); + providerMock.Logger.Returns(new Logger(false)); + + _migrator = new DotNetProjects.Migrator.Migrator((ITransformationProvider)providerMock, Assembly.GetExecutingAssembly(), false); + + _migrator.MigrationsTypes.Clear(); + _upCalled.Clear(); + _downCalled.Clear(); + + _migrator.MigrationsTypes.Add(typeof(FirstMigration)); + _migrator.MigrationsTypes.Add(typeof(SecondMigration)); + _migrator.MigrationsTypes.Add(typeof(ThirdMigration)); + _migrator.MigrationsTypes.Add(typeof(ForthMigration)); + _migrator.MigrationsTypes.Add(typeof(SixthMigration)); + + if (includeBad) + { + _migrator.MigrationsTypes.Add(typeof(BadMigration)); + } + } + + public class AbstractTestMigration : Migration + { + public override void Up() + { + _upCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); + } + + public override void Down() + { + _downCalled.Add(MigrationLoader.GetMigrationVersion(GetType())); + } + } + + [Migration(1, Ignore = true)] + public class FirstMigration : AbstractTestMigration + { + } + + [Migration(2, Ignore = true)] + public class SecondMigration : AbstractTestMigration + { + } + + [Migration(3, Ignore = true)] + public class ThirdMigration : AbstractTestMigration + { + } + + [Migration(4, Ignore = true)] + public class ForthMigration : AbstractTestMigration + { + } + + [Migration(5, Ignore = true)] + public class BadMigration : AbstractTestMigration + { + public override void Up() + { + throw new Exception("oh uh!"); + } + + public override void Down() + { + throw new Exception("oh uh!"); + } + } + + [Migration(6, Ignore = true)] + public class SixthMigration : AbstractTestMigration + { + } + + [Migration(7)] + public class NonIgnoredMigration : AbstractTestMigration + { + } + + [Test] + public void MigrateBackward() + { + SetUpCurrentVersion(3); + _migrator.MigrateTo(1); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(2, Is.EqualTo(_downCalled.Count)); + + Assert.That(3, Is.EqualTo(_downCalled[0])); + Assert.That(2, Is.EqualTo(_downCalled[1])); + } + + [Test] + public void MigrateDownwardWithRollback() + { + SetUpCurrentVersion(6, true); + + Assert.Throws(() => _migrator.MigrateTo(3)); + _provider.Received(1).Rollback(); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(1, Is.EqualTo(_downCalled.Count)); + + Assert.That(6, Is.EqualTo(_downCalled[0])); + } + + [Test] + public void MigrateToCurrentVersion() + { + SetUpCurrentVersion(3); + + _migrator.MigrateTo(3); + + Assert.That(0, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + } + + [Test] + public void MigrateToLastVersion() + { + SetUpCurrentVersion(3, false, false); + + _migrator.MigrateToLastVersion(); + + Assert.That(2, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + } + + [Test] + public void MigrateUpward() + { + SetUpCurrentVersion(1); + _migrator.MigrateTo(3); + + Assert.That(2, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(2, Is.EqualTo(_upCalled[0])); + Assert.That(3, Is.EqualTo(_upCalled[1])); + } + + [Test] + public void MigrateUpwardFrom0() + { + _migrator.MigrateTo(3); + + Assert.That(3, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(1, Is.EqualTo(_upCalled[0])); + Assert.That(2, Is.EqualTo(_upCalled[1])); + Assert.That(3, Is.EqualTo(_upCalled[2])); + } + + [Test] + public void MigrateUpwardWithRollback() + { + SetUpCurrentVersion(3, true); + + Assert.Throws(() => _migrator.MigrateTo(6)); + _provider.Received(1).Rollback(); + + Assert.That(1, Is.EqualTo(_upCalled.Count)); + Assert.That(0, Is.EqualTo(_downCalled.Count)); + + Assert.That(4, Is.EqualTo(_upCalled[0])); + } + + [Test] + public void ToHumanName() + { + Assert.That("Create a table", Is.EqualTo(StringUtils.ToHumanName("CreateATable"))); + } +} diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index 2b50360e..40712dbb 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -1,81 +1,81 @@ -using System.Data; -using System; -using DotNetProjects.Migrator.Providers.Impl.Oracle; -using DotNetProjects.Migrator.Framework; -using Migrator.Tests.Providers.OracleProvider.Base; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.OracleProvider; - -[TestFixture] -[Category("Oracle")] -public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase -{ - [Test] - public void LegacyForeignKeyOverloadHonorsCascadeDelete() - { - Provider.AddTable("CascadeParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - Provider.AddTable("CascadeChild", new Column("ParentId", DbType.Int32)); - Provider.AddForeignKey("CascadeForeignKey", "CascadeChild", new[] { "ParentId" }, "CascadeParent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); - Provider.Insert("CascadeParent", new[] { "Id" }, new object[] { 1 }); - Provider.Insert("CascadeChild", new[] { "ParentId" }, new object[] { 1 }); - Provider.ExecuteNonQuery("DELETE FROM CascadeParent"); - Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM CascadeChild")), Is.Zero); - } - - [Test] - public void RemovingTableDoesNotGuessOwnershipOfLegacyNamedSequence() - { - Provider.AddTable("UnownedSequenceTable", new Column("Id", DbType.Int32)); - Provider.ExecuteNonQuery("CREATE SEQUENCE UnownedSequenceTable_SEQUENCE"); - try - { - Provider.RemoveTable("UnownedSequenceTable"); - Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='UNOWNEDSEQUENCETABLE_SEQUENCE'")), Is.EqualTo(1)); - } - finally { Provider.ExecuteNonQuery("DROP SEQUENCE UnownedSequenceTable_SEQUENCE"); } - } - - [Test] - public void ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger() - { - Provider.AddTable("LegacyOwned", new Column("Id", DbType.Int32)); - Provider.ExecuteNonQuery("CREATE SEQUENCE LegacyOwned_SEQUENCE"); - Provider.ExecuteNonQuery("CREATE TRIGGER LegacyOwned_TRIGGER BEFORE INSERT ON LegacyOwned FOR EACH ROW BEGIN SELECT LegacyOwned_SEQUENCE.NEXTVAL INTO :new.Id FROM dual; END;"); - ((OracleTransformationProvider)Provider).RemoveTableWithOwnedSequences("LegacyOwned", "LegacyOwned_SEQUENCE"); - Assert.That(Provider.TableExists("LegacyOwned"), Is.False); - Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='LEGACYOWNED_SEQUENCE'")), Is.Zero); - Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_TRIGGERS WHERE TRIGGER_NAME='LEGACYOWNED_TRIGGER'")), Is.Zero); - } - - [Test] - public void TableExists_TableExists_Returns() - { - // Arrange - const string testTableName = "MyDefaultTestTable"; - const string propertyName1 = "Color1"; - - Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32) - ); - - // Act - var tableExists = Provider.TableExists(testTableName); - - // Assert - Assert.That(tableExists, Is.True); - } - - [Test] - public void TableExists_TableDoesNotExist_ReturnsFalse() - { - // Arrange - const string myTableName = "MyTable"; - - // Act - var tableExists = Provider.TableExists(myTableName); - - // Assert - Assert.That(tableExists, Is.False); - } -} +using System.Data; +using System; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Framework; +using Migrator.Tests.Providers.OracleProvider.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.OracleProvider; + +[TestFixture] +[Category("Oracle")] +public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase +{ + [Test] + public void LegacyForeignKeyOverloadHonorsCascadeDelete() + { + Provider.AddTable("CascadeParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable("CascadeChild", new Column("ParentId", DbType.Int32)); + Provider.AddForeignKey("CascadeForeignKey", "CascadeChild", new[] { "ParentId" }, "CascadeParent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); + Provider.Insert("CascadeParent", new[] { "Id" }, new object[] { 1 }); + Provider.Insert("CascadeChild", new[] { "ParentId" }, new object[] { 1 }); + Provider.ExecuteNonQuery("DELETE FROM CascadeParent"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM CascadeChild")), Is.Zero); + } + + [Test] + public void RemovingTableDoesNotGuessOwnershipOfLegacyNamedSequence() + { + Provider.AddTable("UnownedSequenceTable", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE UnownedSequenceTable_SEQUENCE"); + try + { + Provider.RemoveTable("UnownedSequenceTable"); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='UNOWNEDSEQUENCETABLE_SEQUENCE'")), Is.EqualTo(1)); + } + finally { Provider.ExecuteNonQuery("DROP SEQUENCE UnownedSequenceTable_SEQUENCE"); } + } + + [Test] + public void ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger() + { + Provider.AddTable("LegacyOwned", new Column("Id", DbType.Int32)); + Provider.ExecuteNonQuery("CREATE SEQUENCE LegacyOwned_SEQUENCE"); + Provider.ExecuteNonQuery("CREATE TRIGGER LegacyOwned_TRIGGER BEFORE INSERT ON LegacyOwned FOR EACH ROW BEGIN SELECT LegacyOwned_SEQUENCE.NEXTVAL INTO :new.Id FROM dual; END;"); + ((OracleTransformationProvider)Provider).RemoveTableWithOwnedSequences("LegacyOwned", "LegacyOwned_SEQUENCE"); + Assert.That(Provider.TableExists("LegacyOwned"), Is.False); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_SEQUENCES WHERE SEQUENCE_NAME='LEGACYOWNED_SEQUENCE'")), Is.Zero); + Assert.That(Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM USER_TRIGGERS WHERE TRIGGER_NAME='LEGACYOWNED_TRIGGER'")), Is.Zero); + } + + [Test] + public void TableExists_TableExists_Returns() + { + // Arrange + const string testTableName = "MyDefaultTestTable"; + const string propertyName1 = "Color1"; + + Provider.AddTable(testTableName, + new Column(propertyName1, DbType.Int32) + ); + + // Act + var tableExists = Provider.TableExists(testTableName); + + // Assert + Assert.That(tableExists, Is.True); + } + + [Test] + public void TableExists_TableDoesNotExist_ReturnsFalse() + { + // Arrange + const string myTableName = "MyTable"; + + // Act + var tableExists = Provider.TableExists(myTableName); + + // Assert + Assert.That(tableExists, Is.False); + } +} diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs index 477c5436..bd535a3f 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs @@ -1,123 +1,123 @@ -using System.Data; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers.Impl.SQLite; -using Migrator.Tests.Providers.SQLite.Base; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.SQLite; - -[TestFixture] -[Category("SQLite")] -public class SQLiteTransformationProvider_AddForeignKeyTests : SQLiteTransformationProviderTestBase -{ - [TestCase(ForeignKeyConstraintType.Cascade, "CASCADE")] - [TestCase(ForeignKeyConstraintType.SetNull, "SET NULL")] - [TestCase(ForeignKeyConstraintType.SetDefault, "SET DEFAULT")] - [TestCase(ForeignKeyConstraintType.Restrict, "RESTRICT")] - [TestCase(ForeignKeyConstraintType.NoAction, "NO ACTION")] - public void AddForeignKey(ForeignKeyConstraintType constraint, string expectedAction) - { - // Arrange - AddTableWithPrimaryKey(); - Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); - Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); - - // Act - Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: constraint); - - // Assert - var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); - var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); - - Assert.That(foreignKeyConstraints.Single().Name, Is.EqualTo("FKName")); - Assert.That(foreignKeyConstraints.Single().ChildTable, Is.EqualTo("TestTwo")); - Assert.That(foreignKeyConstraints.Single().ParentTable, Is.EqualTo("Test")); - Assert.That(foreignKeyConstraints.Single().ChildColumns.Single(), Is.EqualTo("TestId")); - Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("Id")); - - Assert.That(foreignKeyConstraints.Single().OnDelete, Is.EqualTo(expectedAction)); - var expectedClause = constraint == ForeignKeyConstraintType.NoAction ? "" : $" ON DELETE {expectedAction}"; - Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); - Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); - - // Reading and rebuilding an existing foreign key must retain its action. - Provider.RenameColumn("TestTwo", "TestId", "ParentId"); - Assert.That(Provider.GetForeignKeyConstraints("TestTwo").Single().OnDelete, Is.EqualTo(expectedAction)); - - var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); - Assert.That(result, Is.True); - } - - [Test] - public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn() - { - // Arrange - AddTableWithPrimaryKey(); - Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); - Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); - - // Act - Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: ForeignKeyConstraintType.Cascade); - - // Rename column in parent - Provider.RenameColumn("Test", "Id", "IdNew"); - - // Assert - var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); - var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); - - Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); - Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); - Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("IdNew")); - - var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); - Assert.That(result, Is.True); - } - - [Test] - public void AddForeignKey_3_Success() - { - Provider.AddTable("Task", - new Column(name: "BinId", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), - new Column(name: "EstimatedPickTime", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "Item", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Order", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) - ); - - Provider.AddTable("TaskGroup", - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), - new Column(name: "Id", type: DbType.Int32) - ); - - // TODO CK add more columns. - Provider.AddForeignKey(name: "FK_Task_TaskGroup", childTable: "Task", childColumn: "TaskGroupId", parentTable: "TaskGroup", parentColumn: "Id"); - Assert.That(Provider.GetForeignKeyConstraints("Task").Single().OnDelete, Is.EqualTo("NO ACTION")); - Assert.That(((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Task"), Does.Not.Contain("ON DELETE")); - } - - [Test] - public void AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren() - { - // Enable enforcement before any transaction; SQLite ignores PRAGMA changes inside one. - using var connection = new System.Data.SQLite.SQLiteConnection("Data Source=:memory:;Foreign Keys=True"); - connection.Open(); - using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); - Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); - - provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - provider.AddTable("Child", new Column("ParentId", DbType.Int32)); - provider.ExecuteNonQuery("INSERT INTO Parent (Id) VALUES (1), (2)"); - provider.ExecuteNonQuery("INSERT INTO Child (ParentId) VALUES (1), (1), (2)"); - provider.AddForeignKey("FK_Child_Parent", "Child", "ParentId", "Parent", "Id", ForeignKeyConstraintType.Cascade); - - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.EqualTo(3)); - provider.ExecuteNonQuery("DELETE FROM Parent WHERE Id = 1"); - - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 1")), Is.Zero); - Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 2")), Is.EqualTo(1)); - } -} +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Migrator.Tests.Providers.SQLite.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_AddForeignKeyTests : SQLiteTransformationProviderTestBase +{ + [TestCase(ForeignKeyConstraintType.Cascade, "CASCADE")] + [TestCase(ForeignKeyConstraintType.SetNull, "SET NULL")] + [TestCase(ForeignKeyConstraintType.SetDefault, "SET DEFAULT")] + [TestCase(ForeignKeyConstraintType.Restrict, "RESTRICT")] + [TestCase(ForeignKeyConstraintType.NoAction, "NO ACTION")] + public void AddForeignKey(ForeignKeyConstraintType constraint, string expectedAction) + { + // Arrange + AddTableWithPrimaryKey(); + Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); + Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); + + // Act + Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: constraint); + + // Assert + var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); + var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); + + Assert.That(foreignKeyConstraints.Single().Name, Is.EqualTo("FKName")); + Assert.That(foreignKeyConstraints.Single().ChildTable, Is.EqualTo("TestTwo")); + Assert.That(foreignKeyConstraints.Single().ParentTable, Is.EqualTo("Test")); + Assert.That(foreignKeyConstraints.Single().ChildColumns.Single(), Is.EqualTo("TestId")); + Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("Id")); + + Assert.That(foreignKeyConstraints.Single().OnDelete, Is.EqualTo(expectedAction)); + var expectedClause = constraint == ForeignKeyConstraintType.NoAction ? "" : $" ON DELETE {expectedAction}"; + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain($", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(Id){expectedClause})")); + + // Reading and rebuilding an existing foreign key must retain its action. + Provider.RenameColumn("TestTwo", "TestId", "ParentId"); + Assert.That(Provider.GetForeignKeyConstraints("TestTwo").Single().OnDelete, Is.EqualTo(expectedAction)); + + var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); + Assert.That(result, Is.True); + } + + [Test] + public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn() + { + // Arrange + AddTableWithPrimaryKey(); + Provider.ExecuteNonQuery("INSERT INTO Test (Id, name) VALUES (1, 'my name')"); + Provider.ExecuteNonQuery("INSERT INTO TestTwo (TestId) VALUES (1)"); + + // Act + Provider.AddForeignKey(name: "FKName", childTable: "TestTwo", childColumn: "TestId", parentTable: "Test", parentColumn: "Id", constraint: ForeignKeyConstraintType.Cascade); + + // Rename column in parent + Provider.RenameColumn("Test", "Id", "IdNew"); + + // Assert + var foreignKeyConstraints = ((SQLiteTransformationProvider)Provider).GetForeignKeyConstraints("TestTwo"); + var tableSQLCreateScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("TestTwo"); + + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain("CREATE TABLE TestTwo")); + Assert.That(tableSQLCreateScript.Replace("\"", ""), Does.Contain(", CONSTRAINT FKName FOREIGN KEY (TestId) REFERENCES Test(IdNew) ON DELETE CASCADE)")); + Assert.That(foreignKeyConstraints.Single().ParentColumns.Single(), Is.EqualTo("IdNew")); + + var result = ((SQLiteTransformationProvider)Provider).CheckForeignKeyIntegrity(); + Assert.That(result, Is.True); + } + + [Test] + public void AddForeignKey_3_Success() + { + Provider.AddTable("Task", + new Column(name: "BinId", type: DbType.Int32, property: ColumnProperty.NotNull), + new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), + new Column(name: "EstimatedPickTime", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.NotNull), + new Column(name: "Item", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "Order", type: DbType.Int32, property: ColumnProperty.Null), + new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) + ); + + Provider.AddTable("TaskGroup", + new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), + new Column(name: "Id", type: DbType.Int32) + ); + + // TODO CK add more columns. + Provider.AddForeignKey(name: "FK_Task_TaskGroup", childTable: "Task", childColumn: "TaskGroupId", parentTable: "TaskGroup", parentColumn: "Id"); + Assert.That(Provider.GetForeignKeyConstraints("Task").Single().OnDelete, Is.EqualTo("NO ACTION")); + Assert.That(((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Task"), Does.Not.Contain("ON DELETE")); + } + + [Test] + public void AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren() + { + // Enable enforcement before any transaction; SQLite ignores PRAGMA changes inside one. + using var connection = new System.Data.SQLite.SQLiteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + + provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + provider.AddTable("Child", new Column("ParentId", DbType.Int32)); + provider.ExecuteNonQuery("INSERT INTO Parent (Id) VALUES (1), (2)"); + provider.ExecuteNonQuery("INSERT INTO Child (ParentId) VALUES (1), (1), (2)"); + provider.AddForeignKey("FK_Child_Parent", "Child", "ParentId", "Parent", "Id", ForeignKeyConstraintType.Cascade); + + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.EqualTo(3)); + provider.ExecuteNonQuery("DELETE FROM Parent WHERE Id = 1"); + + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 1")), Is.Zero); + Assert.That(System.Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE ParentId = 2")), Is.EqualTo(1)); + } +} diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs index 55bd48ba..6fcbb886 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs @@ -1,111 +1,111 @@ -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers.Impl.SQLite; -using Migrator.Tests.Providers.Generic; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.SQLite; - -[TestFixture] -[Category("SQLite")] -public class SQLiteTransformationProvider_GetColumnsTests : Generic_GetColumnsTestsBase -{ - [SetUp] - public async Task SetUpAsync() - { - await BeginSQLiteTransactionAsync(); - } - - [Test] - public void GetColumns_PrimaryAndUnique_ReturnsFalse() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo( - ColumnProperty.NotNull | - ColumnProperty.Identity | - ColumnProperty.Unique | - ColumnProperty.PrimaryKey)); - } - - [Test] - public void GetColumns_Primary_ColumnPropertyOk() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - Provider.GetColumns(tableName); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.NotNull | - ColumnProperty.PrimaryKeyWithIdentity)); - } - - [Test] - public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNotNull() - { - // Arrange - const string tableName = "GetColumnsTest"; - - Provider.AddTable(tableName, - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) - ); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - Assert.That(columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - } - - [Test] - public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() - { - // Arrange - const string tableName = "GetColumnsTest"; - const string column1Name = "Column1"; - const string column2Name = "Column2"; - const string constraintName = "ConstraintName"; - - Provider.AddTable(tableName, new Column(column1Name, DbType.Int32), new Column(column2Name, DbType.Int32)); - - Provider.AddUniqueConstraint(constraintName, tableName, column1Name, column2Name); - - // Act - var columns = Provider.GetColumns(tableName); - - // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - } - - [Test, Description("Add index. The index should be added and then being detected as index.")] - public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists() - { - // Arrange - const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Bla1", DbType.Int32), new Column("Bla2", DbType.Int32)); - Provider.AddIndex("IndexName", tableName, ["Bla1", "Bla2"]); - - // Act - var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); - - // Assert - Assert.That(sqliteInfo.Columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - Assert.That(sqliteInfo.Columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - Assert.That(sqliteInfo.Uniques, Is.Empty); - Assert.That(sqliteInfo.Indexes.Single().Unique, Is.False); - } -} +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Migrator.Tests.Providers.Generic; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.SQLite; + +[TestFixture] +[Category("SQLite")] +public class SQLiteTransformationProvider_GetColumnsTests : Generic_GetColumnsTestsBase +{ + [SetUp] + public async Task SetUpAsync() + { + await BeginSQLiteTransactionAsync(); + } + + [Test] + public void GetColumns_PrimaryAndUnique_ReturnsFalse() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo( + ColumnProperty.NotNull | + ColumnProperty.Identity | + ColumnProperty.Unique | + ColumnProperty.PrimaryKey)); + } + + [Test] + public void GetColumns_Primary_ColumnPropertyOk() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.GetColumns(tableName); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.NotNull | + ColumnProperty.PrimaryKeyWithIdentity)); + } + + [Test] + public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNotNull() + { + // Arrange + const string tableName = "GetColumnsTest"; + + Provider.AddTable(tableName, + new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), + new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) + ); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + Assert.That(columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + } + + [Test] + public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() + { + // Arrange + const string tableName = "GetColumnsTest"; + const string column1Name = "Column1"; + const string column2Name = "Column2"; + const string constraintName = "ConstraintName"; + + Provider.AddTable(tableName, new Column(column1Name, DbType.Int32), new Column(column2Name, DbType.Int32)); + + Provider.AddUniqueConstraint(constraintName, tableName, column1Name, column2Name); + + // Act + var columns = Provider.GetColumns(tableName); + + // Assert + Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + } + + [Test, Description("Add index. The index should be added and then being detected as index.")] + public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists() + { + // Arrange + const string tableName = "GetColumnsTest"; + Provider.AddTable(tableName, new Column("Bla1", DbType.Int32), new Column("Bla2", DbType.Int32)); + Provider.AddIndex("IndexName", tableName, ["Bla1", "Bla2"]); + + // Act + var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); + + // Assert + Assert.That(sqliteInfo.Columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(sqliteInfo.Columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(sqliteInfo.Uniques, Is.Empty); + Assert.That(sqliteInfo.Indexes.Single().Unique, Is.False); + } +} diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 522b7b36..4be11204 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -1,175 +1,175 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers; - -namespace DotNetProjects.Migrator; - -/// -/// Handles inspecting code to find all of the Migrations in assemblies and reading -/// other metadata such as the last revision, etc. -/// -public class MigrationLoader -{ - private readonly List _migrationsTypes = new List(); - private readonly ITransformationProvider _provider; - - public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) - { - _provider = provider; - AddMigrations(migrationAssembly); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) - { - _provider = provider; - _migrationsTypes.AddRange(migrationTypes); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - /// - /// Returns registered migration types. - /// - public virtual List MigrationsTypes - { - get { return _migrationsTypes; } - } - - /// - /// Returns the last version of the migrations. - /// - public virtual long LastVersion - { - get - { - if (_migrationsTypes.Count == 0) - { - return 0; - } - - return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); - } - } - - public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - _provider is not IMigrationHistory history || - t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); - - public virtual void AddMigrations(Assembly migrationAssembly) - { - if (migrationAssembly != null) - { - _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); - } - } - - /// - /// Check for duplicated version in migrations. - /// - /// CheckForDuplicatedVersion - public virtual void CheckForDuplicatedVersion() - { - var versions = new List(); - foreach (var t in SelectedTypes) - { - var version = GetMigrationVersion(t); - - if (versions.Contains(version)) - { - throw new DuplicatedVersionException(version); - } - - versions.Add(version); - } - } - - /// - /// Collect migrations in one Assembly. - /// - /// The Assembly to browse. - /// The migrations collection - public static List GetMigrationTypes(Assembly asm) - { - var migrations = new List(); - foreach (var t in asm.GetExportedTypes()) - { - - -#if NETSTANDARD - var attrib = t.GetTypeInfo().GetCustomAttribute(); - if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#else - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#endif - - - } - - migrations.Sort(new MigrationTypeComparer(true)); - return migrations; - } - - /// - /// Returns the version of the migration - /// MigrationAttribute. - /// - /// Migration type. - /// Version number sepcified in the attribute - public static long GetMigrationVersion(Type t) - { - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); - } - - public List GetAvailableMigrations() - { - _migrationsTypes.Sort(new MigrationTypeComparer(true)); - return SelectedTypes.Select(GetMigrationVersion).ToList(); - } - - public virtual IMigration GetMigration(long version) - { - foreach (var t in SelectedTypes) - { - if (GetMigrationVersion(t) == version) - { - var migration = CreateInstance(t); - migration.Database = _provider; - return migration; - } - } - - return null; - } - - public virtual IMigration CreateInstance(Type migrationType) - { - return (IMigration)Activator.CreateInstance(migrationType); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator; + +/// +/// Handles inspecting code to find all of the Migrations in assemblies and reading +/// other metadata such as the last revision, etc. +/// +public class MigrationLoader +{ + private readonly List _migrationsTypes = new List(); + private readonly ITransformationProvider _provider; + + public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) + { + _provider = provider; + AddMigrations(migrationAssembly); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) + { + _provider = provider; + _migrationsTypes.AddRange(migrationTypes); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + /// + /// Returns registered migration types. + /// + public virtual List MigrationsTypes + { + get { return _migrationsTypes; } + } + + /// + /// Returns the last version of the migrations. + /// + public virtual long LastVersion + { + get + { + if (_migrationsTypes.Count == 0) + { + return 0; + } + + return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); + } + } + + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => + _provider is not IMigrationHistory history || + t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); + + public virtual void AddMigrations(Assembly migrationAssembly) + { + if (migrationAssembly != null) + { + _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); + } + } + + /// + /// Check for duplicated version in migrations. + /// + /// CheckForDuplicatedVersion + public virtual void CheckForDuplicatedVersion() + { + var versions = new List(); + foreach (var t in SelectedTypes) + { + var version = GetMigrationVersion(t); + + if (versions.Contains(version)) + { + throw new DuplicatedVersionException(version); + } + + versions.Add(version); + } + } + + /// + /// Collect migrations in one Assembly. + /// + /// The Assembly to browse. + /// The migrations collection + public static List GetMigrationTypes(Assembly asm) + { + var migrations = new List(); + foreach (var t in asm.GetExportedTypes()) + { + + +#if NETSTANDARD + var attrib = t.GetTypeInfo().GetCustomAttribute(); + if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) + { + migrations.Add(t); + } +#else + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) + { + migrations.Add(t); + } +#endif + + + } + + migrations.Sort(new MigrationTypeComparer(true)); + return migrations; + } + + /// + /// Returns the version of the migration + /// MigrationAttribute. + /// + /// Migration type. + /// Version number sepcified in the attribute + public static long GetMigrationVersion(Type t) + { + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); + } + + public List GetAvailableMigrations() + { + _migrationsTypes.Sort(new MigrationTypeComparer(true)); + return SelectedTypes.Select(GetMigrationVersion).ToList(); + } + + public virtual IMigration GetMigration(long version) + { + foreach (var t in SelectedTypes) + { + if (GetMigrationVersion(t) == version) + { + var migration = CreateInstance(t); + migration.Database = _provider; + return migration; + } + } + + return null; + } + + public virtual IMigration CreateInstance(Type migrationType) + { + return (IMigration)Activator.CreateInstance(migrationType); + } +} From a09a74aa2415d1d36ce4f3e5a54903de9afa88d4 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:49:05 +0200 Subject: [PATCH 19/19] Resolve PostgreSQL metadata by relation and support native time round trips Replace public-only existence and column/constraint lookup with parameterized PostgreSQL relation resolution. Keep schema and catalog case exact, honor the connection search path, scope constraint lookup to the requested table, and order returned columns. Parse the native time-without-time-zone type and literal defaults. Add live regressions for same-named tables across schemas, quoted names containing apostrophes, search-path resolution and TimeSpan defaults/binding. Local solution build, 79 unit tests and 159 SQLite tests passed; PostgreSQL verification requires PR CI. --- ...ansformationProvider_MetadataScopeTests.cs | 52 ++++++++++++++ .../Data/PostgreSQLSystemDataLoader.cs | 31 +++++--- .../PostgreSQLTransformationProvider.cs | 72 ++++++++++++------- 3 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs new file mode 100644 index 00000000..4a0e1b03 --- /dev/null +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Migrator.Tests.Providers.PostgreSQL.Base; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.PostgreSQL; + +public class PostgreSQLTransformationProvider_MetadataScopeTests : PostgreSQLTransformationProviderTestBase +{ + [Test] + public void QualifiedMetadataDoesNotMixSameNamedTablesOrConstraints() + { + Provider.ExecuteNonQuery("CREATE SCHEMA metadata_a; CREATE SCHEMA metadata_b"); + Provider.ExecuteNonQuery("CREATE TABLE metadata_a.sample (id integer CONSTRAINT same_name UNIQUE); CREATE TABLE metadata_b.sample (value text)"); + Provider.ExecuteNonQuery("CREATE VIEW metadata_b.sample_view AS SELECT value FROM metadata_b.sample"); + Assert.That(Provider.TableExists("metadata_a.sample"), Is.True); + Assert.That(Provider.TableExists("metadata_b.missing"), Is.False); + Assert.That(Provider.ViewExists("metadata_b.sample_view"), Is.True); + Assert.That(Provider.TableExists("metadata_b.sample_view"), Is.False); + Assert.That(Provider.ColumnExists("metadata_a.sample", "id"), Is.True); + Assert.That(Provider.ColumnExists("metadata_b.sample", "id"), Is.False); + Assert.That(Provider.ConstraintExists("metadata_a.sample", "same_name"), Is.True); + Assert.That(Provider.ConstraintExists("metadata_b.sample", "same_name"), Is.False); + Assert.That(Provider.GetColumns("metadata_a.sample").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetColumns("metadata_b.sample").Single().MigratorDbType, Is.EqualTo(MigratorDbType.String)); + Provider.ExecuteNonQuery("SET LOCAL search_path TO metadata_b"); + Assert.That(Provider.GetColumns("sample").Single().Name, Is.EqualTo("value")); + } + + [Test] + public void QuotedCatalogNamesRemainExactAndAreParameterized() + { + Provider.ExecuteNonQuery("CREATE TABLE \"Meta'Table\" (id integer CONSTRAINT \"Key'Name\" UNIQUE)"); + Assert.That(Provider.TableExists("\"Meta'Table\""), Is.True); + Assert.That(Provider.ConstraintExists("\"Meta'Table\"", "Key'Name"), Is.True); + Assert.That(Provider.GetColumns("\"Meta'Table\"").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + } + + [Test] + public void NativeTimeRoundTripsThroughMetadataDefaultsAndParameters() + { + var value = new TimeSpan(0, 12, 34, 56, 789); + Provider.AddTable("NativeTimeRoundTrip", new Column("Value", DbType.Time, value)); + var column = Provider.GetColumns("NativeTimeRoundTrip").Single(); + Assert.That(column.MigratorDbType, Is.EqualTo(MigratorDbType.Time)); + Assert.That(column.DefaultValue, Is.EqualTo(value)); + Provider.Insert("NativeTimeRoundTrip", new[] { "Value" }, new object[] { value }); + Assert.That(Provider.ExecuteScalar("SELECT * FROM NativeTimeRoundTrip"), Is.EqualTo(value)); + } +} diff --git a/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs b/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs index a4cbfa0a..4090e668 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/Data/PostgreSQLSystemDataLoader.cs @@ -11,9 +11,7 @@ public class PostgreSQLSystemDataLoader(IPostgreSQLTransformationProvider postgr public List GetTableConstraints(string tableName, string schemaName = "public") { - var quotedTableName = _postgreSQLTransformationProvider.QuoteTableNameIfRequired(tableName); - - var sql = $@" + var sql = @" SELECT tc.TABLE_SCHEMA, tc.TABLE_NAME, @@ -26,13 +24,21 @@ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND tc.TABLE_NAME = kcu.TABLE_NAME WHERE - LOWER(tc.table_name) = '{quotedTableName.ToLowerInvariant()}' - AND tc.TABLE_SCHEMA = '{schemaName}' + tc.table_name = @table + AND tc.TABLE_SCHEMA = @schema "; List tableConstraints = []; using var cmd = _postgreSQLTransformationProvider.CreateCommand(); + var tableParameter = cmd.CreateParameter(); + tableParameter.ParameterName = "table"; + tableParameter.Value = tableName; + cmd.Parameters.Add(tableParameter); + var schemaParameter = cmd.CreateParameter(); + schemaParameter.ParameterName = "schema"; + schemaParameter.Value = schemaName; + cmd.Parameters.Add(schemaParameter); using var reader = _postgreSQLTransformationProvider.ExecuteQuery(cmd, sql); while (reader.Read()) @@ -60,7 +66,7 @@ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu public List GetColumnInfos(string tableName, string schemaName = "public") { - var sql = $@" + var sql = @" SELECT c.CHARACTER_MAXIMUM_LENGTH, c.COLUMN_DEFAULT, @@ -77,13 +83,22 @@ public List GetColumnInfos(string tableName, string schemaName = "pu c.TABLE_NAME FROM information_schema.columns c WHERE - LOWER(c.table_name) = '{tableName.ToLowerInvariant()}' AND - c.TABLE_SCHEMA = '{schemaName}' + c.table_name = @table AND + c.TABLE_SCHEMA = @schema + ORDER BY c.ORDINAL_POSITION "; List columns = []; using var cmd = _postgreSQLTransformationProvider.CreateCommand(); + var tableParameter = cmd.CreateParameter(); + tableParameter.ParameterName = "table"; + tableParameter.Value = tableName; + cmd.Parameters.Add(tableParameter); + var schemaParameter = cmd.CreateParameter(); + schemaParameter.ParameterName = "schema"; + schemaParameter.Value = schemaName; + cmd.Parameters.Add(schemaParameter); using var reader = _postgreSQLTransformationProvider.ExecuteQuery(cmd, sql); while (reader.Read()) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index b02fdedc..dd1714e3 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -60,10 +60,8 @@ public PostgreSQLTransformationProvider(Dialect dialect, IDbConnection connectio protected override string GetPrimaryKeyConstraintName(string table) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT conname FROM pg_constraint WHERE contype = 'p' AND conrelid = (SELECT oid FROM pg_class WHERE relname = lower('{0}'));", table)); - + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT conname FROM pg_constraint WHERE contype = 'p' AND conrelid = to_regclass(@relation)"); return reader.Read() ? reader.GetString(0) : null; } @@ -313,45 +311,59 @@ public override void RemoveTable(string name) ExecuteNonQuery(string.Format("DROP TABLE IF EXISTS {0} CASCADE", name)); } - public override bool ConstraintExists(string table, string name) + private IDbCommand MetadataCommand(string relation, string name = null) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = 'public' AND constraint_name = lower('{0}')", name)); + var command = CreateCommand(); + var parameter = command.CreateParameter(); + parameter.ParameterName = "relation"; + parameter.Value = QuoteTableNameIfRequired(relation); + command.Parameters.Add(parameter); + if (name != null) + { + parameter = command.CreateParameter(); + parameter.ParameterName = "name"; + parameter.Value = name; + command.Parameters.Add(parameter); + } + return command; + } + public override bool ConstraintExists(string table, string name) + { + using var command = MetadataCommand(table, name); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass(@relation) AND (conname = @name OR conname = lower(@name))"); return reader.Read(); } public override bool ColumnExists(string table, string column) { - if (!TableExists(table)) - { - return false; - } - - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = lower('{0}') AND (column_name = lower('{1}') OR column_name = '{1}')", table, column)); + using var command = MetadataCommand(table, column); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass(@relation) AND attnum > 0 AND NOT attisdropped AND (attname = @name OR attname = lower(@name))"); return reader.Read(); } public override bool TableExists(string table) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = lower('{0}')", table)); + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_class WHERE oid = to_regclass(@relation) AND relkind IN ('r', 'p', 'f')"); return reader.Read(); } public override bool ViewExists(string view) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT table_name FROM information_schema.views WHERE table_schema = 'public' AND table_name = lower('{0}')", view)); - + using var command = MetadataCommand(view); + using var reader = ExecuteQuery(command, "SELECT 1 FROM pg_class WHERE oid = to_regclass(@relation) AND relkind IN ('v', 'm')"); return reader.Read(); } + private (string Table, string Schema) ResolveRelation(string table) + { + using var command = MetadataCommand(table); + using var reader = ExecuteQuery(command, "SELECT c.relname, n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = to_regclass(@relation)"); + if (!reader.Read()) throw new MigrationException("Table does not exist: " + table); + return (reader.GetString(0), reader.GetString(1)); + } + public override List GetDatabases() { return ExecuteStringQuery("SELECT datname FROM pg_database WHERE datistemplate = false"); @@ -473,9 +485,10 @@ public override int GetColumnContentSize(string table, string columnName) public override Column[] GetColumns(string table) { - var columnInfos = _postgreSQLSystemDataLoader.GetColumnInfos(table, "public"); + var relation = ResolveRelation(table); + var columnInfos = _postgreSQLSystemDataLoader.GetColumnInfos(relation.Table, relation.Schema); var columns = new List(); - var tableConstraints = _postgreSQLSystemDataLoader.GetTableConstraints(table); + var tableConstraints = _postgreSQLSystemDataLoader.GetTableConstraints(relation.Table, relation.Schema); var uniqueColumns = tableConstraints.Where(c => c.ConstraintType == "UNIQUE") .GroupBy(c => new { c.TableSchema, c.ConstraintName }).Where(g => g.Count() == 1) .Select(g => g.Single().ColumnName).ToHashSet(StringComparer.Ordinal); @@ -562,7 +575,7 @@ public override Column[] GetColumns(string table) { dbType = MigratorDbType.Xml; } - else if (columnInfo.DataType == "time") + else if (columnInfo.DataType == "time" || columnInfo.DataType == "time without time zone") { dbType = MigratorDbType.Time; } @@ -647,6 +660,13 @@ public override Column[] GetColumns(string table) column.DefaultValue = double.Parse(columnInfo.ColumnDefault.ToString(), CultureInfo.InvariantCulture); } } + else if (column.MigratorDbType == MigratorDbType.Time) + { + var match = stripSingleQuoteRegEx.Match(columnInfo.ColumnDefault); + if (!match.Success || !TimeSpan.TryParse(match.Value, CultureInfo.InvariantCulture, out var time)) + throw new NotSupportedException("Cannot parse PostgreSQL time default: " + columnInfo.ColumnDefault); + column.DefaultValue = time; + } else if (column.MigratorDbType == MigratorDbType.Interval) { if (columnInfo.ColumnDefault.StartsWith("'"))