Compare commits

..
Author SHA1 Message Date
2206fe027a
feat: spec + repo 2026-07-01 20:44:24 +02:00
102 changed files with 3914 additions and 614 deletions

View file

@ -1,7 +1,6 @@
name: default name: default
on: on:
workflow_dispatch:
push: push:
branches: [ "main", "develop" ] branches: [ "main", "develop" ]
paths-ignore: paths-ignore:

View file

@ -45,19 +45,6 @@ To have a consistent experience across all packages, some public interfaces have
### Changed ### Changed
- **request.result:** Document that `Result`/`Result<T>` equality treats any two failures as equal, ignoring error content; branch on the `Error` directly for failure-specific logic
### Fixed
- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`)
- **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0`
- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`)
- **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`)
- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order
- **request.dispatcher:** Add `AddBehavior<T>(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order
### Removed ### Removed
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0

View file

@ -12,5 +12,8 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" /> <PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
<PackageVersion Include="TUnit" Version="1.11.51" /> <PackageVersion Include="TUnit" Version="1.11.51" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -5,4 +5,8 @@
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" /> <Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
<Project Path="src/request.result/Geekeey.Request.Result.csproj" /> <Project Path="src/request.result/Geekeey.Request.Result.csproj" />
<Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" /> <Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" />
<Project Path="src/request.persistence/Geekeey.Request.Persistence.csproj" />
<Project Path="src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj" />
<Project Path="src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
<Project Path="src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj" />
</Solution> </Solution>

View file

@ -87,26 +87,6 @@ internal sealed class ScalarBehaviourTests
await Assert.That(tracker.Executed).IsTrue(); await Assert.That(tracker.Executed).IsTrue();
} }
[Test]
public async Task I_can_order_behaviours_explicitly_by_order_property()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.AddBehavior<OrderedScalarBehaviorA>(2)
.AddBehavior<OrderedScalarBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request);
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
}
[Test] [Test]
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours() public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
{ {

View file

@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
} }
[Test] [Test]
public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers() public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
{ {
var sc = new ServiceCollection(); var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder sc.AddRequestDispatcher(builder => builder
@ -126,18 +126,13 @@ internal sealed class ScalarDispatcherTests
var dispatcher = provider.GetRequiredService<IRequestDispatcher>(); var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" }; var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
var result = await dispatcher.DispatchAsync(request);
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match. // Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest. // InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
// InterfaceConstrainedScalarHandler is an open generic match. // InterfaceConstrainedScalarHandler is an open generic match.
// Ambiguity is no longer resolved silently with .First(); it throws instead. // Currently Dispatcher.SendAsync checks concrete handlers first.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>(); await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler));
await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler");
}
} }
[Test] [Test]
@ -239,49 +234,6 @@ internal sealed class ScalarDispatcherTests
} }
} }
[Test]
public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DuplicateScalarHandlerA))
.Add(typeof(DuplicateScalarHandlerB)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DuplicateScalarRequest();
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerA));
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerB));
}
}
[Test]
public async Task I_can_see_candidates_ordered_by_registration_not_closed_first()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceConstrainedScalarHandler<>))
.Add(typeof(InterfaceInheritedScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
// The open generic handler is registered before the closed handler, so candidates must be
// listed in registration order (open first), not the old fixed closed-before-open order.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
var message = ex?.Message ?? string.Empty;
var openIndex = message.IndexOf("InterfaceConstrainedScalarHandler", StringComparison.Ordinal);
var closedIndex = message.IndexOf("InterfaceInheritedScalarHandler", StringComparison.Ordinal);
await Assert.That(openIndex >= 0 && closedIndex >= 0 && openIndex < closedIndex).IsTrue();
}
[Test] [Test]
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build() public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
{ {

View file

@ -66,26 +66,6 @@ internal sealed class StreamBehaviourTests
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2"); await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
} }
[Test]
public async Task I_can_order_behaviours_explicitly_by_order_property()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.AddBehavior<OrderedStreamBehaviorA>(2)
.AddBehavior<OrderedStreamBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request).ToListAsync();
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
}
[Test] [Test]
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour() public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
{ {

View file

@ -7,26 +7,6 @@ namespace Geekeey.Request.Dispatcher.Tests;
public class StreamDispatcherTests public class StreamDispatcherTests
{ {
[Test]
public async Task I_can_see_it_throw_on_ambiguous_stream_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DuplicateStreamHandlerA))
.Add(typeof(DuplicateStreamHandlerB)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DuplicateStreamRequest();
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerA));
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerB));
}
}
[Test] [Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler() public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
{ {
@ -141,18 +121,13 @@ public class StreamDispatcherTests
var dispatcher = provider.GetRequiredService<IRequestDispatcher>(); var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" }; var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match. // Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest. // InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
// InterfaceConstrainedStreamHandler is an open generic match. // InterfaceConstrainedStreamHandler is an open generic match.
// Ambiguity is no longer resolved silently with .First(); it throws instead. // Currently Dispatcher checks concrete handlers first.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>(); await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler));
await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler");
}
} }
[Test] [Test]

View file

@ -1,24 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public sealed class DuplicateScalarRequest : IScalarRequest<string>
{
}
public sealed class DuplicateScalarHandlerA : IScalarRequestHandler<DuplicateScalarRequest, string>
{
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult("A");
}
}
public sealed class DuplicateScalarHandlerB : IScalarRequestHandler<DuplicateScalarRequest, string>
{
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult("B");
}
}

View file

@ -1,24 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public sealed class DuplicateStreamRequest : IStreamRequest<string>
{
}
public sealed class DuplicateStreamHandlerA : IStreamRequestHandler<DuplicateStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "A";
}
}
public sealed class DuplicateStreamHandlerB : IStreamRequestHandler<DuplicateStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "B";
}
}

View file

@ -1,22 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedA");
return await next(request, cancellationToken);
}
}
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
return await next(request, cancellationToken);
}
}

View file

@ -1,28 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedA");
await foreach (var item in next(request, cancellationToken))
{
yield return item;
}
}
}
public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
await foreach (var item in next(request, cancellationToken))
{
yield return item;
}
}
}

View file

@ -70,27 +70,6 @@ public static class RequestDispatcherBuilderExtensions
return builder; return builder;
} }
/// <summary>
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
/// first (outermost); behaviors with equal order run in registration order.
/// </summary>
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
where TBehavior : class
{
ArgumentNullException.ThrowIfNull(builder);
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect([typeof(TBehavior)], order));
return builder;
}
/// <summary> /// <summary>
/// Adds the specified type to the request dispatcher configuration for inspection. /// Adds the specified type to the request dispatcher configuration for inspection.
/// </summary> /// </summary>

View file

@ -10,27 +10,24 @@ namespace Geekeey.Request.Dispatcher;
internal sealed class RequestDispatcherOptions internal sealed class RequestDispatcherOptions
{ {
private readonly List<(Type Type, int? Order)> _search = []; private readonly List<Type> _search = [];
private readonly Lazy<TypeIndex> _behaviorsTypeIndex; private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
private readonly Lazy<TypeIndex> _handlersTypeIndex; private readonly Lazy<TypeIndex> _handlersTypeIndex;
public RequestDispatcherOptions() public RequestDispatcherOptions()
{ {
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type))); _behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type))); _handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
} }
public void Inspect(IEnumerable<Type> assembly, int? order = null) public void Inspect(IEnumerable<Type> assembly)
{ {
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated) if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
{ {
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
} }
foreach (var type in assembly) _search.AddRange(assembly);
{
_search.Add((type, order));
}
} }
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services) public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
@ -49,22 +46,11 @@ internal sealed class RequestDispatcherOptions
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = []; protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = []; protected readonly List<Type> _openTypeInfo = [];
protected readonly Dictionary<Type, int> _order = [];
protected readonly Dictionary<Type, int> _explicitOrder = [];
protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate) protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
{ {
var index = 0; foreach (var type in collection)
foreach (var (type, order) in collection)
{ {
_order[type] = index++;
if (order is { } explicitOrder)
{
_explicitOrder[type] = explicitOrder;
}
if (type.IsGenericTypeDefinition) if (type.IsGenericTypeDefinition)
{ {
if (type.GetInterfaces().Any(predicate)) if (type.GetInterfaces().Any(predicate))
@ -82,11 +68,6 @@ internal sealed class RequestDispatcherOptions
} }
} }
protected int EffectiveOrder(Type type)
{
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
}
public IEnumerable<T> Resolve<T>(IServiceProvider services) public IEnumerable<T> Resolve<T>(IServiceProvider services)
{ {
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services); return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
@ -116,19 +97,16 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>)); type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
} }
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection) private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
: TypeIndex(collection, IsRequestHandlerType) : TypeIndex(collection, IsRequestHandlerType)
{ {
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface) protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{ {
var result = new List<(int Order, Type Type)>(); var result = new List<Type>();
if (_closedTypeInfo.TryGetValue(@interface, out var list)) if (_closedTypeInfo.TryGetValue(@interface, out var list))
{ {
foreach (var type in list) result.AddRange(list);
{
result.Add((EffectiveOrder(type), type));
}
} }
var requestType = @interface.GetGenericArguments()[0]; var requestType = @interface.GetGenericArguments()[0];
@ -143,7 +121,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType); var impl = type.MakeGenericType(requestType);
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((EffectiveOrder(type), impl)); result.Add(impl);
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -159,7 +137,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType.GetGenericArguments()); var impl = type.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((EffectiveOrder(type), impl)); result.Add(impl);
} }
} }
} }
@ -168,7 +146,7 @@ internal sealed class RequestDispatcherOptions
} }
} }
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; return result;
} }
} }
@ -179,19 +157,16 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>)); type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
} }
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection) private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
: TypeIndex(collection, IsRequestBehaviorType) : TypeIndex(collection, IsRequestBehaviorType)
{ {
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface) protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{ {
var result = new List<(int Order, Type Type)>(); var result = new List<Type>();
if (_closedTypeInfo.TryGetValue(@interface, out var list)) if (_closedTypeInfo.TryGetValue(@interface, out var list))
{ {
foreach (var type in list) result.AddRange(list);
{
result.Add((EffectiveOrder(type), type));
}
} }
var requestType = @interface.GetGenericArguments()[0]; var requestType = @interface.GetGenericArguments()[0];
@ -205,7 +180,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType, responseType); var impl = behaviour.MakeGenericType(requestType, responseType);
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((EffectiveOrder(behaviour), impl)); result.Add(impl);
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -218,7 +193,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface)) if (impl.IsAssignableTo(@interface))
{ {
result.Add((EffectiveOrder(behaviour), impl)); result.Add(impl);
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -226,7 +201,7 @@ internal sealed class RequestDispatcherOptions
} }
} }
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; return result;
} }
} }
} }

View file

@ -43,20 +43,7 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct) Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
{ {
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray(); return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
if (handlers.Length is 0)
{
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
}
if (handlers.Length > 1)
{
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
}
return handlers[0].HandleAsync((TRequest)r, ct);
} }
} }
} }

View file

@ -47,20 +47,7 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct) IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
{ {
var handlers = options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray(); return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
if (handlers.Length is 0)
{
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
}
if (handlers.Length > 1)
{
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
}
return handlers[0].HandleAsync((TRequest)r, ct);
} }
} }
} }

View file

@ -0,0 +1,8 @@
[*.{cs,vb}]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.IDE0060.severity = none
dotnet_diagnostic.IDE0005.severity = none
dotnet_diagnostic.IDE0390.severity = none
dotnet_diagnostic.IDE0391.severity = none

View file

@ -0,0 +1,507 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class EvaluationTests
{
private static async Task<FakeDbContext> CreateDbContext()
{
var options = new DbContextOptionsBuilder<FakeDbContext>()
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
.EnableServiceProviderCaching(false)
.Options;
var context = new FakeDbContext(options);
await context.Database.EnsureCreatedAsync();
return context;
}
private static async Task<Repository<FakeEntity>> CreateRepo()
{
var context = await CreateDbContext();
return new Repository<FakeEntity>(context);
}
private static async Task SeedData(Repository<FakeEntity> repo, params FakeEntity[] entities)
{
foreach (var entity in entities)
{
await repo.AddAsync(entity);
}
}
[Test]
public async Task Where_filters_entities_by_equality()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Gamma" });
var result = await repo.ListAsync(new NameEqualSpec("Beta"));
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Beta");
}
[Test]
public async Task Where_multiple_filters_combine_as_and()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "AlphaBeta" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new MultipleWhereSpec());
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("AlphaBeta");
}
[Test]
public async Task Where_no_match_returns_empty_list()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var result = await repo.ListAsync(new NameEqualSpec("NonExistent"));
await Assert.That(result).IsEmpty();
}
[Test]
public async Task Where_with_FirstOrDefaultAsync_returns_matching()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("Alpha"));
await Assert.That(result).IsNotNull();
await Assert.That(result!.Name).IsEqualTo("Alpha");
}
[Test]
public async Task Where_with_FirstOrDefaultAsync_no_match_returns_null()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("NonExistent"));
await Assert.That(result).IsNull();
}
[Test]
public async Task Where_with_CountAsync_returns_correct_count()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var count = await repo.CountAsync(new NameEqualSpec("Alpha"));
await Assert.That(count).IsEqualTo(2);
}
[Test]
public async Task Where_with_AnyAsync_returns_true_when_match_exists()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var any = await repo.AnyAsync(new NameEqualSpec("Alpha"));
await Assert.That(any).IsTrue();
}
[Test]
public async Task Where_with_AnyAsync_returns_false_when_no_match()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var any = await repo.AnyAsync(new NameEqualSpec("Beta"));
await Assert.That(any).IsFalse();
}
[Test]
public async Task Search_filters_by_like_pattern()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Albatross" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new SearchSpec("Al%"));
await Assert.That(result).Count().IsEqualTo(2);
}
[Test]
public async Task Search_exact_match_works_without_wildcard()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Albatross" });
var result = await repo.ListAsync(new SearchSpec("Alpha"));
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Alpha");
}
[Test]
public async Task Search_multiple_terms_in_same_group_combine_as_or()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Gamma" });
var result = await repo.ListAsync(new MultiSearchSpec("Alpha", "Beta"));
await Assert.That(result).Count().IsEqualTo(2);
}
[Test]
public async Task OrderBy_returns_ascending()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Charlie" },
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new OrderByNameSpec());
await Assert.That(result).Count().IsEqualTo(3);
await Assert.That(result[0].Name).IsEqualTo("Alpha");
await Assert.That(result[1].Name).IsEqualTo("Beta");
await Assert.That(result[2].Name).IsEqualTo("Charlie");
}
[Test]
public async Task OrderByDescending_returns_descending()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Charlie" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new OrderByNameDescSpec());
await Assert.That(result).Count().IsEqualTo(3);
await Assert.That(result[0].Name).IsEqualTo("Charlie");
await Assert.That(result[1].Name).IsEqualTo("Beta");
await Assert.That(result[2].Name).IsEqualTo("Alpha");
}
[Test]
public async Task ThenBy_chains_after_order_by()
{
var repo = await CreateRepo();
var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
await SeedData(repo,
new FakeEntity { Id = id2, Name = "Alpha" },
new FakeEntity { Id = id1, Name = "Alpha" },
new FakeEntity { Id = id3, Name = "Beta" });
var result = await repo.ListAsync(new OrderByNameThenByIdSpec());
await Assert.That(result).Count().IsEqualTo(3);
await Assert.That(result[0].Id).IsEqualTo(id1);
await Assert.That(result[1].Id).IsEqualTo(id2);
await Assert.That(result[2].Name).IsEqualTo("Beta");
}
[Test]
public async Task ThenByDescending_chains_after_order_by()
{
var repo = await CreateRepo();
var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
await SeedData(repo,
new FakeEntity { Id = id2, Name = "Alpha" },
new FakeEntity { Id = id1, Name = "Alpha" },
new FakeEntity { Id = id3, Name = "Beta" });
var result = await repo.ListAsync(new OrderByNameThenByIdDescSpec());
await Assert.That(result).Count().IsEqualTo(3);
await Assert.That(result[0].Id).IsEqualTo(id2);
await Assert.That(result[1].Id).IsEqualTo(id1);
await Assert.That(result[2].Name).IsEqualTo("Beta");
}
[Test]
public async Task Duplicate_order_chain_throws()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
await Assert.That(async () => await repo.ListAsync(new DuplicateOrderSpec()))
.Throws<DuplicateOrderChainException>();
}
[Test]
public async Task Take_limits_results()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Gamma" });
var result = await repo.ListAsync(new OrderedTakeSpec(2));
await Assert.That(result).Count().IsEqualTo(2);
await Assert.That(result[0].Name).IsEqualTo("Alpha");
await Assert.That(result[1].Name).IsEqualTo("Beta");
}
[Test]
public async Task Skip_skips_elements()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Gamma" });
var result = await repo.ListAsync(new OrderedSkipSpec(1));
await Assert.That(result).Count().IsEqualTo(2);
await Assert.That(result[0].Name).IsEqualTo("Beta");
}
[Test]
public async Task Skip_and_take_paginates_correctly()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Charlie" },
new FakeEntity { Name = "Delta" });
var result = await repo.ListAsync(new OrderedSkipTakeSpec(1, 2));
await Assert.That(result).Count().IsEqualTo(2);
await Assert.That(result[0].Name).IsEqualTo("Beta");
await Assert.That(result[1].Name).IsEqualTo("Charlie");
}
[Test]
public async Task Skip_zero_returns_all()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new SkipOnlySpec(0));
await Assert.That(result).Count().IsEqualTo(2);
}
[Test]
public async Task Pagination_is_ignored_by_CountAsync()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" },
new FakeEntity { Name = "Gamma" });
var count = await repo.CountAsync(new OrderedSkipTakeSpec(1, 1));
await Assert.That(count).IsEqualTo(3);
}
[Test]
public async Task Pagination_is_ignored_by_AnyAsync()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var any = await repo.AnyAsync(new OrderedSkipSpec(10));
await Assert.That(any).IsTrue();
}
[Test]
public async Task Select_projects_entity_to_new_type()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new NameProjectionSpec());
await Assert.That(result).Count().IsEqualTo(2);
await Assert.That(result[0]).IsEqualTo("Alpha");
await Assert.That(result[1]).IsEqualTo("Beta");
}
[Test]
public async Task Select_with_FirstOrDefaultAsync_returns_projected_result()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var result = await repo.FirstOrDefaultAsync(new NameProjectionSpec());
await Assert.That(result).IsEqualTo("Alpha");
}
[Test]
public async Task No_selector_on_projected_spec_throws()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
await Assert.That(async () => await repo.ListAsync(new NoSelectorSpec()))
.Throws<SelectorNotFoundException>();
}
[Test]
public async Task Concurrent_selectors_throws()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
await Assert.That(async () => await repo.ListAsync(new ConcurrentSelectorSpec()))
.Throws<ConcurrentSelectorsException>();
}
[Test]
public async Task Where_orderby_and_take_combine_correctly()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Charlie" },
new FakeEntity { Name = "Alice" },
new FakeEntity { Name = "Bob" },
new FakeEntity { Name = "David" });
var result = await repo.ListAsync(new CombinedWhereOrderTakeSpec());
await Assert.That(result).Count().IsEqualTo(2);
await Assert.That(result[0].Name).IsEqualTo("Alice");
await Assert.That(result[1].Name).IsEqualTo("Bob");
}
[Test]
public async Task PostProcessing_applies_after_query()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "AlphaBeta" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new PostProcessingSpec());
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Alpha");
}
[Test]
public async Task Where_only_returns_filtered()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var result = await repo.ListAsync(new WhereSpec());
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Alpha");
}
[Test]
public async Task FirstOrDefaultAsync_returns_first_matching()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var result = await repo.FirstOrDefaultAsync(new FirstOrderedSpec());
await Assert.That(result).IsNotNull();
await Assert.That(result!.Name).IsEqualTo("Alpha");
}
[Test]
public async Task CountAsync_without_spec_returns_all()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var count = await repo.CountAsync();
await Assert.That(count).IsEqualTo(2);
}
[Test]
public async Task AnyAsync_without_spec_returns_true_when_entities_exist()
{
var repo = await CreateRepo();
await SeedData(repo, new FakeEntity { Name = "Alpha" });
var any = await repo.AnyAsync();
await Assert.That(any).IsTrue();
}
[Test]
public async Task AnyAsync_without_spec_returns_false_when_empty()
{
var repo = await CreateRepo();
var any = await repo.AnyAsync();
await Assert.That(any).IsFalse();
}
[Test]
public async Task AsAsyncEnumerable_streams_entities()
{
var repo = await CreateRepo();
await SeedData(repo,
new FakeEntity { Name = "Alpha" },
new FakeEntity { Name = "Beta" });
var count = 0;
await foreach (var _ in repo.AsAsyncEnumerable(new OrderByNameSpec()))
{
count++;
}
await Assert.That(count).IsEqualTo(2);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.persistence.entityframeworkcore\Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,403 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class IncludeEvaluatorTests
{
private static async Task<FakeDbContext> CreateDbContext()
{
var context = new FakeDbContext(new DbContextOptionsBuilder<FakeDbContext>()
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
.EnableServiceProviderCaching(false)
.Options);
await context.Database.EnsureCreatedAsync();
return context;
}
// --- Default evaluator (non-cached, reflection-based) ---
[Test]
public async Task Default_include_reference_nav()
{
var repo = await CreateRepo();
var entity = new FakeEntity
{
Name = "Test",
Child = new FakeChildEntity
{
Value = "child",
},
};
await repo.AddAsync(entity);
var result = await repo.ListAsync(new IncludeChildSpec());
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Test");
}
[Test]
public async Task Default_include_collection_nav()
{
var repo = await CreateRepo();
var entity = new FakeEntity
{
Name = "Test",
};
entity.Details.Add(new FakeDetailEntity
{
Value = "detail",
});
await repo.AddAsync(entity);
var result = await repo.ListAsync(new IncludeDetailsSpec());
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Test");
}
[Test]
public async Task Default_then_include_after_reference()
{
var repo = await CreateRepo();
var entity = new FakeEntity
{
Name = "Test",
Child = new FakeChildEntity
{
Value = "child",
GrandChildren =
{
new FakeGrandChildEntity
{
Value = "grand",
},
},
},
};
await repo.AddAsync(entity);
var result = await repo.ListAsync(new IncludeChildThenGrandChildrenSpec());
await Assert.That(result).Count().IsEqualTo(1);
}
[Test]
public async Task Default_empty_includes()
{
var repo = await CreateRepo();
await repo.AddAsync(new FakeEntity
{
Name = "Alpha",
});
await repo.AddAsync(new FakeEntity
{
Name = "Beta",
});
var builder = new SpecificationBuilder<FakeEntity>();
var spec = new IncludeOnlySpec(builder);
var result = await repo.ListAsync(spec);
await Assert.That(result).Count().IsEqualTo(2);
}
[Test]
public async Task Default_then_include_after_collection()
{
var dbContext = await CreateDbContext();
var repo = new Repository<FakeEntity>(dbContext);
var entity = new FakeEntity
{
Name = "Test",
};
entity.Details.Add(new FakeDetailEntity
{
Value = "detail",
DetailChildren =
{
new FakeDetailChildEntity
{
Value = "childDetail",
},
},
});
await repo.AddAsync(entity);
var builder = new SpecificationBuilder<FakeEntity>();
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
var spec = new IncludeOnlySpec(builder);
var result = await repo.ListAsync(spec);
await Assert.That(result).Count().IsEqualTo(1);
}
// --- Cached evaluator (delegate-based) ---
[Test]
public async Task Cached_include_reference_nav()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
var entity = new FakeEntity
{
Name = "Test",
Child = new FakeChildEntity
{
Value = "child",
},
};
dbContext.FakeEntities.Add(entity);
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child);
var spec = new IncludeOnlySpec(builder);
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(1);
await Assert.That(result[0].Name).IsEqualTo("Test");
}
[Test]
public async Task Cached_include_collection_nav()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
var entity = new FakeEntity
{
Name = "Test",
};
entity.Details.Add(new FakeDetailEntity
{
Value = "detail",
});
dbContext.FakeEntities.Add(entity);
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Details);
var spec = new IncludeOnlySpec(builder);
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(1);
}
[Test]
public async Task Cached_then_include_after_reference()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
var entity = new FakeEntity
{
Name = "Test",
Child = new FakeChildEntity
{
Value = "child",
GrandChildren =
{
new FakeGrandChildEntity
{
Value = "grand",
},
},
},
};
dbContext.FakeEntities.Add(entity);
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
var spec = new IncludeOnlySpec(builder);
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(1);
}
[Test]
public async Task Cached_then_include_after_collection()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
var entity = new FakeEntity
{
Name = "Test",
};
entity.Details.Add(new FakeDetailEntity
{
Value = "detail",
DetailChildren =
{
new FakeDetailChildEntity
{
Value = "childDetail",
},
},
});
dbContext.FakeEntities.Add(entity);
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
var spec = new IncludeOnlySpec(builder);
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(1);
}
[Test]
public async Task Cached_empty_includes()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
dbContext.FakeEntities.AddRange(
new FakeEntity
{
Name = "Alpha",
},
new FakeEntity
{
Name = "Beta",
});
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
var spec = new IncludeOnlySpec(builder);
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(2);
}
// --- Comparison ---
[Test]
public async Task Default_and_cached_produce_same_results()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
dbContext.FakeEntities.Add(new FakeEntity
{
Name = "Test",
Child = new FakeChildEntity
{
Value = "child",
},
});
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child);
var spec = new IncludeOnlySpec(builder);
var resultDefault = IncludeEvaluator.Default.Evaluate(query, spec).ToList();
var resultCached = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(resultDefault).Count().IsEqualTo(1);
await Assert.That(resultCached).Count().IsEqualTo(1);
await Assert.That(resultDefault[0].Name).IsEqualTo(resultCached[0].Name);
}
// --- Cache reuse ---
[Test]
public async Task Cached_include_reuses_delegate()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
dbContext.FakeEntities.AddRange(
new FakeEntity
{
Name = "A",
Child = new FakeChildEntity
{
Value = "a",
},
},
new FakeEntity
{
Name = "B",
Child = new FakeChildEntity
{
Value = "b",
},
});
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child);
var spec = new IncludeOnlySpec(builder);
_ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(2);
}
[Test]
public async Task Cached_then_include_reuses_delegate()
{
var dbContext = await CreateDbContext();
var query = dbContext.FakeEntities.AsQueryable();
dbContext.FakeEntities.AddRange(
new FakeEntity
{
Name = "A",
Child = new FakeChildEntity
{
Value = "a",
GrandChildren =
{
new FakeGrandChildEntity
{
Value = "g1",
},
},
},
},
new FakeEntity
{
Name = "B",
Child = new FakeChildEntity
{
Value = "b",
GrandChildren =
{
new FakeGrandChildEntity
{
Value = "g2",
},
},
},
});
await dbContext.SaveChangesAsync();
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
var spec = new IncludeOnlySpec(builder);
_ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
await Assert.That(result).Count().IsEqualTo(2);
}
private static async Task<Repository<FakeEntity>> CreateRepo()
{
var context = await CreateDbContext();
return new Repository<FakeEntity>(context);
}
}

Some files were not shown because too many files have changed in this diff Show more