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))); + } +}