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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ Predicates also support literal string `Contains`, `StartsWith` and `EndsWith`.
Typed `Update<T>(table, changes, predicate)` updates only properties assigned in
a DTO initializer; `Delete<T>(table, predicate)` removes matching rows. Both
require predicates, retain the migration transaction and return affected counts.
For explicit SQL, use `Query<T>(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).

Expand Down
20 changes: 20 additions & 0 deletions docs/_src/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/
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() { }
}
Expand All @@ -241,6 +251,16 @@ def page(group, slug, title, summary, *sections, source="src/Migrator/Framework/
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) { }
}
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/search-index.json

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions docs/guide/data.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions docs/interpolated-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Interpolated SQL queries

Import `DotNetProjects.Migrator.Framework`. Pass interpolation directly to
`Query<T>` 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<ScreenDto>($"""
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<ScreenDto>($"""
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<T> Query<T>(FormattableString sql) where T : class, new();
IReadOnlyList<T> Query<T>(FormattableString sql, Func<IDataReader, T> 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.
2 changes: 2 additions & 0 deletions src/Migrator.Tests/DtoNoOpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ public void TypedOperationsHonorAnInactiveProviderCondition()
Assert.That(provider.Select<Row>("Rows", x => x.Text.Contains("needle")), Is.Empty);
Assert.That(provider.Update<Row>("Rows", x => new Row { Id = 1 }, x => x.Id == 2), Is.Zero);
Assert.That(provider.Delete<Row>("Rows", x => x.Id == 2), Is.Zero);
Assert.That(provider.Query<Row>($"SELECT * FROM {Sql.Table("Rows")}"), Is.Empty);
Assert.That(provider.Query($"SELECT {1}", r => 1), Is.Empty);
}
}
Loading
Loading