diff --git a/docs/migration-guide-12.1-to-13.md b/docs/migration-guide-12.1-to-13.md index c19567c5..95ae2cf0 100644 --- a/docs/migration-guide-12.1-to-13.md +++ b/docs/migration-guide-12.1-to-13.md @@ -186,7 +186,54 @@ utf8mb4-compatible text columns and the listed engine versions. Other dialects r unmapped presets; custom dialects can override `ResolveCollation(CollationKind)`. Unsupported requests fail during SQL generation, before executing the table operation. SQLite never downgrades Unicode case-insensitivity to its ASCII-only NOCASE behavior. -SQLite rebuilds involving collated columns still fail before replacing the table. +SQLite rebuilds preserve declared column collations, including named custom +collations registered on the connection. `GetColumns` reports these names. +Changing a collation explicitly rebuilds the table; a resulting uniqueness +violation rolls back the change and preserves the original data. Index-level +`COLLATE` clauses remain unsupported for rebuilds and fail before replacing the table. + +## Consolidated migration history + +A consolidated baseline can use `Database.MigrationApplied(version, scope)` to +record versions whose schema it already includes. The runner rechecks the active +scope's history before each planned migration and skips versions now applied, +including their `AfterUp` callbacks. The same rule applies to downgrades when an +earlier `Down` removes another version from history. Recording the baseline's own +version does not insert it twice. History for another scope does not skip a step +in the current scope. Transaction rollback still applies to baseline schema and +history changes according to the selected transaction mode. + +## Adding SQLite identity to an existing table + +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: + +```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); +``` + +`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. + +## Identifier quoting and renamed tables + +Use `QuoteColumnNameIfRequired` for columns in authored SQL and +`QuoteTableNameIfRequired` for tables. A table name may acquire a schema prefix; +using that API for a column can produce an invalid reference such as `dbo.Color`. + +Renaming a table does not rename its explicitly named constraints or backing +indexes. On SQL Server and PostgreSQL, recreating the old table with the old +primary-key name can therefore collide with the renamed table's key. Give the +replacement table a distinct key name (for example `PK_Client_New`), or explicitly +rename the retained key using provider-specific SQL before reusing its name. For PostgreSQL, create an ICU nondeterministic collation explicitly (for example `CREATE COLLATION app_ci (provider=icu, locale='und-u-ks-level2', deterministic=false)`) diff --git a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs index 61668424..2466626c 100644 --- a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs +++ b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs @@ -15,6 +15,15 @@ namespace Migrator.Tests; public class IdentifierAndTimeRegressionTests { + [Test] + public void ColumnQuotingDoesNotInheritTheDefaultTableSchema() + { + using var connection = NSubstitute.Substitute.For(); + using var provider = new SqlServerTransformationProvider(new SqlServerDialect(), connection, "dbo", "default", null); + Assert.That(provider.QuoteColumnNameIfRequired("Color"), Is.EqualTo("[Color]")); + Assert.That(provider.QuoteTableNameIfRequired("Colors"), Is.EqualTo("[dbo].[Colors]")); + } + [TestCase(-51)] [TestCase(51)] public void SQLiteIntervalsKeepTheirSignAndDaysSeparateFromTimeOfDay(int hours) diff --git a/src/Migrator.Tests/MigrationHistoryRegressionTests.cs b/src/Migrator.Tests/MigrationHistoryRegressionTests.cs new file mode 100644 index 00000000..82fa6334 --- /dev/null +++ b/src/Migrator.Tests/MigrationHistoryRegressionTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class MigrationHistoryRegressionTests +{ + [TestCase(MigrationTransactionMode.PerMigration)] + [TestCase(MigrationTransactionMode.WholeSession)] + [TestCase(MigrationTransactionMode.None)] + public void ConsolidatedBaselineSkipsVersionsItMarksApplied(MigrationTransactionMode mode) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "history-test"); + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(Baseline), typeof(Obsolete), typeof(Next)); + runner.Options.TransactionMode = mode; + + runner.MigrateToLastVersion(); + + Assert.That(provider.AppliedMigrations.OrderBy(v => v), Is.EqualTo(new long[] { 1, 2, 3 })); + Assert.That(provider.ColumnExists("CurrentSchema", "NewColumn"), Is.True); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM CurrentSchema"), Is.EqualTo(1)); + runner.MigrateToLastVersion(); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM CurrentSchema"), Is.EqualTo(1)); + } + + [Test] + public void HistoryWrittenForAnotherScopeDoesNotSkipTheCurrentMigration() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "history-test"); + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(OtherScopeBaseline), typeof(Obsolete)); + Assert.That(Assert.Throws(runner.MigrateToLastVersion).Message, Is.EqualTo("Obsolete migration executed.")); + Assert.That(provider.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + Assert.That(provider.IsMigrationApplied(2, "other"), Is.True); + } + + [TestCase(MigrationTransactionMode.PerMigration)] + [TestCase(MigrationTransactionMode.WholeSession)] + public void FailedBaselineRollsBackItsSchemaAndHistory(MigrationTransactionMode mode) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "history-test"); + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(FailedBaseline), typeof(Obsolete)); + runner.Options.TransactionMode = mode; + Assert.That(Assert.Throws(runner.MigrateToLastVersion).Message, Is.EqualTo("Baseline failed.")); + Assert.That(provider.AppliedMigrations, Is.Empty); + Assert.That(provider.TableExists("CurrentSchema"), Is.False); + } + + [TestCase(MigrationTransactionMode.PerMigration)] + [TestCase(MigrationTransactionMode.WholeSession)] + public void DowngradeSkipsVersionsAlreadyRemovedByAnEarlierStep(MigrationTransactionMode mode) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "history-test"); + provider.MigrationApplied(1, "history-test"); + provider.MigrationApplied(2, "history-test"); + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(ObsoleteDown), typeof(ConsolidatedDown)); + runner.Options.TransactionMode = mode; + runner.RollbackTo(0); + Assert.That(provider.AppliedMigrations, Is.Empty); + } + + [Test] + public void BaselineMayIncludeItselfInItsHistoryRange() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "history-test"); + new DotNetProjects.Migrator.Migrator(provider, false, typeof(SelfRecordingBaseline), typeof(Obsolete)).MigrateToLastVersion(); + Assert.That(provider.AppliedMigrations.OrderBy(v => v), Is.EqualTo(new long[] { 1, 2 })); + } + + [Migration(1)] + internal class Baseline : Migration + { + public override void Up() + { + Database.AddTable("CurrentSchema", new Column("Id", DbType.Int32)); + Database.Insert("CurrentSchema", ["Id"], [1]); + Database.MigrationApplied(2, "history-test"); + } + public override void Down() => Database.RemoveTable("CurrentSchema"); + } + + [Migration(1)] + internal class FailedBaseline : Baseline + { + public override void Up() { base.Up(); throw new InvalidOperationException("Baseline failed."); } + } + + [Migration(1)] + internal class OtherScopeBaseline : Migration + { + public override void Up() => Database.MigrationApplied(2, "other"); + public override void Down() { } + } + + [Migration(1)] + internal class SelfRecordingBaseline : Baseline + { + public override void Up() { base.Up(); Database.MigrationApplied(1, "history-test"); } + } + + [Migration(2)] + internal class Obsolete : Migration + { + public override void Up() => throw new InvalidOperationException("Obsolete migration executed."); + public override void AfterUp() => throw new InvalidOperationException("Obsolete callback executed."); + public override void Down() { } + } + + [Migration(3)] + internal class Next : Migration + { + public override void Up() => Database.AddColumn("CurrentSchema", new Column("NewColumn", DbType.Int32)); + public override void Down() => Database.RemoveColumn("CurrentSchema", "NewColumn"); + } + + [Migration(1)] + internal class ObsoleteDown : Migration + { + public override void Up() { } + public override void Down() => throw new InvalidOperationException("Already reverted migration executed."); + public override void AfterDown() => throw new InvalidOperationException("Already reverted callback executed."); + } + + [Migration(2)] + internal class ConsolidatedDown : Migration + { + public override void Up() { } + public override void Down() => Database.MigrationUnApplied(1, "history-test"); + } +} diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs index 536380bb..5cce5a86 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs @@ -8,6 +8,23 @@ namespace Migrator.Tests.Providers.Generic; [TestFixture] public abstract class Generic_ConstraintExistsBase : TransformationProviderBase { + protected void RecreatingARenamedTableUsesADistinctPrimaryKeyName() + { + Provider.AddTable("Clients", new Column("Id", DbType.Int32), + new PrimaryKeyConstraint("PK_Clients", "Id")); + Provider.Insert("Clients", ["Id"], [1]); + Provider.RenameTable("Clients", "Tenants"); + Provider.AddTable("Clients", new Column("Id", DbType.Int32), + new PrimaryKeyConstraint("PK_Clients_New", "Id")); + Provider.Insert("Clients", ["Id"], [2]); + Assert.That(Provider.PrimaryKeyExists("Tenants", "PK_Clients"), Is.True); + Assert.That(Provider.PrimaryKeyExists("Clients", "PK_Clients_New"), Is.True); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT " + + Provider.QuoteColumnNameIfRequired("Id") + " FROM " + Provider.QuoteTableNameIfRequired("Tenants"))), Is.EqualTo(1)); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT " + + Provider.QuoteColumnNameIfRequired("Id") + " FROM " + Provider.QuoteTableNameIfRequired("Clients"))), Is.EqualTo(2)); + } + [Test] public void QuotedConstraintNamesCanBeInspectedAndRemovedFromOnlyTheirTable() { diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs index 22ab1bfd..b621b8d7 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs @@ -8,6 +8,10 @@ namespace Migrator.Tests.Providers.PostgreSQL; [Category("PostgreSQL")] public class PostgreSQLTransformationProvider_ConstraintExistsTests : Generic_ConstraintExistsBase { + [Test] + public void RenamedTableRetainsItsPrimaryKeyWhileTheOriginalNameIsReused() => + RecreatingARenamedTableUsesADistinctPrimaryKeyName(); + [SetUp] public async Task SetUpAsync() { diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs index f42f0fb2..331b7615 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs @@ -8,6 +8,10 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SQLServerTransformationProvider_ConstraintExistsTests : Generic_ConstraintExistsBase { + [Test] + public void RenamedTableRetainsItsPrimaryKeyWhileTheOriginalNameIsReused() => + RecreatingARenamedTableUsesADistinctPrimaryKeyName(); + [Test] public void QualifiedNamesKeepColumnsIndexesAndConstraintsInTheirSchema() { diff --git a/src/Migrator.Tests/SQLiteCollationRegressionTests.cs b/src/Migrator.Tests/SQLiteCollationRegressionTests.cs new file mode 100644 index 00000000..245c494d --- /dev/null +++ b/src/Migrator.Tests/SQLiteCollationRegressionTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteCollationRegressionTests +{ + [TestCase("NOCASE", "HELLO")] + [TestCase("RTRIM", "hello ")] + public void AddingAColumnPreservesCollationDataAndUniqueConstraints(string collation, string equivalent) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Names", new Column("Id", DbType.Int32), + new Column("Name", DbType.String, 50) { Collation = Collation.Named(collation) }, + new PrimaryKeyConstraint("PK_Names", "Id"), new UniqueConstraint("UQ_Names", "Name")); + provider.Insert("Names", ["Id", "Name"], [1, "hello"]); + + provider.AddColumn("Names", new Column("Extra", DbType.Int32) { DefaultValue = 7 }); + + Assert.That(provider.GetColumns("Names").Single(c => c.Name == "Name").Collation?.Name, Is.EqualTo(collation)); + Assert.That(provider.ExecuteScalar("SELECT Extra FROM Names WHERE Name = '" + equivalent + "'"), Is.EqualTo(7)); + Assert.Catch(() => provider.Insert("Names", ["Id", "Name"], [2, equivalent])); + Assert.That(provider.GetTableConstraints("Names").OfType().Single().Name, Is.EqualTo("PK_Names")); + Assert.That(provider.GetTableConstraints("Names").OfType().Single().Name, Is.EqualTo("UQ_Names")); + } + + [Test] + public void MetadataReadsOnlyColumnLevelCollationsAndUsesTheLastDeclaration() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.ExecuteNonQuery(""" + CREATE TABLE Names ( + "Odd,Name" TEXT COLLATE/*separator*/[NOCASE] COLLATE "RTRIM", + Literal TEXT DEFAULT 'COLLATE NOCASE', + Expression TEXT DEFAULT ('x' COLLATE NOCASE), + CHECK (Literal COLLATE BINARY <> 'bad'), + CONSTRAINT "COLLATE" UNIQUE ("Odd,Name")) + """); + var columns = provider.GetColumns("Names"); + Assert.That(columns.Single(c => c.Name == "Odd,Name").Collation?.Name, Is.EqualTo("RTRIM")); + Assert.That(columns.Single(c => c.Name == "Literal").Collation, Is.Null); + Assert.That(columns.Single(c => c.Name == "Expression").Collation, Is.Null); + provider.AddColumn("Names", new Column("Extra", DbType.Int32)); + provider.ExecuteNonQuery("INSERT INTO Names (\"Odd,Name\") VALUES ('hello')"); + Assert.That(provider.ExecuteScalar("SELECT Literal FROM Names"), Is.EqualTo("COLLATE NOCASE")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO Names (\"Odd,Name\") VALUES ('hello ' )")); + } + + [Test] + public void CustomQuotedCollationSurvivesInspectionAndRebuild() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + const string collation = "custom \" comparison"; + connection.CreateCollation(collation, (left, right) => StringComparer.OrdinalIgnoreCase.Compare(left, right)); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("Names", new Column("Name", DbType.String) { Collation = Collation.Named(collation) }); + provider.Insert("Names", ["Name"], ["hello"]); + provider.AddColumn("Names", new Column("Extra", DbType.Int32)); + Assert.That(provider.GetColumns("Names").Single(c => c.Name == "Name").Collation?.Name, Is.EqualTo(collation)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Names WHERE Name = 'HELLO'"), Is.EqualTo(1)); + } + + [Test] + public void ChangingCollationToBinaryIsExplicitAndKeepsTheUniqueConstraint() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Names", new Column("Name", DbType.String) { Collation = Collation.AsciiIgnoreCase }, + new UniqueConstraint("UQ_Names", "Name")); + provider.Insert("Names", ["Name"], ["hello"]); + provider.ChangeColumn("Names", new Column("Name", DbType.String) { Collation = Collation.Binary }); + provider.Insert("Names", ["Name"], ["HELLO"]); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Names"), Is.EqualTo(2)); + Assert.Catch(() => provider.Insert("Names", ["Name"], ["hello"])); + } + + [Test] + public void FailingCollationChangeRollsBackSchemaDataAndComparisonBehavior() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Names", new Column("Name", DbType.String) { Collation = Collation.Binary }, + new UniqueConstraint("UQ_Names", "Name")); + provider.Insert("Names", ["Name"], ["hello"]); + provider.Insert("Names", ["Name"], ["HELLO"]); + Assert.Catch(() => provider.ChangeColumn("Names", new Column("Name", DbType.String) { Collation = Collation.AsciiIgnoreCase })); + Assert.That(provider.GetColumns("Names").Single().Collation?.Name, Is.EqualTo("BINARY")); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Names"), Is.EqualTo(2)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Names WHERE Name = 'hello'"), Is.EqualTo(1)); + } + + [Test] + public void UnsupportedIndexCollationFailsBeforeReplacingTheTable() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Names", new Column("Name", DbType.String)); + provider.ExecuteNonQuery("CREATE UNIQUE INDEX UX_Names ON Names (Name COLLATE NOCASE)"); + provider.Insert("Names", ["Name"], ["hello"]); + Assert.Throws(() => provider.AddColumn("Names", new Column("Extra", DbType.Int32))); + Assert.That(provider.ColumnExists("Names", "Extra"), Is.False); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Names"), Is.EqualTo(1)); + Assert.Catch(() => provider.Insert("Names", ["Name"], ["HELLO"])); + } +} diff --git a/src/Migrator.Tests/SQLiteIdentityUpgradeTests.cs b/src/Migrator.Tests/SQLiteIdentityUpgradeTests.cs new file mode 100644 index 00000000..f017ed71 --- /dev/null +++ b/src/Migrator.Tests/SQLiteIdentityUpgradeTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.SQLite.Models; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteIdentityUpgradeTests +{ + [Test] + public void IdentityAndPrimaryKeyCanBeAddedAtomicallyToAPopulatedTable() + { + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Settings", new Column("Name", DbType.String)); + provider.Insert("Settings", ["Name"], ["first"]); + provider.Insert("Settings", ["Name"], ["second"]); + + var definition = provider.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"); + provider.RecreateTable(definition); + + Assert.That(provider.ExecuteScalar("SELECT COUNT(DISTINCT Id) FROM Settings"), Is.EqualTo(2)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Settings WHERE Name IN ('first', 'second')"), Is.EqualTo(2)); + Assert.That(provider.GetColumns("Settings").Single(c => c.Name == "Id").IsIdentity, Is.True); + Assert.That(provider.GetTableConstraints("Settings").OfType().Single().Name, Is.EqualTo("PK_Settings")); + provider.Insert("Settings", ["Name"], ["third"]); + Assert.That(provider.ExecuteScalar("SELECT Id FROM Settings WHERE Name = 'third'"), Is.EqualTo(3)); + } + + [Test] + public void AddingIdentityWithoutItsKeyRejectsTheOperationWithoutLosingData() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("Settings", new Column("Name", DbType.String)); + provider.Insert("Settings", ["Name"], ["first"]); + Assert.Throws(() => provider.AddColumn("Settings", new Column("Id", DbType.Int32) { IsIdentity = true })); + Assert.That(provider.GetColumns("Settings").Select(c => c.Name), Is.EqualTo(new[] { "Name" })); + Assert.That(provider.ExecuteScalar("SELECT Name FROM Settings"), Is.EqualTo("first")); + Assert.That(provider.GetTables(), Is.EqualTo(new[] { "Settings" })); + } +} diff --git a/src/Migrator.Tests/SchemaConstraintTests.cs b/src/Migrator.Tests/SchemaConstraintTests.cs index e512a9a7..92b93783 100644 --- a/src/Migrator.Tests/SchemaConstraintTests.cs +++ b/src/Migrator.Tests/SchemaConstraintTests.cs @@ -168,8 +168,8 @@ public void SemanticCollationDoesNotSilentlyDowngradeUnicodeToAscii() provider.Insert("AsciiNames", ["Name"], ["é"]); provider.Insert("AsciiNames", ["Name"], ["É"]); Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM AsciiNames")), Is.EqualTo(3)); - Assert.Throws(() => provider.ChangeColumn("AsciiNames", - new Column("Name", DbType.String, 80) { Collation = Collation.AsciiIgnoreCase })); + provider.ChangeColumn("AsciiNames", new Column("Name", DbType.String, 80) { Collation = Collation.AsciiIgnoreCase }); + Assert.Catch(() => provider.Insert("AsciiNames", ["Name"], ["HELLO"])); Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM AsciiNames")), Is.EqualTo(3)); } diff --git a/src/Migrator/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs index 9a50aca3..045c91b1 100644 --- a/src/Migrator/MigrationExecution.cs +++ b/src/Migrator/MigrationExecution.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Reflection; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers; @@ -23,7 +24,11 @@ void Body() if (recordHistory) { var scope = migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope; - if (step.IsUp) provider.MigrationApplied(step.Version, scope); + if (step.IsUp) + { + // Baselines may include their own version in the history range they write. + if (!provider.AppliedMigrations.Contains(step.Version)) provider.MigrationApplied(step.Version, scope); + } else provider.MigrationUnApplied(step.Version, scope); } } diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index 9d94f03c..188a546d 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -317,7 +317,11 @@ void Run() Maintenance(MaintenanceStage.BeforeRun); foreach (var step in plan) { + // A consolidated migration can record (or revert) other versions in its body. + // The plan is a snapshot; consult the provider's current, scope-specific history. + if (_provider.AppliedMigrations.Contains(step.Version) == step.IsUp) continue; Maintenance(MaintenanceStage.BeforeMigration); + if (_provider.AppliedMigrations.Contains(step.Version) == step.IsUp) continue; Execute(_migrationLoader.GetMigration(step.Version), step, true); if (step.IsUp) history.Add(step.Version); else history.Remove(step.Version); Maintenance(MaintenanceStage.AfterMigration); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs index c5c4f983..f899775f 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs @@ -14,31 +14,75 @@ private sealed record Token(string Text, int Start, int End, bool Quoted = false } public static TableConstraint[] Parse(string sql) + { + var result = new List(); + foreach (var definition in Definitions(Tokenize(sql))) ParseDefinition(sql, definition, result); + return result.ToArray(); + } + + internal static Dictionary ColumnCollations(string sql) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(sql)) return result; + foreach (var definition in Definitions(Tokenize(sql))) + { + if (definition.Count == 0 || IsTableConstraint(definition[0])) continue; + for (var i = 1; i < definition.Count; i++) + { + // Defaults and CHECK expressions may contain their own COLLATE operators. + if (definition[i].Is("(")) i = Close(definition, i); + else if (definition[i].Is("COLLATE")) + { + if (++i >= definition.Count) throw new MigrationException("Missing SQLite collation name."); + result[definition[0].Text] = Collation.Named(definition[i].Text); + } + } + } + return result; + } + + internal static bool HasKeyword(string sql, string keyword) => Tokenize(sql).Any(t => t.Is(keyword)); + + internal static bool HasUnsupportedRebuildFeatures(string sql) { var tokens = Tokenize(sql); + for (var i = 0; i < tokens.Count; i++) + { + if (tokens[i].Is("STRICT") || tokens[i].Is("GENERATED") || tokens[i].Is("DEFERRABLE")) return true; + if (i + 1 < tokens.Count && + ((tokens[i].Is("WITHOUT") && tokens[i + 1].Is("ROWID")) || + (tokens[i].Is("CREATE") && tokens[i + 1].Is("VIRTUAL")) || + (tokens[i].Is("ON") && tokens[i + 1].Is("CONFLICT")))) return true; + } + return false; + } + + private static bool IsTableConstraint(Token token) => + token.Is("CONSTRAINT") || token.Is("PRIMARY") || token.Is("UNIQUE") || token.Is("FOREIGN") || token.Is("CHECK"); + + private static IEnumerable> Definitions(List tokens) + { var start = tokens.FindIndex(t => t.Is("(")); if (start < 0) throw new MigrationException("SQLite CREATE TABLE has no column definition list."); var end = Close(tokens, start); - var result = new List(); var first = start + 1; var depth = 0; for (var i = first; i <= end; i++) { if (i == end || (depth == 0 && tokens[i].Is(","))) { - ParseDefinition(sql, tokens.GetRange(first, i - first), result); + yield return tokens.GetRange(first, i - first); first = i + 1; } else if (tokens[i].Is("(")) depth++; else if (tokens[i].Is(")")) depth--; } - return result.ToArray(); } private static void ParseDefinition(string sql, List tokens, List result) { if (tokens.Count == 0) return; - var tableLevel = tokens[0].Is("CONSTRAINT") || tokens[0].Is("PRIMARY") || tokens[0].Is("UNIQUE") || tokens[0].Is("FOREIGN") || tokens[0].Is("CHECK"); + var tableLevel = IsTableConstraint(tokens[0]); var column = tableLevel ? null : tokens[0].Text; string name = null; for (var i = tableLevel ? 0 : 1; i < tokens.Count; i++) diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 9bbde5fa..c5d64db5 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -826,8 +826,10 @@ public void RecreateTable(SQLiteTableInfo sqliteTableInfo) foreach (var foreignKey in sqliteTableInfo.ForeignKeys) SQLiteTableSql.ValidateMatch(foreignKey.Match); 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)) + if (SQLiteConstraintParser.HasUnsupportedRebuildFeatures(script)) throw new NotSupportedException("This table contains SQLite features that cannot be reconstructed faithfully. Use native SQL."); + if (GetCreateIndexSqlStrings(oldName).Any(sql => SQLiteConstraintParser.HasKeyword(sql, "COLLATE"))) + throw new NotSupportedException("Rebuilding indexes with explicit collations requires native SQL."); var triggers = ExecuteStringQuery("SELECT sql FROM sqlite_master WHERE type='trigger' AND lower(tbl_name)=lower('{0}')", oldName.Replace("'", "''")); 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."); @@ -1108,6 +1110,8 @@ public override string[] GetTables() public override Column[] GetColumns(string tableName) { var pragmaTableInfoItems = GetPragmaTableInfoItems(tableName); + var tableScript = GetSqlCreateTableScript(tableName); + var collations = SQLiteConstraintParser.ColumnCollations(tableScript); var tableInfoPrimaryKeys = pragmaTableInfoItems.Where(x => x.Pk > 0).ToList(); var pragmaTableInfoItemsSorted = pragmaTableInfoItems.OrderBy(x => x.Cid).ToList(); @@ -1118,7 +1122,8 @@ public override Column[] GetColumns(string tableName) { var column = new Column(pragmaTableInfoItem.Name) { - Type = _dialect.GetDbTypeFromString(pragmaTableInfoItem.Type) + Type = _dialect.GetDbTypeFromString(pragmaTableInfoItem.Type), + Collation = collations.TryGetValue(pragmaTableInfoItem.Name, out var collation) ? collation : null }; if (pragmaTableInfoItem.NotNull) @@ -1135,8 +1140,6 @@ public override Column[] GetColumns(string tableName) column.DefaultValue = defValue is string sqlDefault ? CatalogDefaultValue.Parse(sqlDefault, column.Type) : defValue; - var tableScript = GetSqlCreateTableScript(tableName); - var columnTableInfoItem = pragmaTableInfoItems.First(x => x.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)); var hasCompoundPrimaryKey = tableInfoPrimaryKeys.Count > 1;