From d3f4acafd5b145a9a1ddf60f8e4baf05e5896d6e Mon Sep 17 00:00:00 2001 From: Glauco Knihs Date: Sun, 21 Jun 2026 00:17:26 -0300 Subject: [PATCH 1/5] Add OperationHint support to Criteria classes and tests --- .../Extensions/EFSearchesExtensions.cs | 9 +- .../Services/CriteriaPerformer.cs | 10 +- .../Services/CriteriaPerformerBase.cs | 13 +- .../Services/CriteriaQuery.cs | 53 +++-- .../Services/SearchManager.cs | 13 +- .../CriteriaOperationHintTests.cs | 204 ++++++++++++++++++ .../CriteriaOperationHintModel.cs | 51 +++++ .../RoyalCode.SmartSearch.Tests.csproj | 1 + 8 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs create mode 100644 src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs index 5d6d21e..97cf091 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore.Infrastructure; +using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch; using RoyalCode.SmartSearch.Defaults; using RoyalCode.SmartSearch.EntityFramework.Services; @@ -28,11 +29,17 @@ public static ICriteria Criteria(this DbContext db) var orderByFactory = db.GetService(); var selectorFactory = db.GetService(); + // Optional: resolved only when OperationHint is registered. Uses the raw provider (not db.GetService(), + // which throws when the service is absent) so this path stays a no-op without OperationHint. + var hintPerformer = ((IInfrastructure)db).Instance + .GetService(typeof(IHintPerformer)) as IHintPerformer; + var preparer = new CriteriaPerformer( db, specifierFactory, orderByFactory, - selectorFactory); + selectorFactory, + hintPerformer); var criteria = new Criteria(preparer); diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs index f20c90b..ff6ba99 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch.Linq.Services; using RoyalCode.SmartSearch.Linq.Sortings; @@ -31,12 +32,17 @@ public sealed class CriteriaPerformer : CriteriaPerformerBa /// The factory to create specifiers for the query. /// The provider for ordering the results. /// The factory for selecting results. + /// + /// The optional operation hint performer. Resolved by DI when OperationHint is registered; + /// otherwise (no-op). + /// public CriteriaPerformer( TDbContext db, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, - ISelectorFactory selectorFactory) - : base(specifierFactory, orderByProvider, selectorFactory) + ISelectorFactory selectorFactory, + IHintPerformer? hintPerformer = null) + : base(specifierFactory, orderByProvider, selectorFactory, hintPerformer) { this.db = db; } diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs index ab11f53..ef91755 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch.Defaults; using RoyalCode.SmartSearch.Linq.Services; using RoyalCode.SmartSearch.Linq.Sortings; @@ -23,6 +24,7 @@ public abstract class CriteriaPerformerBase : ICriteriaPerformer /// Creates a new instance of . @@ -30,14 +32,20 @@ public abstract class CriteriaPerformerBase : ICriteriaPerformerThe factory to create specifiers for the query. /// The provider for ordering the results. /// The factory for selecting results. + /// + /// The optional operation hint performer. When provided, ambient hints are applied to the prepared query; + /// when (i.e. OperationHint is not registered), the behavior is unchanged. + /// protected CriteriaPerformerBase( ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, - ISelectorFactory selectorFactory) + ISelectorFactory selectorFactory, + IHintPerformer? hintPerformer = null) { this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; + this.hintPerformer = hintPerformer; } /// @@ -49,7 +57,8 @@ public IPreparedQuery Prepare(CriteriaOptions options) entities, specifierFactory, orderByProvider, - selectorFactory); + selectorFactory, + hintPerformer); foreach (var filter in options.Filters) filter.ApplyFilter(criteriaQuery); diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs index 2f5a1ac..0d38d00 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch.Filtering; using RoyalCode.SmartSearch.Linq.Services; using RoyalCode.SmartSearch.Linq.Sortings; @@ -38,8 +39,10 @@ public class CriteriaQuery : IPreparedQuery, IFilterHandler private readonly ISpecifierFactory specifierFactory; private readonly IOrderByProvider orderByProvider; private readonly ISelectorFactory selectorFactory; + private readonly IHintPerformer? hintPerformer; private IQueryable query; + private IQueryable? hintedQuery; private int pageNumber; private int skip; @@ -62,16 +65,23 @@ public class CriteriaQuery : IPreparedQuery, IFilterHandler /// The factory used to create specifiers for filtering criteria. /// The provider used to define ordering rules for the query results. /// The factory used to create selectors for projecting query results. + /// + /// The optional operation hint performer. When provided (i.e. OperationHint is registered), + /// ambient hints are applied to the query at the entity-materializing terminals. When , + /// the query behaves exactly as before (no-op). + /// public CriteriaQuery( IQueryable query, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, - ISelectorFactory selectorFactory) + ISelectorFactory selectorFactory, + IHintPerformer? hintPerformer = null) { this.query = query; this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; + this.hintPerformer = hintPerformer; } /// @@ -142,17 +152,28 @@ private void CheckSorting() } } - private IQueryable GetQueryableWithSkip() + /// + /// Returns the base query with ambient operation hints applied (e.g. EF includes). + /// + /// + /// The hints are applied once and cached (idempotent). When no is + /// available, or it has no hints, the original query is returned unchanged. This is only used by the + /// entity-materializing terminals; and the record counting must not apply hints. + /// + private IQueryable GetEntityQuery() + => hintPerformer is null ? query : (hintedQuery ??= hintPerformer.Perform(query)); + + private IQueryable GetQueryableWithSkip(bool applyHints = false) { - var queryable = query; + var queryable = applyHints ? GetEntityQuery() : query; if (skip > 0) queryable = queryable.Skip(skip); return queryable; } - private IQueryable GetQueryableWithSkipAndTake(bool count) + private IQueryable GetQueryableWithSkipAndTake(bool count, bool applyHints = false) { - var queryable = GetQueryableWithSkip(); + var queryable = GetQueryableWithSkip(applyHints); if (take > 0) queryable = queryable.Take(take + (count ? 1 : 0)); return queryable; @@ -175,31 +196,31 @@ public Task ExistsAsync(CancellationToken ct = default) /// public TEntity? FirstOrDefault() - => GetQueryableWithSkip().FirstOrDefault(); + => GetQueryableWithSkip(applyHints: true).FirstOrDefault(); /// - public Task FirstOrDefaultAsync(CancellationToken ct = default) - => GetQueryableWithSkip().FirstOrDefaultAsync(ct); + public Task FirstOrDefaultAsync(CancellationToken ct = default) + => GetQueryableWithSkip(applyHints: true).FirstOrDefaultAsync(ct); /// public TEntity Single() - => GetQueryableWithSkip().Single(); - + => GetQueryableWithSkip(applyHints: true).Single(); + /// public Task SingleAsync(CancellationToken ct = default) - => GetQueryableWithSkip().SingleAsync(ct); + => GetQueryableWithSkip(applyHints: true).SingleAsync(ct); /// - public IReadOnlyList ToList() => GetQueryableWithSkipAndTake(false).ToList(); + public IReadOnlyList ToList() => GetQueryableWithSkipAndTake(false, applyHints: true).ToList(); /// public async Task> ToListAsync(CancellationToken ct) - => await GetQueryableWithSkipAndTake(false).ToListAsync(ct); + => await GetQueryableWithSkipAndTake(false, applyHints: true).ToListAsync(ct); /// public IResultList ToResultList() { - var executableQuery = GetQueryableWithSkipAndTake(true); + var executableQuery = GetQueryableWithSkipAndTake(true, applyHints: true); var items = executableQuery.ToList(); var hasNextPage = items.Count > take; @@ -234,7 +255,7 @@ public IResultList ToResultList() /// public async Task> ToResultListAsync(CancellationToken ct) { - var executableQuery = GetQueryableWithSkipAndTake(true); + var executableQuery = GetQueryableWithSkipAndTake(true, applyHints: true); var items = await executableQuery.ToListAsync(ct); var hasNextPage = items.Count > take; @@ -269,7 +290,7 @@ public async Task> ToResultListAsync(CancellationToken ct) /// public async Task> ToAsyncListAsync(CancellationToken ct) { - var executableQuery = GetQueryableWithSkipAndTake(false); + var executableQuery = GetQueryableWithSkipAndTake(false, applyHints: true); var queryCount = lastCount > 0 ? lastCount diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs index d3b81ae..43e9e92 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch.Linq.Services; using RoyalCode.SmartSearch.Linq.Sortings; @@ -17,6 +18,7 @@ public sealed class SearchManager : ISearchManager private readonly ISpecifierFactory specifierFactory; private readonly IOrderByProvider orderByProvider; private readonly ISelectorFactory selectorFactory; + private readonly IHintPerformer? hintPerformer; /// /// Initializes a new instance of the class, @@ -35,16 +37,22 @@ public sealed class SearchManager : ISearchManager /// The factory responsible for creating selectors that determine which fields to include in the search results. /// Must not be null. /// + /// + /// The optional operation hint performer. Resolved by DI when OperationHint is registered; + /// otherwise (no-op). + /// public SearchManager( TDbContext db, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, - ISelectorFactory selectorFactory) + ISelectorFactory selectorFactory, + IHintPerformer? hintPerformer = null) { this.db = db; this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; + this.hintPerformer = hintPerformer; } /// @@ -54,7 +62,8 @@ public ICriteria Criteria() where TEntity : class db, specifierFactory, orderByProvider, - selectorFactory); + selectorFactory, + hintPerformer); return new InternalCriteria(performer); } diff --git a/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs b/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs new file mode 100644 index 0000000..b6105cd --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs @@ -0,0 +1,204 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RoyalCode.OperationHint.Abstractions; +using RoyalCode.SmartSearch.Tests.OperationHints; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// +/// Integration tests for Phase 1 of the "Include via Operation Hints" feature: ambient hints applied to the +/// EF pipeline. +/// +/// +/// Hints declared in the scope's must drive EF includes on the entity-materializing +/// terminals (Collect/First/Single), but must NOT affect Exists (an Any) nor +/// Select<TDto> (a projection). Without OperationHint registered, behavior is unchanged. +/// +/// +public class CriteriaOperationHintTests +{ + private readonly List sqlLog = []; + + private IServiceProvider CreateProvider(bool withOperationHints) + { + var services = new ServiceCollection(); + + // a single shared in-memory connection kept open for the provider lifetime + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + services.AddSingleton(connection); + + services.AddDbContext(builder => builder + .UseSqlite(connection) + .LogTo(sqlLog.Add, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)); + + services.AddEntityFrameworkSearches(s => s.Add()); + + if (withOperationHints) + { + services.ConfigureOperationHints(registry => + { + registry.AddIncludesHandler((hint, includes) => + { + switch (hint) + { + case SampleHints.IncludeSingle: + includes.IncludeReference(e => e.SingleRelation!); + break; + case SampleHints.IncludeMultiple: + includes.IncludeCollection(e => e.MultipleRelation!); + break; + } + }); + }); + } + + var provider = services.BuildServiceProvider(); + Seed(provider); + return provider; + } + + // Seeds one aggregate in its own scope so query scopes start with an empty change tracker + // (otherwise navigation fixup could populate navigations without an actual include). + private static void Seed(IServiceProvider provider) + { + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + db.Add(new RootEntity + { + Name = "root", + SingleRelation = new SampleEntity { Name = "single" }, + MultipleRelation = [new SampleEntity { Name = "multi-1" }, new SampleEntity { Name = "multi-2" }], + }); + + db.SaveChanges(); + } + + private bool LoggedJoin() => sqlLog.Any(s => s.Contains("JOIN", StringComparison.OrdinalIgnoreCase)); + + [Fact] + public void Collect_Should_Apply_AmbientHint_Include_SingleRelation() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull(); + result[0].MultipleRelation.Should().BeNull(); + } + + [Fact] + public void FirstOrDefault_Should_Apply_AmbientHint_Include() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.FirstOrDefault(); + + result.Should().NotBeNull(); + result!.SingleRelation.Should().NotBeNull(); + } + + [Fact] + public void Single_Should_Apply_AmbientHint_Include() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.Single(); + + result.SingleRelation.Should().NotBeNull(); + } + + [Fact] + public void MultipleHints_Should_Union_Includes() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + var container = scope.ServiceProvider.GetRequiredService(); + container.AddHint(SampleHints.IncludeSingle); + container.AddHint(SampleHints.IncludeMultiple); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull(); + result[0].MultipleRelation.Should().NotBeNull().And.HaveCount(2); + } + + [Fact] + public void Exists_Should_Not_Apply_Includes() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + // a materializing terminal applies the include (anchors the contrast) + sqlLog.Clear(); + criteria.Collect(); + LoggedJoin().Should().BeTrue("Collect must honor the ambient include hint"); + + // Exists is an Any(): the include would be wasted, so it must NOT be applied + sqlLog.Clear(); + var exists = criteria.Exists(); + + exists.Should().BeTrue(); + LoggedJoin().Should().BeFalse("Exists must not apply entity includes"); + } + + [Fact] + public void Select_Dto_Should_Not_Apply_Includes() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + sqlLog.Clear(); + var dto = criteria + .Select(r => new RootDto { Id = r.Id, Name = r.Name }) + .FirstOrDefault(); + + dto.Should().NotBeNull(); + dto!.Name.Should().Be("root"); + LoggedJoin().Should().BeFalse("entity includes must be ignored under a Select projection"); + } + + [Fact] + public void Without_OperationHint_Registered_Query_Is_Unchanged() + { + var provider = CreateProvider(withOperationHints: false); + using var scope = provider.CreateScope(); + + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().BeNull("no OperationHint is registered, so no include is applied"); + result[0].MultipleRelation.Should().BeNull(); + } +} + diff --git a/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs b/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs new file mode 100644 index 0000000..2b5e273 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; + +namespace RoyalCode.SmartSearch.Tests.OperationHints; + +/// Hints used by the Phase 1 criteria/include integration tests. +public enum SampleHints +{ + IncludeSingle, + IncludeMultiple, +} + +/// Navigation target for both the reference and the collection relations of . +public class SampleEntity +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; +} + +/// Aggregate root with a single (reference) and a multiple (collection) navigation to . +public class RootEntity +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public SampleEntity? SingleRelation { get; set; } + + public ICollection? MultipleRelation { get; set; } +} + +/// Projection target used to assert that Select<TDto> ignores entity includes. +public class RootDto +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; +} + +/// +/// Test context. Both navigations target ; relationships are configured by convention +/// (mirroring the OperationHint test context). +/// +public class HintsDbContext : DbContext +{ + public HintsDbContext(DbContextOptions options) : base(options) { } + + public DbSet Roots { get; set; } = null!; + + public DbSet Samples { get; set; } = null!; +} diff --git a/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj b/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj index 7406d07..1bc461f 100644 --- a/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj +++ b/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj @@ -5,6 +5,7 @@ + From c80ab352b48930774cab3a7664e84e8a1888ac71 Mon Sep 17 00:00:00 2001 From: Glauco Knihs Date: Sun, 21 Jun 2026 19:14:23 -0300 Subject: [PATCH 2/5] feat: Implement per-query hints using ICriteriaHint and ICriteriaHintVisitor - Added CriteriaHint class to capture hint values of a single enum type. - Introduced ICriteriaHint interface for a provider-agnostic hint carrier. - Created ICriteriaHintVisitor interface for handling hints in a provider-specific manner. - Updated CriteriaPerformer and CriteriaPerformerBase to support hint registry. - Enhanced CriteriaQuery to apply local hints alongside ambient hints. - Implemented RegistryHintVisitor to apply hints from the registry to queries. - Added tests for per-query hints and their interaction with ambient hints. - Documented the usage of hints in the SmartSearch documentation. --- README.md | 75 ++- .../ICriteria.cs | 21 + .../Defaults/Criteria.cs | 13 + .../Defaults/CriteriaOptions.cs | 23 + .../Hints/CriteriaHint.cs | 19 + .../Hints/ICriteriaHint.cs | 40 ++ .../Extensions/EFSearchesExtensions.cs | 19 +- .../Services/CriteriaPerformer.cs | 9 +- .../Services/CriteriaPerformerBase.cs | 12 +- .../Services/CriteriaQuery.cs | 44 +- .../Services/RegistryHintVisitor.cs | 45 ++ .../Services/SearchManager.cs | 12 +- .../CriteriaOperationHintTests.cs | 81 ++- .../CriteriaUseHintsTests.cs | 257 +++++++++ .../CriteriaOperationHintModel.cs | 8 +- .../SpecifierFunctionGeneratorTests.cs | 26 +- src/smartsearch.md | 493 ++++++++++++++++++ 17 files changed, 1176 insertions(+), 21 deletions(-) create mode 100644 src/RoyalCode.SmartSearch.Core/Hints/CriteriaHint.cs create mode 100644 src/RoyalCode.SmartSearch.Core/Hints/ICriteriaHint.cs create mode 100644 src/RoyalCode.SmartSearch.EntityFramework/Services/RegistryHintVisitor.cs create mode 100644 src/RoyalCode.SmartSearch.Tests/CriteriaUseHintsTests.cs create mode 100644 src/smartsearch.md diff --git a/README.md b/README.md index b749389..99be555 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ # Searches -Search Filter Specifier Pattern + +Search / Filter / Specifier pattern for .NET, with an Entity Framework Core implementation. + +Packages: +- `RoyalCode.SmartSearch.Abstractions` — contracts (`ICriteria`, `ISearch<…>`, `ISorting`, …). +- `RoyalCode.SmartSearch.Core` — default implementations (`Criteria`, `Search`, `CriteriaOptions`). +- `RoyalCode.SmartSearch.Linq` — LINQ specifiers/selectors/order-by. +- `RoyalCode.SmartSearch.EntityFramework` — EF Core pipeline (`ICriteria` over a `DbContext`). +- `RoyalCode.SmartSearch.AspNetCore` — ASP.NET Core helpers. + +## Criteria + +Register entities and resolve `ICriteria` (directly or via `ISearchManager`): + +```csharp +services.AddEntityFrameworkSearches(cfg => cfg.Add()); + +var criteria = provider.GetRequiredService>(); +var items = criteria.FilterBy(new MyFilter { Name = "abc" }).Collect(); +``` + +`ICriteria` exposes `FilterBy`, `OrderBy`, `UseHints`, `Select()`, `AsSearch()`, and the terminals +`Collect`/`CollectAsync`, `Exists`/`ExistsAsync`, `FirstOrDefault`/`Single` (+ async). + +## Loading the aggregate graph (Include) via Operation Hints + +`ICriteria` does **not** take a raw `Include(...)` expression. To load navigations (children, owned, 1:1) you declare +the graph **once** per `(entity, hint)` using [`RoyalCode.OperationHint`](https://github.com/Royal-Code/OperationHint), +then reference it by hint. This keeps the contract provider-neutral and centralizes include knowledge. + +> Rule of thumb: a read that returns a **DTO** uses `Select()`; loading the **aggregate** (to mutate or expose its +> graph) uses **hints**. + +### 1. Register the includes once + +```csharp +services.AddEntityFrameworkSearches(cfg => cfg.Add()); + +services.ConfigureOperationHints(registry => + registry.AddIncludesHandler((hint, includes) => + { + if (hint is BlogHints.WithPosts) includes.IncludeCollection(b => b.Posts); + if (hint is BlogHints.WithOwner) includes.IncludeReference(b => b.Owner); + })); + +public enum BlogHints { WithPosts, WithOwner } +``` + +### 2a. Per-query hints — `UseHints` (isolated to this criteria) + +```csharp +var blog = criteria + .FilterBy(new BlogFilter { Id = 5 }) + .UseHints(BlogHints.WithPosts, BlogHints.WithOwner) + .Single(); // blog.Posts and blog.Owner are loaded +``` + +`UseHints` is local: a sibling criteria in the same scope is unaffected. + +### 2b. Ambient hints — for every criteria in the scope + +```csharp +scope.ServiceProvider.GetRequiredService().AddHint(BlogHints.WithPosts); +var blogs = criteria.Collect(); // every criteria in this scope includes Posts +``` + +`UseHints` and ambient hints **union**. + +### Where hints apply + +- ✅ Entity terminals: `Collect`/`ToList`, `FirstOrDefault`, `Single`, and `AsSearch().ToList()`. +- ❌ `Exists()` (it is an `Any()`), and ❌ after `Select()` (projection — the type changes). +- 🔁 The same registration also drives the post-load `Find` path (`IHintPerformer.Perform(entity, source)`). +- 🟢 Without `OperationHint` registered, criteria behave exactly as before (no include applied). diff --git a/src/RoyalCode.SmartSearch.Abstractions/ICriteria.cs b/src/RoyalCode.SmartSearch.Abstractions/ICriteria.cs index ceba2d3..cdf9385 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/ICriteria.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/ICriteria.cs @@ -38,6 +38,27 @@ public interface ICriteria : ICriteriaOptions> ICriteria FilterBy(TFilter filter) where TFilter : class; + /// + /// + /// Adds one or more operation hints to be applied to this criteria only (per-query). + /// + /// + /// Hints are identified by an value and are resolved by the search engine to load + /// the entity graph (e.g. Entity Framework includes) when materializing entities via , + /// or . They are not applied to + /// nor to projections. + /// + /// + /// Unlike ambient hints, these are local to this criteria and never leak to sibling queries in the same scope. + /// Search engines that do not support hints ignore them gracefully. + /// + /// + /// The hint enum type. + /// The hint values to apply. + /// The same instance for chaining calls. + ICriteria UseHints(params THint[] hints) + where THint : Enum; + /// /// /// Adds a sorting object to be applied to the criteria. diff --git a/src/RoyalCode.SmartSearch.Core/Defaults/Criteria.cs b/src/RoyalCode.SmartSearch.Core/Defaults/Criteria.cs index c26e53c..9dbec22 100644 --- a/src/RoyalCode.SmartSearch.Core/Defaults/Criteria.cs +++ b/src/RoyalCode.SmartSearch.Core/Defaults/Criteria.cs @@ -1,4 +1,5 @@  +using RoyalCode.SmartSearch.Hints; using RoyalCode.SmartSearch.Mappings; using RoyalCode.SmartSearch.Services; using System.Linq.Expressions; @@ -89,6 +90,18 @@ public ICriteria FilterBy(TFilter filter) return this; } + /// + public ICriteria UseHints(params THint[] hints) + where THint : Enum + { + ArgumentNullException.ThrowIfNull(hints); + if (hints.Length is 0) + throw new ArgumentException("At least one hint must be provided.", nameof(hints)); + + options.AddHint(new CriteriaHint(hints)); + return this; + } + /// public ICriteria OrderBy(ISorting sorting) { diff --git a/src/RoyalCode.SmartSearch.Core/Defaults/CriteriaOptions.cs b/src/RoyalCode.SmartSearch.Core/Defaults/CriteriaOptions.cs index 46cd6ec..d2d4c9f 100644 --- a/src/RoyalCode.SmartSearch.Core/Defaults/CriteriaOptions.cs +++ b/src/RoyalCode.SmartSearch.Core/Defaults/CriteriaOptions.cs @@ -1,4 +1,5 @@ using RoyalCode.SmartSearch.Filtering; +using RoyalCode.SmartSearch.Hints; namespace RoyalCode.SmartSearch.Defaults; @@ -9,6 +10,7 @@ public sealed class CriteriaOptions { private List? filters; private List? sortings; + private List? hints; /// /// Gets or sets a value indicating whether tracking is enabled. @@ -25,6 +27,17 @@ public sealed class CriteriaOptions /// public IReadOnlyList Sortings => sortings ?? []; + /// + /// + /// Get all per-query hints added to the criteria (via ICriteria.UseHints). + /// + /// + /// These are local to a single criteria/search and are applied to entity-materializing queries by a + /// provider that understands them (e.g. Entity Framework). Providers without hint support ignore them. + /// + /// + public IReadOnlyList Hints => hints ?? []; + /// /// /// Defines that the query will be paged and determines the number of items per page. @@ -109,6 +122,16 @@ public void AddSorting(IEnumerable? sorting) sortings.AddRange(sorting); } + /// + /// Adds a per-query hint carrier to the criteria. + /// + /// The hint carrier. + public void AddHint(ICriteriaHint hint) + { + hints ??= []; + hints.Add(hint); + } + /// /// Whether the query should be paginated. /// diff --git a/src/RoyalCode.SmartSearch.Core/Hints/CriteriaHint.cs b/src/RoyalCode.SmartSearch.Core/Hints/CriteriaHint.cs new file mode 100644 index 0000000..8663730 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Core/Hints/CriteriaHint.cs @@ -0,0 +1,19 @@ +namespace RoyalCode.SmartSearch.Hints; + +/// +/// Default carrier that captures one or more hint values of a single enum type. +/// +/// The hint enum type captured at the call site. +internal sealed class CriteriaHint : ICriteriaHint + where THint : Enum +{ + private readonly THint[] hints; + + public CriteriaHint(THint[] hints) => this.hints = [.. hints]; + + public void Accept(ICriteriaHintVisitor visitor) + { + foreach (var hint in hints) + visitor.Visit(hint); + } +} diff --git a/src/RoyalCode.SmartSearch.Core/Hints/ICriteriaHint.cs b/src/RoyalCode.SmartSearch.Core/Hints/ICriteriaHint.cs new file mode 100644 index 0000000..bf0c4e5 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Core/Hints/ICriteriaHint.cs @@ -0,0 +1,40 @@ +namespace RoyalCode.SmartSearch.Hints; + +/// +/// +/// A provider-agnostic carrier for a per-query hint captured by ICriteria.UseHints. +/// +/// +/// It holds the hint enum value(s) at the call site and exposes them through double-dispatch +/// (), so the closed hint type can be recovered by a provider-specific +/// (e.g. the Entity Framework registry visitor) without coupling the +/// abstraction to any hint mechanism. +/// +/// +public interface ICriteriaHint +{ + /// + /// Dispatches the captured hint(s) to the given . + /// + /// The visitor that will receive each captured hint value. + void Accept(ICriteriaHintVisitor visitor); +} + +/// +/// +/// A visitor that receives the closed hint enum value(s) captured by an . +/// +/// +/// Implemented by provider-specific code (e.g. Entity Framework) to apply the hint to a query, recovering the +/// concrete THint type that was erased when the hint was stored. +/// +/// +public interface ICriteriaHintVisitor +{ + /// + /// Visits a single hint value with its concrete enum type recovered. + /// + /// The hint enum type. + /// The hint value. + void Visit(THint hint) where THint : Enum; +} diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs index 97cf091..129374b 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EFSearchesExtensions.cs @@ -29,17 +29,26 @@ public static ICriteria Criteria(this DbContext db) var orderByFactory = db.GetService(); var selectorFactory = db.GetService(); - // Optional: resolved only when OperationHint is registered. Uses the raw provider (not db.GetService(), - // which throws when the service is absent) so this path stays a no-op without OperationHint. - var hintPerformer = ((IInfrastructure)db).Instance - .GetService(typeof(IHintPerformer)) as IHintPerformer; + IHintPerformer? hintPerformer; + IHintHandlerRegistry? hintRegistry; + try + { + hintPerformer = db.GetService(); + hintRegistry = db.GetService(); + } + catch (InvalidOperationException) + { + hintPerformer = null; + hintRegistry = null; + } var preparer = new CriteriaPerformer( db, specifierFactory, orderByFactory, selectorFactory, - hintPerformer); + hintPerformer, + hintRegistry); var criteria = new Criteria(preparer); diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs index ff6ba99..526631c 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformer.cs @@ -36,13 +36,18 @@ public sealed class CriteriaPerformer : CriteriaPerformerBa /// The optional operation hint performer. Resolved by DI when OperationHint is registered; /// otherwise (no-op). /// + /// + /// The optional hint handler registry, used to apply per-query hints declared via ICriteria.UseHints. + /// Resolved by DI when OperationHint is registered; otherwise (no-op). + /// public CriteriaPerformer( TDbContext db, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, ISelectorFactory selectorFactory, - IHintPerformer? hintPerformer = null) - : base(specifierFactory, orderByProvider, selectorFactory, hintPerformer) + IHintPerformer? hintPerformer = null, + IHintHandlerRegistry? hintRegistry = null) + : base(specifierFactory, orderByProvider, selectorFactory, hintPerformer, hintRegistry) { this.db = db; } diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs index ef91755..40edbc6 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaPerformerBase.cs @@ -25,6 +25,7 @@ public abstract class CriteriaPerformerBase : ICriteriaPerformer /// Creates a new instance of . @@ -36,16 +37,21 @@ public abstract class CriteriaPerformerBase : ICriteriaPerformer (i.e. OperationHint is not registered), the behavior is unchanged. /// + /// + /// The optional hint handler registry, used to apply per-query hints declared via ICriteria.UseHints. + /// protected CriteriaPerformerBase( ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, ISelectorFactory selectorFactory, - IHintPerformer? hintPerformer = null) + IHintPerformer? hintPerformer = null, + IHintHandlerRegistry? hintRegistry = null) { this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; this.hintPerformer = hintPerformer; + this.hintRegistry = hintRegistry; } /// @@ -58,7 +64,9 @@ public IPreparedQuery Prepare(CriteriaOptions options) specifierFactory, orderByProvider, selectorFactory, - hintPerformer); + hintPerformer, + hintRegistry, + options.Hints); foreach (var filter in options.Filters) filter.ApplyFilter(criteriaQuery); diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs index 0d38d00..4ff66d9 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/CriteriaQuery.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using RoyalCode.OperationHint.Abstractions; using RoyalCode.SmartSearch.Filtering; +using RoyalCode.SmartSearch.Hints; using RoyalCode.SmartSearch.Linq.Services; using RoyalCode.SmartSearch.Linq.Sortings; using RoyalCode.SmartSearch.Mappings; @@ -40,6 +41,8 @@ public class CriteriaQuery : IPreparedQuery, IFilterHandler private readonly IOrderByProvider orderByProvider; private readonly ISelectorFactory selectorFactory; private readonly IHintPerformer? hintPerformer; + private readonly IHintHandlerRegistry? hintRegistry; + private readonly IReadOnlyList? localHints; private IQueryable query; private IQueryable? hintedQuery; @@ -70,18 +73,30 @@ public class CriteriaQuery : IPreparedQuery, IFilterHandler /// ambient hints are applied to the query at the entity-materializing terminals. When , /// the query behaves exactly as before (no-op). /// + /// + /// The optional hint handler registry, used to apply per-query (local) hints. When + /// (or there are no local hints), no local hint is applied. + /// + /// + /// The optional per-query hints declared via ICriteria.UseHints. Applied alongside the ambient hints + /// at the entity-materializing terminals, but isolated to this query (never via the ambient container). + /// public CriteriaQuery( IQueryable query, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, ISelectorFactory selectorFactory, - IHintPerformer? hintPerformer = null) + IHintPerformer? hintPerformer = null, + IHintHandlerRegistry? hintRegistry = null, + IReadOnlyList? localHints = null) { this.query = query; this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; this.hintPerformer = hintPerformer; + this.hintRegistry = hintRegistry; + this.localHints = localHints; } /// @@ -153,15 +168,32 @@ private void CheckSorting() } /// - /// Returns the base query with ambient operation hints applied (e.g. EF includes). + /// Returns the base query with operation hints applied (e.g. EF includes): both ambient hints (from the + /// hint container, via ) and per-query hints (declared via + /// ICriteria.UseHints, applied through the registry visitor). /// /// - /// The hints are applied once and cached (idempotent). When no is - /// available, or it has no hints, the original query is returned unchanged. This is only used by the - /// entity-materializing terminals; and the record counting must not apply hints. + /// The hints are applied once and cached (idempotent). When neither source is available, the original + /// query is returned unchanged. This is only used by the entity-materializing terminals; + /// and the record counting must not apply hints. /// private IQueryable GetEntityQuery() - => hintPerformer is null ? query : (hintedQuery ??= hintPerformer.Perform(query)); + { + if (hintedQuery is not null) + return hintedQuery; + + var entityQuery = hintPerformer is null ? query : hintPerformer.Perform(query); + + if (hintRegistry is not null && localHints is { Count: > 0 }) + { + var visitor = new RegistryHintVisitor>(hintRegistry, entityQuery); + foreach (var hint in localHints) + hint.Accept(visitor); + entityQuery = visitor.Query; + } + + return hintedQuery = entityQuery; + } private IQueryable GetQueryableWithSkip(bool applyHints = false) { diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/RegistryHintVisitor.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/RegistryHintVisitor.cs new file mode 100644 index 0000000..71a93f8 --- /dev/null +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/RegistryHintVisitor.cs @@ -0,0 +1,45 @@ +using RoyalCode.OperationHint.Abstractions; +using RoyalCode.SmartSearch.Hints; + +namespace RoyalCode.SmartSearch.EntityFramework.Services; + +/// +/// +/// Applies per-query criteria hints to an Entity Framework query using the operation hint registry. +/// +/// +/// This is the only place where the criteria hint carrier (provider-neutral, in Core) meets the +/// : the carrier supplies the closed THint through +/// , while this visitor supplies the query type +/// and the registry — resolving both generic arguments by double-dispatch. +/// +/// +/// The query type (e.g. IQueryable<TEntity>). +internal sealed class RegistryHintVisitor : ICriteriaHintVisitor + where TQuery : class +{ + private readonly IHintHandlerRegistry registry; + + /// + /// Creates a new visitor over the given . + /// + /// The hint handler registry. + /// The query to which hints will be applied. + public RegistryHintVisitor(IHintHandlerRegistry registry, TQuery query) + { + this.registry = registry; + Query = query; + } + + /// + /// The query after the visited hints have been applied. + /// + public TQuery Query { get; private set; } + + /// + public void Visit(THint hint) where THint : Enum + { + foreach (var handler in registry.GetQueryHandlers()) + Query = handler.Handle(Query, hint); + } +} diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs b/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs index 43e9e92..9025205 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Services/SearchManager.cs @@ -19,6 +19,7 @@ public sealed class SearchManager : ISearchManager private readonly IOrderByProvider orderByProvider; private readonly ISelectorFactory selectorFactory; private readonly IHintPerformer? hintPerformer; + private readonly IHintHandlerRegistry? hintRegistry; /// /// Initializes a new instance of the class, @@ -41,18 +42,24 @@ public sealed class SearchManager : ISearchManager /// The optional operation hint performer. Resolved by DI when OperationHint is registered; /// otherwise (no-op). /// + /// + /// The optional hint handler registry, used to apply per-query hints declared via ICriteria.UseHints. + /// Resolved by DI when OperationHint is registered; otherwise (no-op). + /// public SearchManager( TDbContext db, ISpecifierFactory specifierFactory, IOrderByProvider orderByProvider, ISelectorFactory selectorFactory, - IHintPerformer? hintPerformer = null) + IHintPerformer? hintPerformer = null, + IHintHandlerRegistry? hintRegistry = null) { this.db = db; this.specifierFactory = specifierFactory; this.orderByProvider = orderByProvider; this.selectorFactory = selectorFactory; this.hintPerformer = hintPerformer; + this.hintRegistry = hintRegistry; } /// @@ -63,7 +70,8 @@ public ICriteria Criteria() where TEntity : class specifierFactory, orderByProvider, selectorFactory, - hintPerformer); + hintPerformer, + hintRegistry); return new InternalCriteria(performer); } diff --git a/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs b/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs index b6105cd..6969abc 100644 --- a/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs +++ b/src/RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs @@ -145,6 +145,86 @@ public void MultipleHints_Should_Union_Includes() result[0].MultipleRelation.Should().NotBeNull().And.HaveCount(2); } + [Fact] + public void DbContextCriteria_Should_Apply_AmbientHint_Include() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var db = scope.ServiceProvider.GetRequiredService(); + var criteria = db.Criteria(); + + var result = criteria.Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull(); + } + + [Fact] + public void DbContextCriteria_WithoutOperationHint_Registered_Query_Is_Unchanged() + { + var provider = CreateProvider(withOperationHints: false); + using var scope = provider.CreateScope(); + + var db = scope.ServiceProvider.GetRequiredService(); + var criteria = db.Criteria(); + + var result = criteria.Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().BeNull("no OperationHint is registered, so no include is applied"); + result[0].MultipleRelation.Should().BeNull(); + } + + [Fact] + public void AsSearch_ToList_Should_Apply_AmbientHint_Include_ForEntityResults() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.UsePages().AsSearch().ToList(); + + result.Items.Should().ContainSingle(); + result.Items[0].SingleRelation.Should().NotBeNull(); + } + + [Fact] + public async Task AsSearch_ToListAsync_Should_Apply_AmbientHint_Include_ForEntityResults() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = await criteria.UsePages().AsSearch().ToListAsync(CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].SingleRelation.Should().NotBeNull(); + } + + [Fact] + public async Task AsSearch_ToAsyncListAsync_Should_Apply_AmbientHint_Include_ForEntityResults() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeSingle); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = await criteria.UsePages().AsSearch().ToAsyncListAsync(CancellationToken.None); + var items = new List(); + await foreach (var item in result.Items) + items.Add(item); + + items.Should().ContainSingle(); + items[0].SingleRelation.Should().NotBeNull(); + } + [Fact] public void Exists_Should_Not_Apply_Includes() { @@ -201,4 +281,3 @@ public void Without_OperationHint_Registered_Query_Is_Unchanged() result[0].MultipleRelation.Should().BeNull(); } } - diff --git a/src/RoyalCode.SmartSearch.Tests/CriteriaUseHintsTests.cs b/src/RoyalCode.SmartSearch.Tests/CriteriaUseHintsTests.cs new file mode 100644 index 0000000..557ee93 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/CriteriaUseHintsTests.cs @@ -0,0 +1,257 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RoyalCode.OperationHint.Abstractions; +using RoyalCode.SmartSearch.Tests.OperationHints; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// +/// Integration tests for Phase 2: per-query hints declared via . +/// +/// +/// Local hints must drive EF includes on entity-materializing terminals, must be isolated to the criteria that +/// declared them (never leaking to sibling criterias in the same scope), must union with ambient hints, must NOT +/// affect Exists or Select<TDto>, and must be a safe no-op when no handler/registry exists. +/// +/// +public class CriteriaUseHintsTests +{ + private readonly List sqlLog = []; + + private IServiceProvider CreateProvider(bool withOperationHints) + { + var services = new ServiceCollection(); + + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + services.AddSingleton(connection); + + services.AddDbContext(builder => builder + .UseSqlite(connection) + .LogTo(sqlLog.Add, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)); + + services.AddEntityFrameworkSearches(s => s.Add()); + + if (withOperationHints) + { + services.ConfigureOperationHints(registry => + { + registry.AddIncludesHandler((hint, includes) => + { + switch (hint) + { + case SampleHints.IncludeSingle: + includes.IncludeReference(e => e.SingleRelation!); + break; + case SampleHints.IncludeMultiple: + includes.IncludeCollection(e => e.MultipleRelation!); + break; + } + }); + }); + } + + var provider = services.BuildServiceProvider(); + Seed(provider); + return provider; + } + + private static void Seed(IServiceProvider provider) + { + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + db.Add(new RootEntity + { + Name = "root", + SingleRelation = new SampleEntity { Name = "single" }, + MultipleRelation = [new SampleEntity { Name = "multi-1" }, new SampleEntity { Name = "multi-2" }], + }); + + db.SaveChanges(); + } + + private bool LoggedJoin() => sqlLog.Any(s => s.Contains("JOIN", StringComparison.OrdinalIgnoreCase)); + + [Fact] + public void UseHints_Should_Apply_Include_On_Collect() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.UseHints(SampleHints.IncludeSingle).Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull(); + result[0].MultipleRelation.Should().BeNull(); + } + + [Fact] + public void UseHints_Should_Apply_Include_On_FirstOrDefault() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.UseHints(SampleHints.IncludeSingle).FirstOrDefault(); + + result.Should().NotBeNull(); + result!.SingleRelation.Should().NotBeNull(); + } + + [Fact] + public void UseHints_Should_Apply_Include_On_Single() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.UseHints(SampleHints.IncludeSingle).Single(); + + result.SingleRelation.Should().NotBeNull(); + } + + [Fact] + public void UseHints_Multiple_Values_Should_Union_Includes() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria + .UseHints(SampleHints.IncludeSingle, SampleHints.IncludeMultiple) + .Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull(); + result[0].MultipleRelation.Should().NotBeNull().And.HaveCount(2); + } + + [Fact] + public void UseHints_With_Null_Hints_Should_Throw() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var act = () => criteria.UseHints(null!); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be("hints"); + } + + [Fact] + public void UseHints_With_Empty_Hints_Should_Throw() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var act = () => criteria.UseHints(); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be("hints"); + } + + [Fact] + public void UseHints_Is_Isolated_Between_Criterias_In_Same_Scope() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + var criteriaWithHint = scope.ServiceProvider.GetRequiredService>(); + var criteriaWithoutHint = scope.ServiceProvider.GetRequiredService>(); + + sqlLog.Clear(); + criteriaWithHint.UseHints(SampleHints.IncludeSingle).Collect(); + LoggedJoin().Should().BeTrue("the criteria that declared UseHints must include"); + + sqlLog.Clear(); + criteriaWithoutHint.Collect(); + LoggedJoin().Should().BeFalse("a local UseHint must not leak to a sibling criteria in the same scope"); + } + + [Fact] + public void UseHints_Combined_With_Ambient_Hint_Unions_Includes() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + + // ambient hint in the container... + scope.ServiceProvider.GetRequiredService().AddHint(SampleHints.IncludeMultiple); + // ...combined with a per-query hint + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = criteria.UseHints(SampleHints.IncludeSingle).Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().NotBeNull("from the per-query UseHints"); + result[0].MultipleRelation.Should().NotBeNull("from the ambient hint").And.HaveCount(2); + } + + [Fact] + public void UseHints_Should_Not_Affect_Exists() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + sqlLog.Clear(); + var exists = criteria.UseHints(SampleHints.IncludeSingle).Exists(); + + exists.Should().BeTrue(); + LoggedJoin().Should().BeFalse("Exists must not apply per-query includes"); + } + + [Fact] + public void UseHints_Should_Not_Affect_Select_Dto() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + sqlLog.Clear(); + var dto = criteria + .UseHints(SampleHints.IncludeSingle) + .Select(r => new RootDto { Id = r.Id, Name = r.Name }) + .FirstOrDefault(); + + dto.Should().NotBeNull(); + dto!.Name.Should().Be("root"); + LoggedJoin().Should().BeFalse("per-query includes must be ignored under a Select projection"); + } + + [Fact] + public void UseHints_Without_OperationHint_Registered_Is_Safe_NoOp() + { + var provider = CreateProvider(withOperationHints: false); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + // no registry available: the hint is silently ignored, no exception + var result = criteria.UseHints(SampleHints.IncludeSingle).Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().BeNull(); + } + + [Fact] + public void UseHints_With_Unregistered_Hint_Is_Safe_NoOp() + { + var provider = CreateProvider(withOperationHints: true); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + // OperationHint is registered, but no handler exists for this enum type: no-op, no exception + var result = criteria.UseHints(UnregisteredHints.Something).Collect(); + + result.Should().ContainSingle(); + result[0].SingleRelation.Should().BeNull(); + } +} diff --git a/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs b/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs index 2b5e273..91a1a33 100644 --- a/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs +++ b/src/RoyalCode.SmartSearch.Tests/OperationHints/CriteriaOperationHintModel.cs @@ -2,13 +2,19 @@ namespace RoyalCode.SmartSearch.Tests.OperationHints; -/// Hints used by the Phase 1 criteria/include integration tests. +/// Hints used by the criteria/include integration tests. public enum SampleHints { IncludeSingle, IncludeMultiple, } +/// A hint enum for which no handler is ever registered (used to assert graceful no-op). +public enum UnregisteredHints +{ + Something, +} + /// Navigation target for both the reference and the collection relations of . public class SampleEntity { diff --git a/src/RoyalCode.SmartSearch.Tests/SpecifierFunctionGeneratorTests.cs b/src/RoyalCode.SmartSearch.Tests/SpecifierFunctionGeneratorTests.cs index ad2acdf..2d6329d 100644 --- a/src/RoyalCode.SmartSearch.Tests/SpecifierFunctionGeneratorTests.cs +++ b/src/RoyalCode.SmartSearch.Tests/SpecifierFunctionGeneratorTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using RoyalCode.SmartSearch.Linq; using RoyalCode.SmartSearch.Linq.Filtering; +using RoyalCode.SmartSearch.Linq.Services; using System.Collections; using System.Linq.Expressions; @@ -424,9 +425,30 @@ public void ConfigureSpecifierGenerator_RunningWithFilter_NotNullProperty_Must_A Assert.Equal(expectedCount, query.Count()); } + [Fact] public void Factory_Must_UseTheSpecifierFilterMethod() { - // todo + // arrange + SimpleFilterManual.Reset(); + var services = new ServiceCollection(); + services.AddSmartSearchLinq(); + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var query = new[] + { + new SimpleModel { Id = 1, Name = "Alpha" }, + new SimpleModel { Id = 2, Name = "Beta" }, + new SimpleModel { Id = 3, Name = "Alphabet" } + }.AsQueryable(); + var filter = new SimpleFilterManual { Name = "Alpha" }; + + // act + var specifier = factory.GetSpecifier(); + var result = specifier.Specify(query, filter).ToList(); + + // assert + Assert.True(SimpleFilterManual.Called); + Assert.Equal([1, 3], result.Select(m => m.Id)); } } @@ -452,6 +474,8 @@ public class SimpleFilterManual public string Name { get; set; } = null!; + public static void Reset() => Called = false; + public IQueryable Filter(IQueryable query) { Called = true; diff --git a/src/smartsearch.md b/src/smartsearch.md new file mode 100644 index 0000000..ff589e2 --- /dev/null +++ b/src/smartsearch.md @@ -0,0 +1,493 @@ +# SmartSearch - Documentacao Orientada a IA + +SmartSearch implementa busca declarativa para .NET usando filtros, specifiers, sorting, paginacao, selecao para DTO e execucao sobre Entity Framework Core. + +## Pacotes + +- `RoyalCode.SmartSearch.Abstractions`: contratos como `ICriteria`, `ISearch`, `ISorting`, `IResultList`. +- `RoyalCode.SmartSearch.Core`: implementacoes padrao, `Criteria`, `Search`, `CriteriaOptions`. +- `RoyalCode.SmartSearch.Linq`: geracao de expressoes, specifiers, selectors e order-by. +- `RoyalCode.SmartSearch.EntityFramework`: pipeline EF Core sobre `DbContext`. +- `RoyalCode.SmartSearch.AspNetCore`: helpers para endpoints. + +## Setup Basico com EF Core + +Registre o `DbContext` e as entidades pesquisaveis: + +```csharp +services.AddDbContext(options => options.UseSqlServer(connectionString)); + +services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + cfg.Add(); +}); +``` + +Resolva `ICriteria` diretamente: + +```csharp +var criteria = scope.ServiceProvider.GetRequiredService>(); +``` + +Ou use `ISearchManager`: + +```csharp +var manager = scope.ServiceProvider.GetRequiredService>(); +var criteria = manager.Criteria(); +``` + +Tambem existe a extensao sobre `DbContext`: + +```csharp +var criteria = db.Criteria(); +``` + +## Fluxo Mental + +- Use `FilterBy(filter)` para aplicar criterios declarativos. +- Use `OrderBy(...)` para ordenar. +- Use `UsePages(...)`, `FetchPage(...)`, `Skip(...)`, `Take(...)` ou `SkipTake(...)` para limitar resultados. +- Use `Select()` quando o retorno e DTO. +- Use `UseHints(...)` quando o retorno e entidade e voce precisa carregar o grafo do agregado. +- Use `Collect()` para lista simples rastreada pelo EF. +- Use `AsSearch().ToList()` para `ResultList` com metadados de pagina. + +## Filtros + +Um filtro e uma classe com propriedades que representam criterios de busca. Valores vazios sao normalmente ignorados. + +Por convencao, toda propriedade publica do filtro vira um criterio usando o mesmo nome no modelo. Use `[Criterion]` +apenas quando precisar configurar algo: operador, caminho alvo, negacao, ignorar propriedade ou regra de valor vazio. +`[Criterion]` sem configuracao e equivalente a nao colocar atributo. + +```csharp +public sealed class OrderFilter +{ + public int? Id { get; set; } + + public string? Number { get; set; } + + [Criterion(TargetProperty = "Customer.Name")] + public string? CustomerName { get; set; } +} +``` + +Uso: + +```csharp +var orders = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .Collect(); +``` + +`FilterBy` recebe um objeto filtro. Nao passe lambda/predicate para `FilterBy`. + +## Filtros Manuais por Metodo + +Quando a regra de filtro nao cabe bem em criterios por propriedade, o proprio filtro pode declarar um metodo +publico que recebe e retorna `IQueryable`. O nome recomendado e `Filter`: + +```csharp +public sealed class OrderFilter +{ + public string? Text { get; set; } + + public IQueryable Filter(IQueryable query) + { + if (!string.IsNullOrWhiteSpace(Text)) + { + query = query.Where(o => + o.Number.Contains(Text) || + o.Customer.Name.Contains(Text)); + } + + return query; + } +} +``` + +Esse metodo e descoberto automaticamente pelo SmartSearch quando nao existe specifier ja registrado ou +resolvido por DI para o par modelo/filtro. Quando ele existe, ele representa o filtro completo; as +propriedades do filtro nao sao processadas novamente pelo gerador por convencao. + +## Operadores de Filtro + +Use `CriterionAttribute` para controlar operador e caminho alvo: + +```csharp +public sealed class InvoiceFilter +{ + [Criterion(Operator = CriterionOperator.GreaterThanOrEqual, TargetProperty = "CreatedAt")] + public DateTime? CreatedAtStart { get; set; } + + [Criterion(Operator = CriterionOperator.LessThanOrEqual, TargetProperty = "CreatedAt")] + public DateTime? CreatedAtEnd { get; set; } +} +``` + +Caminhos aninhados podem ser passados por `TargetProperty`: + +```csharp +[Criterion(TargetProperty = "Customer.Email")] +public string? Email { get; set; } +``` + +## OR / Disjuncao + +Use `[Disjuction("grupo")]` para combinar membros em OR: + +```csharp +public sealed class ContactFilter +{ + [Disjuction("contact")] + public string? Email { get; set; } + + [Disjuction("contact")] + public string? Phone { get; set; } +} +``` + +Tambem ha convencao por nome/caminho contendo `Or`: + +```csharp +public sealed class PersonFilter +{ + public string? FirstNameOrLastName { get; set; } +} +``` + +Se `Or` faz parte do nome e nao deve indicar disjuncao, use `DisableOrFromName`. + +## Sorting + +Sorting dinamico usa `Sorting`: + +```csharp +criteria.OrderBy(new Sorting +{ + OrderBy = "CreatedAt", + Direction = ListSortDirection.Descending +}); +``` + +Registre order-by nomeado quando quiser mapear nomes estaveis para expressoes: + +```csharp +services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + cfg.AddOrderBy("CustomerName", o => o.Customer.Name); +}); +``` + +Uso: + +```csharp +criteria.OrderBy(new Sorting { OrderBy = "CustomerName" }); +``` + +## Paginacao e Limites + +`ICriteria` herda opcoes comuns: + +```csharp +criteria.UsePages(itemsPerPage: 20, pageNumber: 1); +criteria.FetchPage(2); +criteria.Skip(10); +criteria.Take(50); +criteria.SkipTake(skip: 10, take: 50); +criteria.UseCount(); +criteria.UseLastCount(lastCount); +``` + +Para `AsSearch().ToList()`, informe pagina ou limite quando espera itens no `ResultList`: + +```csharp +var page = criteria + .UsePages(itemsPerPage: 20, pageNumber: 1) + .AsSearch() + .ToList(); +``` + +`Collect()` nao retorna metadados de pagina; ele retorna apenas os itens. + +## Terminais Comuns + +### Collect + +Materializa entidades em lista simples. No EF Core, preserva tracking. + +```csharp +IReadOnlyList orders = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .Collect(); +``` + +Async: + +```csharp +var orders = await criteria.CollectAsync(ct); +``` + +### AsSearch().ToList + +Retorna `IResultList` ou `IResultList` com metadados: + +```csharp +var result = criteria + .UsePages(20, 1) + .AsSearch() + .ToList(); + +var items = result.Items; +var total = result.Count; +``` + +Async: + +```csharp +var result = await criteria + .UsePages(20, 1) + .AsSearch() + .ToListAsync(ct); +``` + +### Exists + +Executa como existencia (`Any`). Nao aplica includes/hints. + +```csharp +var exists = criteria + .FilterBy(new OrderFilter { Id = 10 }) + .Exists(); +``` + +### FirstOrDefault + +Retorna o primeiro item ou `null`. + +```csharp +var order = criteria + .FilterBy(new OrderFilter { Number = "A-001" }) + .FirstOrDefault(); +``` + +### Single + +Retorna exatamente um item. Lanca se nao houver nenhum ou se houver mais de um. + +```csharp +var order = criteria + .FilterBy(new OrderFilter { Id = 10 }) + .Single(); +``` + +## Projecao para DTO + +Use `Select()` quando quer retorno em DTO. + +```csharp +public sealed class OrderDto +{ + public int Id { get; set; } + public string Number { get; set; } = ""; +} +``` + +Uso por selector configurado ou convencao: + +```csharp +var result = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .Select() + .UsePages(20, 1) + .AsSearch() + .ToList(); +``` + +Uso com expressao: + +```csharp +var dto = criteria + .Select(o => new OrderDto { Id = o.Id, Number = o.Number }) + .FirstOrDefault(); +``` + +Registre selector quando quiser uma expressao centralizada: + +```csharp +services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + cfg.AddSelector(o => new OrderDto + { + Id = o.Id, + Number = o.Number + }); +}); +``` + +## Operation Hint no SmartSearch + +SmartSearch nao expoe `Include(Expression<...>)` no contrato `ICriteria`. Para carregar navegacoes do agregado, use Operation Hint. + +Use hints quando o retorno e entidade e voce precisa carregar o grafo. Use `Select()` quando o retorno e DTO. + +### Pacotes + +No projeto EF/infra, use: + +```csharp +dotnet add package RoyalCode.OperationHint.EntityFramework +``` + +`RoyalCode.SmartSearch.EntityFramework` ja referencia `RoyalCode.OperationHint.Abstractions`. + +### Registrar Includes + +Registre o grafo uma vez por `(entidade, hint)`: + +```csharp +public enum OrderHints +{ + WithCustomer, + WithItems +} + +services.AddEntityFrameworkSearches(cfg => cfg.Add()); + +services.ConfigureOperationHints(registry => + registry.AddIncludesHandler((hint, includes) => + { + if (hint is OrderHints.WithCustomer) + includes.IncludeReference(o => o.Customer); + + if (hint is OrderHints.WithItems) + includes.IncludeCollection(o => o.Items); + })); +``` + +`AddIncludesHandler` tambem registra o handler de entidade usado por `IHintPerformer.Perform(entity, db)` no caminho pos-carga. + +### Hints por Consulta: UseHints + +`UseHints` e local da criteria e nao vaza para outras criterias no mesmo escopo. + +```csharp +var order = criteria + .FilterBy(new OrderFilter { Id = 10 }) + .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) + .Single(); +``` + +Tambem funciona com `Collect`, `FirstOrDefault`, `Single`, `CollectAsync`, `FirstOrDefaultAsync`, `SingleAsync` e caminhos de entidade via `AsSearch()`: + +```csharp +var page = criteria + .UseHints(OrderHints.WithCustomer) + .UsePages(20, 1) + .AsSearch() + .ToList(); +``` + +`UseHints` exige ao menos um hint. `null` gera `ArgumentNullException`; chamada vazia gera `ArgumentException`. + +### Hints Ambiente: IHintsContainer + +Hints ambiente valem para as criterias executadas no mesmo escopo. + +```csharp +var container = scope.ServiceProvider.GetRequiredService(); +container.AddHint(OrderHints.WithCustomer); + +var orders = criteria.Collect(); +``` + +Hints ambiente e `UseHints` sao combinados. + +### Onde Hints Aplicam + +Aplicam em terminais que materializam entidade: + +- `Collect()` / `CollectAsync()` +- `FirstOrDefault()` / `FirstOrDefaultAsync()` +- `Single()` / `SingleAsync()` +- `AsSearch().ToList()` / `ToListAsync()` / `ToAsyncListAsync()` quando o resultado ainda e entidade + +Nao aplicam em: + +- `Exists()` / `ExistsAsync()` +- Depois de `Select()` +- Projecoes para DTO + +Sem OperationHint registrado, o comportamento e no-op: nenhuma navegacao e incluida. + +### Find / Pos-Carga + +SmartSearch nao fornece uma API `Find`. A paridade vem do Operation Hint: o mesmo `AddIncludesHandler` cobre query e entidade. Em um repository externo: + +```csharp +public Order? FindOrder(int id, IHintsContainer container, IHintPerformer performer, AppDbContext db) +{ + container.AddHint(OrderHints.WithItems); + + var order = db.Set().Find(id); + if (order is not null) + performer.Perform(order, db); + + return order; +} +``` + +## Exemplos Completos + +### Lista simples rastreada com filtro, sorting e hints + +```csharp +var orders = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .OrderBy(new Sorting { OrderBy = "CreatedAt", Direction = ListSortDirection.Descending }) + .UseHints(OrderHints.WithCustomer) + .Collect(); +``` + +### Pagina para UI/API + +```csharp +var page = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .UseHints(OrderHints.WithCustomer) + .UsePages(itemsPerPage: 20, pageNumber: 1) + .AsSearch() + .ToList(); +``` + +### DTO sem hints + +```csharp +var page = criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .Select() + .UsePages(20, 1) + .AsSearch() + .ToList(); +``` + +## Boas Praticas + +- Modele filtros como classes pequenas e declarativas. +- Nao passe lambda para `FilterBy`; use classe filtro e atributos. +- Use `Select()` para leitura em DTO. +- Use `UseHints(...)` para carregar agregado quando o retorno e entidade. +- Para `AsSearch().ToList()`, configure pagina com `UsePages(...)` ou limite com `Take(...)`. +- Configure sortings e selectors nomeados no startup. +- Evite strings magicas de sorting espalhadas; use nomes registrados. +- Teste `Exists` e `Select` quando adicionar hints, pois eles devem permanecer sem includes. + +## Antipadroes + +- Espalhar `.Include(...)` pelos call sites em vez de registrar hints. +- Usar `UseHints` antes de `Select()` esperando carregar navegacoes. +- Usar `AsSearch().ToList()` sem pagina/limite quando espera itens. +- Criar predicates manuais quando `Criterion` cobre o caso. From 24e25c0e9bfdc5ddb4fd03960ea3bcf12dc92e69 Mon Sep 17 00:00:00 2001 From: Glauco Knihs Date: Sun, 21 Jun 2026 19:26:00 -0300 Subject: [PATCH 3/5] chore: Update target frameworks and package versions in build files --- src/Directory.Build.props | 15 ++++++++------- src/tests.targets | 12 ++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3752165..39e0b32 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,19 +1,20 @@ - net8;net9 - net8;net9 + net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 - 0.8.0 + 0.8.5 - 8.0.2 - 9.0.5 + 8.0.6 + 9.0.5 + 10.0.0 - 1.0.2 + 1.0.3 1.0.0 - 1.0.0-preview-4.3 + 1.0.0-preview-6.0 diff --git a/src/tests.targets b/src/tests.targets index 136b8b1..af3cd61 100644 --- a/src/tests.targets +++ b/src/tests.targets @@ -1,6 +1,6 @@ - net8 + net10.0 enable latest enable @@ -8,21 +8,21 @@ true - 8.0.0 + 10.0.0 - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From 73c420caeefbf4fb033da1ecdca42ee203bf4b9e Mon Sep 17 00:00:00 2001 From: Glauco Knihs Date: Sun, 21 Jun 2026 19:27:11 -0300 Subject: [PATCH 4/5] chore: Update .NET version to 10.0.x in workflow files --- .github/workflows/dotnet.yml | 2 +- .github/workflows/smart-search.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index be7b37a..216afcc 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,7 +18,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/.github/workflows/smart-search.yml b/.github/workflows/smart-search.yml index 4af889b..5f173c7 100644 --- a/.github/workflows/smart-search.yml +++ b/.github/workflows/smart-search.yml @@ -18,7 +18,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Build and pack SmartSearch Abstractions run: dotnet build ./src/RoyalCode.SmartSearch.Abstractions/RoyalCode.SmartSearch.Abstractions.csproj -c Release -o ./publish/RoyalCode.SmartSearch.Abstractions From 9605bb66ae67f22c2f5db958797444626ad1177f Mon Sep 17 00:00:00 2001 From: Glauco Knihs Date: Sun, 21 Jun 2026 19:56:50 -0300 Subject: [PATCH 5/5] feat: Enhance filter expression generation with custom attributes and context --- .../FilterExpressionGeneratorAttribute.cs | 4 +- .../ISpecifierExpressionGenerator.cs | 34 +++++ .../FilterExpressionGeneratorResolution.cs | 10 +- .../Filtering/SpecifiersMap.cs | 13 +- src/smartsearch.md | 128 +++++++++++++++++- 5 files changed, 168 insertions(+), 21 deletions(-) diff --git a/src/RoyalCode.SmartSearch.Linq/FilterExpressionGeneratorAttribute.cs b/src/RoyalCode.SmartSearch.Linq/FilterExpressionGeneratorAttribute.cs index 74c92bd..278f0a1 100644 --- a/src/RoyalCode.SmartSearch.Linq/FilterExpressionGeneratorAttribute.cs +++ b/src/RoyalCode.SmartSearch.Linq/FilterExpressionGeneratorAttribute.cs @@ -4,14 +4,14 @@ namespace RoyalCode.SmartSearch; /// /// -/// A filter expression generator attribute that specifies the expression generator to use for a filter property. +/// Specifies the custom expression generator to use for a filter property or filter value type. /// /// /// The expression generator must implement the interface. /// This generator will be used to create the expression for the filter property. /// /// -/// +/// The expression generator type. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property, AllowMultiple = false)] public class FilterExpressionGeneratorAttribute : Attribute where TExpressionGenerator : ISpecifierExpressionGenerator diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/ISpecifierExpressionGenerator.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/ISpecifierExpressionGenerator.cs index 89f673a..2674302 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/ISpecifierExpressionGenerator.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/ISpecifierExpressionGenerator.cs @@ -2,20 +2,54 @@ namespace RoyalCode.SmartSearch.Linq.Filtering; +/// +/// Defines a custom expression generator used by SmartSearch to build a filter expression for a filter property. +/// +/// +/// Implement this interface when a filter property cannot be translated by the default criterion pipeline. +/// The type is selected with . +/// public interface ISpecifierExpressionGenerator { + /// + /// Creates the expression that applies the filter to the current query. + /// + /// The context containing query, filter, model, and member access expressions. + /// + /// An expression that applies the desired filter operation, usually by assigning a new + /// expression back to . + /// public static abstract Expression GenerateExpression(ExpressionGeneratorContext context); } +/// +/// Provides the expressions required by a custom SmartSearch filter expression generator. +/// public class ExpressionGeneratorContext { + /// + /// Gets the query parameter expression that stores the current . + /// public required ParameterExpression Query { get; init; } + /// + /// Gets the filter instance parameter expression. + /// public required ParameterExpression Filter { get; init; } + /// + /// Gets the model/entity parameter expression used by the generated predicate. + /// public required ParameterExpression Model { get; init; } + + /// + /// Gets the member access expression for the target model property. + /// public required MemberExpression ModelMember { get; init; } + /// + /// Gets the member access expression for the source filter property. + /// public required MemberExpression FilterMember { get; init; } } diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/FilterExpressionGeneratorResolution.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/FilterExpressionGeneratorResolution.cs index 6156903..55f8cd6 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/FilterExpressionGeneratorResolution.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/FilterExpressionGeneratorResolution.cs @@ -6,9 +6,9 @@ namespace RoyalCode.SmartSearch.Linq.Filtering.Resolutions; internal class FilterExpressionGeneratorResolution : ICriterionResolution { - private readonly PropertySelection filterProperty; + private readonly PropertySelection? filterProperty; private readonly PropertySelection? targetProperty; - private readonly Func expressionCreateFunction; + private readonly Func? expressionCreateFunction; private readonly Lack? lack; public FilterExpressionGeneratorResolution( @@ -34,7 +34,7 @@ public FilterExpressionGeneratorResolution( { lack = new Lack { - Description = $"The target property path '{targetProperty}' could not be resolved in type '{filterTarget.TargetType.FullName}'." + Description = $"The target property path '{targetPropertyName}' could not be resolved in type '{filterTarget.TargetType.FullName}'." }; return; } @@ -59,7 +59,7 @@ public Expression CreateExpression(ParameterExpression queryParam, ParameterExpr // the predicate function parameter, the entity/model of the query. var targetParam = Expression.Parameter(targetProperty!.RootDeclaringType, "e"); var targetMember = targetProperty.GetMemberAccess(targetParam); - var filterMember = filterProperty.GetMemberAccess(filterParam); + var filterMember = filterProperty!.GetMemberAccess(filterParam); var context = new ExpressionGeneratorContext() { @@ -70,7 +70,7 @@ public Expression CreateExpression(ParameterExpression queryParam, ParameterExpr FilterMember = filterMember, }; - return expressionCreateFunction(context); + return expressionCreateFunction!(context); } public bool IsLacking([NotNullWhen(true)] out Lack? lack) diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/SpecifiersMap.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/SpecifiersMap.cs index 718d48a..5534b15 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/SpecifiersMap.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/SpecifiersMap.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; namespace RoyalCode.SmartSearch.Linq.Filtering; @@ -12,7 +13,7 @@ internal sealed class SpecifiersMap { public static SpecifiersMap Instance { get; } = new(); - private readonly Dictionary<(Type, Type), object> specifiers = new(); + private readonly ConcurrentDictionary<(Type, Type), object> specifiers = new(); [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGet([NotNullWhen(true)] out ISpecifier? specifier) @@ -30,17 +31,15 @@ public bool TryGet([NotNullWhen(true)] out ISpecifier specifiers.Add(key, value); + public void Add((Type, Type) key, object value) => specifiers.TryAdd(key, value); public void Add(ISpecifier specifier) where TModel : class where TFilter : class { var key = (typeof(TModel), typeof(TFilter)); - if (specifiers.ContainsKey(key)) + if (!specifiers.TryAdd(key, specifier)) throw new ArgumentException($"Specifier for {key} already exists."); - - specifiers.Add(key, specifier); } public void Add(Func, TFilter, IQueryable> specifier) @@ -48,9 +47,7 @@ public void Add(Func, TFilter, IQueryable(specifier))) throw new ArgumentException($"Specifier for {key} already exists."); - - specifiers.Add(key, new InternalSpecifier(specifier)); } } diff --git a/src/smartsearch.md b/src/smartsearch.md index ff589e2..aab81c5 100644 --- a/src/smartsearch.md +++ b/src/smartsearch.md @@ -24,6 +24,16 @@ services.AddEntityFrameworkSearches(cfg => }); ``` +Tambem e possivel registrar entidades dinamicamente por `Type`: + +```csharp +services.AddEntityFrameworkSearches(cfg => +{ + foreach (var entityType in discoveredEntityTypes) + cfg.Add(entityType); +}); +``` + Resolva `ICriteria` diretamente: ```csharp @@ -68,7 +78,7 @@ public sealed class OrderFilter public string? Number { get; set; } - [Criterion(TargetProperty = "Customer.Name")] + [Criterion("Customer.Name")] public string? CustomerName { get; set; } } ``` @@ -118,18 +128,18 @@ Use `CriterionAttribute` para controlar operador e caminho alvo: ```csharp public sealed class InvoiceFilter { - [Criterion(Operator = CriterionOperator.GreaterThanOrEqual, TargetProperty = "CreatedAt")] + [Criterion("CreatedAt", CriterionOperator.GreaterThanOrEqual)] public DateTime? CreatedAtStart { get; set; } - [Criterion(Operator = CriterionOperator.LessThanOrEqual, TargetProperty = "CreatedAt")] + [Criterion("CreatedAt", CriterionOperator.LessThanOrEqual)] public DateTime? CreatedAtEnd { get; set; } } ``` -Caminhos aninhados podem ser passados por `TargetProperty`: +Caminhos aninhados podem ser passados pelo construtor ou por `TargetPropertyPath`: ```csharp -[Criterion(TargetProperty = "Customer.Email")] +[Criterion("Customer.Email")] public string? Email { get; set; } ``` @@ -157,7 +167,113 @@ public sealed class PersonFilter } ``` -Se `Or` faz parte do nome e nao deve indicar disjuncao, use `DisableOrFromName`. +Tambem funciona com caminho alvo: + +```csharp +public sealed class PersonFilter +{ + [Criterion(TargetPropertyPath = "FirstNameOrLastName")] + public string? Query { get; set; } +} +``` + +Se `Or` faz parte do nome e nao deve indicar disjuncao, use `DisableOrFromName`: + +```csharp +public sealed class ProductFilter +{ + [Criterion(DisableOrFromName = true)] + public string? ColorOrSizePreference { get; set; } +} +``` + +## Filtros Complexos + +Use `[ComplexFilter]` quando uma propriedade do filtro e um objeto de valor ou subfiltro com campos +internos que devem ser aplicados contra uma propriedade complexa do modelo. + +```csharp +[ComplexFilter] +public sealed class AddressFilter +{ + public string? City { get; set; } + public string? State { get; set; } +} + +public sealed class CustomerFilter +{ + [Criterion("MainAddress")] + public AddressFilter? Address { get; set; } +} +``` + +O atributo pode ficar no tipo complexo ou diretamente na propriedade do filtro. Quando a propriedade +complexa esta nula, nenhum filtro interno e aplicado. + +Filtros complexos tambem podem combinar OR: + +```csharp +[ComplexFilter] +public struct PersonNameFilter +{ + [Criterion("FirstNameOrMiddleNameOrLastName")] + public string? Value { get; set; } +} +``` + +## Geradores Customizados de Expressao + +Use `[FilterExpressionGenerator]` quando uma propriedade de filtro precisa gerar uma expressao +LINQ propria, mas voce ainda quer manter o filtro declarativo. + +```csharp +public enum Period +{ + Today, + Last7Days, + ThisMonth +} + +public sealed class OrderFilter +{ + [Criterion("CreatedAt")] + [FilterExpressionGenerator] + public Period Period { get; set; } +} + +public sealed class PeriodExpressionGenerator : ISpecifierExpressionGenerator +{ + public static DateTime GetStart(Period period) + { + var today = DateTime.UtcNow.Date; + + return period switch + { + Period.Last7Days => today.AddDays(-7), + Period.ThisMonth => new DateTime(today.Year, today.Month, 1), + _ => today + }; + } + + public static Expression GenerateExpression(ExpressionGeneratorContext context) + { + var getStart = typeof(PeriodExpressionGenerator).GetMethod(nameof(GetStart))!; + var start = Expression.Call(getStart, context.FilterMember); + var body = Expression.GreaterThanOrEqual(context.ModelMember, start); + var lambda = Expression.Lambda(body, context.Model); + + var where = ExpressionGenerator.CreateWhereCall( + context.Model.Type, + context.Query, + lambda); + + return Expression.Assign(context.Query, where); + } +} +``` + +O generator recebe `Query`, `Filter`, `Model`, `ModelMember` e `FilterMember`. Retorne uma expressao que +atualiza a query, normalmente atribuindo um `Where(...)` de volta para `context.Query`. ## Sorting