Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2206fe027a |
102 changed files with 3914 additions and 614 deletions
|
|
@ -1,7 +1,6 @@
|
|||
name: default
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
paths-ignore:
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -45,19 +45,6 @@ To have a consistent experience across all packages, some public interfaces have
|
|||
|
||||
### 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
|
||||
|
||||
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
|
||||
|
|
|
|||
|
|
@ -12,5 +12,8 @@
|
|||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
|
||||
<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>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@
|
|||
<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.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>
|
||||
|
|
|
|||
|
|
@ -87,26 +87,6 @@ internal sealed class ScalarBehaviourTests
|
|||
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]
|
||||
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
|
|||
}
|
||||
|
||||
[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();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
|
|
@ -126,18 +126,13 @@ internal sealed class ScalarDispatcherTests
|
|||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
||||
var result = await dispatcher.DispatchAsync(request);
|
||||
|
||||
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
|
||||
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
|
||||
// InterfaceConstrainedScalarHandler is an open generic match.
|
||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler));
|
||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler");
|
||||
}
|
||||
// Currently Dispatcher.SendAsync checks concrete handlers first.
|
||||
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -66,26 +66,6 @@ internal sealed class StreamBehaviourTests
|
|||
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]
|
||||
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,26 +7,6 @@ namespace Geekeey.Request.Dispatcher.Tests;
|
|||
|
||||
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]
|
||||
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 request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
|
||||
var results = await dispatcher.DispatchAsync(request).ToListAsync();
|
||||
|
||||
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
|
||||
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
|
||||
// InterfaceConstrainedStreamHandler is an open generic match.
|
||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler));
|
||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler");
|
||||
}
|
||||
// Currently Dispatcher checks concrete handlers first.
|
||||
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,27 +70,6 @@ public static class RequestDispatcherBuilderExtensions
|
|||
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>
|
||||
/// Adds the specified type to the request dispatcher configuration for inspection.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -10,27 +10,24 @@ namespace Geekeey.Request.Dispatcher;
|
|||
|
||||
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> _handlersTypeIndex;
|
||||
|
||||
public RequestDispatcherOptions()
|
||||
{
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
|
||||
_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)
|
||||
{
|
||||
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
|
||||
}
|
||||
|
||||
foreach (var type in assembly)
|
||||
{
|
||||
_search.Add((type, order));
|
||||
}
|
||||
_search.AddRange(assembly);
|
||||
}
|
||||
|
||||
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
|
||||
|
|
@ -49,22 +46,11 @@ internal sealed class RequestDispatcherOptions
|
|||
|
||||
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
|
||||
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, order) in collection)
|
||||
foreach (var type in collection)
|
||||
{
|
||||
_order[type] = index++;
|
||||
|
||||
if (order is { } explicitOrder)
|
||||
{
|
||||
_explicitOrder[type] = explicitOrder;
|
||||
}
|
||||
|
||||
if (type.IsGenericTypeDefinition)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
|
||||
|
|
@ -116,19 +97,16 @@ internal sealed class RequestDispatcherOptions
|
|||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
||||
}
|
||||
|
||||
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
|
||||
: TypeIndex(collection, IsRequestHandlerType)
|
||||
{
|
||||
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))
|
||||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
result.AddRange(list);
|
||||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
|
|
@ -143,7 +121,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(type), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -159,7 +137,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType.GetGenericArguments());
|
||||
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<,>));
|
||||
}
|
||||
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
|
||||
: TypeIndex(collection, IsRequestBehaviorType)
|
||||
{
|
||||
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))
|
||||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
result.AddRange(list);
|
||||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
|
|
@ -205,7 +180,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType, responseType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -218,7 +193,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -226,7 +201,7 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,20 +43,7 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
|
|||
|
||||
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
|
||||
{
|
||||
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
||||
|
||||
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);
|
||||
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,20 +47,7 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
|
|||
|
||||
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
|
||||
{
|
||||
var handlers = options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
||||
|
||||
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);
|
||||
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in a new issue