Compare commits

..
Author SHA1 Message Date
49eafc2181
chore(ci): add manual workflow dispatch
All checks were successful
default / dotnet-default-workflow (push) Successful in 1m52s
2026-07-12 22:02:05 +02:00
f9de3a99de
feat(dispatcher): add registration-time pipeline behavior ordering via AddBehavior<T>(order:)
Pipeline behavior ordering previously depended on discovery/registration
order. Add an AddBehavior<T>(order:) API that captures the order at
registration time; the BehaviorTypeIndex now orders matching behaviors by
that explicit order (lower runs first), falling back to registration order
for ties. Removed the runtime Order property approach.
2026-07-12 21:58:49 +02:00
8099898f3d
fix(dispatcher): resolve handlers in registration order across closed and open generics
Handler/behavior resolution previously forced closed generics before open
generics regardless of registration order, which was surprising and fragile.
TypeIndex now tracks each registered type's index and orders matching
candidates by registration order, so closed/open ordering is deterministic
and follows registration.
2026-07-12 21:58:47 +02:00
a7d7e9fbaa
fix(dispatcher): throw on ambiguous handlers instead of silent .First()
Multiple handlers for the same request were previously resolved via .First(),
with ordering depending on Type.GetInterfaces() order (non-deterministic).
Now ScalarRequestInvoker/StreamRequestInvoker throw InvalidOperationException
listing the candidate handlers when more than one matches.
2026-07-12 21:58:25 +02:00
15bf9a8e56
fix(validation): make Severity meaningful in Validation.IsValid
Validation.IsValid previously counted every problem regardless of severity,
leaving Severity inert. Now IsValid only fails on Error problems, so Warning
and Info no longer invalidate the result, and IsValidFor(Severity) lets
callers set the failing-severity threshold.
2026-07-12 21:54:45 +02:00
581b0a1ced
fix(validation): return no problems for null reference instance in Rule.Validate
Rule<T,TProperty>.Validate cast a null instance to T and validated it, so a
reference T whose accessor dereferences the instance threw
NullReferenceException on a direct validator.Validate(context) with a null
instance. Guard the null-instance path and return no problems instead.
2026-07-12 21:53:26 +02:00
fd6ed64755
fix(validation): guard null comparison bounds in comparison builders
Passing a null bound to GreaterThan/LessThan/Between etc. called
value.CompareTo(null), throwing NullReferenceException for non-null
reference-type values. Short-circuit when the bound is null so the rule is
satisfied instead of crashing.
2026-07-12 21:53:26 +02:00
5b368602fe
docs(result): document that failure equality ignores error content
Result/Result<T> equality treats any two failures as equal regardless of their
Error, which was undocumented and surprising for failure-specific branching.
2026-07-12 21:53:26 +02:00
fbbe94bfa8
fix(result): Result<T> hash collision
A success result wrapping a value whose GetHashCode is 0 (e.g. Result<int>
with 0) collides with a failure result, both hashing to 0.
2026-07-12 21:25:21 +02:00
06a40184ee
fix(result): correct wrong namespace in Prelude doc comment
The doc comment referenced `Geekeey.Extensions.Result.Prelude` but the actual
namespace is `Geekeey.Request.Result`, so the suggested using directive would
not compile.
2026-07-12 18:33:32 +02:00
102 changed files with 614 additions and 3914 deletions

View file

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

View file

@ -45,6 +45,19 @@ 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

View file

@ -12,8 +12,5 @@
<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>

View file

@ -5,8 +5,4 @@
<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>

View file

@ -87,6 +87,26 @@ 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()
{

View file

@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
@ -126,13 +126,18 @@ 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.
// Currently Dispatcher.SendAsync checks concrete handlers first.
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
// 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");
}
}
[Test]
@ -234,6 +239,49 @@ 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()
{

View file

@ -66,6 +66,26 @@ 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()
{

View file

@ -7,6 +7,26 @@ 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()
{
@ -121,13 +141,18 @@ 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.
// Currently Dispatcher checks concrete handlers first.
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
// 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");
}
}
[Test]

View file

@ -0,0 +1,24 @@
// 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

@ -0,0 +1,24 @@
// 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

@ -0,0 +1,22 @@
// 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

@ -0,0 +1,28 @@
// 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,6 +70,27 @@ 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>

View file

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

View file

@ -43,7 +43,20 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, 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);
}
}
}

View file

@ -47,7 +47,20 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, 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);
}
}
}

View file

@ -1,8 +0,0 @@
[*.{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

@ -1,507 +0,0 @@
// 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

@ -1,22 +0,0 @@
<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

@ -1,403 +0,0 @@
// 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);
}
}

View file

@ -1,119 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class RepositoryTests
{
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;
}
[Test]
public async Task AddAsync_persists_entity()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
var entity = new FakeEntity { Name = "Test" };
await repo.AddAsync(entity);
await Assert.That(entity.Id).IsNotEqualTo(Guid.Empty);
await Assert.That(entity.Name).IsEqualTo("Test");
}
[Test]
public async Task ListAsync_returns_all_entities()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
await repo.AddAsync(new FakeEntity { Name = "A" });
await repo.AddAsync(new FakeEntity { Name = "B" });
var list = await repo.ListAsync();
await Assert.That(list).Count().IsEqualTo(2);
}
[Test]
public async Task UpdateAsync_modifies_entity()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
var entity = new FakeEntity { Name = "Original" };
await repo.AddAsync(entity);
entity.Name = "Updated";
await repo.UpdateAsync(entity);
var all = await repo.ListAsync();
var found = all.FirstOrDefault(e => e.Id == entity.Id);
await Assert.That(found).IsNotNull();
await Assert.That(found!.Name).IsEqualTo("Updated");
}
[Test]
public async Task DeleteAsync_removes_entity()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
var entity = new FakeEntity { Name = "Test" };
await repo.AddAsync(entity);
await repo.DeleteAsync(entity);
var all = await repo.ListAsync();
var found = all.FirstOrDefault(e => e.Id == entity.Id);
await Assert.That(found).IsNull();
}
[Test]
public async Task CountAsync_returns_correct_count()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
await repo.AddAsync(new FakeEntity { Name = "A" });
await repo.AddAsync(new FakeEntity { Name = "B" });
var count = await repo.CountAsync();
await Assert.That(count).IsEqualTo(2);
}
[Test]
public async Task AnyAsync_returns_true_when_entities_exist()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
await repo.AddAsync(new FakeEntity { Name = "A" });
var any = await repo.AnyAsync();
await Assert.That(any).IsTrue();
}
[Test]
public async Task AnyAsync_returns_false_when_no_entities()
{
var context = await CreateDbContext();
var repo = new Repository<FakeEntity>(context);
var any = await repo.AnyAsync();
await Assert.That(any).IsFalse();
}
}

View file

@ -1,15 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class CombinedWhereOrderTakeSpec : Specification<FakeEntity>
{
public CombinedWhereOrderTakeSpec()
{
Query.Where(e => e.Name != "David")
.OrderBy(e => e.Name)
.Builder
.Take(2);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class ConcurrentSelectorSpec : Specification<FakeEntity, string>
{
public ConcurrentSelectorSpec()
{
Query.Select(e => e.Name);
Query.SelectMany(e => e.Name.Split(' '));
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class DuplicateOrderSpec : Specification<FakeEntity>
{
public DuplicateOrderSpec()
{
Query.OrderBy(e => e.Name);
Query.OrderBy(e => e.Id);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeEntityId { get; set; }
public FakeEntity FakeEntity { get; set; } = null!;
public List<FakeGrandChildEntity> GrandChildren { get; set; } = [];
}

View file

@ -1,60 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeDbContext(DbContextOptions<FakeDbContext> options) : DbContext(options)
{
public DbSet<FakeEntity> FakeEntities => Set<FakeEntity>();
public DbSet<FakeChildEntity> FakeChildEntities => Set<FakeChildEntity>();
public DbSet<FakeDetailEntity> FakeDetailEntities => Set<FakeDetailEntity>();
public DbSet<FakeGrandChildEntity> FakeGrandChildEntities => Set<FakeGrandChildEntity>();
public DbSet<FakeDetailChildEntity> FakeDetailChildEntities => Set<FakeDetailChildEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<FakeEntity>(entity =>
{
entity.HasKey(static e => e.Id);
entity.Property(static e => e.Name).IsRequired();
entity.HasOne(static e => e.Child)
.WithOne(static c => c.FakeEntity)
.HasForeignKey<FakeChildEntity>(static c => c.FakeEntityId);
entity.HasMany(static e => e.Details)
.WithOne(static d => d.FakeEntity)
.HasForeignKey(static d => d.FakeEntityId);
});
modelBuilder.Entity<FakeChildEntity>(child =>
{
child.HasKey(static c => c.Id);
child.Property(static c => c.Value).IsRequired();
child.HasMany(static c => c.GrandChildren)
.WithOne(static g => g.FakeChildEntity)
.HasForeignKey(static g => g.FakeChildEntityId);
});
modelBuilder.Entity<FakeGrandChildEntity>(grandChild =>
{
grandChild.HasKey(static g => g.Id);
grandChild.Property(static g => g.Value).IsRequired();
});
modelBuilder.Entity<FakeDetailEntity>(detail =>
{
detail.HasKey(static d => d.Id);
detail.Property(static d => d.Value).IsRequired();
detail.HasMany(static d => d.DetailChildren)
.WithOne(static dc => dc.FakeDetailEntity)
.HasForeignKey(static dc => dc.FakeDetailEntityId);
});
modelBuilder.Entity<FakeDetailChildEntity>(detailChild =>
{
detailChild.HasKey(static d => d.Id);
detailChild.Property(static d => d.Value).IsRequired();
});
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeDetailChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeDetailEntityId { get; set; }
public FakeDetailEntity FakeDetailEntity { get; set; } = null!;
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeDetailEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeEntityId { get; set; }
public FakeEntity FakeEntity { get; set; } = null!;
public List<FakeDetailChildEntity> DetailChildren { get; set; } = [];
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public FakeChildEntity Child { get; set; } = null!;
public List<FakeDetailEntity> Details { get; set; } = [];
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FakeGrandChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeChildEntityId { get; set; }
public FakeChildEntity FakeChildEntity { get; set; } = null!;
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class FirstOrderedSpec : Specification<FakeEntity>
{
public FirstOrderedSpec()
{
Query.OrderBy(e => e.Name);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class IncludeChildSpec : Specification<FakeEntity>
{
public IncludeChildSpec()
{
Query.Include(static e => e.Child);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class IncludeChildThenGrandChildrenSpec : Specification<FakeEntity>
{
public IncludeChildThenGrandChildrenSpec()
{
Query.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class IncludeDetailsSpec : Specification<FakeEntity>
{
public IncludeDetailsSpec()
{
Query.Include(static e => e.Details);
}
}

View file

@ -1,11 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class IncludeOnlySpec : Specification<FakeEntity>
{
public IncludeOnlySpec(SpecificationBuilder<FakeEntity> builder) : base(builder)
{
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class MultiSearchSpec : Specification<FakeEntity>
{
public MultiSearchSpec(string term1, string term2)
{
Query.Search(e => e.Name, term1, searchGroup: 1)
.Search(e => e.Name, term2, searchGroup: 1);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class MultipleWhereSpec : Specification<FakeEntity>
{
public MultipleWhereSpec()
{
Query.Where(e => e.Name.StartsWith("A"))
.Where(e => e.Name.EndsWith("Beta"));
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class NameEqualSpec : Specification<FakeEntity>
{
public NameEqualSpec(string name)
{
Query.Where(e => e.Name == name);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class NameProjectionSpec : Specification<FakeEntity, string>
{
public NameProjectionSpec()
{
Query.Select(e => e.Name);
}
}

View file

@ -1,8 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class NoSelectorSpec : Specification<FakeEntity, string>
{
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderByNameDescSpec : Specification<FakeEntity>
{
public OrderByNameDescSpec()
{
Query.OrderByDescending(e => e.Name);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderByNameSpec : Specification<FakeEntity>
{
public OrderByNameSpec()
{
Query.OrderBy(e => e.Name);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderByNameThenByIdDescSpec : Specification<FakeEntity>
{
public OrderByNameThenByIdDescSpec()
{
Query.OrderBy(e => e.Name).ThenByDescending(e => e.Id);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderByNameThenByIdSpec : Specification<FakeEntity>
{
public OrderByNameThenByIdSpec()
{
Query.OrderBy(e => e.Name).ThenBy(e => e.Id);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderedSkipSpec : Specification<FakeEntity>
{
public OrderedSkipSpec(int skip)
{
Query.OrderBy(e => e.Name);
Query.Skip(skip);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderedSkipTakeSpec : Specification<FakeEntity>
{
public OrderedSkipTakeSpec(int skip, int take)
{
Query.OrderBy(e => e.Name);
Query.Skip(skip).Take(take);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class OrderedTakeSpec : Specification<FakeEntity>
{
public OrderedTakeSpec(int take)
{
Query.OrderBy(e => e.Name);
Query.Take(take);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class PostProcessingSpec : Specification<FakeEntity>
{
public PostProcessingSpec()
{
Query.Where(e => e.Name.StartsWith("A"))
.PostProcessing(items => items.Take(1));
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class SearchSpec : Specification<FakeEntity>
{
public SearchSpec(string term, int searchGroup = 1)
{
Query.Search(e => e.Name, term, searchGroup: searchGroup);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class SkipOnlySpec : Specification<FakeEntity>
{
public SkipOnlySpec(int skip)
{
Query.Skip(skip);
}
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
internal sealed class WhereSpec : Specification<FakeEntity>
{
public WhereSpec()
{
Query.Where(e => e.Name == "Alpha");
}
}

View file

@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.Request.Persistence.EntityFrameworkCore.Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageDescription>Entity Framework Core implementation of the generic repository and specification pattern.</PackageDescription>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence.entityframeworkcore</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.persistence\Geekeey.Request.Persistence.csproj" />
</ItemGroup>
</Project>

View file

@ -1,174 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
/// <summary>
/// Represents a repository for <typeparamref name="TEntity"/>.
/// </summary>
/// <typeparam name="TEntity">The type of entity.</typeparam>
public partial class Repository<TEntity> : IRepository<TEntity>
where TEntity : class
{
private readonly SpecificationEvaluator _evaluator = SpecificationEvaluator.Default;
/// <summary>
/// Creates a new repository.
/// </summary>
/// <param name="dbContext">The <see cref="DbContext"/> used by the repository.</param>
public Repository(DbContext dbContext)
{
DbContext = dbContext;
}
/// <summary>
/// Gets the <see cref="DbContext"/> used by the repository.
/// </summary>
protected DbContext DbContext { get; }
/// <inheritdoc />
public virtual async Task<TEntity?> FirstOrDefaultAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<TResult?> FirstOrDefaultAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<List<TEntity>> ListAsync(CancellationToken cancellationToken = default)
{
return await DbContext.Set<TEntity>().ToListAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<List<TEntity>> ListAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken);
return specification.PostProcessingAction is null
? queryResult
: [.. specification.PostProcessingAction(queryResult)];
}
/// <inheritdoc />
public virtual async Task<List<TResult>> ListAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default)
{
var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken);
return specification.PostProcessingAction is null
? queryResult
: [.. specification.PostProcessingAction(queryResult)];
}
/// <inheritdoc />
public virtual async Task<int> CountAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification, true).CountAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<int> CountAsync(CancellationToken cancellationToken = default)
{
return await DbContext.Set<TEntity>().CountAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<bool> AnyAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification, true).AnyAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<bool> AnyAsync(CancellationToken cancellationToken = default)
{
return await DbContext.Set<TEntity>().AnyAsync(cancellationToken);
}
/// <inheritdoc />
public virtual IAsyncEnumerable<TEntity> AsAsyncEnumerable(ISpecification<TEntity> specification)
{
return ApplySpecification(specification).AsAsyncEnumerable();
}
protected virtual IQueryable<TEntity> ApplySpecification(ISpecification<TEntity> specification, bool evaluateCriteriaOnly = false)
{
return _evaluator.Evaluate(DbContext.Set<TEntity>(), specification, evaluateCriteriaOnly);
}
protected virtual IQueryable<TResult> ApplySpecification<TResult>(ISpecification<TEntity, TResult> specification)
{
return _evaluator.Evaluate(DbContext.Set<TEntity>(), specification);
}
}
public partial class Repository<TEntity>
{
/// <inheritdoc />
public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().Add(entity);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().AddRange(entities);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().Update(entity);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task UpdateRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().UpdateRange(entities);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().Remove(entity);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task DeleteRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
DbContext.Set<TEntity>().RemoveRange(entities);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task DeleteRangeAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default)
{
var query = ApplySpecification(specification);
DbContext.Set<TEntity>().RemoveRange(query);
await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await DbContext.SaveChangesAsync(cancellationToken);
}
}

View file

@ -1,60 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class SpecificationEvaluator
{
private readonly IReadOnlyList<IEvaluator> _evaluators;
public static SpecificationEvaluator Default { get; } = new();
private SpecificationEvaluator(bool cache = false)
{
_evaluators =
[
WhereEvaluator.Instance,
cache ? IncludeEvaluator.Cached : IncludeEvaluator.Default,
SearchEvaluator.Instance,
OrderEvaluator.Instance,
PaginationEvaluator.Instance
];
}
public IQueryable<TResult> Evaluate<T, TResult>(IQueryable<T> query, ISpecification<T, TResult> specification)
where T : class
{
ArgumentNullException.ThrowIfNull(specification);
if (specification.Selector is null && specification.SelectorMany is null)
{
throw new SelectorNotFoundException();
}
if (specification.Selector is not null && specification.SelectorMany is not null)
{
throw new ConcurrentSelectorsException();
}
query = Evaluate<T>(query, specification);
return specification.Selector is not null
? query.Select(specification.Selector)
: query.SelectMany(specification.SelectorMany!);
}
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification, bool evaluateCriteriaOnly = false)
where T : class
{
ArgumentNullException.ThrowIfNull(specification);
var evaluators = evaluateCriteriaOnly ? _evaluators.Where(static instance => instance.IsCriteriaEvaluator) : _evaluators;
foreach (var evaluator in evaluators)
{
query = evaluator.Evaluate(query, specification);
}
return query;
}
}

View file

@ -1,25 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
/// <summary>
/// Defines an evaluator that applies a specific aspect of a specification to a query.
/// </summary>
public interface IEvaluator
{
/// <summary>
/// Gets a value indicating whether this evaluator filters the result set (criteria)
/// rather than shaping the query (e.g., includes, ordering, pagination).
/// </summary>
bool IsCriteriaEvaluator { get; }
/// <summary>
/// Applies the evaluator's logic to the specified query based on the given specification.
/// </summary>
/// <typeparam name="T">The type of entity being queried.</typeparam>
/// <param name="query">The query to evaluate.</param>
/// <param name="specification">The specification containing the expression to apply.</param>
/// <returns>The modified query.</returns>
IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class;
}

View file

@ -1,173 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class IncludeEvaluator : IEvaluator
{
private static readonly MethodInfo IncludeMethodInfo
= typeof(EntityFrameworkQueryableExtensions)
.GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.Include))
.Single(static mi =>
mi.GetGenericArguments().Length is 2 &&
mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) &&
mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>));
private static readonly MethodInfo ThenIncludeAfterReferenceMethodInfo
= typeof(EntityFrameworkQueryableExtensions)
.GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude))
.Where(static mi => mi.GetGenericArguments().Length is 3)
.Single(static mi =>
mi.GetParameters()[0].ParameterType.GenericTypeArguments[1].IsGenericParameter &&
mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) &&
mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>));
private static readonly MethodInfo ThenIncludeAfterEnumerableMethodInfo
= typeof(EntityFrameworkQueryableExtensions)
.GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude))
.Where(static mi => mi.GetGenericArguments().Length is 3)
.Single(static mi =>
{
var typeInfo = mi.GetParameters()[0].ParameterType.GenericTypeArguments[1];
return typeInfo.IsGenericType &&
typeInfo.GetGenericTypeDefinition() == typeof(IEnumerable<>) &&
mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) &&
mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>);
});
private static readonly ConcurrentDictionary<(Type EntityType, Type PropertyType, Type? PreviousPropertyType), Lazy<Func<IQueryable, LambdaExpression, IQueryable>>> DelegatesCache = new();
private readonly bool _cacheEnabled;
private IncludeEvaluator(bool cacheEnabled)
{
_cacheEnabled = cacheEnabled;
}
public static IncludeEvaluator Default { get; } = new(false);
public static IncludeEvaluator Cached { get; } = new(true);
public bool IsCriteriaEvaluator => false;
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
foreach (var includeInfo in specification.IncludeExpressions)
{
if (includeInfo.Type == IncludeType.Include)
{
query = BuildInclude<T>(query, includeInfo);
}
else if (includeInfo.Type == IncludeType.ThenInclude)
{
query = BuildThenInclude<T>(query, includeInfo);
}
}
return query;
}
private IQueryable<T> BuildInclude<T>(IQueryable query, IncludeExpressionInfo includeInfo)
{
if (!_cacheEnabled)
{
var result = IncludeMethodInfo.MakeGenericMethod(includeInfo.EntityType, includeInfo.PropertyType)
.Invoke(null, [query, includeInfo.LambdaExpression]);
_ = result ?? throw new TargetException();
return (IQueryable<T>)result;
}
var include = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, null), CreateIncludeDelegate).Value;
return (IQueryable<T>)include(query, includeInfo.LambdaExpression);
}
private static Lazy<Func<IQueryable, LambdaExpression, IQueryable>> CreateIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey)
{
return new Lazy<Func<IQueryable, LambdaExpression, IQueryable>>(() =>
{
var concreteInclude = IncludeMethodInfo.MakeGenericMethod(cacheKey.EntityType, cacheKey.PropertyType);
var sourceParameter = Expression.Parameter(typeof(IQueryable));
var selectorParameter = Expression.Parameter(typeof(LambdaExpression));
var call = Expression.Call(concreteInclude,
Expression.Convert(sourceParameter, typeof(IQueryable<>).MakeGenericType(cacheKey.EntityType)),
Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PropertyType))));
var lambda = Expression.Lambda<Func<IQueryable, LambdaExpression, IQueryable>>(call, sourceParameter, selectorParameter);
return lambda.Compile();
});
}
private IQueryable<T> BuildThenInclude<T>(IQueryable query, IncludeExpressionInfo includeInfo)
{
ArgumentNullException.ThrowIfNull(includeInfo.PreviousPropertyType);
if (!_cacheEnabled)
{
var result = (IsGenericEnumerable(includeInfo.PreviousPropertyType, out var previousPropertyType)
? ThenIncludeAfterEnumerableMethodInfo
: ThenIncludeAfterReferenceMethodInfo).MakeGenericMethod(includeInfo.EntityType, previousPropertyType, includeInfo.PropertyType)
.Invoke(null, [query, includeInfo.LambdaExpression]);
_ = result ?? throw new TargetException();
return (IQueryable<T>)result;
}
var thenInclude = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, includeInfo.PreviousPropertyType), CreateThenIncludeDelegate).Value;
return (IQueryable<T>)thenInclude(query, includeInfo.LambdaExpression);
}
private static Lazy<Func<IQueryable, LambdaExpression, IQueryable>> CreateThenIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey)
{
return new Lazy<Func<IQueryable, LambdaExpression, IQueryable>>(() =>
{
ArgumentNullException.ThrowIfNull(cacheKey.PreviousPropertyType);
var thenIncludeInfo = ThenIncludeAfterReferenceMethodInfo;
if (IsGenericEnumerable(cacheKey.PreviousPropertyType, out var previousPropertyType))
{
thenIncludeInfo = ThenIncludeAfterEnumerableMethodInfo;
}
var concreteThenInclude = thenIncludeInfo.MakeGenericMethod(cacheKey.EntityType, previousPropertyType, cacheKey.PropertyType);
var sourceParameter = Expression.Parameter(typeof(IQueryable));
var selectorParameter = Expression.Parameter(typeof(LambdaExpression));
var call = Expression.Call(concreteThenInclude,
Expression.Convert(sourceParameter, typeof(IIncludableQueryable<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PreviousPropertyType)),
Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(previousPropertyType, cacheKey.PropertyType))));
var lambda = Expression.Lambda<Func<IQueryable, LambdaExpression, IQueryable>>(call, sourceParameter, selectorParameter);
return lambda.Compile();
});
}
private static bool IsGenericEnumerable(Type type, out Type propertyType)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
propertyType = type.GenericTypeArguments[0];
return true;
}
propertyType = type;
return false;
}
}

View file

@ -1,43 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Request.Persistence.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class OrderEvaluator : IEvaluator
{
private OrderEvaluator() { }
internal static OrderEvaluator Instance { get; } = new();
public bool IsCriteriaEvaluator { get; }
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
if (specification.OrderExpressions.Count(static info => info.OrderType is OrderType.OrderBy or OrderType.OrderByDescending) > 1)
{
throw new DuplicateOrderChainException();
}
IOrderedQueryable<T>? orderedQuery = null;
foreach (var orderExpression in specification.OrderExpressions)
{
orderedQuery = orderExpression.OrderType switch
{
OrderType.OrderBy => query.OrderBy(orderExpression.KeySelector),
OrderType.OrderByDescending => query.OrderByDescending(orderExpression.KeySelector),
OrderType.ThenBy => orderedQuery!.ThenBy(orderExpression.KeySelector),
OrderType.ThenByDescending => orderedQuery!.ThenByDescending(orderExpression.KeySelector),
_ => orderedQuery
};
}
if (orderedQuery is not null)
{
query = orderedQuery;
}
return query;
}
}

View file

@ -1,28 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class PaginationEvaluator : IEvaluator
{
private PaginationEvaluator() { }
public static PaginationEvaluator Instance { get; } = new();
public bool IsCriteriaEvaluator { get; }
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
if (specification.Skip is not null and not 0)
{
query = query.Skip(specification.Skip.Value);
}
if (specification.Take is not null)
{
query = query.Take(specification.Take.Value);
}
return query;
}
}

View file

@ -1,81 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class SearchEvaluator : IEvaluator
{
private static readonly MemberExpression Functions
= Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions)) ?? throw new TargetException("The EF.Functions not found!"));
private static readonly MethodInfo LikeMethodInfo
= typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])
?? throw new TargetException("The EF.Functions.Like not found");
private SearchEvaluator() { }
public static SearchEvaluator Instance { get; } = new();
public bool IsCriteriaEvaluator { get; } = true;
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
foreach (var searchCriteria in specification.SearchCriteria.GroupBy(static info => info.SearchGroup))
{
query = Search(query, searchCriteria);
}
return query;
}
private static IQueryable<T> Search<T>(IQueryable<T> source, IEnumerable<SearchExpressionInfo<T>> criteria)
{
Expression? expr = null;
var parameter = Expression.Parameter(typeof(T), "x");
foreach (var info in criteria)
{
if (string.IsNullOrEmpty(info.SearchTerm))
{
continue;
}
LambdaExpression selector = info.Selector;
selector = ParameterReplacerVisitor.Replace(selector, info.Selector.Parameters[0], parameter);
var expression = ((Expression<Func<string>>)(() => info.SearchTerm)).Body;
var likeExpression = Expression.Call(null, LikeMethodInfo, Functions, selector.Body, expression);
expr = expr is null ? likeExpression : Expression.OrElse(expr, likeExpression);
}
return expr is null ? source : source.Where(Expression.Lambda<Func<T, bool>>(expr, parameter));
}
private sealed class ParameterReplacerVisitor : ExpressionVisitor
{
private readonly Expression _newExpression;
private readonly ParameterExpression _oldParameter;
private ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression)
{
_oldParameter = oldParameter;
_newExpression = newExpression;
}
internal static T Replace<T>(T expression, ParameterExpression oldParameter, Expression newExpression) where T : Expression
{
return (T)new ParameterReplacerVisitor(oldParameter, newExpression).Visit(expression);
}
protected override Expression VisitParameter(ParameterExpression expression)
{
return expression == _oldParameter ? _newExpression : expression;
}
}
}

View file

@ -1,23 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
internal sealed class WhereEvaluator : IEvaluator
{
private WhereEvaluator() { }
internal static WhereEvaluator Instance { get; } = new();
public bool IsCriteriaEvaluator { get; } = true;
public IQueryable<T> Evaluate<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
foreach (var info in specification.WhereExpressions)
{
query = query.Where(info.Filter);
}
return query;
}
}

View file

@ -1,33 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
/// <summary>
/// Represents an error that occurs when a specification defines both <c>Select()</c> and <c>SelectMany()</c> transforms.
/// </summary>
/// <remarks>
/// Only one selector transform is permitted per specification.
/// </remarks>
public sealed class ConcurrentSelectorsException : Exception
{
private const string ExceptionMessage = "Concurrent specification selector transforms defined. Ensure only one of the Select() or SelectMany() transforms is used in the same specification!";
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentSelectorsException"/> class.
/// </summary>
public ConcurrentSelectorsException()
: base(ExceptionMessage)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentSelectorsException"/> class with a reference
/// to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public ConcurrentSelectorsException(Exception innerException)
: base(ExceptionMessage, innerException)
{
}
}

View file

@ -1,34 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
/// <summary>
/// Represents an error that occurs when a specification contains more than one ordering chain.
/// </summary>
/// <remarks>
/// Only a single <c>OrderBy()</c> or <c>OrderByDescending()</c> clause is permitted per specification.
/// Subsequent ordering must use <c>ThenBy()</c> or <c>ThenByDescending()</c>.
/// </remarks>
public sealed class DuplicateOrderChainException : Exception
{
private const string ExceptionMessage = "The specification contains more than one Order chain!";
/// <summary>
/// Initializes a new instance of the <see cref="DuplicateOrderChainException"/> class.
/// </summary>
public DuplicateOrderChainException()
: base(ExceptionMessage)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DuplicateOrderChainException"/> class with a reference
/// to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public DuplicateOrderChainException(Exception innerException)
: base(ExceptionMessage, innerException)
{
}
}

View file

@ -1,33 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.EntityFrameworkCore;
/// <summary>
/// Represents an error that occurs when a specification has no selector transform defined.
/// </summary>
/// <remarks>
/// A specification targeting a projected result must define either <c>Select()</c> or <c>SelectMany()</c>.
/// </remarks>
public sealed class SelectorNotFoundException : Exception
{
private const string ExceptionMessage = "The specification must have a selector transform defined. Ensure either Select() or SelectMany() is used in the specification!";
/// <summary>
/// Initializes a new instance of the <see cref="SelectorNotFoundException"/> class.
/// </summary>
public SelectorNotFoundException()
: base(ExceptionMessage)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SelectorNotFoundException"/> class with a reference
/// to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public SelectorNotFoundException(Exception innerException)
: base(ExceptionMessage, innerException)
{
}
}

View file

@ -1,8 +0,0 @@
[*.{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

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

View file

@ -1,216 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class SpecificationBuilderTests
{
[Test]
public async Task Where_adds_filter()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Where(static x => x.Name == "test");
await Assert.That(builder.WhereExpressions).Count().IsEqualTo(1);
}
[Test]
public async Task Where_with_false_condition_does_not_add_filter()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Where(static x => x.Name == "test", false);
await Assert.That(builder.WhereExpressions).IsEmpty();
}
[Test]
public async Task OrderBy_adds_order_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.OrderBy(static x => x.Name);
await Assert.That(builder.OrderExpressions).Count().IsEqualTo(1);
await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderBy);
}
[Test]
public async Task OrderByDescending_adds_order_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.OrderByDescending(static x => x.Name);
await Assert.That(builder.OrderExpressions).Count().IsEqualTo(1);
await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderByDescending);
}
[Test]
public async Task ThenBy_after_order_by_chains_correctly()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.OrderBy(static x => x.Name).ThenBy(static x => x.Id);
await Assert.That(builder.OrderExpressions).Count().IsEqualTo(2);
await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderBy);
await Assert.That(builder.OrderExpressions[1].OrderType).IsEqualTo(OrderType.ThenBy);
}
[Test]
public async Task ThenByDescending_after_order_by_chains_correctly()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.OrderBy(static x => x.Name).ThenByDescending(static x => x.Id);
await Assert.That(builder.OrderExpressions).Count().IsEqualTo(2);
await Assert.That(builder.OrderExpressions[1].OrderType).IsEqualTo(OrderType.ThenByDescending);
}
[Test]
public async Task Take_sets_value()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Take(10);
await Assert.That(builder.Take).IsEqualTo(10);
}
[Test]
public async Task DuplicateTake_throws()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Take(10);
await Assert.That(() => builder.Take(5)).Throws<DuplicateTakeException>();
}
[Test]
public async Task Skip_sets_value()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Skip(5);
await Assert.That(builder.Skip).IsEqualTo(5);
}
[Test]
public async Task DuplicateSkip_throws()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Skip(5);
await Assert.That(() => builder.Skip(10)).Throws<DuplicateSkipException>();
}
[Test]
public async Task Include_adds_include_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static x => x.Name);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
}
[Test]
public async Task Include_on_reference_navigation_adds_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
await Assert.That(builder.IncludeExpressions[0].EntityType).IsEqualTo(typeof(FakeEntity));
await Assert.That(builder.IncludeExpressions[0].PropertyType).IsEqualTo(typeof(FakeChildEntity));
}
[Test]
public async Task Include_on_collection_navigation_adds_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Details);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
await Assert.That(builder.IncludeExpressions[0].EntityType).IsEqualTo(typeof(FakeEntity));
await Assert.That(builder.IncludeExpressions[0].PropertyType).IsEqualTo(typeof(List<FakeDetailEntity>));
}
[Test]
public async Task Include_with_false_condition_does_not_add_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child, false);
await Assert.That(builder.IncludeExpressions).IsEmpty();
}
[Test]
public async Task ThenInclude_after_reference_navigation_adds_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child).ThenInclude(static c => c.Value);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(2);
await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
await Assert.That(builder.IncludeExpressions[1].Type).IsEqualTo(IncludeType.ThenInclude);
await Assert.That(builder.IncludeExpressions[1].PreviousPropertyType).IsEqualTo(typeof(FakeChildEntity));
}
[Test]
public async Task ThenInclude_with_false_condition_does_not_add_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child).ThenInclude(static c => c.Value, false);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
}
[Test]
public async Task Include_chains_ThenInclude_multiple_levels()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Include(static e => e.Child).ThenInclude(static c => c.FakeEntity).ThenInclude(static fe => fe.Details);
await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(3);
await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
await Assert.That(builder.IncludeExpressions[1].Type).IsEqualTo(IncludeType.ThenInclude);
await Assert.That(builder.IncludeExpressions[2].Type).IsEqualTo(IncludeType.ThenInclude);
await Assert.That(builder.IncludeExpressions[2].PreviousPropertyType).IsEqualTo(typeof(FakeEntity));
}
[Test]
public async Task ThenInclude_on_discarded_chain_does_not_add_expression()
{
var builder = new SpecificationBuilder<FakeEntity>();
var includable = builder.Include(static e => e.Child, false);
includable.ThenInclude(static c => c.Value);
await Assert.That(builder.IncludeExpressions).IsEmpty();
}
[Test]
public async Task Select_sets_selector()
{
var builder = new SpecificationBuilder<FakeEntity, string>();
builder.Select(static x => x.Name);
await Assert.That(builder.Selector).IsNotNull();
}
[Test]
public async Task PostProcessing_sets_action()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.PostProcessing(static items => items.Where(static x => x.Name.Length > 0));
await Assert.That(builder.PostProcessingAction).IsNotNull();
}
[Test]
public async Task Search_adds_criteria()
{
var builder = new SpecificationBuilder<FakeEntity>();
builder.Search(static x => x.Name, "test");
await Assert.That(builder.SearchCriteria).Count().IsEqualTo(1);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeEntityId { get; set; }
public FakeEntity FakeEntity { get; set; } = null!;
public List<FakeGrandChildEntity> GrandChildren { get; set; } = [];
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeDetailChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeDetailEntityId { get; set; }
public FakeDetailEntity FakeDetailEntity { get; set; } = null!;
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeDetailEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeEntityId { get; set; }
public FakeEntity FakeEntity { get; set; } = null!;
public List<FakeDetailChildEntity> DetailChildren { get; set; } = [];
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public FakeChildEntity Child { get; set; } = null!;
public List<FakeDetailEntity> Details { get; set; } = [];
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeGrandChildEntity
{
public Guid Id { get; set; }
public string Value { get; set; } = string.Empty;
public Guid FakeChildEntityId { get; set; }
public FakeChildEntity FakeChildEntity { get; set; } = null!;
}

View file

@ -1,12 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeSpecification : Specification<FakeEntity>
{
public FakeSpecification()
{
Query.Where(static x => x.Name.Length > 0);
}
}

View file

@ -1,13 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class FakeSpecificationWithResult : Specification<FakeEntity, string>
{
public FakeSpecificationWithResult()
{
Query.Where(static x => x.Name.Length > 0);
Query.Select(static x => x.Name);
}
}

View file

@ -1,8 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence.Tests;
internal sealed class SpecificationTestBuilder : Specification<FakeEntity>
{
}

View file

@ -1,31 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.Request.Persistence.Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageDescription>Generic repository and specification pattern abstractions for .NET.</PackageDescription>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -1,155 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Defines query operations for entities of type <typeparamref name="TEntity"/>.
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
public partial interface IRepository<TEntity>
where TEntity : class
{
/// <summary>
/// Returns the first element that matches the specification, or <see langword="null"/>.
/// </summary>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The entity, or <see langword="null"/>.</returns>
Task<TEntity?> FirstOrDefaultAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the first projected result that matches the specification, or <see langword="null"/>.
/// </summary>
/// <typeparam name="TResult">The result type.</typeparam>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The projected result, or <see langword="null"/>.</returns>
Task<TResult?> FirstOrDefaultAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns all entities.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A list of entities.</returns>
Task<List<TEntity>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns entities that match the specification.
/// </summary>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A list of entities.</returns>
Task<List<TEntity>> ListAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns projected results that match the specification.
/// </summary>
/// <typeparam name="TResult">The result type.</typeparam>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A list of projected results.</returns>
Task<List<TResult>> ListAsync<TResult>(ISpecification<TEntity, TResult> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the number of entities that match the specification.
/// </summary>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The count.</returns>
Task<int> CountAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the total number of entities.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The count.</returns>
Task<int> CountAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns whether any entity matches the specification.
/// </summary>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns><see langword="true"/> if any entity matches; otherwise <see langword="false"/>.</returns>
Task<bool> AnyAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Returns whether any entity exists.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns><see langword="true"/> if any entity exists; otherwise <see langword="false"/>.</returns>
Task<bool> AnyAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns an async enumerable for entities that match the specification.
/// </summary>
/// <param name="specification">The specification.</param>
/// <returns>An async enumerable of entities.</returns>
IAsyncEnumerable<TEntity> AsAsyncEnumerable(ISpecification<TEntity> specification);
}
/// <summary>
/// Defines persistence operations for entities of type <typeparamref name="TEntity"/>.
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
public partial interface IRepository<TEntity>
{
/// <summary>
/// Adds an entity.
/// </summary>
/// <param name="entity">The entity to add.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The added entity.</returns>
Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Adds multiple entities.
/// </summary>
/// <param name="entities">The entities to add.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The added entities.</returns>
Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an entity.
/// </summary>
/// <param name="entity">The entity to update.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Updates multiple entities.
/// </summary>
/// <param name="entities">The entities to update.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task UpdateRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an entity.
/// </summary>
/// <param name="entity">The entity to delete.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes multiple entities.
/// </summary>
/// <param name="entities">The entities to delete.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task DeleteRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes entities that match the specification.
/// </summary>
/// <param name="specification">The specification.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task DeleteRangeAsync(ISpecification<TEntity> specification, CancellationToken cancellationToken = default);
/// <summary>
/// Persists all pending changes.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The number of affected rows.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View file

@ -1,71 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Encapsulates query logic for <typeparamref name="TEntity"/>.
/// </summary>
/// <typeparam name="TEntity">The type being queried against.</typeparam>
public interface ISpecification<TEntity>
{
/// <summary>
/// Gets the collection of filters.
/// </summary>
IReadOnlyList<WhereExpressionInfo<TEntity>> WhereExpressions { get; }
/// <summary>
/// Gets the collection of order expressions.
/// </summary>
IReadOnlyList<OrderExpressionInfo<TEntity>> OrderExpressions { get; }
/// <summary>
/// Gets the collection of include expressions.
/// </summary>
IReadOnlyList<IncludeExpressionInfo> IncludeExpressions { get; }
/// <summary>
/// Gets the collection of search criteria.
/// </summary>
IReadOnlyList<SearchExpressionInfo<TEntity>> SearchCriteria { get; }
/// <summary>
/// Gets the post-processing action to apply to the result.
/// </summary>
Func<IEnumerable<TEntity>, IEnumerable<TEntity>>? PostProcessingAction { get; }
/// <summary>
/// Gets the number of elements to take.
/// </summary>
int? Take { get; }
/// <summary>
/// Gets the number of elements to skip.
/// </summary>
int? Skip { get; }
}
/// <summary>
/// Encapsulates query logic for <typeparamref name="T"/> and projects the result into <typeparamref name="TResult"/>.
/// </summary>
/// <typeparam name="T">The type being queried against.</typeparam>
/// <typeparam name="TResult">The type of the result.</typeparam>
public interface ISpecification<T, TResult> : ISpecification<T>
{
/// <summary>
/// Gets the selector expression.
/// </summary>
Expression<Func<T, TResult>>? Selector { get; }
/// <summary>
/// Gets the selector many expression.
/// </summary>
Expression<Func<T, IEnumerable<TResult>>>? SelectorMany { get; }
/// <summary>
/// Gets the post-processing action to apply to the projected result.
/// </summary>
new Func<IEnumerable<TResult>, IEnumerable<TResult>>? PostProcessingAction { get; }
}

View file

@ -1,20 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Specifies the type of include operation.
/// </summary>
public enum IncludeType
{
/// <summary>
/// Represents an Include operation.
/// </summary>
Include = 1,
/// <summary>
/// Represents a ThenInclude operation.
/// </summary>
ThenInclude = 2,
}

View file

@ -1,30 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Specifies the ordering direction.
/// </summary>
public enum OrderType
{
/// <summary>
/// Orders by ascending.
/// </summary>
OrderBy = 1,
/// <summary>
/// Orders by descending.
/// </summary>
OrderByDescending = 2,
/// <summary>
/// Then by ascending.
/// </summary>
ThenBy = 3,
/// <summary>
/// Then by descending.
/// </summary>
ThenByDescending = 4
}

View file

@ -1,103 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Represents a specification for filtering, ordering, and including related entities.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public abstract class Specification<T> : ISpecification<T>
{
/// <summary>
/// Creates a new specification.
/// </summary>
protected Specification()
: this(new SpecificationBuilder<T>())
{
}
/// <summary>
/// Creates a new specification with the given builder.
/// </summary>
/// <param name="builder">The specification builder.</param>
protected Specification(SpecificationBuilder<T> builder)
{
Query = builder;
}
/// <summary>
/// Gets the specification builder.
/// </summary>
protected SpecificationBuilder<T> Query { get; }
/// <inheritdoc />
IReadOnlyList<WhereExpressionInfo<T>> ISpecification<T>.WhereExpressions
=> Query.WhereExpressions;
/// <inheritdoc />
IReadOnlyList<OrderExpressionInfo<T>> ISpecification<T>.OrderExpressions
=> Query.OrderExpressions;
/// <inheritdoc />
IReadOnlyList<IncludeExpressionInfo> ISpecification<T>.IncludeExpressions
=> Query.IncludeExpressions;
/// <inheritdoc />
IReadOnlyList<SearchExpressionInfo<T>> ISpecification<T>.SearchCriteria
=> Query.SearchCriteria;
/// <inheritdoc />
Func<IEnumerable<T>, IEnumerable<T>>? ISpecification<T>.PostProcessingAction
=> Query.PostProcessingAction;
/// <inheritdoc />
int? ISpecification<T>.Take => Query.Take;
/// <inheritdoc />
int? ISpecification<T>.Skip => Query.Skip;
}
/// <summary>
/// Represents a specification for filtering, ordering, and including related entities,
/// with a projection to <typeparamref name="TResult"/>.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public abstract class Specification<T, TResult> : Specification<T>, ISpecification<T, TResult>
{
/// <summary>
/// Creates a new specification.
/// </summary>
protected Specification() : this(new SpecificationBuilder<T, TResult>())
{
}
/// <summary>
/// Creates a new specification with the given builder.
/// </summary>
/// <param name="builder">The specification builder.</param>
protected Specification(SpecificationBuilder<T, TResult> builder) : base(builder)
{
Query = builder;
}
/// <summary>
/// Gets the specification builder.
/// </summary>
protected new SpecificationBuilder<T, TResult> Query { get; }
/// <inheritdoc />
Expression<Func<T, TResult>>? ISpecification<T, TResult>.Selector
=> Query.Selector;
/// <inheritdoc />
Expression<Func<T, IEnumerable<TResult>>>? ISpecification<T, TResult>.SelectorMany
=> Query.SelectorMany;
/// <inheritdoc />
Func<IEnumerable<TResult>, IEnumerable<TResult>>? ISpecification<T, TResult>.PostProcessingAction
=> Query.PostProcessingAction;
}

View file

@ -1,101 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides extension methods for <see cref="IncludableSpecificationBuilder{T, TProperty}"/>.
/// </summary>
public static class IncludableBuilderExtensions
{
/// <summary>
/// Specifies a ThenInclude operation on a reference navigation property.
/// </summary>
/// <param name="builder">The includable builder.</param>
/// <param name="expression">The expression.</param>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TPreviousProperty">The previous property type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable builder for further chaining.</returns>
public static IncludableSpecificationBuilder<TEntity, TProperty> ThenInclude<TEntity, TPreviousProperty, TProperty>(
this IncludableSpecificationBuilder<TEntity, TPreviousProperty> builder,
Expression<Func<TPreviousProperty, TProperty>> expression)
where TEntity : class
{
return ThenInclude(builder, expression, condition: true);
}
/// <summary>
/// Specifies a ThenInclude operation when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The includable builder.</param>
/// <param name="expression">The expression.</param>
/// <param name="condition">Whether to apply the ThenInclude.</param>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TPreviousProperty">The previous property type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable builder for further chaining.</returns>
public static IncludableSpecificationBuilder<TEntity, TProperty> ThenInclude<TEntity, TPreviousProperty, TProperty>(
this IncludableSpecificationBuilder<TEntity, TPreviousProperty> builder,
Expression<Func<TPreviousProperty, TProperty>> expression, bool condition)
where TEntity : class
{
if (condition && !builder.IsChainDiscarded)
{
var info = new IncludeExpressionInfo(expression, typeof(TEntity), typeof(TProperty), typeof(TPreviousProperty));
builder.Builder.IncludeExpressions.Add(info);
}
var includeBuilder = new IncludableSpecificationBuilder<TEntity, TProperty>(builder.Builder, !condition || builder.IsChainDiscarded);
return includeBuilder;
}
/// <summary>
/// Specifies a ThenInclude operation on a collection navigation property.
/// </summary>
/// <param name="previousBuilder">The includable builder.</param>
/// <param name="expression">The expression.</param>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TPreviousProperty">The previous property type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable builder for further chaining.</returns>
public static IncludableSpecificationBuilder<TEntity, TProperty> ThenInclude<TEntity, TPreviousProperty, TProperty>(
this IncludableSpecificationBuilder<TEntity, IEnumerable<TPreviousProperty>> previousBuilder,
Expression<Func<TPreviousProperty, TProperty>> expression)
where TEntity : class
{
return ThenInclude(previousBuilder, expression, condition: true);
}
/// <summary>
/// Specifies a ThenInclude operation on a collection navigation property when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The includable builder.</param>
/// <param name="expression">The expression.</param>
/// <param name="condition">Whether to apply the ThenInclude.</param>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TPreviousProperty">The previous property type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable builder for further chaining.</returns>
public static IncludableSpecificationBuilder<TEntity, TProperty> ThenInclude<TEntity, TPreviousProperty, TProperty>(
this IncludableSpecificationBuilder<TEntity, IEnumerable<TPreviousProperty>> builder,
Expression<Func<TPreviousProperty, TProperty>> expression,
bool condition)
where TEntity : class
{
if (condition && !builder.IsChainDiscarded)
{
var info = new IncludeExpressionInfo(expression, typeof(TEntity), typeof(TProperty), typeof(IEnumerable<TPreviousProperty>));
builder.Builder.IncludeExpressions.Add(info);
}
var includeBuilder = new IncludableSpecificationBuilder<TEntity, TProperty>(builder.Builder, !condition || builder.IsChainDiscarded);
return includeBuilder;
}
}

View file

@ -1,33 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides a builder for chaining ThenInclude operations.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
public class IncludableSpecificationBuilder<T, TProperty> where T : class
{
/// <summary>
/// Gets the underlying specification builder.
/// </summary>
public SpecificationBuilder<T> Builder { get; }
/// <summary>
/// Gets or sets whether the chain is discarded.
/// </summary>
public bool IsChainDiscarded { get; set; }
/// <summary>
/// Creates a new includable specification builder.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="isChainDiscarded">Whether the chain is discarded.</param>
public IncludableSpecificationBuilder(SpecificationBuilder<T> builder, bool isChainDiscarded = false)
{
Builder = builder;
IsChainDiscarded = isChainDiscarded;
}
}

View file

@ -1,80 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides extension methods for <see cref="OrderedSpecificationBuilder{T}"/>.
/// </summary>
public static class OrderedBuilderExtensions
{
/// <summary>
/// Adds a ThenBy expression for secondary ordering.
/// </summary>
/// <param name="builder">The ordered builder.</param>
/// <param name="expression">The expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The ordered builder.</returns>
public static OrderedSpecificationBuilder<T> ThenBy<T>(this OrderedSpecificationBuilder<T> builder, Expression<Func<T, object?>> expression)
{
return ThenBy(builder, expression, condition: true);
}
/// <summary>
/// Adds a ThenBy expression when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The ordered builder.</param>
/// <param name="expression">The expression.</param>
/// <param name="condition">Whether to apply the ThenBy.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The ordered builder.</returns>
public static OrderedSpecificationBuilder<T> ThenBy<T>(this OrderedSpecificationBuilder<T> builder, Expression<Func<T, object?>> expression, bool condition)
{
if (condition && !builder.IsChainDiscarded)
{
builder.Builder.OrderExpressions.Add(new OrderExpressionInfo<T>(expression, OrderType.ThenBy));
}
else
{
builder.IsChainDiscarded = true;
}
return builder;
}
/// <summary>
/// Adds a ThenByDescending expression for secondary ordering.
/// </summary>
/// <param name="builder">The ordered builder.</param>
/// <param name="expression">The expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The ordered builder.</returns>
public static OrderedSpecificationBuilder<T> ThenByDescending<T>(this OrderedSpecificationBuilder<T> builder, Expression<Func<T, object?>> expression)
{
return ThenByDescending(builder, expression, condition: true);
}
/// <summary>
/// Adds a ThenByDescending expression when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The ordered builder.</param>
/// <param name="expression">The expression.</param>
/// <param name="condition">Whether to apply the ThenByDescending.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The ordered builder.</returns>
public static OrderedSpecificationBuilder<T> ThenByDescending<T>(this OrderedSpecificationBuilder<T> builder, Expression<Func<T, object?>> expression, bool condition)
{
if (condition && !builder.IsChainDiscarded)
{
builder.Builder.OrderExpressions.Add(new OrderExpressionInfo<T>(expression, OrderType.ThenByDescending));
}
else
{
builder.IsChainDiscarded = true;
}
return builder;
}
}

View file

@ -1,32 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides a builder for chaining ThenBy / ThenByDescending operations.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public sealed class OrderedSpecificationBuilder<T>
{
/// <summary>
/// Gets the underlying specification builder.
/// </summary>
public SpecificationBuilder<T> Builder { get; }
/// <summary>
/// Gets or sets whether the chain is discarded.
/// </summary>
public bool IsChainDiscarded { get; set; }
/// <summary>
/// Creates a new ordered specification builder.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="isChainDiscarded">Whether the chain is discarded.</param>
public OrderedSpecificationBuilder(SpecificationBuilder<T> builder, bool isChainDiscarded = false)
{
Builder = builder;
IsChainDiscarded = isChainDiscarded;
}
}

View file

@ -1,72 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides a mutable container for building a specification for <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public class SpecificationBuilder<T>
{
/// <summary>
/// Gets the list of where expressions.
/// </summary>
public List<WhereExpressionInfo<T>> WhereExpressions { get; } = [];
/// <summary>
/// Gets the list of order expressions.
/// </summary>
public List<OrderExpressionInfo<T>> OrderExpressions { get; } = [];
/// <summary>
/// Gets the list of include expressions.
/// </summary>
public List<IncludeExpressionInfo> IncludeExpressions { get; } = [];
/// <summary>
/// Gets the list of search criteria.
/// </summary>
public List<SearchExpressionInfo<T>> SearchCriteria { get; } = [];
/// <summary>
/// Gets or sets the post-processing action.
/// </summary>
public Func<IEnumerable<T>, IEnumerable<T>>? PostProcessingAction { get; set; }
/// <summary>
/// Gets or sets the number of elements to take.
/// </summary>
public int? Take { get; set; }
/// <summary>
/// Gets or sets the number of elements to skip.
/// </summary>
public int? Skip { get; set; }
}
/// <summary>
/// Provides a mutable container for building a specification for <typeparamref name="T"/>
/// with a projection to <typeparamref name="TResult"/>.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class SpecificationBuilder<T, TResult> : SpecificationBuilder<T>
{
/// <summary>
/// Gets or sets the selector expression.
/// </summary>
public Expression<Func<T, TResult>>? Selector { get; set; }
/// <summary>
/// Gets or sets the selector many expression.
/// </summary>
public Expression<Func<T, IEnumerable<TResult>>>? SelectorMany { get; set; }
/// <summary>
/// Gets or sets the post-processing action for the projected result.
/// </summary>
public new Func<IEnumerable<TResult>, IEnumerable<TResult>>? PostProcessingAction { get; set; }
}

View file

@ -1,305 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Provides extension methods for <see cref="SpecificationBuilder{T}"/>.
/// </summary>
public static class SpecificationBuilderExtensions
{
/// <summary>
/// Adds a filter predicate to the specification.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="criteria">The filter expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Where<T>(this SpecificationBuilder<T> builder, Expression<Func<T, bool>> criteria)
{
return Where(builder, criteria, condition: true);
}
/// <summary>
/// Adds a filter predicate to the specification when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="criteria">The filter expression.</param>
/// <param name="condition">Whether to apply the filter.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Where<T>(this SpecificationBuilder<T> builder, Expression<Func<T, bool>> criteria, bool condition)
{
if (condition)
{
builder.WhereExpressions.Add(new WhereExpressionInfo<T>(criteria));
}
return builder;
}
/// <summary>
/// Adds an ascending order expression.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="orderExpression">The order expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>An ordered specification builder for further chaining.</returns>
public static OrderedSpecificationBuilder<T> OrderBy<T>(this SpecificationBuilder<T> builder, Expression<Func<T, object?>> orderExpression)
{
return OrderBy(builder, orderExpression, condition: true);
}
/// <summary>
/// Adds an ascending order expression when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="orderExpression">The order expression.</param>
/// <param name="condition">Whether to apply the order.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>An ordered specification builder for further chaining.</returns>
public static OrderedSpecificationBuilder<T> OrderBy<T>(this SpecificationBuilder<T> builder, Expression<Func<T, object?>> orderExpression, bool condition)
{
if (condition)
{
builder.OrderExpressions.Add(new OrderExpressionInfo<T>(orderExpression, OrderType.OrderBy));
}
var orderedSpecificationBuilder = new OrderedSpecificationBuilder<T>(builder, !condition);
return orderedSpecificationBuilder;
}
/// <summary>
/// Adds a descending order expression.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="orderExpression">The order expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>An ordered specification builder for further chaining.</returns>
public static OrderedSpecificationBuilder<T> OrderByDescending<T>(this SpecificationBuilder<T> builder, Expression<Func<T, object?>> orderExpression)
{
return OrderByDescending(builder, orderExpression, condition: true);
}
/// <summary>
/// Adds a descending order expression when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="orderExpression">The order expression.</param>
/// <param name="condition">Whether to apply the order.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>An ordered specification builder for further chaining.</returns>
public static OrderedSpecificationBuilder<T> OrderByDescending<T>(this SpecificationBuilder<T> builder, Expression<Func<T, object?>> orderExpression, bool condition)
{
if (condition)
{
builder.OrderExpressions.Add(new OrderExpressionInfo<T>(orderExpression, OrderType.OrderByDescending));
}
var orderedSpecificationBuilder = new OrderedSpecificationBuilder<T>(builder, !condition);
return orderedSpecificationBuilder;
}
/// <summary>
/// Adds an include expression to load a related entity.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="includeExpression">The include expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable specification builder for further chaining.</returns>
public static IncludableSpecificationBuilder<T, TProperty> Include<T, TProperty>(this SpecificationBuilder<T> builder, Expression<Func<T, TProperty>> includeExpression) where T : class
{
return Include(builder, includeExpression, condition: true);
}
/// <summary>
/// Adds an include expression when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="includeExpression">The include expression.</param>
/// <param name="condition">Whether to apply the include.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TProperty">The property type.</typeparam>
/// <returns>An includable specification builder for further chaining.</returns>
public static IncludableSpecificationBuilder<T, TProperty> Include<T, TProperty>(this SpecificationBuilder<T> builder, Expression<Func<T, TProperty>> includeExpression, bool condition) where T : class
{
if (condition)
{
var info = new IncludeExpressionInfo(includeExpression, typeof(T), typeof(TProperty));
builder.IncludeExpressions.Add(info);
}
var includeBuilder = new IncludableSpecificationBuilder<T, TProperty>(builder, !condition);
return includeBuilder;
}
/// <summary>
/// Specifies the number of elements to return.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="take">The number of elements to take.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Take<T>(this SpecificationBuilder<T> builder, int take)
{
return Take(builder, take, true);
}
/// <summary>
/// Specifies the number of elements to return when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="take">The number of elements to take.</param>
/// <param name="condition">Whether to apply the take.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Take<T>(this SpecificationBuilder<T> builder, int take, bool condition)
{
if (condition)
{
if (builder.Take is not null)
{
throw new DuplicateTakeException();
}
builder.Take = take;
}
return builder;
}
/// <summary>
/// Specifies the number of elements to skip before returning.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="skip">The number of elements to skip.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Skip<T>(this SpecificationBuilder<T> builder, int skip)
{
return Skip(builder, skip, condition: true);
}
/// <summary>
/// Specifies the number of elements to skip when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="skip">The number of elements to skip.</param>
/// <param name="condition">Whether to apply the skip.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Skip<T>(this SpecificationBuilder<T> builder, int skip, bool condition)
{
if (condition)
{
if (builder.Skip is not null)
{
throw new DuplicateSkipException();
}
builder.Skip = skip;
}
return builder;
}
/// <summary>
/// Specifies a selector to project the result.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="selector">The selector expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T, TResult> Select<T, TResult>(this SpecificationBuilder<T, TResult> builder, Expression<Func<T, TResult>> selector)
{
builder.Selector = selector;
return builder;
}
/// <summary>
/// Specifies a selector many to flatten the result.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="selector">The selector many expression.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T, TResult> SelectMany<T, TResult>(this SpecificationBuilder<T, TResult> builder, Expression<Func<T, IEnumerable<TResult>>> selector)
{
builder.SelectorMany = selector;
return builder;
}
/// <summary>
/// Specifies a post-processing action to apply to the result.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="predicate">The post-processing action.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> PostProcessing<T>(this SpecificationBuilder<T> builder, Func<IEnumerable<T>, IEnumerable<T>> predicate)
{
builder.PostProcessingAction = predicate;
return builder;
}
/// <summary>
/// Specifies a post-processing action to apply to the projected result.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="predicate">The post-processing action.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T, TResult> PostProcessing<T, TResult>(this SpecificationBuilder<T, TResult> builder, Func<IEnumerable<TResult>, IEnumerable<TResult>> predicate)
{
builder.PostProcessingAction = predicate;
return builder;
}
/// <summary>
/// Adds a search criteria (SQL LIKE) for the given selector.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="selector">The selector expression.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="searchGroup">The search group.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Search<T>(this SpecificationBuilder<T> builder, Expression<Func<T, string>> selector, string searchTerm, int searchGroup = 1) where T : class
{
return Search(builder, selector, searchTerm, condition: true, searchGroup);
}
/// <summary>
/// Adds a search criteria when <paramref name="condition"/> is <see langword="true"/>.
/// </summary>
/// <param name="builder">The specification builder.</param>
/// <param name="selector">The selector expression.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="condition">Whether to apply the search.</param>
/// <param name="searchGroup">The search group.</param>
/// <typeparam name="T">The entity type.</typeparam>
/// <returns>The builder.</returns>
public static SpecificationBuilder<T> Search<T>(this SpecificationBuilder<T> builder, Expression<Func<T, string>> selector, string searchTerm, bool condition, int searchGroup = 1) where T : class
{
if (condition)
{
builder.SearchCriteria.Add(new SearchExpressionInfo<T>(selector, searchTerm, searchGroup));
}
return builder;
}
}

View file

@ -1,29 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Thrown when <c>Skip</c> is used more than once in the same specification.
/// </summary>
public sealed class DuplicateSkipException : Exception
{
private const string ExceptionMessage = "Duplicate use of Skip(). Ensure you don't use Skip() more than once in the same specification!";
/// <summary>
/// Creates a new <see cref="DuplicateSkipException"/>.
/// </summary>
public DuplicateSkipException()
: base(ExceptionMessage)
{
}
/// <summary>
/// Creates a new <see cref="DuplicateSkipException"/> with an inner exception.
/// </summary>
/// <param name="innerException">The inner exception.</param>
public DuplicateSkipException(Exception innerException)
: base(ExceptionMessage, innerException)
{
}
}

View file

@ -1,29 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Persistence;
/// <summary>
/// Thrown when <c>Take</c> is used more than once in the same specification.
/// </summary>
public sealed class DuplicateTakeException : Exception
{
private const string ExceptionMessage = "Duplicate use of Take(). Ensure you don't use Take() more than once in the same specification!";
/// <summary>
/// Creates a new <see cref="DuplicateTakeException"/>.
/// </summary>
public DuplicateTakeException()
: base(ExceptionMessage)
{
}
/// <summary>
/// Creates a new <see cref="DuplicateTakeException"/> with an inner exception.
/// </summary>
/// <param name="innerException">The inner exception.</param>
public DuplicateTakeException(Exception innerException)
: base(ExceptionMessage, innerException)
{
}
}

View file

@ -1,74 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Stores information about an include expression.
/// </summary>
public sealed class IncludeExpressionInfo
{
/// <summary>
/// Gets the lambda expression.
/// </summary>
public LambdaExpression LambdaExpression { get; }
/// <summary>
/// Gets the entity type.
/// </summary>
public Type EntityType { get; }
/// <summary>
/// Gets the property type.
/// </summary>
public Type PropertyType { get; }
/// <summary>
/// Gets the previous property type, or <see langword="null"/> for an Include.
/// </summary>
public Type? PreviousPropertyType { get; }
/// <summary>
/// Gets the include type.
/// </summary>
public IncludeType Type { get; }
private IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType, Type? previousPropertyType, IncludeType includeType)
{
ArgumentNullException.ThrowIfNull(expression);
ArgumentNullException.ThrowIfNull(entityType);
ArgumentNullException.ThrowIfNull(propertyType);
LambdaExpression = expression;
EntityType = entityType;
PropertyType = propertyType;
PreviousPropertyType = previousPropertyType;
Type = includeType;
}
/// <summary>
/// Creates an Include expression info.
/// </summary>
/// <param name="expression">The lambda expression.</param>
/// <param name="entityType">The entity type.</param>
/// <param name="propertyType">The property type.</param>
public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType)
: this(expression, entityType, propertyType, null, IncludeType.Include)
{
}
/// <summary>
/// Creates a ThenInclude expression info.
/// </summary>
/// <param name="expression">The lambda expression.</param>
/// <param name="entityType">The entity type.</param>
/// <param name="propertyType">The property type.</param>
/// <param name="previousPropertyType">The previous property type.</param>
public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType, Type previousPropertyType)
: this(expression, entityType, propertyType, previousPropertyType, IncludeType.ThenInclude)
{
ArgumentNullException.ThrowIfNull(previousPropertyType);
}
}

View file

@ -1,45 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Stores information about an order expression.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public sealed class OrderExpressionInfo<T>
{
private readonly Lazy<Func<T, object?>> _keySelectorFunc;
/// <summary>
/// Creates a new order expression info.
/// </summary>
/// <param name="keySelector">The key selector expression.</param>
/// <param name="orderType">The order type.</param>
public OrderExpressionInfo(Expression<Func<T, object?>> keySelector, OrderType orderType)
{
ArgumentNullException.ThrowIfNull(keySelector);
KeySelector = keySelector;
OrderType = orderType;
_keySelectorFunc = new Lazy<Func<T, object?>>(KeySelector.Compile);
}
/// <summary>
/// Gets the key selector expression.
/// </summary>
public Expression<Func<T, object?>> KeySelector { get; }
/// <summary>
/// Gets the order type.
/// </summary>
public OrderType OrderType { get; }
/// <summary>
/// Gets the compiled key selector function.
/// </summary>
public Func<T, object?> KeySelectorFunc => _keySelectorFunc.Value;
}

View file

@ -1,53 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Stores information about a search expression.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public sealed class SearchExpressionInfo<T>
{
private readonly Lazy<Func<T, string>> _selectorFunc;
/// <summary>
/// Creates a new search expression info.
/// </summary>
/// <param name="selector">The selector expression.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="searchGroup">The search group.</param>
public SearchExpressionInfo(Expression<Func<T, string>> selector, string searchTerm, int searchGroup = 1)
{
ArgumentNullException.ThrowIfNull(selector);
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
Selector = selector;
SearchTerm = searchTerm;
SearchGroup = searchGroup;
_selectorFunc = new Lazy<Func<T, string>>(Selector.Compile);
}
/// <summary>
/// Gets the selector expression.
/// </summary>
public Expression<Func<T, string>> Selector { get; }
/// <summary>
/// Gets the search term.
/// </summary>
public string SearchTerm { get; }
/// <summary>
/// Gets the search group.
/// </summary>
public int SearchGroup { get; }
/// <summary>
/// Gets the compiled selector function.
/// </summary>
public Func<T, string> SelectorFunc => _selectorFunc.Value;
}

View file

@ -1,38 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Persistence;
/// <summary>
/// Stores information about a where expression.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public sealed class WhereExpressionInfo<T>
{
private readonly Lazy<Func<T, bool>> _filterFunc;
/// <summary>
/// Creates a new where expression info.
/// </summary>
/// <param name="filter">The filter expression.</param>
public WhereExpressionInfo(Expression<Func<T, bool>> filter)
{
ArgumentNullException.ThrowIfNull(filter);
Filter = filter;
_filterFunc = new Lazy<Func<T, bool>>(Filter.Compile);
}
/// <summary>
/// Gets the filter expression.
/// </summary>
public Expression<Func<T, bool>> Filter { get; }
/// <summary>
/// Gets the compiled filter function.
/// </summary>
public Func<T, bool> FilterFunc => _filterFunc.Value;
}

View file

@ -166,11 +166,48 @@ internal sealed class ResultEqualityTests
}
[Test]
public async Task I_can_get_hashcode_and_get_zero_for_failure()
public async Task I_can_get_hashcode_and_get_non_zero_for_failure()
{
var result = Prelude.Failure<int>("error");
await Assert.That(result.GetHashCode()).IsZero();
await Assert.That(result.GetHashCode()).IsNotEqualTo(0);
}
[Test]
public async Task I_can_get_hashcode_and_not_collide_non_generic_success_and_failure()
{
await Assert.That(Prelude.Success().GetHashCode())
.IsNotEqualTo(Prelude.Failure("error").GetHashCode());
}
[Test]
public async Task I_can_get_hashcode_and_not_collide_success_value_zero_with_failure()
{
var success = Prelude.Success(0);
var failure = Prelude.Failure<int>("error");
await Assert.That(success.Equals(failure)).IsFalse();
await Assert.That(success.GetHashCode()).IsNotEqualTo(failure.GetHashCode());
}
[Test]
public async Task I_can_equal_failure_and_failure_ignoring_error_content()
{
var a = Prelude.Failure<int>("error 1");
var b = Prelude.Failure<int>("error 2");
await Assert.That(a.Equals(b)).IsTrue();
await Assert.That(a.Error).IsNotEqualTo(b.Error);
}
[Test]
public async Task I_can_equal_non_generic_failure_and_failure_ignoring_error_content()
{
var a = Prelude.Failure("error 1");
var b = Prelude.Failure("error 2");
await Assert.That(a.Equals(b)).IsTrue();
await Assert.That(a.Error).IsNotEqualTo(b.Error);
}
[Test]
@ -199,4 +236,53 @@ internal sealed class ResultEqualityTests
await Assert.That(a.Equals(b)).IsFalse();
}
[Test]
public async Task I_can_use_equality_operator_and_get_false_for_success_and_failure()
{
var success = Prelude.Success(2);
var failure = Prelude.Failure<int>("error");
await Assert.That(success == failure).IsFalse();
await Assert.That(success != failure).IsTrue();
}
[Test]
public async Task I_can_use_equality_operator_and_get_true_for_successes_with_equal_value()
{
await Assert.That(Prelude.Success(2) == Prelude.Success(2)).IsTrue();
await Assert.That(Prelude.Success(2) != Prelude.Success(3)).IsTrue();
}
[Test]
public async Task I_can_use_equality_operator_and_compare_result_to_value()
{
await Assert.That(Prelude.Success(2) == 2).IsTrue();
await Assert.That(Prelude.Success(2) != 3).IsTrue();
await Assert.That(Prelude.Failure<int>("x") == 2).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_false_for_null()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals(null)).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_false_for_wrong_type()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals("not a result")).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_true_for_boxed_equal_result()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals(Prelude.Success(2))).IsTrue();
}
}

View file

@ -9,7 +9,7 @@ namespace Geekeey.Request.Result;
/// A class containing various utility methods, a 'prelude' to the rest of the library.
/// </summary>
/// <remarks>
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Result.Prelude;</c>.
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Request.Result.Prelude;</c>.
/// Recommended to be imported globally via a global using statement.
/// </remarks>
public static class Prelude

View file

@ -9,7 +9,15 @@ namespace Geekeey.Request.Result;
public partial class Result : IEquatable<Result>
{
/// <inheritdoc/>
/// <summary>
/// Checks whether two results are equal. Results are equal if both are success or both are failure.
/// </summary>
/// <remarks>
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
/// directly instead of relying on equality.
/// </remarks>
/// <param name="other">The result to check for equality with the current result.</param>
[Pure]
public bool Equals(Result? other)
{
@ -49,7 +57,8 @@ public partial class Result : IEquatable<Result>
public partial class Result : IEqualityOperators<Result, Result, bool>
{
/// <summary>
/// Checks whether two results are equal. Results are equal if they are both success or both failure.
/// Checks whether two results are equal. Results are equal if they are both success or both failure. Two
/// failures are equal regardless of their error content.
/// </summary>
/// <param name="a">The first result to compare.</param>
/// <param name="b">The second result to compare.</param>
@ -79,6 +88,11 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
/// Checks whether the result is equal to another result. Results are equal if both results are success values and
/// the success values are equal, or if both results are failures.
/// </summary>
/// <remarks>
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
/// directly instead of relying on equality.
/// </remarks>
/// <param name="other">The result to check for equality with the current result.</param>
[Pure]
public bool Equals(Result<T>? other)
@ -181,15 +195,20 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
return comparer.GetHashCode(result.Value);
}
if (result is { IsSuccess: true, Value: null })
{
return 0;
}
return result.Error?.GetHashCode() ?? 0;
}
}
public partial class Result<T> : IEqualityOperators<Result<T>, Result<T>, bool>, IEqualityOperators<Result<T>, T, bool>
{
/// <summary>
/// Checks whether two results are equal. Results are equal if both results are success values and the success
/// values are equal, or if both results are failures.
/// values are equal, or if both results are failures. Two failures are equal regardless of their error content.
/// </summary>
/// <param name="a">The first result to compare.</param>
/// <param name="b">The second result to compare.</param>

View file

@ -314,6 +314,71 @@ internal sealed class RuleBuilderExtensionsTests
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_can_validate_greater_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between_with_null_bounds_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.Between(null, null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_see_it_throw_for_invalid_between_configuration()
{

View file

@ -0,0 +1,49 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ValidationSeverityTests
{
[Test]
public async Task I_can_have_warning_problems_not_invalidate_by_default()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
.WithSeverity(Severity.Warning));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
}
[Test]
public async Task I_can_have_info_problems_only_invalidate_at_info_threshold()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
.WithSeverity(Severity.Info));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsTrue();
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
}
[Test]
public async Task I_can_have_error_problems_invalidate_by_default()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required."));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.IsValid).IsFalse();
await Assert.That(result.IsValidFor(Severity.Error)).IsFalse();
}
}

View file

@ -14,7 +14,8 @@ internal sealed class ValidatorTests
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.IsValid).IsFalse();
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(result.Problems).Count().IsEqualTo(1);
var problem = result.Problems.Single();
@ -39,12 +40,24 @@ internal sealed class ValidatorTests
using (Assert.Multiple())
{
await Assert.That(invalid.IsValid).IsFalse();
await Assert.That(invalid.IsValid).IsTrue();
await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(valid.Problems).IsEmpty();
}
}
[Test]
public async Task I_can_validate_with_a_null_instance_without_throwing()
{
var validator = new PropertyValidator<Person?, string?>(person
=> person!.Name, rule => rule.Must(value => value is not null, "Name is required."));
var result = validator.Validate((Person?)null);
await Assert.That(result.IsValid).IsTrue();
}
[Test]
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
{

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ComparableModel
{
public ComparableValue? Value { get; init; }
}
internal sealed class ComparableValue : IComparable<ComparableValue?>
{
public int Number { get; init; }
public int CompareTo(ComparableValue? other)
{
return Number.CompareTo(other!.Number);
}
}

View file

@ -46,7 +46,7 @@ internal abstract record Rule<T, TProperty> : Rule
if (context.Instance is null && default(T) is null)
{
return Validate((T)context.Instance!, context);
return [];
}
var actualType = context.Instance?.GetType().FullName ?? "null";

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