From ff8236ffc47f67c7fdc76a2e9a13a2fae8e70d0d Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:39:20 +0200 Subject: [PATCH 1/2] Add parameterized interpolated SQL queries with DTO and tuple mapping --- README.md | 3 + docs/_src/content.py | 20 ++ docs/assets/search-index.json | 2 +- docs/guide/data.html | 20 ++ docs/interpolated-queries.md | 88 +++++++++ src/Migrator.Tests/InterpolatedQueryTests.cs | 185 ++++++++++++++++++ .../Live/DtoSelectIntegrationTests.cs | 10 + .../Framework/InterpolatedQueryExtensions.cs | 94 +++++++++ src/Migrator/Framework/Sql.cs | 14 ++ .../Framework/TypedSelect/SelectQuery.cs | 14 +- 10 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 docs/interpolated-queries.md create mode 100644 src/Migrator.Tests/InterpolatedQueryTests.cs create mode 100644 src/Migrator/Framework/InterpolatedQueryExtensions.cs create mode 100644 src/Migrator/Framework/Sql.cs diff --git a/README.md b/README.md index 267a6024..143dc580 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,9 @@ 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. +For explicit SQL, use `Query(FormattableString)` with `Sql.Col`/`Sql.Table` +identifier wrappers or `:column`/`:table` formats and automatically parameterized +values. A mapper overload supports tuples. See [interpolated queries](docs/interpolated-queries.md). 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 d08f0781..b9a75ded 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -217,6 +217,16 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ x => x.GenericDefinition.StartsWith("old")); int deleted = Database.Delete("Common_Screens", x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); + var queried = Database.Query($""" + SELECT {Sql.Col("Id")}, {Sql.Col("Archive")}, {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """); + var tuples = Database.Query($""" + SELECT {"Id":column}, {"GenericDefinition":column} + FROM {"Common_Screens":table} WHERE {"Archive":column} = {false} + """, r => (Id: Convert.ToInt32(r.GetValue(0)), + Definition: r.IsDBNull(1) ? null : r.GetString(1))); } public override void Down() { } } @@ -241,6 +251,16 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/ x => x.GenericDefinition.StartsWith("old")); int deleted = Context.Delete("Common_Screens", x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); + var queried = Context.Query($""" + SELECT {Sql.Col("Id")}, {Sql.Col("Archive")}, {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """); + var tuples = Context.Query($""" + SELECT {"Id":column}, {"GenericDefinition":column} + FROM {"Common_Screens":table} WHERE {"Archive":column} = {false} + """, r => (Id: Convert.ToInt32(r.GetValue(0)), + Definition: r.IsDBNull(1) ? null : r.GetString(1))); } public override void BuildDown(MigrationBuilder migration) { } } diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index 1c37d494..e9ce49eb 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, 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\" });" + "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 var queried = Database.Query ($\"\"\"\n SELECT {Sql.Col(\"Id\")}, {Sql.Col(\"Archive\")}, {Sql.Col(\"GenericDefinition\")}\n FROM {Sql.Table(\"Common_Screens\")}\n WHERE {Sql.Col(\"Archive\")} = {false}\n \"\"\");\n var tuples = Database.Query($\"\"\"\n SELECT {\"Id\":column}, {\"GenericDefinition\":column}\n FROM {\"Common_Screens\":table} WHERE {\"Archive\":column} = {false}\n \"\"\", r => (Id: Convert.ToInt32(r.GetValue(0)),\n Definition: r.IsDBNull(1) ? null : r.GetString(1)));\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 var queried = Context.Query ($\"\"\"\n SELECT {Sql.Col(\"Id\")}, {Sql.Col(\"Archive\")}, {Sql.Col(\"GenericDefinition\")}\n FROM {Sql.Table(\"Common_Screens\")}\n WHERE {Sql.Col(\"Archive\")} = {false}\n \"\"\");\n var tuples = Context.Query($\"\"\"\n SELECT {\"Id\":column}, {\"GenericDefinition\":column}\n FROM {\"Common_Screens\":table} WHERE {\"Archive\":column} = {false}\n \"\"\", r => (Id: Convert.ToInt32(r.GetValue(0)),\n Definition: r.IsDBNull(1) ? null : r.GetString(1)));\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/guide/data.html b/docs/guide/data.html index 01e4d8de..39c7943a 100644 --- a/docs/guide/data.html +++ b/docs/guide/data.html @@ -32,6 +32,16 @@ x => x.GenericDefinition.StartsWith("old")); int deleted = Database.Delete<ScreenDto>("Common_Screens", x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); + var queried = Database.Query<ScreenDto>($""" + SELECT {Sql.Col("Id")}, {Sql.Col("Archive")}, {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """); + var tuples = Database.Query($""" + SELECT {"Id":column}, {"GenericDefinition":column} + FROM {"Common_Screens":table} WHERE {"Archive":column} = {false} + """, r => (Id: Convert.ToInt32(r.GetValue(0)), + Definition: r.IsDBNull(1) ? null : r.GetString(1))); } public override void Down() { } }
@@ -62,6 +72,16 @@ x => x.GenericDefinition.StartsWith("old")); int deleted = Context.Delete<ScreenDto>("Common_Screens", x => x.Archive && x.GenericDefinition.EndsWith(".obsolete")); + var queried = Context.Query<ScreenDto>($""" + SELECT {Sql.Col("Id")}, {Sql.Col("Archive")}, {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """); + var tuples = Context.Query($""" + SELECT {"Id":column}, {"GenericDefinition":column} + FROM {"Common_Screens":table} WHERE {"Archive":column} = {false} + """, r => (Id: Convert.ToInt32(r.GetValue(0)), + Definition: r.IsDBNull(1) ? null : r.GetString(1))); } 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/docs/interpolated-queries.md b/docs/interpolated-queries.md new file mode 100644 index 00000000..209f359b --- /dev/null +++ b/docs/interpolated-queries.md @@ -0,0 +1,88 @@ +# Interpolated SQL queries + +Import `DotNetProjects.Migrator.Framework`. Pass interpolation directly to +`Query` as a `FormattableString`, preserving argument types: + +```csharp +public sealed class ScreenDto +{ + public int Id { get; set; } + public bool Archive { get; set; } + public string GenericDefinition { get; set; } +} + +var screens = Database.Query($""" + SELECT {Sql.Col("Id")}, {Sql.Col("Archive")}, + {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """); +``` + +`Sql.Col` wraps a column identifier atom; a dot inside its name is part of that +column name, not an alias separator. Use trusted literal aliases such as +`s.{Sql.Col("Id")}` in a join. `Sql.Table` wraps a table path and supports +schema qualification through the existing provider quoting rules. Ordinary +values (including ordinary strings) become native driver parameters. Identifiers +must be non-empty. Complex objects and collections are rejected rather than +converted to SQL text. + +The same API also supports format specifiers: + +```csharp +var screens = Database.Query($""" + SELECT {"Id":column}, {"Archive":column}, {"GenericDefinition":column} + FROM {"Common_Screens":table} + WHERE {"Archive":column} = {false} + """); +``` + +`:column` and `:table` require identifier strings (or matching wrappers). Other +format specifiers such as `:D2` throw; values retain their native types rather +than being formatted into strings. Escaped braces and alignment use the .NET +composite-format parser. Each placeholder occurrence gets its own parameter, +including repeated argument indexes, so positional drivers bind in SQL order. + +For tuples, constructor mapping or unusual projections, supply a mapper: + +```csharp +var screens = Database.Query($""" + SELECT {Sql.Col("Id")}, {Sql.Col("GenericDefinition")} + FROM {Sql.Table("Common_Screens")} + WHERE {Sql.Col("Archive")} = {false} + """, r => ( + Id: Convert.ToInt32(r.GetValue(0)), + Definition: r.IsDBNull(1) ? null : r.GetString(1))); +``` + +The signatures are: + +```csharp +IReadOnlyList Query(FormattableString sql) where T : class, new(); +IReadOnlyList Query(FormattableString sql, Func mapper); +``` + +DTO results reuse the [typed DTO mapping](dto-queries.md): columns must match +mapped names, including standard `ColumnAttribute` overrides; SQL aliases should +use those mapped names. Extra result columns are ignored, missing columns throw +with DTO/property context. The mapper overload has no constructor/type +constraints and is invoked once per row; the mapper must not dispose or advance +the reader itself. + +Both return materialized lists and dispose their command and reader before +return, including binding, execution and mapping failures. They retain the +provider's connection, command timeout, native parameter conversions and active +migration transaction. In a fluent migration use `Context.Query(...)` for an +immediate query. + +SQL literal text is trusted migration code. Interpolated values are parameterized, +but literal text is not validated or made portable. SQL null, collation, ordering, +joins and other SQL semantics remain explicit in the query. For example, +`WHERE Column = {null}` does not become `IS NULL`; author null predicates in SQL. +Do not first assign interpolation to `var`/`string`, concatenate it into a string, +or call `ToString()`: this loses arguments, and there is deliberately no `string` +overload. To store a query, use `FormattableString sql = $"..."`. + +This uses `FormattableString` rather than a custom interpolated string handler. +Both identifier syntaxes already work with it; a handler can be considered later +if profiling justifies the additional complexity. diff --git a/src/Migrator.Tests/InterpolatedQueryTests.cs b/src/Migrator.Tests/InterpolatedQueryTests.cs new file mode 100644 index 00000000..1ed62a6c --- /dev/null +++ b/src/Migrator.Tests/InterpolatedQueryTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Data; +using System.Linq; +using System.Runtime.CompilerServices; +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; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class InterpolatedQueryTests +{ + public class Row + { + [DtoColumn("select")] public int Id { get; set; } + public string Text { get; set; } + public bool Active { 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 \"query rows\" (\"select\" INTEGER, Text TEXT, Active INTEGER)"); + provider.Insert("query rows", new[] { "select", "Text", "Active" }, new object[] { 1, "O'Brien", true }); + provider.Insert("query rows", new[] { "select", "Text", "Active" }, new object[] { 2, null, false }); + } + [TearDown] public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [Test] + public void MapsDtosUsingExplicitIdentifierWrappersAndNativeValues() + { + ITransformationProvider database = provider; + var rows = database.Query($"SELECT {Sql.Col("select")}, {Sql.Col("Text")}, {Sql.Col("Active")} FROM {Sql.Table("query rows")} WHERE {Sql.Col("Active")} = {true}"); + Assert.That(rows.Single().Id, Is.EqualTo(1)); + Assert.That(rows.Single().Text, Is.EqualTo("O'Brien")); + Assert.That(rows.Single().Active, Is.True); + } + + [Test] + public void FormatSpecifiersSupportTheSameDtoMapping() + { + var row = provider.Query($"SELECT {"select":column}, {"Text":column}, {"Active":column} FROM {"query rows":table} WHERE {"select":column} = {2}").Single(); + Assert.That(row.Id, Is.EqualTo(2)); Assert.That(row.Text, Is.Null); Assert.That(row.Active, Is.False); + } + + [Test] + public void MapperOverloadSupportsTuplesAndDisposesTheReaderBeforeReturn() + { + var rows = provider.Query($"SELECT {Sql.Col("select")}, {Sql.Col("Text")} FROM {Sql.Table("query rows")} ORDER BY {Sql.Col("select")}", + r => (Id: Convert.ToInt32(r.GetValue(0)), Text: r.IsDBNull(1) ? null : r.GetString(1))); + Assert.That(rows.Select(x => x.Id), Is.EqualTo(new[] { 1, 2 })); + Assert.That(rows[0].Text, Is.EqualTo("O'Brien")); Assert.That(rows[1].Text, Is.Null); + } + + [Test] + public void OrdinaryStringsAreValuesNotIdentifiersOrSqlFragments() + { + var injection = "'; DROP TABLE anything; --"; + Assert.That(provider.Query($"SELECT 1 FROM {Sql.Table("query rows")} WHERE Text = {injection}", r => r.GetInt64(0)), Is.Empty); + Assert.That(provider.Query($"SELECT {"select"}, {injection}", r => (r.GetString(0), r.GetString(1))).Single(), Is.EqualTo(("select", injection))); + Assert.That(provider.Select("query rows").Count, Is.EqualTo(2)); + } + + [Test] + public void PreservesEscapedBracesRepeatedArgumentsAlignmentAndNullParameters() + { + var sql = FormattableStringFactory.Create("SELECT '{{literal}}', {0}, {0,8}, {1}, {2}", 7, null, DBNull.Value); + var row = provider.Query(sql, r => (r.GetString(0), r.GetInt64(1), r.GetInt64(2), r.IsDBNull(3), r.IsDBNull(4))).Single(); + Assert.That(row, Is.EqualTo(("{literal}", 7L, 7L, true, true))); + } + + [Test] + public void UsesTheActiveTransactionAndSupportsMapperFailures() + { + provider.BeginTransaction(); + provider.Update("query rows", x => new Row { Text = "uncommitted" }, x => x.Id == 1); + var rows = provider.Query($"SELECT * FROM {Sql.Table("query rows")} WHERE {Sql.Col("select")} = {1}"); + provider.Rollback(); + Assert.That(rows.Single().Text, Is.EqualTo("uncommitted")); + Assert.Throws(() => provider.Query($"SELECT * FROM {Sql.Table("query rows")}", r => throw new InvalidOperationException("mapper failure"))); + Assert.That(provider.Query($"SELECT * FROM {Sql.Table("query rows")}").Count, Is.EqualTo(2)); + } + + [Test] + public void QuotesQualifiedTablePathsAndColumnAtomsContainingDots() + { + provider.ExecuteNonQuery("ATTACH ':memory:' AS \"extra schema\""); + provider.ExecuteNonQuery("CREATE TABLE \"extra schema\".\"dotted table\" (\"a.b\" INTEGER)"); + provider.ExecuteNonQuery("INSERT INTO \"extra schema\".\"dotted table\" VALUES (3)"); + Assert.That(provider.Query($"SELECT {Sql.Col("a.b")} FROM {Sql.Table("\"extra schema\".\"dotted table\"")}", r => r.GetInt64(0)).Single(), Is.EqualTo(3)); + } + + [Test] + public void MissingDtoColumnsAreReportedWithMappingContext() + { + var error = Assert.Throws(() => provider.Query($"SELECT {Sql.Col("select")} FROM {Sql.Table("query rows")}")); + Assert.That(error.Message, Does.Contain("Text").And.Contain("Row.Text")); + Assert.That(provider.Query($"SELECT * FROM {Sql.Table("query rows")}").Count, Is.EqualTo(2)); + } + + [TestCase("unknown-format")] + [TestCase("wrong-identifier-type")] + [TestCase("empty-identifier")] + [TestCase("wrong-wrapper-format")] + [TestCase("complex-value")] + [TestCase("bad-placeholder")] + public void InvalidFormatsAndValuesFailBeforeCommandCreation(string kind) + { + var database = Substitute.For(); + FormattableString sql = kind switch + { + "unknown-format" => $"SELECT {7:D2}", + "wrong-identifier-type" => $"SELECT {7:column}", + "empty-identifier" => $"SELECT {Sql.Col("")}", + "wrong-wrapper-format" => $"SELECT {Sql.Col("Id"):table}", + "complex-value" => $"SELECT {new object()}", + _ => FormattableStringFactory.Create("SELECT {1}", 7) + }; + Assert.Catch(() => database.Query(sql, r => 1)); + database.DidNotReceive().CreateCommand(); + } +} + +public class InterpolatedQueryBindingTests +{ + [TestCase("oracle", ":p0", "p0", 1)] + [TestCase("hana", "?", "p0", true)] + [TestCase("informix", "?", "@p0", true)] + public void RepeatedPlaceholdersBindPerOccurrenceInOrder(string kind, string placeholder, string parameterName, 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(); 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.Query(FormattableStringFactory.Create("SELECT 1 WHERE {0} = {1} OR {0} = {2}", true, 7, 9), r => 1); + Assert.That(command.CommandText, Does.Contain(placeholder)); + Assert.That(holder.Parameters.Count, Is.EqualTo(4)); + Assert.That(holder.Parameters[0].ParameterName, Is.EqualTo(parameterName)); + Assert.That(holder.Parameters.Cast().Select(x => x.Value), Is.EqualTo(new[] { boolean, 7, boolean, 9 })); + Assert.That(reader.IsClosed, Is.True); command.Received().Dispose(); + } + + [Test] + public void DisposesCommandWhenParameterBindingFails() + { + var database = Substitute.For(); var command = Substitute.For(); + database.CreateCommand().Returns(command); database.GenerateParameterName(0).Returns("@p0"); + database.When(x => x.AddQueryParameter(command, 0, 1)).Do(_ => throw new NotSupportedException("binding failure")); + Assert.Throws(() => database.Query($"SELECT {1}", r => 1)); + command.Received().Dispose(); database.DidNotReceive().ExecuteQuery(Arg.Any(), Arg.Any()); + } + + [TestCase(false)] + [TestCase(true)] + public void DisposesReaderAndCommandOnExecutionOrMapperFailure(bool mapperFailure) + { + var database = Substitute.For(); var command = Substitute.For(); + database.CreateCommand().Returns(command); + var reader = Substitute.For(); reader.Read().Returns(true); + if (mapperFailure) database.ExecuteQuery(command, Arg.Any()).Returns(reader); + else database.ExecuteQuery(command, Arg.Any()).Returns(_ => throw new InvalidOperationException("driver failure")); + Assert.Throws(() => database.Query($"SELECT 1", r => throw new InvalidOperationException("mapper failure"))); + command.Received().Dispose(); + if (mapperFailure) reader.Received().Dispose(); + } +} diff --git a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs index 07932a7a..8b0fe24f 100644 --- a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs +++ b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs @@ -53,8 +53,18 @@ public void MapsDtosAndFiltersUsingNativeDriverParameters() 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 queried = Provider.Query($"SELECT {Sql.Col("id")}, {Sql.Col("active")}, {Sql.Col("payload")}, {Sql.Col("optional")}, {Sql.Col("status")} FROM {Sql.Table(table)} WHERE {Sql.Col("active")} = {true}"); + Assert.That(queried.Select(x => x.Id), Is.EquivalentTo(new[] { 1, 3 })); + var tuples = Provider.Query($"SELECT {"id":column}, {"payload":column} FROM {table:table} WHERE {"id":column} = {1}", + r => (Id: Convert.ToInt32(r.GetValue(0)), Text: r.GetString(1))); + Assert.That(tuples.Single(), Is.EqualTo((1, "O'Brien"))); + var repeated = System.Runtime.CompilerServices.FormattableStringFactory.Create("SELECT " + Provider.QuoteColumnNameIfRequired("id") + + " FROM " + Provider.QuoteTableNameIfRequired(table) + " WHERE " + Provider.QuoteColumnNameIfRequired("id") + " = {0} OR " + + Provider.QuoteColumnNameIfRequired("id") + " = {0}", 1); + Assert.That(Provider.Query(repeated, r => Convert.ToInt32(r.GetValue(0))).Single(), Is.EqualTo(1)); var injection = "'; DROP TABLE " + table + "; --"; Assert.That(Provider.Select(table, x => x.Payload == injection), Is.Empty); + Assert.That(Provider.Query($"SELECT * FROM {Sql.Table(table)} WHERE {Sql.Col("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)); diff --git a/src/Migrator/Framework/InterpolatedQueryExtensions.cs b/src/Migrator/Framework/InterpolatedQueryExtensions.cs new file mode 100644 index 00000000..d1d718d7 --- /dev/null +++ b/src/Migrator/Framework/InterpolatedQueryExtensions.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator.Framework.TypedSelect; + +namespace DotNetProjects.Migrator.Framework; + +/// Interpolated SQL queries with provider quoting, native parameters and materialized results. +public static class InterpolatedQueryExtensions +{ + /// Map columns to a DTO using the same property/column mapping as Select<T>. + public static IReadOnlyList Query(this ITransformationProvider provider, FormattableString sql) + where T : class, new() + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(sql); + var mapping = DtoMapping.For(typeof(T)); + using var command = Prepare(provider, sql); + using var reader = provider.ExecuteQuery(command, command.CommandText); + return SelectQuery.Materialize(reader, mapping); + } + + /// Map each result row to a DTO, tuple or other value. The mapper does not own the reader. + public static IReadOnlyList Query(this ITransformationProvider provider, FormattableString sql, + Func mapper) + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(sql); + ArgumentNullException.ThrowIfNull(mapper); + using var command = Prepare(provider, sql); + using var reader = provider.ExecuteQuery(command, command.CommandText); + var result = new List(); + while (reader.Read()) result.Add(mapper(reader)); + return result; + } + + private static IDbCommand Prepare(ITransformationProvider provider, FormattableString sql) + { + var formatter = new QueryFormatter(provider); + // The framework parser handles escaped braces, alignment and repeated placeholders. + // Every placeholder occurrence gets its own parameter for positional drivers. + var text = string.Format(formatter, sql.Format, sql.GetArguments()); + ArgumentException.ThrowIfNullOrWhiteSpace(text); + var command = provider.CreateCommand(); + try + { + command.CommandText = text; + for (var i = 0; i < formatter.Values.Count; i++) provider.AddQueryParameter(command, i, formatter.Values[i]); + return command; + } + catch { command.Dispose(); throw; } + } + + private sealed class QueryFormatter(ITransformationProvider provider) : IFormatProvider, ICustomFormatter + { + internal List Values { get; } = new(); + public object GetFormat(Type formatType) => formatType == typeof(ICustomFormatter) ? this : null; + + public string Format(string format, object arg, IFormatProvider formatProvider) + { + if (arg is SqlColumn column) + { + ValidateIdentifierFormat(format, "column"); + ArgumentException.ThrowIfNullOrWhiteSpace(column.Name); + return provider.QuoteColumnNameIfRequired(column.Name); + } + if (arg is SqlTable table) + { + ValidateIdentifierFormat(format, "table"); + ArgumentException.ThrowIfNullOrWhiteSpace(table.Name); + return provider.QuoteTableNameIfRequired(table.Name); + } + if (!string.IsNullOrEmpty(format)) + { + if (format is not ("column" or "table")) + throw new FormatException($"Unsupported SQL interpolation format '{format}'. Use :column or :table for identifiers."); + if (arg is not string identifier || string.IsNullOrWhiteSpace(identifier)) + throw new ArgumentException("SQL identifier interpolation requires a non-empty string."); + return format == "column" ? provider.QuoteColumnNameIfRequired(identifier) : provider.QuoteTableNameIfRequired(identifier); + } + if (arg != null && arg != DBNull.Value && !DtoMapping.IsScalar(arg.GetType())) + throw new NotSupportedException($"SQL interpolation value type '{arg.GetType().Name}' is not a supported scalar."); + var name = provider.GenerateParameterName(Values.Count); + Values.Add(arg); + return name; + } + + private static void ValidateIdentifierFormat(string format, string expected) + { + if (!string.IsNullOrEmpty(format) && format != expected) + throw new FormatException($"A SQL {expected} wrapper cannot use format '{format}'."); + } + } +} diff --git a/src/Migrator/Framework/Sql.cs b/src/Migrator/Framework/Sql.cs new file mode 100644 index 00000000..d567abd2 --- /dev/null +++ b/src/Migrator/Framework/Sql.cs @@ -0,0 +1,14 @@ +namespace DotNetProjects.Migrator.Framework; + +/// A column identifier atom, quoted by the current provider when interpolated into Query. +public readonly record struct SqlColumn(string Name); + +/// A table identifier path, quoted by the current provider when interpolated into Query. +public readonly record struct SqlTable(string Name); + +/// Explicit identifier wrappers for interpolated SQL. Ordinary arguments become parameters. +public static class Sql +{ + public static SqlColumn Col(string name) => new(name); + public static SqlTable Table(string name) => new(name); +} diff --git a/src/Migrator/Framework/TypedSelect/SelectQuery.cs b/src/Migrator/Framework/TypedSelect/SelectQuery.cs index 9d83402b..05edc8d1 100644 --- a/src/Migrator/Framework/TypedSelect/SelectQuery.cs +++ b/src/Migrator/Framework/TypedSelect/SelectQuery.cs @@ -19,7 +19,19 @@ internal static IReadOnlyList Execute(ITransformationProvider provider, st 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(); + return Materialize(reader, mapping); + } + + internal static IReadOnlyList Materialize(IDataReader reader, DtoMapping mapping) where T : class, new() + { + var ordinals = mapping.Properties.Select(p => + { + try { return reader.GetOrdinal(p.Column); } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + throw new DataException($"Query result is missing column '{p.Column}' for '{typeof(T).Name}.{p.Property.Name}'.", ex); + } + }).ToArray(); var result = new List(); while (reader.Read()) { From ffc4c94425a7ce94f24ea0d265d391e722479711 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 14 Oct 2026 21:49:58 +0200 Subject: [PATCH 2/2] Honor inactive provider conditions in interpolated queries --- src/Migrator.Tests/DtoNoOpTests.cs | 2 ++ src/Migrator/Framework/InterpolatedQueryExtensions.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Migrator.Tests/DtoNoOpTests.cs b/src/Migrator.Tests/DtoNoOpTests.cs index fbf606c1..ddb0a3a8 100644 --- a/src/Migrator.Tests/DtoNoOpTests.cs +++ b/src/Migrator.Tests/DtoNoOpTests.cs @@ -15,5 +15,7 @@ public void TypedOperationsHonorAnInactiveProviderCondition() 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); + Assert.That(provider.Query($"SELECT * FROM {Sql.Table("Rows")}"), Is.Empty); + Assert.That(provider.Query($"SELECT {1}", r => 1), Is.Empty); } } diff --git a/src/Migrator/Framework/InterpolatedQueryExtensions.cs b/src/Migrator/Framework/InterpolatedQueryExtensions.cs index d1d718d7..5e7065bc 100644 --- a/src/Migrator/Framework/InterpolatedQueryExtensions.cs +++ b/src/Migrator/Framework/InterpolatedQueryExtensions.cs @@ -14,6 +14,7 @@ public static IReadOnlyList Query(this ITransformationProvider provider, F { ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(sql); + if (provider is Providers.NoOpTransformationProvider) return Array.Empty(); var mapping = DtoMapping.For(typeof(T)); using var command = Prepare(provider, sql); using var reader = provider.ExecuteQuery(command, command.CommandText); @@ -27,6 +28,7 @@ public static IReadOnlyList Query(this ITransformationProvider provider, F ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(sql); ArgumentNullException.ThrowIfNull(mapper); + if (provider is Providers.NoOpTransformationProvider) return Array.Empty(); using var command = Prepare(provider, sql); using var reader = provider.ExecuteQuery(command, command.CommandText); var result = new List();