diff --git a/README.md b/README.md index ac850b38..85777f3f 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ 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. +Alternatively, pass an immutable typed filter such as +`Filter.Equal(x => x.Archive, false).And(Filter.GreaterThan(x => x.Id, minimumId))`. 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..80cbb8d6 100644 --- a/docs/_src/content.py +++ b/docs/_src/content.py @@ -210,6 +210,9 @@ 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 filter = Filter.Equal(x => x.Archive, false) + .And(Filter.GreaterThan(x => x.Id, minimumId)); + var filteredScreens = Database.Select("Common_Screens", filter); } public override void Down() { } } @@ -227,6 +230,9 @@ 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 filter = Filter.Equal(x => x.Archive, false) + .And(Filter.GreaterThan(x => x.Id, minimumId)); + var filteredScreens = Context.Select("Common_Screens", filter); } public override void BuildDown(MigrationBuilder migration) { } } diff --git a/docs/assets/search-index.json b/docs/assets/search-index.json index 2d019777..6520b5e1 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 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 var filter = Filter .Equal(x => x.Archive, false)\n .And(Filter .GreaterThan(x => x.Id, minimumId));\n var filteredScreens = Database.Select(\"Common_Screens\", filter);\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 filter = Filter .Equal(x => x.Archive, false)\n .And(Filter .GreaterThan(x => x.Id, minimumId));\n var filteredScreens = Context.Select(\"Common_Screens\", filter);\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..61dd428f 100644 --- a/docs/dto-queries.md +++ b/docs/dto-queries.md @@ -71,3 +71,47 @@ 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. + +## Explicit typed filters + +Use `Filter` when you prefer explicit operations to predicate translation: + +```csharp +var filter = Filter.Equal(x => x.Archive, false) + .And(Filter.GreaterThan(x => x.Id, minimumId)); + +var screens = Database.Select("Common_Screens", filter); +// Explicit generic syntax is also supported: +var sameScreens = Database.Select("Common_Screens", filter); +``` + +The builder supports `Equal`, `NotEqual`, `LessThan`, `LessThanOrEqual`, +`GreaterThan`, `GreaterThanOrEqual`, `IsNull` and `IsNotNull`. Combine filters using +`And`, `Or` and `Not()`; each returns a new immutable filter, preserving grouping. +Reusing a filter does not reuse commands or parameter collections. Values are +captured when the filter is built, so later changes to local variables do not +change it. + +Selectors must name a direct mapped property in its declared type. The generic +`TValue` connects selector and comparison value types; widened/boxed selectors, +casts, calculations, method calls and nested properties are rejected. Ordering +has the same type restrictions as the lambda API. `IsNull` and `IsNotNull` require +a nullable value or reference property; equality with a null reference/nullable +value also renders a SQL null check. + +```csharp +public sealed class OptionalDto +{ + public int? Optional { get; set; } +} + +// Include NULL rows explicitly when comparing against a non-null value: +var optionalFilter = Filter.IsNull(x => x.Optional) + .Or(Filter.NotEqual(x => x.Optional, 7)); +``` + +Both APIs share the DTO mapping, quoting, native parameter binding, SQL null +semantics, transaction and resource-lifetime behavior described above. A null +filter throws instead of selecting all rows; omit the predicate/filter argument +to select all rows. For explicit null arguments, use `predicate: null` to select +the lambda overload or `filter: null` to select the filter overload. diff --git a/docs/guide/data.html b/docs/guide/data.html index 9be66206..ea9e21a5 100644 --- a/docs/guide/data.html +++ b/docs/guide/data.html @@ -25,6 +25,9 @@ var minimumId = 10; var screens = Database.Select<ScreenDto>( "Common_Screens", x => !x.Archive && x.Id > minimumId); + var filter = Filter<ScreenDto>.Equal(x => x.Archive, false) + .And(Filter<ScreenDto>.GreaterThan(x => x.Id, minimumId)); + var filteredScreens = Database.Select("Common_Screens", filter); } public override void Down() { } }
@@ -48,6 +51,9 @@ var minimumId = 10; var screens = Context.Select<ScreenDto>( "Common_Screens", x => !x.Archive && x.Id > minimumId); + var filter = Filter<ScreenDto>.Equal(x => x.Archive, false) + .And(Filter<ScreenDto>.GreaterThan(x => x.Id, minimumId)); + var filteredScreens = Context.Select("Common_Screens", filter); } 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/DtoFilterTests.cs b/src/Migrator.Tests/DtoFilterTests.cs new file mode 100644 index 00000000..c99ca8fd --- /dev/null +++ b/src/Migrator.Tests/DtoFilterTests.cs @@ -0,0 +1,178 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Linq.Expressions; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; +using DtoColumn = System.ComponentModel.DataAnnotations.Schema.ColumnAttribute; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class DtoFilterTests +{ + public enum Status { New = 1, Archived = 2 } + public class Row + { + [DtoColumn("select")] public int Id { get; set; } + public bool Active { get; set; } + public int? Optional { get; set; } + public Status State { get; set; } + public string Name { get; set; } + [NotMapped] public string Display { get; set; } + } + public class BaseRow { [DtoColumn("select")] public int Id { get; set; } } + public class DerivedRow : BaseRow { public string Name { 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 \"filter rows\" (\"select\" INTEGER, Active INTEGER, Optional INTEGER, State INTEGER, Name TEXT)"); + var columns = new[] { "select", "Active", "Optional", "State", "Name" }; + provider.Insert("filter rows", columns, new object[] { 1, true, null, 1, "O'Brien" }); + provider.Insert("filter rows", columns, new object[] { 2, false, 7, 2, "other" }); + provider.Insert("filter rows", columns, new object[] { 3, true, 9, 1, "third" }); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [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("null", 1)] + [TestCase("not-null", 2, 3)] + [TestCase("nullable-equal", 2)] + [TestCase("nullable-not-equal", 3)] + [TestCase("equal-null", 1)] + [TestCase("not-equal-null", 2, 3)] + [TestCase("enum", 2)] + [TestCase("boolean", 1, 3)] + [TestCase("reference-null")] + [TestCase("reference-not-null", 1, 2, 3)] + public void FiltersMappedPropertiesWithAllSupportedOperations(string operation, params int[] expected) + { + var filter = operation switch + { + "equal" => Filter.Equal(x => x.Id, 2), + "not-equal" => Filter.NotEqual(x => x.Id, 2), + "less" => Filter.LessThan(x => x.Id, 2), + "less-equal" => Filter.LessThanOrEqual(x => x.Id, 2), + "greater" => Filter.GreaterThan(x => x.Id, 2), + "greater-equal" => Filter.GreaterThanOrEqual(x => x.Id, 2), + "null" => Filter.IsNull(x => x.Optional), + "not-null" => Filter.IsNotNull(x => x.Optional), + "nullable-equal" => Filter.Equal(x => x.Optional, 7), + "nullable-not-equal" => Filter.NotEqual(x => x.Optional, 7), + "equal-null" => Filter.Equal(x => x.Optional, null), + "not-equal-null" => Filter.NotEqual(x => x.Optional, null), + "enum" => Filter.Equal(x => x.State, Status.Archived), + "reference-null" => Filter.IsNull(x => x.Name), + "reference-not-null" => Filter.IsNotNull(x => x.Name), + _ => Filter.Equal(x => x.Active, true) + }; + ITransformationProvider database = provider; + Assert.That(database.Select("filter rows", filter).Select(x => x.Id), Is.EquivalentTo(expected)); + } + + [Test] + public void ComposedFiltersMatchLambdaPredicatesAndPreserveGrouping() + { + var filter = Filter.Equal(x => x.Active, false).Not() + .And(Filter.IsNull(x => x.Optional).Or(Filter.GreaterThan(x => x.Id, 2))); + var result = provider.Select("filter rows", filter); + var lambda = provider.Select("filter rows", x => !(!x.Active) && (x.Optional == null || x.Id > 2)); + Assert.That(result.Select(x => x.Id), Is.EquivalentTo(new[] { 1, 3 })); + Assert.That(result.Select(x => x.Id), Is.EquivalentTo(lambda.Select(x => x.Id))); + } + + [Test] + public void FiltersInheritedMappedProperties() + { + var filter = Filter.GreaterThan(x => x.Id, 2); + var row = provider.Select("filter rows", filter).Single(); + Assert.That(row.Id, Is.EqualTo(3)); + Assert.That(row.Name, Is.EqualTo("third")); + } + + [Test] + public void FiltersAreImmutableAndCaptureValuesAtConstruction() + { + var id = 1; + var original = Filter.Equal(x => x.Id, id); + id = 2; + var combined = original.Or(Filter.Equal(x => x.Id, id)); + Assert.That(provider.Select("filter rows", original).Single().Id, Is.EqualTo(1)); + Assert.That(provider.Select("filter rows", combined).Select(x => x.Id), Is.EquivalentTo(new[] { 1, 2 })); + Assert.That(provider.Select("filter rows", original).Single().Id, Is.EqualTo(1)); + } + + [Test] + public void FilterValuesAreParameterizedAndCanBeReusedInATransaction() + { + var filter = Filter.Equal(x => x.Name, "'; DROP TABLE anything; --"); + Assert.That(provider.Select("filter rows", filter), Is.Empty); + provider.BeginTransaction(); + provider.Update("filter rows", new[] { "Name" }, new object[] { "changed" }, new[] { "select" }, new object[] { 1 }); + var changed = Filter.Equal(x => x.Name, "changed"); + Assert.That(provider.Select("filter rows", changed).Single().Id, Is.EqualTo(1)); + provider.Rollback(); + Assert.That(provider.Select("filter rows", changed), Is.Empty); + Assert.That(provider.Select("filter rows").Count, Is.EqualTo(3)); + } + + [TestCase("calculation")] + [TestCase("nested")] + [TestCase("method")] + [TestCase("conversion")] + [TestCase("constant")] + public void RejectsSelectorsThatAreNotDirectProperties(string kind) + { + Expression> column = kind switch + { + "calculation" => x => x.Id + 1, + "nested" => x => x.Name.Length, + "method" => x => x.Name.GetHashCode(), + "conversion" => x => (int)x.State, + _ => x => 7 + }; + Assert.Throws(() => Filter.Equal(column, 1)); + } + + [Test] + public void RejectsUnmappedAndWidenedSelectorsAndUnsupportedOperators() + { + Assert.Throws(() => Filter.Equal(x => x.Display, "unused")); + Assert.Throws(() => Filter.Equal(x => x.Id, "wrong type")); + Assert.Throws(() => Filter.Equal(x => x.Name, "name")); + Assert.Throws(() => Filter.Equal(x => (long)x.Id, 1L)); + Assert.Throws(() => Filter.GreaterThan(x => x.Active, false)); + Assert.Throws(() => Filter.GreaterThan(x => x.State, Status.New)); + Assert.Throws(() => Filter.GreaterThan(x => x.Name, "a")); + Assert.Throws(() => Filter.LessThan(x => x.Optional, null)); + Assert.Throws(() => Filter.IsNull(x => x.Id)); + } + + [Test] + public void RejectsNullArgumentsRatherThanReadingAllRows() + { + var filter = Filter.Equal(x => x.Id, 1); + Assert.Throws(() => Filter.Equal(null, 1)); + Assert.Throws(() => filter.And(null)); + Assert.Throws(() => filter.Or(null)); + var database = Substitute.For(); + Assert.Throws(() => database.Select("filter rows", filter: null)); + database.DidNotReceive().CreateCommand(); + } +} diff --git a/src/Migrator.Tests/DtoSelectTests.cs b/src/Migrator.Tests/DtoSelectTests.cs index 46d64d88..1c3cdcbf 100644 --- a/src/Migrator.Tests/DtoSelectTests.cs +++ b/src/Migrator.Tests/DtoSelectTests.cs @@ -246,11 +246,14 @@ 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, false)] + [TestCase("hana", "?", "p0", "p1", true, false)] + [TestCase("informix", "?", "@p0", "@p1", true, false)] + [TestCase("oracle", ":p0", "p0", "p1", 1, true)] + [TestCase("hana", "?", "p0", "p1", true, true)] + [TestCase("informix", "?", "@p0", "@p1", true, true)] public void PreservesProviderParameterNamesConversionsAndOccurrenceOrder(string kind, string placeholder, - string firstName, string secondName, object boolean) + string firstName, string secondName, object boolean, bool useFilter) { var connection = Substitute.For(); connection.State.Returns(ConnectionState.Open); @@ -269,7 +272,10 @@ 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 (useFilter) + provider.Select("Rows", Filter.GreaterThan(x => x.Id, 5).And(Filter.Equal(x => x.Active, true))); + else + provider.Select("Rows", x => x.Id > 5 && x.Active); Assert.Multiple(() => { Assert.That(command.CommandText, Does.Contain(placeholder)); diff --git a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs index 1858f000..5871bb9d 100644 --- a/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs +++ b/src/Migrator.Tests/Providers/Live/DtoSelectIntegrationTests.cs @@ -53,6 +53,10 @@ 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 filter = Filter.Equal(x => x.Active, true).And(Filter.Equal(x => x.State, state)) + .And(Filter.GreaterThan(x => x.Id, minimum).Or(Filter.IsNull(x => x.Optional))); + Assert.That(Provider.Select(table, filter).Select(x => x.Id), Is.EquivalentTo(rows.Select(x => x.Id))); + Assert.That(Provider.Select(table, Filter.Equal(x => x.Active, true).Not()).Single().Id, Is.EqualTo(2)); 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)); diff --git a/src/Migrator/Framework/DtoSelectExtensions.cs b/src/Migrator/Framework/DtoSelectExtensions.cs index a28466ec..0ae3ddd2 100644 --- a/src/Migrator/Framework/DtoSelectExtensions.cs +++ b/src/Migrator/Framework/DtoSelectExtensions.cs @@ -8,6 +8,15 @@ namespace DotNetProjects.Migrator.Framework; /// Materialized, single-table DTO queries for migrations. public static class DtoSelectExtensions { + /// Select DTOs using an explicit typed filter. The filter is required; use Select<T>(table) to read all rows. + public static IReadOnlyList Select(this ITransformationProvider provider, string table, + Filter filter) where T : class, new() + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(filter); + return SelectQuery.Execute(provider, table, DtoMapping.For(typeof(T)), filter.Predicate); + } + /// /// Select public writable DTO properties by column name. Supports comparisons, boolean operators, /// null checks and captured scalar fields; unsupported expressions throw before execution. diff --git a/src/Migrator/Framework/Filter.cs b/src/Migrator/Framework/Filter.cs new file mode 100644 index 00000000..52757d25 --- /dev/null +++ b/src/Migrator/Framework/Filter.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using DotNetProjects.Migrator.Framework.TypedSelect; + +namespace DotNetProjects.Migrator.Framework; + +/// +/// An immutable single-table DTO filter. Selectors must name a direct mapped property; +/// comparisons use SQL null and collation semantics. Values are captured when the filter is built. +/// +public sealed class Filter where T : class, new() +{ + internal SelectPredicate Predicate { get; } + private Filter(SelectPredicate predicate) => Predicate = predicate; + + /// Compare a mapped property with a value of its declared type. + public static Filter Equal(Expression> column, TValue value) => + Compare(column, ExpressionType.Equal, value); + + /// Compare for inequality. SQL NULL rows are excluded unless explicitly included. + public static Filter NotEqual(Expression> column, TValue value) => + Compare(column, ExpressionType.NotEqual, value); + + public static Filter LessThan(Expression> column, TValue value) => + Compare(column, ExpressionType.LessThan, value); + + public static Filter LessThanOrEqual(Expression> column, TValue value) => + Compare(column, ExpressionType.LessThanOrEqual, value); + + public static Filter GreaterThan(Expression> column, TValue value) => + Compare(column, ExpressionType.GreaterThan, value); + + public static Filter GreaterThanOrEqual(Expression> column, TValue value) => + Compare(column, ExpressionType.GreaterThanOrEqual, value); + + /// Test a reference or nullable value property for SQL NULL. + public static Filter IsNull(Expression> column) => NullCheck(column, ExpressionType.Equal); + + /// Test a reference or nullable value property for a non-NULL value. + public static Filter IsNotNull(Expression> column) => NullCheck(column, ExpressionType.NotEqual); + + public Filter And(Filter other) + { + ArgumentNullException.ThrowIfNull(other); + return new Filter(new LogicalPredicate(ExpressionType.AndAlso, Predicate, other.Predicate)); + } + + public Filter Or(Filter other) + { + ArgumentNullException.ThrowIfNull(other); + return new Filter(new LogicalPredicate(ExpressionType.OrElse, Predicate, other.Predicate)); + } + + public Filter Not() => new(new LogicalPredicate(ExpressionType.Not, Predicate)); + + private static Filter Compare(Expression> column, ExpressionType operation, TValue value) => + new(PredicateTranslator.Compare(Property(column), operation, value)); + + private static Filter NullCheck(Expression> column, ExpressionType operation) + { + var property = Property(column); + var type = property.Property.PropertyType; + if (type.IsValueType && Nullable.GetUnderlyingType(type) == null) + throw new ArgumentException($"Property '{property.Property.Name}' is not nullable.", nameof(column)); + return new Filter(new ComparisonPredicate(property, operation, null)); + } + + private static MappedProperty Property(Expression> column) + { + ArgumentNullException.ThrowIfNull(column); + if (column.Body is not MemberExpression { Member: PropertyInfo property } member || + member.Expression != column.Parameters[0] || property.PropertyType != typeof(TValue)) + throw new ArgumentException("Select a direct mapped property using its declared type, without casts or calculations.", nameof(column)); + return DtoMapping.For(typeof(T)).Find(property); + } +}