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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/smart-search.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Build and pack SmartSearch Abstractions
run: dotnet build ./src/RoyalCode.SmartSearch.Abstractions/RoyalCode.SmartSearch.Abstractions.csproj -c Release

Expand Down
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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<TEntity>`, `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<TEntity>` (directly or via `ISearchManager<TDbContext>`):

```csharp
services.AddEntityFrameworkSearches<MyDbContext>(cfg => cfg.Add<MyEntity>());

var criteria = provider.GetRequiredService<ICriteria<MyEntity>>();
var items = criteria.FilterBy(new MyFilter { Name = "abc" }).Collect();
```

`ICriteria<TEntity>` exposes `FilterBy`, `OrderBy`, `UseHints`, `Select<TDto>()`, `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<TDto>()`; loading the **aggregate** (to mutate or expose its
> graph) uses **hints**.

### 1. Register the includes once

```csharp
services.AddEntityFrameworkSearches<MyDbContext>(cfg => cfg.Add<Blog>());

services.ConfigureOperationHints(registry =>
registry.AddIncludesHandler<Blog, BlogHints>((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<IHintsContainer>().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<TDto>()` (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).
12 changes: 6 additions & 6 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<Project>
<PropertyGroup>
<LibTargets>net8.0;net9.0;net10.0;</LibTargets>
<AspTargets>net8.0;net9.0;net10.0;</AspTargets>
<LibTargets>net8.0;net9.0;net10.0</LibTargets>
<AspTargets>net8.0;net9.0;net10.0</AspTargets>
</PropertyGroup>
<PropertyGroup>
<SearchesVer>0.10.1</SearchesVer>
<SearchesVer>0.10.2</SearchesVer>
<SearchesPreview></SearchesPreview>
</PropertyGroup>
<PropertyGroup>
Expand All @@ -13,12 +13,12 @@
<ProblemsVer>1.0.0-preview-6.0</ProblemsVer>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net8.0'">
<AspVer>8.0.0</AspVer>
<AspVer>8.0.6</AspVer>
<EFVer>8.0.10</EFVer>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
<AspVer>9.0.0</AspVer>
<EFVer>9.0.0</EFVer>
<AspVer>9.0.5</AspVer>
<EFVer>9.0.5</EFVer>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<AspVer >10.0.0</AspVer>
Expand Down
21 changes: 21 additions & 0 deletions src/RoyalCode.SmartSearch.Abstractions/ICriteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ public interface ICriteria<TEntity> : ICriteriaOptions<ICriteria<TEntity>>
ICriteria<TEntity> FilterBy<TFilter>(TFilter filter)
where TFilter : class;

/// <summary>
/// <para>
/// Adds one or more operation hints to be applied to this criteria only (per-query).
/// </para>
/// <para>
/// Hints are identified by an <see langword="enum"/> value and are resolved by the search engine to load
/// the entity graph (e.g. Entity Framework includes) when materializing entities via <see cref="Collect"/>,
/// <see cref="FirstOrDefault"/> or <see cref="Single"/>. They are <b>not</b> applied to <see cref="Exists"/>
/// nor to <see cref="Select{TDto}()"/> projections.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
/// <typeparam name="THint">The hint enum type.</typeparam>
/// <param name="hints">The hint values to apply.</param>
/// <returns>The same instance for chaining calls.</returns>
ICriteria<TEntity> UseHints<THint>(params THint[] hints)
where THint : Enum;

/// <summary>
/// <para>
/// Adds a sorting object to be applied to the criteria.
Expand Down
13 changes: 13 additions & 0 deletions src/RoyalCode.SmartSearch.Core/Defaults/Criteria.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

using RoyalCode.SmartSearch.Hints;
using RoyalCode.SmartSearch.Mappings;
using RoyalCode.SmartSearch.Services;
using System.Linq.Expressions;
Expand Down Expand Up @@ -89,6 +90,18 @@ public ICriteria<TEntity> FilterBy<TFilter>(TFilter filter)
return this;
}

/// <inheritdoc />
public ICriteria<TEntity> UseHints<THint>(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<THint>(hints));
return this;
}

/// <inheritdoc />
public ICriteria<TEntity> OrderBy(ISorting sorting)
{
Expand Down
23 changes: 23 additions & 0 deletions src/RoyalCode.SmartSearch.Core/Defaults/CriteriaOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using RoyalCode.SmartSearch.Filtering;
using RoyalCode.SmartSearch.Hints;

namespace RoyalCode.SmartSearch.Defaults;

Expand All @@ -9,6 +10,7 @@ public sealed class CriteriaOptions
{
private List<IFilter>? filters;
private List<ISorting>? sortings;
private List<ICriteriaHint>? hints;

/// <summary>
/// Gets or sets a value indicating whether tracking is enabled.
Expand All @@ -25,6 +27,17 @@ public sealed class CriteriaOptions
/// </summary>
public IReadOnlyList<ISorting> Sortings => sortings ?? [];

/// <summary>
/// <para>
/// Get all per-query hints added to the criteria (via <c>ICriteria.UseHints</c>).
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public IReadOnlyList<ICriteriaHint> Hints => hints ?? [];

/// <summary>
/// <para>
/// Defines that the query will be paged and determines the number of items per page.
Expand Down Expand Up @@ -109,6 +122,16 @@ public void AddSorting(IEnumerable<ISorting>? sorting)
sortings.AddRange(sorting);
}

/// <summary>
/// Adds a per-query hint carrier to the criteria.
/// </summary>
/// <param name="hint">The hint carrier.</param>
public void AddHint(ICriteriaHint hint)
{
hints ??= [];
hints.Add(hint);
}

/// <summary>
/// Whether the query should be paginated.
/// </summary>
Expand Down
19 changes: 19 additions & 0 deletions src/RoyalCode.SmartSearch.Core/Hints/CriteriaHint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace RoyalCode.SmartSearch.Hints;

/// <summary>
/// Default <see cref="ICriteriaHint"/> carrier that captures one or more hint values of a single enum type.
/// </summary>
/// <typeparam name="THint">The hint enum type captured at the call site.</typeparam>
internal sealed class CriteriaHint<THint> : 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);
}
}
40 changes: 40 additions & 0 deletions src/RoyalCode.SmartSearch.Core/Hints/ICriteriaHint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace RoyalCode.SmartSearch.Hints;

/// <summary>
/// <para>
/// A provider-agnostic carrier for a per-query hint captured by <c>ICriteria.UseHints</c>.
/// </para>
/// <para>
/// It holds the hint enum value(s) at the call site and exposes them through double-dispatch
/// (<see cref="Accept"/>), so the closed hint type can be recovered by a provider-specific
/// <see cref="ICriteriaHintVisitor"/> (e.g. the Entity Framework registry visitor) without coupling the
/// abstraction to any hint mechanism.
/// </para>
/// </summary>
public interface ICriteriaHint
{
/// <summary>
/// Dispatches the captured hint(s) to the given <paramref name="visitor"/>.
/// </summary>
/// <param name="visitor">The visitor that will receive each captured hint value.</param>
void Accept(ICriteriaHintVisitor visitor);
}

/// <summary>
/// <para>
/// A visitor that receives the closed hint enum value(s) captured by an <see cref="ICriteriaHint"/>.
/// </para>
/// <para>
/// Implemented by provider-specific code (e.g. Entity Framework) to apply the hint to a query, recovering the
/// concrete <c>THint</c> type that was erased when the hint was stored.
/// </para>
/// </summary>
public interface ICriteriaHintVisitor
{
/// <summary>
/// Visits a single hint value with its concrete enum type recovered.
/// </summary>
/// <typeparam name="THint">The hint enum type.</typeparam>
/// <param name="hint">The hint value.</param>
void Visit<THint>(THint hint) where THint : Enum;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using RoyalCode.OperationHint.Abstractions;
using RoyalCode.SmartSearch;
using RoyalCode.SmartSearch.Defaults;
using RoyalCode.SmartSearch.EntityFramework.Services;
Expand Down Expand Up @@ -28,11 +29,26 @@ public static ICriteria<TEntity> Criteria<TEntity>(this DbContext db)
var orderByFactory = db.GetService<IOrderByProvider>();
var selectorFactory = db.GetService<ISelectorFactory>();

IHintPerformer? hintPerformer;
IHintHandlerRegistry? hintRegistry;
try
{
hintPerformer = db.GetService<IHintPerformer>();
hintRegistry = db.GetService<IHintHandlerRegistry>();
}
catch (InvalidOperationException)
{
hintPerformer = null;
hintRegistry = null;
}

var preparer = new CriteriaPerformer<DbContext, TEntity>(
db,
specifierFactory,
orderByFactory,
selectorFactory);
selectorFactory,
hintPerformer,
hintRegistry);

var criteria = new Criteria<TEntity>(preparer);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using RoyalCode.OperationHint.Abstractions;
using RoyalCode.SmartSearch.Linq.Services;
using RoyalCode.SmartSearch.Linq.Sortings;

Expand Down Expand Up @@ -31,12 +32,22 @@ public sealed class CriteriaPerformer<TDbContext, TEntity> : CriteriaPerformerBa
/// <param name="specifierFactory">The factory to create specifiers for the query.</param>
/// <param name="orderByProvider">The provider for ordering the results.</param>
/// <param name="selectorFactory">The factory for selecting results.</param>
/// <param name="hintPerformer">
/// The optional operation hint performer. Resolved by DI when <c>OperationHint</c> is registered;
/// <see langword="null"/> otherwise (no-op).
/// </param>
/// <param name="hintRegistry">
/// The optional hint handler registry, used to apply per-query hints declared via <c>ICriteria.UseHints</c>.
/// Resolved by DI when <c>OperationHint</c> is registered; <see langword="null"/> otherwise (no-op).
/// </param>
public CriteriaPerformer(
TDbContext db,
ISpecifierFactory specifierFactory,
IOrderByProvider orderByProvider,
ISelectorFactory selectorFactory)
: base(specifierFactory, orderByProvider, selectorFactory)
ISelectorFactory selectorFactory,
IHintPerformer? hintPerformer = null,
IHintHandlerRegistry? hintRegistry = null)
: base(specifierFactory, orderByProvider, selectorFactory, hintPerformer, hintRegistry)
{
this.db = db;
}
Expand Down
Loading
Loading