diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json index d510cf1b..6f5fed38 100644 --- a/docs/fluent-operation-coverage.json +++ b/docs/fluent-operation-coverage.json @@ -1,6 +1,7 @@ { "AddTable": "Create.Table(...).WithFields(...) / WithEngine(...)", - "AddColumn": "Create.Column(name).OnTable(table).OfType(...) / Create.Column(definition).OnTable(table)", + "AddColumn": "Create.Column(name).OnTable(table).OfType(...) / Create.Column(definition).OnTable(table); Database.AddColumn(table, column, primaryKey) for the combined operation", + "RemoveUniqueConstraint": "Database.RemoveUniqueConstraint(table, definition) for exact metadata-based selection, including unnamed SQLite constraints", "ChangeColumn": "Alter.Column(name).OnTable(table).OfType(...) / Alter.Column(definition).OnTable(table)", "AddPrimaryKey": "Create.PrimaryKey(name).OnTable(table).WithColumns(...)", "AddPrimaryKeyNonClustered": "Create.NonClusteredPrimaryKey(name).OnTable(table).WithColumns(...)", diff --git a/docs/migration-guide-12.1-to-13.md b/docs/migration-guide-12.1-to-13.md index 4cf37c92..70acdd45 100644 --- a/docs/migration-guide-12.1-to-13.md +++ b/docs/migration-guide-12.1-to-13.md @@ -261,24 +261,23 @@ default remains text; use a CLR `Guid` when authoring a GUID default. ### SQLite identity columns -SQLite requires the identity column and its single-column primary key in the -same table definition. Separate `AddColumn` and `AddPrimaryKey` calls create an -invalid intermediate definition. Use the SQLite provider's atomic rebuild API: +Use the provider-independent overload to add a column with an explicit primary key: ```csharp -var sqlite = (SQLiteTransformationProvider)Database; -var definition = sqlite.GetSQLiteTableInfo("Settings"); -definition.Columns.Add(new Column("Id", DbType.Int32) { IsIdentity = true }); -definition.ColumnMappings.Add(new MappingInfo { OldName = null, NewName = "Id" }); -definition.PrimaryKey = new PrimaryKeyConstraint("PK_Settings", "Id"); -sqlite.RecreateTable(definition); +Database.AddColumn("Settings", + new Column("Id", DbType.Int32) { IsIdentity = true }, + new PrimaryKeyConstraint("PK_Settings", "Id")); ``` -`SQLiteTransformationProvider` is in `DotNetProjects.Migrator.Providers.Impl.SQLite`; -`MappingInfo` is in its `Models` namespace. Existing rows receive generated IDs. -This example assumes the table has no existing primary key or dependent foreign -keys requiring a separate migration plan. +The key must have a nonempty name and valid, ordered column members. Existing primary keys are rejected rather than replaced, and caller-owned definitions are not mutated. SQLite adds both definitions in a single transactional table rebuild, preserving existing rows and generating IDs. MySQL/MariaDB add both in one ALTER statement because AUTO_INCREMENT must be indexed immediately. Other providers use their normal AddColumn/AddPrimaryKey operations and DDL transaction semantics; the overload does not promise cross-provider rollback if a later DDL statement fails. Existing columns in a composite key must already meet the provider's requirements. +`IsIdentity` alone still does not imply a primary key. Separate AddColumn/AddPrimaryKey calls cannot introduce an SQLite identity column. SQLite downgrade can use RemovePrimaryKey followed by RemoveColumn; the migration runner manages SQLite's foreign-key state. + +### Removing legacy unnamed unique constraints + +Use `Database.RemoveUniqueConstraint(table, constraint)` with a `UniqueConstraint` returned by `GetTableConstraints`. The operation matches both the declared name and ordered columns, and requires exactly one match. SQLite rebuilds internally to remove unnamed legacy constraints without discarding other unique/check constraints. Other providers remove the verified named constraint using their existing DDL implementation. Unknown or ambiguous selections fail before mutation. + +Custom ITransformationProvider implementations must implement these two new methods; implementations derived from TransformationProvider inherit the portable defaults. NoOpTransformationProvider supports both as no-ops. These are direct provider APIs; fluent callers can use Database for these combined operations. ## Identifier quoting and renamed tables Use `QuoteColumnNameIfRequired` for columns in authored SQL and diff --git a/src/Migrator.Tests/ColumnWithPrimaryKeySqlTests.cs b/src/Migrator.Tests/ColumnWithPrimaryKeySqlTests.cs new file mode 100644 index 00000000..3c075f2b --- /dev/null +++ b/src/Migrator.Tests/ColumnWithPrimaryKeySqlTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("Unit")] +public class ColumnWithPrimaryKeySqlTests +{ + private sealed class RecordingMySqlProvider() : MySqlTransformationProvider(new MysqlDialect(), (IDbConnection)null, null, null) + { + public List Statements { get; } = []; + public override bool TableExists(string table) => true; + public override Column[] GetColumns(string table) => [new Column("Value", DbType.String)]; + public override TableConstraint[] GetTableConstraints(string table) => []; + public override int ExecuteNonQuery(string sql) { Statements.Add(sql); return 0; } + } + + private sealed class RecordingSqlServerProvider() : SqlServerTransformationProvider(new SqlServerDialect(), (IDbConnection)null, null, null, null) + { + public List Statements { get; } = []; + public override bool TableExists(string table) => true; + public override Column[] GetColumns(string table) => [new Column("Value", DbType.String)]; + public override TableConstraint[] GetTableConstraints(string table) => []; + public override int ExecuteNonQuery(string sql) { Statements.Add(sql); return 0; } + } + + [Test] + public void MySqlAddsAutoIncrementAndKeyInOneStatement() + { + using var provider = new RecordingMySqlProvider(); + provider.AddColumn("Settings", new Column("Id", DbType.Int32) { IsIdentity = true }, new PrimaryKeyConstraint("PK_Settings", "Id")); + Assert.That(provider.Statements, Has.Count.EqualTo(1)); + Assert.That(provider.Statements[0], Does.Contain("AUTO_INCREMENT").IgnoreCase.And.Contain("PRIMARY KEY").IgnoreCase); + Assert.That(provider.Statements[0], Does.Contain(", ADD CONSTRAINT")); + } + + [Test] + public void SqlServerUsesItsNonclusteredPrimaryKeyImplementation() + { + using var provider = new RecordingSqlServerProvider(); + var column = new Column("Id", DbType.Int32) { IsIdentity = true }; + provider.AddColumn("Settings", column, new PrimaryKeyConstraint("PK_Settings", "Id") { NonClustered = true }); + Assert.That(provider.Statements, Has.Count.EqualTo(2)); + Assert.That(provider.Statements[0], Does.Contain("IDENTITY").IgnoreCase); + Assert.That(provider.Statements[1], Does.Contain("PRIMARY KEY NONCLUSTERED").IgnoreCase); + Assert.That(column.IsNullable, Is.True); + } +} diff --git a/src/Migrator.Tests/ColumnWithPrimaryKeyTests.cs b/src/Migrator.Tests/ColumnWithPrimaryKeyTests.cs new file mode 100644 index 00000000..2e3ca62a --- /dev/null +++ b/src/Migrator.Tests/ColumnWithPrimaryKeyTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; + +namespace Migrator.Tests; + +[Category("SQLite")] +[TestFixture(false)] +[TestFixture(true)] +public class ColumnWithPrimaryKeyTests(bool systemData) +{ + private IDbConnection connection; + private ITransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = systemData + ? new System.Data.SQLite.SQLiteConnection("Data Source=:memory:") + : new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:;Foreign Keys=False"); + connection.Open(); + provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Settings (Value TEXT, CONSTRAINT UQ_Value UNIQUE(Value), CHECK(length(Value)>0)); CREATE INDEX IX_Value ON Settings(Value); INSERT INTO Settings VALUES ('first'), ('second');"); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [Test] + public void AddsIdentityAndKeyTogetherAndSupportsPortableDowngrade() + { + var column = new Column("Id", DbType.Int32) { IsIdentity = true }; + provider.AddColumn("Settings", column, new PrimaryKeyConstraint("PK_Settings", "Id")); + Assert.That(column.IsNullable, Is.True, "The caller's definition must not be mutated."); + Assert.That(provider.ExecuteScalar("SELECT COUNT(DISTINCT Id) FROM Settings"), Is.EqualTo(2)); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().Name, Is.EqualTo("PK_Settings")); + provider.Insert("Settings", ["Value"], ["third"]); + Assert.That(provider.ExecuteScalar("SELECT Id FROM Settings WHERE Value='third'"), Is.EqualTo(3)); + provider.RemovePrimaryKey("Settings"); + provider.RemoveColumn("Settings", "Id"); + Assert.That(provider.GetColumns("Settings").Select(c => c.Name), Is.EqualTo(new[] { "Value" })); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Settings"), Is.EqualTo(3)); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().Name, Is.EqualTo("UQ_Value")); + Assert.That(provider.GetTableConstraints("Settings").OfType().Count(), Is.EqualTo(1)); + Assert.That(provider.GetIndexes("Settings").Any(index => index.Name == "IX_Value"), Is.True); + } + + [TestCase("Missing")] + [TestCase("Value")] + public void InvalidIdentityKeyLeavesOriginalSchemaAndData(string keyColumn) + { + Assert.Catch(() => provider.AddColumn("Settings", new Column("Id", DbType.Int32) { IsIdentity = true }, new PrimaryKeyConstraint("PK_Settings", keyColumn))); + Assert.That(provider.ColumnExists("Settings", "Id"), Is.False); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Settings"), Is.EqualTo(2)); + Assert.That(provider.GetTables(), Is.EqualTo(new[] { "Settings" })); + } + + [Test] + public void ExistingPrimaryKeyIsNotReplaced() + { + provider.AddPrimaryKey("PK_Old", "Settings", "Value"); + Assert.Catch(() => provider.AddColumn("Settings", new Column("Id", DbType.Int32) { IsIdentity = true }, new PrimaryKeyConstraint("PK_New", "Id"))); + Assert.That(provider.ColumnExists("Settings", "Id"), Is.False); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().Name, Is.EqualTo("PK_Old")); + } + + [TestCase(false)] + [TestCase(true)] + public void RemovesOnlyTheSelectedUniqueDefinition(bool named) + { + provider.ExecuteNonQuery($"CREATE TABLE Legacy (A TEXT, B TEXT, {(named ? "CONSTRAINT UQ_A " : "")}UNIQUE(A), UNIQUE(B), CHECK(length(A)>0)); INSERT INTO Legacy VALUES ('a','b');"); + var constraint = provider.GetTableConstraints("Legacy").OfType().Single(key => key.KeyColumns.SequenceEqual(new[] { "A" })); + provider.RemoveUniqueConstraint("Legacy", constraint); + Assert.That(provider.GetTableConstraints("Legacy").OfType().Single().KeyColumns, Is.EqualTo(new[] { "B" })); + Assert.That(provider.GetTableConstraints("Legacy").OfType().Count(), Is.EqualTo(1)); + provider.ExecuteNonQuery("INSERT INTO Legacy VALUES ('a','c')"); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Legacy"), Is.EqualTo(2)); + } + + [Test] + public void CompositeKeyKeepsOrderAndFailedBackfillRollsBack() + { + provider.AddColumn("Settings", new Column("Part", DbType.Int32) { DefaultValue = 1 }, new PrimaryKeyConstraint("PK_Settings", "Part", "Value")); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().KeyColumns, Is.EqualTo(new[] { "Part", "Value" })); + provider.RemovePrimaryKey("Settings"); + Assert.Catch(() => provider.AddColumn("Settings", new Column("Duplicate", DbType.String, 20) { DefaultValue = "same" }, new PrimaryKeyConstraint("PK_Duplicate", "Duplicate"))); + Assert.That(provider.ColumnExists("Settings", "Duplicate"), Is.False); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Settings"), Is.EqualTo(2)); + Assert.That(provider.GetTables(), Is.EqualTo(new[] { "Settings" })); + } + + [Test] + public void UnknownUniqueDefinitionDoesNotRemoveOtherConstraints() + { + Assert.Throws(() => provider.RemoveUniqueConstraint("Settings", new UniqueConstraint("UQ_Value", "Missing"))); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().Name, Is.EqualTo("UQ_Value")); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Settings"), Is.EqualTo(2)); + } +} diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index a1a43116..0e5fa3e5 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -99,6 +99,12 @@ public interface ITransformationProvider : IDisposable /// The name of the table that will get the new column /// An instance of a Column with the specified properties void AddColumn(string table, Column column); + + /// Add a column and an explicit primary key as one schema operation. SQLite rebuilds once; other providers use their normal DDL transaction semantics. + void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey); + + /// Remove the exact unique constraint returned by metadata, including unnamed SQLite constraints. + void RemoveUniqueConstraint(string table, UniqueConstraint constraint); /// /// Add a foreign key constraint diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 09cd7eed..9162e48a 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -13,6 +13,13 @@ namespace DotNetProjects.Migrator.Providers.Impl.Mysql; /// public class MySqlTransformationProvider : TransformationProvider { + public override void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) + { + var definition = PrepareColumnWithPrimaryKey(table, column, primaryKey); + // AUTO_INCREMENT must be indexed in the same ALTER statement, including on MariaDB. + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD COLUMN {_dialect.GetAndMapColumnProperties(definition).ColumnSql}, ADD {_dialect.GetTableConstraintSql(primaryKey)}"); + } + public MySqlTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, null, scope) // we ignore schemas for MySql (schema == database for MySql) { diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index c1b1f4fd..923f98bb 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -1015,6 +1015,36 @@ public override void AddColumn(string table, Column column) RecreateTable(sqliteInfo); } + public override void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(column); + ArgumentNullException.ThrowIfNull(primaryKey); + ArgumentException.ThrowIfNullOrWhiteSpace(primaryKey.Name); + var definition = GetSQLiteTableInfo(table); + if (definition.Columns.Any(existing => existing.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase))) + throw new MigrationException("Column already exists."); + if (definition.PrimaryKey != null) throw new MigrationException("The table already has a primary key."); + definition.Columns.Add(column.CopyDefinition()); + ValidateKeyColumns(primaryKey.Name, primaryKey.KeyColumns, definition.Columns.ToArray()); + definition.ColumnMappings.Add(new MappingInfo { NewName = column.Name }); + definition.PrimaryKey = new PrimaryKeyConstraint(primaryKey.Name, primaryKey.KeyColumns) { NonClustered = primaryKey.NonClustered }; + RecreateTable(definition); + } + + public override void RemoveUniqueConstraint(string table, UniqueConstraint constraint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(constraint); + var definition = GetSQLiteTableInfo(table); + var matches = definition.Uniques.Where(candidate => + string.Equals(candidate.Name, constraint.Name, StringComparison.OrdinalIgnoreCase) && + candidate.KeyColumns.SequenceEqual(constraint.KeyColumns, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (matches.Length != 1) throw new MigrationException("Unique constraint selection must match exactly one definition."); + definition.Uniques.Remove(matches[0]); + RecreateTable(definition); + } + public override void AddColumn(string table, string columnName, DbType type, int size) { var column = new Column(columnName, type, size); diff --git a/src/Migrator/Providers/NoOpTransformationProvider.cs b/src/Migrator/Providers/NoOpTransformationProvider.cs index b81592b2..670d004a 100644 --- a/src/Migrator/Providers/NoOpTransformationProvider.cs +++ b/src/Migrator/Providers/NoOpTransformationProvider.cs @@ -6,6 +6,7 @@ using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using Index = DotNetProjects.Migrator.Framework.Index; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; namespace DotNetProjects.Migrator.Providers; @@ -15,6 +16,8 @@ namespace DotNetProjects.Migrator.Providers; public class NoOpTransformationProvider : ITransformationProvider { public TableConstraint[] GetTableConstraints(string table) => []; + public void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) { } + public void RemoveUniqueConstraint(string table, UniqueConstraint constraint) { } public static readonly NoOpTransformationProvider Instance = new NoOpTransformationProvider(); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 8e0b36b0..a14e2e59 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1525,6 +1525,44 @@ public virtual void AddColumn(string table, Column column) AddColumn(table, _dialect.GetAndMapColumnProperties(column.CopyDefinition()).ColumnSql); } + public virtual void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) + { + var definition = PrepareColumnWithPrimaryKey(table, column, primaryKey); + AddColumn(table, definition); + if (primaryKey.NonClustered) AddPrimaryKeyNonClustered(primaryKey.Name, table, primaryKey.KeyColumns); + else AddPrimaryKey(primaryKey.Name, table, primaryKey.KeyColumns); + } + + protected Column PrepareColumnWithPrimaryKey(string table, Column column, PrimaryKeyConstraint primaryKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(column); + ArgumentNullException.ThrowIfNull(primaryKey); + ArgumentException.ThrowIfNullOrWhiteSpace(primaryKey.Name); + if (!TableExists(table)) throw new MigrationException("Table does not exist."); + var columns = GetColumns(table); + if (columns.Any(existing => existing.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase))) throw new MigrationException("Column already exists."); + if (GetTableConstraints(table).OfType().Any()) throw new MigrationException("The table already has a primary key."); + ValidateKeyColumns(primaryKey.Name, primaryKey.KeyColumns, columns.Append(column).ToArray()); + // Validate provider-specific key options before any DDL. + _dialect.GetTableConstraintSql(primaryKey); + var definition = column.CopyDefinition(); + if (primaryKey.KeyColumns.Contains(column.Name, StringComparer.OrdinalIgnoreCase)) definition.IsNullable = false; + return definition; + } + + public virtual void RemoveUniqueConstraint(string table, UniqueConstraint constraint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(constraint); + var matches = GetTableConstraints(table).OfType().Where(candidate => + string.Equals(candidate.Name, constraint.Name, StringComparison.OrdinalIgnoreCase) && + candidate.KeyColumns.SequenceEqual(constraint.KeyColumns, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (matches.Length != 1) throw new MigrationException("Unique constraint selection must match exactly one definition."); + if (string.IsNullOrWhiteSpace(matches[0].Name)) throw new NotSupportedException("This provider cannot remove an unnamed unique constraint."); + RemoveConstraint(table, matches[0].Name); + } + public virtual void GenerateForeignKey(string primaryTable, string refTable) { GenerateForeignKey(primaryTable, refTable, ForeignKeyConstraintType.NoAction);