Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion docs/migration-guide-12.1-to-13.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`)
Expand Down
9 changes: 9 additions & 0 deletions src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ namespace Migrator.Tests;

public class IdentifierAndTimeRegressionTests
{
[Test]
public void ColumnQuotingDoesNotInheritTheDefaultTableSchema()
{
using var connection = NSubstitute.Substitute.For<IDbConnection>();
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)
Expand Down
135 changes: 135 additions & 0 deletions src/Migrator.Tests/MigrationHistoryRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(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<InvalidOperationException>(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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
111 changes: 111 additions & 0 deletions src/Migrator.Tests/SQLiteCollationRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -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<PrimaryKeyConstraint>().Single().Name, Is.EqualTo("PK_Names"));
Assert.That(provider.GetTableConstraints("Names").OfType<UniqueConstraint>().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<NotSupportedException>(() => 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"]));
}
}
Loading
Loading