From b31a4f52b3744fb57579ea77548f9879adb554c6 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:01:39 +0200 Subject: [PATCH 1/5] Add materialized DTO selection with typed predicate translation --- README.md | 6 + docs/_src/content.py | 35 +++ docs/assets/search-index.json | 2 +- docs/dto-queries.md | 73 +++++ docs/fluent-operation-coverage.json | 1 + docs/guide/auto-reversing.html | 12 +- docs/guide/cli.html | 32 +- docs/guide/conditional.html | 10 +- docs/guide/connections.html | 22 +- docs/guide/constraints.html | 22 +- docs/guide/data.html | 101 ++++-- docs/guide/defaults-collations.html | 24 +- docs/guide/dependency-injection.html | 12 +- docs/guide/extensions.html | 12 +- docs/guide/foreign-keys.html | 22 +- docs/guide/indexes.html | 20 +- docs/guide/maintenance.html | 12 +- docs/guide/mysql.html | 12 +- docs/guide/oracle.html | 12 +- docs/guide/postgresql.html | 12 +- docs/guide/preview.html | 24 +- docs/guide/profiles.html | 24 +- docs/guide/providers.html | 12 +- docs/guide/schema.html | 34 +- docs/guide/sql-server.html | 10 +- docs/guide/sql.html | 26 +- docs/guide/sqlite.html | 12 +- docs/guide/tags.html | 24 +- docs/guide/testing.html | 12 +- docs/guide/transactions.html | 24 +- docs/guide/versioning.html | 22 +- docs/index.html | 12 +- src/Migrator.Tests/DtoSelectTests.cs | 294 ++++++++++++++++++ .../Live/DtoSelectIntegrationTests.cs | 62 ++++ src/Migrator/Framework/DtoSelectExtensions.cs | 24 ++ .../Framework/ITransformationProvider.cs | 12 + .../Framework/TypedSelect/DtoMapping.cs | 79 +++++ .../Framework/TypedSelect/SelectPredicate.cs | 97 ++++++ .../Framework/TypedSelect/SelectQuery.cs | 68 ++++ .../Providers/TransformationProvider.cs | 13 + 40 files changed, 1074 insertions(+), 265 deletions(-) create mode 100644 docs/dto-queries.md create mode 100644 src/Migrator.Tests/DtoSelectTests.cs create mode 100644 src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs create mode 100644 src/Migrator/Framework/DtoSelectExtensions.cs create mode 100644 src/Migrator/Framework/TypedSelect/DtoMapping.cs create mode 100644 src/Migrator/Framework/TypedSelect/SelectPredicate.cs create mode 100644 src/Migrator/Framework/TypedSelect/SelectQuery.cs diff --git a/README.md b/README.md index e25bef03..ac850b38 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,12 @@ See the [mapping and migration guide](docs/migration-guide-12.1-to-13.md#explici ### SQLite reconstruction and data types +Read migration-local DTOs with `Database.Select("Common_Screens", +x => !x.Archive && x.Id > minimumId)`. This returns a materialized list, quotes +identifiers, parameterizes values and uses the active migration transaction. +See [DTO queries](docs/dto-queries.md) for column mapping, supported predicates +and SQL null semantics. + Supported rebuilds retain mapped data, named/composite keys, declared column collations, supported indexes and triggers, and the AUTOINCREMENT high-water mark. Generated columns, `STRICT`, `WITHOUT ROWID` and indexes with explicit collations are rejected for reconstruction; hidden rowid values are not preserved. Foreign keys retain separate update/delete actions; unsupported `MATCH FULL`/`PARTIAL` requests fail explicitly. See the [SQLite preservation matrix](docs/migration-framework-comparison.md#what-survives-reconstructionand-what-is-not-guaranteed). CLR `Guid` defaults use the same blob representation as inserted GUID parameters. Existing text GUID defaults remain unchanged during unrelated rebuilds; converting mixed storage requires an explicit data migration. SQLite's `AsciiIgnoreCase` preset selects ASCII-only `NOCASE`; Unicode case-insensitive requests need a suitable custom collation, registered on the connection and selected by name. diff --git a/docs/_src/content.py b/docs/_src/content.py index fbab22ee..557f500d 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -196,6 +196,41 @@ 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("Read DTOs", '

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

', pair("Read screens using a typed predicate", ''' +public class ReadScreens : Migration +{ + public sealed class ScreenDto + { + public int Id { get; set; } + public bool Archive { get; set; } + public string GenericDefinition { get; set; } + } + public override void Up() + { + var minimumId = 10; + var screens = Database.Select( + "Common_Screens", x => !x.Archive && x.Id > minimumId); + } + public override void Down() { } +} +''', ''' +public class ReadScreens : FluentMigration +{ + public sealed class ScreenDto + { + public int Id { get; set; } + public bool Archive { get; set; } + public string GenericDefinition { get; set; } + } + public override void BuildUp(MigrationBuilder migration) + { + var minimumId = 10; + var screens = Context.Select( + "Common_Screens", x => !x.Archive && x.Id > minimumId); + } + public override void BuildDown(MigrationBuilder migration) { } +} +''', kind="class")), 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" }); ''', ''' diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index 53fa4cb9..2d019777 100644 --- a/docs/assets/search-index.json +++ b/docs/assets/search-index.json @@ -53,7 +53,7 @@ "group": "Operations", "summary": "Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.", "url": "guide/data.html", - "text": " Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns. The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected. Keep one assignment per role/group pair int removed = Database.DeleteDuplicateRows(\"Assignments\",\n new[] { \"RoleId\", \"GroupId\" }, DuplicateRowRetention.Any); migration.Delete.DuplicateRows().FromTable(\"Assignments\")\n .ByColumns(\"RoleId\", \"GroupId\").KeepAny(); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" + "text": " Select(table, predicate) reads a single table into a materialized IReadOnlyList, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration. Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits. Read screens using a typed predicate using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\npublic class ReadScreens : Migration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void Up()\n {\n var minimumId = 10;\n var screens = Database.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n }\n public override void Down() { }\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\npublic class ReadScreens : FluentMigration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void BuildUp(MigrationBuilder migration)\n {\n var minimumId = 10;\n var screens = Context.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n }\n public override void BuildDown(MigrationBuilder migration) { }\n} Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns. The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected. Keep one assignment per role/group pair int removed = Database.DeleteDuplicateRows(\"Assignments\",\n new[] { \"RoleId\", \"GroupId\" }, DuplicateRowRetention.Any); migration.Delete.DuplicateRows().FromTable(\"Assignments\")\n .ByColumns(\"RoleId\", \"GroupId\").KeepAny(); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" }, { "title": "Schema inspection", diff --git a/docs/dto-queries.md b/docs/dto-queries.md new file mode 100644 index 00000000..ec5dcf0a --- /dev/null +++ b/docs/dto-queries.md @@ -0,0 +1,73 @@ +# Reading DTOs in migrations + +Import `DotNetProjects.Migrator.Framework` to use `Select` on an +`ITransformationProvider`. Keep DTO definitions with their migration so later +application model changes do not change historical migrations. + +```csharp +using DotNetProjects.Migrator.Framework; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; + +public sealed class ScreenDto +{ + public int Id { get; set; } + public bool Archive { get; set; } + [DtoColumn("GenericDefinition")] + public string Definition { get; set; } +} + +// Inside a migration's Up(): +var minimumId = 10; +var screens = Database.Select( + "Common_Screens", x => !x.Archive && x.Id > minimumId); +``` + +The table is explicit; no pluralization or application entity metadata is needed. +The query selects public readable/writable instance properties, with column names +matching property names unless the standard `ColumnAttribute` overrides them. +Use the `DtoColumn` alias above because Migrator also has a `ColumnAttribute` +enum. The standard `NotMappedAttribute` excludes a property. Indexers, fields, +static properties and properties without public getters and setters are ignored. +DTOs must be reference types with a public parameterless constructor. Empty +mappings, duplicate column names and unsupported property types fail explicitly. + +Supported scalar types are strings, byte arrays, booleans, integer types, float, +double, decimal, enums, Guid, DateTime, DateTimeOffset, TimeOnly and TimeSpan, +including nullable value types. Their database representation and precision +remain provider-specific. SQL NULL maps to nullable/reference properties; mapping +it to a non-nullable value type throws a `DataException` naming the column and +property. Enums map from their numeric storage; GUIDs accept GUID values, strings +or 16-byte storage. The mapper uses invariant numeric conversions and reports +format and overflow errors. It does not track entities or load related objects. + +`Select(table)` reads all rows. The optional predicate accepts: + +- Direct property comparisons with constants or captured scalar fields using + `==`, `!=`, `<`, `<=`, `>` and `>=`. +- `&&`, `||`, `!`, grouping, and direct boolean properties such as `x.Active`. +- `== null` and `!= null`, translated to `IS NULL` and `IS NOT NULL`. +- Numeric enum equality and lossless integral promotions inserted by C#. + +Byte-array comparisons and ordering of strings, booleans, GUIDs and enums are +unsupported. Methods, property-to-property comparisons, arithmetic, navigation +properties, lossy casts, joins and general LINQ operators are unsupported and +throw before executing a query. Capture computed values in local scalar variables +first; the translator reads constants and fields without compiling a lambda or +executing arbitrary getters/methods. + +Predicates use SQL null semantics and database collation. For example, +`x.Optional != 7` excludes NULL rows; use +`x.Optional == null || x.Optional != 7` to include them. There is no implicit +client-side filtering. All non-null values become driver parameters; identifiers +use the provider's quoting rules. SQL parameter occurrence order is preserved +for positional drivers, and built-in providers retain their native value +conversions. Custom providers can override `AddQueryParameter` if they need +different parameter names or storage conversions. + +The returned `IReadOnlyList` is fully materialized. The command and reader are +disposed before return, also on errors. The provider connection stays open and +the query participates in its active migration transaction. Row order is +unspecified; sorting after materialization is explicit client-side work. + +In a `FluentMigration`, call `Context.Select` for immediate reads. Reads during +`BuildUp` cannot see schema operations merely queued in that same builder. diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json index 8a553807..99d0f8f9 100644 --- a/docs/fluent-operation-coverage.json +++ b/docs/fluent-operation-coverage.json @@ -56,6 +56,7 @@ "QuoteColumnNamesIfRequired": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", "QuoteTableNameIfRequired": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", "GenerateParameterName": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "AddQueryParameter": "Context.AddQueryParameter(command, index, value); native driver binding for typed DTO queries", "Encode": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", "Concatenate": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", "CreateDatabases": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", diff --git a/docs/guide/auto-reversing.html b/docs/guide/auto-reversing.html index c0eed847..dcfa1e1c 100644 --- a/docs/guide/auto-reversing.html +++ b/docs/guide/auto-reversing.html @@ -5,9 +5,9 @@ Migrator.NET -

Automatic reversal

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

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

Creation and its reverse

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

A reversible table creation
-

Classic

-
using System;
+

Automatic reversal

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

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

Creation and its reverse

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

A reversible table creation
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

Command-line tool

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

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

Install and connect

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

Install the CLI
-

Classic

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

Fluent

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

Shared commands · both styles

Inspect before applying

Inspect a migration assembly
-

Classic

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

Command-line tool

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

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

Install and connect

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

Install the CLI
+

Classic

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

Fluent

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

Shared commands · both styles

Inspect before applying

Inspect a migration assembly
+

Classic

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

Fluent

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

Fluent

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

Shared commands · both styles

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

Apply and roll back

Deploy a selected scope
-

Classic

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

Fluent

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

Shared commands · both styles

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

Apply and roll back

Deploy a selected scope
+

Classic

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

Fluent

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

Shared commands · both styles

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

Options and exit codes

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

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

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

Conditional logic

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

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

Provider-specific operations

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

Run a SQLite-specific statement
-

Classic

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

Fluent

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

Conditional logic

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

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

Provider-specific operations

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

Run a SQLite-specific statement
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Schema-dependent decisions

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

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

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

Commands and callbacks

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

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

Bind command parameters

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

Execute a parameterized command
-

Classic

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

Commands and callbacks

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

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

Bind command parameters

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

Execute a parameterized command
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Connection ownership

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

Database administration

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

Create a database on a supporting server
-

Classic

-
Database.CreateDatabases("Reporting");
-

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Preview and reversal

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

+});

Inside Up() / BuildUp(MigrationBuilder migration)

Connection ownership

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

Database administration

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

Create a database on a supporting server
+

Classic

+
Database.CreateDatabases("Reporting");
+

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Preview and reversal

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

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

Keys and constraints

Declare table invariants independently of column attributes.

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

Add uniqueness and a check

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

Add two named constraints
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the intended object

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

Remove a check constraint
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint identity

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

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

+

Keys and constraints

Declare table invariants independently of column attributes.

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

Add uniqueness and a check

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

Add two named constraints
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Remove the intended object

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

Remove a check constraint
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint identity

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

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

diff --git a/docs/guide/data.html b/docs/guide/data.html index 6616e6a1..9be66206 100644 --- a/docs/guide/data.html +++ b/docs/guide/data.html @@ -5,40 +5,85 @@ 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 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
+

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.

Read DTOs

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

Read screens using a typed predicate

Classic

-
Database.Insert("Users", new[] { "Id", "Name" }, new object[] { 1, "Ada" });
+
using System;
+using System.Data;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+
+public class ReadScreens : Migration
+{
+    public sealed class ScreenDto
+    {
+        public int Id { get; set; }
+        public bool Archive { get; set; }
+        public string GenericDefinition { get; set; }
+    }
+    public override void Up()
+    {
+        var minimumId = 10;
+        var screens = Database.Select<ScreenDto>(
+            "Common_Screens", x => !x.Archive && x.Id > minimumId);
+    }
+    public override void Down() { }
+}

Fluent

-
migration.Insert.IntoTable("Users")
-    .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
+
using System;
+using System.Data;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Framework.Fluent;
+
+public class ReadScreens : FluentMigration
+{
+    public sealed class ScreenDto
+    {
+        public int Id { get; set; }
+        public bool Archive { get; set; }
+        public string GenericDefinition { get; set; }
+    }
+    public override void BuildUp(MigrationBuilder migration)
+    {
+        var minimumId = 10;
+        var screens = Context.Select<ScreenDto>(
+            "Common_Screens", x => !x.Archive && x.Id > minimumId);
+    }
+    public override void BuildDown(MigrationBuilder migration) { }
+}

Choose one authoring style

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.Update("Users", new[] { "Name" }, new object[] { "Ada Lovelace" },
-    new[] { "Id" }, new object[] { 1 });
+
Database.Insert("Users", new[] { "Id", "Name" }, new object[] { 1, "Ada" });

Fluent

-
migration.Update.Table("Users")
+
migration.Insert.IntoTable("Users")
+    .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 });
+

Fluent

+
migration.Update.Table("Users")
     .Set(new[] { "Name" }, new object[] { "Ada Lovelace" })
-    .Where(new[] { "Id" }, new object[] { 1 });

Inside Up() / BuildUp(MigrationBuilder migration)

Delete one user
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Delete duplicate rows

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

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

Keep one assignment per role/group pair
-

Classic

-
int removed = Database.DeleteDuplicateRows("Assignments",
-    new[] { "RoleId", "GroupId" }, DuplicateRowRetention.Any);
-

Fluent

-
migration.Delete.DuplicateRows().FromTable("Assignments")
-    .ByColumns("RoleId", "GroupId").KeepAny();

Inside Up() / BuildUp(MigrationBuilder migration)

Conditional seed data

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

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

Inside Up() / BuildUp(MigrationBuilder migration)

Delete one user
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Delete duplicate rows

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

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

Keep one assignment per role/group pair

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Copying and reversal

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

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

Inside Up() / BuildUp(MigrationBuilder migration)

Conditional seed data

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

Insert a missing seed

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Inside Up() / BuildUp(MigrationBuilder migration)

Copying and reversal

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

Copy users into an archive table
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Defaults and collations

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

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

Literal and expression defaults

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

A database-generated timestamp
-

Classic

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

Defaults and collations

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

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

Literal and expression defaults

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

A database-generated timestamp
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Comparison behavior

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

ASCII-insensitive SQLite text
-

Classic

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

Inside Up() / BuildUp(MigrationBuilder migration)

Comparison behavior

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

ASCII-insensitive SQLite text
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Presets and provider names

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

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

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

Dependency injection and logging

Resolve the runner and migration dependencies inside one service scope.

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

Register the integration

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

A scoped migration host
-

Classic

-
using DotNetProjects.Migrator;
+

Dependency injection and logging

Resolve the runner and migration dependencies inside one service scope.

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

Register the integration

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

A scoped migration host
+

Classic

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

Fluent

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

Fluent

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

Custom extensions

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

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

Share a schema convention

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

Reusable audit-column helpers
-

Classic

-
using System;
+

Custom extensions

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

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

Share a schema convention

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

Reusable audit-column helpers
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

Foreign keys

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

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

Add a relationship

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

Orders belong to users
-

Classic

-
((IForeignKeyActions)Database).AddForeignKey(
+

Foreign keys

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

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

Add a relationship

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

Orders belong to users
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Remove a relationship

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

Remove the foreign key
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Database semantics

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

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

+ .OnUpdate(ForeignKeyConstraintType.NoAction);

Inside Up() / BuildUp(MigrationBuilder migration)

Remove a relationship

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

Remove the foreign key
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Database semantics

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

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

diff --git a/docs/guide/indexes.html b/docs/guide/indexes.html index 009f6369..9f062862 100644 --- a/docs/guide/indexes.html +++ b/docs/guide/indexes.html @@ -5,15 +5,15 @@ Migrator.NET -

Indexes

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

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

Create and remove an index

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

Index a user name
-

Classic

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

Indexes

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

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

Create and remove an index

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

Index a user name
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Drop an index
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle supports filtered unique indexes through functional expressions: every key column uses CASE WHEN with the complete filter predicate, so non-matching rows produce all-NULL keys and are omitted. Filters may reference non-key columns. Oracle retains its limited non-unique, key-column expression emulation and cannot read functional expressions back as FilterItems. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

Unique index or unique constraint?

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

+});
+

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Drop an index
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Provider options

Index definitions also expose IncludeColumns, FilterItems and Clustered. SQL Server (2008+), PostgreSQL and SQLite support filters on any table column, including columns outside KeyColumns. EqualTo or NotEqualTo with null (or DBNull.Value) becomes IS NULL or IS NOT NULL. GetIndexes(table), also available as Schema.Table(table).Indexes() in fluent migrations, reads back the keys, included columns, flags and supported FilterItems. Filters preserve null checks and escaped string values.

UnsupportedFilterBehavior defaults to UnsupportedIndexFilterBehavior.Throw. Set it to Ignore, or append OnUnsupportedFilter(UnsupportedIndexFilterBehavior.Ignore) in fluent code, to create an unfiltered index on a provider that cannot apply the filters. For unique indexes this enforces uniqueness across all rows. This option only affects unsupported filters; it does not suppress other invalid options or execution failures.

Oracle supports filtered unique indexes through functional expressions: every key column uses CASE WHEN with the complete filter predicate, so non-matching rows produce all-NULL keys and are omitted. Filters may reference non-key columns. Oracle retains its limited non-unique, key-column expression emulation and cannot read functional expressions back as FilterItems. Oracle rejects included and clustered index requests; SQLite reconstruction rejects existing index SQL with explicit COLLATE clauses.

The fallback policy is an authoring option and is not stored in database metadata. Preview handles simple indexes and rejects filtered indexes even in Ignore mode.

Unique index or unique constraint?

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

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

Maintenance migrations

Place ordered work at the runner's lifecycle stages.

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

Choose a stage

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

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

A scoped maintenance operation

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

Write a deployment marker
-

Classic

-
using System;
+

Maintenance migrations

Place ordered work at the runner's lifecycle stages.

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

Choose a stage

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

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

A scoped maintenance operation

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

Write a deployment marker
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

MySQL and MariaDB

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

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

Choose the matching dialect

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

Select a supported collation

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

Case-insensitive, accent-sensitive text
-

Classic

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

MySQL and MariaDB

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

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

Choose the matching dialect

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

Select a supported collation

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

Case-insensitive, accent-sensitive text
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Constraint metadata

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

Locking and values

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

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

Oracle

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

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

Connection and schema

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

Create an identity definition

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

An identity table with an explicit key
-

Classic

-
Database.AddTable("Entries",
+

Oracle

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

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

Connection and schema

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

Create an identity definition

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

An identity table with an explicit key
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Object cleanup

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

Values and options

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

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

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

PostgreSQL

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

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

Connect with Npgsql

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

Use native interval values

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

Store a job duration
-

Classic

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

PostgreSQL

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

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

Connect with Npgsql

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

Use native interval values

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

Store a job duration
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Collations and schemas

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

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

Transactions and coordination

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

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

Planning and SQL preview

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

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

Read-only version planning

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

Inspect version steps
-

Classic

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

Planning and SQL preview

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

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

Read-only version planning

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

Inspect version steps
+

Classic

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

Fluent

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

Fluent

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

Shared host · both styles

Preview connected SQL

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

Generate operation SQL
-

Classic

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

Fluent

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

Shared host · both styles

Preview connected SQL

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

Generate operation SQL
+

Classic

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

Fluent

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

Choose one authoring style

Offline generation and boundaries

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

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

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

Profiles

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

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

Define a named profile

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

A development seed profile
-

Classic

-
using System;
+

Profiles

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

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

Define a named profile

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

A development seed profile
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

Choose one authoring style

Select a profile

Run the demo profile
-

Classic

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

Fluent

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

Choose one authoring style

Select a profile

Run the demo profile
+

Classic

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

Fluent

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

Shared host · both styles

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

Execution versus repeatables

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

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

Provider overview

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

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

Database families

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

Bring a connection

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

Provider selection · open connection supplied by the host
-

Classic

-
using var selectedProvider = ProviderFactory.Create(
+

Provider overview

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

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

Database families

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

Bring a connection

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

Provider selection · open connection supplied by the host
+

Classic

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

Fluent

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

Fluent

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

Shared host · both styles

Qualification

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

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

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

Schema inspection

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

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

Inspect tables and columns

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

Add a column only when it is missing
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Read ordered constraints

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

Read table constraint definitions
-

Classic

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

Schema inspection

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

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

Inspect tables and columns

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

Add a column only when it is missing
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Read ordered constraints

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

Read table constraint definitions
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Create a view

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

A projection over Users
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Create a view

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

A projection over Users
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Reads and portability

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

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

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

SQL Server

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

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

Select the provider

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

Name constraints explicitly

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

Add a nonclustered primary key
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Indexes and SQL batches

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

Types and object names

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

+

SQL Server

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

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

Select the provider

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

Name constraints explicitly

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

Add a nonclustered primary key
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Indexes and SQL batches

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

Types and object names

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

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

Execute SQL and scripts

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

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

Execute a statement

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

A SQL data change
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Files and embedded resources

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

Execute a SQL file
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Execute an embedded SQL resource
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Client batch separators

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

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

+

Execute SQL and scripts

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

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

Execute a statement

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

A SQL data change
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Files and embedded resources

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

Execute a SQL file
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

Execute an embedded SQL resource
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

Client batch separators

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

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

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

SQLite

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

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

Automatic reconstruction

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

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

Change a column on an existing SQLite table
-

Classic

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

SQLite

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

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

Automatic reconstruction

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

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

Change a column on an existing SQLite table
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

What survives a rebuild

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

Boundaries are explicit

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

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

Values and identity

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

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

How this differs from other tools

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

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

Tags

Select a subset of versioned migrations using explicit ordinal names.

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

Tag migration classes

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

A reporting migration
-

Classic

-
using System;
+

Tags

Select a subset of versioned migrations using explicit ordinal names.

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

Tag migration classes

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

A reporting migration
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

Choose one authoring style

Configure matching

Select tags on the runner
-

Classic

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

Choose one authoring style

Configure matching

Select tags on the runner
+

Classic

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

Fluent

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

Fluent

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

Shared host · both styles

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

Downgrade behavior

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

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

Testing and deployment

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

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

Test a migration lifecycle

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

A host-level smoke check
-

Classic

-
runner.MigrateToLastVersion();
+

Testing and deployment

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

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

Test a migration lifecycle

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

A host-level smoke check
+

Classic

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

Fluent

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

Fluent

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

Transactions and locks

Transaction rollback and cross-process coordination solve different problems.

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

Choose the transaction boundary

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

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

Configure a session transaction
-

Classic

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

Fluent

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

Shared host · both styles

Coordinate competing runners

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

Acquire a native deployment lock
-

Classic

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

Transactions and locks

Transaction rollback and cross-process coordination solve different problems.

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

Choose the transaction boundary

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

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

Configure a session transaction
+

Classic

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

Fluent

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

Shared host · both styles

Coordinate competing runners

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

Acquire a native deployment lock
+

Classic

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

Fluent

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

Fluent

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

Shared host · both styles

Scope of protection

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

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

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

Versioning and scoped history

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

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

Choose a version scheme

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

A dated migration
-

Classic

-
using System;
+

Versioning and scoped history

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

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

Choose a version scheme

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

A dated migration
+

Classic

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

Fluent

-
using System;
+}
+

Fluent

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

Choose one authoring style

Scope selection

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

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

Consolidated baselines

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

Mark a version included by a baseline
-

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

+}

Choose one authoring style

Scope selection

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

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

Consolidated baselines

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

Mark a version included by a baseline
+

Classic

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

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

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

Database changes,
written in C#.

A PARTICULAR STRENGTH / SQLITE

A small database.
Room to evolve.

Change the schema you have. Without an ORM model.

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

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

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

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

IdINTEGER · PRIMARY KEY

Name255 500 · NOT NULL

↳ Existing rows travel with the schema.

Change a column on an existing SQLite table
-

Classic

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

IdINTEGER · PRIMARY KEY

Name255 500 · NOT NULL

↳ Existing rows travel with the schema.

Change a column on an existing SQLite table
+

Classic

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

Fluent

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

Fluent

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

Inside Up() / BuildUp(MigrationBuilder migration)

diff --git a/src/Migrator.Tests/DtoSelectTests.cs b/src/Migrator.Tests/DtoSelectTests.cs new file mode 100644 index 00000000..dab88ae1 --- /dev/null +++ b/src/Migrator.Tests/DtoSelectTests.cs @@ -0,0 +1,294 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; +using System.Data; +using System.Linq; +using System.Linq.Expressions; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.Hana; +using DotNetProjects.Migrator.Providers.Impl.Informix; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class DtoSelectTests +{ + private SqliteConnection connection; + private SQLiteTransformationProvider provider; + public enum State { New = 1, Archived = 2 } + + public class ScreenDto + { + public int Id { get; set; } + public bool Archive { get; set; } + [DtoColumn("GenericDefinition")] public string Definition { get; set; } + public int? Optional { get; set; } + public State State { get; set; } + [NotMapped] public string Display => $"{Id}: {Definition}"; + [NotMapped] public object Ignored { get; set; } + } + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + provider.ExecuteNonQuery("CREATE TABLE Common_Screens (Id INTEGER, Archive INTEGER, GenericDefinition TEXT, Optional INTEGER, State INTEGER)"); + provider.Insert("Common_Screens", new[] { "Id", "Archive", "GenericDefinition", "Optional", "State" }, new object[] { 1, false, "O'Brien", null, 1 }); + provider.Insert("Common_Screens", new[] { "Id", "Archive", "GenericDefinition", "Optional", "State" }, new object[] { 2, true, "other", 7, 2 }); + provider.Insert("Common_Screens", new[] { "Id", "Archive", "GenericDefinition", "Optional", "State" }, new object[] { 3, false, null, 9, 1 }); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [Test] + public void MapsNamedColumnsNullableNumbersBooleansAndEnumsThroughInterface() + { + ITransformationProvider database = provider; + var rows = database.Select("Common_Screens"); + Assert.That(rows.Count, Is.EqualTo(3)); + var first = rows.Single(x => x.Id == 1); + Assert.Multiple(() => + { + Assert.That(first.Definition, Is.EqualTo("O'Brien")); + Assert.That(first.Optional, Is.Null); + Assert.That(first.Archive, Is.False); + Assert.That(first.State, Is.EqualTo(State.New)); + Assert.That(rows.Single(x => x.Id == 2).Optional, Is.EqualTo(7)); + Assert.That(rows.Single(x => x.Id == 3).Definition, Is.Null); + }); + } + + [Test] + public void TranslatesCapturedValuesBooleanLogicAndGrouping() + { + var minimumId = 1; + var definition = "O'Brien"; + var rows = provider.Select("Common_Screens", x => + !x.Archive && (x.Id > minimumId || x.Definition == definition)); + Assert.That(rows.Select(x => x.Id), Is.EquivalentTo(new[] { 1, 3 })); + } + + [TestCase("equal", 2)] + [TestCase("not-equal", 1, 3)] + [TestCase("less", 1)] + [TestCase("less-equal", 1, 2)] + [TestCase("greater", 3)] + [TestCase("greater-equal", 2, 3)] + [TestCase("reversed", 3)] + [TestCase("null", 1)] + [TestCase("not-null", 2, 3)] + [TestCase("captured-null", 1)] + [TestCase("enum", 2)] + public void SupportsComparisonsAndNullChecks(string operation, params int[] expected) + { + int? missing = null; + State state = State.Archived; + Expression> predicate = operation switch + { + "equal" => x => x.Id == 2, + "not-equal" => x => x.Id != 2, + "less" => x => x.Id < 2, + "less-equal" => x => x.Id <= 2, + "greater" => x => x.Id > 2, + "greater-equal" => x => x.Id >= 2, + "reversed" => x => 2 < x.Id, + "null" => x => x.Optional == null, + "not-null" => x => x.Optional != null, + "captured-null" => x => x.Optional == missing, + _ => x => x.State == state + }; + Assert.That(provider.Select("Common_Screens", predicate).Select(x => x.Id), Is.EquivalentTo(expected)); + } + + [Test] + public void ValuesCannotChangeSqlStructure() + { + var injection = "'; DROP TABLE Common_Screens; --"; + Assert.That(provider.Select("Common_Screens", x => x.Definition == injection), Is.Empty); + Assert.That(provider.TableExists("Common_Screens"), Is.True); + } + + [Test] + public void ComparisonsKeepSqlNullSemanticsAndAllowExplicitNullInclusion() + { + Assert.That(provider.Select("Common_Screens", x => x.Optional != 7).Select(x => x.Id), Is.EqualTo(new[] { 3 })); + Assert.That(provider.Select("Common_Screens", x => x.Optional == null || x.Optional != 7).Select(x => x.Id), Is.EquivalentTo(new[] { 1, 3 })); + } + + [Test] + public void SupportsLosslessIntegralPromotions() + { + long threshold = 2; + Assert.That(provider.Select("Common_Screens", x => x.Id > threshold).Single().Id, Is.EqualTo(3)); + } + + public class QuotedDto { [DtoColumn("select")] public int Id { get; set; } } + + [Test] + public void QuotesTableAndMappedColumnIdentifiers() + { + provider.ExecuteNonQuery("CREATE TABLE \"table with spaces\" (\"select\" INTEGER)"); + provider.ExecuteNonQuery("INSERT INTO \"table with spaces\" VALUES (4)"); + Assert.That(provider.Select("table with spaces", x => x.Id == 4).Single().Id, Is.EqualTo(4)); + } + + [Test] + public void UsesTheActiveMigrationTransactionAndReturnsMaterializedRows() + { + provider.BeginTransaction(); + provider.Update("Common_Screens", new[] { "GenericDefinition" }, new object[] { "uncommitted" }, new[] { "Id" }, new object[] { 1 }); + var rows = provider.Select("Common_Screens", x => x.Id == 1); + provider.Rollback(); + Assert.That(rows.Single().Definition, Is.EqualTo("uncommitted")); + Assert.That(provider.Select("Common_Screens", x => x.Id == 1).Single().Definition, Is.EqualTo("O'Brien")); + } + + [TestCase("method")] + [TestCase("column-column")] + [TestCase("arithmetic")] + [TestCase("unmapped")] + [TestCase("static-property")] + [TestCase("narrowing")] + public void RejectsUnsupportedPredicatesBeforeCreatingACommand(string kind) + { + var database = Substitute.For(); + Expression> predicate = kind switch + { + "method" => x => x.Definition.StartsWith("O"), + "column-column" => x => x.Id == x.Optional, + "arithmetic" => x => x.Id + 1 == 2, + "unmapped" => x => x.Display == "1", + "static-property" => x => x.Id == DateTime.Now.Day, + _ => x => (byte)x.Id == 1 + }; + Assert.Throws(() => database.Select("Common_Screens", predicate)); + database.DidNotReceive().CreateCommand(); + } + + public class EmptyDto { public int ReadOnly => 0; } + public class UnsupportedDto { public object Value { get; set; } } + public class DuplicateDto { [DtoColumn("Id")] public int A { get; set; } [DtoColumn("id")] public int B { get; set; } } + + [Test] + public void RejectsInvalidMappingsAndTables() + { + Assert.Throws(() => provider.Select("Common_Screens")); + Assert.Throws(() => provider.Select("Common_Screens")); + Assert.Throws(() => provider.Select("Common_Screens")); + Assert.Throws(() => provider.Select(" ")); + } + + public class RequiredDto { [DtoColumn("Optional")] public int Value { get; set; } } + public class WrongTypeDto { [DtoColumn("GenericDefinition")] public int Value { get; set; } } + + [Test] + public void MappingErrorsNameTheColumnAndDtoProperty() + { + var nullError = Assert.Throws(() => provider.Select("Common_Screens")); + Assert.That(nullError.Message, Does.Contain("Optional").And.Contain("RequiredDto.Value")); + var typeError = Assert.Throws(() => provider.Select("Common_Screens")); + Assert.That(typeError.Message, Does.Contain("GenericDefinition").And.Contain("WrongTypeDto.Value")); + // A failed materialization leaves the connection usable. + Assert.That(provider.Select("Common_Screens").Count, Is.EqualTo(3)); + } + + public class ConversionDto + { + public Guid Key { get; set; } + public DateTime Created { get; set; } + public TimeOnly Time { get; set; } + public TimeSpan Duration { get; set; } + public byte[] Data { get; set; } + public decimal Amount { get; set; } + } + + [Test] + public void MapsProviderStorageRepresentations() + { + var key = Guid.NewGuid(); + provider.ExecuteNonQuery("CREATE TABLE Converted (Key BLOB, Created TEXT, Time TEXT, Duration INTEGER, Data BLOB, Amount NUMERIC)"); + provider.Insert("Converted", new[] { "Key", "Created", "Time", "Duration", "Data", "Amount" }, + new object[] { key, "2026-10-14T10:30:00Z", new TimeOnly(10, 30), TimeSpan.FromSeconds(2), new byte[] { 1, 2 }, 12.5m }); + var row = provider.Select("Converted", x => x.Key == key).Single(); + Assert.Multiple(() => + { + Assert.That(row.Key, Is.EqualTo(key)); + Assert.That(row.Created, Is.EqualTo(new DateTime(2026, 10, 14, 10, 30, 0, DateTimeKind.Utc))); + Assert.That(row.Time, Is.EqualTo(new TimeOnly(10, 30))); + Assert.That(row.Duration, Is.EqualTo(TimeSpan.FromSeconds(2))); + Assert.That(row.Data, Is.EqualTo(new byte[] { 1, 2 })); + Assert.That(row.Amount, Is.EqualTo(12.5m)); + }); + } +} + +public class DtoSelectBindingTests +{ + public class Row { public int Id { get; set; } public bool Active { get; set; } } + + [TestCase("oracle", ":p0", "p0", "p1", 1)] + [TestCase("hana", "?", "p0", "p1", true)] + [TestCase("informix", "?", "@p0", "@p1", true)] + public void PreservesProviderParameterNamesConversionsAndOccurrenceOrder(string kind, string placeholder, + string firstName, string secondName, object boolean) + { + var connection = Substitute.For(); + connection.State.Returns(ConnectionState.Open); + var command = Substitute.For(); + connection.CreateCommand().Returns(command); + using var holder = new SqliteCommand(); + command.Parameters.Returns(holder.Parameters); + command.CreateParameter().Returns(_ => new SqliteParameter()); + using var data = new DataTable(); + data.Columns.Add("Id", typeof(int)); data.Columns.Add("Active", typeof(bool)); + using var reader = data.CreateDataReader(); + command.ExecuteReader().Returns(reader); + using TransformationProvider provider = kind switch + { + "oracle" => new OracleTransformationProvider(new OracleDialect(), connection, null, null, null), + "hana" => new HanaTransformationProvider(new HanaDialect(), connection, null, null), + _ => new InformixTransformationProvider(new InformixDialect(), connection, null, null) + }; + provider.Select("Rows", x => x.Id > 5 && x.Active); + Assert.Multiple(() => + { + Assert.That(command.CommandText, Does.Contain(placeholder)); + Assert.That(holder.Parameters.Count, Is.EqualTo(2)); + Assert.That(holder.Parameters[0].ParameterName, Is.EqualTo(firstName)); + Assert.That(holder.Parameters[0].Value, Is.EqualTo(5)); + Assert.That(holder.Parameters[1].ParameterName, Is.EqualTo(secondName)); + Assert.That(holder.Parameters[1].Value, Is.EqualTo(boolean)); + Assert.That(reader.IsClosed, Is.True); + }); + command.Received().Dispose(); + } + + [TestCase(false)] + [TestCase(true)] + public void DisposesResourcesOnExecutionOrMappingFailure(bool mappingFailure) + { + var database = Substitute.For(); + var command = Substitute.For(); + database.CreateCommand().Returns(command); + database.QuoteColumnNameIfRequired(Arg.Any()).Returns(x => (string)x[0]); + database.QuoteTableNameIfRequired(Arg.Any()).Returns(x => (string)x[0]); + using var data = new DataTable(); data.Columns.Add("Id", typeof(int)); data.Columns.Add("Active", typeof(bool)); + data.Rows.Add(DBNull.Value, true); + using var reader = data.CreateDataReader(); + if (mappingFailure) database.ExecuteQuery(command, Arg.Any()).Returns(reader); + else database.ExecuteQuery(command, Arg.Any()).Returns(_ => throw new InvalidOperationException("driver failure")); + Assert.Catch(() => database.Select("Rows")); + command.Received().Dispose(); + if (mappingFailure) Assert.That(reader.IsClosed, Is.True); + else reader.Dispose(); + } +} diff --git a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs new file mode 100644 index 00000000..1858f000 --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; + +namespace Migrator.Tests.Providers.Live; + +[TestFixture("SQLite", ProviderTypes.SQLite, Category = "SQLite")] +[TestFixture("SQLServer", ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture("PostgreSQL", ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture("Oracle", ProviderTypes.Oracle, Category = "Oracle")] +[TestFixture("MySQL", ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] +[TestFixture("Firebird", ProviderTypes.Firebird, Category = "Firebird")] +[TestFixture("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] +[TestFixture("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] +[TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] +[TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] +[NonParallelizable] +public class DtoSelectIntegrationTests(string database, ProviderTypes providerType) : LiveProviderFixture(database, providerType) +{ + public enum Status { New = 1, Archived = 2 } + public class Row + { + [DtoColumn("id")] public int Id { get; set; } + [DtoColumn("active")] public bool Active { get; set; } + [DtoColumn("payload")] public string Payload { get; set; } + [DtoColumn("optional")] public int? Optional { get; set; } + [DtoColumn("status")] public Status State { get; set; } + } + + [Test] + public void MapsDtosAndFiltersUsingNativeDriverParameters() + { + var table = "dto_" + Guid.NewGuid().ToString("N")[..12]; + Provider.AddTable(table, new Column("id", DbType.Int32) { IsNullable = false }, + new Column("active", DbType.Boolean) { IsNullable = false }, new Column("payload", DbType.String, 100), + new Column("optional", DbType.Int32), new Column("status", DbType.Int32) { IsNullable = false }); + var columns = new[] { "id", "active", "payload", "optional", "status" }; + try + { + Provider.Insert(table, columns, new object[] { 1, true, "O'Brien", null, 1 }); + Provider.Insert(table, columns, new object[] { 2, false, "other", 7, 2 }); + Provider.Insert(table, columns, new object[] { 3, true, "third", 9, 1 }); + var minimum = 1; + var state = Status.New; + var rows = Provider.Select(table, x => x.Active && x.State == state && (x.Id > minimum || x.Optional == null)); + Assert.That(rows.Select(x => x.Id), Is.EquivalentTo(new[] { 1, 3 })); + Assert.That(rows.Single(x => x.Id == 1).Payload, Is.EqualTo("O'Brien")); + Assert.That(rows.Single(x => x.Id == 1).Optional, Is.Null); + Assert.That(rows.Single(x => x.Id == 3).Optional, Is.EqualTo(9)); + var injection = "'; DROP TABLE " + table + "; --"; + Assert.That(Provider.Select(table, x => x.Payload == injection), Is.Empty); + Assert.That(Provider.Select(table).Count, Is.EqualTo(3)); + } + finally { Provider.RemoveTable(table); } + } +} diff --git a/src/Migrator/Framework/DtoSelectExtensions.cs b/src/Migrator/Framework/DtoSelectExtensions.cs new file mode 100644 index 00000000..a28466ec --- /dev/null +++ b/src/Migrator/Framework/DtoSelectExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using DotNetProjects.Migrator.Framework.TypedSelect; + +namespace DotNetProjects.Migrator.Framework; + +/// Materialized, single-table DTO queries for migrations. +public static class DtoSelectExtensions +{ + /// + /// Select public writable DTO properties by column name. Supports comparisons, boolean operators, + /// null checks and captured scalar fields; unsupported expressions throw before execution. + /// Comparisons follow database collation and SQL null semantics. + /// + public static IReadOnlyList Select(this ITransformationProvider provider, string table, + Expression> predicate = null) where T : class, new() + { + ArgumentNullException.ThrowIfNull(provider); + var mapping = DtoMapping.For(typeof(T)); + var filter = predicate == null ? null : new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); + return SelectQuery.Execute(provider, table, mapping, filter); + } +} diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index 2df1c247..19d96741 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -724,6 +724,18 @@ IDataReader SelectComplex(IDbCommand cmd, string table, string[] columns, string /// The index number of the parameter string GenerateParameterName(int index); + /// Add a query parameter in SQL occurrence order. Providers can override driver-specific binding. + void AddQueryParameter(IDbCommand command, int index, object value) + { + ArgumentNullException.ThrowIfNull(command); + var parameter = command.CreateParameter(); + parameter.ParameterName = GenerateParameterName(index); + parameter.Value = value == null ? DBNull.Value : value is Enum + ? Convert.ChangeType(value, Enum.GetUnderlyingType(value.GetType()), System.Globalization.CultureInfo.InvariantCulture) + : value; + command.Parameters.Add(parameter); + } + /// /// Remove all indexes of a table /// diff --git a/src/Migrator/Framework/TypedSelect/DtoMapping.cs b/src/Migrator/Framework/TypedSelect/DtoMapping.cs new file mode 100644 index 00000000..4fcdb5df --- /dev/null +++ b/src/Migrator/Framework/TypedSelect/DtoMapping.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Reflection; + +namespace DotNetProjects.Migrator.Framework.TypedSelect; + +internal sealed class DtoMapping +{ + private static readonly ConcurrentDictionary Cache = new(); + internal readonly MappedProperty[] Properties; + + private DtoMapping(Type type) + { + Properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetMethod?.IsPublic == true && p.SetMethod?.IsPublic == true && + p.GetIndexParameters().Length == 0 && !p.IsDefined(typeof(NotMappedAttribute))) + .Select(p => new MappedProperty(p, p.GetCustomAttribute()?.Name ?? p.Name)).ToArray(); + if (Properties.Length == 0) + throw new ArgumentException($"DTO '{type.Name}' has no public writable mapped properties."); + foreach (var property in Properties) + { + if (string.IsNullOrWhiteSpace(property.Column) || !IsScalar(property.Property.PropertyType)) + throw new ArgumentException($"DTO property '{type.Name}.{property.Property.Name}' must map to a named scalar column."); + } + if (Properties.GroupBy(p => p.Column, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1)) + throw new ArgumentException($"DTO '{type.Name}' maps multiple properties to the same column."); + } + + internal static DtoMapping For(Type type) => Cache.GetOrAdd(type, t => new DtoMapping(t)); + + internal MappedProperty Find(PropertyInfo property) => Properties.FirstOrDefault(p => p.Property == property) + ?? throw new NotSupportedException($"Property '{property.Name}' is not a mapped writable DTO property."); + + internal static Type ScalarType(Type type) => Nullable.GetUnderlyingType(type) ?? type; + + internal static bool IsScalar(Type type) + { + type = ScalarType(type); + return type.IsEnum || type == typeof(string) || type == typeof(byte[]) || type == typeof(Guid) || + type == typeof(bool) || type == typeof(byte) || type == typeof(sbyte) || type == typeof(short) || + type == typeof(ushort) || type == typeof(int) || type == typeof(uint) || type == typeof(long) || + type == typeof(ulong) || type == typeof(float) || type == typeof(double) || type == typeof(decimal) || + type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeOnly) || type == typeof(TimeSpan); + } + + internal static object ConvertValue(object value, Type target) + { + if (value == null || value == DBNull.Value) + { + if (!target.IsValueType || Nullable.GetUnderlyingType(target) != null) return null; + throw new InvalidCastException($"SQL NULL cannot be assigned to '{target.Name}'."); + } + target = ScalarType(target); + if (target.IsInstanceOfType(value)) return value; + if (target.IsEnum) + return Enum.ToObject(target, Convert.ChangeType(value, Enum.GetUnderlyingType(target), CultureInfo.InvariantCulture)); + if (target == typeof(Guid)) + return value is byte[] bytes ? new Guid(bytes) : Guid.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)); + if (target == typeof(DateTime)) + return value is string date ? DateTime.Parse(date, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + : Convert.ChangeType(value, target, CultureInfo.InvariantCulture); + if (target == typeof(DateTimeOffset)) + return value is DateTime time ? new DateTimeOffset(time) + : DateTimeOffset.Parse(Convert.ToString(value, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + if (target == typeof(TimeOnly)) + return value is TimeSpan span ? TimeOnly.FromTimeSpan(span) : value is DateTime dateTime ? TimeOnly.FromDateTime(dateTime) + : TimeOnly.Parse(Convert.ToString(value, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + if (target == typeof(TimeSpan)) + return value is long ticks ? TimeSpan.FromTicks(ticks) + : TimeSpan.Parse(Convert.ToString(value, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + return Convert.ChangeType(value, target, CultureInfo.InvariantCulture); + } +} + +internal sealed record MappedProperty(PropertyInfo Property, string Column); diff --git a/src/Migrator/Framework/TypedSelect/SelectPredicate.cs b/src/Migrator/Framework/TypedSelect/SelectPredicate.cs new file mode 100644 index 00000000..ce4053f2 --- /dev/null +++ b/src/Migrator/Framework/TypedSelect/SelectPredicate.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace DotNetProjects.Migrator.Framework.TypedSelect; + +internal abstract record SelectPredicate; +internal sealed record ComparisonPredicate(MappedProperty Property, ExpressionType Operator, object Value) : SelectPredicate; +internal sealed record LogicalPredicate(ExpressionType Operator, SelectPredicate Left, SelectPredicate Right = null) : SelectPredicate; + +internal sealed class PredicateTranslator(DtoMapping mapping, ParameterExpression parameter) +{ + internal SelectPredicate Translate(Expression expression) + { + if (expression is BinaryExpression logical && logical.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse) + return new LogicalPredicate(logical.NodeType, Translate(logical.Left), Translate(logical.Right)); + if (expression is UnaryExpression { NodeType: ExpressionType.Not } not) + return new LogicalPredicate(ExpressionType.Not, Translate(not.Operand)); + if (expression is BinaryExpression comparison && IsComparison(comparison.NodeType)) + { + if (TryProperty(comparison.Left, out var property)) + return Compare(property, comparison.NodeType, ReadValue(comparison.Right)); + if (TryProperty(comparison.Right, out property)) + return Compare(property, Reverse(comparison.NodeType), ReadValue(comparison.Left)); + } + if (TryProperty(expression, out var boolean) && boolean.Property.PropertyType == typeof(bool)) + return Compare(boolean, ExpressionType.Equal, true); + throw new NotSupportedException($"Unsupported DTO predicate '{expression}'. Use direct property comparisons and boolean operators."); + } + + internal static bool IsComparison(ExpressionType operation) => operation is ExpressionType.Equal or ExpressionType.NotEqual + or ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual; + + internal static ComparisonPredicate Compare(MappedProperty property, ExpressionType operation, object value) + { + var type = DtoMapping.ScalarType(property.Property.PropertyType); + if (type == typeof(byte[]) || (operation is not (ExpressionType.Equal or ExpressionType.NotEqual) && + (type == typeof(bool) || type == typeof(Guid) || type == typeof(string) || type.IsEnum))) + throw new NotSupportedException($"Comparison '{operation}' is not supported for '{property.Property.Name}'."); + if (value == null && operation is not (ExpressionType.Equal or ExpressionType.NotEqual)) + throw new NotSupportedException("Only equality and inequality can compare with SQL NULL."); + if (value != null && !DtoMapping.IsScalar(value.GetType())) + throw new NotSupportedException($"Predicate value type '{value.GetType().Name}' is not a scalar."); + return new ComparisonPredicate(property, operation, value); + } + + private bool TryProperty(Expression expression, out MappedProperty property) + { + // C# inserts numeric promotions (e.g. byte -> int) and enum conversions in comparisons. + while (expression is UnaryExpression { NodeType: ExpressionType.Convert } conversion && + conversion.Method == null && IsSafeConversion(conversion.Operand.Type, conversion.Type)) + expression = conversion.Operand; + if (expression is MemberExpression { Member: PropertyInfo info } member && member.Expression == parameter) + { + property = mapping.Find(info); + return true; + } + property = null; + return false; + } + + private static bool IsSafeConversion(Type source, Type target) + { + source = DtoMapping.ScalarType(source); + target = DtoMapping.ScalarType(target); + if (source == target || (source.IsEnum && Enum.GetUnderlyingType(source) == target)) return true; + return (source == typeof(byte) || source == typeof(sbyte) || source == typeof(short) || source == typeof(ushort)) && + (target == typeof(int) || target == typeof(long)) || + source == typeof(int) && target == typeof(long) || source == typeof(uint) && (target == typeof(long) || target == typeof(ulong)); + } + + private static object ReadValue(Expression expression) + { + if (expression is ConstantExpression constant) return constant.Value; + if (expression is MemberExpression { Member: FieldInfo field } member) + return field.GetValue(member.Expression == null ? null : ReadValue(member.Expression)); + if (expression is UnaryExpression { NodeType: ExpressionType.Convert, Method: null } conversion && + IsSafeConversion(conversion.Operand.Type, conversion.Type)) + { + var value = ReadValue(conversion.Operand); + if (value == null) return null; + var type = DtoMapping.ScalarType(conversion.Type); + return type.IsEnum ? Enum.ToObject(type, value) + : Convert.ChangeType(value, type, System.Globalization.CultureInfo.InvariantCulture); + } + throw new NotSupportedException($"Unsupported predicate value '{expression}'. Capture a scalar value in a local variable first."); + } + + private static ExpressionType Reverse(ExpressionType operation) => operation switch + { + ExpressionType.LessThan => ExpressionType.GreaterThan, + ExpressionType.LessThanOrEqual => ExpressionType.GreaterThanOrEqual, + ExpressionType.GreaterThan => ExpressionType.LessThan, + ExpressionType.GreaterThanOrEqual => ExpressionType.LessThanOrEqual, + _ => operation + }; +} diff --git a/src/Migrator/Framework/TypedSelect/SelectQuery.cs b/src/Migrator/Framework/TypedSelect/SelectQuery.cs new file mode 100644 index 00000000..b8b33dd7 --- /dev/null +++ b/src/Migrator/Framework/TypedSelect/SelectQuery.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Linq.Expressions; + +namespace DotNetProjects.Migrator.Framework.TypedSelect; + +internal static class SelectQuery +{ + internal static IReadOnlyList Execute(ITransformationProvider provider, string table, DtoMapping mapping, + SelectPredicate predicate) where T : class, new() + { + ArgumentException.ThrowIfNullOrWhiteSpace(table); + var values = new List(); + var sql = "SELECT " + string.Join(", ", mapping.Properties.Select(p => provider.QuoteColumnNameIfRequired(p.Column))) + + " FROM " + provider.QuoteTableNameIfRequired(table); + if (predicate != null) sql += " WHERE " + Render(provider, predicate, values); + using var command = provider.CreateCommand(); + for (var i = 0; i < values.Count; i++) provider.AddQueryParameter(command, i, values[i]); + using var reader = provider.ExecuteQuery(command, sql); + var ordinals = mapping.Properties.Select(p => reader.GetOrdinal(p.Column)).ToArray(); + var result = new List(); + while (reader.Read()) + { + var dto = new T(); + for (var i = 0; i < mapping.Properties.Length; i++) + { + var property = mapping.Properties[i]; + try + { + property.Property.SetValue(dto, DtoMapping.ConvertValue(reader.GetValue(ordinals[i]), property.Property.PropertyType)); + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException or ArgumentException) + { + throw new DataException($"Cannot map column '{property.Column}' to '{typeof(T).Name}.{property.Property.Name}' ({property.Property.PropertyType.Name}).", ex); + } + } + result.Add(dto); + } + return result; + } + + private static string Render(ITransformationProvider provider, SelectPredicate predicate, List values) + { + if (predicate is LogicalPredicate logical) + { + var left = Render(provider, logical.Left, values); + if (logical.Operator == ExpressionType.Not) return "(NOT " + left + ")"; + return "(" + left + (logical.Operator == ExpressionType.AndAlso ? " AND " : " OR ") + + Render(provider, logical.Right, values) + ")"; + } + var comparison = (ComparisonPredicate)predicate; + var column = provider.QuoteColumnNameIfRequired(comparison.Property.Column); + if (comparison.Value == null) + return "(" + column + (comparison.Operator == ExpressionType.Equal ? " IS NULL)" : " IS NOT NULL)"); + var operation = comparison.Operator switch + { + ExpressionType.Equal => " = ", ExpressionType.NotEqual => " <> ", + ExpressionType.LessThan => " < ", ExpressionType.LessThanOrEqual => " <= ", + ExpressionType.GreaterThan => " > ", ExpressionType.GreaterThanOrEqual => " >= ", + _ => throw new NotSupportedException("Unsupported comparison.") + }; + var parameter = provider.GenerateParameterName(values.Count); + values.Add(comparison.Value); + return "(" + column + operation + parameter + ")"; + } +} diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 98636584..f506f87e 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -23,6 +23,7 @@ using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Data.Common; using System.IO; +using System.Globalization; using System.Linq; using System.Text; using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; @@ -1814,6 +1815,18 @@ public virtual string GenerateParameterNameParameter(int index) return "@p" + index; } + /// Bind a typed query value using the same conversion rules as data operations. + public virtual void AddQueryParameter(IDbCommand command, int index, object value) + { + ArgumentNullException.ThrowIfNull(command); + if (value is Enum) + value = Convert.ChangeType(value, Enum.GetUnderlyingType(value.GetType()), CultureInfo.InvariantCulture); + var parameter = command.CreateParameter(); + ConfigureParameterWithValue(parameter, index, value); + parameter.ParameterName = GenerateParameterNameParameter(index); + command.Parameters.Add(parameter); + } + public virtual string GenerateParameterName(int index) { return GenerateParameterNameParameter(index); From fd1cea32b9f7bbbc8b381b61ec97815b12286c46 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:12:11 +0200 Subject: [PATCH 2/5] Resolve inherited DTO properties by their declaring type --- src/Migrator.Tests/DtoSelectTests.cs | 11 +++++++++++ src/Migrator/Framework/TypedSelect/DtoMapping.cs | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/DtoSelectTests.cs b/src/Migrator.Tests/DtoSelectTests.cs index dab88ae1..46d64d88 100644 --- a/src/Migrator.Tests/DtoSelectTests.cs +++ b/src/Migrator.Tests/DtoSelectTests.cs @@ -133,6 +133,17 @@ public void SupportsLosslessIntegralPromotions() public class QuotedDto { [DtoColumn("select")] public int Id { get; set; } } + public class BaseDto { public int Id { get; set; } } + public class DerivedDto : BaseDto { public string GenericDefinition { get; set; } } + + [Test] + public void MapsAndFiltersInheritedDtoProperties() + { + var row = provider.Select("Common_Screens", x => x.Id == 1).Single(); + Assert.That(row.Id, Is.EqualTo(1)); + Assert.That(row.GenericDefinition, Is.EqualTo("O'Brien")); + } + [Test] public void QuotesTableAndMappedColumnIdentifiers() { diff --git a/src/Migrator/Framework/TypedSelect/DtoMapping.cs b/src/Migrator/Framework/TypedSelect/DtoMapping.cs index 4fcdb5df..c50d21f9 100644 --- a/src/Migrator/Framework/TypedSelect/DtoMapping.cs +++ b/src/Migrator/Framework/TypedSelect/DtoMapping.cs @@ -32,7 +32,9 @@ private DtoMapping(Type type) internal static DtoMapping For(Type type) => Cache.GetOrAdd(type, t => new DtoMapping(t)); - internal MappedProperty Find(PropertyInfo property) => Properties.FirstOrDefault(p => p.Property == property) + // Expressions refer to the declaring type; GetProperties reflects inherited members through the DTO type. + internal MappedProperty Find(PropertyInfo property) => Properties.FirstOrDefault(p => + p.Property.DeclaringType == property.DeclaringType && p.Property.Name == property.Name) ?? throw new NotSupportedException($"Property '{property.Name}' is not a mapped writable DTO property."); internal static Type ScalarType(Type type) => Nullable.GetUnderlyingType(type) ?? type; From 1e244c3edb4dc1b39f3bfa827b386ee2773465b2 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:32:15 +0200 Subject: [PATCH 3/5] Add literal string predicates and typed DTO update/delete APIs --- README.md | 4 + docs/_src/content.py | 16 +- docs/assets/search-index.json | 2 +- docs/dto-queries.md | 42 +++- docs/guide/data.html | 16 +- src/Migrator.Tests/DtoSelectTests.cs | 22 ++- .../DtoStringAndMutationTests.cs | 186 ++++++++++++++++++ .../Live/DtoSelectIntegrationTests.cs | 7 + .../Framework/DtoMutationExtensions.cs | 71 +++++++ src/Migrator/Framework/DtoSelectExtensions.cs | 3 +- src/Migrator/Framework/IDialect.cs | 3 + src/Migrator/Framework/SqlLikePattern.cs | 12 ++ .../Framework/TypedSelect/SelectPredicate.cs | 12 +- .../Framework/TypedSelect/SelectQuery.cs | 11 +- src/Migrator/Providers/Dialect.cs | 3 + .../Impl/SqlServer/SqlServerDialect.cs | 2 + .../Providers/Impl/Sybase/SybaseDialect.cs | 2 + 17 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 src/Migrator.Tests/DtoStringAndMutationTests.cs create mode 100644 src/Migrator/Framework/DtoMutationExtensions.cs create mode 100644 src/Migrator/Framework/SqlLikePattern.cs diff --git a/README.md b/README.md index ac850b38..267a6024 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,10 @@ x => !x.Archive && x.Id > minimumId)`. This returns a materialized list, quotes identifiers, parameterizes values and uses the active migration transaction. See [DTO queries](docs/dto-queries.md) for column mapping, supported predicates and SQL null semantics. +Predicates also support literal string `Contains`, `StartsWith` and `EndsWith`. +Typed `Update(table, changes, predicate)` updates only properties assigned in +a DTO initializer; `Delete(table, predicate)` removes matching rows. Both +require predicates, retain the migration transaction and return affected counts. Supported rebuilds retain mapped data, named/composite keys, declared column collations, supported indexes and triggers, and the AUTOINCREMENT high-water mark. Generated columns, `STRICT`, `WITHOUT ROWID` and indexes with explicit collations are rejected for reconstruction; hidden rowid values are not preserved. Foreign keys retain separate update/delete actions; unsupported `MATCH FULL`/`PARTIAL` requests fail explicitly. See the [SQLite preservation matrix](docs/migration-framework-comparison.md#what-survives-reconstructionand-what-is-not-guaranteed). diff --git a/docs/_src/content.py b/docs/_src/content.py index 557f500d..d08f0781 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -196,7 +196,7 @@ 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("Read DTOs", '

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

', pair("Read screens using a typed predicate", ''' + section("Read DTOs", '

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition, null checks and literal string Contains/StartsWith/EndsWith searches. Update<T> changes only initializer-assigned properties; Delete<T> removes matching rows. Both mutations require a predicate and return affected row counts. Other methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

', pair("Read screens using a typed predicate", ''' public class ReadScreens : Migration { public sealed class ScreenDto @@ -210,6 +210,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ var minimumId = 10; var screens = Database.Select( "Common_Screens", x => !x.Archive && x.Id > minimumId); + var matching = Database.Select("Common_Screens", + x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains("aa")); + int updated = Database.Update("Common_Screens", + x => new ScreenDto { Archive = true }, + x => x.GenericDefinition.StartsWith("old")); + int deleted = Database.Delete("Common_Screens", + x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); } public override void Down() { } } @@ -227,6 +234,13 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ var minimumId = 10; var screens = Context.Select( "Common_Screens", x => !x.Archive && x.Id > minimumId); + var matching = Context.Select("Common_Screens", + x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains("aa")); + int updated = Context.Update("Common_Screens", + x => new ScreenDto { Archive = true }, + x => x.GenericDefinition.StartsWith("old")); + int deleted = Context.Delete("Common_Screens", + x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); } public override void BuildDown(MigrationBuilder migration) { } } diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index 2d019777..1c37d494 100644 --- a/docs/assets/search-index.json +++ b/docs/assets/search-index.json @@ -53,7 +53,7 @@ "group": "Operations", "summary": "Insert, update and delete using explicit column/value arrays. Keep predicates separate from changed values.", "url": "guide/data.html", - "text": " Select(table, predicate) reads a single table into a materialized IReadOnlyList, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration. Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits. Read screens using a typed predicate using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\npublic class ReadScreens : Migration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void Up()\n {\n var minimumId = 10;\n var screens = Database.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n }\n public override void Down() { }\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\npublic class ReadScreens : FluentMigration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void BuildUp(MigrationBuilder migration)\n {\n var minimumId = 10;\n var screens = Context.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n }\n public override void BuildDown(MigrationBuilder migration) { }\n} Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns. The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected. Keep one assignment per role/group pair int removed = Database.DeleteDuplicateRows(\"Assignments\",\n new[] { \"RoleId\", \"GroupId\" }, DuplicateRowRetention.Any); migration.Delete.DuplicateRows().FromTable(\"Assignments\")\n .ByColumns(\"RoleId\", \"GroupId\").KeepAny(); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" + "text": " Select(table, predicate) reads a single table into a materialized IReadOnlyList, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration. Predicates support direct comparisons, boolean composition, null checks and literal string Contains/StartsWith/EndsWith searches. Update changes only initializer-assigned properties; Delete removes matching rows. Both mutations require a predicate and return affected row counts. Other methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits. Read screens using a typed predicate using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\n\npublic class ReadScreens : Migration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void Up()\n {\n var minimumId = 10;\n var screens = Database.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n var matching = Database.Select (\"Common_Screens\",\n x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains(\"aa\"));\n int updated = Database.Update (\"Common_Screens\",\n x => new ScreenDto { Archive = true },\n x => x.GenericDefinition.StartsWith(\"old\"));\n int deleted = Database.Delete (\"Common_Screens\",\n x => x.Archive && x.GenericDefinition.EndsWith(\".obsolete\"));\n }\n public override void Down() { }\n} using System;\nusing System.Data;\nusing DotNetProjects.Migrator;\nusing DotNetProjects.Migrator.Framework;\nusing DotNetProjects.Migrator.Framework.Fluent;\n\npublic class ReadScreens : FluentMigration\n{\n public sealed class ScreenDto\n {\n public int Id { get; set; }\n public bool Archive { get; set; }\n public string GenericDefinition { get; set; }\n }\n public override void BuildUp(MigrationBuilder migration)\n {\n var minimumId = 10;\n var screens = Context.Select (\n \"Common_Screens\", x => !x.Archive && x.Id > minimumId);\n var matching = Context.Select (\"Common_Screens\",\n x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains(\"aa\"));\n int updated = Context.Update (\"Common_Screens\",\n x => new ScreenDto { Archive = true },\n x => x.GenericDefinition.StartsWith(\"old\"));\n int deleted = Context.Delete (\"Common_Screens\",\n x => x.Archive && x.GenericDefinition.EndsWith(\".obsolete\"));\n }\n public override void BuildDown(MigrationBuilder migration) { }\n} Column and value arrays must have the same length. The provider binds values using its driver-specific parameter mappings. For multiple rows issue multiple Insert.IntoTable(...).Row(...) expressions. Each Row completes one insert; its returned builder offers only IfNotExists, so a second Row cannot silently replace the first. Insert, update and delete each expose only their supported steps. Insert a user Database.Insert(\"Users\", new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" }); Classic update/delete without a predicate affects every row. Fluent update/delete requires Where(...) or an explicit AllRows() to complete the operation. Empty predicate arrays are rejected; an unfinished chain fails during Build, Apply or Preview before any queued operation executes. Fluent WhereSql is available for updates only; its text is trusted SQL, not an escaped user input. Update one user Database.Update(\"Users\", new[] { \"Name\" }, new object[] { \"Ada Lovelace\" },\n new[] { \"Id\" }, new object[] { 1 }); migration.Update.Table(\"Users\")\n .Set(new[] { \"Name\" }, new object[] { \"Ada Lovelace\" })\n .Where(new[] { \"Id\" }, new object[] { 1 }); Delete one user Database.Delete(\"Users\", new[] { \"Id\" }, new object[] { 1 }); migration.Delete.FromTable(\"Users\").Where(new[] { \"Id\" }, new object[] { 1 }); DeleteDuplicateRows keeps one arbitrary row per composite key using database equality and collation. It supports SQLite ordinary rowid tables, PostgreSQL, Oracle ROWID tables and SQL Server. NULL keys compare equal by default; pass DuplicateNullHandling.ExcludeNullKeys to leave rows with any NULL key untouched. The direct API returns the database-reported affected-row count. Non-key values do not influence the survivor. Keys must be non-empty, distinct existing columns. The operation executes one DELETE in the existing transaction without changing the schema. Normal DELETE triggers and foreign-key rules apply. It does not prevent concurrent or future duplicates: coordinate writers and add an appropriate unique constraint separately. Deleted data cannot be automatically reversed. SQL preview is unsupported because safe physical row identity selection requires live metadata. Unsupported providers and SQLite tables without an accessible rowid are rejected. Keep one assignment per role/group pair int removed = Database.DeleteDuplicateRows(\"Assignments\",\n new[] { \"RoleId\", \"GroupId\" }, DuplicateRowRetention.Any); migration.Delete.DuplicateRows().FromTable(\"Assignments\")\n .ByColumns(\"RoleId\", \"GroupId\").KeepAny(); Use an explicit identifying predicate when a seed should exist only once. This is distinct from a migration version: a named profile can run repeatedly without a history entry. Coordinate competing writers; a check-then-insert helper is not a substitute for a database unique key. Insert a missing seed Database.InsertIfNotExists(\"Users\", new[] { \"Id\", \"Name\" },\n new object[] { 1, \"Ada\" }, new[] { \"Id\" }, new object[] { 1 }); migration.Insert.IntoTable(\"Users\")\n .Row(new[] { \"Id\", \"Name\" }, new object[] { 1, \"Ada\" })\n .IfNotExists(new[] { \"Id\" }, new object[] { 1 }); Use the provider CopyDataFromTableToTable helper or fluent Execute.CopyDataFromTable(...).ToTable(...).WithColumns(...) for named-column copies. Both tables must already exist and target columns must accept the source values. Execute.UpdateTable(target).FromTable(source).Set(copyPairs).Match(keyPairs) maps source/target pairs. CopyDataFromTable also supports OrderBy after WithColumns. These operations retain provider limits and are outside the SQL-preview subset. A reverse data migration needs authored recovery logic; auto-reversal cannot recreate deleted or overwritten values. Copy users into an archive table Database.CopyDataFromTableToTable(\"Users\",\n new System.Collections.Generic.List { \"Id\", \"Name\" }, \"ArchivedUsers\",\n new System.Collections.Generic.List { \"UserId\", \"DisplayName\" }); migration.Execute.CopyDataFromTable(\"Users\").ToTable(\"ArchivedUsers\")\n .WithColumns(new[] { \"Id\", \"Name\" }, new[] { \"UserId\", \"DisplayName\" });" }, { "title": "Schema inspection", diff --git a/docs/dto-queries.md b/docs/dto-queries.md index ec5dcf0a..fb5975dc 100644 --- a/docs/dto-queries.md +++ b/docs/dto-queries.md @@ -47,14 +47,29 @@ format and overflow errors. It does not track entities or load related objects. - `&&`, `||`, `!`, grouping, and direct boolean properties such as `x.Active`. - `== null` and `!= null`, translated to `IS NULL` and `IS NOT NULL`. - Numeric enum equality and lossless integral promotions inserted by C#. +- String `Contains(string)`, `StartsWith(string)` and `EndsWith(string)` on direct + mapped properties, with constants or captured strings. Search values are + parameterized and LIKE wildcard/escape characters are treated literally. Byte-array comparisons and ordering of strings, booleans, GUIDs and enums are -unsupported. Methods, property-to-property comparisons, arithmetic, navigation +unsupported. Other methods, property-to-property comparisons, arithmetic, navigation properties, lossy casts, joins and general LINQ operators are unsupported and throw before executing a query. Capture computed values in local scalar variables first; the translator reads constants and fields without compiling a lambda or executing arbitrary getters/methods. +```csharp +var matching = Database.Select("Common_Screens", + x => (!x.Archive && x.Id > 4) || x.Definition.Contains("aa")); +``` + +String searches follow database collation, not .NET ordinal matching. NULL column +values are excluded, including under negation; empty search text matches every +non-NULL value. Null search text throws before execution. Character and +`StringComparison` overloads are unsupported. SQL Server and Sybase bracket +wildcards are escaped through their dialects; other dialects escape `%`, `_` and +the explicit `~` escape character. + Predicates use SQL null semantics and database collation. For example, `x.Optional != 7` excludes NULL rows; use `x.Optional == null || x.Optional != 7` to include them. There is no implicit @@ -71,3 +86,28 @@ unspecified; sorting after materialization is explicit client-side work. In a `FluentMigration`, call `Context.Select` for immediate reads. Reads during `BuildUp` cannot see schema operations merely queued in that same builder. + +## Typed updates and deletes + +Both mutation APIs require a predicate and return the driver-reported affected +row count. They reuse the selection predicates, including string searches. + +```csharp +int updated = Database.Update("Common_Screens", + x => new ScreenDto { Archive = true, Definition = "archived" }, + x => x.Id > 4 && x.Definition.StartsWith("old")); + +int deleted = Database.Delete("Common_Screens", + x => x.Archive && x.Definition.EndsWith(".obsolete")); +``` + +Only initializer-assigned properties are updated; unassigned properties keep +their existing values. Explicit null assignments render SQL NULL. Initializer +values must be constants or captured scalar fields. Empty initializers, +row-dependent calculations, constructor arguments, and unmapped assignments are +rejected before execution. The DTO is not constructed and its setters are not +executed during translation. Null predicates are rejected and there are no +predicate-free typed mutation overloads. Commands are disposed on success and +failure; native parameter conversions, positional parameter order, command +timeouts and active migration transactions are retained. In fluent migrations, +use `Context.Update` / `Context.Delete` for immediate operations. diff --git a/docs/guide/data.html b/docs/guide/data.html index 9be66206..01e4d8de 100644 --- a/docs/guide/data.html +++ b/docs/guide/data.html @@ -5,7 +5,7 @@ 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.

Read DTOs

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition and null checks. Unsupported methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

Read screens using a typed predicate
+

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.

Read DTOs

Select<T>(table, predicate) reads a single table into a materialized IReadOnlyList<T>, binds predicate values as native driver parameters and disposes the command and reader. DTOs need a public parameterless constructor and public readable/writable scalar properties. ColumnAttribute can override names; NotMappedAttribute excludes properties. Keep DTOs with their migration.

Predicates support direct comparisons, boolean composition, null checks and literal string Contains/StartsWith/EndsWith searches. Update<T> changes only initializer-assigned properties; Delete<T> removes matching rows. Both mutations require a predicate and return affected row counts. Other methods, joins and general LINQ operations fail explicitly. Comparisons follow database collation and SQL null semantics: Optional != 7 excludes NULL rows. Context.Select executes immediately during fluent authoring and cannot see queued operations. See the DTO query reference for mapping and translation limits.

Read screens using a typed predicate

Classic

using System;
 using System.Data;
@@ -25,6 +25,13 @@
         var minimumId = 10;
         var screens = Database.Select<ScreenDto>(
             "Common_Screens", x => !x.Archive && x.Id > minimumId);
+        var matching = Database.Select<ScreenDto>("Common_Screens",
+            x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains("aa"));
+        int updated = Database.Update<ScreenDto>("Common_Screens",
+            x => new ScreenDto { Archive = true },
+            x => x.GenericDefinition.StartsWith("old"));
+        int deleted = Database.Delete<ScreenDto>("Common_Screens",
+            x => x.Archive && x.GenericDefinition.EndsWith(".obsolete"));
     }
     public override void Down() { }
 }
@@ -48,6 +55,13 @@ var minimumId = 10; var screens = Context.Select<ScreenDto>( "Common_Screens", x => !x.Archive && x.Id > minimumId); + var matching = Context.Select<ScreenDto>("Common_Screens", + x => (!x.Archive && x.Id > 4) || x.GenericDefinition.Contains("aa")); + int updated = Context.Update<ScreenDto>("Common_Screens", + x => new ScreenDto { Archive = true }, + x => x.GenericDefinition.StartsWith("old")); + int deleted = Context.Delete<ScreenDto>("Common_Screens", + x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); } public override void BuildDown(MigrationBuilder migration) { } }

Choose one authoring style

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
diff --git a/src/Migrator.Tests/DtoSelectTests.cs b/src/Migrator.Tests/DtoSelectTests.cs index 46d64d88..72a6e038 100644 --- a/src/Migrator.Tests/DtoSelectTests.cs +++ b/src/Migrator.Tests/DtoSelectTests.cs @@ -174,7 +174,7 @@ public void RejectsUnsupportedPredicatesBeforeCreatingACommand(string kind) var database = Substitute.For(); Expression> predicate = kind switch { - "method" => x => x.Definition.StartsWith("O"), + "method" => x => x.Definition.ToLower() == "o", "column-column" => x => x.Id == x.Optional, "arithmetic" => x => x.Id + 1 == 2, "unmapped" => x => x.Display == "1", @@ -246,11 +246,17 @@ public class DtoSelectBindingTests { public class Row { public int Id { get; set; } public bool Active { get; set; } } - [TestCase("oracle", ":p0", "p0", "p1", 1)] - [TestCase("hana", "?", "p0", "p1", true)] - [TestCase("informix", "?", "@p0", "@p1", true)] + [TestCase("oracle", ":p0", "p0", "p1", 1, "select")] + [TestCase("hana", "?", "p0", "p1", true, "select")] + [TestCase("informix", "?", "@p0", "@p1", true, "select")] + [TestCase("oracle", ":p0", "p0", "p1", 1, "update")] + [TestCase("hana", "?", "p0", "p1", true, "update")] + [TestCase("informix", "?", "@p0", "@p1", true, "update")] + [TestCase("oracle", ":p0", "p0", "p1", 1, "delete")] + [TestCase("hana", "?", "p0", "p1", true, "delete")] + [TestCase("informix", "?", "@p0", "@p1", true, "delete")] public void PreservesProviderParameterNamesConversionsAndOccurrenceOrder(string kind, string placeholder, - string firstName, string secondName, object boolean) + string firstName, string secondName, object boolean, string operation) { var connection = Substitute.For(); connection.State.Returns(ConnectionState.Open); @@ -269,7 +275,9 @@ public void PreservesProviderParameterNamesConversionsAndOccurrenceOrder(string "hana" => new HanaTransformationProvider(new HanaDialect(), connection, null, null), _ => new InformixTransformationProvider(new InformixDialect(), connection, null, null) }; - provider.Select("Rows", x => x.Id > 5 && x.Active); + if (operation == "update") provider.Update("Rows", x => new Row { Id = 5 }, x => x.Active); + else if (operation == "delete") provider.Delete("Rows", x => x.Id > 5 && x.Active); + else provider.Select("Rows", x => x.Id > 5 && x.Active); Assert.Multiple(() => { Assert.That(command.CommandText, Does.Contain(placeholder)); @@ -278,7 +286,7 @@ public void PreservesProviderParameterNamesConversionsAndOccurrenceOrder(string Assert.That(holder.Parameters[0].Value, Is.EqualTo(5)); Assert.That(holder.Parameters[1].ParameterName, Is.EqualTo(secondName)); Assert.That(holder.Parameters[1].Value, Is.EqualTo(boolean)); - Assert.That(reader.IsClosed, Is.True); + if (operation == "select") Assert.That(reader.IsClosed, Is.True); }); command.Received().Dispose(); } diff --git a/src/Migrator.Tests/DtoStringAndMutationTests.cs b/src/Migrator.Tests/DtoStringAndMutationTests.cs new file mode 100644 index 00000000..20955150 --- /dev/null +++ b/src/Migrator.Tests/DtoStringAndMutationTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using DotNetProjects.Migrator.Providers.Impl.Sybase; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class DtoStringAndMutationTests +{ + public class Row + { + public int Id { get; set; } + public bool Archive { get; set; } + [DtoColumn("select")] public string Text { get; set; } + public int? Optional { get; set; } + } + private SqliteConnection connection; + private SQLiteTransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + provider.ExecuteNonQuery("CREATE TABLE \"typed rows\" (Id INTEGER, Archive INTEGER, \"select\" TEXT, Optional INTEGER)"); + var columns = new[] { "Id", "Archive", "select", "Optional" }; + provider.Insert("typed rows", columns, new object[] { 1, false, "alpha aa omega", 7 }); + provider.Insert("typed rows", columns, new object[] { 2, true, "other", 9 }); + provider.Insert("typed rows", columns, new object[] { 3, false, null, null }); + } + + [TearDown] public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [TestCase("contains")] + [TestCase("starts")] + [TestCase("ends")] + public void SupportsStringFunctionsAndCapturedText(string kind) + { + var needle = kind == "contains" ? "aa" : kind == "starts" ? "alpha" : "omega"; + Expression> predicate = kind switch + { + "contains" => x => x.Text.Contains(needle), + "starts" => x => x.Text.StartsWith(needle), + _ => x => x.Text.EndsWith(needle) + }; + Assert.That(provider.Select("typed rows", predicate).Single().Id, Is.EqualTo(1)); + } + + [TestCase("%")] + [TestCase("_")] + [TestCase("~")] + [TestCase("[ab]")] + [TestCase("\\")] + [TestCase("'; DROP TABLE anything; --")] + public void StringSearchTreatsWildcardsEscapeCharactersAndSqlTextLiterally(string needle) + { + provider.Update("typed rows", x => new Row { Text = needle }, x => x.Id == 1); + Assert.That(provider.Select("typed rows", x => x.Text.Contains(needle)).Single().Id, Is.EqualTo(1)); + Assert.That(provider.Select("typed rows", x => x.Text.StartsWith(needle)).Single().Id, Is.EqualTo(1)); + Assert.That(provider.Select("typed rows", x => x.Text.EndsWith(needle)).Single().Id, Is.EqualTo(1)); + Assert.That(provider.Select("typed rows").Count, Is.EqualTo(3)); + } + + [Test] + public void StringFunctionsComposeWithBooleanLogicAndKeepSqlNullSemantics() + { + Assert.That(provider.Select("typed rows", x => (!x.Archive && x.Id > 4) || x.Text.Contains("aa")).Single().Id, Is.EqualTo(1)); + Assert.That(provider.Select("typed rows", x => !x.Text.Contains("aa")).Single().Id, Is.EqualTo(2)); + Assert.That(provider.Select("typed rows", x => x.Text.Contains("")).Select(x => x.Id), Is.EquivalentTo(new[] { 1, 2 })); + Assert.That(provider.Select("typed rows", x => x.Text.StartsWith("")).Count, Is.EqualTo(2)); + Assert.That(provider.Select("typed rows", x => x.Text.EndsWith("")).Count, Is.EqualTo(2)); + } + + [TestCase("char")] + [TestCase("comparison")] + [TestCase("nested")] + [TestCase("column")] + public void UnsupportedStringOverloadsFailBeforeCommandCreation(string kind) + { + var database = Substitute.For(); + Expression> predicate = kind switch + { + "char" => x => x.Text.Contains('a'), + "comparison" => x => x.Text.Contains("aa", StringComparison.Ordinal), + "nested" => x => x.Text.ToLower().Contains("aa"), + _ => x => x.Text.Contains(x.Text) + }; + Assert.Throws(() => database.Select("typed rows", predicate)); + database.DidNotReceive().CreateCommand(); + } + + [Test] + public void NullSearchTextFailsBeforeCommandCreation() + { + var database = Substitute.For(); string needle = null; + Assert.Throws(() => database.Select("typed rows", x => x.Text.Contains(needle))); + database.DidNotReceive().CreateCommand(); + } + + [Test] + public void UpdateChangesOnlyInitializedPropertiesAndReturnsAffectedRows() + { + var newText = "O'Brien"; + Assert.That(provider.Update("typed rows", x => new Row { Text = newText, Archive = true, Optional = null }, + x => x.Text.StartsWith("alpha")), Is.EqualTo(1)); + var row = provider.Select("typed rows", x => x.Id == 1).Single(); + Assert.That(row.Text, Is.EqualTo(newText)); Assert.That(row.Archive, Is.True); Assert.That(row.Optional, Is.Null); + Assert.That(row.Id, Is.EqualTo(1)); + Assert.That(provider.Select("typed rows", x => x.Id == 2).Single().Text, Is.EqualTo("other")); + Assert.That(provider.Update("typed rows", x => new Row { Archive = false }, x => x.Id == 999), Is.Zero); + } + + [Test] + public void DeleteUsesGroupedPredicatesAndReturnsAffectedRows() + { + Assert.That(provider.Delete("typed rows", x => (x.Id > 4 && x.Archive) || x.Text.Contains("aa")), Is.EqualTo(1)); + Assert.That(provider.Select("typed rows").Select(x => x.Id), Is.EquivalentTo(new[] { 2, 3 })); + Assert.That(provider.Delete("typed rows", x => x.Id == 999), Is.Zero); + } + + [Test] + public void UpdatesAndDeletesParticipateInTheMigrationTransaction() + { + provider.BeginTransaction(); + provider.Update("typed rows", x => new Row { Text = "changed" }, x => x.Id == 1); + provider.Delete("typed rows", x => x.Id == 2); + Assert.That(provider.Select("typed rows").Count, Is.EqualTo(2)); + provider.Rollback(); + Assert.That(provider.Select("typed rows").Count, Is.EqualTo(3)); + Assert.That(provider.Select("typed rows", x => x.Id == 1).Single().Text, Is.EqualTo("alpha aa omega")); + } + + [TestCase("empty")] + [TestCase("identity")] + [TestCase("row-dependent")] + public void InvalidUpdateAssignmentsFailBeforeCommandCreation(string kind) + { + var database = Substitute.For(); + Expression> changes = kind switch + { + "empty" => x => new Row(), + "identity" => x => x, + _ => x => new Row { Id = x.Id + 1 } + }; + Assert.Throws(() => database.Update("typed rows", changes, x => x.Id == 1)); + database.DidNotReceive().CreateCommand(); + } + + [Test] + public void MutationsRequirePredicatesAndDisposeCommandsOnFailure() + { + var database = Substitute.For(); + Assert.Throws(() => database.Delete("typed rows", null)); + Assert.Throws(() => database.Update("typed rows", x => new Row { Id = 1 }, null)); + database.DidNotReceive().CreateCommand(); + var command = Substitute.For(); + database.CreateCommand().Returns(command); + database.QuoteTableNameIfRequired(Arg.Any()).Returns(x => (string)x[0]); + database.QuoteColumnNameIfRequired(Arg.Any()).Returns(x => (string)x[0]); + database.GenerateParameterName(Arg.Any()).Returns(x => "@p" + x[0]); + command.ExecuteNonQuery().Returns(_ => throw new InvalidOperationException("driver failure")); + Assert.Throws(() => database.Delete("typed rows", x => x.Id == 1)); + command.Received().Dispose(); + } +} + +public class DtoLikeEscapingTests +{ + [Test] + public void DialectsEscapeOnlyTheirOwnWildcards() + { + Assert.That(new OracleDialect().EscapeLikePattern("%_~[a]"), Is.EqualTo("~%~_~~[a]")); + Assert.That(new SQLiteDialect().EscapeLikePattern("%_~[a]"), Is.EqualTo("~%~_~~[a]")); + Assert.That(new SqlServerDialect().EscapeLikePattern("%_~[a]"), Is.EqualTo("~%~_~~~[a]")); + Assert.That(new SybaseDialect().EscapeLikePattern("%_~[a]"), Is.EqualTo("~%~_~~~[a]")); + } +} diff --git a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs index 1858f000..07932a7a 100644 --- a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs +++ b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs @@ -56,6 +56,13 @@ public void MapsDtosAndFiltersUsingNativeDriverParameters() var injection = "'; DROP TABLE " + table + "; --"; Assert.That(Provider.Select(table, x => x.Payload == injection), Is.Empty); Assert.That(Provider.Select(table).Count, Is.EqualTo(3)); + var literal = "literal%_~[b]\\tail"; + Assert.That(Provider.Update(table, x => new Row { Payload = literal, Optional = null }, x => x.Id == 3), Is.EqualTo(1)); + Assert.That(Provider.Select(table, x => x.Payload.Contains("%_~[b]\\")).Single().Id, Is.EqualTo(3)); + Assert.That(Provider.Select(table, x => x.Payload.StartsWith("literal%_")).Single().Id, Is.EqualTo(3)); + Assert.That(Provider.Select(table, x => x.Payload.EndsWith("[b]\\tail")).Single().Id, Is.EqualTo(3)); + Assert.That(Provider.Delete(table, x => x.Payload.Contains("%_~") && x.Id == 3), Is.EqualTo(1)); + Assert.That(Provider.Select(table).Count, Is.EqualTo(2)); } finally { Provider.RemoveTable(table); } } diff --git a/src/Migrator/Framework/DtoMutationExtensions.cs b/src/Migrator/Framework/DtoMutationExtensions.cs new file mode 100644 index 00000000..1949d126 --- /dev/null +++ b/src/Migrator/Framework/DtoMutationExtensions.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using DotNetProjects.Migrator.Framework.TypedSelect; + +namespace DotNetProjects.Migrator.Framework; + +/// Parameterized DTO updates and deletes with required predicates. +public static class DtoMutationExtensions +{ + /// Delete matching rows using the same predicates as Select<T>. A predicate is required. + public static int Delete(this ITransformationProvider provider, string table, + Expression> predicate) where T : class, new() + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(predicate); + var mapping = DtoMapping.For(typeof(T)); + var filter = new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); + var values = new List(); + var sql = "DELETE FROM " + provider.QuoteTableNameIfRequired(table) + " WHERE " + SelectQuery.Render(provider, filter, values); + return Execute(provider, sql, values); + } + + /// + /// Update only properties assigned in a DTO initializer, using constants/captured scalar values. + /// Row-dependent assignments and empty initializers are unsupported. A predicate is required. + /// + public static int Update(this ITransformationProvider provider, string table, + Expression> changes, Expression> predicate) where T : class, new() + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentException.ThrowIfNullOrWhiteSpace(table); + ArgumentNullException.ThrowIfNull(changes); + ArgumentNullException.ThrowIfNull(predicate); + var mapping = DtoMapping.For(typeof(T)); + var filter = new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); + if (changes.Body is not MemberInitExpression initializer || initializer.NewExpression.Arguments.Count != 0 || initializer.Bindings.Count == 0) + throw new NotSupportedException("Use a non-empty DTO initializer with a parameterless constructor for update assignments."); + var assignments = new List(); + var values = new List(); + var assigned = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var binding in initializer.Bindings) + { + if (binding is not MemberAssignment assignment || binding.Member is not PropertyInfo property) + throw new NotSupportedException("Update assignments must target mapped writable DTO properties."); + var mapped = mapping.Find(property); + if (!assigned.Add(mapped.Column)) throw new ArgumentException($"Column '{mapped.Column}' is assigned more than once.", nameof(changes)); + var value = PredicateTranslator.ReadValue(assignment.Expression); + if (value != null && !DtoMapping.IsScalar(value.GetType())) + throw new NotSupportedException("Update values must be scalar constants or captured fields."); + var parameter = value == null ? "NULL" : provider.GenerateParameterName(values.Count); + if (value != null) values.Add(value); + assignments.Add(provider.QuoteColumnNameIfRequired(mapped.Column) + " = " + parameter); + } + var sql = "UPDATE " + provider.QuoteTableNameIfRequired(table) + " SET " + string.Join(", ", assignments) + + " WHERE " + SelectQuery.Render(provider, filter, values); + return Execute(provider, sql, values); + } + + private static int Execute(ITransformationProvider provider, string sql, List values) + { + using var command = provider.CreateCommand(); + command.CommandText = sql; + for (var i = 0; i < values.Count; i++) provider.AddQueryParameter(command, i, values[i]); + provider.Logger?.Trace(sql); + provider.Logger?.ApplyingDBChange(sql); + return command.ExecuteNonQuery(); + } +} diff --git a/src/Migrator/Framework/DtoSelectExtensions.cs b/src/Migrator/Framework/DtoSelectExtensions.cs index a28466ec..447cc732 100644 --- a/src/Migrator/Framework/DtoSelectExtensions.cs +++ b/src/Migrator/Framework/DtoSelectExtensions.cs @@ -10,7 +10,8 @@ public static class DtoSelectExtensions { /// /// Select public writable DTO properties by column name. Supports comparisons, boolean operators, - /// null checks and captured scalar fields; unsupported expressions throw before execution. + /// null checks, string Contains/StartsWith/EndsWith and captured scalar fields; + /// unsupported expressions throw before execution. /// Comparisons follow database collation and SQL null semantics. /// public static IReadOnlyList Select(this ITransformationProvider provider, string table, diff --git a/src/Migrator/Framework/IDialect.cs b/src/Migrator/Framework/IDialect.cs index b46df7e3..5fff2ff5 100644 --- a/src/Migrator/Framework/IDialect.cs +++ b/src/Migrator/Framework/IDialect.cs @@ -4,6 +4,9 @@ namespace DotNetProjects.Migrator.Framework; public interface IDialect { + /// Escape literal text for a LIKE pattern using '~' as the SQL ESCAPE character. + string EscapeLikePattern(string value) => SqlLikePattern.Escape(value); + string QuoteIdentifier(string name); string GetCollationSql(string name); string GetCollationSql(Collation collation); diff --git a/src/Migrator/Framework/SqlLikePattern.cs b/src/Migrator/Framework/SqlLikePattern.cs new file mode 100644 index 00000000..d39652d6 --- /dev/null +++ b/src/Migrator/Framework/SqlLikePattern.cs @@ -0,0 +1,12 @@ +using System; + +namespace DotNetProjects.Migrator.Framework; + +internal static class SqlLikePattern +{ + internal static string Escape(string value) + { + ArgumentNullException.ThrowIfNull(value); + return value.Replace("~", "~~").Replace("%", "~%").Replace("_", "~_"); + } +} diff --git a/src/Migrator/Framework/TypedSelect/SelectPredicate.cs b/src/Migrator/Framework/TypedSelect/SelectPredicate.cs index ce4053f2..0bbac593 100644 --- a/src/Migrator/Framework/TypedSelect/SelectPredicate.cs +++ b/src/Migrator/Framework/TypedSelect/SelectPredicate.cs @@ -7,6 +7,7 @@ namespace DotNetProjects.Migrator.Framework.TypedSelect; internal abstract record SelectPredicate; internal sealed record ComparisonPredicate(MappedProperty Property, ExpressionType Operator, object Value) : SelectPredicate; internal sealed record LogicalPredicate(ExpressionType Operator, SelectPredicate Left, SelectPredicate Right = null) : SelectPredicate; +internal sealed record StringPredicate(MappedProperty Property, string Method, string Value) : SelectPredicate; internal sealed class PredicateTranslator(DtoMapping mapping, ParameterExpression parameter) { @@ -16,6 +17,15 @@ internal SelectPredicate Translate(Expression expression) return new LogicalPredicate(logical.NodeType, Translate(logical.Left), Translate(logical.Right)); if (expression is UnaryExpression { NodeType: ExpressionType.Not } not) return new LogicalPredicate(ExpressionType.Not, Translate(not.Operand)); + if (expression is MethodCallExpression call && call.Method.DeclaringType == typeof(string) && + call.Method.Name is nameof(string.Contains) or nameof(string.StartsWith) or nameof(string.EndsWith) && + call.Arguments.Count == 1 && call.Method.GetParameters()[0].ParameterType == typeof(string) && + TryProperty(call.Object, out var textProperty)) + { + var text = (string)ReadValue(call.Arguments[0]); + ArgumentNullException.ThrowIfNull(text); + return new StringPredicate(textProperty, call.Method.Name, text); + } if (expression is BinaryExpression comparison && IsComparison(comparison.NodeType)) { if (TryProperty(comparison.Left, out var property)) @@ -69,7 +79,7 @@ private static bool IsSafeConversion(Type source, Type target) source == typeof(int) && target == typeof(long) || source == typeof(uint) && (target == typeof(long) || target == typeof(ulong)); } - private static object ReadValue(Expression expression) + internal static object ReadValue(Expression expression) { if (expression is ConstantExpression constant) return constant.Value; if (expression is MemberExpression { Member: FieldInfo field } member) diff --git a/src/Migrator/Framework/TypedSelect/SelectQuery.cs b/src/Migrator/Framework/TypedSelect/SelectQuery.cs index b8b33dd7..9d83402b 100644 --- a/src/Migrator/Framework/TypedSelect/SelectQuery.cs +++ b/src/Migrator/Framework/TypedSelect/SelectQuery.cs @@ -41,7 +41,7 @@ internal static IReadOnlyList Execute(ITransformationProvider provider, st return result; } - private static string Render(ITransformationProvider provider, SelectPredicate predicate, List values) + internal static string Render(ITransformationProvider provider, SelectPredicate predicate, List values) { if (predicate is LogicalPredicate logical) { @@ -50,6 +50,15 @@ private static string Render(ITransformationProvider provider, SelectPredicate p return "(" + left + (logical.Operator == ExpressionType.AndAlso ? " AND " : " OR ") + Render(provider, logical.Right, values) + ")"; } + if (predicate is StringPredicate text) + { + var pattern = provider.Dialect.EscapeLikePattern(text.Value); + if (text.Method != nameof(string.StartsWith)) pattern = "%" + pattern; + if (text.Method != nameof(string.EndsWith)) pattern += "%"; + var name = provider.GenerateParameterName(values.Count); + values.Add(pattern); + return "(" + provider.QuoteColumnNameIfRequired(text.Property.Column) + " LIKE " + name + " ESCAPE '~')"; + } var comparison = (ComparisonPredicate)predicate; var column = provider.QuoteColumnNameIfRequired(comparison.Property.Column); if (comparison.Value == null) diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index 6cf21442..ed135f45 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -15,6 +15,9 @@ namespace DotNetProjects.Migrator.Providers; /// public abstract class Dialect : IDialect { + /// Escape literal search text; SQL rendering uses ESCAPE '~'. + public virtual string EscapeLikePattern(string value) => SqlLikePattern.Escape(value); + private readonly Dictionary _propertyMap = []; private readonly HashSet _reservedWords = []; private readonly TypeNames _typeNames = new(); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs index a06a8980..fcb7cffe 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs @@ -6,6 +6,8 @@ namespace DotNetProjects.Migrator.Providers.Impl.SqlServer; public class SqlServerDialect : Dialect { + public override string EscapeLikePattern(string value) => base.EscapeLikePattern(value).Replace("[", "~["); + protected override string ResolveCollation(CollationKind kind) => kind switch { CollationKind.Binary => "Latin1_General_100_BIN2", CollationKind.CaseSensitive => "Latin1_General_100_CS_AS_SC", CollationKind.CaseInsensitive => "Latin1_General_100_CI_AS_SC", diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 0ad86d6a..ad270850 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -5,6 +5,8 @@ namespace DotNetProjects.Migrator.Providers.Impl.Sybase; public class SybaseDialect : Dialect { + public override string EscapeLikePattern(string value) => base.EscapeLikePattern(value).Replace("[", "~["); + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. public override bool SupportsIndex => false; From d4ce0478db276ef9c88e28509c5ce1104e762372 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:44:27 +0200 Subject: [PATCH 4/5] Let the Firebird fixture release its attachment before database cleanup --- .../Providers/Live/DtoSelectIntegrationTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs index 07932a7a..11496df6 100644 --- a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs +++ b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs @@ -64,6 +64,12 @@ public void MapsDtosAndFiltersUsingNativeDriverParameters() Assert.That(Provider.Delete(table, x => x.Payload.Contains("%_~") && x.Id == 3), Is.EqualTo(1)); Assert.That(Provider.Select(table).Count, Is.EqualTo(2)); } - finally { Provider.RemoveTable(table); } + finally + { + // Firebird can retain metadata locks until the attachment closes. Its + // fixture closes the provider and drops the isolated database in TearDown. + // Do not mask assertion/query failures with an early DROP TABLE failure. + if (database != "Firebird") Provider.RemoveTable(table); + } } } From 60f6853f66b979ee98baed8d8441c594236cfe4f Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:48:16 +0200 Subject: [PATCH 5/5] Honor inactive provider conditions in typed DTO operations --- src/Migrator.Tests/DtoNoOpTests.cs | 19 +++++++++++++++++++ .../Framework/DtoMutationExtensions.cs | 2 ++ src/Migrator/Framework/DtoSelectExtensions.cs | 2 ++ 3 files changed, 23 insertions(+) create mode 100644 src/Migrator.Tests/DtoNoOpTests.cs diff --git a/src/Migrator.Tests/DtoNoOpTests.cs b/src/Migrator.Tests/DtoNoOpTests.cs new file mode 100644 index 00000000..fbf606c1 --- /dev/null +++ b/src/Migrator.Tests/DtoNoOpTests.cs @@ -0,0 +1,19 @@ +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class DtoNoOpTests +{ + public class Row { public int Id { get; set; } public string Text { get; set; } } + + [Test] + public void TypedOperationsHonorAnInactiveProviderCondition() + { + ITransformationProvider provider = NoOpTransformationProvider.Instance; + Assert.That(provider.Select("Rows", x => x.Text.Contains("needle")), Is.Empty); + Assert.That(provider.Update("Rows", x => new Row { Id = 1 }, x => x.Id == 2), Is.Zero); + Assert.That(provider.Delete("Rows", x => x.Id == 2), Is.Zero); + } +} diff --git a/src/Migrator/Framework/DtoMutationExtensions.cs b/src/Migrator/Framework/DtoMutationExtensions.cs index 1949d126..0294ad03 100644 --- a/src/Migrator/Framework/DtoMutationExtensions.cs +++ b/src/Migrator/Framework/DtoMutationExtensions.cs @@ -16,6 +16,7 @@ public static int Delete(this ITransformationProvider provider, string table, ArgumentNullException.ThrowIfNull(provider); ArgumentException.ThrowIfNullOrWhiteSpace(table); ArgumentNullException.ThrowIfNull(predicate); + if (provider is Providers.NoOpTransformationProvider) return 0; var mapping = DtoMapping.For(typeof(T)); var filter = new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); var values = new List(); @@ -34,6 +35,7 @@ public static int Update(this ITransformationProvider provider, string table, ArgumentException.ThrowIfNullOrWhiteSpace(table); ArgumentNullException.ThrowIfNull(changes); ArgumentNullException.ThrowIfNull(predicate); + if (provider is Providers.NoOpTransformationProvider) return 0; var mapping = DtoMapping.For(typeof(T)); var filter = new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); if (changes.Body is not MemberInitExpression initializer || initializer.NewExpression.Arguments.Count != 0 || initializer.Bindings.Count == 0) diff --git a/src/Migrator/Framework/DtoSelectExtensions.cs b/src/Migrator/Framework/DtoSelectExtensions.cs index 447cc732..6648385d 100644 --- a/src/Migrator/Framework/DtoSelectExtensions.cs +++ b/src/Migrator/Framework/DtoSelectExtensions.cs @@ -18,6 +18,8 @@ public static IReadOnlyList Select(this ITransformationProvider provider, Expression> predicate = null) where T : class, new() { ArgumentNullException.ThrowIfNull(provider); + ArgumentException.ThrowIfNullOrWhiteSpace(table); + if (provider is Providers.NoOpTransformationProvider) return Array.Empty(); var mapping = DtoMapping.For(typeof(T)); var filter = predicate == null ? null : new PredicateTranslator(mapping, predicate.Parameters[0]).Translate(predicate.Body); return SelectQuery.Execute(provider, table, mapping, filter);