From 5193e1bb7f26609a3f358a1a58dad8d755266178 Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Tue, 20 Oct 2026 02:26:54 +0300 Subject: [PATCH] feat(searchy): send filter values as parameters and sort on any type Filter values were embedded in the expression tree as constants. EF Core then compiled, cached and sent a new SQL text for every distinct value, so a search screen typing different numbers filled the query cache and the database's statement statistics with one entry per value. Values are now captured the way closure variables are, so EF Core sends them as parameters: a query shape compiles once, whatever the values, and lists go as a single array parameter (= ANY (@p) on PostgreSQL). Also: - Values convert with the invariant culture, so "100.5" means the same on every server, and the conversion now covers Guid, enums by name or number, booleans written as 1/0, DateTimeOffset, DateOnly and TimeSpan. - Sorting accepts any property type (bool, long, enums, Guid, nullable numbers...) and dotted paths, not just seven types on top-level properties. - An unknown filter or sort field throws an SWException naming it, instead of a NullReferenceException. - Searchy's source is shared by both packages; each keeps only its text rule (string methods in the core package, escaped ILIKE in PgSql). Adds SW.EfCoreExtensions.PgSql.IntegrationTests (PostgreSQL 17 through Testcontainers) and a pull-request workflow running both test projects. 13 of its 17 cases fail against 10.0.2. Publishes as 10.1.x. --- .github/workflows/nuget-publish.yml | 2 +- .github/workflows/tests.yml | 22 ++ README.md | 140 ++++++------ .../OrdersDatabase.cs | 138 +++++++++++ ...reExtensions.PgSql.IntegrationTests.csproj | 19 ++ .../SearchyTests.cs | 192 ++++++++++++++++ .../ExpressionExtensions.cs | 160 ------------- .../IQueryableOfTExtensions.cs | 216 ------------------ .../SW.EfCoreExtensions.PgSql.csproj | 5 + SW.EfCoreExtensions.PgSql/SearchyTextMatch.cs | 32 +++ SW.EfCoreExtensions.sln | 42 ++++ .../IQueryableOfTExtensions.cs | 216 ------------------ .../{ => Searchy}/ExpressionExtensions.cs | 78 ++++--- .../Searchy/IQueryableOfTExtensions.cs | 123 ++++++++++ .../Searchy}/SearchyFilterExtensions.cs | 0 SW.EfCoreExtensions/Searchy/SearchyValue.cs | 85 +++++++ .../SearchyFilterExtensions.cs | 12 - SW.EfCoreExtensions/SearchyTextMatch.cs | 13 ++ 18 files changed, 788 insertions(+), 707 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 SW.EfCoreExtensions.PgSql.IntegrationTests/OrdersDatabase.cs create mode 100644 SW.EfCoreExtensions.PgSql.IntegrationTests/SW.EfCoreExtensions.PgSql.IntegrationTests.csproj create mode 100644 SW.EfCoreExtensions.PgSql.IntegrationTests/SearchyTests.cs delete mode 100644 SW.EfCoreExtensions.PgSql/ExpressionExtensions.cs delete mode 100644 SW.EfCoreExtensions.PgSql/IQueryableOfTExtensions.cs create mode 100644 SW.EfCoreExtensions.PgSql/SearchyTextMatch.cs delete mode 100644 SW.EfCoreExtensions/IQueryableOfTExtensions.cs rename SW.EfCoreExtensions/{ => Searchy}/ExpressionExtensions.cs (58%) create mode 100644 SW.EfCoreExtensions/Searchy/IQueryableOfTExtensions.cs rename {SW.EfCoreExtensions.PgSql => SW.EfCoreExtensions/Searchy}/SearchyFilterExtensions.cs (100%) create mode 100644 SW.EfCoreExtensions/Searchy/SearchyValue.cs delete mode 100644 SW.EfCoreExtensions/SearchyFilterExtensions.cs create mode 100644 SW.EfCoreExtensions/SearchyTextMatch.cs diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index fcdffde..8e1ff89 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -21,7 +21,7 @@ jobs: SW.EfCoreExtensions/SW.EfCoreExtensions.csproj SW.EfCoreExtensions.PgSql/SW.EfCoreExtensions.PgSql.csproj major-version: '10' - minor-version: '0' + minor-version: '1' dotnet-version: '10.0.x' run-tests: false test-projects: '**/*UnitTests/*.csproj' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a2d5e25 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Unit tests + run: dotnet test SW.EfCoreExtensions.UnitTests + # Starts PostgreSQL in Docker through Testcontainers. + - name: PostgreSQL integration tests + run: dotnet test SW.EfCoreExtensions.PgSql.IntegrationTests diff --git a/README.md b/README.md index e273ac9..744880c 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,73 @@ - -[![GitHub Actions](https://github.com/simplify9/EfCoreExtensions/actions/workflows/nuget-publish.yml/badge.svg)](https://github.com/simplify9/EfCoreExtensions/actions/workflows/nuget-publish.yml) -[![NuGet](https://img.shields.io/nuget/v/SimplyWorks.EfCoreExtensions?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE) - -| **Package** | **Version** | -| :----------------:|:----------------------:| -|`SimplyWorks.EfCoreExtensions`| [![NuGet](https://img.shields.io/nuget/v/SimplyWorks.EfCoreExtensions?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions) | - - -## Overview - -This repository contains open-source Entity Framework Core extensions for .NET, provided by Simplify9. The main NuGet package is [`SimplyWorks.EfCoreExtensions`](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions), targeting `net10.0` and licensed under MIT. - -### Main Features -The extensions are organized by file and provide additional helpers for EF Core, including: - -- **ChangeTrackerExtensions.cs**: Soft deletion, audit values, tenant values, domain event dispatching. -- **DbContextExtensions.cs**: Helpers for working with DbContext and relational types. -- **EntityEntryExtensions.cs**: Property setting utilities. -- **EntityTypeBuilderExtensions.cs**: Entity type builder helpers. -- **ExpressionExtensions.cs**: Expression utilities. -- **ICollectionExtensions.cs**: Collection update helpers. -- **IHostExtensions.cs**: Host-related helpers. -- **IQueryableOfTExtensions.cs**: Queryable extensions and search conditions. -- **ModelBuilderExtensions.cs**: Model builder helpers. -- **OwnedNavigationBuilderExtensions.cs**: Owned navigation builder helpers. - -See the source files for the full list of available extension methods and their usage. - -### Projects -- `SW.EfCoreExtensions`: Main EF Core extensions library. -- `SW.EfCoreExtensions.PgSql`: PostgreSQL-specific extensions. -- `SW.EfCoreExtensions.UnitTests`: Unit tests for the extensions. - -### License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. - -### Build & CI -GitHub Actions are used for CI/CD. See the badge above for status. NuGet packages are published automatically on new releases. - ->`Contact> BuildContact` - ->`Dimensions> BuildDimensions` - ->`Weight> BuildWeight` - ->`Money> BuildMoney` - ->`Audit> BuildAudit` - -#### PropertyBuilderExtensions.cs - -#### RelationalDbType.cs - -#### [Searchy](https://github.com/simplify9/Searchy) ->`Search` - ->`SearchyCondition` - ->`SearchyFilter` - ->`SearchySort` - - -## Getting support 👷 -If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/EfCoreExtensions/issues). We'll get back to you promptly! + +[![GitHub Actions](https://github.com/simplify9/EfCoreExtensions/actions/workflows/nuget-publish.yml/badge.svg)](https://github.com/simplify9/EfCoreExtensions/actions/workflows/nuget-publish.yml) +[![NuGet](https://img.shields.io/nuget/v/SimplyWorks.EfCoreExtensions?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE) + +| **Package** | **Version** | +| :----------------:|:----------------------:| +|`SimplyWorks.EfCoreExtensions`| [![NuGet](https://img.shields.io/nuget/v/SimplyWorks.EfCoreExtensions?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions) | + + +## Overview + +This repository contains open-source Entity Framework Core extensions for .NET, provided by Simplify9. The main NuGet package is [`SimplyWorks.EfCoreExtensions`](https://www.nuget.org/packages/SimplyWorks.EfCoreExtensions), targeting `net10.0` and licensed under MIT. + +### Main Features +The extensions are organized by file and provide additional helpers for EF Core, including: + +- **ChangeTrackerExtensions.cs**: Soft deletion, audit values, tenant values, domain event dispatching. +- **DbContextExtensions.cs**: Helpers for working with DbContext and relational types. +- **EntityEntryExtensions.cs**: Property setting utilities. +- **EntityTypeBuilderExtensions.cs**: Entity type builder helpers. +- **ExpressionExtensions.cs**: Expression utilities. +- **ICollectionExtensions.cs**: Collection update helpers. +- **IHostExtensions.cs**: Host-related helpers. +- **IQueryableOfTExtensions.cs**: Queryable extensions and search conditions. +- **ModelBuilderExtensions.cs**: Model builder helpers. +- **OwnedNavigationBuilderExtensions.cs**: Owned navigation builder helpers. + +See the source files for the full list of available extension methods and their usage. + +### Projects +- `SW.EfCoreExtensions`: Main EF Core extensions library. +- `SW.EfCoreExtensions.PgSql`: PostgreSQL-specific extensions. +- `SW.EfCoreExtensions.UnitTests`: Unit tests for the extensions. + +### License +This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. + +### Build & CI +GitHub Actions are used for CI/CD. See the badge above for status. NuGet packages are published automatically on new releases. + +>`Contact> BuildContact` + +>`Dimensions> BuildDimensions` + +>`Weight> BuildWeight` + +>`Money> BuildMoney` + +>`Audit> BuildAudit` + +#### PropertyBuilderExtensions.cs + +#### RelationalDbType.cs + +#### [Searchy](https://github.com/simplify9/Searchy) +>`Search` + +>`SearchyCondition` + +>`SearchyFilter` + +>`SearchySort` + +Filter values are sent as SQL parameters, so a query shape compiles once and its plan is reused for every +value. Values convert to the field's type with the invariant culture, including `Guid`, enums (by name or +number) and booleans written as `1`/`0`. Sorts accept any property type and dotted paths +(`Customer.Address.City`). In `SimplyWorks.EfCoreExtensions.PgSql`, `StartsWith` and `Contains` are +case-insensitive `ILIKE`, with `%`, `_` and `\` in the value matched literally. + + +## Getting support 👷 +If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/EfCoreExtensions/issues). We'll get back to you promptly! diff --git a/SW.EfCoreExtensions.PgSql.IntegrationTests/OrdersDatabase.cs b/SW.EfCoreExtensions.PgSql.IntegrationTests/OrdersDatabase.cs new file mode 100644 index 0000000..da28770 --- /dev/null +++ b/SW.EfCoreExtensions.PgSql.IntegrationTests/OrdersDatabase.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Testcontainers.PostgreSql; + +namespace SW.EfCoreExtensions.PgSql.IntegrationTests; + +public enum OrderStatus { Draft = 0, Placed = 1, Shipped = 2, Cancelled = 3 } + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } + public string Country { get; set; } +} + +public class Order +{ + public int Id { get; set; } + public string Number { get; set; } + public int CustomerId { get; set; } + public Customer Customer { get; set; } + public OrderStatus Status { get; set; } + public decimal Total { get; set; } + public decimal? Discount { get; set; } + public bool Paid { get; set; } + public long Quantity { get; set; } + public Guid ExternalId { get; set; } + public DateTime PlacedOn { get; set; } + public List Lines { get; set; } = new(); +} + +public class OrderLine +{ + public int Id { get; set; } + public string Sku { get; set; } + public int Quantity { get; set; } +} + +public class OrdersContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet Customers => Set(); +} + +/// +/// One Postgres 17 container per test run with a small order book, plus what EF Core did: the SQL +/// it sent and how many times it compiled a query. +/// +[TestClass] +public static class OrdersDatabase +{ + static PostgreSqlContainer container; + static DbContextOptions options; + + static int compilations; + static readonly List commands = new(); + + public static int Compilations => Volatile.Read(ref compilations); + + public static IReadOnlyList Commands + { + get { lock (commands) return commands.ToList(); } + } + + public static void ClearCommands() + { + lock (commands) commands.Clear(); + } + + public static List All { get; private set; } + + [AssemblyInitialize] + public static async Task Start(TestContext _) + { + container = new PostgreSqlBuilder().WithImage("postgres:17-alpine").Build(); + await container.StartAsync(); + + // One options instance for the run, so every context shares EF Core's query cache, as the + // contexts of one application do. + options = new DbContextOptionsBuilder() + .UseNpgsql(container.GetConnectionString()) + .LogTo(Record, new[] { CoreEventId.QueryCompilationStarting, RelationalEventId.CommandExecuted }, LogLevel.Debug) + .Options; + + await using var db = NewContext(); + await db.Database.EnsureCreatedAsync(); + + var customers = new[] + { + new Customer { Name = "Northwind", Country = "FR" }, + new Customer { Name = "Contoso", Country = "DE" }, + new Customer { Name = "Fabrikam", Country = "ES" }, + }; + db.Customers.AddRange(customers); + + var start = new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + for (var i = 1; i <= 30; i++) + db.Orders.Add(new Order + { + Number = i == 7 ? "PROMO_50%" : $"ORD-{i:000}", + Customer = customers[i % 3], + Status = (OrderStatus)(i % 4), + Total = 10m * i + 0.5m, + Discount = i % 5 == 0 ? null : i % 7, + Paid = i % 2 == 0, + Quantity = 1_000_000_000L * (i % 6), + ExternalId = new Guid($"00000000-0000-0000-0000-{i:000000000000}"), + PlacedOn = start.AddHours(13 * i), + Lines = { new OrderLine { Sku = $"SKU-{i % 4}", Quantity = i } }, + }); + await db.SaveChangesAsync(); + + All = await db.Orders.AsNoTracking().Include(o => o.Customer).Include(o => o.Lines).ToListAsync(); + } + + [AssemblyCleanup] + public static async Task Stop() + { + if (container != null) await container.DisposeAsync(); + } + + public static OrdersContext NewContext() => new(options); + + static void Record(string message) + { + if (message.Contains(nameof(CoreEventId.QueryCompilationStarting))) + Interlocked.Increment(ref compilations); + else + lock (commands) commands.Add(message); + } +} diff --git a/SW.EfCoreExtensions.PgSql.IntegrationTests/SW.EfCoreExtensions.PgSql.IntegrationTests.csproj b/SW.EfCoreExtensions.PgSql.IntegrationTests/SW.EfCoreExtensions.PgSql.IntegrationTests.csproj new file mode 100644 index 0000000..bd62603 --- /dev/null +++ b/SW.EfCoreExtensions.PgSql.IntegrationTests/SW.EfCoreExtensions.PgSql.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + false + + + + + + + + + + + + + + diff --git a/SW.EfCoreExtensions.PgSql.IntegrationTests/SearchyTests.cs b/SW.EfCoreExtensions.PgSql.IntegrationTests/SearchyTests.cs new file mode 100644 index 0000000..f4234aa --- /dev/null +++ b/SW.EfCoreExtensions.PgSql.IntegrationTests/SearchyTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.PrimitiveTypes; +using static SW.EfCoreExtensions.PgSql.IntegrationTests.OrdersDatabase; + +[assembly: DoNotParallelize] + +namespace SW.EfCoreExtensions.PgSql.IntegrationTests; + +[TestClass] +public class SearchyTests +{ + static SearchyCondition Where(string field, SearchyRule rule, object value) => + new(new SearchyFilter { Field = field, Rule = rule, Value = value }); + + static async Task> Search(params SearchyCondition[] conditions) + { + await using var db = NewContext(); + return await db.Orders.AsNoTracking().Search(conditions).OrderBy(o => o.Id).ToListAsync(); + } + + static List Expected(Func predicate) => All.Where(predicate).OrderBy(o => o.Id).ToList(); + + static void AssertSameOrders(IEnumerable expected, IEnumerable actual) => + CollectionAssert.AreEqual(expected.Select(o => o.Id).ToList(), actual.Select(o => o.Id).ToList()); + + [TestMethod] + public async Task Values_are_sent_as_parameters_and_the_query_compiles_once() + { + // Warm up the shape, then search for ten different values. + await Search(Where(nameof(Order.Number), SearchyRule.EqualsTo, "ORD-001")); + var before = Compilations; + ClearCommands(); + + foreach (var i in Enumerable.Range(10, 10)) + Assert.AreEqual(1, (await Search(Where(nameof(Order.Number), SearchyRule.EqualsTo, $"ORD-{i:000}"))).Count); + + Assert.AreEqual(before, Compilations, "a new value must not compile a new query"); + foreach (var sql in Commands) + StringAssert.DoesNotMatch(sql, new System.Text.RegularExpressions.Regex("ORD-01"), "values must not appear in the SQL text"); + } + + [TestMethod] + public async Task Every_rule_with_a_value_uses_a_parameter() + { + ClearCommands(); + await Search( + Where(nameof(Order.Total), SearchyRule.GreaterThan, 12.25m), + Where(nameof(Order.Number), SearchyRule.Contains, "zz-literal-zz"), + Where(nameof(Order.Number), SearchyRule.StartsWith, "yy-literal-yy"), + Where(nameof(Order.Quantity), SearchyRule.Range, new long[] { 7_777_777, 8_888_888 }), + Where(nameof(Order.Number), SearchyRule.EqualsToList, new[] { "xx-literal-xx" })); + + var sql = string.Join("\n", Commands); + foreach (var literal in new[] { "12.25", "zz-literal-zz", "yy-literal-yy", "7777777", "8888888", "xx-literal-xx" }) + Assert.IsFalse(sql.Contains(literal), $"'{literal}' appears in the SQL text:\n{sql}"); + } + + [TestMethod] + public async Task A_list_is_one_array_parameter() + { + ClearCommands(); + var numbers = Enumerable.Range(1, 200).Select(i => $"ORD-{i:000}").ToArray(); + var found = await Search(Where(nameof(Order.Number), SearchyRule.EqualsToList, numbers)); + + AssertSameOrders(Expected(o => numbers.Contains(o.Number)), found); + StringAssert.Contains(Commands.Last(), "= ANY ("); + } + + [TestMethod] + public async Task Comparison_rules_return_the_right_rows() + { + AssertSameOrders(Expected(o => o.Total > 100m), await Search(Where(nameof(Order.Total), SearchyRule.GreaterThan, 100m))); + AssertSameOrders(Expected(o => o.Total >= 100.5m), await Search(Where(nameof(Order.Total), SearchyRule.GreaterThanOrEquals, "100.5"))); + AssertSameOrders(Expected(o => o.Total < 50m), await Search(Where(nameof(Order.Total), SearchyRule.LessThan, 50))); + AssertSameOrders(Expected(o => o.Total <= 50.5m), await Search(Where(nameof(Order.Total), SearchyRule.LessThanOrEquals, 50.5))); + AssertSameOrders(Expected(o => o.Status != OrderStatus.Draft), await Search(Where(nameof(Order.Status), SearchyRule.NotEqualsTo, "Draft"))); + AssertSameOrders(Expected(o => o.Total >= 50m && o.Total < 150m), + await Search(Where(nameof(Order.Total), SearchyRule.Range, new[] { 50m, 150m }))); + } + + [TestMethod] + public async Task Text_rules_are_case_insensitive_and_match_wildcards_literally() + { + AssertSameOrders(Expected(o => o.Number.StartsWith("ORD-01")), await Search(Where(nameof(Order.Number), SearchyRule.StartsWith, "ord-01"))); + AssertSameOrders(Expected(o => o.Number == "PROMO_50%"), await Search(Where(nameof(Order.Number), SearchyRule.Contains, "_50%"))); + AssertSameOrders(Expected(o => o.Customer.Name.Contains("wind")), await Search(Where("Customer.Name", SearchyRule.Contains, "WIND"))); + } + + [TestMethod] + public async Task Values_convert_to_any_field_type() + { + var external = All[4].ExternalId; + AssertSameOrders(Expected(o => o.ExternalId == external), await Search(Where(nameof(Order.ExternalId), SearchyRule.EqualsTo, external.ToString()))); + AssertSameOrders(Expected(o => o.Status == OrderStatus.Shipped), await Search(Where(nameof(Order.Status), SearchyRule.EqualsTo, "shipped"))); + AssertSameOrders(Expected(o => o.Status == OrderStatus.Shipped), await Search(Where(nameof(Order.Status), SearchyRule.EqualsTo, 2L))); + AssertSameOrders(Expected(o => o.Paid), await Search(Where(nameof(Order.Paid), SearchyRule.EqualsTo, "1"))); + AssertSameOrders(Expected(o => o.Discount == null), await Search(Where(nameof(Order.Discount), SearchyRule.EqualsTo, null))); + AssertSameOrders(Expected(o => o.PlacedOn < new DateTime(2026, 10, 3, 0, 0, 0, DateTimeKind.Utc)), + await Search(Where(nameof(Order.PlacedOn), SearchyRule.LessThan, "2026-10-03T00:00:00Z"))); + } + + [TestMethod] + public async Task Numbers_parse_the_same_whatever_the_server_culture() + { + var culture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + AssertSameOrders(Expected(o => o.Total > 100.5m), await Search(Where(nameof(Order.Total), SearchyRule.GreaterThan, "100.5"))); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + + public static IEnumerable SortFields => new[] + { + new object[] { nameof(Order.Paid) }, // bool + new object[] { nameof(Order.Discount) }, // decimal? + new object[] { nameof(Order.Status) }, // enum + new object[] { nameof(Order.ExternalId) }, // Guid + new object[] { nameof(Order.Quantity) }, // long + new object[] { nameof(Order.PlacedOn) }, // DateTime + new object[] { "Customer.Name" }, // a path + new object[] { "customer.country" }, // a path, any case + }; + + [TestMethod] + [DynamicData(nameof(SortFields))] + public async Task Sorts_on_any_type_and_path(string field) + { + await using var db = NewContext(); + // Id as the second key makes the expected order total. + var sorted = await db.Orders.AsNoTracking().Include(o => o.Customer) + .Search(Array.Empty(), new[] { new SearchySort(field, SearchySortOrder.DEC), new SearchySort(nameof(Order.Id), SearchySortOrder.ASC) }) + .ToListAsync(); + + Func key = field.ToLowerInvariant() switch + { + "paid" => o => o.Paid, + "discount" => o => o.Discount, + "status" => o => o.Status, + "externalid" => o => o.ExternalId, + "quantity" => o => o.Quantity, + "placedon" => o => o.PlacedOn, + "customer.name" => o => o.Customer.Name, + _ => o => o.Customer.Country, + }; + // Postgres puts NULLs first in a descending sort, as LINQ's default comparer does in reverse. + var expected = All.OrderByDescending(key, Comparer.Create(PostgresOrder)).ThenBy(o => o.Id).Select(o => o.Id).ToList(); + + CollectionAssert.AreEqual(expected, sorted.Select(o => o.Id).ToList()); + } + + static int PostgresOrder(object a, object b) => (a, b) switch + { + (null, null) => 0, + (null, _) => 1, + (_, null) => -1, + (string x, string y) => string.CompareOrdinal(x, y), + _ => Comparer.Default.Compare(a, b), + }; + + [TestMethod] + public async Task An_unknown_field_names_the_field() + { + var error = await Assert.ThrowsExceptionAsync(() => Search(Where("Nope", SearchyRule.EqualsTo, 1))); + StringAssert.Contains(error.Message, "Nope"); + } + + [TestMethod] + public async Task Paging_and_counting_still_work() + { + await using var db = NewContext(); + var request = new SearchyRequest { PageSize = 5, PageIndex = 1 }; + request.Sorts.Add(new SearchySort(nameof(Order.Id), SearchySortOrder.ASC)); + request.Conditions.Add(Where(nameof(Order.Paid), SearchyRule.EqualsTo, true)); + + var response = await db.Orders.AsNoTracking().ToSearchyResponseAsync(request); + + var paid = All.Where(o => o.Paid).OrderBy(o => o.Id).ToList(); + Assert.AreEqual(paid.Count, response.TotalCount); + CollectionAssert.AreEqual(paid.Skip(5).Take(5).Select(o => o.Id).ToList(), response.Result.Select(o => o.Id).ToList()); + } +} diff --git a/SW.EfCoreExtensions.PgSql/ExpressionExtensions.cs b/SW.EfCoreExtensions.PgSql/ExpressionExtensions.cs deleted file mode 100644 index 7a5fbab..0000000 --- a/SW.EfCoreExtensions.PgSql/ExpressionExtensions.cs +++ /dev/null @@ -1,160 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SW.PrimitiveTypes; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Text; - -namespace SW.EfCoreExtensions -{ - internal static class ExpressionExtensions - { - public static Expression BuildConditionsExpression(this Expression parameter, IEnumerable searchyConditions) - { - Expression finalExpression = null; - - foreach (var searchyCondition in searchyConditions) - { - Expression conditionExpression = null; - - foreach (var searchyFilter in searchyCondition.Filters.Where(f => f.IsValid())) - { - var filterExpression = parameter.BuildFilterExpression(searchyFilter); - - if (conditionExpression == null) - conditionExpression = filterExpression; - - else if (filterExpression != null) - conditionExpression = Expression.AndAlso(conditionExpression, filterExpression); - } - - if (finalExpression == null) - finalExpression = conditionExpression; - else if (conditionExpression != null) - finalExpression = Expression.OrElse(finalExpression, conditionExpression); - } - - return finalExpression; - } - - static Expression BuildFilterExpression(this Expression parameter, ISearchyFilter filter) - { - var fieldSegments = filter.Field.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); - var fieldNameExpression = parameter; - var fieldType = typeof(TEntity); - - foreach (var fieldSegment in fieldSegments) - { - fieldNameExpression = Expression.Property(fieldNameExpression, fieldSegment); - fieldType = fieldType.GetProperty(fieldSegment, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - } - - switch (filter.Rule) - { - case SearchyRule.StartsWith: - return BuildILikeExpression(fieldNameExpression, $"{EscapeLikePattern(filter.Value)}%"); - - case SearchyRule.Contains: - return BuildILikeExpression(fieldNameExpression, $"%{EscapeLikePattern(filter.Value)}%"); - - case SearchyRule.EqualsTo: - return Expression.Equal(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.NotEqualsTo: - return Expression.NotEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.LessThan: - return Expression.LessThan(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.LessThanOrEquals: - return Expression.LessThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.GreaterThan: - return Expression.GreaterThan(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.GreaterThanOrEquals: - return Expression.GreaterThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); - - case SearchyRule.Range: - var rangeValues = PopulateRangeValues(filter.Value); - var lowerRangeExpression = Expression.GreaterThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(rangeValues.Lower, fieldType)); - var upperRangeExpression = Expression.LessThan(fieldNameExpression, GetValueAsConstantExpression(rangeValues.Upper, fieldType)); - return Expression.AndAlso(lowerRangeExpression, upperRangeExpression); - - case SearchyRule.EqualsToList: - var containsMethod = typeof(Enumerable).GetMethods().Where(o => o.Name == "Contains" & o.GetParameters().Count() == 2).First(); - containsMethod = containsMethod.MakeGenericMethod(fieldType); - var equalToListListType = typeof(List<>).MakeGenericType(new[] { fieldType }); - var addMethod = equalToListListType.GetMethod("Add"); - var equalToListList = Activator.CreateInstance(equalToListListType); - foreach (var item in (IEnumerable)filter.Value) - addMethod.Invoke(equalToListList, new object[] { item.ConvertValueToType(fieldType) }); - //filter.Value = equalToList; - return Expression.Call(containsMethod, new[] { Expression.Constant(equalToListList), fieldNameExpression }); - - default: - return null; - } - } - - // The value is escaped so a '%' or '_' in it matches literally, as string.Contains/StartsWith - // do. The escape character has to be passed explicitly: ILike without one makes Npgsql emit - // ESCAPE '', which turns escaping off altogether. - const string LikeEscapeCharacter = "\\"; - - static Expression BuildILikeExpression(Expression fieldNameExpression, string pattern) => - Expression.Call( - typeof(NpgsqlDbFunctionsExtensions), - nameof(NpgsqlDbFunctionsExtensions.ILike), - Type.EmptyTypes, - Expression.Property(null, typeof(EF), nameof(EF.Functions)), - fieldNameExpression, - Expression.Constant(pattern), - Expression.Constant(LikeEscapeCharacter) - ); - - static string EscapeLikePattern(object value) => - $"{value}" - .Replace(LikeEscapeCharacter, LikeEscapeCharacter + LikeEscapeCharacter) - .Replace("%", LikeEscapeCharacter + "%") - .Replace("_", LikeEscapeCharacter + "_"); - - static Expression GetValueAsConstantExpression(object value, Type type) - { - Expression constantExpression = Expression.Constant(value.ConvertValueToType(type)); - if (Nullable.GetUnderlyingType(type) != null) - constantExpression = Expression.Convert(constantExpression, type); - return constantExpression; - } - - static RangeValues PopulateRangeValues(object valueCollection) - { - var rangeValues = new RangeValues(); - int index = 0; - foreach (var item in (IEnumerable)valueCollection) - { - if (index == 0) - rangeValues.Lower = item; - else if (index == 1) - rangeValues.Upper = item; - - index++; - } - - if (index != 2) - throw new ArgumentException("Range rule value collection should contain exactly two values."); - - return rangeValues; - } - - private class RangeValues - { - public object Lower { get; set; } - public object Upper { get; set; } - } - - } -} diff --git a/SW.EfCoreExtensions.PgSql/IQueryableOfTExtensions.cs b/SW.EfCoreExtensions.PgSql/IQueryableOfTExtensions.cs deleted file mode 100644 index 40fec5e..0000000 --- a/SW.EfCoreExtensions.PgSql/IQueryableOfTExtensions.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using SW.PrimitiveTypes; - -namespace SW.EfCoreExtensions -{ - public static class IQueryableOfTExtensions - { - - async public static Task> ToDictionaryAsync(this IQueryable query, SearchyRequest searchyRequest, Func keySelector, Func valueSelector = null) - { - return await query. - Search(searchyRequest.Conditions). - ToDictionaryAsync(keySelector, valueSelector ?? keySelector); - } - - async public static Task> ToSearchyResponseAsync(this IQueryable query, SearchyRequest searchyRequest) - { - return new SearchyResponse - { - Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(), - TotalCount = searchyRequest.CountRows ? await query.Search(searchyRequest.Conditions).CountAsync() : 0 - }; - } - - public static IQueryable Search(this IQueryable target, string field, object valueEquals) - { - return Search(target, new SearchyCondition[] { new SearchyCondition(field, SearchyRule.EqualsTo, valueEquals) }); - } - - public static IQueryable Search(this IQueryable target, string field, SearchyRule rule, object value) - { - return Search(target, new SearchyCondition[] { new SearchyCondition(field, rule, value) }); - } - - public static IQueryable Search(this IQueryable target, SearchyCondition condition) - { - return Search(target, new SearchyCondition[] { condition }); - } - - public static IQueryable Search(this IQueryable target, - IEnumerable conditions, - IEnumerable orders = null, - int pageSize = 0, - int pageIndex = 0) - { - - var param = Expression.Parameter(typeof(TEntity), "TEntity"); - var finalexp = param.BuildConditionsExpression(conditions); - - if (finalexp != null) - { - var finalwhereexp = Expression.Lambda>(finalexp, param); - target = target.Where(finalwhereexp); - } - - if (orders != null && orders.Count() > 0) - { - var mainOrderBy = orders.FirstOrDefault(); - var mainSortMemberType = typeof(TEntity).GetProperty(mainOrderBy.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - - target = target.BuildOrderByThenBy(mainOrderBy, mainSortMemberType, true); - - var searchySorts = new List - { - mainOrderBy - }; - - foreach (var searchySort in orders.Except(searchySorts.AsEnumerable())) - { - var sortMemberType = typeof(TEntity).GetProperty(searchySort.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - target = target.BuildOrderByThenBy(searchySort, sortMemberType, false); - } - } - - if (pageSize > 0 & pageIndex > 0) - target = target.Skip(pageIndex * pageSize).Take(pageSize); - else if (pageSize > 0) - target = target.Take(pageSize); - - return target; - } - - public static IQueryable Search(this IQueryable target, - string navigationProperty, - IEnumerable conditions, - IEnumerable orders = null, - int pageSize = 0, - int pageIndex = 0) - { - var param = Expression.Parameter(typeof(TEntity), "TEntity"); - var _parammany = Expression.Parameter(typeof(TRelated), "TRelated"); - var _finalexp = _parammany.BuildConditionsExpression(conditions); - - if (_finalexp != null) - { - var anyMethond = typeof(Enumerable).GetMethods().Where(m => m.Name == "Any" & m.GetParameters().Length == 2).Single().MakeGenericMethod(typeof(TRelated)); - var innerFunction = Expression.Lambda>(_finalexp, _parammany); - var finalWherEexp = Expression.Lambda>(Expression.Call(anyMethond, Expression.Property(param, typeof(TEntity).GetProperty(navigationProperty, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)), innerFunction), new ParameterExpression[] { param }); - target = target.Where(finalWherEexp); - } - - if (orders != null && orders.Count() > 0) - { - var mainOrderBy = orders.FirstOrDefault(); - var mainSortMemberType = typeof(TEntity).GetProperty(mainOrderBy.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - - target = target.BuildOrderByThenBy(mainOrderBy, mainSortMemberType, true); - - var searchySorts = new List - { - mainOrderBy - }; - - foreach (var searchySort in orders.Except(searchySorts.AsEnumerable())) - { - var sortMemberType = typeof(TEntity).GetProperty(searchySort.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - target = target.BuildOrderByThenBy(searchySort, sortMemberType, false); - } - } - - if (pageSize > 0 & pageIndex > 0) - target = target.Skip(pageIndex * pageSize).Take(pageSize); - else if (pageSize > 0) - target = target.Take(pageSize); - - return target; - - } - - static IQueryable BuildOrderBy(this IQueryable query, SearchySort searchySort) - { - ParameterExpression pe = Expression.Parameter(typeof(TEntity), ""); - if (searchySort.Sort == SearchySortOrder.ASC) - return query.OrderBy(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - else - return query.OrderByDescending(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - } - - static IQueryable BuildThenBy(this IQueryable query, SearchySort searchySort) - { - var pe = Expression.Parameter(typeof(TEntity), ""); - var orderedQuery = (IOrderedQueryable)query; - - if (searchySort.Sort == SearchySortOrder.ASC) - return orderedQuery.ThenBy(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - else - return orderedQuery.ThenByDescending(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - } - - static IQueryable BuildOrderByThenBy(this IQueryable query, SearchySort searchySort, Type type, bool mainOrderBy) - { - switch (true) - { - case object _ when type.Equals(typeof(int)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(string)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(DateTime?)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(DateTime)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(byte)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(short)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(decimal)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - default: - - throw new SWException("Unsupported sort datatype: " + type.ToString()); - - } - } - } -} diff --git a/SW.EfCoreExtensions.PgSql/SW.EfCoreExtensions.PgSql.csproj b/SW.EfCoreExtensions.PgSql/SW.EfCoreExtensions.PgSql.csproj index 230e1a5..b544ce3 100644 --- a/SW.EfCoreExtensions.PgSql/SW.EfCoreExtensions.PgSql.csproj +++ b/SW.EfCoreExtensions.PgSql/SW.EfCoreExtensions.PgSql.csproj @@ -20,6 +20,11 @@ + + + + + True diff --git a/SW.EfCoreExtensions.PgSql/SearchyTextMatch.cs b/SW.EfCoreExtensions.PgSql/SearchyTextMatch.cs new file mode 100644 index 0000000..44929d8 --- /dev/null +++ b/SW.EfCoreExtensions.PgSql/SearchyTextMatch.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace SW.EfCoreExtensions +{ + internal static partial class ExpressionExtensions + { + // The value is escaped so a '%' or '_' in it matches literally, as string.Contains/StartsWith + // do. The escape character has to be passed explicitly: ILike without one makes Npgsql emit + // ESCAPE '', which turns escaping off altogether. + const string LikeEscapeCharacter = "\\"; + + // ILIKE: case-insensitive, and served by pg_trgm indexes like LIKE is. + private static partial Expression TextMatch(Expression field, string value, bool prefixOnly) => + Expression.Call( + typeof(NpgsqlDbFunctionsExtensions), + nameof(NpgsqlDbFunctionsExtensions.ILike), + Type.EmptyTypes, + Expression.Property(null, typeof(EF), nameof(EF.Functions)), + field, + SearchyValue.Captured(prefixOnly ? $"{EscapeLikePattern(value)}%" : $"%{EscapeLikePattern(value)}%", typeof(string)), + Expression.Constant(LikeEscapeCharacter) + ); + + static string EscapeLikePattern(string value) => + value + .Replace(LikeEscapeCharacter, LikeEscapeCharacter + LikeEscapeCharacter) + .Replace("%", LikeEscapeCharacter + "%") + .Replace("_", LikeEscapeCharacter + "_"); + } +} diff --git a/SW.EfCoreExtensions.sln b/SW.EfCoreExtensions.sln index 6398809..d4640dc 100644 --- a/SW.EfCoreExtensions.sln +++ b/SW.EfCoreExtensions.sln @@ -9,24 +9,66 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SW.EfCoreExtensions.UnitTes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.EfCoreExtensions.PgSql", "SW.EfCoreExtensions.PgSql\SW.EfCoreExtensions.PgSql.csproj", "{4714EEB6-9E56-455E-9D5C-37DBBC96D108}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.EfCoreExtensions.PgSql.IntegrationTests", "SW.EfCoreExtensions.PgSql.IntegrationTests\SW.EfCoreExtensions.PgSql.IntegrationTests.csproj", "{50978831-7375-42F8-8706-8DB287845A7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|x64.Build.0 = Debug|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Debug|x86.Build.0 = Debug|Any CPU {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|Any CPU.Build.0 = Release|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|x64.ActiveCfg = Release|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|x64.Build.0 = Release|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|x86.ActiveCfg = Release|Any CPU + {1A8E9683-E003-45B7-B69F-F52BBA1690A2}.Release|x86.Build.0 = Release|Any CPU {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|x64.ActiveCfg = Debug|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|x64.Build.0 = Debug|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|x86.ActiveCfg = Debug|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Debug|x86.Build.0 = Debug|Any CPU {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|Any CPU.ActiveCfg = Release|Any CPU {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|Any CPU.Build.0 = Release|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|x64.ActiveCfg = Release|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|x64.Build.0 = Release|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|x86.ActiveCfg = Release|Any CPU + {6798DF6F-255E-40D5-9581-78D2AB09ABD3}.Release|x86.Build.0 = Release|Any CPU {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|x64.ActiveCfg = Debug|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|x64.Build.0 = Debug|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|x86.ActiveCfg = Debug|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Debug|x86.Build.0 = Debug|Any CPU {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|Any CPU.ActiveCfg = Release|Any CPU {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|Any CPU.Build.0 = Release|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|x64.ActiveCfg = Release|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|x64.Build.0 = Release|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|x86.ActiveCfg = Release|Any CPU + {4714EEB6-9E56-455E-9D5C-37DBBC96D108}.Release|x86.Build.0 = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|x64.ActiveCfg = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|x64.Build.0 = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|x86.ActiveCfg = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Debug|x86.Build.0 = Debug|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|Any CPU.Build.0 = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|x64.ActiveCfg = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|x64.Build.0 = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|x86.ActiveCfg = Release|Any CPU + {50978831-7375-42F8-8706-8DB287845A7B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SW.EfCoreExtensions/IQueryableOfTExtensions.cs b/SW.EfCoreExtensions/IQueryableOfTExtensions.cs deleted file mode 100644 index 40fec5e..0000000 --- a/SW.EfCoreExtensions/IQueryableOfTExtensions.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using SW.PrimitiveTypes; - -namespace SW.EfCoreExtensions -{ - public static class IQueryableOfTExtensions - { - - async public static Task> ToDictionaryAsync(this IQueryable query, SearchyRequest searchyRequest, Func keySelector, Func valueSelector = null) - { - return await query. - Search(searchyRequest.Conditions). - ToDictionaryAsync(keySelector, valueSelector ?? keySelector); - } - - async public static Task> ToSearchyResponseAsync(this IQueryable query, SearchyRequest searchyRequest) - { - return new SearchyResponse - { - Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(), - TotalCount = searchyRequest.CountRows ? await query.Search(searchyRequest.Conditions).CountAsync() : 0 - }; - } - - public static IQueryable Search(this IQueryable target, string field, object valueEquals) - { - return Search(target, new SearchyCondition[] { new SearchyCondition(field, SearchyRule.EqualsTo, valueEquals) }); - } - - public static IQueryable Search(this IQueryable target, string field, SearchyRule rule, object value) - { - return Search(target, new SearchyCondition[] { new SearchyCondition(field, rule, value) }); - } - - public static IQueryable Search(this IQueryable target, SearchyCondition condition) - { - return Search(target, new SearchyCondition[] { condition }); - } - - public static IQueryable Search(this IQueryable target, - IEnumerable conditions, - IEnumerable orders = null, - int pageSize = 0, - int pageIndex = 0) - { - - var param = Expression.Parameter(typeof(TEntity), "TEntity"); - var finalexp = param.BuildConditionsExpression(conditions); - - if (finalexp != null) - { - var finalwhereexp = Expression.Lambda>(finalexp, param); - target = target.Where(finalwhereexp); - } - - if (orders != null && orders.Count() > 0) - { - var mainOrderBy = orders.FirstOrDefault(); - var mainSortMemberType = typeof(TEntity).GetProperty(mainOrderBy.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - - target = target.BuildOrderByThenBy(mainOrderBy, mainSortMemberType, true); - - var searchySorts = new List - { - mainOrderBy - }; - - foreach (var searchySort in orders.Except(searchySorts.AsEnumerable())) - { - var sortMemberType = typeof(TEntity).GetProperty(searchySort.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - target = target.BuildOrderByThenBy(searchySort, sortMemberType, false); - } - } - - if (pageSize > 0 & pageIndex > 0) - target = target.Skip(pageIndex * pageSize).Take(pageSize); - else if (pageSize > 0) - target = target.Take(pageSize); - - return target; - } - - public static IQueryable Search(this IQueryable target, - string navigationProperty, - IEnumerable conditions, - IEnumerable orders = null, - int pageSize = 0, - int pageIndex = 0) - { - var param = Expression.Parameter(typeof(TEntity), "TEntity"); - var _parammany = Expression.Parameter(typeof(TRelated), "TRelated"); - var _finalexp = _parammany.BuildConditionsExpression(conditions); - - if (_finalexp != null) - { - var anyMethond = typeof(Enumerable).GetMethods().Where(m => m.Name == "Any" & m.GetParameters().Length == 2).Single().MakeGenericMethod(typeof(TRelated)); - var innerFunction = Expression.Lambda>(_finalexp, _parammany); - var finalWherEexp = Expression.Lambda>(Expression.Call(anyMethond, Expression.Property(param, typeof(TEntity).GetProperty(navigationProperty, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)), innerFunction), new ParameterExpression[] { param }); - target = target.Where(finalWherEexp); - } - - if (orders != null && orders.Count() > 0) - { - var mainOrderBy = orders.FirstOrDefault(); - var mainSortMemberType = typeof(TEntity).GetProperty(mainOrderBy.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - - target = target.BuildOrderByThenBy(mainOrderBy, mainSortMemberType, true); - - var searchySorts = new List - { - mainOrderBy - }; - - foreach (var searchySort in orders.Except(searchySorts.AsEnumerable())) - { - var sortMemberType = typeof(TEntity).GetProperty(searchySort.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; - target = target.BuildOrderByThenBy(searchySort, sortMemberType, false); - } - } - - if (pageSize > 0 & pageIndex > 0) - target = target.Skip(pageIndex * pageSize).Take(pageSize); - else if (pageSize > 0) - target = target.Take(pageSize); - - return target; - - } - - static IQueryable BuildOrderBy(this IQueryable query, SearchySort searchySort) - { - ParameterExpression pe = Expression.Parameter(typeof(TEntity), ""); - if (searchySort.Sort == SearchySortOrder.ASC) - return query.OrderBy(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - else - return query.OrderByDescending(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - } - - static IQueryable BuildThenBy(this IQueryable query, SearchySort searchySort) - { - var pe = Expression.Parameter(typeof(TEntity), ""); - var orderedQuery = (IOrderedQueryable)query; - - if (searchySort.Sort == SearchySortOrder.ASC) - return orderedQuery.ThenBy(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - else - return orderedQuery.ThenByDescending(Expression.Lambda>(Expression.Property(pe, searchySort.Field), new ParameterExpression[] { pe })); - } - - static IQueryable BuildOrderByThenBy(this IQueryable query, SearchySort searchySort, Type type, bool mainOrderBy) - { - switch (true) - { - case object _ when type.Equals(typeof(int)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(string)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(DateTime?)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(DateTime)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(byte)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(short)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - case object _ when type.Equals(typeof(decimal)): - - if (mainOrderBy) - return query.BuildOrderBy(searchySort); - else - return query.BuildThenBy(searchySort); - - default: - - throw new SWException("Unsupported sort datatype: " + type.ToString()); - - } - } - } -} diff --git a/SW.EfCoreExtensions/ExpressionExtensions.cs b/SW.EfCoreExtensions/Searchy/ExpressionExtensions.cs similarity index 58% rename from SW.EfCoreExtensions/ExpressionExtensions.cs rename to SW.EfCoreExtensions/Searchy/ExpressionExtensions.cs index 8e0cb4b..29aa8d6 100644 --- a/SW.EfCoreExtensions/ExpressionExtensions.cs +++ b/SW.EfCoreExtensions/Searchy/ExpressionExtensions.cs @@ -5,11 +5,12 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using System.Text; namespace SW.EfCoreExtensions { - internal static class ExpressionExtensions + // Shared by both packages; each package supplies TextMatch (string methods in the core package, + // ILIKE in the PostgreSQL one). + internal static partial class ExpressionExtensions { public static Expression BuildConditionsExpression(this Expression parameter, IEnumerable searchyConditions) { @@ -39,73 +40,81 @@ public static Expression BuildConditionsExpression(this Expression para return finalExpression; } - static Expression BuildFilterExpression(this Expression parameter, ISearchyFilter filter) + /// + /// The member access for a dotted path ("Customer.Address.City"), matched case-insensitively, + /// and the path's type. + /// + public static (Expression Member, Type Type) PropertyPath(this Expression parameter, Type rootType, string path) { - var fieldSegments = filter.Field.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); - var fieldNameExpression = parameter; - var fieldType = typeof(TEntity); + var member = parameter; + var type = rootType; - foreach (var fieldSegment in fieldSegments) + foreach (var segment in path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries)) { - fieldNameExpression = Expression.Property(fieldNameExpression, fieldSegment); - fieldType = fieldType.GetProperty(fieldSegment, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance).PropertyType; + var property = type.GetProperty(segment, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) + ?? throw new SWException($"'{segment}' is not a property of {type.Name} (in '{path}')."); + member = Expression.Property(member, property); + type = property.PropertyType; } + return (member, type); + } + + static Expression BuildFilterExpression(this Expression parameter, ISearchyFilter filter) + { + var (fieldNameExpression, fieldType) = parameter.PropertyPath(typeof(TEntity), filter.Field); + switch (filter.Rule) { case SearchyRule.StartsWith: - return Expression.Call(fieldNameExpression, typeof(string).GetMethod("StartsWith", new Type[] { typeof(string) }), GetValueAsConstantExpression(filter.Value, fieldType)); + return TextMatch(fieldNameExpression, $"{filter.Value}", prefixOnly: true); case SearchyRule.Contains: - return Expression.Call(fieldNameExpression, typeof(string).GetMethod("Contains", new Type[] { typeof(string) }), GetValueAsConstantExpression(filter.Value, fieldType)); + return TextMatch(fieldNameExpression, $"{filter.Value}", prefixOnly: false); case SearchyRule.EqualsTo: - return Expression.Equal(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.Equal(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.NotEqualsTo: - return Expression.NotEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.NotEqual(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.LessThan: - return Expression.LessThan(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.LessThan(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.LessThanOrEquals: - return Expression.LessThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.LessThanOrEqual(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.GreaterThan: - return Expression.GreaterThan(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.GreaterThan(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.GreaterThanOrEquals: - return Expression.GreaterThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(filter.Value, fieldType)); + return Expression.GreaterThanOrEqual(fieldNameExpression, SearchyValue.Captured(filter.Value, fieldType)); case SearchyRule.Range: var rangeValues = PopulateRangeValues(filter.Value); - var lowerRangeExpression = Expression.GreaterThanOrEqual(fieldNameExpression, GetValueAsConstantExpression(rangeValues.Lower, fieldType)); - var upperRangeExpression = Expression.LessThan(fieldNameExpression, GetValueAsConstantExpression(rangeValues.Upper, fieldType)); + var lowerRangeExpression = Expression.GreaterThanOrEqual(fieldNameExpression, SearchyValue.Captured(rangeValues.Lower, fieldType)); + var upperRangeExpression = Expression.LessThan(fieldNameExpression, SearchyValue.Captured(rangeValues.Upper, fieldType)); return Expression.AndAlso(lowerRangeExpression, upperRangeExpression); case SearchyRule.EqualsToList: - var containsMethod = typeof(Enumerable).GetMethods().Where(o => o.Name == "Contains" & o.GetParameters().Count() == 2).First(); - containsMethod = containsMethod.MakeGenericMethod(fieldType); - var equalToListListType = typeof(List<>).MakeGenericType(new[] { fieldType }); - var addMethod = equalToListListType.GetMethod("Add"); - var equalToListList = Activator.CreateInstance(equalToListListType); + var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(fieldType)); foreach (var item in (IEnumerable)filter.Value) - addMethod.Invoke(equalToListList, new object[] { item.ConvertValueToType(fieldType) }); - //filter.Value = equalToList; - return Expression.Call(containsMethod, new[] { Expression.Constant(equalToListList), fieldNameExpression }); + list.Add(SearchyValue.Convert(item, fieldType)); + var containsMethod = typeof(Enumerable).GetMethods() + .First(o => o.Name == nameof(Enumerable.Contains) && o.GetParameters().Length == 2) + .MakeGenericMethod(fieldType); + return Expression.Call(containsMethod, SearchyValue.Captured(list, list.GetType()), fieldNameExpression); default: return null; } } - static Expression GetValueAsConstantExpression(object value, Type type) - { - Expression constantExpression = Expression.Constant(value.ConvertValueToType(type)); - if (Nullable.GetUnderlyingType(type) != null) - constantExpression = Expression.Convert(constantExpression, type); - return constantExpression; - } + /// + /// The text rules, which differ by provider. is the raw search text; + /// the implementation must pass it as a captured value, never as a constant. + /// + private static partial Expression TextMatch(Expression field, string value, bool prefixOnly); static RangeValues PopulateRangeValues(object valueCollection) { @@ -132,6 +141,5 @@ private class RangeValues public object Lower { get; set; } public object Upper { get; set; } } - } } diff --git a/SW.EfCoreExtensions/Searchy/IQueryableOfTExtensions.cs b/SW.EfCoreExtensions/Searchy/IQueryableOfTExtensions.cs new file mode 100644 index 0000000..e88ecde --- /dev/null +++ b/SW.EfCoreExtensions/Searchy/IQueryableOfTExtensions.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.PrimitiveTypes; + +namespace SW.EfCoreExtensions +{ + public static class IQueryableOfTExtensions + { + + async public static Task> ToDictionaryAsync(this IQueryable query, SearchyRequest searchyRequest, Func keySelector, Func valueSelector = null) + { + return await query. + Search(searchyRequest.Conditions). + ToDictionaryAsync(keySelector, valueSelector ?? keySelector); + } + + async public static Task> ToSearchyResponseAsync(this IQueryable query, SearchyRequest searchyRequest) + { + return new SearchyResponse + { + Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync(), + TotalCount = searchyRequest.CountRows ? await query.Search(searchyRequest.Conditions).CountAsync() : 0 + }; + } + + public static IQueryable Search(this IQueryable target, string field, object valueEquals) + { + return Search(target, new SearchyCondition[] { new SearchyCondition(field, SearchyRule.EqualsTo, valueEquals) }); + } + + public static IQueryable Search(this IQueryable target, string field, SearchyRule rule, object value) + { + return Search(target, new SearchyCondition[] { new SearchyCondition(field, rule, value) }); + } + + public static IQueryable Search(this IQueryable target, SearchyCondition condition) + { + return Search(target, new SearchyCondition[] { condition }); + } + + public static IQueryable Search(this IQueryable target, + IEnumerable conditions, + IEnumerable orders = null, + int pageSize = 0, + int pageIndex = 0) + { + + var param = Expression.Parameter(typeof(TEntity), "TEntity"); + var finalexp = param.BuildConditionsExpression(conditions); + + if (finalexp != null) + { + var finalwhereexp = Expression.Lambda>(finalexp, param); + target = target.Where(finalwhereexp); + } + + return target.SortAndPage(orders, pageSize, pageIndex); + } + + public static IQueryable Search(this IQueryable target, + string navigationProperty, + IEnumerable conditions, + IEnumerable orders = null, + int pageSize = 0, + int pageIndex = 0) + { + var param = Expression.Parameter(typeof(TEntity), "TEntity"); + var _parammany = Expression.Parameter(typeof(TRelated), "TRelated"); + var _finalexp = _parammany.BuildConditionsExpression(conditions); + + if (_finalexp != null) + { + var anyMethond = typeof(Enumerable).GetMethods().Where(m => m.Name == "Any" & m.GetParameters().Length == 2).Single().MakeGenericMethod(typeof(TRelated)); + var innerFunction = Expression.Lambda>(_finalexp, _parammany); + var finalWherEexp = Expression.Lambda>(Expression.Call(anyMethond, Expression.Property(param, typeof(TEntity).GetProperty(navigationProperty, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)), innerFunction), new ParameterExpression[] { param }); + target = target.Where(finalWherEexp); + } + + return target.SortAndPage(orders, pageSize, pageIndex); + } + + static IQueryable SortAndPage(this IQueryable target, + IEnumerable orders, int pageSize, int pageIndex) + { + var first = true; + foreach (var sort in orders ?? Enumerable.Empty()) + { + target = target.OrderByPath(sort.Field, sort.Sort == SearchySortOrder.DEC, thenBy: !first); + first = false; + } + + if (pageSize > 0 & pageIndex > 0) + target = target.Skip(pageIndex * pageSize).Take(pageSize); + else if (pageSize > 0) + target = target.Take(pageSize); + + return target; + } + + static readonly MethodInfo[] OrderingMethods = typeof(Queryable).GetMethods() + .Where(m => m.GetParameters().Length == 2 && m.Name is nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) + or nameof(Queryable.ThenBy) or nameof(Queryable.ThenByDescending)) + .ToArray(); + + /// + /// Orders by a property path ("Name", "Customer.Address.City") of any type. + /// + static IQueryable OrderByPath(this IQueryable query, string path, bool descending, bool thenBy) + { + var parameter = Expression.Parameter(typeof(TEntity), "e"); + var (member, type) = parameter.PropertyPath(typeof(TEntity), path); + var name = (thenBy ? "ThenBy" : "OrderBy") + (descending ? "Descending" : ""); + var method = OrderingMethods.Single(m => m.Name == name).MakeGenericMethod(typeof(TEntity), type); + return (IQueryable)method.Invoke(null, + new object[] { query, Expression.Lambda(member, parameter) }); + } + } +} diff --git a/SW.EfCoreExtensions.PgSql/SearchyFilterExtensions.cs b/SW.EfCoreExtensions/Searchy/SearchyFilterExtensions.cs similarity index 100% rename from SW.EfCoreExtensions.PgSql/SearchyFilterExtensions.cs rename to SW.EfCoreExtensions/Searchy/SearchyFilterExtensions.cs diff --git a/SW.EfCoreExtensions/Searchy/SearchyValue.cs b/SW.EfCoreExtensions/Searchy/SearchyValue.cs new file mode 100644 index 0000000..45b4b5a --- /dev/null +++ b/SW.EfCoreExtensions/Searchy/SearchyValue.cs @@ -0,0 +1,85 @@ +using System; +using System.Globalization; +using System.Linq.Expressions; + +namespace SW.EfCoreExtensions +{ + /// + /// Turns a filter value into the field's type and into an expression EF Core sends as a parameter. + /// + internal static class SearchyValue + { + /// + /// The value as a member of a captured object, the shape of a closure variable. EF Core turns + /// that into a SQL parameter, so one query shape compiles once and its plan is reused for every + /// value. A constant would compile, cache and send a new SQL text per distinct value. + /// + public static Expression Captured(object value, Type type) + { + var converted = value is null || type.IsInstanceOfType(value) ? value : Convert(value, type); + var holder = Activator.CreateInstance(typeof(Holder<>).MakeGenericType(type), converted); + return Expression.Property(Expression.Constant(holder), nameof(Holder.Value)); + } + + /// + /// Converts with the invariant culture, so a value means the same on every server. Also covers + /// Guid, enums by name or number, and booleans written as 1/0. + /// + public static object Convert(object value, Type type) + { + var target = Nullable.GetUnderlyingType(type) ?? type; + var nullable = !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + if (value is null || value is string { Length: 0 } && nullable && target != typeof(string)) + return nullable ? null : Activator.CreateInstance(type); + + if (target.IsInstanceOfType(value)) + return value; + + var text = value as string ?? (value as IFormattable)?.ToString(null, CultureInfo.InvariantCulture) ?? value.ToString(); + + if (target == typeof(Guid)) + return Guid.Parse(text); + + if (target.IsEnum) + return Enum.Parse(target, text, ignoreCase: true); + + if (target == typeof(bool)) + return text switch + { + "1" => true, + "0" => false, + _ => bool.Parse(text), + }; + + if (target == typeof(DateTime)) + // A UTC designator keeps the value in UTC rather than shifting it to server time. + return value is DateTimeOffset offset + ? offset.UtcDateTime + : DateTime.Parse(text, CultureInfo.InvariantCulture, + text.EndsWith("Z", StringComparison.OrdinalIgnoreCase) + ? DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal + : DateTimeStyles.None); + + if (target == typeof(DateTimeOffset)) + return value is DateTime dateTime + ? new DateTimeOffset(dateTime) + : DateTimeOffset.Parse(text, CultureInfo.InvariantCulture); + + if (target == typeof(TimeSpan)) + return TimeSpan.Parse(text, CultureInfo.InvariantCulture); + + if (target == typeof(DateOnly)) + return DateOnly.Parse(text, CultureInfo.InvariantCulture); + + return System.Convert.ChangeType(value is IConvertible ? value : text, target, CultureInfo.InvariantCulture); + } + + private sealed class Holder + { + public Holder(T value) => Value = value; + + public T Value { get; } + } + } +} diff --git a/SW.EfCoreExtensions/SearchyFilterExtensions.cs b/SW.EfCoreExtensions/SearchyFilterExtensions.cs deleted file mode 100644 index 5966dd5..0000000 --- a/SW.EfCoreExtensions/SearchyFilterExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SW.PrimitiveTypes; - -namespace SW.EfCoreExtensions -{ - internal static class SearchyFilterExtensions - { - public static bool IsValid(this SearchyFilter searchyFilter) - { - return !string.IsNullOrWhiteSpace(searchyFilter.Field) && searchyFilter.Rule != default; - } - } -} diff --git a/SW.EfCoreExtensions/SearchyTextMatch.cs b/SW.EfCoreExtensions/SearchyTextMatch.cs new file mode 100644 index 0000000..ac98cce --- /dev/null +++ b/SW.EfCoreExtensions/SearchyTextMatch.cs @@ -0,0 +1,13 @@ +using System.Linq.Expressions; + +namespace SW.EfCoreExtensions +{ + internal static partial class ExpressionExtensions + { + // string.StartsWith/Contains: LIKE on relational providers, case as the column's collation has it. + private static partial Expression TextMatch(Expression field, string value, bool prefixOnly) => + Expression.Call(field, + typeof(string).GetMethod(prefixOnly ? nameof(string.StartsWith) : nameof(string.Contains), new[] { typeof(string) }), + SearchyValue.Captured(value, typeof(string))); + } +}