From bc4ec95256f4c8d8edbabf9ec714e7cb20a70679 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Thu, 24 Sep 2026 13:45:47 +0200 Subject: [PATCH] Keep duplicate-row cleanup portable across migration providers Consumers currently repeat physical-row SQL for SQLite, PostgreSQL, Oracle and SQL Server. Provide explicit arbitrary-survivor semantics through DeleteDuplicateRows and the fluent Delete.DuplicateRows builder, including equal/excluded NULL keys and affected-row counts. Validate keys and unsupported row identities before deletion; preserve transaction ownership and reject automatic reversal and metadata-free SQL preview. Cover both SQLite drivers, rollback, composite keys, quoting, rowid shadowing, unsupported providers and direct/fluent execution on all three server databases. Unit/SQLite: 1189 passed; live SQL Server/PostgreSQL/Oracle: 12 passed; 108 documentation examples compiled. --- docs/_src/content.py | 7 ++ docs/assets/search-index.json | 2 +- docs/fluent-operation-coverage.json | 1 + docs/guide/auto-reversing.html | 12 +- docs/guide/cli.html | 32 +++--- docs/guide/conditional.html | 10 +- docs/guide/connections.html | 22 ++-- docs/guide/constraints.html | 22 ++-- docs/guide/data.html | 28 +++-- docs/guide/defaults-collations.html | 24 ++-- docs/guide/dependency-injection.html | 12 +- docs/guide/extensions.html | 12 +- docs/guide/foreign-keys.html | 22 ++-- docs/guide/indexes.html | 20 ++-- docs/guide/maintenance.html | 12 +- docs/guide/mysql.html | 12 +- docs/guide/oracle.html | 12 +- docs/guide/postgresql.html | 12 +- docs/guide/preview.html | 24 ++-- docs/guide/profiles.html | 24 ++-- docs/guide/providers.html | 12 +- docs/guide/schema.html | 34 +++--- docs/guide/sql-server.html | 10 +- docs/guide/sql.html | 26 ++--- docs/guide/sqlite.html | 12 +- docs/guide/tags.html | 24 ++-- docs/guide/testing.html | 12 +- docs/guide/transactions.html | 24 ++-- docs/guide/versioning.html | 22 ++-- docs/index.html | 12 +- .../DeleteDuplicateRowsLiveTests.cs | 53 +++++++++ .../DeleteDuplicateRowsTests.cs | 106 ++++++++++++++++++ .../DeleteDuplicateRowsValidationTests.cs | 30 +++++ .../Framework/DuplicateNullHandling.cs | 9 ++ .../Framework/DuplicateRowRetention.cs | 8 ++ .../Framework/Fluent/DuplicateRowsBuilder.cs | 44 ++++++++ .../Framework/Fluent/SchemaBuilders.cs | 5 + .../Framework/ITransformationProvider.cs | 3 + .../Providers/DuplicateRowDeletion.cs | 75 +++++++++++++ .../Providers/NoOpTransformationProvider.cs | 1 + .../Providers/TransformationProvider.cs | 3 + 41 files changed, 599 insertions(+), 248 deletions(-) create mode 100644 src/Migrator.Tests/DeleteDuplicateRowsLiveTests.cs create mode 100644 src/Migrator.Tests/DeleteDuplicateRowsTests.cs create mode 100644 src/Migrator.Tests/DeleteDuplicateRowsValidationTests.cs create mode 100644 src/Migrator/Framework/DuplicateNullHandling.cs create mode 100644 src/Migrator/Framework/DuplicateRowRetention.cs create mode 100644 src/Migrator/Framework/Fluent/DuplicateRowsBuilder.cs create mode 100644 src/Migrator/Providers/DuplicateRowDeletion.cs diff --git a/docs/_src/content.py b/docs/_src/content.py index 58d5562c..05b8c682 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -213,6 +213,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Database.Delete("Users", new[] { "Id" }, new object[] { 1 }); ''', ''' migration.Delete.FromTable("Users").Where(new[] { "Id" }, new object[] { 1 }); +''')), + section("Delete duplicate rows", '

DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns.

The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected.

', pair("Keep one assignment per role/group pair", ''' +int removed = Database.DeleteDuplicateRows("Assignments", + new[] { "RoleId", "GroupId" }, DuplicateRowRetention.Any); +''', ''' +migration.Delete.DuplicateRows().FromTable("Assignments") + .ByColumns("RoleId", "GroupId").KeepAny(); ''')), section("Conditional seed data", '

Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key.

', pair("Insert a missing seed", ''' Database.InsertIfNotExists("Users", new[] { "Id", "Name" }, diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index b936899e..54f54c38 100644 --- a/docs/assets/search-index.json +++ b/docs/assets/search-index.json @@ -53,7 +53,7 @@ "group": "Operations", "summary": "Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.", "url": "guide/data.html", - "text": " Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" + "text": " Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns. The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected. Keep one assignment per role/group pair int removed = Database.DeleteDuplicateRows(\"Assignments\",\n new[] { \"RoleId\", \"GroupId\" }, DuplicateRowRetention.Any); migration.Delete.DuplicateRows().FromTable(\"Assignments\")\n .ByColumns(\"RoleId\", \"GroupId\").KeepAny(); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" }, { "title": "Schema inspection", diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json index 6f5fed38..8a553807 100644 --- a/docs/fluent-operation-coverage.json +++ b/docs/fluent-operation-coverage.json @@ -2,6 +2,7 @@ "AddTable": "Create.Table(...).WithFields(...) / WithEngine(...)", "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", + "DeleteDuplicateRows": "Delete.DuplicateRows().FromTable(table).ByColumns(...).KeepAny(nulls)", "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/guide/auto-reversing.html b/docs/guide/auto-reversing.html index 308e05f6..c0eed847 100644 --- a/docs/guide/auto-reversing.html +++ b/docs/guide/auto-reversing.html @@ -5,9 +5,9 @@ Migrator.NET -

Automatic reversal

Fluent operations can describe a supported reverse sequence; Classic migrations author it directly.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Creation and its reverse

AutoReversingMigration derives reverse operations in reverse order and validates reversal support before its first change. The Classic equivalent makes the Down operation explicit. Both examples below create the same table and remove it on downgrade.

A reversible table creation
-

Classic

-
using System;
+

Automatic reversal

Fluent operations can describe a supported reverse sequence; Classic migrations author it directly.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Creation and its reverse

AutoReversingMigration derives reverse operations in reverse order and validates reversal support before its first change. The Classic equivalent makes the Down operation explicit. Both examples below create the same table and remove it on downgrade.

A reversible table creation
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -17,9 +17,9 @@
 {
     public override void Up() => Database.AddTable("Notes", new Column("Text", DbType.String, 500));
     public override void Down() => Database.RemoveTable("Notes");
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
diff --git a/docs/guide/cli.html b/docs/guide/cli.html
index fbe17d14..0e8de4d9 100644
--- a/docs/guide/cli.html
+++ b/docs/guide/cli.html
@@ -5,26 +5,26 @@
 Migrator.NET
 
 
-

Command-line tool

List, validate, plan, preview, apply and reverse migrations from a deployment script.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Install and connect

Install DotNetProjects.Migrator.Tool as a .NET tool. Set MIGRATOR_CONNECTION in the deployment environment or select another variable with --connection-env. Both Classic and Fluent classes use the same commands. The tool does not print the connection-string value.

Install the CLI
-

Classic

-
dotnet tool install --global DotNetProjects.Migrator.Tool
-

Fluent

-
dotnet tool install --global DotNetProjects.Migrator.Tool

Shared commands · both styles

Inspect before applying

Inspect a migration assembly
-

Classic

-
migrator list --assembly MyMigrations.dll --provider SQLite
+

Command-line tool

List, validate, plan, preview, apply and reverse migrations from a deployment script.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Install and connect

Install DotNetProjects.Migrator.Tool as a .NET tool. Set MIGRATOR_CONNECTION in the deployment environment or select another variable with --connection-env. Both Classic and Fluent classes use the same commands. The tool does not print the connection-string value.

Install the CLI
+

Classic

+
dotnet tool install --global DotNetProjects.Migrator.Tool
+

Fluent

+
dotnet tool install --global DotNetProjects.Migrator.Tool

Shared commands · both styles

Inspect before applying

Inspect a migration assembly
+

Classic

+
migrator list --assembly MyMigrations.dll --provider SQLite
 migrator status --assembly MyMigrations.dll --provider SQLite
 migrator validate --assembly MyMigrations.dll --provider SQLite
 migrator plan --assembly MyMigrations.dll --provider SQLite --target 10
-migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql
-

Fluent

-
migrator list --assembly MyMigrations.dll --provider SQLite
+migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql
+

Fluent

+
migrator list --assembly MyMigrations.dll --provider SQLite
 migrator status --assembly MyMigrations.dll --provider SQLite
 migrator validate --assembly MyMigrations.dll --provider SQLite
 migrator plan --assembly MyMigrations.dll --provider SQLite --target 10
-migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql

Shared commands · both styles

Validate checks version planning, not arbitrary migration-body behavior. Plan lists version steps without running bodies. SQL generation renders a supported operation subset; it does not produce an idempotent history-managed bundle.

Apply and roll back

Deploy a selected scope
-

Classic

-
migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession
-migrator rollback --assembly MyMigrations.dll --provider SQLite --scope billing --target 0
-

Fluent

-
migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession
+migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql

Shared commands · both styles

Validate checks version planning, not arbitrary migration-body behavior. Plan lists version steps without running bodies. SQL generation renders a supported operation subset; it does not produce an idempotent history-managed bundle.

Apply and roll back

Deploy a selected scope
+

Classic

+
migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession
+migrator rollback --assembly MyMigrations.dll --provider SQLite --scope billing --target 0
+

Fluent

+
migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession
 migrator rollback --assembly MyMigrations.dll --provider SQLite --scope billing --target 0

Shared commands · both styles

Rollback requires an explicit lower target and rejects any plan containing upward steps. Target checks run after taking the configured lock and refreshing history. Tags, profiles and provider choice must match the intended deployment.

Options and exit codes

OptionPurpose
--tags a,b / --tag-match Any|AllFilter versioned migrations.
--profiles a,bSelect named profiles.
--schema / --scopeProvider schema and migration history scope.
--timeout SECONDSDatabase command timeout.
--lock / --lock-timeout SECONDSNative migration lock on supported providers.
--offlineSQL generation assuming empty history; profiles/maintenance rejected.

Exit codes: 0 success; 1 load/execution failure; 2 invalid arguments; 3 unsupported provider/operation; 4 lock timeout. SQL output can contain data authored in migrations. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird; use a custom host for other library providers.

diff --git a/docs/guide/conditional.html b/docs/guide/conditional.html index d03594db..7b9daf02 100644 --- a/docs/guide/conditional.html +++ b/docs/guide/conditional.html @@ -5,9 +5,9 @@ Migrator.NET -

Conditional logic

Choose between inspecting the live schema and declaring a provider-specific operation.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Provider-specific operations

The Classic provider indexer selects a named provider or a no-op provider. Fluent IfProvider wraps structured operations in a provider condition. Use the provider names understood by the dialect; this SQLite example leaves other providers unchanged.

Run a SQLite-specific statement
-

Classic

-
Database["SQLite"].ExecuteNonQuery("UPDATE Users SET Name = upper(Name)");
-

Fluent

-
migration.IfProvider("SQLite", sqlite =>
+

Conditional logic

Choose between inspecting the live schema and declaring a provider-specific operation.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Provider-specific operations

The Classic provider indexer selects a named provider or a no-op provider. Fluent IfProvider wraps structured operations in a provider condition. Use the provider names understood by the dialect; this SQLite example leaves other providers unchanged.

Run a SQLite-specific statement
+

Classic

+
Database["SQLite"].ExecuteNonQuery("UPDATE Users SET Name = upper(Name)");
+

Fluent

+
migration.IfProvider("SQLite", sqlite =>
     sqlite.Execute.Sql("UPDATE Users SET Name = upper(Name)"));

Inside Up() / BuildUp(MigrationBuilder migration)

Schema-dependent decisions

Use Database.TableExists/ColumnExists or FluentMigration.Schema for connected checks. These inspect the current database. A fluent BuildUp method collects operations before they execute, so queued creation is not visible to a live metadata read in the same method.

For execution-time decisions after earlier operations, use an explicit provider callback. That callback cannot be previewed and requires an authored reverse. Avoid making a migration silently succeed with the wrong schema: an existence check alone does not validate a column’s type or constraint definition.

diff --git a/docs/guide/connections.html b/docs/guide/connections.html index 53dd8f3d..e47c059c 100644 --- a/docs/guide/connections.html +++ b/docs/guide/connections.html @@ -5,18 +5,18 @@ Migrator.NET -

Commands and callbacks

Use the active provider connection when a migration needs driver-level work.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Bind command parameters

The provider creates a command associated with its current transaction. Dispose it after use. Generate parameter names through the provider, rather than assuming every driver uses the same convention. The callback is deferred until fluent execution reaches it.

Execute a parameterized command
-

Classic

-
using var command = Database.CreateCommand();
+

Commands and callbacks

Use the active provider connection when a migration needs driver-level work.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Bind command parameters

The provider creates a command associated with its current transaction. Dispose it after use. Generate parameter names through the provider, rather than assuming every driver uses the same convention. The callback is deferred until fluent execution reaches it.

Execute a parameterized command
+

Classic

+
using var command = Database.CreateCommand();
 var name = Database.GenerateParameterName(0);
 command.CommandText = "UPDATE Users SET Name = " + name + " WHERE Id = 1";
 var value = command.CreateParameter();
 value.ParameterName = name;
 value.Value = "Ada";
 command.Parameters.Add(value);
-command.ExecuteNonQuery();
-

Fluent

-
migration.Execute.WithProvider(provider =>
+command.ExecuteNonQuery();
+

Fluent

+
migration.Execute.WithProvider(provider =>
 {
     using var command = provider.CreateCommand();
     var name = provider.GenerateParameterName(0);
@@ -26,8 +26,8 @@
     value.Value = "Ada";
     command.Parameters.Add(value);
     command.ExecuteNonQuery();
-});

Inside Up() / BuildUp(MigrationBuilder migration)

Connection ownership

WithCommand creates and disposes a provider command around your action. WithConnection exposes the connection; WithProvider exposes the complete transformation provider. Do not close or replace a runner-owned connection, commit its transaction or switch databases while holding a native migration lock.

Database administration

Database creation and other administration require a connection and identity authorized for that operation. Use a dedicated host with TransactionMode.None. Fluent administration rejects an active transaction; do not combine it with WholeSession or assume a Classic provider call can participate in transactional DDL.

Create a database on a supporting server
-

Classic

-
Database.CreateDatabases("Reporting");
-

Fluent

-
migration.Administration.CreateDatabase("Reporting");

Inside Up() / BuildUp(MigrationBuilder migration)

The remaining mappings are DropDatabases / Administration.DropDatabase, SwitchDatabase / Administration.SwitchDatabase, and KillDatabaseConnections / Administration.KillConnections. These are explicit administrative actions with provider-specific support. Database switches invalidate assumptions about migration history and session locks: keep provisioning separate from ordinary schema migrations. They are outside SQL preview and automatic reversal.

Preview and reversal

Callbacks can perform arbitrary C# work and cannot be translated into SQL preview. They require explicit reverse behavior. Keeping external network calls out of migration bodies makes failures easier to reason about: a database rollback cannot undo an email or an HTTP request.

+});

Inside Up() / BuildUp(MigrationBuilder migration)

Connection ownership

WithCommand creates and disposes a provider command around your action. WithConnection exposes the connection; WithProvider exposes the complete transformation provider. Do not close or replace a runner-owned connection, commit its transaction or switch databases while holding a native migration lock.

Database administration

Database creation and other administration require a connection and identity authorized for that operation. Use a dedicated host with TransactionMode.None. Fluent administration rejects an active transaction; do not combine it with WholeSession or assume a Classic provider call can participate in transactional DDL.

Create a database on a supporting server
+

Classic

+
Database.CreateDatabases("Reporting");
+

Fluent

+
migration.Administration.CreateDatabase("Reporting");

Inside Up() / BuildUp(MigrationBuilder migration)

The remaining mappings are DropDatabases / Administration.DropDatabase, SwitchDatabase / Administration.SwitchDatabase, and KillDatabaseConnections / Administration.KillConnections. These are explicit administrative actions with provider-specific support. Database switches invalidate assumptions about migration history and session locks: keep provisioning separate from ordinary schema migrations. They are outside SQL preview and automatic reversal.

Preview and reversal

Callbacks can perform arbitrary C# work and cannot be translated into SQL preview. They require explicit reverse behavior. Keeping external network calls out of migration bodies makes failures easier to reason about: a database rollback cannot undo an email or an HTTP request.

diff --git a/docs/guide/constraints.html b/docs/guide/constraints.html index 8c29a227..1f24130a 100644 --- a/docs/guide/constraints.html +++ b/docs/guide/constraints.html @@ -5,14 +5,14 @@ Migrator.NET -

Keys and constraints

Declare table invariants independently of column attributes.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Add uniqueness and a check

Existing rows must satisfy a new constraint. A rebuild or ALTER operation can fail if duplicate or invalid data is present. CHECK expressions are trusted SQL and depend on the target engine. Primary keys, unique constraints, foreign keys and checks have typed definitions.

Add two named constraints
-

Classic

-
Database.AddUniqueConstraint("UQ_Users_Name", "Users", "Name");
-Database.AddCheckConstraint("CK_Users_Id", "Users", "Id > 0");
-

Fluent

-
migration.Create.UniqueConstraint("UQ_Users_Name").OnTable("Users").WithColumns("Name");
-migration.Create.CheckConstraint("CK_Users_Id").OnTable("Users").WithExpression("Id > 0");

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the intended object

Use dedicated primary-key and foreign-key removal methods; generic RemoveConstraint is for unique/check constraints in the SQLite provider. Avoid RemoveAllConstraints unless the migration deliberately replaces every invariant.

Remove a check constraint
-

Classic

-
Database.RemoveConstraint("Users", "CK_Users_Id");
-

Fluent

-
migration.Delete.Constraint("CK_Users_Id").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint identity

GetTableConstraints returns ordered typed definitions. SQLite can return a null name for an unnamed legacy constraint; an autoindex name is not a substitute constraint name. PrimaryKeyExists checks the actual key name. MySQL reports the primary key name as PRIMARY.

Altering a column does not give that column ownership of a unique constraint. SQL Server implicit ownership markers are no longer used for deletion. Explicitly remove only the object your migration intends to change.

+

Keys and constraints

Declare table invariants independently of column attributes.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Add uniqueness and a check

Existing rows must satisfy a new constraint. A rebuild or ALTER operation can fail if duplicate or invalid data is present. CHECK expressions are trusted SQL and depend on the target engine. Primary keys, unique constraints, foreign keys and checks have typed definitions.

Add two named constraints
+

Classic

+
Database.AddUniqueConstraint("UQ_Users_Name", "Users", "Name");
+Database.AddCheckConstraint("CK_Users_Id", "Users", "Id > 0");
+

Fluent

+
migration.Create.UniqueConstraint("UQ_Users_Name").OnTable("Users").WithColumns("Name");
+migration.Create.CheckConstraint("CK_Users_Id").OnTable("Users").WithExpression("Id > 0");

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the intended object

Use dedicated primary-key and foreign-key removal methods; generic RemoveConstraint is for unique/check constraints in the SQLite provider. Avoid RemoveAllConstraints unless the migration deliberately replaces every invariant.

Remove a check constraint
+

Classic

+
Database.RemoveConstraint("Users", "CK_Users_Id");
+

Fluent

+
migration.Delete.Constraint("CK_Users_Id").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint identity

GetTableConstraints returns ordered typed definitions. SQLite can return a null name for an unnamed legacy constraint; an autoindex name is not a substitute constraint name. PrimaryKeyExists checks the actual key name. MySQL reports the primary key name as PRIMARY.

Altering a column does not give that column ownership of a unique constraint. SQL Server implicit ownership markers are no longer used for deletion. Explicitly remove only the object your migration intends to change.

diff --git a/docs/guide/data.html b/docs/guide/data.html index d4f371e3..6616e6a1 100644 --- a/docs/guide/data.html +++ b/docs/guide/data.html @@ -21,18 +21,24 @@

Classic

Database.Delete("Users", new[] { "Id" }, new object[] { 1 });

Fluent

-
migration.Delete.FromTable("Users").Where(new[] { "Id" }, new object[] { 1 });

Inside Up() / BuildUp(MigrationBuilder migration)

Conditional seed data

Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key.

Insert a missing seed
+
migration.Delete.FromTable("Users").Where(new[] { "Id" }, new object[] { 1 });

Inside Up() / BuildUp(MigrationBuilder migration)

Delete duplicate rows

DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns.

The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected.

Keep one assignment per role/group pair

Classic

-
Database.InsertIfNotExists("Users", new[] { "Id", "Name" },
-    new object[] { 1, "Ada" }, new[] { "Id" }, new object[] { 1 });
+
int removed = Database.DeleteDuplicateRows("Assignments",
+    new[] { "RoleId", "GroupId" }, DuplicateRowRetention.Any);

Fluent

-
migration.Insert.IntoTable("Users")
-    .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" })
-    .IfNotExists(new[] { "Id" }, new object[] { 1 });

Inside Up() / BuildUp(MigrationBuilder migration)

Copying and reversal

Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values.

Copy users into an archive table
+
migration.Delete.DuplicateRows().FromTable("Assignments")
+    .ByColumns("RoleId", "GroupId").KeepAny();

Inside Up() / BuildUp(MigrationBuilder migration)

Conditional seed data

Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key.

Insert a missing seed

Classic

-
Database.CopyDataFromTableToTable("Users",
-    new System.Collections.Generic.List<string> { "Id", "Name" }, "ArchivedUsers",
-    new System.Collections.Generic.List<string> { "UserId", "DisplayName" });
+
Database.InsertIfNotExists("Users", new[] { "Id", "Name" },
+    new object[] { 1, "Ada" }, new[] { "Id" }, new object[] { 1 });

Fluent

-
migration.Execute.CopyDataFromTable("Users").ToTable("ArchivedUsers")
-    .WithColumns(new[] { "Id", "Name" }, new[] { "UserId", "DisplayName" });

Inside Up() / BuildUp(MigrationBuilder migration)

+
migration.Insert.IntoTable("Users")
+    .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" })
+    .IfNotExists(new[] { "Id" }, new object[] { 1 });

Inside Up() / BuildUp(MigrationBuilder migration)

Copying and reversal

Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values.

Copy users into an archive table
+

Classic

+
Database.CopyDataFromTableToTable("Users",
+    new System.Collections.Generic.List<string> { "Id", "Name" }, "ArchivedUsers",
+    new System.Collections.Generic.List<string> { "UserId", "DisplayName" });
+

Fluent

+
migration.Execute.CopyDataFromTable("Users").ToTable("ArchivedUsers")
+    .WithColumns(new[] { "Id", "Name" }, new[] { "UserId", "DisplayName" });

Inside Up() / BuildUp(MigrationBuilder migration)

diff --git a/docs/guide/defaults-collations.html b/docs/guide/defaults-collations.html index c3f9a452..a5fcafcd 100644 --- a/docs/guide/defaults-collations.html +++ b/docs/guide/defaults-collations.html @@ -5,22 +5,22 @@ Migrator.NET -

Defaults and collations

Distinguish values from SQL expressions, and comparison intent from a provider's installed collation name.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Literal and expression defaults

Ordinary strings are quoted literal values. RawSql.Insert marks trusted SQL to evaluate on the database. The expression below works on SQLite; provider function names and return types can differ.

A database-generated timestamp
-

Classic

-
Database.AddTable("Events", new Column("CreatedAt", DbType.DateTime)
+

Defaults and collations

Distinguish values from SQL expressions, and comparison intent from a provider's installed collation name.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Literal and expression defaults

Ordinary strings are quoted literal values. RawSql.Insert marks trusted SQL to evaluate on the database. The expression below works on SQLite; provider function names and return types can differ.

A database-generated timestamp
+

Classic

+
Database.AddTable("Events", new Column("CreatedAt", DbType.DateTime)
 {
     DefaultValue = RawSql.Insert("CURRENT_TIMESTAMP"), IsNullable = false
-});
-

Fluent

-
migration.Create.Table("Events")
+});
+

Fluent

+
migration.Create.Table("Events")
     .WithColumn("CreatedAt").OfType(DbType.DateTime).NotNullable()
-    .WithDefaultValue(RawSql.Insert("CURRENT_TIMESTAMP"));

Inside Up() / BuildUp(MigrationBuilder migration)

Comparison behavior

Collation presets request semantics. Unsupported mappings fail before DDL. SQLite AsciiIgnoreCase maps to NOCASE and folds ASCII only; it is not Unicode case folding. Named custom SQLite collations must be registered on the connection before schema or data operations use them.

ASCII-insensitive SQLite text
-

Classic

-
Database.AddTable("Labels", new Column("Name", DbType.String, 100)
+    .WithDefaultValue(RawSql.Insert("CURRENT_TIMESTAMP"));

Inside Up() / BuildUp(MigrationBuilder migration)

Comparison behavior

Collation presets request semantics. Unsupported mappings fail before DDL. SQLite AsciiIgnoreCase maps to NOCASE and folds ASCII only; it is not Unicode case folding. Named custom SQLite collations must be registered on the connection before schema or data operations use them.

ASCII-insensitive SQLite text
+

Classic

+
Database.AddTable("Labels", new Column("Name", DbType.String, 100)
 {
     Collation = Collation.AsciiIgnoreCase
-});
-

Fluent

-
migration.Create.Table("Labels")
+});
+

Fluent

+
migration.Create.Table("Labels")
     .WithColumn("Name").AsString(100)
     .WithCollation(Collation.AsciiIgnoreCase);

Inside Up() / BuildUp(MigrationBuilder migration)

Presets and provider names

RequestMeaning
CaseInsensitive / CaseSensitiveCase behavior with accent sensitivity; supported SQL Server/MySQL/MariaDB mappings, or an explicit installed name on other engines.
BinaryProvider binary comparison; not a promise of identical linguistic ordering.
AsciiIgnoreCaseSQLite NOCASE; ASCII letters only.
Collation.Named(name)An installed or registered provider-specific collation.

Use named collations for language-specific or exact comparison behavior. PostgreSQL ICU nondeterministic collations must be created explicitly; SQL rendering does not create shared database objects. Read the mapping table for engine versions and restrictions.

diff --git a/docs/guide/dependency-injection.html b/docs/guide/dependency-injection.html index 4214eca3..c7ce54e9 100644 --- a/docs/guide/dependency-injection.html +++ b/docs/guide/dependency-injection.html @@ -5,9 +5,9 @@ Migrator.NET -

Dependency injection and logging

Resolve the runner and migration dependencies inside one service scope.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Register the integration

Install DotNetProjects.Migrator.Extensions.DependencyInjection and Microsoft.Extensions.Logging alongside the core and database driver. This example uses the connection-string provider factory so provider disposal belongs to the DI scope. The providerName explicitly selects the SQLite driver.

A scoped migration host
-

Classic

-
using DotNetProjects.Migrator;
+

Dependency injection and logging

Resolve the runner and migration dependencies inside one service scope.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Register the integration

Install DotNetProjects.Migrator.Extensions.DependencyInjection and Microsoft.Extensions.Logging alongside the core and database driver. This example uses the connection-string provider factory so provider disposal belongs to the DI scope. The providerName explicitly selects the SQLite driver.

A scoped migration host
+

Classic

+
using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Providers;
 using DotNetProjects.Migrator.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
@@ -21,9 +21,9 @@
 
 using var container = services.BuildServiceProvider();
 using var scope = container.CreateScope();
-scope.ServiceProvider.GetRequiredService<Migrator>().MigrateToLastVersion();
-

Fluent

-
using DotNetProjects.Migrator;
+scope.ServiceProvider.GetRequiredService<Migrator>().MigrateToLastVersion();
+

Fluent

+
using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Providers;
 using DotNetProjects.Migrator.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
diff --git a/docs/guide/extensions.html b/docs/guide/extensions.html
index 0ec6431f..910cd362 100644
--- a/docs/guide/extensions.html
+++ b/docs/guide/extensions.html
@@ -5,9 +5,9 @@
 Migrator.NET
 
 
-

Custom extensions

Reuse schema conventions without hiding provider behavior or changing the migration contract.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Share a schema convention

A small helper can express a repeated column policy in both styles. Keep helper behavior stable for historical migrations; changing a helper can change what an old migration does on a fresh database. The example uses static methods to keep its dependencies explicit.

Reusable audit-column helpers
-

Classic

-
using System;
+

Custom extensions

Reuse schema conventions without hiding provider behavior or changing the migration contract.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Share a schema convention

A small helper can express a repeated column policy in both styles. Keep helper behavior stable for historical migrations; changing a helper can change what an old migration does on a fresh database. The example uses static methods to keep its dependencies explicit.

Reusable audit-column helpers
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -19,9 +19,9 @@
         {
             IsNullable = false, DefaultValue = RawSql.Insert("CURRENT_TIMESTAMP")
         });
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
diff --git a/docs/guide/foreign-keys.html b/docs/guide/foreign-keys.html
index 559e32a6..f2b4e938 100644
--- a/docs/guide/foreign-keys.html
+++ b/docs/guide/foreign-keys.html
@@ -5,19 +5,19 @@
 Migrator.NET
 
 
-

Foreign keys

Define ordered child/parent columns and independent actions for update and delete.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Add a relationship

Parent key columns must identify a suitable primary/unique key. Child and parent arrays are positional: each child column corresponds to the parent column at the same index. Both tables and their compatible columns must already exist for this example. The Classic example uses IForeignKeyActions for independent update/delete actions; the older AddForeignKey overload supplies one action for both.

Orders belong to users
-

Classic

-
((IForeignKeyActions)Database).AddForeignKey(
+

Foreign keys

Define ordered child/parent columns and independent actions for update and delete.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Add a relationship

Parent key columns must identify a suitable primary/unique key. Child and parent arrays are positional: each child column corresponds to the parent column at the same index. Both tables and their compatible columns must already exist for this example. The Classic example uses IForeignKeyActions for independent update/delete actions; the older AddForeignKey overload supplies one action for both.

Orders belong to users
+

Classic

+
((IForeignKeyActions)Database).AddForeignKey(
     "FK_Orders_Users", "Orders", new[] { "UserId" },
     "Users", new[] { "Id" }, ForeignKeyConstraintType.Cascade,
-    ForeignKeyConstraintType.NoAction);
-

Fluent

-
migration.Create.ForeignKey("FK_Orders_Users")
+    ForeignKeyConstraintType.NoAction);
+

Fluent

+
migration.Create.ForeignKey("FK_Orders_Users")
     .FromTable("Orders").WithColumns("UserId")
     .ToTable("Users").WithColumns("Id")
     .OnDelete(ForeignKeyConstraintType.Cascade)
-    .OnUpdate(ForeignKeyConstraintType.NoAction);

Inside Up() / BuildUp(MigrationBuilder migration)

Remove a relationship

Remove dependent keys before incompatible table or key changes. Restore them only after the existing data satisfies the replacement relationship.

Remove the foreign key
-

Classic

-
Database.RemoveForeignKey("Orders", "FK_Orders_Users");
-

Fluent

-
migration.Delete.ForeignKey("FK_Orders_Users").FromTable("Orders");

Inside Up() / BuildUp(MigrationBuilder migration)

Database semantics

Supported actions depend on the database; do not assume every engine implements CASCADE, RESTRICT, SET NULL and SET DEFAULT identically. SQLite rebuilds preserve separate update/delete actions and validate integrity before an owned transaction commits. MATCH FULL and MATCH PARTIAL requests are rejected because SQLite does not enforce those semantics.

SetNull needs nullable child columns. Test action behavior using actual data, especially composite keys and partially NULL values. Oracle supports its own subset of foreign-key actions.

+ .OnUpdate(ForeignKeyConstraintType.NoAction);

Inside Up() / BuildUp(MigrationBuilder migration)

Remove a relationship

Remove dependent keys before incompatible table or key changes. Restore them only after the existing data satisfies the replacement relationship.

Remove the foreign key
+

Classic

+
Database.RemoveForeignKey("Orders", "FK_Orders_Users");
+

Fluent

+
migration.Delete.ForeignKey("FK_Orders_Users").FromTable("Orders");

Inside Up() / BuildUp(MigrationBuilder migration)

Database semantics

Supported actions depend on the database; do not assume every engine implements CASCADE, RESTRICT, SET NULL and SET DEFAULT identically. SQLite rebuilds preserve separate update/delete actions and validate integrity before an owned transaction commits. MATCH FULL and MATCH PARTIAL requests are rejected because SQLite does not enforce those semantics.

SetNull needs nullable child columns. Test action behavior using actual data, especially composite keys and partially NULL values. Oracle supports its own subset of foreign-key actions.

diff --git a/docs/guide/indexes.html b/docs/guide/indexes.html index e37c0541..60446bd1 100644 --- a/docs/guide/indexes.html +++ b/docs/guide/indexes.html @@ -5,15 +5,15 @@ Migrator.NET -

Indexes

An index is a separate schema object, even when it enforces uniqueness.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Create and remove an index

Use an explicit name so the index can be inspected or removed later. Fluent Create.Index(name).OnTable(table).WithColumns(...) names each part explicitly and preserves column order. Append Unique(), Clustered(), IncludeColumns(...) or WithFilter(...). For an existing Index definition, use Create.Index(definition).OnTable(table); fully qualify the model type if System.Index is also in scope.

Index a user name
-

Classic

-
Database.AddIndex("Users", new DotNetProjects.Migrator.Framework.Index
+

Indexes

An index is a separate schema object, even when it enforces uniqueness.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Create and remove an index

Use an explicit name so the index can be inspected or removed later. Fluent Create.Index(name).OnTable(table).WithColumns(...) names each part explicitly and preserves column order. Append Unique(), Clustered(), IncludeColumns(...) or WithFilter(...). For an existing Index definition, use Create.Index(definition).OnTable(table); fully qualify the model type if System.Index is also in scope.

Index a user name
+

Classic

+
Database.AddIndex("Users", new DotNetProjects.Migrator.Framework.Index
 {
     Name = "IX_Users_Name", KeyColumns = new[] { "Name" }, Unique = false
-});
-

Fluent

-
migration.Create.Index("IX_Users_Name").OnTable("Users").WithColumns("Name");

Inside Up() / BuildUp(MigrationBuilder migration)

Drop an index
-

Classic

-
Database.RemoveIndex("Users", "IX_Users_Name");
-

Fluent

-
migration.Delete.Index("IX_Users_Name").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. These options are provider-specific. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses. Preview handles simple indexes and rejects unsupported options.

Unique index or unique constraint?

Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys.

+});
+

Fluent

+
migration.Create.Index("IX_Users_Name").OnTable("Users").WithColumns("Name");

Inside Up() / BuildUp(MigrationBuilder migration)

Drop an index
+

Classic

+
Database.RemoveIndex("Users", "IX_Users_Name");
+

Fluent

+
migration.Delete.Index("IX_Users_Name").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. These options are provider-specific. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses. Preview handles simple indexes and rejects unsupported options.

Unique index or unique constraint?

Use UniqueConstraint for a table-level invariant and an Index with Unique for an index definition. Do not infer ownership from a generated name. SQLite RemoveAllIndexes preserves declared table UNIQUE constraints; remove those through the constraint APIs. Check query plans and data cardinality when choosing index keys.

diff --git a/docs/guide/maintenance.html b/docs/guide/maintenance.html index a91c4824..29ec7e17 100644 --- a/docs/guide/maintenance.html +++ b/docs/guide/maintenance.html @@ -5,9 +5,9 @@ Migrator.NET -

Maintenance migrations

Place ordered work at the runner's lifecycle stages.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose a stage

StageWhen
BeforeRunBefore versioned migration work in this run.
BeforeMigrationBefore each executed versioned migration.
AfterMigrationAfter each executed versioned migration.
AfterRunAfter the run's migration/profile work.

Maintenance classes accept Order and Scope. They use Up and do not acquire version records. Hooks stop on failure; later stages are not finally blocks or guaranteed cleanup paths. Lock release and connection/transaction restoration are runner responsibilities.

A scoped maintenance operation

The example expects an existing DeploymentLog table. Choose a table that already exists at the selected stage. Fluent callbacks execute at the corresponding operation position.

Write a deployment marker
-

Classic

-
using System;
+

Maintenance migrations

Place ordered work at the runner's lifecycle stages.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose a stage

StageWhen
BeforeRunBefore versioned migration work in this run.
BeforeMigrationBefore each executed versioned migration.
AfterMigrationAfter each executed versioned migration.
AfterRunAfter the run's migration/profile work.

Maintenance classes accept Order and Scope. They use Up and do not acquire version records. Hooks stop on failure; later stages are not finally blocks or guaranteed cleanup paths. Lock release and connection/transaction restoration are runner responsibilities.

A scoped maintenance operation

The example expects an existing DeploymentLog table. Choose a table that already exists at the selected stage. Fluent callbacks execute at the corresponding operation position.

Write a deployment marker
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -18,9 +18,9 @@
     public override void Up()
         => Database.Insert("DeploymentLog", new[] { "Message" }, new object[] { "Migration run finished" });
     public override void Down() { }
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
diff --git a/docs/guide/mysql.html b/docs/guide/mysql.html
index e2cb09af..ba814a71 100644
--- a/docs/guide/mysql.html
+++ b/docs/guide/mysql.html
@@ -5,12 +5,12 @@
 Migrator.NET
 
 
-

MySQL and MariaDB

Related providers with explicit engine, collation and DDL transaction differences.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose the matching dialect

Select ProviderTypes.Mysql for MySQL and MariaDB for MariaDB. Use an open driver connection or configure the factory. Do not treat compatible wire protocols as proof of identical server syntax or metadata behavior. DDL can commit implicitly; the runner rejects WholeSession for these dialects.

Select a supported collation

Semantic presets require utf8mb4-compatible text and the documented server versions: MySQL 8 and MariaDB 10.10+ have different mappings. Use a named installed collation if exact linguistic or trailing-space behavior matters.

Case-insensitive, accent-sensitive text
-

Classic

-
Database.AddTable("Labels", new Column("Name", DbType.String, 100)
+

MySQL and MariaDB

Related providers with explicit engine, collation and DDL transaction differences.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose the matching dialect

Select ProviderTypes.Mysql for MySQL and MariaDB for MariaDB. Use an open driver connection or configure the factory. Do not treat compatible wire protocols as proof of identical server syntax or metadata behavior. DDL can commit implicitly; the runner rejects WholeSession for these dialects.

Select a supported collation

Semantic presets require utf8mb4-compatible text and the documented server versions: MySQL 8 and MariaDB 10.10+ have different mappings. Use a named installed collation if exact linguistic or trailing-space behavior matters.

Case-insensitive, accent-sensitive text
+

Classic

+
Database.AddTable("Labels", new Column("Name", DbType.String, 100)
 {
     Collation = Collation.CaseInsensitive
-});
-

Fluent

-
migration.Create.Table("Labels").WithColumn("Name").AsString(100)
+});
+

Fluent

+
migration.Create.Table("Labels").WithColumn("Name").AsString(100)
     .WithCollation(Collation.CaseInsensitive);

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint metadata

MySQL reports primary keys as PRIMARY even if the migration supplied a symbolic name. MySQL/MariaDB catalogs expose unique indexes as unique constraints, so metadata cannot recover every original CREATE UNIQUE INDEX versus UNIQUE-clause choice. Do not derive ownership from that distinction.

Locking and values

DatabaseMigrationLock uses named session locks. These coordinate one server, not a distributed cluster. Interval values use signed .NET ticks. String overflow behavior depends on SQL mode; boundary CI uses STRICT_ALL_TABLES. Check server settings when evaluating length and decimal errors.

diff --git a/docs/guide/oracle.html b/docs/guide/oracle.html index ef3e56f5..e4f0fc20 100644 --- a/docs/guide/oracle.html +++ b/docs/guide/oracle.html @@ -5,14 +5,14 @@ Migrator.NET -

Oracle

Preserve explicit constraints and be deliberate about identity, sequences and implicit DDL commits.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Connection and schema

Use ProviderTypes.Oracle with the Oracle managed ADO.NET driver and the intended schema. MsOracle is a historical variant. Oracle DDL is not generally atomic across a migration; WholeSession is rejected. Some quoted qualified metadata lookups are explicitly rejected.

Create an identity definition

Identity is a column attribute and is validated before table creation. It need not be a primary key on every engine, but the example pairs it with an explicit key. Use a server/driver combination qualified for native identity.

An identity table with an explicit key
-

Classic

-
Database.AddTable("Entries",
+

Oracle

Preserve explicit constraints and be deliberate about identity, sequences and implicit DDL commits.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Connection and schema

Use ProviderTypes.Oracle with the Oracle managed ADO.NET driver and the intended schema. MsOracle is a historical variant. Oracle DDL is not generally atomic across a migration; WholeSession is rejected. Some quoted qualified metadata lookups are explicitly rejected.

Create an identity definition

Identity is a column attribute and is validated before table creation. It need not be a primary key on every engine, but the example pairs it with an explicit key. Use a server/driver combination qualified for native identity.

An identity table with an explicit key
+

Classic

+
Database.AddTable("Entries",
     new Column("Id", DbType.Int32) { IsIdentity = true, IsNullable = false },
     new Column("Text", DbType.String, 255),
-    new PrimaryKeyConstraint("PK_Entries", "Id"));
-

Fluent

-
migration.Create.Table("Entries")
+    new PrimaryKeyConstraint("PK_Entries", "Id"));
+

Fluent

+
migration.Create.Table("Entries")
     .WithColumn("Id").AsInt32().Identity().NotNullable()
     .WithColumn("Text").AsString(255)
     .WithPrimaryKey("PK_Entries", "Id");

Inside Up() / BuildUp(MigrationBuilder migration)

Object cleanup

RemoveTable leaves unrelated sequences intact. Oracle removes table-owned triggers and native identity objects. For a legacy sequence that the migration explicitly owns, OracleTransformationProvider.RemoveTableWithOwnedSequences validates named sequences and propagates cleanup errors. It does not infer sequence ownership from naming patterns.

Values and options

Oracle empty character strings become NULL. Time uses DATE with a fixed 1970-01-01 date and whole-second precision; fractional Time inputs are rejected. Intervals use native storage. Changes that require an unsupported in-place type conversion need an explicit data migration.

Included/clustered index options are rejected rather than ignored. Ordered foreign-key pairs and delete actions are preserved by structured metadata. A SQL Server clustered-index request is not translated into an Oracle index-organized table.

diff --git a/docs/guide/postgresql.html b/docs/guide/postgresql.html index f2d8db34..e5044df8 100644 --- a/docs/guide/postgresql.html +++ b/docs/guide/postgresql.html @@ -5,12 +5,12 @@ Migrator.NET -

PostgreSQL

Native intervals, schema-aware metadata, transactional DDL and advisory locks.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Connect with Npgsql

Use ProviderTypes.PostgreSQL and an open Npgsql connection, with the intended default schema. Connection search_path affects unqualified relation lookup. Metadata readers resolve the requested relation through PostgreSQL and distinguish same-named tables in different schemas.

Use native interval values

PostgreSQL maps duration values to native intervals. Time without time zone maps to a time of day; use TimeOnly for that input. Parameter mappings and scalar CLR return types are separate concerns: raw ADO.NET values remain driver-specific.

Store a job duration
-

Classic

-
Database.AddColumn("Jobs", new Column("Elapsed", MigratorDbType.Interval)
+

PostgreSQL

Native intervals, schema-aware metadata, transactional DDL and advisory locks.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Connect with Npgsql

Use ProviderTypes.PostgreSQL and an open Npgsql connection, with the intended default schema. Connection search_path affects unqualified relation lookup. Metadata readers resolve the requested relation through PostgreSQL and distinguish same-named tables in different schemas.

Use native interval values

PostgreSQL maps duration values to native intervals. Time without time zone maps to a time of day; use TimeOnly for that input. Parameter mappings and scalar CLR return types are separate concerns: raw ADO.NET values remain driver-specific.

Store a job duration
+

Classic

+
Database.AddColumn("Jobs", new Column("Elapsed", MigratorDbType.Interval)
 {
     DefaultValue = TimeSpan.FromDays(2)
-});
-

Fluent

-
migration.Create.Column("Elapsed").OnTable("Jobs").OfType(MigratorDbType.Interval)
+});
+

Fluent

+
migration.Create.Column("Elapsed").OnTable("Jobs").OfType(MigratorDbType.Interval)
     .WithDefaultValue(TimeSpan.FromDays(2));

Inside Up() / BuildUp(MigrationBuilder migration)

Collations and schemas

Create any ICU nondeterministic collation explicitly, then select it with Collation.Named. Column rendering does not silently create shared collation objects. Binary maps to C; language and case semantics should use a specific installed name.

Schema-aware metadata does not establish complete qualification for every operation. Test quoted names and search-path behavior with your migration. Renaming a table retains its named constraints; avoid colliding names when recreating the old table.

Transactions and coordination

WholeSession is supported for verified transactional DDL, and DatabaseMigrationLock uses a session advisory lock. Statements that require special transaction treatment need a separate deployment design. Keep the connection stable while the lease is held.

diff --git a/docs/guide/preview.html b/docs/guide/preview.html index 2f82ca69..3800d20a 100644 --- a/docs/guide/preview.html +++ b/docs/guide/preview.html @@ -5,22 +5,22 @@ Migrator.NET -

Planning and SQL preview

A version plan answers what runs. SQL preview shows the supported operation SQL.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Read-only version planning

Plan and DryRun inspect applied versions through IMigrationHistory without creating/upgrading history or invoking migration bodies, callbacks, transactions or SQLite PRAGMA changes. Set the same scope, tags and assembly you intend to deploy. The fragment below assumes an initialized runner.

Inspect version steps
-

Classic

-
var plan = runner.Plan(10);
+

Planning and SQL preview

A version plan answers what runs. SQL preview shows the supported operation SQL.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Read-only version planning

Plan and DryRun inspect applied versions through IMigrationHistory without creating/upgrading history or invoking migration bodies, callbacks, transactions or SQLite PRAGMA changes. Set the same scope, tags and assembly you intend to deploy. The fragment below assumes an initialized runner.

Inspect version steps
+

Classic

+
var plan = runner.Plan(10);
 foreach (var step in plan)
     Console.WriteLine($"{step.Version}: {(step.IsUp ? "up" : "down")}");
 runner.DryRun = true;
-runner.MigrateTo(10);
-

Fluent

-
var plan = runner.Plan(10);
+runner.MigrateTo(10);
+

Fluent

+
var plan = runner.Plan(10);
 foreach (var step in plan)
     Console.WriteLine($"{step.Version}: {(step.IsUp ? "up" : "down")}");
 runner.DryRun = true;
-runner.MigrateTo(10);

Shared host · both styles

Preview connected SQL

PreviewSql reads connected history and schema. Classic bodies require explicit opt-in; provider calls are captured through a proxy that rejects unsupported access. Fluent authoring builds operations directly. This is trusted C# execution in both cases, not a security sandbox.

Generate operation SQL
-

Classic

-
var sql = runner.PreviewSql(10, ProviderTypes.SQLite, allowLegacyBodies: true);
-Console.WriteLine(sql);
-

Fluent

-
var sql = runner.PreviewSql(10, ProviderTypes.SQLite);
+runner.MigrateTo(10);

Shared host · both styles

Preview connected SQL

PreviewSql reads connected history and schema. Classic bodies require explicit opt-in; provider calls are captured through a proxy that rejects unsupported access. Fluent authoring builds operations directly. This is trusted C# execution in both cases, not a security sandbox.

Generate operation SQL
+

Classic

+
var sql = runner.PreviewSql(10, ProviderTypes.SQLite, allowLegacyBodies: true);
+Console.WriteLine(sql);
+

Fluent

+
var sql = runner.PreviewSql(10, ProviderTypes.SQLite);
 Console.WriteLine(sql);

Choose one authoring style

Offline generation and boundaries

MigrationSqlPreview.Generate can render supported operations without connecting; the CLI exposes --offline. Earlier structured create/rename operations update the planned schema. Raw SQL invalidates that knowledge, so later dependencies can fail.

Basic tables/columns, supported renames, simple indexes, inserts and raw SQL form the preview subset. Unsupported alterations, constraint changes, filters, callbacks and schema dependencies throw. InitializeOnce overrides are rejected rather than skipped silently. Post-commit callbacks do not run. Output contains operation SQL, not history guards or an idempotent deployment bundle.

diff --git a/docs/guide/profiles.html b/docs/guide/profiles.html index 0bdaf7e2..746446fb 100644 --- a/docs/guide/profiles.html +++ b/docs/guide/profiles.html @@ -5,9 +5,9 @@ Migrator.NET -

Profiles

Run explicitly selected work after versioned migrations without recording a version.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Define a named profile

A profile is useful for optional seed data or environment setup. It runs every time its name is selected. Make repeated execution deliberate: use an identifying predicate or other idempotent operation where appropriate.

A development seed profile
-

Classic

-
using System;
+

Profiles

Run explicitly selected work after versioned migrations without recording a version.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Define a named profile

A profile is useful for optional seed data or environment setup. It runs every time its name is selected. Make repeated execution deliberate: use an identifying predicate or other idempotent operation where appropriate.

A development seed profile
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -19,9 +19,9 @@
         new[] { "Id", "Name" }, new object[] { 1, "Ada" },
         new[] { "Id" }, new object[] { 1 });
     public override void Down() { }
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -35,10 +35,10 @@
             .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" })
             .IfNotExists(new[] { "Id" }, new object[] { 1 });
     public override void BuildDown(MigrationBuilder migration) { }
-}

Choose one authoring style

Select a profile

Run the demo profile
-

Classic

-
runner.Options.Profiles.Add("demo");
-runner.MigrateToLastVersion();
-

Fluent

-
runner.Options.Profiles.Add("demo");
+}

Choose one authoring style

Select a profile

Run the demo profile
+

Classic

+
runner.Options.Profiles.Add("demo");
+runner.MigrateToLastVersion();
+

Fluent

+
runner.Options.Profiles.Add("demo");
 runner.MigrateToLastVersion();

Shared host · both styles

Profiles accept Order and Scope. Execution orders by Order and then ordinal full type name. Profile execution uses Up and does not create a migration-version entry or use Down as an undo history. An auxiliary-only run preserves existing version history.

Execution versus repeatables

A selected profile runs because it was selected, not because its source checksum changed. Treat this separately from versioned migrations and checksum-based repeatable SQL. Offline CLI SQL generation rejects profiles because it cannot represent the complete lifecycle.

diff --git a/docs/guide/providers.html b/docs/guide/providers.html index 0995b7aa..2b73b5d1 100644 --- a/docs/guide/providers.html +++ b/docs/guide/providers.html @@ -5,14 +5,14 @@ Migrator.NET -

Provider overview

One authoring contract, explicit database behavior. Choose the driver and provider together.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Database families

DatabaseProviderTypesGuide
SQLiteSQLite / MonoSQLiteLive-schema reconstruction
SQL ServerSqlServer / SqlServer2005Constraints, batches and locks
PostgreSQLPostgreSQL / PostgreSQL82Schemas, types and locks
MySQL / MariaDBMysql / MariaDBDDL and collation behavior
OracleOracle / MsOracleIdentity and metadata
SAP HANAHanaAdditional providers
Db2 / Informix / Firebird / Ingres / SybaseIBM_DB2 / IBM_Informix / Firebird / Ingres / SybaseEngine-specific guidance

Bring a connection

Pass an open IDbConnection to ProviderFactory.Create. Both migration styles use that provider. Alternatively use the connection-string overload and configure providerName so the provider can resolve the ADO.NET factory. Use a matching driver and test the exact server version you deploy.

Provider selection · open connection supplied by the host
-

Classic

-
using var selectedProvider = ProviderFactory.Create(
+

Provider overview

One authoring contract, explicit database behavior. Choose the driver and provider together.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Database families

DatabaseProviderTypesGuide
SQLiteSQLite / MonoSQLiteLive-schema reconstruction
SQL ServerSqlServer / SqlServer2005Constraints, batches and locks
PostgreSQLPostgreSQL / PostgreSQL82Schemas, types and locks
MySQL / MariaDBMysql / MariaDBDDL and collation behavior
OracleOracle / MsOracleIdentity and metadata
SAP HANAHanaAdditional providers
Db2 / Informix / Firebird / Ingres / SybaseIBM_DB2 / IBM_Informix / Firebird / Ingres / SybaseEngine-specific guidance

Bring a connection

Pass an open IDbConnection to ProviderFactory.Create. Both migration styles use that provider. Alternatively use the connection-string overload and configure providerName so the provider can resolve the ADO.NET factory. Use a matching driver and test the exact server version you deploy.

Provider selection · open connection supplied by the host
+

Classic

+
using var selectedProvider = ProviderFactory.Create(
     ProviderTypes.PostgreSQL, connection, defaultSchema: "public", scope: "billing");
 var selectedRunner = new Migrator(selectedProvider, typeof(CreateUsers).Assembly, false);
-selectedRunner.MigrateToLastVersion();
-

Fluent

-
using var selectedProvider = ProviderFactory.Create(
+selectedRunner.MigrateToLastVersion();
+

Fluent

+
using var selectedProvider = ProviderFactory.Create(
     ProviderTypes.PostgreSQL, connection, defaultSchema: "public", scope: "billing");
 var selectedRunner = new Migrator(selectedProvider, typeof(CreateUsers).Assembly, false);
 selectedRunner.MigrateToLastVersion();

Shared host · both styles

Qualification

The CI matrix includes SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase and SAP HANA. Ingres and historical provider aliases have separate qualification needs. Read testing and the live-engine matrix for exact drivers and server setup.

Database support is operation-specific. Column types, collation presets, index options, DDL transactions and metadata readers can differ. Test stored values and preserved schema, not only the generated SQL.

diff --git a/docs/guide/schema.html b/docs/guide/schema.html index 5886643e..1bad8d79 100644 --- a/docs/guide/schema.html +++ b/docs/guide/schema.html @@ -5,23 +5,23 @@ Migrator.NET -

Schema inspection

Read the connected database before deciding what to change. Metadata is different from a model snapshot.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Inspect tables and columns

Classic migrations read through Database. FluentMigration exposes Schema for queries and Context for the full provider API. A fluent authoring method runs before its queued operations: an inspection cannot see a table merely queued earlier in the same builder.

Add a column only when it is missing
-

Classic

-
if (!Database.ColumnExists("Users", "Email"))
-    Database.AddColumn("Users", new Column("Email", DbType.String, 320));
-

Fluent

-
if (!Schema.Table("Users").ColumnExists("Email"))
-    migration.Create.Column("Email").OnTable("Users").AsString(320);

Inside Up() / BuildUp(MigrationBuilder migration)

Read ordered constraints

GetColumns returns inferred column attributes, not primary/unique membership flags. It is obsolete because native types and defaults cannot be mapped back to exact .NET definitions; use migration history for the original definition. Read typed table constraints to retain ordered composite keys. Unique indexes remain index metadata. MySQL/MariaDB catalogs cannot distinguish every original unique-index versus UNIQUE-clause authoring choice.

Read table constraint definitions
-

Classic

-
var constraints = Database.GetTableConstraints("Users");
+

Schema inspection

Read the connected database before deciding what to change. Metadata is different from a model snapshot.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Inspect tables and columns

Classic migrations read through Database. FluentMigration exposes Schema for queries and Context for the full provider API. A fluent authoring method runs before its queued operations: an inspection cannot see a table merely queued earlier in the same builder.

Add a column only when it is missing
+

Classic

+
if (!Database.ColumnExists("Users", "Email"))
+    Database.AddColumn("Users", new Column("Email", DbType.String, 320));
+

Fluent

+
if (!Schema.Table("Users").ColumnExists("Email"))
+    migration.Create.Column("Email").OnTable("Users").AsString(320);

Inside Up() / BuildUp(MigrationBuilder migration)

Read ordered constraints

GetColumns returns inferred column attributes, not primary/unique membership flags. It is obsolete because native types and defaults cannot be mapped back to exact .NET definitions; use migration history for the original definition. Read typed table constraints to retain ordered composite keys. Unique indexes remain index metadata. MySQL/MariaDB catalogs cannot distinguish every original unique-index versus UNIQUE-clause authoring choice.

Read table constraint definitions
+

Classic

+
var constraints = Database.GetTableConstraints("Users");
 foreach (var constraint in constraints)
-    Console.WriteLine(constraint.Name);
-

Fluent

-
var constraints = Schema.Table("Users").ConstraintDefinitions();
+    Console.WriteLine(constraint.Name);
+

Fluent

+
var constraints = Schema.Table("Users").ConstraintDefinitions();
 foreach (var constraint in constraints)
-    Console.WriteLine(constraint.Name);

Inside Up() / BuildUp(MigrationBuilder migration)

Create a view

ViewField selects columns from a base table. The alternative IViewElement overload represents explicit columns and joins. View definitions are provider-dependent and outside SQL preview and automatic reversal. Write a provider-appropriate DROP VIEW statement in the reverse method, and manage dependent views when changing their underlying tables.

A projection over Users
-

Classic

-
Database.AddView("UserNames", "Users", new ViewField("Id"), new ViewField("Name"));
-

Fluent

-
migration.Create.View("UserNames").FromTable("Users")
+    Console.WriteLine(constraint.Name);

Inside Up() / BuildUp(MigrationBuilder migration)

Create a view

ViewField selects columns from a base table. The alternative IViewElement overload represents explicit columns and joins. View definitions are provider-dependent and outside SQL preview and automatic reversal. Write a provider-appropriate DROP VIEW statement in the reverse method, and manage dependent views when changing their underlying tables.

A projection over Users
+

Classic

+
Database.AddView("UserNames", "Users", new ViewField("Id"), new ViewField("Name"));
+

Fluent

+
migration.Create.View("UserNames").FromTable("Users")
     .WithFields(new ViewField("Id"), new ViewField("Name"));

Inside Up() / BuildUp(MigrationBuilder migration)

Reads and portability

Dispose readers and commands obtained from the provider. Fluent Schema.Query and Schema.Table(name).Select accept a reader callback and handle disposal. Use Schema.Table(name).SelectScalar(columns, where) for a scalar selection. Use provider quoting helpers for table and column identifiers separately: quoting a table may introduce schema qualification, which is not valid for a column expression.

Metadata fidelity depends on the provider. Unsupported readers throw instead of pretending that an empty schema was found. A successful existence check is not a full schema-drift report.

diff --git a/docs/guide/sql-server.html b/docs/guide/sql-server.html index 5b0ca18e..5cc3be31 100644 --- a/docs/guide/sql-server.html +++ b/docs/guide/sql-server.html @@ -5,8 +5,8 @@ Migrator.NET -

SQL Server

Explicit keys, provider-specific indexes, transactional DDL and application locks.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Select the provider

Use ProviderTypes.SqlServer with an open Microsoft.Data.SqlClient connection. Pass the intended default schema, commonly dbo. Historical SqlServer2005 is a separate alias with older type mappings. WholeSession transactions and DatabaseMigrationLock are available for SQL Server.

Name constraints explicitly

Column changes preserve explicit constraints and indexes. Add/remove uniqueness independently. For a nonclustered primary key on an existing compatible table use the dedicated API shown below. Review existing clustered indexes before changing key layout.

Add a nonclustered primary key
-

Classic

-
Database.AddPrimaryKeyNonClustered("PK_Users", "Users", "Id");
-

Fluent

-
migration.Create.NonClusteredPrimaryKey("PK_Users").OnTable("Users").WithColumns("Id");

Inside Up() / BuildUp(MigrationBuilder migration)

Indexes and SQL batches

Index definitions can express included/filter/cluster options where supported. The script APIs split standalone GO lines; raw ExecuteNonQuery/Execute.Sql does not. SQLCMD directives and GO repetition are rejected before executing script batches. Prefer scripts for client batch syntax and commands for parameterized statements.

Types and object names

Use TimeOnly for time values and TimeSpan for interval ticks. SqlServer2005 uses its older DATETIME precision behavior. Use separate quoting helpers for table and column names. A table rename leaves named constraints/indexes attached with their old names; assign distinct names when creating a replacement table.

+

SQL Server

Explicit keys, provider-specific indexes, transactional DDL and application locks.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Select the provider

Use ProviderTypes.SqlServer with an open Microsoft.Data.SqlClient connection. Pass the intended default schema, commonly dbo. Historical SqlServer2005 is a separate alias with older type mappings. WholeSession transactions and DatabaseMigrationLock are available for SQL Server.

Name constraints explicitly

Column changes preserve explicit constraints and indexes. Add/remove uniqueness independently. For a nonclustered primary key on an existing compatible table use the dedicated API shown below. Review existing clustered indexes before changing key layout.

Add a nonclustered primary key
+

Classic

+
Database.AddPrimaryKeyNonClustered("PK_Users", "Users", "Id");
+

Fluent

+
migration.Create.NonClusteredPrimaryKey("PK_Users").OnTable("Users").WithColumns("Id");

Inside Up() / BuildUp(MigrationBuilder migration)

Indexes and SQL batches

Index definitions can express included/filter/cluster options where supported. The script APIs split standalone GO lines; raw ExecuteNonQuery/Execute.Sql does not. SQLCMD directives and GO repetition are rejected before executing script batches. Prefer scripts for client batch syntax and commands for parameterized statements.

Types and object names

Use TimeOnly for time values and TimeSpan for interval ticks. SqlServer2005 uses its older DATETIME precision behavior. Use separate quoting helpers for table and column names. A table rename leaves named constraints/indexes attached with their old names; assign distinct names when creating a replacement table.

diff --git a/docs/guide/sql.html b/docs/guide/sql.html index 826ffcf5..b305824b 100644 --- a/docs/guide/sql.html +++ b/docs/guide/sql.html @@ -5,16 +5,16 @@ Migrator.NET -

Execute SQL and scripts

Use schema operations where they fit, and keep database-specific SQL explicit.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Execute a statement

Raw SQL passes through to the selected database. It does not translate between dialects. Values from application input should be bound through a command; migration SQL is trusted application code.

A SQL data change
-

Classic

-
Database.ExecuteNonQuery("UPDATE Users SET Name = 'Unknown' WHERE Name IS NULL");
-

Fluent

-
migration.Execute.Sql("UPDATE Users SET Name = 'Unknown' WHERE Name IS NULL");

Inside Up() / BuildUp(MigrationBuilder migration)

Files and embedded resources

ExecuteScript reads a file; ExecuteResourceScript reads an assembly resource. Fluent equivalents capture script text as dedicated operations. Make files available at deployment and set resource names explicitly. Relative file paths are resolved against the process working directory.

Execute a SQL file
-

Classic

-
Database.ExecuteScript("Scripts/backfill.sql");
-

Fluent

-
migration.Execute.Script("Scripts/backfill.sql");

Inside Up() / BuildUp(MigrationBuilder migration)

Execute an embedded SQL resource
-

Classic

-
Database.ExecuteResourceScript(GetType().Assembly, "MyMigrations.Scripts.backfill.sql");
-

Fluent

-
migration.Execute.EmbeddedScript(GetType().Assembly, "MyMigrations.Scripts.backfill.sql");

Inside Up() / BuildUp(MigrationBuilder migration)

For the second example mark backfill.sql as an EmbeddedResource in the migration project and use its actual manifest resource name. Missing resources fail before script execution.

Client batch separators

The script APIs split standalone SQL Server GO lines, including optional line comments, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail before any batches execute. ExecuteNonQuery and Execute.Sql do not split client separators.

Other providers receive one command unless they implement IScriptBatchProvider. A database SQL file is not necessarily compatible with SQL*Plus, mysql-client or isql command syntax. Raw SQL also invalidates planned schema knowledge during SQL preview.

+

Execute SQL and scripts

Use schema operations where they fit, and keep database-specific SQL explicit.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Execute a statement

Raw SQL passes through to the selected database. It does not translate between dialects. Values from application input should be bound through a command; migration SQL is trusted application code.

A SQL data change
+

Classic

+
Database.ExecuteNonQuery("UPDATE Users SET Name = 'Unknown' WHERE Name IS NULL");
+

Fluent

+
migration.Execute.Sql("UPDATE Users SET Name = 'Unknown' WHERE Name IS NULL");

Inside Up() / BuildUp(MigrationBuilder migration)

Files and embedded resources

ExecuteScript reads a file; ExecuteResourceScript reads an assembly resource. Fluent equivalents capture script text as dedicated operations. Make files available at deployment and set resource names explicitly. Relative file paths are resolved against the process working directory.

Execute a SQL file
+

Classic

+
Database.ExecuteScript("Scripts/backfill.sql");
+

Fluent

+
migration.Execute.Script("Scripts/backfill.sql");

Inside Up() / BuildUp(MigrationBuilder migration)

Execute an embedded SQL resource
+

Classic

+
Database.ExecuteResourceScript(GetType().Assembly, "MyMigrations.Scripts.backfill.sql");
+

Fluent

+
migration.Execute.EmbeddedScript(GetType().Assembly, "MyMigrations.Scripts.backfill.sql");

Inside Up() / BuildUp(MigrationBuilder migration)

For the second example mark backfill.sql as an EmbeddedResource in the migration project and use its actual manifest resource name. Missing resources fail before script execution.

Client batch separators

The script APIs split standalone SQL Server GO lines, including optional line comments, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail before any batches execute. ExecuteNonQuery and Execute.Sql do not split client separators.

Other providers receive one command unless they implement IScriptBatchProvider. A database SQL file is not necessarily compatible with SQL*Plus, mysql-client or isql command syntax. Raw SQL also invalidates planned schema knowledge during SQL preview.

diff --git a/docs/guide/sqlite.html b/docs/guide/sqlite.html index e8d48357..9399be2a 100644 --- a/docs/guide/sqlite.html +++ b/docs/guide/sqlite.html @@ -5,14 +5,14 @@ Migrator.NET -

SQLite

Change existing tables from the live schema, without maintaining an ORM model.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Automatic reconstruction

For supported changes, Migrator reads SQLiteTableInfo, changes its representation, creates a replacement table, copies mapped rows, swaps tables and recreates represented dependent objects. This provides column type/default/nullability changes and adding/removing primary, foreign, unique and check constraints.

Native rename and eligible drop-column paths are used when supported by the engine. Complex alterations use reconstruction. Existing rows must satisfy the new definition; a default does not rewrite every existing NULL during a column change.

Change a column on an existing SQLite table
-

Classic

-
Database.ChangeColumn("Users", new Column("Name", DbType.String, 500)
+

SQLite

Change existing tables from the live schema, without maintaining an ORM model.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Automatic reconstruction

For supported changes, Migrator reads SQLiteTableInfo, changes its representation, creates a replacement table, copies mapped rows, swaps tables and recreates represented dependent objects. This provides column type/default/nullability changes and adding/removing primary, foreign, unique and check constraints.

Native rename and eligible drop-column paths are used when supported by the engine. Complex alterations use reconstruction. Existing rows must satisfy the new definition; a default does not rewrite every existing NULL during a column change.

Change a column on an existing SQLite table
+

Classic

+
Database.ChangeColumn("Users", new Column("Name", DbType.String, 500)
 {
     IsNullable = false, DefaultValue = "Unknown",
     Collation = Collation.AsciiIgnoreCase
-});
-

Fluent

-
migration.Alter.Column("Name").OnTable("Users")
+});
+

Fluent

+
migration.Alter.Column("Name").OnTable("Users")
     .AsString(500).NotNullable().WithDefaultValue("Unknown")
     .WithCollation(Collation.AsciiIgnoreCase);

Inside Up() / BuildUp(MigrationBuilder migration)

What survives a rebuild

DetailBehavior
Mapped dataNamed-column copy preserves mapped values, subject to the new definition accepting them.
Keys and constraintsNamed/composite keys, ordered foreign-key pairs and separate update/delete actions are retained.
Column collationsDeclared names are retained. Register custom collations on the connection.
Indexes and triggersSupported definitions are recreated; unsafe trigger rename/drop-column cases are rejected.
AUTOINCREMENTThe sequence high-water mark survives, including previously deleted identities.
Hidden rowidNot part of the mapped data and may change.

Boundaries are explicit

Reconstruction rejects generated columns, STRICT, WITHOUT ROWID and indexes with explicit COLLATE clauses. It is not an arbitrary SQL dependency rewriter. Adjust dependent views, complex expressions and triggers explicitly when required. MATCH FULL and MATCH PARTIAL are rejected because SQLite does not enforce their semantics.

Owned rebuild transactions and runner transactions validate foreign-key integrity before commit and restore the prior enforcement setting. For caller-owned active transactions configure foreign keys before beginning the transaction. A SQLite write lock is not a session-wide migration lease; coordinate deployment externally or provide IMigrationLock.

Values and identity

CLR Guid defaults use blobs from Guid.ToByteArray(), matching inserted parameters. Legacy text GUID defaults remain SQL expressions during unrelated rebuilds, so storage is not silently converted. Convert mixed text/blob keys explicitly and consistently across related tables.

SQLite INTEGER is signed 64-bit. Declared text lengths and decimal precision do not impose SQL Server-like enforcement. An identity needs a single INTEGER primary key in the same definition. For adding identity to an existing table, use an atomic SQLite RecreateTable definition containing both objects.

How this differs from other tools

FluentMigrator leaves general column alterations and later foreign-key changes to manual reconstruction. DbUp and Evolve run supplied scripts. EF Core also rebuilds SQLite tables using model-represented artifacts. Migrator reconstructs from live metadata without an ORM. The sourced operation comparison distinguishes native SQL, emulation and manual work.

diff --git a/docs/guide/tags.html b/docs/guide/tags.html index 85bb6f09..f58ed6a6 100644 --- a/docs/guide/tags.html +++ b/docs/guide/tags.html @@ -5,9 +5,9 @@ Migrator.NET -

Tags

Select a subset of versioned migrations using explicit ordinal names.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Tag migration classes

Tags is in DotNetProjects.Migrator. One class can declare multiple names. Choose names for deployment intent such as core or reporting; do not use a tag to hide a dependency that a selected migration still requires.

A reporting migration
-

Classic

-
using System;
+

Tags

Select a subset of versioned migrations using explicit ordinal names.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Tag migration classes

Tags is in DotNetProjects.Migrator. One class can declare multiple names. Choose names for deployment intent such as core or reporting; do not use a tag to hide a dependency that a selected migration still requires.

A reporting migration
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -17,9 +17,9 @@
 {
     public override void Up() => Database.AddTable("ReportLog", new Column("Name", DbType.String, 255));
     public override void Down() => Database.RemoveTable("ReportLog");
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -32,12 +32,12 @@
         => migration.Create.Table("ReportLog").WithColumn("Name").AsString(255);
     public override void BuildDown(MigrationBuilder migration)
         => migration.Delete.Table("ReportLog");
-}

Choose one authoring style

Configure matching

Select tags on the runner
-

Classic

-
runner.Options.Tags.Add("reporting");
+}

Choose one authoring style

Configure matching

Select tags on the runner
+

Classic

+
runner.Options.Tags.Add("reporting");
 runner.Options.TagMatch = TagMatchMode.Any;
-runner.MigrateToLastVersion();
-

Fluent

-
runner.Options.Tags.Add("reporting");
+runner.MigrateToLastVersion();
+

Fluent

+
runner.Options.Tags.Add("reporting");
 runner.Options.TagMatch = TagMatchMode.Any;
 runner.MigrateToLastVersion();

Shared host · both styles

Any requires at least one selected tag; All requires every selected tag. Matching is ordinal and case-sensitive. Without a tag filter all eligible versioned migrations are selected. Profiles have their own explicit name selection.

Downgrade behavior

Applied versions excluded by the active filter remain applied during downgrade. A filtered run is therefore not a promise that the whole database matches one contiguous global version range. Keep deployment filters stable and inspect the plan before reversing selected changes.

diff --git a/docs/guide/testing.html b/docs/guide/testing.html index a9890408..f6f4fd97 100644 --- a/docs/guide/testing.html +++ b/docs/guide/testing.html @@ -5,15 +5,15 @@ Migrator.NET -

Testing and deployment

Verify stored data, preserved schema and repeat execution on the actual target engine.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Test a migration lifecycle

Create a disposable database, apply the migration, check the schema and rows, run to the same target again, then downgrade and verify the intended reverse. Both authoring styles use the same runner. This fragment assumes an initialized runner whose migration set creates Users.

A host-level smoke check
-

Classic

-
runner.MigrateToLastVersion();
+

Testing and deployment

Verify stored data, preserved schema and repeat execution on the actual target engine.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Test a migration lifecycle

Create a disposable database, apply the migration, check the schema and rows, run to the same target again, then downgrade and verify the intended reverse. Both authoring styles use the same runner. This fragment assumes an initialized runner whose migration set creates Users.

A host-level smoke check
+

Classic

+
runner.MigrateToLastVersion();
 if (!provider.TableExists("Users")) throw new Exception("Users missing");
 runner.MigrateToLastVersion();
 runner.MigrateTo(0);
-if (provider.TableExists("Users")) throw new Exception("Users was not removed");
-

Fluent

-
runner.MigrateToLastVersion();
+if (provider.TableExists("Users")) throw new Exception("Users was not removed");
+

Fluent

+
runner.MigrateToLastVersion();
 if (!provider.TableExists("Users")) throw new Exception("Users missing");
 runner.MigrateToLastVersion();
 runner.MigrateTo(0);
diff --git a/docs/guide/transactions.html b/docs/guide/transactions.html
index 39f8783d..f231db99 100644
--- a/docs/guide/transactions.html
+++ b/docs/guide/transactions.html
@@ -5,18 +5,18 @@
 Migrator.NET
 
 
-

Transactions and locks

Transaction rollback and cross-process coordination solve different problems.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose the transaction boundary

ModeBehavior
PerMigrationDefault. Each successful migration commits independently.
NoneProvider/operation transaction behavior; no runner-managed migration transaction.
WholeSessionOne session transaction on SQLite, PostgreSQL or SQL Server; history initialization happens first.

Actual atomicity depends on the database and operation. Administration commands or implicit-commit DDL can violate assumptions. AfterUp/AfterDown run after commit; WholeSession defers them until the session commit. A callback failure cannot undo durable changes.

Configure a session transaction
-

Classic

-
runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
-runner.MigrateToLastVersion();
-

Fluent

-
runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
-runner.MigrateToLastVersion();

Shared host · both styles

Coordinate competing runners

DatabaseMigrationLock uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. The lease is session-owned and remains held across migration commits. This host fragment assumes a supported provider; SQLite rejects this built-in lock.

Acquire a native deployment lock
-

Classic

-
runner.Options.Lock = new DatabaseMigrationLock();
+

Transactions and locks

Transaction rollback and cross-process coordination solve different problems.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose the transaction boundary

ModeBehavior
PerMigrationDefault. Each successful migration commits independently.
NoneProvider/operation transaction behavior; no runner-managed migration transaction.
WholeSessionOne session transaction on SQLite, PostgreSQL or SQL Server; history initialization happens first.

Actual atomicity depends on the database and operation. Administration commands or implicit-commit DDL can violate assumptions. AfterUp/AfterDown run after commit; WholeSession defers them until the session commit. A callback failure cannot undo durable changes.

Configure a session transaction
+

Classic

+
runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
+runner.MigrateToLastVersion();
+

Fluent

+
runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
+runner.MigrateToLastVersion();

Shared host · both styles

Coordinate competing runners

DatabaseMigrationLock uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. The lease is session-owned and remains held across migration commits. This host fragment assumes a supported provider; SQLite rejects this built-in lock.

Acquire a native deployment lock
+

Classic

+
runner.Options.Lock = new DatabaseMigrationLock();
 runner.Options.LockTimeout = TimeSpan.FromSeconds(60);
-runner.MigrateToLastVersion();
-

Fluent

-
runner.Options.Lock = new DatabaseMigrationLock();
+runner.MigrateToLastVersion();
+

Fluent

+
runner.Options.Lock = new DatabaseMigrationLock();
 runner.Options.LockTimeout = TimeSpan.FromSeconds(60);
 runner.MigrateToLastVersion();

Shared host · both styles

Scope of protection

Native locks are keyed by database, history table and scope. Coordinate separately if different scopes modify shared objects. Do not close/replace the connection, switch databases or manipulate the native lock inside a migration. MySQL named locks coordinate one server, not an entire distributed cluster.

Implement IMigrationLock for another lease mechanism, or serialize deployments outside the process. A transaction, history primary key or ordinary database write lock alone does not prove that the whole migration sequence is serialized.

diff --git a/docs/guide/versioning.html b/docs/guide/versioning.html index afa92a68..2c776edc 100644 --- a/docs/guide/versioning.html +++ b/docs/guide/versioning.html @@ -5,9 +5,9 @@ Migrator.NET -

Versioning and scoped history

Number changes, keep applied source immutable and give independent modules explicit histories.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose a version scheme

Migration accepts a numeric version or year/month/day/hour/minute/second components. Use one monotonic scheme per migration set. The date constructor builds a numeric identifier; it does not consult a clock or resolve branch collisions for you. Missing lower-numbered versions up to the target can still be applied.

A dated migration
-

Classic

-
using System;
+

Versioning and scoped history

Number changes, keep applied source immutable and give independent modules explicit histories.

Examples use Classic and Fluent tabs. Your choice follows you through the manual.

Choose a version scheme

Migration accepts a numeric version or year/month/day/hour/minute/second components. Use one monotonic scheme per migration set. The date constructor builds a numeric identifier; it does not consult a clock or resolve branch collisions for you. Missing lower-numbered versions up to the target can still be applied.

A dated migration
+

Classic

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -17,9 +17,9 @@
 {
     public override void Up() => Database.AddColumn("Users", new Column("Email", DbType.String, 320));
     public override void Down() => Database.RemoveColumn("Users", "Email");
-}
-

Fluent

-
using System;
+}
+

Fluent

+
using System;
 using System.Data;
 using DotNetProjects.Migrator;
 using DotNetProjects.Migrator.Framework;
@@ -32,8 +32,8 @@
         => migration.Create.Column("Email").OnTable("Users").AsString(320);
     public override void BuildDown(MigrationBuilder migration)
         => migration.Delete.Column("Email").FromTable("Users");
-}

Choose one authoring style

Scope selection

An explicit MigrationAttribute.Scope selects that migration only for the matching provider scope. Unscoped migrations inherit the runner scope. Discovery, duplicate validation and history reads use the effective scope. Duplicate numeric versions in distinct explicit scopes are independent; physical tables are not isolated.

Set SchemaInfoTableName before any history access if you need a different table. AppliedMigrations lists recorded versions; LastAppliedMigrationVersion is nullable when history is empty. AssemblyLastMigrationVersion describes the loaded set.

Consolidated baselines

A baseline can record versions whose schema it already includes. The runner rechecks active-scope history before each planned step, skipping newly covered versions and their AfterUp callbacks. Downward runs similarly skip versions removed by an earlier Down. Recording the baseline version itself does not create a duplicate.

Mark a version included by a baseline
-

Classic

-
Database.MigrationApplied(1, "billing");
-

Fluent

-
migration.Execute.WithProvider(provider => provider.MigrationApplied(1, "billing"));

Inside Up() / BuildUp(MigrationBuilder migration)

Only record a version after establishing the schema it represents. History entries are not a substitute for verifying an existing database. Schema/history rollback follows the selected transaction mode. No migration-content checksum is stored.

+}

Choose one authoring style

Scope selection

An explicit MigrationAttribute.Scope selects that migration only for the matching provider scope. Unscoped migrations inherit the runner scope. Discovery, duplicate validation and history reads use the effective scope. Duplicate numeric versions in distinct explicit scopes are independent; physical tables are not isolated.

Set SchemaInfoTableName before any history access if you need a different table. AppliedMigrations lists recorded versions; LastAppliedMigrationVersion is nullable when history is empty. AssemblyLastMigrationVersion describes the loaded set.

Consolidated baselines

A baseline can record versions whose schema it already includes. The runner rechecks active-scope history before each planned step, skipping newly covered versions and their AfterUp callbacks. Downward runs similarly skip versions removed by an earlier Down. Recording the baseline version itself does not create a duplicate.

Mark a version included by a baseline
+

Classic

+
Database.MigrationApplied(1, "billing");
+

Fluent

+
migration.Execute.WithProvider(provider => provider.MigrationApplied(1, "billing"));

Inside Up() / BuildUp(MigrationBuilder migration)

Only record a version after establishing the schema it represents. History entries are not a substitute for verifying an existing database. Schema/history rollback follows the selected transaction mode. No migration-content checksum is stored.

diff --git a/docs/index.html b/docs/index.html index 62b174ba..8165c413 100644 --- a/docs/index.html +++ b/docs/index.html @@ -62,15 +62,15 @@

Database changes,
written in C#.

A PARTICULAR STRENGTH / SQLITE

A small database.
Room to evolve.

Change the schema you have. Without an ORM model.

Migrator reads SQLite’s live schema and automatically reconstructs tables for supported changes to column types, defaults and nullability, and primary, foreign, unique and check constraints.

Existing rows are copied and supported schema artifacts are preserved. You describe the change; the provider handles the rebuild.

FluentMigrator requires manual reconstruction for general column alterations and later foreign-key changes. DbUp and Evolve leave it to your scripts. EF Core also rebuilds tables, using model metadata.

Read the SQLite guide and preservation limits ↗
See the sourced operation comparison →
-
TABLE / UsersΔ 002

IdINTEGER · PRIMARY KEY

Name255 500 · NOT NULL

↳ Existing rows travel with the schema.

Change a column on an existing SQLite table
-

Classic

-
Database.ChangeColumn("Users", new Column("Name", DbType.String, 500)
+    
TABLE / UsersΔ 002

IdINTEGER · PRIMARY KEY

Name255 500 · NOT NULL

↳ Existing rows travel with the schema.

Change a column on an existing SQLite table
+

Classic

+
Database.ChangeColumn("Users", new Column("Name", DbType.String, 500)
 {
     IsNullable = false, DefaultValue = "Unknown",
     Collation = Collation.AsciiIgnoreCase
-});
-

Fluent

-
migration.Alter.Column("Name").OnTable("Users")
+});
+

Fluent

+
migration.Alter.Column("Name").OnTable("Users")
     .AsString(500).NotNullable().WithDefaultValue("Unknown")
     .WithCollation(Collation.AsciiIgnoreCase);

Inside Up() / BuildUp(MigrationBuilder migration)

diff --git a/src/Migrator.Tests/DeleteDuplicateRowsLiveTests.cs b/src/Migrator.Tests/DeleteDuplicateRowsLiveTests.cs new file mode 100644 index 00000000..a713740a --- /dev/null +++ b/src/Migrator.Tests/DeleteDuplicateRowsLiveTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Settings; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture(ProviderTypes.SqlServer, "SQLServer", Category = "SQLServer")] +[TestFixture(ProviderTypes.PostgreSQL, "PostgreSQL", Category = "PostgreSQL")] +[TestFixture(ProviderTypes.Oracle, "Oracle", Category = "Oracle")] +public class DeleteDuplicateRowsLiveTests(ProviderTypes type, string configurationId) +{ + [TestCase(false, DuplicateNullHandling.Equal, 4)] + [TestCase(false, DuplicateNullHandling.ExcludeNullKeys, 2)] + [TestCase(true, DuplicateNullHandling.Equal, 4)] + [TestCase(true, DuplicateNullHandling.ExcludeNullKeys, 2)] + public void DeletesOnlyDuplicateCompositeKeys(bool fluent, DuplicateNullHandling nulls, int expected) + { + var config = new ConfigurationReader().GetDatabaseConnectionConfigById(configurationId); + using IDbConnection connection = type switch + { + ProviderTypes.SqlServer => new Microsoft.Data.SqlClient.SqlConnection(config.ConnectionString), + ProviderTypes.PostgreSQL => new Npgsql.NpgsqlConnection(config.ConnectionString), + _ => new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString) + }; + connection.Open(); + using var provider = ProviderFactory.Create(type, connection, config.Schema); + string table = "Dedup_" + Guid.NewGuid().ToString("N")[..12]; + provider.AddTable(table, new Column("Key One", DbType.Int32), new Column("select", DbType.Int32), new Column("Payload", DbType.String, 20)); + try + { + object[][] rows = [[1,1,"one"],[1,1,"two"],[1,1,"three"],[1,2,"different"], + [null,1,"n1"],[null,1,"n2"],[null,null,"n3"],[null,null,"n4"],[9,9,"unique"]]; + foreach (var row in rows) provider.Insert(table, ["Key One", "select", "Payload"], row); + if (fluent) + { + var builder = new MigrationBuilder(); + builder.Delete.DuplicateRows().FromTable(table).ByColumns("Key One", "select").KeepAny(nulls); + builder.Apply(provider); + } + else Assert.That(provider.DeleteDuplicateRows(table, ["Key One", "select"], DuplicateRowRetention.Any, nulls), Is.EqualTo(expected)); + string quotedTable = provider.QuoteTableNameIfRequired(table); + Assert.That(Convert.ToInt32(provider.ExecuteScalar($"SELECT COUNT(*) FROM {quotedTable}")), Is.EqualTo(9 - expected)); + Assert.That(Convert.ToString(provider.ExecuteScalar($"SELECT {provider.QuoteColumnNameIfRequired("Payload")} FROM {quotedTable} WHERE {provider.QuoteColumnNameIfRequired("Key One")}=9")), Is.EqualTo("unique")); + Assert.That(provider.DeleteDuplicateRows(table, ["Key One", "select"], DuplicateRowRetention.Any, nulls), Is.Zero); + } + finally { provider.RemoveTable(table); } + } +} diff --git a/src/Migrator.Tests/DeleteDuplicateRowsTests.cs b/src/Migrator.Tests/DeleteDuplicateRowsTests.cs new file mode 100644 index 00000000..ec04bbc5 --- /dev/null +++ b/src/Migrator.Tests/DeleteDuplicateRowsTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +[TestFixture(false)] +[TestFixture(true)] +public class DeleteDuplicateRowsTests(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:"); + connection.Open(); + provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery(""" + CREATE TABLE "Duplicate Rows" ("Key One" TEXT, "select" INTEGER, Payload TEXT); + CREATE INDEX IX_Duplicates ON "Duplicate Rows"("Key One"); + INSERT INTO "Duplicate Rows" VALUES ('a',1,'one'),('a',1,'two'),('a',1,'three'),('a',2,'different'), + (NULL,1,'n1'),(NULL,1,'n2'),(NULL,NULL,'n3'),(NULL,NULL,'n4'),('z',3,'unique'); + """); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [TestCase(DuplicateNullHandling.Equal, 4)] + [TestCase(DuplicateNullHandling.ExcludeNullKeys, 2)] + public void DeletesOnlyDuplicatesAndReportsAffectedRows(DuplicateNullHandling nulls, int expected) + { + int removed = provider.DeleteDuplicateRows("Duplicate Rows", ["Key One", "select"], DuplicateRowRetention.Any, nulls); + Assert.That(removed, Is.EqualTo(expected)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Duplicate Rows\""), Is.EqualTo(9 - expected)); + Assert.That(provider.ExecuteScalar("SELECT Payload FROM \"Duplicate Rows\" WHERE \"select\"=3"), Is.EqualTo("unique")); + Assert.That(provider.ExecuteScalar("SELECT Payload FROM \"Duplicate Rows\" WHERE \"select\"=2"), Is.EqualTo("different")); + Assert.That(provider.GetIndexes("Duplicate Rows").Any(index => index.Name == "IX_Duplicates"), Is.True); + Assert.That(provider.DeleteDuplicateRows("Duplicate Rows", ["Key One", "select"], DuplicateRowRetention.Any, nulls), Is.Zero); + } + + [Test] + public void InvalidKeysLeaveRowsUntouched() + { + foreach (var keys in new[] { Array.Empty(), new[] { "missing" }, new[] { "select", "SELECT" } }) + Assert.Catch(() => provider.DeleteDuplicateRows("Duplicate Rows", keys, DuplicateRowRetention.Any)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Duplicate Rows\""), Is.EqualTo(9)); + } + + [Test] + public void RowIdShadowingUsesAnUnshadowedPhysicalIdentifier() + { + provider.ExecuteNonQuery("CREATE TABLE Shadow (rowid TEXT, K INTEGER); INSERT INTO Shadow VALUES ('same',1),('same',1)"); + Assert.That(provider.DeleteDuplicateRows("Shadow", ["K"], DuplicateRowRetention.Any), Is.EqualTo(1)); + } + + [TestCase("CREATE TABLE Unsupported (K INTEGER, Id INTEGER PRIMARY KEY) WITHOUT ROWID")] + [TestCase("CREATE TABLE Unsupported (K INTEGER, Id INTEGER, rowid TEXT, _rowid_ TEXT, oid TEXT)")] + public void UnsupportedRowIdentityIsRejectedWithoutDeleting(string create) + { + provider.ExecuteNonQuery(create); + provider.ExecuteNonQuery("INSERT INTO Unsupported (K, Id) VALUES (1,1),(1,2)"); + Assert.Throws(() => provider.DeleteDuplicateRows("Unsupported", ["K"], DuplicateRowRetention.Any)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Unsupported"), Is.EqualTo(2)); + } + + [TestCase(DuplicateNullHandling.Equal, 5)] + [TestCase(DuplicateNullHandling.ExcludeNullKeys, 7)] + public void FluentOperationSnapshotsKeysAndMatchesImperativeBehavior(DuplicateNullHandling nulls, int remaining) + { + var builder = new MigrationBuilder(); + string[] keys = ["Key One", "select"]; + builder.Delete.DuplicateRows().FromTable("Duplicate Rows").ByColumns(keys).KeepAny(nulls); + keys[0] = "missing"; + builder.Apply(provider); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Duplicate Rows\""), Is.EqualTo(remaining)); + Assert.Throws(() => builder.Build().Single().Reverse()); + Assert.Throws(() => builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); + } + + [Test] + public void IncompleteFluentOperationIsRejectedBeforeExecution() + { + var builder = new MigrationBuilder(); + builder.Delete.DuplicateRows().FromTable("Duplicate Rows").ByColumns("Key One"); + Assert.Catch(() => builder.Apply(provider)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Duplicate Rows\""), Is.EqualTo(9)); + } + + [Test] + public void TransactionRollbackRestoresDeletedRows() + { + provider.BeginTransaction(); + Assert.That(provider.DeleteDuplicateRows("Duplicate Rows", ["Key One", "select"], DuplicateRowRetention.Any), Is.EqualTo(4)); + provider.Rollback(); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Duplicate Rows\""), Is.EqualTo(9)); + } +} diff --git a/src/Migrator.Tests/DeleteDuplicateRowsValidationTests.cs b/src/Migrator.Tests/DeleteDuplicateRowsValidationTests.cs new file mode 100644 index 00000000..d1600819 --- /dev/null +++ b/src/Migrator.Tests/DeleteDuplicateRowsValidationTests.cs @@ -0,0 +1,30 @@ +using System; +using System.Data; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("Unit")] +public class DeleteDuplicateRowsValidationTests +{ + [Test] + public void UnsupportedProviderIsRejectedBeforeUsingItsConnection() + { + using var provider = new MySqlTransformationProvider(new MysqlDialect(), (IDbConnection)null, null, null); + Assert.Throws(() => provider.DeleteDuplicateRows("Data", ["K"], DuplicateRowRetention.Any)); + var builder = new MigrationBuilder(); + builder.Delete.DuplicateRows().FromTable("Data").ByColumns("K").KeepAny(); + Assert.Throws(() => builder.Apply(provider)); + } + + [Test] + public void UnknownOptionsAreRejectedBeforeUsingTheConnection() + { + using var provider = new MySqlTransformationProvider(new MysqlDialect(), (IDbConnection)null, null, null); + Assert.Throws(() => provider.DeleteDuplicateRows("Data", ["K"], (DuplicateRowRetention)99)); + Assert.Throws(() => provider.DeleteDuplicateRows("Data", ["K"], DuplicateRowRetention.Any, (DuplicateNullHandling)99)); + } +} diff --git a/src/Migrator/Framework/DuplicateNullHandling.cs b/src/Migrator/Framework/DuplicateNullHandling.cs new file mode 100644 index 00000000..52a93ded --- /dev/null +++ b/src/Migrator/Framework/DuplicateNullHandling.cs @@ -0,0 +1,9 @@ +namespace DotNetProjects.Migrator.Framework; + +public enum DuplicateNullHandling +{ + /// NULL key values compare equal for duplicate grouping. + Equal, + /// Do not delete rows containing NULL in any key column. + ExcludeNullKeys +} diff --git a/src/Migrator/Framework/DuplicateRowRetention.cs b/src/Migrator/Framework/DuplicateRowRetention.cs new file mode 100644 index 00000000..07d2e840 --- /dev/null +++ b/src/Migrator/Framework/DuplicateRowRetention.cs @@ -0,0 +1,8 @@ +namespace DotNetProjects.Migrator.Framework; + +/// Which row survives within each duplicate key group. +public enum DuplicateRowRetention +{ + /// Keep one arbitrary row; other column values do not determine the survivor. + Any +} diff --git a/src/Migrator/Framework/Fluent/DuplicateRowsBuilder.cs b/src/Migrator/Framework/Fluent/DuplicateRowsBuilder.cs new file mode 100644 index 00000000..5ba2404f --- /dev/null +++ b/src/Migrator/Framework/Fluent/DuplicateRowsBuilder.cs @@ -0,0 +1,44 @@ +using System; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator.Framework.Fluent; + +public sealed class DuplicateRowsBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + internal DuplicateRowsBuilder(PendingOperation pending, string table) { this.pending = pending; this.table = table; } + + public DuplicateRowsRetentionBuilder ByColumns(params string[] columns) + { + DuplicateRowDeletion.Validate(table, columns, DuplicateRowRetention.Any, DuplicateNullHandling.Equal); + return new DuplicateRowsRetentionBuilder(pending, table, (string[])columns.Clone()); + } +} + +public sealed class DuplicateRowsRetentionBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + private readonly string[] columns; + internal DuplicateRowsRetentionBuilder(PendingOperation pending, string table, string[] columns) + { this.pending = pending; this.table = table; this.columns = columns; } + + public void KeepAny(DuplicateNullHandling nulls = DuplicateNullHandling.Equal) + { + DuplicateRowDeletion.Validate(table, columns, DuplicateRowRetention.Any, nulls); + pending.Complete(() => new DeleteDuplicateRowsOperation(table, (string[])columns.Clone(), nulls)); + } +} + +public sealed record DeleteDuplicateRowsOperation(string Table, string[] KeyColumns, DuplicateNullHandling Nulls) : MigrationOperation +{ + public override void Validate(ITransformationProvider provider) + { + DuplicateRowDeletion.Validate(Table, KeyColumns, DuplicateRowRetention.Any, Nulls); + if (provider is not NoOpTransformationProvider && !DuplicateRowDeletion.Supports(provider.Dialect)) + throw new NotSupportedException("Duplicate-row deletion is unsupported by this provider."); + } + public override void Apply(ITransformationProvider provider) => provider.DeleteDuplicateRows(Table, KeyColumns, DuplicateRowRetention.Any, Nulls); + // Physical row identity requires live metadata. The base rejects SQL preview and automatic reversal. +} diff --git a/src/Migrator/Framework/Fluent/SchemaBuilders.cs b/src/Migrator/Framework/Fluent/SchemaBuilders.cs index 3a46350f..cc784475 100644 --- a/src/Migrator/Framework/Fluent/SchemaBuilders.cs +++ b/src/Migrator/Framework/Fluent/SchemaBuilders.cs @@ -148,6 +148,11 @@ public void WithElements(params IViewElement[] elements) public sealed class DeleteRoot(MigrationBuilder builder) { + public FromTableBuilder DuplicateRows() + { + var pending = new PendingOperation(builder, "Duplicate-row deletion requires FromTable(...).ByColumns(...).KeepAny()"); + return new FromTableBuilder(table => new DuplicateRowsBuilder(pending, table)); + } public void Table(string table) => builder.Add(new RemoveOperation(RemoveKind.Table, BuilderArguments.Name(table))); public RemoveFromTableBuilder Column(string name) => Remove(RemoveKind.Column, BuilderArguments.Name(name)); public RemoveFromTableBuilder ForeignKey(string name) => Remove(RemoveKind.ForeignKey, BuilderArguments.Name(name)); diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index 0e5fa3e5..2df1c247 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -100,6 +100,9 @@ public interface ITransformationProvider : IDisposable /// An instance of a Column with the specified properties void AddColumn(string table, Column column); + /// Delete duplicate key groups, keeping one arbitrary row. Returns affected rows; does not change the schema or prevent future duplicates. + int DeleteDuplicateRows(string table, string[] keyColumns, DuplicateRowRetention keep, DuplicateNullHandling nulls = DuplicateNullHandling.Equal); + /// 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); diff --git a/src/Migrator/Providers/DuplicateRowDeletion.cs b/src/Migrator/Providers/DuplicateRowDeletion.cs new file mode 100644 index 00000000..1cd32c85 --- /dev/null +++ b/src/Migrator/Providers/DuplicateRowDeletion.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; + +namespace DotNetProjects.Migrator.Providers; + +internal static class DuplicateRowDeletion +{ + internal static bool Supports(IDialect dialect) => dialect is SQLiteDialect or PostgreSQLDialect or OracleDialect or SqlServerDialect; + + internal static void Validate(string table, string[] keys, DuplicateRowRetention keep, DuplicateNullHandling nulls) + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(keys); + if (keys.Length == 0 || keys.Any(string.IsNullOrWhiteSpace) || keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != keys.Length) + throw new ArgumentException("Duplicate keys must be distinct, non-empty column names.", nameof(keys)); + if (keep != DuplicateRowRetention.Any) throw new ArgumentOutOfRangeException(nameof(keep)); + if (nulls is not (DuplicateNullHandling.Equal or DuplicateNullHandling.ExcludeNullKeys)) throw new ArgumentOutOfRangeException(nameof(nulls)); + } + + internal static int Execute(TransformationProvider provider, string table, string[] keys, DuplicateRowRetention keep, DuplicateNullHandling nulls) + { + Validate(table, keys, keep, nulls); + if (!Supports(provider.Dialect)) throw new NotSupportedException("Duplicate-row deletion supports SQLite, PostgreSQL, Oracle and SQL Server."); + if (!provider.TableExists(table)) throw new MigrationException("Table does not exist: " + table); + var columns = provider.GetColumns(table).Select(column => column.Name).ToArray(); + if (keys.Any(key => !columns.Contains(key, StringComparer.OrdinalIgnoreCase))) + throw new MigrationException("A duplicate key column does not exist."); + var quotedTable = provider.QuoteTableNameIfRequired(table); + var quotedKeys = keys.Select(provider.QuoteColumnNameIfRequired).ToArray(); + + if (provider.Dialect is SqlServerDialect) + { + var ordinal = "__migrator_duplicate_ordinal"; + while (columns.Contains(ordinal, StringComparer.OrdinalIgnoreCase)) ordinal += "_"; + var cte = "__migrator_duplicates"; + while (table.Contains(cte, StringComparison.OrdinalIgnoreCase)) cte += "_"; + var filter = nulls == DuplicateNullHandling.ExcludeNullKeys + ? " WHERE " + string.Join(" AND ", quotedKeys.Select(key => key + " IS NOT NULL")) : ""; + return provider.ExecuteNonQuery($"WITH {cte} AS (SELECT {string.Join(", ", quotedKeys)}, ROW_NUMBER() OVER (PARTITION BY {string.Join(", ", quotedKeys)} ORDER BY (SELECT NULL)) AS {ordinal} FROM {quotedTable}{filter}) DELETE FROM {cte} WHERE {ordinal} > 1"); + } + + string rowOrder; + var alias = "a"; + if (provider is SQLiteTransformationProvider sqlite) + { + var script = sqlite.GetSqlCreateTableScript(table); + if (string.IsNullOrWhiteSpace(script) || SQLiteConstraintParser.HasKeyword(script, "WITHOUT") || SQLiteConstraintParser.HasKeyword(script, "VIRTUAL")) + throw new NotSupportedException("Duplicate-row deletion requires an ordinary SQLite rowid table."); + var rowid = new[] { "_rowid_", "rowid", "oid" }.FirstOrDefault(name => !columns.Contains(name, StringComparer.OrdinalIgnoreCase)) + ?? throw new NotSupportedException("All SQLite physical row identifiers are shadowed by declared columns."); + rowOrder = $"b.{rowid} < a.{rowid}"; + alias = "AS a"; + } + else if (provider.Dialect is PostgreSQLDialect) + { + // ctid alone is not unique across partitions or inherited child tables. + rowOrder = "(b.tableoid, b.ctid) < (a.tableoid, a.ctid)"; + } + else if (provider.Dialect is OracleDialect) + { + rowOrder = "b.ROWID < a.ROWID"; + } + else throw new NotSupportedException("The provider does not expose the required physical row identity."); + + var equality = string.Join(" AND ", quotedKeys.Select(key => nulls == DuplicateNullHandling.Equal + ? $"(b.{key} = a.{key} OR (b.{key} IS NULL AND a.{key} IS NULL))" + : $"b.{key} = a.{key}")); + return provider.ExecuteNonQuery($"DELETE FROM {quotedTable} {alias} WHERE EXISTS (SELECT 1 FROM {quotedTable} b WHERE {equality} AND {rowOrder})"); + } +} diff --git a/src/Migrator/Providers/NoOpTransformationProvider.cs b/src/Migrator/Providers/NoOpTransformationProvider.cs index 670d004a..1c11d148 100644 --- a/src/Migrator/Providers/NoOpTransformationProvider.cs +++ b/src/Migrator/Providers/NoOpTransformationProvider.cs @@ -16,6 +16,7 @@ namespace DotNetProjects.Migrator.Providers; public class NoOpTransformationProvider : ITransformationProvider { public TableConstraint[] GetTableConstraints(string table) => []; + public int DeleteDuplicateRows(string table, string[] keyColumns, DuplicateRowRetention keep, DuplicateNullHandling nulls = DuplicateNullHandling.Equal) => 0; public void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) { } public void RemoveUniqueConstraint(string table, UniqueConstraint constraint) { } diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index a14e2e59..e1a9ca0d 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1525,6 +1525,9 @@ public virtual void AddColumn(string table, Column column) AddColumn(table, _dialect.GetAndMapColumnProperties(column.CopyDefinition()).ColumnSql); } + public virtual int DeleteDuplicateRows(string table, string[] keyColumns, DuplicateRowRetention keep, DuplicateNullHandling nulls = DuplicateNullHandling.Equal) + => DuplicateRowDeletion.Execute(this, table, keyColumns, keep, nulls); + public virtual void AddColumn(string table, Column column, PrimaryKeyConstraint primaryKey) { var definition = PrepareColumnWithPrimaryKey(table, column, primaryKey);