diff --git a/README.md b/README.md index 321a55b2..32141e26 100644 --- a/README.md +++ b/README.md @@ -262,15 +262,34 @@ public override void Down() ```csharp public override void BuildUp(MigrationBuilder migration) { - migration.Create.Column("Email", "Users").AsString(320); + migration.Create.Column("Email").OnTable("Users").AsString(320); } public override void BuildDown(MigrationBuilder migration) { - migration.Delete.Column("Email", "Users"); + migration.Delete.Column("Email").FromTable("Users"); } ``` +Fluent expressions name the table separately: `Create.Column(name).OnTable(table)`, +`Alter.Column(name).OnTable(table)` and `Delete.Column(name).FromTable(table)`. +Renames read `Rename.Column(oldName).OnTable(table).To(newName)`. Indexes and +constraints follow the same pattern: + +```csharp +migration.Create.Index("IX_Users_Email").OnTable("Users").WithColumns("Email").Unique(); +migration.Create.ForeignKey("FK_Orders_Users") + .FromTable("Orders").WithColumns("UserId") + .ToTable("Users").WithColumns("Id") + .OnDelete(ForeignKeyConstraintType.Cascade); +``` + +Table, create-column and alter-column builders share type and option methods; +each column requires `As...` or `OfType(...)`. Update and delete require a +predicate or explicit `AllRows()`. Unfinished expressions fail before execution. +See the [API map](https://dotnetprojects.github.io/Migrator.NET/guide/api-map.html) +for the complete syntax. + Provider implementations determine which operations are available and how they map to SQL. Use `Database.ExecuteNonQuery(...)` or `migration.Execute.Sql(...)` for custom SQL and keep dialect-specific statements explicit. The [Classic/Fluent API map](https://dotnetprojects.github.io/Migrator.NET/guide/api-map.html) lists the corresponding operations. ### Explicit constraints, SQL defaults and collations diff --git a/docs/_src/content.py b/docs/_src/content.py index dd38d0d8..58d5562c 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -126,7 +126,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("Why does SQL preview reject my migration?", '

Preview renders a structured subset. A provider callback, unsupported constraint alteration or schema dependency after raw SQL cannot be represented reliably and raises an error. Read planning and SQL preview rather than treating preview as a full execution simulation.

')) page("Operations", "creating-tables", "Creating tables", "Describe a complete table: columns first, with explicit named keys and constraints.", - section("Create a table with a key", '

The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain.

', CREATE_USERS), + section("Create a table with a key", '

The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain. WithColumn returns a builder bound to that specific column. Table-level methods such as WithPrimaryKey return the table builder; call WithColumn again before supplying more column options.

', CREATE_USERS), section("Composite keys and uniqueness", '

Use the declared key order consistently in both primary and foreign keys. A composite unique constraint applies to the tuple; it does not make each column unique separately. The fully qualified constraint type below avoids the name collision with System.Data.UniqueConstraint.

', pair("A table with an ordered composite key", ''' Database.AddTable("Subscriptions", new Column("TenantId", DbType.Int32), @@ -150,8 +150,8 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Database.RenameTable("Users", "Members"); Database.RenameColumn("Members", "Name", "DisplayName"); ''', ''' -migration.Rename.Table("Users", "Members"); -migration.Rename.Column("Members", "Name", "DisplayName"); +migration.Rename.Table("Users").To("Members"); +migration.Rename.Column("Name").OnTable("Members").To("DisplayName"); ''')), section("Change a complete column definition", '

Supply the type, length, nullability, default and collation you intend to retain. ChangeColumn replaces the column definition; it does not infer that table constraints should be created or removed. Existing rows must remain valid for the new definition.

', pair("Widen a required display name", ''' Database.ChangeColumn("Users", new Column("Name", DbType.String, 500) @@ -159,20 +159,21 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ IsNullable = false }); ''', ''' -migration.Alter.Column("Name", "Users") +migration.Alter.Column("Name").OnTable("Users") .AsString(500).NotNullable(); ''')), section("A deployment sequence for populated data", '

Add a nullable column, deploy code that can read both forms, backfill values, then enforce the final requirement in a later migration. Large data copies and index creation can hold locks for substantial time; test them against a representative dataset.

On SQLite, a supported alteration may recreate the table and copy rows. On Oracle and some other engines, DDL may commit implicitly. Review the transaction guide and your provider page before choosing the deployment boundary.

')) page("Operations", "columns", "Columns and data types", "Type, size, precision, nullability, defaults, identity and collation are explicit column attributes.", + section("Explicit table and column steps", '

Create.Column(name).OnTable(table) and Alter.Column(name).OnTable(table) select the table before exposing type and column options. Delete.Column(name).FromTable(table) completes a removal. For a complete Column model use Create.Column(definition).OnTable(table) or Alter.Column(definition).OnTable(table); the definition is copied. Every named fluent column requires As... or OfType(...) before execution.

Table columns, added columns and altered columns share the same options, including AsGuid, AsBoolean, AsDecimal(precision, scale), AsDate, AsDateTime and AsDateTime2. AsDateTime maps to DbType.DateTime; AsDateTime2 maps to DbType.DateTime2. OfType(DbType) and OfType(MigratorDbType) remain available for other types. Nullability defaults to nullable.

'), section("Add and remove a column", '

Column builders take column name followed by table name. Classic AddColumn takes table name first. New nullable columns accept existing rows without a backfill. A required column usually needs a compatible default or a staged data migration.

', pair("Add an optional email address", ''' Database.AddColumn("Users", new Column("Email", DbType.String, 320)); ''', ''' -migration.Create.Column("Email", "Users").AsString(320).Nullable(); +migration.Create.Column("Email").OnTable("Users").AsString(320).Nullable(); '''), pair("Remove the email column", ''' Database.RemoveColumn("Users", "Email"); ''', ''' -migration.Delete.Column("Email", "Users"); +migration.Delete.Column("Email").FromTable("Users"); ''')), section("Precision and defaults", '

For decimal values specify precision and scale. In a Column constructor an integer after the type is the size, not a numeric default. Set DefaultValue explicitly to avoid overload ambiguity. Plain strings are values; trusted SQL expressions use RawSql.Insert.

', pair("An amount with four decimal places", ''' Database.AddColumn("Orders", new Column("Amount", DbType.Decimal) @@ -180,7 +181,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Precision = 12, Scale = 4, IsNullable = false, DefaultValue = 0m }); ''', ''' -migration.Create.Column("Amount", "Orders").OfType(DbType.Decimal) +migration.Create.Column("Amount").OnTable("Orders").OfType(DbType.Decimal) .WithPrecision(12, 4).NotNullable().WithDefaultValue(0m); ''')), section("Time of day and durations", '

Use TimeOnly for time-of-day values and TimeSpan for intervals. A TimeSpan is a duration, including negative and multi-day values, so a TimeSpan default on a Time column is rejected. PostgreSQL and Oracle have native intervals; SQLite, SQL Server and MySQL/MariaDB store intervals as signed .NET ticks.

', pair("Clock time and elapsed time", ''' @@ -195,13 +196,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("Database storage differs", '

SQLite does not enforce declared string lengths or decimal precision. UInt64 values above Int64.MaxValue are rejected there. Oracle character empty strings become NULL; Informix and Sybase have their own trimming and range behavior. Consult the type support and boundary matrix for supported mappings and live-test scope. A shared DbType does not imply identical native storage.

')) page("Operations", "data", "Data operations", "Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.", - section("Insert rows", '

Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple operations; the fluent Row method describes one row, not an accumulated collection of rows.

', pair("Insert a user", ''' + section("Insert rows", '

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.

', pair("Insert a user", ''' Database.Insert("Users", new[] { "Id", "Name" }, new object[] { 1, "Ada" }); ''', ''' migration.Insert.IntoTable("Users") .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" }); ''')), - section("Update and delete with predicates", '

Without a predicate, update/delete affects every row. Supply predicate columns and values deliberately. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input.

', pair("Update one user", ''' + section("Update and delete with predicates", '

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.

', pair("Update one user", ''' Database.Update("Users", new[] { "Name" }, new object[] { "Ada Lovelace" }, new[] { "Id" }, new object[] { 1 }); ''', ''' @@ -221,13 +222,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" }) .IfNotExists(new[] { "Id" }, new object[] { 1 }); ''')), - section("Copying and reversal", '

Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyData for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateFrom maps source/target pairs. 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.

', pair("Copy users into an archive table", ''' + section("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.

', pair("Copy users into an archive table", ''' Database.CopyDataFromTableToTable("Users", new System.Collections.Generic.List { "Id", "Name" }, "ArchivedUsers", new System.Collections.Generic.List { "UserId", "DisplayName" }); ''', ''' -migration.Execute.CopyData("Users", new[] { "Id", "Name" }, - "ArchivedUsers", new[] { "UserId", "DisplayName" }); +migration.Execute.CopyDataFromTable("Users").ToTable("ArchivedUsers") + .WithColumns(new[] { "Id", "Name" }, new[] { "UserId", "DisplayName" }); '''))) page("Operations", "schema", "Schema inspection", "Read the connected database before deciding what to change. Metadata is different from a model snapshot.", @@ -236,7 +237,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Database.AddColumn("Users", new Column("Email", DbType.String, 320)); ''', ''' if (!Schema.Table("Users").ColumnExists("Email")) - migration.Create.Column("Email", "Users").AsString(320); + migration.Create.Column("Email").OnTable("Users").AsString(320); ''')), section("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.

', pair("Read table constraint definitions", ''' var constraints = Database.GetTableConstraints("Users"); @@ -250,9 +251,10 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("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.

', pair("A projection over Users", ''' Database.AddView("UserNames", "Users", new ViewField("Id"), new ViewField("Name")); ''', ''' -migration.Create.View("UserNames", "Users", new ViewField("Id"), new ViewField("Name")); +migration.Create.View("UserNames").FromTable("Users") + .WithFields(new ViewField("Id"), new ViewField("Name")); ''')), - section("Reads and portability", '

Dispose readers and commands obtained from the provider. Fluent Schema.Query and Select accept a reader callback and handle disposal. 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.

'), source="src/Migrator/Framework/Fluent/FluentMigration.cs") + section("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.

'), source="src/Migrator/Framework/Fluent/FluentMigration.cs") page("Operations", "sql", "Execute SQL and scripts", "Use schema operations where they fit, and keep database-specific SQL explicit.", section("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.

', pair("A SQL data change", ''' @@ -303,20 +305,17 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("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.

')) page("Schema basics", "indexes", "Indexes", "An index is a separate schema object, even when it enforces uniqueness.", - section("Create and remove an index", '

Use an explicit name so the index can be inspected or removed later. Both APIs accept the same Index definition. Fully qualify this type if System.Index is also in scope. Columns retain the order in KeyColumns.

', pair("Index a user name", ''' + section("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.

', pair("Index a user name", ''' Database.AddIndex("Users", new DotNetProjects.Migrator.Framework.Index { Name = "IX_Users_Name", KeyColumns = new[] { "Name" }, Unique = false }); ''', ''' -migration.Create.Index("Users", new DotNetProjects.Migrator.Framework.Index -{ - Name = "IX_Users_Name", KeyColumns = new[] { "Name" }, Unique = false -}); +migration.Create.Index("IX_Users_Name").OnTable("Users").WithColumns("Name"); '''), pair("Drop an index", ''' Database.RemoveIndex("Users", "IX_Users_Name"); ''', ''' -migration.Delete.Index("IX_Users_Name", "Users"); +migration.Delete.Index("IX_Users_Name").FromTable("Users"); ''')), section("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.

'), section("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.

'), source="src/Migrator/Framework/Index.cs") @@ -326,13 +325,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Database.AddUniqueConstraint("UQ_Users_Name", "Users", "Name"); Database.AddCheckConstraint("CK_Users_Id", "Users", "Id > 0"); ''', ''' -migration.Create.Unique("UQ_Users_Name", "Users", "Name"); -migration.Create.Check("CK_Users_Id", "Users", "Id > 0"); +migration.Create.UniqueConstraint("UQ_Users_Name").OnTable("Users").WithColumns("Name"); +migration.Create.CheckConstraint("CK_Users_Id").OnTable("Users").WithExpression("Id > 0"); ''')), section("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.

', pair("Remove a check constraint", ''' Database.RemoveConstraint("Users", "CK_Users_Id"); ''', ''' -migration.Delete.Constraint("CK_Users_Id", "Users"); +migration.Delete.Constraint("CK_Users_Id").FromTable("Users"); ''')), section("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.

')) @@ -343,15 +342,16 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ "Users", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.NoAction); ''', ''' -migration.Create.ForeignKey("FK_Orders_Users", - "Orders", new[] { "UserId" }, "Users", new[] { "Id" }, - onDelete: ForeignKeyConstraintType.Cascade, - onUpdate: ForeignKeyConstraintType.NoAction); +migration.Create.ForeignKey("FK_Orders_Users") + .FromTable("Orders").WithColumns("UserId") + .ToTable("Users").WithColumns("Id") + .OnDelete(ForeignKeyConstraintType.Cascade) + .OnUpdate(ForeignKeyConstraintType.NoAction); ''')), section("Remove a relationship", '

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

', pair("Remove the foreign key", ''' Database.RemoveForeignKey("Orders", "FK_Orders_Users"); ''', ''' -migration.Delete.ForeignKey("FK_Orders_Users", "Orders"); +migration.Delete.ForeignKey("FK_Orders_Users").FromTable("Orders"); ''')), section("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.

')) @@ -461,9 +461,9 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ public class AddUserEmail : FluentMigration { public override void BuildUp(MigrationBuilder migration) - => migration.Create.Column("Email", "Users").AsString(320); + => migration.Create.Column("Email").OnTable("Users").AsString(320); public override void BuildDown(MigrationBuilder migration) - => migration.Delete.Column("Email", "Users"); + => migration.Delete.Column("Email").FromTable("Users"); } ''', kind="class")), section("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.

'), @@ -582,7 +582,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ Collation = Collation.AsciiIgnoreCase }); ''', ''' -migration.Alter.Column("Name", "Users") +migration.Alter.Column("Name").OnTable("Users") .AsString(500).NotNullable().WithDefaultValue("Unknown") .WithCollation(Collation.AsciiIgnoreCase); ''') @@ -599,7 +599,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("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.

', pair("Add a nonclustered primary key", ''' Database.AddPrimaryKeyNonClustered("PK_Users", "Users", "Id"); ''', ''' -migration.Create.NonClusteredPrimaryKey("PK_Users", "Users", "Id"); +migration.Create.NonClusteredPrimaryKey("PK_Users").OnTable("Users").WithColumns("Id"); ''')), section("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.

'), section("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.

'), source="src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs") @@ -612,7 +612,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ DefaultValue = TimeSpan.FromDays(2) }); ''', ''' -migration.Create.Column("Elapsed", "Jobs").OfType(MigratorDbType.Interval) +migration.Create.Column("Elapsed").OnTable("Jobs").OfType(MigratorDbType.Interval) .WithDefaultValue(TimeSpan.FromDays(2)); ''')), section("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.

'), @@ -655,10 +655,10 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("Source inventory and evidence", '

Ingres remains a source dialect outside the eleven-engine matrix. Redshift, Snowflake and Db2 for IBM i require separate provider/infrastructure qualification; PostgreSQL tests do not qualify Redshift, and Db2 LUW tests do not qualify IBM i.

See qualification requirements and live test setup for exact coverage and reproduction commands.

'), source="src/Migrator/ProviderFactory.cs") page("Advanced topics", "conditional", "Conditional logic", "Choose between inspecting the live schema and declaring a provider-specific operation.", - section("Provider-specific operations", '

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

', pair("Run a SQLite-specific statement", ''' + section("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.

', pair("Run a SQLite-specific statement", ''' Database["SQLite"].ExecuteNonQuery("UPDATE Users SET Name = upper(Name)"); ''', ''' -migration.IfDatabase("SQLite", sqlite => +migration.IfProvider("SQLite", sqlite => sqlite.Execute.Sql("UPDATE Users SET Name = upper(Name)")); ''')), section("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.

'), source="src/Migrator/Framework/Fluent/MigrationBuilder.cs") @@ -677,7 +677,7 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ public static class AuditColumns { public static void Add(MigrationBuilder migration, string table) - => migration.Create.Column("CreatedAt", table).OfType(DbType.DateTime) + => migration.Create.Column("CreatedAt").OnTable(table).OfType(DbType.DateTime) .NotNullable().WithDefaultValue(RawSql.Insert("CURRENT_TIMESTAMP")); } ''', kind="class")), @@ -702,8 +702,8 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ section("Behavior changes to review", '

Column changes preserve explicit uniqueness; old SQL Server ownership markers no longer control deletion. TimeSpan inputs mean intervals, so convert clock-time inputs to TimeOnly. SQLite GUID defaults use the same blob representation as inserted parameters; unrelated rebuilds preserve existing text defaults.

Read the complete compatibility migration guide for constructor replacements, custom-provider contracts, identity, constraint metadata and collation mappings. Version-specific details live there; these chapters describe the current API.

'), source="docs/migration-guide-12.1-to-13.md") page("Reference", "api-map", "Classic / Fluent API map", "A practical index of the two authoring surfaces and their shared provider contracts.", - section("Schema operations", table(["Classic", "Fluent"], [["AddTable", "Create.Table"], ["AddColumn(table, column)", "Create.Column(name, table)"], ["ChangeColumn(table, column)", "Alter.Column(name, table) / Alter.Column(table, column)"], ["RemoveTable / RemoveColumn", "Delete.Table / Delete.Column"], ["RenameTable / RenameColumn", "Rename.Table / Rename.Column"], ["AddPrimaryKey / AddUniqueConstraint / AddCheckConstraint", "Create.PrimaryKey / Create.Unique / Create.Check"], ["AddForeignKey / RemoveForeignKey", "Create.ForeignKey / Delete.ForeignKey"], ["AddIndex / RemoveIndex", "Create.Index / Delete.Index"], ["GetTableConstraints / GetColumns", "Schema.Table(name).ConstraintDefinitions() / Columns()"]])), - section("Data and execution", table(["Classic", "Fluent"], [["Insert / InsertIfNotExists", "Insert.IntoTable(...).Row(...) / IfNotExists(...)"], ["Update / Delete", "Update.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...)"], ["ExecuteNonQuery / ExecuteScript / ExecuteResourceScript", "Execute.Sql / Execute.Script / Execute.EmbeddedScript"], ["CopyDataFromTableToTable / UpdateTargetFromSource", "Execute.CopyData / Execute.UpdateFrom"], ["TruncateTable", "Execute.Truncate"], ["CreateCommand / Connection", "Execute.WithCommand / Execute.WithConnection"], ["Database provider access", "Context or Execute.WithProvider"], ["History / transactions", "Shared runner and explicit provider context"]])), + section("Schema operations", table(["Classic", "Fluent"], [["AddTable", "Create.Table"], ["AddColumn(table, column)", "Create.Column(name).OnTable(table)"], ["ChangeColumn(table, column)", "Alter.Column(name).OnTable(table) / Alter.Column(definition).OnTable(table)"], ["RemoveTable / RemoveColumn", "Delete.Table(table) / Delete.Column(name).FromTable(table)"], ["RenameTable / RenameColumn", "Rename.Table(old).To(new) / Rename.Column(old).OnTable(table).To(new)"], ["AddPrimaryKey / AddUniqueConstraint / AddCheckConstraint", "Create.PrimaryKey(name).OnTable(table).WithColumns(...) / Create.UniqueConstraint / Create.CheckConstraint"], ["AddForeignKey / RemoveForeignKey", "Create.ForeignKey(name).FromTable(child).WithColumns(...).ToTable(parent).WithColumns(...) / Delete.ForeignKey(name).FromTable(table)"], ["AddIndex / RemoveIndex", "Create.Index(name).OnTable(table).WithColumns(...) / Delete.Index(name).FromTable(table)"], ["GetTableConstraints / GetColumns", "Schema.Table(name).ConstraintDefinitions() / Columns()"]])), + section("Data and execution", table(["Classic", "Fluent"], [["Insert / InsertIfNotExists", "Insert.IntoTable(...).Row(...) / IfNotExists(...)"], ["Update / Delete", "Update.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...)"], ["ExecuteNonQuery / ExecuteScript / ExecuteResourceScript", "Execute.Sql / Execute.Script / Execute.EmbeddedScript"], ["CopyDataFromTableToTable / UpdateTargetFromSource", "Execute.CopyDataFromTable(source).ToTable(target).WithColumns(...) / Execute.UpdateTable(target).FromTable(source).Set(...).Match(...)"], ["TruncateTable", "Execute.Truncate"], ["CreateCommand / Connection", "Execute.WithCommand / Execute.WithConnection"], ["Database provider access", "Context or Execute.WithProvider"], ["History / transactions", "Shared runner and explicit provider context"]])), section("Execution is not preview or reversal", '

Both APIs reach the same provider layer, but not every operation has SQL-preview or automatic-reversal support. Provider capabilities still govern execution. Read preview, reversal and the machine-checked method-family inventory for the distinction.

'), source="src/Migrator/Framework/Fluent/MigrationBuilder.cs") page("Reference", "contributing", "Contributing", "Make a provider change reproducible, then verify its observable behavior.", diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index b47070d6..b936899e 100644 --- a/docs/assets/search-index.json +++ b/docs/assets/search-index.json @@ -32,35 +32,35 @@ "group": "Operations", "summary": "Describe a complete table: columns first, with explicit named keys and constraints.", "url": "guide/creating-tables.html", - "text": " The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain. CreateUsers.cs using System.Data;\nusing DotNetProjects.Migrator.Framework;\n\n[Migration(1)]\npublic class CreateUsers : Migration\n{\n public override void Up()\n {\n Database.AddTable(\"Users\",\n new Column(\"Id\", DbType.Int32) { IsNullable = false },\n new Column(\"Name\", DbType.String, 255),\n new PrimaryKeyConstraint(\"PK_Users\", \"Id\"));\n }\n\n public override void Down() => Database.RemoveTable(\"Users\");\n} using DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\n[Migration(1)]\npublic class CreateUsers : FluentMigration\n{\n public override void BuildUp(MigrationBuilder migration)\n {\n migration.Create.Table(\"Users\")\n .WithColumn(\"Id\").AsInt32().NotNullable()\n .WithColumn(\"Name\").AsString(255)\n .WithPrimaryKey(\"PK_Users\", \"Id\");\n }\n\n public override void BuildDown(MigrationBuilder migration)\n => migration.Delete.Table(\"Users\");\n} Use the declared key order consistently in both primary and foreign keys. A composite unique constraint applies to the tuple; it does not make each column unique separately. The fully qualified constraint type below avoids the name collision with System.Data.UniqueConstraint. A table with an ordered composite key Database.AddTable(\"Subscriptions\",\n new Column(\"TenantId\", DbType.Int32),\n new Column(\"UserId\", DbType.Int32),\n new Column(\"Email\", DbType.String, 255),\n new PrimaryKeyConstraint(\"PK_Subscriptions\", \"TenantId\", \"UserId\"),\n new DotNetProjects.Migrator.Framework.UniqueConstraint(\n \"UQ_Subscriptions_Email\", \"TenantId\", \"Email\")); migration.Create.Table(\"Subscriptions\")\n .WithColumn(\"TenantId\").AsInt32()\n .WithColumn(\"UserId\").AsInt32()\n .WithColumn(\"Email\").AsString(255)\n .WithPrimaryKey(\"PK_Subscriptions\", \"TenantId\", \"UserId\")\n .WithUniqueConstraint(\"UQ_Subscriptions_Email\", \"TenantId\", \"Email\"); Identity generation is a column attribute, separate from primary-key membership. SQLite requires an INTEGER identity column and its single-column primary key in the same definition. Use a complete Create.Table/AddTable operation to satisfy that rule. To reverse creation use Database.RemoveTable or migration.Delete.Table ; dropping a table also removes its rows. For supported creation operations, automatic reversal can derive the reverse operation. Explicitly author reverse behavior for destructive changes. " + "text": " The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain. WithColumn returns a builder bound to that specific column. Table-level methods such as WithPrimaryKey return the table builder; call WithColumn again before supplying more column options. CreateUsers.cs using System.Data;\nusing DotNetProjects.Migrator.Framework;\n\n[Migration(1)]\npublic class CreateUsers : Migration\n{\n public override void Up()\n {\n Database.AddTable(\"Users\",\n new Column(\"Id\", DbType.Int32) { IsNullable = false },\n new Column(\"Name\", DbType.String, 255),\n new PrimaryKeyConstraint(\"PK_Users\", \"Id\"));\n }\n\n public override void Down() => Database.RemoveTable(\"Users\");\n} using DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\n[Migration(1)]\npublic class CreateUsers : FluentMigration\n{\n public override void BuildUp(MigrationBuilder migration)\n {\n migration.Create.Table(\"Users\")\n .WithColumn(\"Id\").AsInt32().NotNullable()\n .WithColumn(\"Name\").AsString(255)\n .WithPrimaryKey(\"PK_Users\", \"Id\");\n }\n\n public override void BuildDown(MigrationBuilder migration)\n => migration.Delete.Table(\"Users\");\n} Use the declared key order consistently in both primary and foreign keys. A composite unique constraint applies to the tuple; it does not make each column unique separately. The fully qualified constraint type below avoids the name collision with System.Data.UniqueConstraint. A table with an ordered composite key Database.AddTable(\"Subscriptions\",\n new Column(\"TenantId\", DbType.Int32),\n new Column(\"UserId\", DbType.Int32),\n new Column(\"Email\", DbType.String, 255),\n new PrimaryKeyConstraint(\"PK_Subscriptions\", \"TenantId\", \"UserId\"),\n new DotNetProjects.Migrator.Framework.UniqueConstraint(\n \"UQ_Subscriptions_Email\", \"TenantId\", \"Email\")); migration.Create.Table(\"Subscriptions\")\n .WithColumn(\"TenantId\").AsInt32()\n .WithColumn(\"UserId\").AsInt32()\n .WithColumn(\"Email\").AsString(255)\n .WithPrimaryKey(\"PK_Subscriptions\", \"TenantId\", \"UserId\")\n .WithUniqueConstraint(\"UQ_Subscriptions_Email\", \"TenantId\", \"Email\"); Identity generation is a column attribute, separate from primary-key membership. SQLite requires an INTEGER identity column and its single-column primary key in the same definition. Use a complete Create.Table/AddTable operation to satisfy that rule. To reverse creation use Database.RemoveTable or migration.Delete.Table ; dropping a table also removes its rows. For supported creation operations, automatic reversal can derive the reverse operation. Explicitly author reverse behavior for destructive changes. " }, { "title": "Altering tables", "group": "Operations", "summary": "Rename objects and evolve populated tables while preserving the schema details you still need.", "url": "guide/altering-tables.html", - "text": " Use explicit old and new names. The column rename signature is table, old name, new name in both APIs. A table rename does not rename explicit constraints or their backing indexes. Reusing the original key name for a replacement table may collide on SQL Server or PostgreSQL. Rename existing objects Database.RenameTable(\"Users\", \"Members\");\nDatabase.RenameColumn(\"Members\", \"Name\", \"DisplayName\"); migration.Rename.Table(\"Users\", \"Members\");\nmigration.Rename.Column(\"Members\", \"Name\", \"DisplayName\"); Supply the type, length, nullability, default and collation you intend to retain. ChangeColumn replaces the column definition; it does not infer that table constraints should be created or removed. Existing rows must remain valid for the new definition. Widen a required display name Database.ChangeColumn(\"Users\", new Column(\"Name\", DbType.String, 500)\n{\n IsNullable = false\n}); migration.Alter.Column(\"Name\", \"Users\")\n .AsString(500).NotNullable(); Add a nullable column, deploy code that can read both forms, backfill values, then enforce the final requirement in a later migration. Large data copies and index creation can hold locks for substantial time; test them against a representative dataset. On SQLite, a supported alteration may recreate the table and copy rows. On Oracle and some other engines, DDL may commit implicitly. Review the transaction guide and your provider page before choosing the deployment boundary. " + "text": " Use explicit old and new names. The column rename signature is table, old name, new name in both APIs. A table rename does not rename explicit constraints or their backing indexes. Reusing the original key name for a replacement table may collide on SQL Server or PostgreSQL. Rename existing objects Database.RenameTable(\"Users\", \"Members\");\nDatabase.RenameColumn(\"Members\", \"Name\", \"DisplayName\"); migration.Rename.Table(\"Users\").To(\"Members\");\nmigration.Rename.Column(\"Name\").OnTable(\"Members\").To(\"DisplayName\"); Supply the type, length, nullability, default and collation you intend to retain. ChangeColumn replaces the column definition; it does not infer that table constraints should be created or removed. Existing rows must remain valid for the new definition. Widen a required display name Database.ChangeColumn(\"Users\", new Column(\"Name\", DbType.String, 500)\n{\n IsNullable = false\n}); migration.Alter.Column(\"Name\").OnTable(\"Users\")\n .AsString(500).NotNullable(); Add a nullable column, deploy code that can read both forms, backfill values, then enforce the final requirement in a later migration. Large data copies and index creation can hold locks for substantial time; test them against a representative dataset. On SQLite, a supported alteration may recreate the table and copy rows. On Oracle and some other engines, DDL may commit implicitly. Review the transaction guide and your provider page before choosing the deployment boundary. " }, { "title": "Columns and data types", "group": "Operations", "summary": "Type, size, precision, nullability, defaults, identity and collation are explicit column attributes.", "url": "guide/columns.html", - "text": " Column builders take column name followed by table name. Classic AddColumn takes table name first. New nullable columns accept existing rows without a backfill. A required column usually needs a compatible default or a staged data migration. Add an optional email address Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320)); migration.Create.Column(\"Email\", \"Users\").AsString(320).Nullable(); Remove the email column Database.RemoveColumn(\"Users\", \"Email\"); migration.Delete.Column(\"Email\", \"Users\"); For decimal values specify precision and scale. In a Column constructor an integer after the type is the size, not a numeric default. Set DefaultValue explicitly to avoid overload ambiguity. Plain strings are values; trusted SQL expressions use RawSql.Insert. An amount with four decimal places Database.AddColumn(\"Orders\", new Column(\"Amount\", DbType.Decimal)\n{\n Precision = 12, Scale = 4, IsNullable = false, DefaultValue = 0m\n}); migration.Create.Column(\"Amount\", \"Orders\").OfType(DbType.Decimal)\n .WithPrecision(12, 4).NotNullable().WithDefaultValue(0m); Use TimeOnly for time-of-day values and TimeSpan for intervals. A TimeSpan is a duration, including negative and multi-day values, so a TimeSpan default on a Time column is rejected. PostgreSQL and Oracle have native intervals; SQLite, SQL Server and MySQL/MariaDB store intervals as signed .NET ticks. Clock time and elapsed time Database.AddTable(\"Jobs\",\n new Column(\"RunAt\", DbType.Time) { DefaultValue = new TimeOnly(9, 30) },\n new Column(\"Elapsed\", MigratorDbType.Interval) { DefaultValue = TimeSpan.Zero }); migration.Create.Table(\"Jobs\")\n .WithColumn(\"RunAt\").OfType(DbType.Time).WithDefaultValue(new TimeOnly(9, 30))\n .WithColumn(\"Elapsed\").OfType(MigratorDbType.Interval).WithDefaultValue(TimeSpan.Zero); SQLite does not enforce declared string lengths or decimal precision. UInt64 values above Int64.MaxValue are rejected there. Oracle character empty strings become NULL; Informix and Sybase have their own trimming and range behavior. Consult the type support and boundary matrix for supported mappings and live-test scope. A shared DbType does not imply identical native storage. " + "text": " Create.Column(name).OnTable(table) and Alter.Column(name).OnTable(table) select the table before exposing type and column options. Delete.Column(name).FromTable(table) completes a removal. For a complete Column model use Create.Column(definition).OnTable(table) or Alter.Column(definition).OnTable(table); the definition is copied. Every named fluent column requires As... or OfType(...) before execution. Table columns, added columns and altered columns share the same options, including AsGuid, AsBoolean, AsDecimal(precision, scale), AsDate, AsDateTime and AsDateTime2. AsDateTime maps to DbType.DateTime; AsDateTime2 maps to DbType.DateTime2. OfType(DbType) and OfType(MigratorDbType) remain available for other types. Nullability defaults to nullable. Column builders take column name followed by table name. Classic AddColumn takes table name first. New nullable columns accept existing rows without a backfill. A required column usually needs a compatible default or a staged data migration. Add an optional email address Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320)); migration.Create.Column(\"Email\").OnTable(\"Users\").AsString(320).Nullable(); Remove the email column Database.RemoveColumn(\"Users\", \"Email\"); migration.Delete.Column(\"Email\").FromTable(\"Users\"); For decimal values specify precision and scale. In a Column constructor an integer after the type is the size, not a numeric default. Set DefaultValue explicitly to avoid overload ambiguity. Plain strings are values; trusted SQL expressions use RawSql.Insert. An amount with four decimal places Database.AddColumn(\"Orders\", new Column(\"Amount\", DbType.Decimal)\n{\n Precision = 12, Scale = 4, IsNullable = false, DefaultValue = 0m\n}); migration.Create.Column(\"Amount\").OnTable(\"Orders\").OfType(DbType.Decimal)\n .WithPrecision(12, 4).NotNullable().WithDefaultValue(0m); Use TimeOnly for time-of-day values and TimeSpan for intervals. A TimeSpan is a duration, including negative and multi-day values, so a TimeSpan default on a Time column is rejected. PostgreSQL and Oracle have native intervals; SQLite, SQL Server and MySQL/MariaDB store intervals as signed .NET ticks. Clock time and elapsed time Database.AddTable(\"Jobs\",\n new Column(\"RunAt\", DbType.Time) { DefaultValue = new TimeOnly(9, 30) },\n new Column(\"Elapsed\", MigratorDbType.Interval) { DefaultValue = TimeSpan.Zero }); migration.Create.Table(\"Jobs\")\n .WithColumn(\"RunAt\").OfType(DbType.Time).WithDefaultValue(new TimeOnly(9, 30))\n .WithColumn(\"Elapsed\").OfType(MigratorDbType.Interval).WithDefaultValue(TimeSpan.Zero); SQLite does not enforce declared string lengths or decimal precision. UInt64 values above Int64.MaxValue are rejected there. Oracle character empty strings become NULL; Informix and Sybase have their own trimming and range behavior. Consult the type support and boundary matrix for supported mappings and live-test scope. A shared DbType does not imply identical native storage. " }, { "title": "Data operations", "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 operations; the fluent Row method describes one row, not an accumulated collection of rows. 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\" }); Without a predicate, update/delete affects every row. Supply predicate columns and values deliberately. 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.CopyData for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateFrom maps source/target pairs. 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.CopyData(\"Users\", new[] { \"Id\", \"Name\" },\n \"ArchivedUsers\", 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 }); 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", "group": "Operations", "summary": "Read the connected database before deciding what to change. Metadata is different from a model snapshot.", "url": "guide/schema.html", - "text": " 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 if (!Database.ColumnExists(\"Users\", \"Email\"))\n Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320)); if (!Schema.Table(\"Users\").ColumnExists(\"Email\"))\n migration.Create.Column(\"Email\", \"Users\").AsString(320); 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 var constraints = Database.GetTableConstraints(\"Users\");\nforeach (var constraint in constraints)\n Console.WriteLine(constraint.Name); var constraints = Schema.Table(\"Users\").ConstraintDefinitions();\nforeach (var constraint in constraints)\n Console.WriteLine(constraint.Name); 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 Database.AddView(\"UserNames\", \"Users\", new ViewField(\"Id\"), new ViewField(\"Name\")); migration.Create.View(\"UserNames\", \"Users\", new ViewField(\"Id\"), new ViewField(\"Name\")); Dispose readers and commands obtained from the provider. Fluent Schema.Query and Select accept a reader callback and handle disposal. 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. " + "text": " 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 if (!Database.ColumnExists(\"Users\", \"Email\"))\n Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320)); if (!Schema.Table(\"Users\").ColumnExists(\"Email\"))\n migration.Create.Column(\"Email\").OnTable(\"Users\").AsString(320); 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 var constraints = Database.GetTableConstraints(\"Users\");\nforeach (var constraint in constraints)\n Console.WriteLine(constraint.Name); var constraints = Schema.Table(\"Users\").ConstraintDefinitions();\nforeach (var constraint in constraints)\n Console.WriteLine(constraint.Name); 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 Database.AddView(\"UserNames\", \"Users\", new ViewField(\"Id\"), new ViewField(\"Name\")); migration.Create.View(\"UserNames\").FromTable(\"Users\")\n .WithFields(new ViewField(\"Id\"), new ViewField(\"Name\")); 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. " }, { "title": "Execute SQL and scripts", @@ -81,21 +81,21 @@ "group": "Schema basics", "summary": "An index is a separate schema object, even when it enforces uniqueness.", "url": "guide/indexes.html", - "text": " Use an explicit name so the index can be inspected or removed later. Both APIs accept the same Index definition. Fully qualify this type if System.Index is also in scope. Columns retain the order in KeyColumns. Index a user name Database.AddIndex(\"Users\", new DotNetProjects.Migrator.Framework.Index\n{\n Name = \"IX_Users_Name\", KeyColumns = new[] { \"Name\" }, Unique = false\n}); migration.Create.Index(\"Users\", new DotNetProjects.Migrator.Framework.Index\n{\n Name = \"IX_Users_Name\", KeyColumns = new[] { \"Name\" }, Unique = false\n}); Drop an index Database.RemoveIndex(\"Users\", \"IX_Users_Name\"); migration.Delete.Index(\"IX_Users_Name\", \"Users\"); 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. 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. " + "text": " 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 Database.AddIndex(\"Users\", new DotNetProjects.Migrator.Framework.Index\n{\n Name = \"IX_Users_Name\", KeyColumns = new[] { \"Name\" }, Unique = false\n}); migration.Create.Index(\"IX_Users_Name\").OnTable(\"Users\").WithColumns(\"Name\"); Drop an index Database.RemoveIndex(\"Users\", \"IX_Users_Name\"); migration.Delete.Index(\"IX_Users_Name\").FromTable(\"Users\"); 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. 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. " }, { "title": "Keys and constraints", "group": "Schema basics", "summary": "Declare table invariants independently of column attributes.", "url": "guide/constraints.html", - "text": " 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 Database.AddUniqueConstraint(\"UQ_Users_Name\", \"Users\", \"Name\");\nDatabase.AddCheckConstraint(\"CK_Users_Id\", \"Users\", \"Id > 0\"); migration.Create.Unique(\"UQ_Users_Name\", \"Users\", \"Name\");\nmigration.Create.Check(\"CK_Users_Id\", \"Users\", \"Id > 0\"); 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 Database.RemoveConstraint(\"Users\", \"CK_Users_Id\"); migration.Delete.Constraint(\"CK_Users_Id\", \"Users\"); 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. " + "text": " 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 Database.AddUniqueConstraint(\"UQ_Users_Name\", \"Users\", \"Name\");\nDatabase.AddCheckConstraint(\"CK_Users_Id\", \"Users\", \"Id > 0\"); migration.Create.UniqueConstraint(\"UQ_Users_Name\").OnTable(\"Users\").WithColumns(\"Name\");\nmigration.Create.CheckConstraint(\"CK_Users_Id\").OnTable(\"Users\").WithExpression(\"Id > 0\"); 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 Database.RemoveConstraint(\"Users\", \"CK_Users_Id\"); migration.Delete.Constraint(\"CK_Users_Id\").FromTable(\"Users\"); 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. " }, { "title": "Foreign keys", "group": "Schema basics", "summary": "Define ordered child/parent columns and independent actions for update and delete.", "url": "guide/foreign-keys.html", - "text": " 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 ((IForeignKeyActions)Database).AddForeignKey(\n \"FK_Orders_Users\", \"Orders\", new[] { \"UserId\" },\n \"Users\", new[] { \"Id\" }, ForeignKeyConstraintType.Cascade,\n ForeignKeyConstraintType.NoAction); migration.Create.ForeignKey(\"FK_Orders_Users\",\n \"Orders\", new[] { \"UserId\" }, \"Users\", new[] { \"Id\" },\n onDelete: ForeignKeyConstraintType.Cascade,\n onUpdate: ForeignKeyConstraintType.NoAction); Remove dependent keys before incompatible table or key changes. Restore them only after the existing data satisfies the replacement relationship. Remove the foreign key Database.RemoveForeignKey(\"Orders\", \"FK_Orders_Users\"); migration.Delete.ForeignKey(\"FK_Orders_Users\", \"Orders\"); 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. " + "text": " 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 ((IForeignKeyActions)Database).AddForeignKey(\n \"FK_Orders_Users\", \"Orders\", new[] { \"UserId\" },\n \"Users\", new[] { \"Id\" }, ForeignKeyConstraintType.Cascade,\n ForeignKeyConstraintType.NoAction); migration.Create.ForeignKey(\"FK_Orders_Users\")\n .FromTable(\"Orders\").WithColumns(\"UserId\")\n .ToTable(\"Users\").WithColumns(\"Id\")\n .OnDelete(ForeignKeyConstraintType.Cascade)\n .OnUpdate(ForeignKeyConstraintType.NoAction); Remove dependent keys before incompatible table or key changes. Restore them only after the existing data satisfies the replacement relationship. Remove the foreign key Database.RemoveForeignKey(\"Orders\", \"FK_Orders_Users\"); migration.Delete.ForeignKey(\"FK_Orders_Users\").FromTable(\"Orders\"); 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. " }, { "title": "Defaults and collations", @@ -144,7 +144,7 @@ "group": "Migration types", "summary": "Number changes, keep applied source immutable and give independent modules explicit histories.", "url": "guide/versioning.html", - "text": " 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 using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\n[Migration(2026, 9, 23, 10, 0, 0)]\npublic class AddUserEmail : Migration\n{\n public override void Up() => Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320));\n public override void Down() => Database.RemoveColumn(\"Users\", \"Email\");\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\n[Migration(2026, 9, 23, 10, 0, 0)]\npublic class AddUserEmail : FluentMigration\n{\n public override void BuildUp(MigrationBuilder migration)\n => migration.Create.Column(\"Email\", \"Users\").AsString(320);\n public override void BuildDown(MigrationBuilder migration)\n => migration.Delete.Column(\"Email\", \"Users\");\n} 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. 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 Database.MigrationApplied(1, \"billing\"); migration.Execute.WithProvider(provider => provider.MigrationApplied(1, \"billing\")); 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. " + "text": " 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 using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\n[Migration(2026, 9, 23, 10, 0, 0)]\npublic class AddUserEmail : Migration\n{\n public override void Up() => Database.AddColumn(\"Users\", new Column(\"Email\", DbType.String, 320));\n public override void Down() => Database.RemoveColumn(\"Users\", \"Email\");\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\n[Migration(2026, 9, 23, 10, 0, 0)]\npublic class AddUserEmail : FluentMigration\n{\n public override void BuildUp(MigrationBuilder migration)\n => migration.Create.Column(\"Email\").OnTable(\"Users\").AsString(320);\n public override void BuildDown(MigrationBuilder migration)\n => migration.Delete.Column(\"Email\").FromTable(\"Users\");\n} 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. 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 Database.MigrationApplied(1, \"billing\"); migration.Execute.WithProvider(provider => provider.MigrationApplied(1, \"billing\")); 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. " }, { "title": "Tags", @@ -186,21 +186,21 @@ "group": "Database providers", "summary": "Change existing tables from the live schema, without maintaining an ORM model.", "url": "guide/sqlite.html", - "text": " 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 Database.ChangeColumn(\"Users\", new Column(\"Name\", DbType.String, 500)\n{\n IsNullable = false, DefaultValue = \"Unknown\",\n Collation = Collation.AsciiIgnoreCase\n}); migration.Alter.Column(\"Name\", \"Users\")\n .AsString(500).NotNullable().WithDefaultValue(\"Unknown\")\n .WithCollation(Collation.AsciiIgnoreCase); Detail Behavior Mapped data Named-column copy preserves mapped values, subject to the new definition accepting them. Keys and constraints Named/composite keys, ordered foreign-key pairs and separate update/delete actions are retained. Column collations Declared names are retained. Register custom collations on the connection. Indexes and triggers Supported definitions are recreated; unsafe trigger rename/drop-column cases are rejected. AUTOINCREMENT The sequence high-water mark survives, including previously deleted identities. Hidden rowid Not part of the mapped data and may change. 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. 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. 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. " + "text": " 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 Database.ChangeColumn(\"Users\", new Column(\"Name\", DbType.String, 500)\n{\n IsNullable = false, DefaultValue = \"Unknown\",\n Collation = Collation.AsciiIgnoreCase\n}); migration.Alter.Column(\"Name\").OnTable(\"Users\")\n .AsString(500).NotNullable().WithDefaultValue(\"Unknown\")\n .WithCollation(Collation.AsciiIgnoreCase); Detail Behavior Mapped data Named-column copy preserves mapped values, subject to the new definition accepting them. Keys and constraints Named/composite keys, ordered foreign-key pairs and separate update/delete actions are retained. Column collations Declared names are retained. Register custom collations on the connection. Indexes and triggers Supported definitions are recreated; unsafe trigger rename/drop-column cases are rejected. AUTOINCREMENT The sequence high-water mark survives, including previously deleted identities. Hidden rowid Not part of the mapped data and may change. 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. 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. 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. " }, { "title": "SQL Server", "group": "Database providers", "summary": "Explicit keys, provider-specific indexes, transactional DDL and application locks.", "url": "guide/sql-server.html", - "text": " 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. 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 Database.AddPrimaryKeyNonClustered(\"PK_Users\", \"Users\", \"Id\"); migration.Create.NonClusteredPrimaryKey(\"PK_Users\", \"Users\", \"Id\"); 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. 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. " + "text": " 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. 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 Database.AddPrimaryKeyNonClustered(\"PK_Users\", \"Users\", \"Id\"); migration.Create.NonClusteredPrimaryKey(\"PK_Users\").OnTable(\"Users\").WithColumns(\"Id\"); 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. 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. " }, { "title": "PostgreSQL", "group": "Database providers", "summary": "Native intervals, schema-aware metadata, transactional DDL and advisory locks.", "url": "guide/postgresql.html", - "text": " 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. 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 Database.AddColumn(\"Jobs\", new Column(\"Elapsed\", MigratorDbType.Interval)\n{\n DefaultValue = TimeSpan.FromDays(2)\n}); migration.Create.Column(\"Elapsed\", \"Jobs\").OfType(MigratorDbType.Interval)\n .WithDefaultValue(TimeSpan.FromDays(2)); 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. 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. " + "text": " 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. 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 Database.AddColumn(\"Jobs\", new Column(\"Elapsed\", MigratorDbType.Interval)\n{\n DefaultValue = TimeSpan.FromDays(2)\n}); migration.Create.Column(\"Elapsed\").OnTable(\"Jobs\").OfType(MigratorDbType.Interval)\n .WithDefaultValue(TimeSpan.FromDays(2)); 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. 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. " }, { "title": "MySQL and MariaDB", @@ -228,14 +228,14 @@ "group": "Advanced topics", "summary": "Choose between inspecting the live schema and declaring a provider-specific operation.", "url": "guide/conditional.html", - "text": " The Classic provider indexer selects a named provider or a no-op provider. Fluent IfDatabase 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 Database[\"SQLite\"].ExecuteNonQuery(\"UPDATE Users SET Name = upper(Name)\"); migration.IfDatabase(\"SQLite\", sqlite =>\n sqlite.Execute.Sql(\"UPDATE Users SET Name = upper(Name)\")); 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. " + "text": " 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 Database[\"SQLite\"].ExecuteNonQuery(\"UPDATE Users SET Name = upper(Name)\"); migration.IfProvider(\"SQLite\", sqlite =>\n sqlite.Execute.Sql(\"UPDATE Users SET Name = upper(Name)\")); 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. " }, { "title": "Custom extensions", "group": "Advanced topics", "summary": "Reuse schema conventions without hiding provider behavior or changing the migration contract.", "url": "guide/extensions.html", - "text": " 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 using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\npublic static class AuditColumns\n{\n public static void Add(ITransformationProvider database, string table)\n => database.AddColumn(table, new Column(\"CreatedAt\", DbType.DateTime)\n {\n IsNullable = false, DefaultValue = RawSql.Insert(\"CURRENT_TIMESTAMP\")\n });\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\npublic static class AuditColumns\n{\n public static void Add(MigrationBuilder migration, string table)\n => migration.Create.Column(\"CreatedAt\", table).OfType(DbType.DateTime)\n .NotNullable().WithDefaultValue(RawSql.Insert(\"CURRENT_TIMESTAMP\"));\n} RunnerOptions.Activator constructs migrations when a DI container is not appropriate. IMigrationLock supplies a disposable lease for custom deployment coordination. Release must work on success and failure. Configure these at the host boundary rather than in individual migrations. ITransformationProvider defines execution and metadata operations. A custom provider needs accurate typed constraint definitions or an explicit unsupported error. IMigrationHistory enables read-only planning and effective-scope history selection. IScriptBatchProvider extends script processing. Keep SQL rendering independent of a live connection. Custom MigrationOperation implementations need deliberate validation, application, SQL rendering and reversal behavior. See the API map and implementation contracts before claiming preview or reversal support. " + "text": " 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 using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\npublic static class AuditColumns\n{\n public static void Add(ITransformationProvider database, string table)\n => database.AddColumn(table, new Column(\"CreatedAt\", DbType.DateTime)\n {\n IsNullable = false, DefaultValue = RawSql.Insert(\"CURRENT_TIMESTAMP\")\n });\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\npublic static class AuditColumns\n{\n public static void Add(MigrationBuilder migration, string table)\n => migration.Create.Column(\"CreatedAt\").OnTable(table).OfType(DbType.DateTime)\n .NotNullable().WithDefaultValue(RawSql.Insert(\"CURRENT_TIMESTAMP\"));\n} RunnerOptions.Activator constructs migrations when a DI container is not appropriate. IMigrationLock supplies a disposable lease for custom deployment coordination. Release must work on success and failure. Configure these at the host boundary rather than in individual migrations. ITransformationProvider defines execution and metadata operations. A custom provider needs accurate typed constraint definitions or an explicit unsupported error. IMigrationHistory enables read-only planning and effective-scope history selection. IScriptBatchProvider extends script processing. Keep SQL rendering independent of a live connection. Custom MigrationOperation implementations need deliberate validation, application, SQL rendering and reversal behavior. See the API map and implementation contracts before claiming preview or reversal support. " }, { "title": "Testing and deployment", @@ -256,7 +256,7 @@ "group": "Reference", "summary": "A practical index of the two authoring surfaces and their shared provider contracts.", "url": "guide/api-map.html", - "text": " Classic Fluent AddTable Create.Table AddColumn(table, column) Create.Column(name, table) ChangeColumn(table, column) Alter.Column(name, table) / Alter.Column(table, column) RemoveTable / RemoveColumn Delete.Table / Delete.Column RenameTable / RenameColumn Rename.Table / Rename.Column AddPrimaryKey / AddUniqueConstraint / AddCheckConstraint Create.PrimaryKey / Create.Unique / Create.Check AddForeignKey / RemoveForeignKey Create.ForeignKey / Delete.ForeignKey AddIndex / RemoveIndex Create.Index / Delete.Index GetTableConstraints / GetColumns Schema.Table(name).ConstraintDefinitions() / Columns() Classic Fluent Insert / InsertIfNotExists Insert.IntoTable(...).Row(...) / IfNotExists(...) Update / Delete Update.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...) ExecuteNonQuery / ExecuteScript / ExecuteResourceScript Execute.Sql / Execute.Script / Execute.EmbeddedScript CopyDataFromTableToTable / UpdateTargetFromSource Execute.CopyData / Execute.UpdateFrom TruncateTable Execute.Truncate CreateCommand / Connection Execute.WithCommand / Execute.WithConnection Database provider access Context or Execute.WithProvider History / transactions Shared runner and explicit provider context Both APIs reach the same provider layer, but not every operation has SQL-preview or automatic-reversal support. Provider capabilities still govern execution. Read preview , reversal and the machine-checked method-family inventory for the distinction. " + "text": " Classic Fluent AddTable Create.Table AddColumn(table, column) Create.Column(name).OnTable(table) ChangeColumn(table, column) Alter.Column(name).OnTable(table) / Alter.Column(definition).OnTable(table) RemoveTable / RemoveColumn Delete.Table(table) / Delete.Column(name).FromTable(table) RenameTable / RenameColumn Rename.Table(old).To(new) / Rename.Column(old).OnTable(table).To(new) AddPrimaryKey / AddUniqueConstraint / AddCheckConstraint Create.PrimaryKey(name).OnTable(table).WithColumns(...) / Create.UniqueConstraint / Create.CheckConstraint AddForeignKey / RemoveForeignKey Create.ForeignKey(name).FromTable(child).WithColumns(...).ToTable(parent).WithColumns(...) / Delete.ForeignKey(name).FromTable(table) AddIndex / RemoveIndex Create.Index(name).OnTable(table).WithColumns(...) / Delete.Index(name).FromTable(table) GetTableConstraints / GetColumns Schema.Table(name).ConstraintDefinitions() / Columns() Classic Fluent Insert / InsertIfNotExists Insert.IntoTable(...).Row(...) / IfNotExists(...) Update / Delete Update.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...) ExecuteNonQuery / ExecuteScript / ExecuteResourceScript Execute.Sql / Execute.Script / Execute.EmbeddedScript CopyDataFromTableToTable / UpdateTargetFromSource Execute.CopyDataFromTable(source).ToTable(target).WithColumns(...) / Execute.UpdateTable(target).FromTable(source).Set(...).Match(...) TruncateTable Execute.Truncate CreateCommand / Connection Execute.WithCommand / Execute.WithConnection Database provider access Context or Execute.WithProvider History / transactions Shared runner and explicit provider context Both APIs reach the same provider layer, but not every operation has SQL-preview or automatic-reversal support. Provider capabilities still govern execution. Read preview , reversal and the machine-checked method-family inventory for the distinction. " }, { "title": "Contributing", diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json index af329b52..d510cf1b 100644 --- a/docs/fluent-operation-coverage.json +++ b/docs/fluent-operation-coverage.json @@ -1,41 +1,41 @@ { "AddTable": "Create.Table(...).WithFields(...) / WithEngine(...)", - "AddColumn": "Create.Column(...).OfType(...).WithPrecision(...)", - "ChangeColumn": "Alter.Column(...).OfType(...).WithPrecision(...)", - "AddPrimaryKey": "Create.PrimaryKey(...) / Create.NonClusteredPrimaryKey(...)", - "AddPrimaryKeyNonClustered": "Create.PrimaryKey(...) / Create.NonClusteredPrimaryKey(...)", - "AddForeignKey": "Create.ForeignKey(...); supply a generated name for GenerateForeignKey convenience overloads", - "GenerateForeignKey": "Create.ForeignKey(...); supply a generated name for GenerateForeignKey convenience overloads", - "AddUniqueConstraint": "Create.Unique(...) / Create.Check(...)", - "AddCheckConstraint": "Create.Unique(...) / Create.Check(...)", - "AddIndex": "Create.Index(table, Index)", - "AddView": "Create.View(...), both view-definition forms", - "RemoveTable": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveColumn": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveForeignKey": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveConstraint": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemovePrimaryKey": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveColumnDefaultValue": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveIndex": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveAllIndexes": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveAllConstraints": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RemoveAllForeignKeys": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", - "RenameTable": "Rename.Table(...) / Rename.Column(...)", - "RenameColumn": "Rename.Table(...) / Rename.Column(...)", + "AddColumn": "Create.Column(name).OnTable(table).OfType(...) / Create.Column(definition).OnTable(table)", + "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(...)", + "AddForeignKey": "Create.ForeignKey(name).FromTable(child).WithColumns(...).ToTable(parent).WithColumns(...).OnDelete(...).OnUpdate(...)", + "GenerateForeignKey": "Create.ForeignKey(name).FromTable(child).WithColumns(...).ToTable(parent).WithColumns(...); supply the generated name explicitly", + "AddUniqueConstraint": "Create.UniqueConstraint(name).OnTable(table).WithColumns(...)", + "AddCheckConstraint": "Create.CheckConstraint(name).OnTable(table).WithExpression(sql)", + "AddIndex": "Create.Index(name).OnTable(table).WithColumns(...) / Create.Index(definition).OnTable(table)", + "AddView": "Create.View(name).FromTable(table).WithFields(...) / WithElements(...)", + "RemoveTable": "Delete.Table(table)", + "RemoveColumn": "Delete.Column(name).FromTable(table)", + "RemoveForeignKey": "Delete.ForeignKey(name).FromTable(table)", + "RemoveConstraint": "Delete.Constraint(name).FromTable(table)", + "RemovePrimaryKey": "Delete.PrimaryKey().FromTable(table)", + "RemoveColumnDefaultValue": "Delete.DefaultValue(column).FromTable(table)", + "RemoveIndex": "Delete.Index(name).FromTable(table)", + "RemoveAllIndexes": "Delete.AllIndexes().FromTable(table)", + "RemoveAllConstraints": "Delete.AllConstraints().FromTable(table)", + "RemoveAllForeignKeys": "Delete.ForeignKeysForColumn(column).FromTable(table)", + "RenameTable": "Rename.Table(oldName).To(newName)", + "RenameColumn": "Rename.Column(oldName).OnTable(table).To(newName)", "Insert": "Insert.IntoTable(...).Row(...)[.IfNotExists(...)]", "InsertIfNotExists": "Insert.IntoTable(...).Row(...)[.IfNotExists(...)]", - "Update": "Update.Table(...).Set(...).Where(...) / WhereSql(...)", - "Delete": "Delete.FromTable(...).Where(...)", + "Update": "Update.Table(table).Set(...).Where(...) / WhereSql(...) / AllRows()", + "Delete": "Delete.FromTable(table).Where(...) / AllRows()", "TruncateTable": "Execute.Truncate(...)", - "CopyDataFromTableToTable": "Execute.CopyData(...) / Execute.UpdateFrom(...)", - "UpdateTargetFromSource": "Execute.CopyData(...) / Execute.UpdateFrom(...)", + "CopyDataFromTableToTable": "Execute.CopyDataFromTable(source).ToTable(target).WithColumns(sourceColumns, targetColumns).OrderBy(...)", + "UpdateTargetFromSource": "Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs)", "ExecuteNonQuery": "Execute.Sql(...) / Script(...) / EmbeddedScript(...)", "ExecuteQuery": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", "ExecuteStringQuery": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", "ExecuteScalar": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", - "Select": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", - "SelectComplex": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", - "SelectScalar": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", + "Select": "Schema.Table(table).Select(...); raw selection overloads through explicit Context", + "SelectComplex": "Schema.Table(table).Select(...)", + "SelectScalar": "Schema.Table(table).SelectScalar(columns, where)", "GetColumns": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", "GetColumnByName": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", "GetIndexes": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", @@ -60,7 +60,7 @@ "DropDatabases": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", "SwitchDatabase": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", "KillDatabaseConnections": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", - "IsThisProvider": "IfDatabase(...); Context.IsThisProvider(...) for imperative branching", + "IsThisProvider": "IfProvider(...); Context.IsThisProvider(...) for imperative branching", "CreateCommand": "Execute.WithCommand(...); explicit Context for advanced command lifetime", "GetCommand": "Execute.WithCommand(...); explicit Context for advanced command lifetime", "BeginTransaction": "Explicit Context history/transaction APIs; not schema expressions", diff --git a/docs/fluent-operation-coverage.md b/docs/fluent-operation-coverage.md index f81686a3..b6dd7ca8 100644 --- a/docs/fluent-operation-coverage.md +++ b/docs/fluent-operation-coverage.md @@ -4,17 +4,17 @@ The machine-readable [inventory](fluent-operation-coverage.json) maps every name | Capability | Authoring / inspection | Validation evidence | | --- | --- | --- | -| Complete tables, columns, types, defaults, identity, nullability and precision | `Create.Table`, `Create.Column`, `Alter.Column` | Complete table snapshot, SQLite schema/data, provider precision tests | -| Keys, foreign-key actions, unique/check constraints | `Create.PrimaryKey`, `ForeignKey`, `Unique`, `Check` | Provider constraint/action suites; unsupported providers throw | -| Index keys, include/filter/cluster options | `Create.Index` with the existing `Index` model | Provider index suites; preview rejects unsupported options | -| Views and joins | `Create.View` with either normal definition model | Definitions snapshot caller input; provider implementations retain their own limitations | +| Complete tables, columns, types, defaults, identity, nullability and precision | `Create.Table(...).WithColumn(...)`, `Create.Column(...).OnTable(...)`, `Alter.Column(...).OnTable(...)` | Shared column options, independent column handles and snapshots, SQLite schema/data | +| Keys, foreign-key actions, unique/check constraints | `Create.PrimaryKey/UniqueConstraint/CheckConstraint(...).OnTable(...)`; `Create.ForeignKey(...).FromTable(...).WithColumns(...).ToTable(...).WithColumns(...).OnDelete(...).OnUpdate(...)` | Composite key order, independent actions and provider dispatch; unsupported providers throw | +| Index keys, include/filter/cluster options | `Create.Index(name).OnTable(table).WithColumns(...)` or `Create.Index(definition).OnTable(table)` | Input/build snapshots, provider index suites; preview rejects unsupported options | +| Views and joins | `Create.View(name).FromTable(table).WithFields(...)` or `.WithElements(...)` | Definitions snapshot caller input; provider implementations retain their own limitations | | Drop/rename operations | `Delete` / `Rename` | SQLite schema tests, reversal tests; destructive changes need explicit reverse definitions | -| Inserts, conditional insert, update, delete | `Insert`, `Update`, `Delete.FromTable` | FluentDataChangesAndSchemaReadsPersistExpectedRows | -| Truncate, data copy, update from another table | `Execute.Truncate/CopyData/UpdateFrom` | Normal provider tests; mutable pair snapshot regression | +| Inserts, conditional insert, update, delete | `Insert.IntoTable(...).Row(...).IfNotExists(...)`, `Update.Table(...).Set(...).Where(...)`, `Delete.FromTable(...).Where(...)`; explicit `AllRows()` for unfiltered update/delete | Persisted rows, invalid/incomplete expressions rejected before execution | +| Truncate, data copy, update from another table | `Execute.Truncate(table)`, `Execute.CopyDataFromTable(source).ToTable(target).WithColumns(...)`, `Execute.UpdateTable(target).FromTable(source).Set(...).Match(...)` | SQLite copy, normal provider tests, mutable pair snapshots | | SQL, files, resources | `Execute.Sql/Script/EmbeddedScript` | SQL execution/preview tests; SQL Server GO batch splitting through the script APIs, with explicit rejection of unsupported client directives | -| Scalars/readers/existence/metadata | `Schema`, `Schema.Table`, `Select`, `SelectScalar` | Reader disposal and persisted-data assertions; nullable helpers are additive | +| Scalars/readers/existence/metadata | `Schema`, `Schema.Table(table).Select(...) / SelectScalar(...)` | Reader disposal and persisted-data assertions; nullable helpers are additive | | Database administration | `Administration` | Typed operations flag transaction incompatibility; backend capabilities still apply | -| Provider conditions, commands/connections | `IfDatabase`, `Execute.WithCommand/WithConnection/WithProvider` | Inactive reversal, explicit preview rejection; callbacks execute trusted code | +| Provider conditions, commands/connections | `IfProvider`, `Execute.WithCommand/WithConnection/WithProvider` | Inactive reversal, explicit preview rejection; callbacks execute trusted code | | History and transaction administration | Explicit `Context` | Runner transaction/scope/history regressions; not schema expressions | Execution coverage is broader than SQL-generation and automatic-reversal coverage. Unsupported preview/reversal operations fail explicitly. The inventory is an API coverage check, not a claim that every overload has an independent live test on every engine. Continue adding behavioral provider tests when changing an operation's semantics. diff --git a/docs/guide/altering-tables.html b/docs/guide/altering-tables.html index 0a3121a9..75f7bae6 100644 --- a/docs/guide/altering-tables.html +++ b/docs/guide/altering-tables.html @@ -10,13 +10,13 @@
Database.RenameTable("Users", "Members");
 Database.RenameColumn("Members", "Name", "DisplayName");

Fluent

-
migration.Rename.Table("Users", "Members");
-migration.Rename.Column("Members", "Name", "DisplayName");

Inside Up() / BuildUp(MigrationBuilder migration)

Change a complete column definition

Supply the type, length, nullability, default and collation you intend to retain. ChangeColumn replaces the column definition; it does not infer that table constraints should be created or removed. Existing rows must remain valid for the new definition.

Widen a required display name
+
migration.Rename.Table("Users").To("Members");
+migration.Rename.Column("Name").OnTable("Members").To("DisplayName");

Inside Up() / BuildUp(MigrationBuilder migration)

Change a complete column definition

Supply the type, length, nullability, default and collation you intend to retain. ChangeColumn replaces the column definition; it does not infer that table constraints should be created or removed. Existing rows must remain valid for the new definition.

Widen a required display name

Classic

Database.ChangeColumn("Users", new Column("Name", DbType.String, 500)
 {
     IsNullable = false
 });

Fluent

-
migration.Alter.Column("Name", "Users")
+
migration.Alter.Column("Name").OnTable("Users")
     .AsString(500).NotNullable();

Inside Up() / BuildUp(MigrationBuilder migration)

A deployment sequence for populated data

Add a nullable column, deploy code that can read both forms, backfill values, then enforce the final requirement in a later migration. Large data copies and index creation can hold locks for substantial time; test them against a representative dataset.

On SQLite, a supported alteration may recreate the table and copy rows. On Oracle and some other engines, DDL may commit implicitly. Review the transaction guide and your provider page before choosing the deployment boundary.

diff --git a/docs/guide/api-map.html b/docs/guide/api-map.html index e528c382..c26e8397 100644 --- a/docs/guide/api-map.html +++ b/docs/guide/api-map.html @@ -5,4 +5,4 @@ Migrator.NET -

Classic / Fluent API map

A practical index of the two authoring surfaces and their shared provider contracts.

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

Schema operations

ClassicFluent
AddTableCreate.Table
AddColumn(table, column)Create.Column(name, table)
ChangeColumn(table, column)Alter.Column(name, table) / Alter.Column(table, column)
RemoveTable / RemoveColumnDelete.Table / Delete.Column
RenameTable / RenameColumnRename.Table / Rename.Column
AddPrimaryKey / AddUniqueConstraint / AddCheckConstraintCreate.PrimaryKey / Create.Unique / Create.Check
AddForeignKey / RemoveForeignKeyCreate.ForeignKey / Delete.ForeignKey
AddIndex / RemoveIndexCreate.Index / Delete.Index
GetTableConstraints / GetColumnsSchema.Table(name).ConstraintDefinitions() / Columns()

Data and execution

ClassicFluent
Insert / InsertIfNotExistsInsert.IntoTable(...).Row(...) / IfNotExists(...)
Update / DeleteUpdate.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...)
ExecuteNonQuery / ExecuteScript / ExecuteResourceScriptExecute.Sql / Execute.Script / Execute.EmbeddedScript
CopyDataFromTableToTable / UpdateTargetFromSourceExecute.CopyData / Execute.UpdateFrom
TruncateTableExecute.Truncate
CreateCommand / ConnectionExecute.WithCommand / Execute.WithConnection
Database provider accessContext or Execute.WithProvider
History / transactionsShared runner and explicit provider context

Execution is not preview or reversal

Both APIs reach the same provider layer, but not every operation has SQL-preview or automatic-reversal support. Provider capabilities still govern execution. Read preview, reversal and the machine-checked method-family inventory for the distinction.

+

Classic / Fluent API map

A practical index of the two authoring surfaces and their shared provider contracts.

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

Schema operations

ClassicFluent
AddTableCreate.Table
AddColumn(table, column)Create.Column(name).OnTable(table)
ChangeColumn(table, column)Alter.Column(name).OnTable(table) / Alter.Column(definition).OnTable(table)
RemoveTable / RemoveColumnDelete.Table(table) / Delete.Column(name).FromTable(table)
RenameTable / RenameColumnRename.Table(old).To(new) / Rename.Column(old).OnTable(table).To(new)
AddPrimaryKey / AddUniqueConstraint / AddCheckConstraintCreate.PrimaryKey(name).OnTable(table).WithColumns(...) / Create.UniqueConstraint / Create.CheckConstraint
AddForeignKey / RemoveForeignKeyCreate.ForeignKey(name).FromTable(child).WithColumns(...).ToTable(parent).WithColumns(...) / Delete.ForeignKey(name).FromTable(table)
AddIndex / RemoveIndexCreate.Index(name).OnTable(table).WithColumns(...) / Delete.Index(name).FromTable(table)
GetTableConstraints / GetColumnsSchema.Table(name).ConstraintDefinitions() / Columns()

Data and execution

ClassicFluent
Insert / InsertIfNotExistsInsert.IntoTable(...).Row(...) / IfNotExists(...)
Update / DeleteUpdate.Table(...).Set(...).Where(...) / Delete.FromTable(...).Where(...)
ExecuteNonQuery / ExecuteScript / ExecuteResourceScriptExecute.Sql / Execute.Script / Execute.EmbeddedScript
CopyDataFromTableToTable / UpdateTargetFromSourceExecute.CopyDataFromTable(source).ToTable(target).WithColumns(...) / Execute.UpdateTable(target).FromTable(source).Set(...).Match(...)
TruncateTableExecute.Truncate
CreateCommand / ConnectionExecute.WithCommand / Execute.WithConnection
Database provider accessContext or Execute.WithProvider
History / transactionsShared runner and explicit provider context

Execution is not preview or reversal

Both APIs reach the same provider layer, but not every operation has SQL-preview or automatic-reversal support. Provider capabilities still govern execution. Read preview, reversal and the machine-checked method-family inventory for the distinction.

diff --git a/docs/guide/columns.html b/docs/guide/columns.html index a1d22c48..a68a5ebf 100644 --- a/docs/guide/columns.html +++ b/docs/guide/columns.html @@ -5,28 +5,28 @@ Migrator.NET -

Columns and data types

Type, size, precision, nullability, defaults, identity and collation are explicit column attributes.

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

Add and remove a column

Column builders take column name followed by table name. Classic AddColumn takes table name first. New nullable columns accept existing rows without a backfill. A required column usually needs a compatible default or a staged data migration.

Add an optional email address
-

Classic

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

Fluent

-
migration.Create.Column("Email", "Users").AsString(320).Nullable();

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the email column
-

Classic

-
Database.RemoveColumn("Users", "Email");
-

Fluent

-
migration.Delete.Column("Email", "Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Precision and defaults

For decimal values specify precision and scale. In a Column constructor an integer after the type is the size, not a numeric default. Set DefaultValue explicitly to avoid overload ambiguity. Plain strings are values; trusted SQL expressions use RawSql.Insert.

An amount with four decimal places
-

Classic

-
Database.AddColumn("Orders", new Column("Amount", DbType.Decimal)
+

Columns and data types

Type, size, precision, nullability, defaults, identity and collation are explicit column attributes.

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

Explicit table and column steps

Create.Column(name).OnTable(table) and Alter.Column(name).OnTable(table) select the table before exposing type and column options. Delete.Column(name).FromTable(table) completes a removal. For a complete Column model use Create.Column(definition).OnTable(table) or Alter.Column(definition).OnTable(table); the definition is copied. Every named fluent column requires As... or OfType(...) before execution.

Table columns, added columns and altered columns share the same options, including AsGuid, AsBoolean, AsDecimal(precision, scale), AsDate, AsDateTime and AsDateTime2. AsDateTime maps to DbType.DateTime; AsDateTime2 maps to DbType.DateTime2. OfType(DbType) and OfType(MigratorDbType) remain available for other types. Nullability defaults to nullable.

Add and remove a column

Column builders take column name followed by table name. Classic AddColumn takes table name first. New nullable columns accept existing rows without a backfill. A required column usually needs a compatible default or a staged data migration.

Add an optional email address
+

Classic

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

Fluent

+
migration.Create.Column("Email").OnTable("Users").AsString(320).Nullable();

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the email column
+

Classic

+
Database.RemoveColumn("Users", "Email");
+

Fluent

+
migration.Delete.Column("Email").FromTable("Users");

Inside Up() / BuildUp(MigrationBuilder migration)

Precision and defaults

For decimal values specify precision and scale. In a Column constructor an integer after the type is the size, not a numeric default. Set DefaultValue explicitly to avoid overload ambiguity. Plain strings are values; trusted SQL expressions use RawSql.Insert.

An amount with four decimal places
+

Classic

+
Database.AddColumn("Orders", new Column("Amount", DbType.Decimal)
 {
     Precision = 12, Scale = 4, IsNullable = false, DefaultValue = 0m
-});
-

Fluent

-
migration.Create.Column("Amount", "Orders").OfType(DbType.Decimal)
-    .WithPrecision(12, 4).NotNullable().WithDefaultValue(0m);

Inside Up() / BuildUp(MigrationBuilder migration)

Time of day and durations

Use TimeOnly for time-of-day values and TimeSpan for intervals. A TimeSpan is a duration, including negative and multi-day values, so a TimeSpan default on a Time column is rejected. PostgreSQL and Oracle have native intervals; SQLite, SQL Server and MySQL/MariaDB store intervals as signed .NET ticks.

Clock time and elapsed time
-

Classic

-
Database.AddTable("Jobs",
+});
+

Fluent

+
migration.Create.Column("Amount").OnTable("Orders").OfType(DbType.Decimal)
+    .WithPrecision(12, 4).NotNullable().WithDefaultValue(0m);

Inside Up() / BuildUp(MigrationBuilder migration)

Time of day and durations

Use TimeOnly for time-of-day values and TimeSpan for intervals. A TimeSpan is a duration, including negative and multi-day values, so a TimeSpan default on a Time column is rejected. PostgreSQL and Oracle have native intervals; SQLite, SQL Server and MySQL/MariaDB store intervals as signed .NET ticks.

Clock time and elapsed time
+

Classic

+
Database.AddTable("Jobs",
     new Column("RunAt", DbType.Time) { DefaultValue = new TimeOnly(9, 30) },
-    new Column("Elapsed", MigratorDbType.Interval) { DefaultValue = TimeSpan.Zero });
-

Fluent

-
migration.Create.Table("Jobs")
+    new Column("Elapsed", MigratorDbType.Interval) { DefaultValue = TimeSpan.Zero });
+

Fluent

+
migration.Create.Table("Jobs")
     .WithColumn("RunAt").OfType(DbType.Time).WithDefaultValue(new TimeOnly(9, 30))
-    .WithColumn("Elapsed").OfType(MigratorDbType.Interval).WithDefaultValue(TimeSpan.Zero);

Inside Up() / BuildUp(MigrationBuilder migration)

Database storage differs

SQLite does not enforce declared string lengths or decimal precision. UInt64 values above Int64.MaxValue are rejected there. Oracle character empty strings become NULL; Informix and Sybase have their own trimming and range behavior. Consult the type support and boundary matrix for supported mappings and live-test scope. A shared DbType does not imply identical native storage.

+ .WithColumn("Elapsed").OfType(MigratorDbType.Interval).WithDefaultValue(TimeSpan.Zero);

Inside Up() / BuildUp(MigrationBuilder migration)

Database storage differs

SQLite does not enforce declared string lengths or decimal precision. UInt64 values above Int64.MaxValue are rejected there. Oracle character empty strings become NULL; Informix and Sybase have their own trimming and range behavior. Consult the type support and boundary matrix for supported mappings and live-test scope. A shared DbType does not imply identical native storage.

diff --git a/docs/guide/conditional.html b/docs/guide/conditional.html index faef06f8..f7bf70ad 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 IfDatabase 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
+

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.IfDatabase("SQLite", sqlite =>
+
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/constraints.html b/docs/guide/constraints.html index b73378d3..c18cc4dc 100644 --- a/docs/guide/constraints.html +++ b/docs/guide/constraints.html @@ -10,9 +10,9 @@
Database.AddUniqueConstraint("UQ_Users_Name", "Users", "Name");
 Database.AddCheckConstraint("CK_Users_Id", "Users", "Id > 0");

Fluent

-
migration.Create.Unique("UQ_Users_Name", "Users", "Name");
-migration.Create.Check("CK_Users_Id", "Users", "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
+
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", "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.

+
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/creating-tables.html b/docs/guide/creating-tables.html index cb1d124a..942c84f5 100644 --- a/docs/guide/creating-tables.html +++ b/docs/guide/creating-tables.html @@ -5,7 +5,7 @@ Migrator.NET -

Creating tables

Describe a complete table: columns first, with explicit named keys and constraints.

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

Create a table with a key

The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain.

CreateUsers.cs
+

Creating tables

Describe a complete table: columns first, with explicit named keys and constraints.

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

Create a table with a key

The table definition groups related schema objects into one operation. Primary-key columns are emitted as non-nullable. In the fluent API a complete table is collected before execution, so keys can refer to columns declared in the same chain. WithColumn returns a builder bound to that specific column. Table-level methods such as WithPrimaryKey return the table builder; call WithColumn again before supplying more column options.

CreateUsers.cs

Classic

using System.Data;
 using DotNetProjects.Migrator.Framework;
diff --git a/docs/guide/data.html b/docs/guide/data.html
index 1c0bae90..64c55b78 100644
--- a/docs/guide/data.html
+++ b/docs/guide/data.html
@@ -5,12 +5,12 @@
 Migrator.NET
 
 
-

Data operations

Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.

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

Insert rows

Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple operations; the fluent Row method describes one row, not an accumulated collection of rows.

Insert a user
+

Data operations

Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.

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

Insert rows

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

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Update and delete with predicates

Without a predicate, update/delete affects every row. Supply predicate columns and values deliberately. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input.

Update one user
+ .Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" });

Inside Up() / BuildUp(MigrationBuilder migration)

Update and delete with predicates

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

Classic

Database.Update("Users", new[] { "Name" }, new object[] { "Ada Lovelace" },
     new[] { "Id" }, new object[] { 1 });
@@ -28,11 +28,11 @@

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.CopyData for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateFrom maps source/target pairs. 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
+ .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.CopyData("Users", new[] { "Id", "Name" },
-    "ArchivedUsers", new[] { "UserId", "DisplayName" });

Inside Up() / BuildUp(MigrationBuilder migration)

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

Inside Up() / BuildUp(MigrationBuilder migration)

diff --git a/docs/guide/extensions.html b/docs/guide/extensions.html index b11e31f8..57a5b26b 100644 --- a/docs/guide/extensions.html +++ b/docs/guide/extensions.html @@ -30,6 +30,6 @@ public static class AuditColumns { public static void Add(MigrationBuilder migration, string table) - => migration.Create.Column("CreatedAt", table).OfType(DbType.DateTime) + => migration.Create.Column("CreatedAt").OnTable(table).OfType(DbType.DateTime) .NotNullable().WithDefaultValue(RawSql.Insert("CURRENT_TIMESTAMP")); }

Choose one authoring style

Custom activation and locks

RunnerOptions.Activator constructs migrations when a DI container is not appropriate. IMigrationLock supplies a disposable lease for custom deployment coordination. Release must work on success and failure. Configure these at the host boundary rather than in individual migrations.

Provider authors

ITransformationProvider defines execution and metadata operations. A custom provider needs accurate typed constraint definitions or an explicit unsupported error. IMigrationHistory enables read-only planning and effective-scope history selection. IScriptBatchProvider extends script processing.

Keep SQL rendering independent of a live connection. Custom MigrationOperation implementations need deliberate validation, application, SQL rendering and reversal behavior. See the API map and implementation contracts before claiming preview or reversal support.

diff --git a/docs/guide/foreign-keys.html b/docs/guide/foreign-keys.html index 3075f854..fde787c3 100644 --- a/docs/guide/foreign-keys.html +++ b/docs/guide/foreign-keys.html @@ -12,11 +12,12 @@ "Users", new[] { "Id" }, ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.NoAction);

Fluent

-
migration.Create.ForeignKey("FK_Orders_Users",
-    "Orders", new[] { "UserId" }, "Users", new[] { "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
+
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", "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.

+
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 1618946e..c6d7ecb9 100644 --- a/docs/guide/indexes.html +++ b/docs/guide/indexes.html @@ -5,18 +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. Both APIs accept the same Index definition. Fully qualify this type if System.Index is also in scope. Columns retain the order in KeyColumns.

Index a user name
+

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("Users", new DotNetProjects.Migrator.Framework.Index
-{
-    Name = "IX_Users_Name", KeyColumns = new[] { "Name" }, Unique = false
-});

Inside Up() / BuildUp(MigrationBuilder migration)

Drop an index
+
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", "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.

+
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/postgresql.html b/docs/guide/postgresql.html index f43f636c..82353f0d 100644 --- a/docs/guide/postgresql.html +++ b/docs/guide/postgresql.html @@ -12,5 +12,5 @@ DefaultValue = TimeSpan.FromDays(2) });

Fluent

-
migration.Create.Column("Elapsed", "Jobs").OfType(MigratorDbType.Interval)
+
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/schema.html b/docs/guide/schema.html index 3fce2273..6cda148d 100644 --- a/docs/guide/schema.html +++ b/docs/guide/schema.html @@ -11,7 +11,7 @@ Database.AddColumn("Users", new Column("Email", DbType.String, 320));

Fluent

if (!Schema.Table("Users").ColumnExists("Email"))
-    migration.Create.Column("Email", "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
+ 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)
@@ -23,4 +23,5 @@
 

Classic

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

Fluent

-
migration.Create.View("UserNames", "Users", 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 Select accept a reader callback and handle disposal. 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.

+
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 cec02f87..5ca4da4f 100644 --- a/docs/guide/sql-server.html +++ b/docs/guide/sql-server.html @@ -9,4 +9,4 @@

Classic

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

Fluent

-
migration.Create.NonClusteredPrimaryKey("PK_Users", "Users", "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.

+
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/sqlite.html b/docs/guide/sqlite.html index 4b6922b4..faa0d0e6 100644 --- a/docs/guide/sqlite.html +++ b/docs/guide/sqlite.html @@ -13,6 +13,6 @@ Collation = Collation.AsciiIgnoreCase });

Fluent

-
migration.Alter.Column("Name", "Users")
+
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/versioning.html b/docs/guide/versioning.html index 0502fda3..bb253274 100644 --- a/docs/guide/versioning.html +++ b/docs/guide/versioning.html @@ -29,9 +29,9 @@ public class AddUserEmail : FluentMigration { public override void BuildUp(MigrationBuilder migration) - => migration.Create.Column("Email", "Users").AsString(320); + => migration.Create.Column("Email").OnTable("Users").AsString(320); public override void BuildDown(MigrationBuilder migration) - => migration.Delete.Column("Email", "Users"); + => 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");
diff --git a/docs/index.html b/docs/index.html index c6dfd9cf..560db596 100644 --- a/docs/index.html +++ b/docs/index.html @@ -70,7 +70,7 @@

Database changes,
written in C#.

Collation = Collation.AsciiIgnoreCase });

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

diff --git a/docs/migration-guide-12.1-to-13.md b/docs/migration-guide-12.1-to-13.md index c342a633..560db5c7 100644 --- a/docs/migration-guide-12.1-to-13.md +++ b/docs/migration-guide-12.1-to-13.md @@ -66,6 +66,46 @@ Replace `WithProperty`, unnamed `PrimaryKey()` and `Unique()` with `WithPrimaryKey(name, columns)` and `WithUniqueConstraint(name, columns)`. Use `Create.ForeignKey` with separate delete/update actions. +Fluent expressions select their table in a separate step: + +```csharp +migration.Create.Column("Email").OnTable("Users").AsString(320).Nullable(); +migration.Alter.Column("Name").OnTable("Users").AsString(200).NotNullable(); +migration.Rename.Column("Name").OnTable("Users").To("DisplayName"); +migration.Delete.Column("Email").FromTable("Users"); +migration.Create.Index("IX_Users_Name").OnTable("Users").WithColumns("DisplayName"); +migration.Create.ForeignKey("FK_Orders_Users") + .FromTable("Orders").WithColumns("UserId") + .ToTable("Users").WithColumns("Id") + .OnDelete(ForeignKeyConstraintType.Cascade); +``` + +The unreleased positional fluent overloads are removed. Use +`Create.UniqueConstraint(name).OnTable(table).WithColumns(...)` and +`Create.CheckConstraint(name).OnTable(table).WithExpression(sql)` for named +constraints. Renames end with `.To(newName)`. All object removal expressions +except `Delete.Table(table)` end with `.FromTable(table)`; for example, +`Delete.PrimaryKey().FromTable(table)` and +`Delete.DefaultValue(column).FromTable(table)`. + +Every named fluent column must specify a type. Table creation, column creation +and alteration share the same type/options methods. `AsDateTime()` now means +`DbType.DateTime`; use `AsDateTime2()` to preserve the earlier helper's mapping. +Table-level methods return the table builder: configure column attributes before +adding a constraint, or retain the specific column builder in a variable. + +Insert exposes only `IntoTable(...).Row(...)[.IfNotExists(...)]`; issue another +insert expression for each row. Update exposes `Table(...).Set(...)` followed by +`Where(...)`, `WhereSql(...)` or `AllRows()`. Delete exposes `FromTable(...)` +followed by `Where(...)` or `AllRows()`. Incomplete expressions throw during +`Build`, `Apply` and `Preview`, before any operation executes. + +Provider conditions use `IfProvider(name, configure)`. Table reads use +`Schema.Table(table).Select(...)` and `.SelectScalar(columns, where)`. +Data transfer uses `Execute.CopyDataFromTable(source).ToTable(target) +.WithColumns(sourceColumns, targetColumns)[.OrderBy(...)]`; joined updates use +`Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs)`. + ### Column changes do not own constraints `ChangeColumn` changes attributes without inferring creation or removal of diff --git a/docs/runner-guide.md b/docs/runner-guide.md index 31ad675b..e32918c5 100644 --- a/docs/runner-guide.md +++ b/docs/runner-guide.md @@ -29,6 +29,26 @@ Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table def The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. +Select an existing table explicitly when authoring an object: + +```csharp +migration.Create.Column("Email").OnTable("Users").AsString(320).Nullable(); +migration.Alter.Column("Name").OnTable("Users").AsString(200).NotNullable(); +migration.Rename.Column("Name").OnTable("Users").To("DisplayName"); +migration.Create.Index("IX_Users_Email").OnTable("Users").WithColumns("Email"); +migration.Delete.Index("IX_Users_Email").FromTable("Users"); +migration.Delete.Column("Email").FromTable("Users"); +``` + +Table definitions, column additions and column alterations share the same type +and option methods. Each named column must specify its type. `AsDateTime()` maps +to `DbType.DateTime`, while `AsDateTime2()` maps to `DbType.DateTime2`. +Complete update/delete expressions with `Where(...)` or `AllRows()`; updates +also support `WhereSql(...)`. An unfinished chain causes `Build`, `Apply` and +`Preview` to throw before any queued operation executes. Each insert expression +describes one row. Use `IfProvider(name, configure)` for provider conditions and +`Schema.Table(table).Select(...)` / `.SelectScalar(...)` for table reads. + ## Scripts and provider-specific cleanup `Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. diff --git a/examples/FluentQuickStart/Program.cs b/examples/FluentQuickStart/Program.cs index 16301a5e..3acad203 100644 --- a/examples/FluentQuickStart/Program.cs +++ b/examples/FluentQuickStart/Program.cs @@ -7,13 +7,14 @@ using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "demo"); -var runner = new Migrator(provider, false, typeof(CreateUsers)); +var runner = new Migrator(provider, false, typeof(CreateUsers), typeof(AddUserEmail)); runner.Options.Tags.Add("core"); runner.Options.TransactionMode = MigrationTransactionMode.WholeSession; -Console.WriteLine(runner.PreviewSql(1, ProviderTypes.SQLite)); +Console.WriteLine(runner.PreviewSql(2, ProviderTypes.SQLite)); if (provider.TableExists("Users") || provider.TableExists(provider.SchemaInfoTable)) throw new Exception("Preview wrote to the database."); runner.MigrateToLastVersion(); if (!provider.ColumnExists("Users", "Name")) throw new Exception("Migration failed."); +if (!provider.ColumnExists("Users", "Email") || !provider.IndexExists("Users", "IX_Users_Email")) throw new Exception("Column/index migration failed."); runner.MigrateTo(0); if (provider.TableExists("Users")) throw new Exception("Automatic reversal failed."); Console.WriteLine("Quick-start migration, preview and reversal passed."); @@ -28,3 +29,13 @@ public override void BuildUp(MigrationBuilder migration) .WithColumn("Name").AsString(255).NotNullable(); } } + +[Migration(2, Scope = "demo"), Tags("core")] +public class AddUserEmail : AutoReversingMigration +{ + public override void BuildUp(MigrationBuilder migration) + { + migration.Create.Column("Email").OnTable("Users").AsString(320).Nullable(); + migration.Create.Index("IX_Users_Email").OnTable("Users").WithColumns("Email"); + } +} diff --git a/src/Migrator.Tests/FluentAuthoringTests.cs b/src/Migrator.Tests/FluentAuthoringTests.cs new file mode 100644 index 00000000..3deacd8e --- /dev/null +++ b/src/Migrator.Tests/FluentAuthoringTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests; + +public class FluentAuthoringTests +{ + private static IEnumerable IncompleteExpressions() + { + yield return Case("column table", b => b.Create.Column("Email")); + yield return Case("column type", b => b.Create.Column("Email").OnTable("Users")); + yield return Case("table column type", b => b.Create.Table("Other").WithColumn("Email")); + yield return Case("alter table", b => b.Alter.Column("Email")); + yield return Case("delete table", b => b.Delete.Column("Email")); + yield return Case("rename destination", b => b.Rename.Table("Users")); + yield return Case("rename column destination", b => b.Rename.Column("Email").OnTable("Users")); + yield return Case("key columns", b => b.Create.PrimaryKey("PK_Users").OnTable("Users")); + yield return Case("check expression", b => b.Create.CheckConstraint("CK_Id").OnTable("Users")); + yield return Case("foreign key parent", b => b.Create.ForeignKey("FK_User").FromTable("Users").WithColumns("ParentId")); + yield return Case("index columns", b => b.Create.Index("IX_Email").OnTable("Users")); + yield return Case("view fields", b => b.Create.View("Names").FromTable("Users")); + yield return Case("insert values", b => b.Insert.IntoTable("Users")); + yield return Case("update values", b => b.Update.Table("Users")); + yield return Case("update predicate", b => b.Update.Table("Users").Set(new[] { "Email" }, new object[] { "new" })); + yield return Case("delete predicate", b => b.Delete.FromTable("Users")); + yield return Case("copy columns", b => b.Execute.CopyDataFromTable("Users").ToTable("Other")); + yield return Case("update source", b => b.Execute.UpdateTable("Users")); + } + + private static TestCaseData Case(string name, Action expression) + => new TestCaseData(expression).SetName("Incomplete expression rejects all execution: " + name); + + [TestCaseSource(nameof(IncompleteExpressions))] + public void IncompleteExpressionsRejectBuildPreviewAndApplyBeforeAnyProviderCall(Action expression) + { + var builder = new MigrationBuilder(); + builder.Create.Table("Users").WithColumn("Id").AsInt32(); + expression(builder); + var provider = Substitute.For(); + Assert.Throws(() => builder.Build()); + Assert.Throws(() => builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); + Assert.Throws(() => builder.Apply(provider)); + Assert.That(provider.ReceivedCalls(), Is.Empty); + } + + [Test] + public void ColumnHandlesKeepTheirOwnColumnAndBuildReturnsIndependentSnapshots() + { + var builder = new MigrationBuilder(); + var table = builder.Create.Table("Users"); + var first = table.WithColumn("Id").AsInt32(); + table.WithColumn("Name").AsString(80); + first.NotNullable(); + var initial = (CreateTableOperation)builder.Build().Single(); + Assert.That(((Column)initial.Fields[0]).IsNullable, Is.False); + Assert.That(((Column)initial.Fields[1]).IsNullable, Is.True); + first.WithDefaultValue(42); + ((Column)initial.Fields[1]).Name = "Corrupted"; + var latest = (CreateTableOperation)builder.Build().Single(); + Assert.That(((Column)initial.Fields[0]).DefaultValue, Is.Null); + Assert.That(((Column)latest.Fields[0]).DefaultValue, Is.EqualTo(42)); + Assert.That(((Column)latest.Fields[1]).Name, Is.EqualTo("Name")); + } + + [Test] + public void SharedColumnTypesAndOptionsWorkForTableCreateAndAlter() + { + var builder = new MigrationBuilder(); + builder.Create.Table("Users").WithColumn("Amount").AsDecimal(18, 4).NotNullable().WithDefaultValue(0m); + builder.Create.Column("Amount").OnTable("Other").AsDecimal(18, 4).NotNullable().WithDefaultValue(0m); + builder.Alter.Column("Amount").OnTable("Third").AsDecimal(18, 4).NotNullable().WithDefaultValue(0m); + builder.Create.Column("Created").OnTable("Users").AsDateTime(); + builder.Alter.Column("Modified").OnTable("Users").AsDateTime2(); + builder.Create.Column("Token").OnTable("Users").AsGuid(); + builder.Alter.Column("Active").OnTable("Users").AsBoolean(); + var operations = builder.Build(); + var columns = new[] { (Column)((CreateTableOperation)operations[0]).Fields[0], ((ColumnOperation)operations[1]).Column, ((ColumnOperation)operations[2]).Column }; + foreach (var column in columns) + { + Assert.That(column.Type, Is.EqualTo(DbType.Decimal)); + Assert.That(column.Precision, Is.EqualTo(18)); + Assert.That(column.Scale, Is.EqualTo(4)); + Assert.That(column.IsNullable, Is.False); + Assert.That(column.DefaultValue, Is.EqualTo(0m)); + } + Assert.That(((ColumnOperation)operations[2]).Alter, Is.True); + Assert.That(operations.Skip(3).Cast().Select(op => op.Column.Type), + Is.EqualTo(new[] { DbType.DateTime, DbType.DateTime2, DbType.Guid, DbType.Boolean })); + } + + [Test] + public void DeferredExpressionsRetainStartOrderAndRejectDuplicateCompletion() + { + var builder = new MigrationBuilder(); + var rename = builder.Rename.Table("Users"); + builder.Create.Column("Email").OnTable("Members").AsString(); + rename.To("Members"); + Assert.That(builder.Build()[0], Is.EqualTo(new RenameOperation("Users", "Members"))); + Assert.Throws(() => rename.To("Other")); + var reverse = builder.Build()[0].Reverse(); + Assert.That(reverse, Is.EqualTo(new RenameOperation("Members", "Users"))); + } + + [Test] + public void NamedConstraintAndRemovalStepsDispatchToTheSelectedTable() + { + var builder = new MigrationBuilder(); + builder.Create.PrimaryKey("PK").OnTable("Users").WithColumns("Tenant", "Id"); + builder.Create.NonClusteredPrimaryKey("PK_NC").OnTable("Other").WithColumns("Id"); + builder.Create.UniqueConstraint("UQ").OnTable("Users").WithColumns("Email"); + builder.Create.CheckConstraint("CK").OnTable("Users").WithExpression("Id > 0"); + builder.Delete.Column("Email").FromTable("Users"); + builder.Delete.PrimaryKey().FromTable("Users"); + builder.Delete.DefaultValue("Id").FromTable("Users"); + builder.Delete.ForeignKey("FK").FromTable("Users"); + builder.Delete.Constraint("CK").FromTable("Users"); + builder.Delete.Index("IX").FromTable("Users"); + builder.Delete.AllIndexes().FromTable("Users"); + builder.Delete.AllConstraints().FromTable("Users"); + builder.Delete.ForeignKeysForColumn("ParentId").FromTable("Users"); + var provider = Substitute.For(); + builder.Apply(provider); + provider.Received().AddPrimaryKey("PK", "Users", Arg.Is(c => c.SequenceEqual(new[] { "Tenant", "Id" }))); + provider.Received().AddPrimaryKeyNonClustered("PK_NC", "Other", "Id"); + provider.Received().AddUniqueConstraint("UQ", "Users", "Email"); + provider.Received().AddCheckConstraint("CK", "Users", "Id > 0"); + provider.Received().RemoveColumn("Users", "Email"); + provider.Received().RemovePrimaryKey("Users"); + provider.Received().RemoveColumnDefaultValue("Users", "Id"); + provider.Received().RemoveForeignKey("Users", "FK"); + provider.Received().RemoveConstraint("Users", "CK"); + provider.Received().RemoveIndex("Users", "IX"); + provider.Received().RemoveAllIndexes("Users"); + provider.Received().RemoveAllConstraints("Users"); + provider.Received().RemoveAllForeignKeys("Users", "ParentId"); + } + + [Test] + public void IndexAndTypedColumnDefinitionsSnapshotInputsAndBuildResults() + { + var keys = new[] { "Email" }; + var filter = new FilterItem { ColumnName = "Id", Value = 1 }; + var definition = new Column("Name", DbType.String, 80); + var index = new Index { Name = "IX_Typed", KeyColumns = keys }; + var builder = new MigrationBuilder(); + builder.Create.Index("IX_Fluent").OnTable("Users").WithColumns(keys).Unique().Clustered().IncludeColumns("Id").WithFilter(filter); + builder.Create.Index(index).OnTable("Other"); + builder.Alter.Column(definition).OnTable("Users"); + keys[0] = "Mutated"; filter.ColumnName = "Mutated"; definition.Name = "Mutated"; + var first = builder.Build(); + var fluentIndex = ((IndexOperation)first[0]).Index; + Assert.That(fluentIndex.KeyColumns, Is.EqualTo(new[] { "Email" })); + Assert.That(fluentIndex.Unique && fluentIndex.Clustered, Is.True); + Assert.That(fluentIndex.IncludeColumns, Is.EqualTo(new[] { "Id" })); + Assert.That(fluentIndex.FilterItems[0].ColumnName, Is.EqualTo("Id")); + Assert.That(((IndexOperation)first[1]).Index.KeyColumns, Is.EqualTo(new[] { "Email" })); + Assert.That(((ColumnOperation)first[2]).Column.Name, Is.EqualTo("Name")); + fluentIndex.KeyColumns[0] = "Corrupted"; + Assert.That(((IndexOperation)builder.Build()[0]).Index.KeyColumns, Is.EqualTo(new[] { "Email" })); + } + + [Test] + public void CompositeForeignKeyRetainsColumnOrderActionsAndSnapshots() + { + var child = new[] { "Tenant", "UserId" }; + var parent = new[] { "Tenant", "Id" }; + var builder = new MigrationBuilder(); + builder.Create.ForeignKey("FK").FromTable("Orders").WithColumns(child) + .ToTable("Users").WithColumns(parent).OnDelete(ForeignKeyConstraintType.Cascade).OnUpdate(ForeignKeyConstraintType.Restrict); + child[0] = "Changed"; parent[0] = "Changed"; + var operation = (ConstraintOperation)builder.Build().Single(); + Assert.That(operation.Columns, Is.EqualTo(new[] { "Tenant", "UserId" })); + Assert.That(operation.ParentColumns, Is.EqualTo(new[] { "Tenant", "Id" })); + Assert.That(operation.OnDelete, Is.EqualTo(ForeignKeyConstraintType.Cascade)); + Assert.That(operation.OnUpdate, Is.EqualTo(ForeignKeyConstraintType.Restrict)); + Assert.That(operation.Reverse(), Is.EqualTo(new RemoveOperation(RemoveKind.ForeignKey, "Orders", "FK"))); + Assert.Throws(() => new MigrationBuilder().Create.ForeignKey("Invalid") + .FromTable("Orders").WithColumns("Tenant", "UserId").ToTable("Users").WithColumns("Id")); + } + + [Test] + public void EmptyPredicatesAndMismatchedValuesAreRejected() + { + var builder = new MigrationBuilder(); + Assert.Throws(() => builder.Insert.IntoTable("Users").Row(new[] { "Id" }, Array.Empty())); + Assert.Throws(() => builder.Delete.FromTable("Users").Where(Array.Empty(), Array.Empty())); + Assert.Throws(() => builder.Update.Table("Users").Set(new[] { "Id" }, new object[] { 1 }).WhereSql(" ")); + Assert.Throws(() => builder.Create.Column("Id").OnTable(" ")); + Assert.Throws(() => builder.Execute.CopyDataFromTable("Users").ToTable("Other") + .WithColumns(new[] { "Id" }, new[] { "Id", "Name" })); + } + + [Test, Category("SQLite")] + public void TableReadsAndUnfilteredDataOperationsQuoteTableNames() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var builder = new MigrationBuilder(); + builder.Create.Table("User Records").WithColumn("Id").AsInt32(); + builder.Insert.IntoTable("User Records").Row(new[] { "Id" }, new object[] { 1 }); + builder.Insert.IntoTable("User Records").Row(new[] { "Id" }, new object[] { 2 }); + builder.Update.Table("User Records").Set(new[] { "Id" }, new object[] { 3 }).WhereSql("Id = 2"); + builder.Apply(provider); + var table = new SchemaInspector(provider).Table("User Records"); + Assert.That(Convert.ToInt32(table.SelectScalar("Id", "Id = 3")), Is.EqualTo(3)); + var all = new MigrationBuilder(); + all.Update.Table("User Records").Set(new[] { "Id" }, new object[] { 4 }).AllRows(); + all.Apply(provider); + Assert.That(Convert.ToInt32(table.SelectScalar("COUNT(*)", "Id = 4")), Is.EqualTo(2)); + var delete = new MigrationBuilder(); + delete.Delete.FromTable("User Records").AllRows(); + delete.Apply(provider); + Assert.That(Convert.ToInt32(table.SelectScalar("COUNT(*)")), Is.Zero); + } + + [Test] + public void ViewFormsAndDataValuesSnapshotTheirInputs() + { + var field = new ViewField("Name"); + var element = new ViewColumn("u", "Name"); + var value = new byte[] { 1, 2 }; + var builder = new MigrationBuilder(); + builder.Create.View("Names").FromTable("Users").WithFields(field); + builder.Create.View("NamesWithAlias").FromTable("Users").WithElements(element); + builder.Insert.IntoTable("Users").Row(new[] { "Data" }, new object[] { value }); + value[0] = 9; + var first = builder.Build(); + Assert.That(((ViewOperation)first[0]).Fields[0], Is.Not.SameAs(field)); + Assert.That(((ViewOperation)first[1]).Elements[0], Is.Not.SameAs(element)); + Assert.That(((DataOperation)first[2]).Values[0], Is.EqualTo(new byte[] { 1, 2 })); + ((byte[])((DataOperation)first[2]).Values[0])[0] = 8; + var second = builder.Build(); + Assert.That(((ViewOperation)second[0]).Fields[0], Is.Not.SameAs(((ViewOperation)first[0]).Fields[0])); + Assert.That(((DataOperation)second[2]).Values[0], Is.EqualTo(new byte[] { 1, 2 })); + } + + [Test, Category("SQLite")] + public void ExplicitTablesRunThroughCreateAlterRenameIndexViewCopyAndAllRows() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var builder = new MigrationBuilder(); + builder.Create.Table("Users").WithColumn("Id").AsInt32().WithColumn("Name").AsString(); + builder.Create.Column("Email").OnTable("Users").AsString(100); + builder.Insert.IntoTable("Users").Row(new[] { "Id", "Name" }, new object[] { 1, "Ada" }); + builder.Alter.Column("Email").OnTable("Users").AsString(320); + builder.Rename.Column("Name").OnTable("Users").To("DisplayName"); + builder.Rename.Table("Users").To("Members"); + builder.Create.Index("IX_Name").OnTable("Members").WithColumns("DisplayName").Unique(); + builder.Create.View("Names").FromTable("Members").WithFields(new ViewField("DisplayName")); + builder.Create.Table("Archive").WithColumn("Name").AsString(); + builder.Execute.CopyDataFromTable("Members").ToTable("Archive").WithColumns(new[] { "DisplayName" }, new[] { "Name" }).OrderBy("DisplayName"); + builder.Update.Table("Members").Set(new[] { "Email" }, new object[] { "ada@example.org" }).AllRows(); + builder.Apply(provider); + var schema = new SchemaInspector(provider); + Assert.That(schema.Table("Members").SelectScalar("Email"), Is.EqualTo("ada@example.org")); + Assert.That(schema.Table("Archive").SelectScalar("Name"), Is.EqualTo("Ada")); + Assert.That(schema.Table("Names").SelectScalar("DisplayName"), Is.EqualTo("Ada")); + Assert.That(schema.Table("Members").IndexExists("IX_Name"), Is.True); + var delete = new MigrationBuilder(); + delete.Delete.FromTable("Members").AllRows(); + delete.Delete.Index("IX_Name").FromTable("Members"); + delete.Delete.Column("Email").FromTable("Members"); + delete.Apply(provider); + Assert.That(Convert.ToInt32(schema.Table("Members").SelectScalar("COUNT(*)")), Is.Zero); + Assert.That(schema.Table("Members").ColumnExists("Email"), Is.False); + } +} diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index bec00573..803d2979 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -41,7 +41,7 @@ public void TransactionIncompatibilityIsRejectedBeforeEarlierOperationsRun() Assert.Throws(() => builder.Apply(provider)); Assert.That(provider.TableExists("ShouldNotExist"), Is.False); var conditional = new MigrationBuilder(); - conditional.IfDatabase("PostgreSQL", nested => nested.Administration.CreateDatabase("Other")); + conditional.IfProvider("PostgreSQL", nested => nested.Administration.CreateDatabase("Other")); Assert.DoesNotThrow(() => conditional.Apply(provider)); } finally { provider.Rollback(); } @@ -60,12 +60,13 @@ [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() } [Test] public void InvalidDataModifiersFailBeforeExecution() { - var b = new MigrationBuilder(); - Assert.Throws(() => b.Update.Table("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); - Assert.Throws(() => b.Delete.FromTable("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); - Assert.Throws(() => b.Delete.FromTable("Example").WhereSql("Id = 1")); - Assert.Throws(() => b.Delete.FromTable("Example").Row(new[] { "Id" }, new object[] { 1 })); - Assert.Throws(() => b.Delete.FromTable("Example").Set(new[] { "Id" }, new object[] { 1 })); + // Unsupported combinations are no longer offered by the fluent types. + Assert.That(typeof(UpdateDataBuilder).GetMethod("IfNotExists"), Is.Null); + Assert.That(typeof(DeleteDataBuilder).GetMethod("IfNotExists"), Is.Null); + Assert.That(typeof(DeleteDataBuilder).GetMethod("WhereSql"), Is.Null); + Assert.That(typeof(DeleteDataBuilder).GetMethod("Row"), Is.Null); + Assert.That(typeof(DeleteDataBuilder).GetMethod("Set"), Is.Null); + Assert.That(typeof(InsertRowBuilder).GetMethod("Row"), Is.Null); var p = Substitute.For(); Assert.Throws(() => new DataOperation(DataKind.Delete, "Example", new[] { "Id" }, new object[] { 1 }).Apply(p)); Assert.Throws(() => new DataOperation(DataKind.Delete, "Example", null, null, WhereSql: "Id=1").Apply(p)); @@ -108,7 +109,7 @@ [Test] public void PreviewRejectsStructuredDependenciesAfterRawSql() [Test] public void CopyOperationsSnapshotMutableDefinitions() { var pairs = new[] { new DotNetProjects.Migrator.Framework.Models.ColumnPair { ColumnNameSource = "Old", ColumnNameTarget = "New" } }; - var builder = new MigrationBuilder(); builder.Execute.UpdateFrom("Source", "Target", pairs, pairs); + var builder = new MigrationBuilder(); builder.Execute.UpdateTable("Target").FromTable("Source").Set(pairs).Match(pairs); pairs[0].ColumnNameSource = "Mutated"; var operation = (UpdateFromOperation)builder.Build().Single(); Assert.That(operation.Copy[0].ColumnNameSource, Is.EqualTo("Old")); @@ -128,7 +129,7 @@ [Test] public void CopyOperationsSnapshotMutableDefinitions() builder.Apply(provider); var schema = new SchemaInspector(provider); Assert.That(schema.Table("ValuesTable").ColumnExists("Name"), Is.True); - schema.Select("ValuesTable", new[] { "Name" }, reader => + schema.Table("ValuesTable").Select(new[] { "Name" }, reader => { Assert.That(reader.Read(), Is.True); Assert.That(reader.GetString(0), Is.EqualTo("updated")); Assert.That(reader.Read(), Is.False); }); @@ -137,12 +138,12 @@ [Test] public void OfflinePreviewTracksCreatedThenRenamedTable() { var builder = new MigrationBuilder(); builder.Create.Table("First").WithColumn("Id").AsInt32(); - builder.Rename.Table("First", "Second"); - builder.Create.Column("Name", "Second").AsString(); + builder.Rename.Table("First").To("Second"); + builder.Create.Column("Name").OnTable("Second").AsString(); var sql = builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite)); Assert.That(sql.Count, Is.EqualTo(3)); Assert.That(sql[2], Does.Contain("Second")); - var unknown = new MigrationBuilder(); unknown.Create.Column("Id", "Missing"); + var unknown = new MigrationBuilder(); unknown.Create.Column("Id").OnTable("Missing").AsInt32(); Assert.Throws(() => unknown.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); } [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() @@ -173,7 +174,8 @@ [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() provider.AddTable("Parent", new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Parent", "Id")); var builder = new MigrationBuilder(); builder.Create.Table("Child").WithColumn("ParentId").AsInt32(); - builder.Create.ForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); + builder.Create.ForeignKey("FK_Child").FromTable("Child").WithColumns("ParentId") + .ToTable("Parent").WithColumns("Id").OnDelete(ForeignKeyConstraintType.Cascade); builder.Apply(provider); provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (1); DELETE FROM Parent WHERE Id=1"); Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.Zero); diff --git a/src/Migrator.Tests/SchemaBuilderTests.cs b/src/Migrator.Tests/SchemaBuilderTests.cs index 016f4e23..879a982f 100644 --- a/src/Migrator.Tests/SchemaBuilderTests.cs +++ b/src/Migrator.Tests/SchemaBuilderTests.cs @@ -28,8 +28,9 @@ public void ForeignKeyExecutesAfterCompletedTableWithIndependentActions() { var builder = new MigrationBuilder(); builder.Create.Table("Child").WithColumn("ParentId").AsInt32(); - builder.Create.ForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, - ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Restrict); + builder.Create.ForeignKey("FK_Child").FromTable("Child").WithColumns("ParentId") + .ToTable("Parent").WithColumns("Id") + .OnDelete(ForeignKeyConstraintType.Cascade).OnUpdate(ForeignKeyConstraintType.Restrict); var provider = Substitute.For(); builder.Apply(provider); Received.InOrder(() => { @@ -42,7 +43,7 @@ public void ForeignKeyExecutesAfterCompletedTableWithIndependentActions() public void ExistingTableColumnRetainsAuthoredOptions() { var builder = new MigrationBuilder(); - builder.Create.Column("Name", "Existing").AsString(80).NotNullable().WithDefaultValue("guest"); + builder.Create.Column("Name").OnTable("Existing").AsString(80).NotNullable().WithDefaultValue("guest"); var provider = Substitute.For(); builder.Apply(provider); provider.Received(1).AddColumn("Existing", Arg.Is(c => c.Name == "Name" && c.Size == 80 diff --git a/src/Migrator/Framework/Fluent/BuilderStages.cs b/src/Migrator/Framework/Fluent/BuilderStages.cs new file mode 100644 index 00000000..12834fc0 --- /dev/null +++ b/src/Migrator/Framework/Fluent/BuilderStages.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; + +namespace DotNetProjects.Migrator.Framework.Fluent; + +/// Selects the table containing the object being defined or renamed. +public sealed class OnTableBuilder +{ + private readonly Func next; + internal OnTableBuilder(Func next) => this.next = next; + public T OnTable(string table) => next(BuilderArguments.Name(table)); +} + +/// Selects the source table of a relationship or view. +public sealed class FromTableBuilder +{ + private readonly Func next; + internal FromTableBuilder(Func next) => this.next = next; + public T FromTable(string table) => next(BuilderArguments.Name(table)); +} + +/// Selects the destination table of a relationship or data copy. +public sealed class ToTableBuilder +{ + private readonly Func next; + internal ToTableBuilder(Func next) => this.next = next; + public T ToTable(string table) => next(BuilderArguments.Name(table)); +} + +/// Supplies an ordered, nonempty set of column names. +public sealed class ColumnsBuilder +{ + private readonly Func next; + internal ColumnsBuilder(Func next) => this.next = next; + public T WithColumns(params string[] columns) => next(BuilderArguments.Names(columns)); +} + +// Register at the start of a chain, retaining authoring order and rejecting unfinished +// expressions at Build(), before Apply() can execute any earlier operation. +internal sealed class PendingOperation +{ + private Func operation; + internal PendingOperation(MigrationBuilder builder, string description) + => builder.Add(() => operation?.Invoke() ?? throw new InvalidOperationException($"Incomplete fluent operation: {description}.")); + + internal void Complete(Func value) + { + if (operation != null) throw new InvalidOperationException("This fluent operation has already been completed. Start a new expression for another operation."); + operation = value; + } +} + +internal static class BuilderArguments +{ + internal static string Name(string value) + { + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("A nonempty name or expression is required.", nameof(value)); + return value; + } + + internal static string[] Names(string[] values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Length == 0) throw new ArgumentException("At least one column is required.", nameof(values)); + return values.Select(Name).ToArray(); + } + + internal static object[] Values(string[] names, object[] values) + { + ArgumentNullException.ThrowIfNull(values); + if (names.Length != values.Length) throw new ArgumentException("Columns and values must have equal lengths.", nameof(values)); + return values.Select(value => value is byte[] bytes ? bytes.Clone() : value).ToArray(); + } +} diff --git a/src/Migrator/Framework/Fluent/ColumnBuilders.cs b/src/Migrator/Framework/Fluent/ColumnBuilders.cs new file mode 100644 index 00000000..0c0e79aa --- /dev/null +++ b/src/Migrator/Framework/Fluent/ColumnBuilders.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; + +namespace DotNetProjects.Migrator.Framework.Fluent; + +/// Column options shared by table definitions, additions and alterations. +public abstract class ColumnDefinitionBuilder where T : ColumnDefinitionBuilder +{ + private readonly Column column; + private bool hasType; + private T Self => (T)this; + + internal ColumnDefinitionBuilder(string name) => column = new Column(BuilderArguments.Name(name)); + + internal Column BuildColumn() + { + if (!hasType) throw new InvalidOperationException($"Specify a type for column '{column.Name}' using As... or OfType(...)."); + return Definitions.CopyColumn(column); + } + + public T OfType(DbType type) { column.Type = type; hasType = true; return Self; } + public T OfType(MigratorDbType type) { column.MigratorDbType = type; hasType = true; return Self; } + public T AsInt16() => OfType(DbType.Int16); + public T AsInt32() => OfType(DbType.Int32); + public T AsInt64() => OfType(DbType.Int64); + public T AsString(int size = 255) => OfType(DbType.String).WithSize(size); + public T AsAnsiString(int size = 255) => OfType(DbType.AnsiString).WithSize(size); + public T AsGuid() => OfType(DbType.Guid); + public T AsBoolean() => OfType(DbType.Boolean); + public T AsDate() => OfType(DbType.Date); + public T AsDateTime() => OfType(DbType.DateTime); + public T AsDateTime2() => OfType(DbType.DateTime2); + public T AsDateTimeOffset() => OfType(DbType.DateTimeOffset); + public T AsDecimal(int precision, int scale) => OfType(DbType.Decimal).WithPrecision(precision, scale); + public T AsDouble() => OfType(DbType.Double); + public T AsBinary(int size) => OfType(DbType.Binary).WithSize(size); + public T WithSize(int size) { column.Size = size; return Self; } + public T WithPrecision(int precision, int scale) + { + if (precision <= 0) throw new ArgumentOutOfRangeException(nameof(precision)); + if (scale < 0 || scale > precision) throw new ArgumentOutOfRangeException(nameof(scale)); + column.Precision = precision; column.Scale = scale; return Self; + } + public T WithDefaultValue(object value) { column.DefaultValue = value is byte[] bytes ? bytes.Clone() : value; return Self; } + public T NotNullable() { column.IsNullable = false; return Self; } + public T Nullable() { column.IsNullable = true; return Self; } + public T Unsigned() { column.IsUnsigned = true; return Self; } + public T WithCollation(Collation collation) { column.Collation = collation; return Self; } + public T Identity() { column.IsIdentity = true; return Self; } +} + +public sealed class ColumnBuilder : ColumnDefinitionBuilder +{ + internal ColumnBuilder(string name) : base(name) { } +} + +public sealed class TableBuilder +{ + private readonly List> fields = new(); + private string engine; + + internal TableBuilder(MigrationBuilder builder, string name) + { + BuilderArguments.Name(name); + builder.Add(() => new CreateTableOperation(name, engine, fields.Select(field => field()).ToArray())); + } + + public TableColumnBuilder WithColumn(string name) + { + var column = new TableColumnBuilder(this, name); + fields.Add(column.BuildColumn); + return column; + } + public TableBuilder WithFields(params IDbField[] values) + { + foreach (var value in values.Select(Definitions.Copy)) fields.Add(() => Definitions.Copy(value)); + return this; + } + public TableBuilder WithPrimaryKey(string name, params string[] columns) + => WithFields(new PrimaryKeyConstraint(BuilderArguments.Name(name), BuilderArguments.Names(columns))); + public TableBuilder WithUniqueConstraint(string name, params string[] columns) + => WithFields(new UniqueConstraint(BuilderArguments.Name(name), BuilderArguments.Names(columns))); + public TableBuilder WithCheckConstraint(string name, string expression) + => WithFields(new CheckConstraint(BuilderArguments.Name(name), BuilderArguments.Name(expression))); + public TableBuilder WithEngine(string value) { engine = BuilderArguments.Name(value); return this; } +} + +/// Options for one specific column, followed by the next table definition. +public sealed class TableColumnBuilder : ColumnDefinitionBuilder +{ + private readonly TableBuilder table; + internal TableColumnBuilder(TableBuilder table, string name) : base(name) => this.table = table; + public TableColumnBuilder WithColumn(string name) => table.WithColumn(name); + public TableBuilder WithFields(params IDbField[] fields) => table.WithFields(fields); + public TableBuilder WithPrimaryKey(string name, params string[] columns) => table.WithPrimaryKey(name, columns); + public TableBuilder WithUniqueConstraint(string name, params string[] columns) => table.WithUniqueConstraint(name, columns); + public TableBuilder WithCheckConstraint(string name, string expression) => table.WithCheckConstraint(name, expression); + public TableBuilder WithEngine(string engine) => table.WithEngine(engine); +} + +public sealed class AlterRoot(MigrationBuilder builder) +{ + public OnTableBuilder Column(string name) => ColumnExpression.Start(builder, name, true); + + /// Uses a complete existing column definition, copied at authoring time. + public ColumnDefinitionOnTableBuilder Column(Column definition) => new(builder, definition, true); +} + +public sealed class ColumnDefinitionOnTableBuilder +{ + private readonly PendingOperation pending; + private readonly Column column; + private readonly bool alter; + internal ColumnDefinitionOnTableBuilder(MigrationBuilder builder, Column definition, bool alter) + { + column = Definitions.CopyColumn(definition); + BuilderArguments.Name(column.Name); + this.alter = alter; + pending = new PendingOperation(builder, $"Column '{column.Name}' requires OnTable(...)"); + } + public void OnTable(string table) + { + BuilderArguments.Name(table); + pending.Complete(() => new ColumnOperation(table, Definitions.CopyColumn(column), alter)); + } +} + +internal static class ColumnExpression +{ + internal static OnTableBuilder Start(MigrationBuilder builder, string name, bool alter) + { + var column = new ColumnBuilder(name); + var pending = new PendingOperation(builder, $"Column '{name}' requires OnTable(...)"); + return new(table => + { + pending.Complete(() => new ColumnOperation(table, column.BuildColumn(), alter)); + return column; + }); + } +} diff --git a/src/Migrator/Framework/Fluent/DataBuilders.cs b/src/Migrator/Framework/Fluent/DataBuilders.cs new file mode 100644 index 00000000..59dc03dc --- /dev/null +++ b/src/Migrator/Framework/Fluent/DataBuilders.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using DotNetProjects.Migrator.Framework.Models; + +namespace DotNetProjects.Migrator.Framework.Fluent; + +public sealed class InsertRoot(MigrationBuilder builder) +{ + public InsertDataBuilder IntoTable(string table) => new(builder, BuilderArguments.Name(table)); +} + +public sealed class UpdateRoot(MigrationBuilder builder) +{ + public UpdateDataBuilder Table(string table) => new(builder, BuilderArguments.Name(table)); +} + +public sealed class InsertDataBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + internal InsertDataBuilder(MigrationBuilder builder, string table) + { + this.table = table; + pending = new PendingOperation(builder, $"Insert into '{table}' requires Row(...)"); + } + /// Defines one insert. Start another Insert.IntoTable expression for another row. + public InsertRowBuilder Row(string[] columns, object[] values) + { + var names = BuilderArguments.Names(columns); + var data = BuilderArguments.Values(names, values); + var row = new InsertRowBuilder(); + pending.Complete(() => new DataOperation(row.WhereColumns == null ? DataKind.Insert : DataKind.InsertIfMissing, + table, names.ToArray(), BuilderArguments.Values(names, data), row.WhereColumns?.ToArray(), + row.WhereColumns == null ? null : BuilderArguments.Values(row.WhereColumns, row.WhereValues))); + return row; + } +} + +public sealed class InsertRowBuilder +{ + internal string[] WhereColumns { get; private set; } + internal object[] WhereValues { get; private set; } + internal InsertRowBuilder() { } + public void IfNotExists(string[] columns, object[] values) + { + var names = BuilderArguments.Names(columns); + var data = BuilderArguments.Values(names, values); + WhereColumns = names; WhereValues = data; + } +} + +public sealed class UpdateDataBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + internal UpdateDataBuilder(MigrationBuilder builder, string table) + { + this.table = table; + pending = new PendingOperation(builder, $"Update '{table}' requires Set(...) and Where(...), WhereSql(...) or AllRows()"); + } + public UpdateWhereBuilder Set(string[] columns, object[] values) + { + var names = BuilderArguments.Names(columns); + var data = BuilderArguments.Values(names, values); + return new UpdateWhereBuilder(pending, table, names, data); + } +} + +public sealed class UpdateWhereBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + private readonly string[] columns; + private readonly object[] values; + internal UpdateWhereBuilder(PendingOperation pending, string table, string[] columns, object[] values) + { this.pending = pending; this.table = table; this.columns = columns; this.values = values; } + public void Where(string[] columns, object[] values) + { + var names = BuilderArguments.Names(columns); + var data = BuilderArguments.Values(names, values); + Complete(names, data, null); + } + public void WhereSql(string sql) => Complete(null, null, BuilderArguments.Name(sql)); + public void AllRows() => Complete(null, null, null); + private void Complete(string[] whereColumns, object[] whereValues, string sql) + => pending.Complete(() => new DataOperation(DataKind.Update, table, columns.ToArray(), BuilderArguments.Values(columns, values), + whereColumns?.ToArray(), whereColumns == null ? null : BuilderArguments.Values(whereColumns, whereValues), sql)); +} + +public sealed class DeleteDataBuilder +{ + private readonly PendingOperation pending; + private readonly string table; + internal DeleteDataBuilder(MigrationBuilder builder, string table) + { + this.table = table; + pending = new PendingOperation(builder, $"Delete from '{table}' requires Where(...) or AllRows()"); + } + public void Where(string[] columns, object[] values) + { + var names = BuilderArguments.Names(columns); + var data = BuilderArguments.Values(names, values); + pending.Complete(() => new DataOperation(DataKind.Delete, table, Array.Empty(), Array.Empty(), + names.ToArray(), BuilderArguments.Values(names, data))); + } + public void AllRows() => pending.Complete(() => new DataOperation(DataKind.Delete, table, Array.Empty(), Array.Empty())); +} + +public sealed class CopyDataColumnsBuilder +{ + private readonly PendingOperation pending; + private readonly string source, target; + internal CopyDataColumnsBuilder(PendingOperation pending, string source, string target) + { this.pending = pending; this.source = source; this.target = target; } + public CopyDataOptionsBuilder WithColumns(string[] sourceColumns, string[] targetColumns) + { + var from = BuilderArguments.Names(sourceColumns); + var to = BuilderArguments.Names(targetColumns); + if (from.Length != to.Length) throw new ArgumentException("Source and target must have equal column counts."); + var options = new CopyDataOptionsBuilder(); + pending.Complete(() => new CopyDataOperation(source, from.ToArray(), target, to.ToArray(), options.OrderColumns?.ToArray())); + return options; + } +} + +public sealed class CopyDataOptionsBuilder +{ + internal string[] OrderColumns { get; private set; } + internal CopyDataOptionsBuilder() { } + public CopyDataOptionsBuilder OrderBy(params string[] columns) { OrderColumns = BuilderArguments.Names(columns); return this; } +} + +public sealed class UpdateFromSetBuilder +{ + private readonly PendingOperation pending; + private readonly string source, target; + internal UpdateFromSetBuilder(PendingOperation pending, string source, string target) + { this.pending = pending; this.source = source; this.target = target; } + public UpdateFromMatchBuilder Set(params ColumnPair[] columns) + => new(pending, source, target, CopyPairs(columns)); + + internal static ColumnPair[] CopyPairs(ColumnPair[] columns) + { + ArgumentNullException.ThrowIfNull(columns); + if (columns.Length == 0) throw new ArgumentException("At least one column pair is required.", nameof(columns)); + return columns.Select(pair => new ColumnPair + { + ColumnNameSource = BuilderArguments.Name(pair.ColumnNameSource), + ColumnNameTarget = BuilderArguments.Name(pair.ColumnNameTarget) + }).ToArray(); + } +} + +public sealed class UpdateFromMatchBuilder +{ + private readonly PendingOperation pending; + private readonly string source, target; + private readonly ColumnPair[] copy; + internal UpdateFromMatchBuilder(PendingOperation pending, string source, string target, ColumnPair[] copy) + { this.pending = pending; this.source = source; this.target = target; this.copy = copy; } + public void Match(params ColumnPair[] columns) + { + var match = UpdateFromSetBuilder.CopyPairs(columns); + pending.Complete(() => new UpdateFromOperation(source, target, copy.Select(Definitions.CopyPair).ToArray(), match.Select(Definitions.CopyPair).ToArray())); + } +} diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs index a99c1b32..d86e7f55 100644 --- a/src/Migrator/Framework/Fluent/FluentMigration.cs +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -42,14 +42,6 @@ public sealed class SchemaInspector(ITransformationProvider provider) public List Strings(string sql, params object[] args) => provider.ExecuteStringQuery(sql, args); public void Query(string sql, Action read) { using var command = provider.CreateCommand(); using var reader = provider.ExecuteQuery(command, sql); read(reader); } - public object SelectScalar(string columns, string table, string where = null) => provider.SelectScalar(columns, table, where); - public void Select(string table, string[] columns, Action read, string[] whereColumns = null, object[] whereValues = null, - string[] nullColumns = null, string[] notNullColumns = null) - { - using var command = provider.CreateCommand(); - using var reader = provider.SelectComplex(command, table, columns, whereColumns, whereValues, nullColumns, notNullColumns); - read(reader); - } public string[] QuoteColumns(params string[] names) => provider.QuoteColumnNamesIfRequired(names); public string ParameterName(int index) => provider.GenerateParameterName(index); public string QuoteColumn(string name) => provider.QuoteColumnNameIfRequired(name); @@ -59,6 +51,17 @@ public void Select(string table, string[] columns, Action where == null + ? provider.SelectScalar(columns, provider.QuoteTableNameIfRequired(table)) + : provider.SelectScalar(columns, provider.QuoteTableNameIfRequired(table), where); + public void Select(string[] columns, Action read, string[] whereColumns = null, object[] whereValues = null, + string[] nullColumns = null, string[] notNullColumns = null) + { + using var command = provider.CreateCommand(); + using var reader = provider.SelectComplex(command, table, columns, whereColumns, whereValues, nullColumns, notNullColumns); + read(reader); + } public bool Exists() => provider.TableExists(table); public bool ColumnExists(string column) => provider.ColumnExists(table, column); public bool ConstraintExists(string name) => provider.ConstraintExists(table, name); diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index d030c77d..16152b77 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -4,11 +4,7 @@ using System.IO; using System.Linq; using System.Reflection; -using DotNetProjects.Migrator.Providers; -using DotNetProjects.Migrator.Providers.Models.Indexes; -using DotNetProjects.Migrator.Providers.Models; -using DotNetProjects.Migrator.Framework.Models; -using Index = DotNetProjects.Migrator.Framework.Index; + namespace DotNetProjects.Migrator.Framework.Fluent; public sealed class MigrationBuilder @@ -18,12 +14,13 @@ public sealed class MigrationBuilder public AlterRoot Alter => new(this); public DeleteRoot Delete => new(this); public RenameRoot Rename => new(this); - public DataRoot Insert => new(this, DataKind.Insert); - public DataRoot Update => new(this, DataKind.Update); + public InsertRoot Insert => new(this); + public UpdateRoot Update => new(this); public ExecuteRoot Execute => new(this); public AdministrationRoot Administration => new(this); public void Add(MigrationOperation operation) => operations.Add(() => operation); internal void Add(Func operation) => operations.Add(operation); + /// Snapshots the expressions in authoring order, rejecting incomplete chains. public IReadOnlyList Build() => operations.Select(x => x()).ToArray(); public void Apply(ITransformationProvider provider) => ApplyOperations(provider, Build()); internal static void ApplyOperations(ITransformationProvider provider, IReadOnlyList operations) @@ -32,118 +29,16 @@ internal static void ApplyOperations(ITransformationProvider provider, IReadOnly foreach (var operation in operations) operation.Apply(provider); } public IReadOnlyList Preview(SqlGenerationContext context) => Build().Select(op => op.ToSql(context)).Where(sql => sql.Length != 0).ToArray(); - public void IfDatabase(string name, Action configure) + /// Queues operations only for a matching provider name (for example, SQLite). + public void IfProvider(string name, Action configure) { + BuilderArguments.Name(name); + ArgumentNullException.ThrowIfNull(configure); var nested = new MigrationBuilder(); configure(nested); foreach (var op in nested.Build()) Add(new ConditionalOperation(name, op)); } public void WithReverse(MigrationOperation forward, MigrationOperation backward) => Add(new ReversibleOperation(forward, backward)); } -public sealed class CreateRoot(MigrationBuilder builder) -{ - public TableBuilder Table(string name) => new(builder, name); - public ColumnBuilder Column(string name, string table) => new(builder, table, name, false); - public void Index(string table, Index index) => builder.Add(new IndexOperation(table, (Index)Definitions.Copy(index))); - public void PrimaryKey(string name, string table, params string[] columns) => Constraint(ConstraintKind.PrimaryKey, name, table, columns); - public void NonClusteredPrimaryKey(string name, string table, params string[] columns) => Constraint(ConstraintKind.NonClusteredPrimaryKey, name, table, columns); - public void Unique(string name, string table, params string[] columns) => Constraint(ConstraintKind.Unique, name, table, columns); - private void Constraint(ConstraintKind kind, string name, string table, string[] columns) => builder.Add(new ConstraintOperation(kind, table, name, (string[])columns.Clone())); - public void Check(string name, string table, string sql) => builder.Add(new ConstraintOperation(ConstraintKind.Check, table, name, Array.Empty(), Check: sql)); - public void ForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete = ForeignKeyConstraintType.NoAction, ForeignKeyConstraintType onUpdate = ForeignKeyConstraintType.NoAction) - => builder.Add(new ConstraintOperation(ConstraintKind.ForeignKey, childTable, name, (string[])childColumns.Clone(), parentTable, (string[])parentColumns.Clone(), OnDelete: onDelete, OnUpdate: onUpdate)); - public void View(string name, string table, params IViewElement[] elements) => builder.Add(new ViewOperation(name, table, null, elements.Select(Definitions.CopyViewElement).ToArray())); - public void View(string name, string table, params IViewField[] fields) => builder.Add(new ViewOperation(name, table, fields.Select(Definitions.CopyViewField).ToArray())); -} -public sealed class TableBuilder -{ - private readonly List fields = new(); - private Column current; - private string engine; - internal TableBuilder(MigrationBuilder builder, string name) => builder.Add(() => new CreateTableOperation(name, engine, fields.Select(Definitions.Copy).ToArray())); - public TableBuilder WithColumn(string name) { current = new Column(name,DbType.String); fields.Add(current); return this; } - public TableBuilder WithFields(params IDbField[] values) { fields.AddRange(values.Select(Definitions.Copy)); return this; } - public TableBuilder WithPrimaryKey(string name, params string[] columns) { fields.Add(new PrimaryKeyConstraint(name, columns)); return this; } - public TableBuilder WithUniqueConstraint(string name, params string[] columns) { fields.Add(new UniqueConstraint(name, columns)); return this; } - public TableBuilder WithCheckConstraint(string name, string expression) { fields.Add(new CheckConstraint(name, expression)); return this; } - public TableBuilder WithEngine(string value) { engine = value; return this; } - private Column Current => current ?? throw new InvalidOperationException("Call WithColumn first."); - public TableBuilder OfType(DbType type) { Current.Type = type; return this; } - public TableBuilder OfType(MigratorDbType type) { Current.MigratorDbType = type; return this; } - public TableBuilder AsInt32() => OfType(DbType.Int32); - public TableBuilder AsInt64() => OfType(DbType.Int64); - public TableBuilder AsString(int size = 255) { OfType(DbType.String); Current.Size = size; return this; } - public TableBuilder AsGuid() => OfType(DbType.Guid); - public TableBuilder AsBoolean() => OfType(DbType.Boolean); - public TableBuilder AsDateTime() => OfType(DbType.DateTime2); - public TableBuilder WithSize(int size) { Current.Size = size; return this; } - public TableBuilder WithPrecision(int precision, int scale) { Current.Precision = precision; Current.Scale = scale; return this; } - public TableBuilder WithDefaultValue(object value) { Current.DefaultValue = value; return this; } - public TableBuilder NotNullable() { Current.IsNullable = false; return this; } - public TableBuilder Nullable() { Current.IsNullable = true; return this; } - public TableBuilder Unsigned() { Current.IsUnsigned = true; return this; } - public TableBuilder WithCollation(Collation name) { Current.Collation = name; return this; } - public TableBuilder Identity() { Current.IsIdentity = true; return this; } -} -public sealed class ColumnBuilder -{ - private readonly Column column; - internal ColumnBuilder(MigrationBuilder builder, string table, string name, bool alter) { column = new Column(name,DbType.String); builder.Add(() => new ColumnOperation(table, Definitions.CopyColumn(column), alter)); } - public ColumnBuilder OfType(DbType value) { column.Type = value; return this; } - public ColumnBuilder OfType(MigratorDbType value) { column.MigratorDbType = value; return this; } - public ColumnBuilder AsInt32() => OfType(DbType.Int32); - public ColumnBuilder AsInt64() => OfType(DbType.Int64); - public ColumnBuilder AsString(int size = 255) { column.Size = size; return OfType(DbType.String); } - public ColumnBuilder WithSize(int value) { column.Size = value; return this; } - public ColumnBuilder WithPrecision(int precision, int scale) { column.Precision = precision; column.Scale = scale; return this; } - public ColumnBuilder WithDefaultValue(object value) { column.DefaultValue = value; return this; } - public ColumnBuilder NotNullable() { column.IsNullable = false; return this; } - public ColumnBuilder Nullable() { column.IsNullable = true; return this; } - public ColumnBuilder Unsigned() { column.IsUnsigned = true; return this; } - public ColumnBuilder WithCollation(Collation name) { column.Collation = name; return this; } - public ColumnBuilder Identity() { column.IsIdentity = true; return this; } -} -public sealed class AlterRoot(MigrationBuilder builder) -{ - public ColumnBuilder Column(string name, string table) => new(builder, table, name, true); - public void Column(string table, Column column) => builder.Add(new ColumnOperation(table, Definitions.CopyColumn(column), true)); -} -public sealed class DeleteRoot(MigrationBuilder builder) -{ - public void Table(string table) => builder.Add(new RemoveOperation(RemoveKind.Table, table)); - public void Column(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Column, table, name)); - public void ForeignKey(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.ForeignKey, table, name)); - public void Constraint(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Constraint, table, name)); - public void PrimaryKey(string table) => builder.Add(new RemoveOperation(RemoveKind.PrimaryKey, table)); - public void Default(string column, string table) => builder.Add(new RemoveOperation(RemoveKind.Default, table, column)); - public void Index(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Index, table, name)); - public void AllIndexes(string table) => builder.Add(new RemoveOperation(RemoveKind.AllIndexes, table)); - public void AllConstraints(string table) => builder.Add(new RemoveOperation(RemoveKind.AllConstraints, table)); - public void ForeignKeysForColumn(string table, string column) => builder.Add(new RemoveOperation(RemoveKind.ForeignKeysForColumn, table, column)); - public DataBuilder FromTable(string table) => new(builder, DataKind.Delete, table); -} -public sealed class RenameRoot(MigrationBuilder builder) -{ - public void Table(string oldName, string newName) => builder.Add(new RenameOperation(oldName, newName)); - public void Column(string table, string oldName, string newName) => builder.Add(new RenameOperation(table, newName, oldName)); -} -public sealed class DataRoot(MigrationBuilder builder, DataKind kind) -{ - public DataBuilder IntoTable(string table) => new(builder, kind, table); - public DataBuilder Table(string table) => new(builder, kind, table); -} -public sealed class DataBuilder -{ - private DataKind kind; - private string[] columns = Array.Empty(), whereColumns; - private object[] values = Array.Empty(), whereValues; - private string whereSql; - internal DataBuilder(MigrationBuilder builder, DataKind kind, string table) { this.kind = kind; builder.Add(() => new DataOperation(this.kind, table, columns.ToArray(), values.ToArray(), whereColumns?.ToArray(), whereValues?.ToArray(), whereSql)); } - public DataBuilder Row(string[] names, object[] data) { if (kind == DataKind.Delete) throw new InvalidOperationException("Delete accepts predicates through Where, not row values."); if (names.Length != data.Length) throw new ArgumentException("Columns and values must have equal lengths."); columns = names.ToArray(); values = data.ToArray(); return this; } - public DataBuilder Set(string[] names, object[] data) => Row(names, data); - public DataBuilder Where(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Where columns and values must have equal lengths."); whereColumns = names.ToArray(); whereValues = data.ToArray(); return this; } - public DataBuilder WhereSql(string sql) { if (kind != DataKind.Update) throw new NotSupportedException("Raw predicates are supported only for Update; use Where columns/values for Delete."); whereSql = sql; return this; } - public DataBuilder IfNotExists(string[] names, object[] data) { if (kind is not (DataKind.Insert or DataKind.InsertIfMissing)) throw new InvalidOperationException("IfNotExists is only valid for Insert."); kind = DataKind.InsertIfMissing; return Where(names, data); } -} public sealed class ExecuteRoot(MigrationBuilder builder) { public void Sql(string sql, int? timeout = null, params object[] parameters) => builder.Add(new SqlOperation(sql, timeout, parameters.Length == 0 ? null : parameters.ToArray())); @@ -153,10 +48,18 @@ public sealed class ExecuteRoot(MigrationBuilder builder) public void WithCommand(Action action) => builder.Add(new CallbackOperation("Command callback", p => { using var command = p.CreateCommand(); action(command); })); public void WithConnection(Action action) => builder.Add(new CallbackOperation("Connection callback", p => action(p.Connection))); public void Truncate(string table) => builder.Add(new RemoveOperation(RemoveKind.Truncate, table)); - public void CopyData(string source, IEnumerable sourceColumns, string target, IEnumerable targetColumns, IEnumerable orderBy = null) - { builder.Add(new CopyDataOperation(source, sourceColumns.ToArray(), target, targetColumns.ToArray(), orderBy?.ToArray())); } - public void UpdateFrom(string source, string target, ColumnPair[] copy, ColumnPair[] match) - { builder.Add(new UpdateFromOperation(source, target, copy.Select(Definitions.CopyPair).ToArray(), match.Select(Definitions.CopyPair).ToArray())); } + public ToTableBuilder CopyDataFromTable(string source) + { + BuilderArguments.Name(source); + var pending = new PendingOperation(builder, "Copy data requires ToTable(...).WithColumns(...)"); + return new(target => new CopyDataColumnsBuilder(pending, source, target)); + } + public FromTableBuilder UpdateTable(string target) + { + BuilderArguments.Name(target); + var pending = new PendingOperation(builder, "Update from another table requires FromTable(...).Set(...).Match(...)"); + return new(source => new UpdateFromSetBuilder(pending, source, target)); + } } public sealed class AdministrationRoot(MigrationBuilder builder) { diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index bfca7461..18075394 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -148,11 +148,12 @@ public override void Apply(ITransformationProvider p) case DataKind.InsertIfMissing: p.InsertIfNotExists(Table, Columns, Values, WhereColumns, WhereValues); break; case DataKind.Update: if (WhereSql != null) p.Update(Table, Columns, Values, WhereSql); - else p.Update(Table, Columns, Values, WhereColumns ?? Array.Empty(), WhereValues ?? Array.Empty()); break; + else if (WhereColumns == null || WhereColumns.Length == 0) p.Update(Table, Columns, Values); + else p.Update(Table, Columns, Values, WhereColumns, WhereValues); break; case DataKind.Delete: if ((Columns?.Length ?? 0) != 0 || (Values?.Length ?? 0) != 0) throw new InvalidOperationException("Delete does not accept row values; use WhereColumns and WhereValues."); if (WhereSql != null) throw new NotSupportedException("Delete requires structured Where columns/values."); - p.Delete(Table, WhereColumns, WhereValues); break; + p.Delete(WhereColumns == null ? p.QuoteTableNameIfRequired(Table) : Table, WhereColumns, WhereValues); break; } } public override string ToSql(SqlGenerationContext c) diff --git a/src/Migrator/Framework/Fluent/SchemaBuilders.cs b/src/Migrator/Framework/Fluent/SchemaBuilders.cs new file mode 100644 index 00000000..3a46350f --- /dev/null +++ b/src/Migrator/Framework/Fluent/SchemaBuilders.cs @@ -0,0 +1,208 @@ +using System; +using System.Linq; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace DotNetProjects.Migrator.Framework.Fluent; + +public sealed class CreateRoot(MigrationBuilder builder) +{ + public TableBuilder Table(string name) => new(builder, name); + public OnTableBuilder Column(string name) => ColumnExpression.Start(builder, name, false); + public ColumnDefinitionOnTableBuilder Column(Column definition) => new(builder, definition, false); + + public OnTableBuilder PrimaryKey(string name) => Constraint(ConstraintKind.PrimaryKey, name); + public OnTableBuilder NonClusteredPrimaryKey(string name) => Constraint(ConstraintKind.NonClusteredPrimaryKey, name); + public OnTableBuilder UniqueConstraint(string name) => Constraint(ConstraintKind.Unique, name); + private OnTableBuilder Constraint(ConstraintKind kind, string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"{kind} '{name}' requires OnTable(...).WithColumns(...)"); + return new(table => new ConstraintColumnsBuilder(pending, kind, name, table)); + } + public OnTableBuilder CheckConstraint(string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"Check '{name}' requires OnTable(...).WithExpression(...)"); + return new(table => new CheckExpressionBuilder(pending, name, table)); + } + public FromTableBuilder>>> ForeignKey(string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"Foreign key '{name}' requires FromTable(...).WithColumns(...).ToTable(...).WithColumns(...)"); + return new(child => new(columns => new(parent => new(parentColumns => + { + if (columns.Length != parentColumns.Length) throw new ArgumentException("Child and parent keys must have equal column counts."); + var options = new ForeignKeyOptionsBuilder(); + pending.Complete(() => new ConstraintOperation(ConstraintKind.ForeignKey, child, name, columns.ToArray(), parent, parentColumns.ToArray(), + OnDelete: options.DeleteAction, OnUpdate: options.UpdateAction)); + return options; + })))); + } + public OnTableBuilder> Index(string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"Index '{name}' requires OnTable(...).WithColumns(...)"); + return new(table => new(columns => + { + var options = new IndexOptionsBuilder(new Index { Name = name, KeyColumns = columns }); + pending.Complete(() => new IndexOperation(table, options.BuildIndex())); + return options; + })); + } + public IndexDefinitionOnTableBuilder Index(Index definition) => new(builder, definition); + public FromTableBuilder View(string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"View '{name}' requires FromTable(...) and WithFields(...) or WithElements(...)"); + return new(table => new ViewDefinitionBuilder(pending, name, table)); + } +} + +public sealed class ConstraintColumnsBuilder +{ + private readonly PendingOperation pending; + private readonly ConstraintKind kind; + private readonly string name, table; + internal ConstraintColumnsBuilder(PendingOperation pending, ConstraintKind kind, string name, string table) + { this.pending = pending; this.kind = kind; this.name = name; this.table = table; } + public void WithColumns(params string[] columns) + { + var copy = BuilderArguments.Names(columns); + pending.Complete(() => new ConstraintOperation(kind, table, name, copy.ToArray())); + } +} + +public sealed class CheckExpressionBuilder +{ + private readonly PendingOperation pending; + private readonly string name, table; + internal CheckExpressionBuilder(PendingOperation pending, string name, string table) + { this.pending = pending; this.name = name; this.table = table; } + public void WithExpression(string sql) + { + BuilderArguments.Name(sql); + pending.Complete(() => new ConstraintOperation(ConstraintKind.Check, table, name, Array.Empty(), Check: sql)); + } +} + +public sealed class ForeignKeyOptionsBuilder +{ + internal ForeignKeyConstraintType DeleteAction { get; private set; } = ForeignKeyConstraintType.NoAction; + internal ForeignKeyConstraintType UpdateAction { get; private set; } = ForeignKeyConstraintType.NoAction; + internal ForeignKeyOptionsBuilder() { } + public ForeignKeyOptionsBuilder OnDelete(ForeignKeyConstraintType action) { DeleteAction = action; return this; } + public ForeignKeyOptionsBuilder OnUpdate(ForeignKeyConstraintType action) { UpdateAction = action; return this; } +} + +public sealed class IndexOptionsBuilder +{ + private readonly Index index; + internal IndexOptionsBuilder(Index index) => this.index = index; + internal Index BuildIndex() => (Index)Definitions.Copy(index); + public IndexOptionsBuilder Unique() { index.Unique = true; return this; } + public IndexOptionsBuilder Clustered() { index.Clustered = true; return this; } + public IndexOptionsBuilder IncludeColumns(params string[] columns) { index.IncludeColumns = BuilderArguments.Names(columns); return this; } + public IndexOptionsBuilder WithFilter(params FilterItem[] filters) + { + index.FilterItems = filters.Select(f => new FilterItem { ColumnName = f.ColumnName, Filter = f.Filter, Value = f.Value }).ToList(); + return this; + } +} + +public sealed class IndexDefinitionOnTableBuilder +{ + private readonly PendingOperation pending; + private readonly Index index; + internal IndexDefinitionOnTableBuilder(MigrationBuilder builder, Index definition) + { + index = (Index)Definitions.Copy(definition); + BuilderArguments.Name(index.Name); + BuilderArguments.Names(index.KeyColumns); + pending = new PendingOperation(builder, $"Index '{index.Name}' requires OnTable(...)"); + } + public void OnTable(string table) + { + BuilderArguments.Name(table); + pending.Complete(() => new IndexOperation(table, (Index)Definitions.Copy(index))); + } +} + +public sealed class ViewDefinitionBuilder +{ + private readonly PendingOperation pending; + private readonly string name, table; + internal ViewDefinitionBuilder(PendingOperation pending, string name, string table) + { this.pending = pending; this.name = name; this.table = table; } + public void WithFields(params IViewField[] fields) + { + var copy = fields.Select(Definitions.CopyViewField).ToArray(); + pending.Complete(() => new ViewOperation(name, table, copy.Select(Definitions.CopyViewField).ToArray())); + } + public void WithElements(params IViewElement[] elements) + { + var copy = elements.Select(Definitions.CopyViewElement).ToArray(); + pending.Complete(() => new ViewOperation(name, table, null, copy.Select(Definitions.CopyViewElement).ToArray())); + } +} + +public sealed class DeleteRoot(MigrationBuilder builder) +{ + 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)); + public RemoveFromTableBuilder Constraint(string name) => Remove(RemoveKind.Constraint, BuilderArguments.Name(name)); + public RemoveFromTableBuilder PrimaryKey() => Remove(RemoveKind.PrimaryKey); + public RemoveFromTableBuilder DefaultValue(string column) => Remove(RemoveKind.Default, BuilderArguments.Name(column)); + public RemoveFromTableBuilder Index(string name) => Remove(RemoveKind.Index, BuilderArguments.Name(name)); + public RemoveFromTableBuilder AllIndexes() => Remove(RemoveKind.AllIndexes); + public RemoveFromTableBuilder AllConstraints() => Remove(RemoveKind.AllConstraints); + public RemoveFromTableBuilder ForeignKeysForColumn(string column) => Remove(RemoveKind.ForeignKeysForColumn, BuilderArguments.Name(column)); + private RemoveFromTableBuilder Remove(RemoveKind kind, string name = null) => new(builder, kind, name); + public DeleteDataBuilder FromTable(string table) => new(builder, BuilderArguments.Name(table)); +} + +public sealed class RemoveFromTableBuilder +{ + private readonly PendingOperation pending; + private readonly RemoveKind kind; + private readonly string name; + internal RemoveFromTableBuilder(MigrationBuilder builder, RemoveKind kind, string name) + { + this.kind = kind; this.name = name; + pending = new PendingOperation(builder, $"Delete {kind} requires FromTable(...)"); + } + public void FromTable(string table) + { + BuilderArguments.Name(table); + pending.Complete(() => new RemoveOperation(kind, table, name)); + } +} + +public sealed class RenameRoot(MigrationBuilder builder) +{ + public RenameToBuilder Table(string name) + { + BuilderArguments.Name(name); + return new(new PendingOperation(builder, $"Rename table '{name}' requires To(...)"), name, null); + } + public OnTableBuilder Column(string name) + { + BuilderArguments.Name(name); + var pending = new PendingOperation(builder, $"Rename column '{name}' requires OnTable(...).To(...)"); + return new(table => new RenameToBuilder(pending, table, name)); + } +} + +public sealed class RenameToBuilder +{ + private readonly PendingOperation pending; + private readonly string table, column; + internal RenameToBuilder(PendingOperation pending, string table, string column) + { this.pending = pending; this.table = table; this.column = column; } + public void To(string name) + { + BuilderArguments.Name(name); + pending.Complete(() => new RenameOperation(table, name, column)); + } +}