Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2206fe027a |
102 changed files with 3914 additions and 614 deletions
|
|
@ -1,7 +1,6 @@
|
|||
name: default
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
paths-ignore:
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -45,19 +45,6 @@ To have a consistent experience across all packages, some public interfaces have
|
|||
|
||||
### Changed
|
||||
|
||||
- **request.result:** Document that `Result`/`Result<T>` equality treats any two failures as equal, ignoring error content; branch on the `Error` directly for failure-specific logic
|
||||
|
||||
### Fixed
|
||||
|
||||
- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`)
|
||||
- **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0`
|
||||
- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`)
|
||||
- **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`)
|
||||
- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold
|
||||
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered
|
||||
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order
|
||||
- **request.dispatcher:** Add `AddBehavior<T>(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order
|
||||
|
||||
### Removed
|
||||
|
||||
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
|
||||
|
|
|
|||
|
|
@ -12,5 +12,8 @@
|
|||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
|
||||
<PackageVersion Include="TUnit" Version="1.11.51" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@
|
|||
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
|
||||
<Project Path="src/request.result/Geekeey.Request.Result.csproj" />
|
||||
<Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" />
|
||||
<Project Path="src/request.persistence/Geekeey.Request.Persistence.csproj" />
|
||||
<Project Path="src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj" />
|
||||
<Project Path="src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
|
||||
<Project Path="src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
|
|
@ -87,26 +87,6 @@ internal sealed class ScalarBehaviourTests
|
|||
await Assert.That(tracker.Executed).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<ScalarTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(ScalarTestHandler))
|
||||
.AddBehavior<OrderedScalarBehaviorA>(2)
|
||||
.AddBehavior<OrderedScalarBehaviorB>(1));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<ScalarTestTracker>();
|
||||
|
||||
var request = new ScalarTestRequest { Value = "Hello" };
|
||||
await dispatcher.DispatchAsync(request);
|
||||
|
||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
|
|||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers()
|
||||
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
|
|
@ -126,18 +126,13 @@ internal sealed class ScalarDispatcherTests
|
|||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
||||
var result = await dispatcher.DispatchAsync(request);
|
||||
|
||||
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
|
||||
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
|
||||
// InterfaceConstrainedScalarHandler is an open generic match.
|
||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler));
|
||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler");
|
||||
}
|
||||
// Currently Dispatcher.SendAsync checks concrete handlers first.
|
||||
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -239,49 +234,6 @@ internal sealed class ScalarDispatcherTests
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(DuplicateScalarHandlerA))
|
||||
.Add(typeof(DuplicateScalarHandlerB)));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new DuplicateScalarRequest();
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerA));
|
||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerB));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_candidates_ordered_by_registration_not_closed_first()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(InterfaceConstrainedScalarHandler<>))
|
||||
.Add(typeof(InterfaceInheritedScalarHandler)));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
||||
|
||||
// The open generic handler is registered before the closed handler, so candidates must be
|
||||
// listed in registration order (open first), not the old fixed closed-before-open order.
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
||||
|
||||
var message = ex?.Message ?? string.Empty;
|
||||
var openIndex = message.IndexOf("InterfaceConstrainedScalarHandler", StringComparison.Ordinal);
|
||||
var closedIndex = message.IndexOf("InterfaceInheritedScalarHandler", StringComparison.Ordinal);
|
||||
|
||||
await Assert.That(openIndex >= 0 && closedIndex >= 0 && openIndex < closedIndex).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -66,26 +66,6 @@ internal sealed class StreamBehaviourTests
|
|||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<StreamTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(StreamTestHandler))
|
||||
.AddBehavior<OrderedStreamBehaviorA>(2)
|
||||
.AddBehavior<OrderedStreamBehaviorB>(1));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<StreamTestTracker>();
|
||||
|
||||
var request = new StreamTestRequest { Value = "Hello" };
|
||||
await dispatcher.DispatchAsync(request).ToListAsync();
|
||||
|
||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,26 +7,6 @@ namespace Geekeey.Request.Dispatcher.Tests;
|
|||
|
||||
public class StreamDispatcherTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_see_it_throw_on_ambiguous_stream_handlers()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(DuplicateStreamHandlerA))
|
||||
.Add(typeof(DuplicateStreamHandlerB)));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new DuplicateStreamRequest();
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerA));
|
||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerB));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
|
||||
{
|
||||
|
|
@ -141,18 +121,13 @@ public class StreamDispatcherTests
|
|||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
|
||||
var results = await dispatcher.DispatchAsync(request).ToListAsync();
|
||||
|
||||
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
|
||||
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
|
||||
// InterfaceConstrainedStreamHandler is an open generic match.
|
||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
|
||||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler));
|
||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler");
|
||||
}
|
||||
// Currently Dispatcher checks concrete handlers first.
|
||||
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public sealed class DuplicateScalarRequest : IScalarRequest<string>
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class DuplicateScalarHandlerA : IScalarRequestHandler<DuplicateScalarRequest, string>
|
||||
{
|
||||
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult("A");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DuplicateScalarHandlerB : IScalarRequestHandler<DuplicateScalarRequest, string>
|
||||
{
|
||||
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult("B");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public sealed class DuplicateStreamRequest : IStreamRequest<string>
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class DuplicateStreamHandlerA : IStreamRequestHandler<DuplicateStreamRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return "A";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DuplicateStreamHandlerB : IStreamRequestHandler<DuplicateStreamRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return "B";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
||||
{
|
||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedA");
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
||||
{
|
||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedB");
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedA");
|
||||
await foreach (var item in next(request, cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedB");
|
||||
await foreach (var item in next(request, cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,27 +70,6 @@ public static class RequestDispatcherBuilderExtensions
|
|||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
|
||||
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
|
||||
/// first (outermost); behaviors with equal order run in registration order.
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
|
||||
where TBehavior : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
|
||||
|
||||
builder.Services.AddOptions<RequestDispatcherOptions>()
|
||||
.Configure(options => options.Inspect([typeof(TBehavior)], order));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified type to the request dispatcher configuration for inspection.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -10,27 +10,24 @@ namespace Geekeey.Request.Dispatcher;
|
|||
|
||||
internal sealed class RequestDispatcherOptions
|
||||
{
|
||||
private readonly List<(Type Type, int? Order)> _search = [];
|
||||
private readonly List<Type> _search = [];
|
||||
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
|
||||
private readonly Lazy<TypeIndex> _handlersTypeIndex;
|
||||
|
||||
public RequestDispatcherOptions()
|
||||
{
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
|
||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
|
||||
}
|
||||
|
||||
public void Inspect(IEnumerable<Type> assembly, int? order = null)
|
||||
public void Inspect(IEnumerable<Type> assembly)
|
||||
{
|
||||
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
|
||||
{
|
||||
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
|
||||
}
|
||||
|
||||
foreach (var type in assembly)
|
||||
{
|
||||
_search.Add((type, order));
|
||||
}
|
||||
_search.AddRange(assembly);
|
||||
}
|
||||
|
||||
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
|
||||
|
|
@ -49,22 +46,11 @@ internal sealed class RequestDispatcherOptions
|
|||
|
||||
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
|
||||
protected readonly List<Type> _openTypeInfo = [];
|
||||
protected readonly Dictionary<Type, int> _order = [];
|
||||
protected readonly Dictionary<Type, int> _explicitOrder = [];
|
||||
|
||||
protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate)
|
||||
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
|
||||
{
|
||||
var index = 0;
|
||||
|
||||
foreach (var (type, order) in collection)
|
||||
foreach (var type in collection)
|
||||
{
|
||||
_order[type] = index++;
|
||||
|
||||
if (order is { } explicitOrder)
|
||||
{
|
||||
_explicitOrder[type] = explicitOrder;
|
||||
}
|
||||
|
||||
if (type.IsGenericTypeDefinition)
|
||||
{
|
||||
if (type.GetInterfaces().Any(predicate))
|
||||
|
|
@ -82,11 +68,6 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
protected int EffectiveOrder(Type type)
|
||||
{
|
||||
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
|
||||
}
|
||||
|
||||
public IEnumerable<T> Resolve<T>(IServiceProvider services)
|
||||
{
|
||||
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
|
||||
|
|
@ -116,19 +97,16 @@ internal sealed class RequestDispatcherOptions
|
|||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
||||
}
|
||||
|
||||
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
|
||||
: TypeIndex(collection, IsRequestHandlerType)
|
||||
{
|
||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||
{
|
||||
var result = new List<(int Order, Type Type)>();
|
||||
var result = new List<Type>();
|
||||
|
||||
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
||||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
result.AddRange(list);
|
||||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
|
|
@ -143,7 +121,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(type), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -159,7 +137,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(type), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +146,7 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,19 +157,16 @@ internal sealed class RequestDispatcherOptions
|
|||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
|
||||
}
|
||||
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
|
||||
: TypeIndex(collection, IsRequestBehaviorType)
|
||||
{
|
||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||
{
|
||||
var result = new List<(int Order, Type Type)>();
|
||||
var result = new List<Type>();
|
||||
|
||||
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
||||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
result.AddRange(list);
|
||||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
|
|
@ -205,7 +180,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType, responseType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -218,7 +193,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
result.Add(impl);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -226,7 +201,7 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,20 +43,7 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
|
|||
|
||||
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
|
||||
{
|
||||
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
||||
|
||||
if (handlers.Length is 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
|
||||
}
|
||||
|
||||
if (handlers.Length > 1)
|
||||
{
|
||||
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
|
||||
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
|
||||
}
|
||||
|
||||
return handlers[0].HandleAsync((TRequest)r, ct);
|
||||
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,20 +47,7 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
|
|||
|
||||
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
|
||||
{
|
||||
var handlers = options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
||||
|
||||
if (handlers.Length is 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
|
||||
}
|
||||
|
||||
if (handlers.Length > 1)
|
||||
{
|
||||
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
|
||||
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
|
||||
}
|
||||
|
||||
return handlers[0].HandleAsync((TRequest)r, ct);
|
||||
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
[*.{cs,vb}]
|
||||
dotnet_diagnostic.CA1822.severity = none
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.IDE0060.severity = none
|
||||
dotnet_diagnostic.IDE0005.severity = none
|
||||
dotnet_diagnostic.IDE0390.severity = none
|
||||
dotnet_diagnostic.IDE0391.severity = none
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
|
||||
|
||||
internal sealed class EvaluationTests
|
||||
{
|
||||
private static async Task<FakeDbContext> CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FakeDbContext>()
|
||||
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
|
||||
.EnableServiceProviderCaching(false)
|
||||
.Options;
|
||||
|
||||
var context = new FakeDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static async Task<Repository<FakeEntity>> CreateRepo()
|
||||
{
|
||||
var context = await CreateDbContext();
|
||||
return new Repository<FakeEntity>(context);
|
||||
}
|
||||
|
||||
private static async Task SeedData(Repository<FakeEntity> repo, params FakeEntity[] entities)
|
||||
{
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
await repo.AddAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_filters_entities_by_equality()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Gamma" });
|
||||
|
||||
var result = await repo.ListAsync(new NameEqualSpec("Beta"));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_multiple_filters_combine_as_and()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "AlphaBeta" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new MultipleWhereSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("AlphaBeta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_no_match_returns_empty_list()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var result = await repo.ListAsync(new NameEqualSpec("NonExistent"));
|
||||
|
||||
await Assert.That(result).IsEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_with_FirstOrDefaultAsync_returns_matching()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("Alpha"));
|
||||
|
||||
await Assert.That(result).IsNotNull();
|
||||
await Assert.That(result!.Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_with_FirstOrDefaultAsync_no_match_returns_null()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("NonExistent"));
|
||||
|
||||
await Assert.That(result).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_with_CountAsync_returns_correct_count()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var count = await repo.CountAsync(new NameEqualSpec("Alpha"));
|
||||
|
||||
await Assert.That(count).IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_with_AnyAsync_returns_true_when_match_exists()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var any = await repo.AnyAsync(new NameEqualSpec("Alpha"));
|
||||
|
||||
await Assert.That(any).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_with_AnyAsync_returns_false_when_no_match()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var any = await repo.AnyAsync(new NameEqualSpec("Beta"));
|
||||
|
||||
await Assert.That(any).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Search_filters_by_like_pattern()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Albatross" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new SearchSpec("Al%"));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Search_exact_match_works_without_wildcard()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Albatross" });
|
||||
|
||||
var result = await repo.ListAsync(new SearchSpec("Alpha"));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Search_multiple_terms_in_same_group_combine_as_or()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Gamma" });
|
||||
|
||||
var result = await repo.ListAsync(new MultiSearchSpec("Alpha", "Beta"));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OrderBy_returns_ascending()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Charlie" },
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderByNameSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(3);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alpha");
|
||||
await Assert.That(result[1].Name).IsEqualTo("Beta");
|
||||
await Assert.That(result[2].Name).IsEqualTo("Charlie");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OrderByDescending_returns_descending()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Charlie" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderByNameDescSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(3);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Charlie");
|
||||
await Assert.That(result[1].Name).IsEqualTo("Beta");
|
||||
await Assert.That(result[2].Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThenBy_chains_after_order_by()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||
var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Id = id2, Name = "Alpha" },
|
||||
new FakeEntity { Id = id1, Name = "Alpha" },
|
||||
new FakeEntity { Id = id3, Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderByNameThenByIdSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(3);
|
||||
await Assert.That(result[0].Id).IsEqualTo(id1);
|
||||
await Assert.That(result[1].Id).IsEqualTo(id2);
|
||||
await Assert.That(result[2].Name).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThenByDescending_chains_after_order_by()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||
var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Id = id2, Name = "Alpha" },
|
||||
new FakeEntity { Id = id1, Name = "Alpha" },
|
||||
new FakeEntity { Id = id3, Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderByNameThenByIdDescSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(3);
|
||||
await Assert.That(result[0].Id).IsEqualTo(id2);
|
||||
await Assert.That(result[1].Id).IsEqualTo(id1);
|
||||
await Assert.That(result[2].Name).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Duplicate_order_chain_throws()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
await Assert.That(async () => await repo.ListAsync(new DuplicateOrderSpec()))
|
||||
.Throws<DuplicateOrderChainException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Take_limits_results()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Gamma" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderedTakeSpec(2));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alpha");
|
||||
await Assert.That(result[1].Name).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Skip_skips_elements()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Gamma" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderedSkipSpec(1));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Skip_and_take_paginates_correctly()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Charlie" },
|
||||
new FakeEntity { Name = "Delta" });
|
||||
|
||||
var result = await repo.ListAsync(new OrderedSkipTakeSpec(1, 2));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Beta");
|
||||
await Assert.That(result[1].Name).IsEqualTo("Charlie");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Skip_zero_returns_all()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new SkipOnlySpec(0));
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Pagination_is_ignored_by_CountAsync()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" },
|
||||
new FakeEntity { Name = "Gamma" });
|
||||
|
||||
var count = await repo.CountAsync(new OrderedSkipTakeSpec(1, 1));
|
||||
|
||||
await Assert.That(count).IsEqualTo(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Pagination_is_ignored_by_AnyAsync()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var any = await repo.AnyAsync(new OrderedSkipSpec(10));
|
||||
|
||||
await Assert.That(any).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Select_projects_entity_to_new_type()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new NameProjectionSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
await Assert.That(result[0]).IsEqualTo("Alpha");
|
||||
await Assert.That(result[1]).IsEqualTo("Beta");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Select_with_FirstOrDefaultAsync_returns_projected_result()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var result = await repo.FirstOrDefaultAsync(new NameProjectionSpec());
|
||||
|
||||
await Assert.That(result).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task No_selector_on_projected_spec_throws()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
await Assert.That(async () => await repo.ListAsync(new NoSelectorSpec()))
|
||||
.Throws<SelectorNotFoundException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Concurrent_selectors_throws()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
await Assert.That(async () => await repo.ListAsync(new ConcurrentSelectorSpec()))
|
||||
.Throws<ConcurrentSelectorsException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_orderby_and_take_combine_correctly()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Charlie" },
|
||||
new FakeEntity { Name = "Alice" },
|
||||
new FakeEntity { Name = "Bob" },
|
||||
new FakeEntity { Name = "David" });
|
||||
|
||||
var result = await repo.ListAsync(new CombinedWhereOrderTakeSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alice");
|
||||
await Assert.That(result[1].Name).IsEqualTo("Bob");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PostProcessing_applies_after_query()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "AlphaBeta" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new PostProcessingSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Where_only_returns_filtered()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.ListAsync(new WhereSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FirstOrDefaultAsync_returns_first_matching()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var result = await repo.FirstOrDefaultAsync(new FirstOrderedSpec());
|
||||
|
||||
await Assert.That(result).IsNotNull();
|
||||
await Assert.That(result!.Name).IsEqualTo("Alpha");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CountAsync_without_spec_returns_all()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var count = await repo.CountAsync();
|
||||
|
||||
await Assert.That(count).IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnyAsync_without_spec_returns_true_when_entities_exist()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo, new FakeEntity { Name = "Alpha" });
|
||||
|
||||
var any = await repo.AnyAsync();
|
||||
|
||||
await Assert.That(any).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnyAsync_without_spec_returns_false_when_empty()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
|
||||
var any = await repo.AnyAsync();
|
||||
|
||||
await Assert.That(any).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AsAsyncEnumerable_streams_entities()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await SeedData(repo,
|
||||
new FakeEntity { Name = "Alpha" },
|
||||
new FakeEntity { Name = "Beta" });
|
||||
|
||||
var count = 0;
|
||||
|
||||
await foreach (var _ in repo.AsAsyncEnumerable(new OrderByNameSpec()))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
await Assert.That(count).IsEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="TUnit" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\request.persistence.entityframeworkcore\Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
|
||||
|
||||
internal sealed class IncludeEvaluatorTests
|
||||
{
|
||||
private static async Task<FakeDbContext> CreateDbContext()
|
||||
{
|
||||
var context = new FakeDbContext(new DbContextOptionsBuilder<FakeDbContext>()
|
||||
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
|
||||
.EnableServiceProviderCaching(false)
|
||||
.Options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
return context;
|
||||
}
|
||||
|
||||
// --- Default evaluator (non-cached, reflection-based) ---
|
||||
|
||||
[Test]
|
||||
public async Task Default_include_reference_nav()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "child",
|
||||
},
|
||||
};
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
var result = await repo.ListAsync(new IncludeChildSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Test");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Default_include_collection_nav()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
};
|
||||
entity.Details.Add(new FakeDetailEntity
|
||||
{
|
||||
Value = "detail",
|
||||
});
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
var result = await repo.ListAsync(new IncludeDetailsSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Test");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Default_then_include_after_reference()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "child",
|
||||
GrandChildren =
|
||||
{
|
||||
new FakeGrandChildEntity
|
||||
{
|
||||
Value = "grand",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
var result = await repo.ListAsync(new IncludeChildThenGrandChildrenSpec());
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Default_empty_includes()
|
||||
{
|
||||
var repo = await CreateRepo();
|
||||
await repo.AddAsync(new FakeEntity
|
||||
{
|
||||
Name = "Alpha",
|
||||
});
|
||||
await repo.AddAsync(new FakeEntity
|
||||
{
|
||||
Name = "Beta",
|
||||
});
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
var result = await repo.ListAsync(spec);
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Default_then_include_after_collection()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var repo = new Repository<FakeEntity>(dbContext);
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
};
|
||||
entity.Details.Add(new FakeDetailEntity
|
||||
{
|
||||
Value = "detail",
|
||||
DetailChildren =
|
||||
{
|
||||
new FakeDetailChildEntity
|
||||
{
|
||||
Value = "childDetail",
|
||||
},
|
||||
},
|
||||
});
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
|
||||
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
|
||||
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
var result = await repo.ListAsync(spec);
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
}
|
||||
|
||||
// --- Cached evaluator (delegate-based) ---
|
||||
|
||||
[Test]
|
||||
public async Task Cached_include_reference_nav()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "child",
|
||||
},
|
||||
};
|
||||
dbContext.FakeEntities.Add(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Child);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
await Assert.That(result[0].Name).IsEqualTo("Test");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cached_include_collection_nav()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
};
|
||||
entity.Details.Add(new FakeDetailEntity
|
||||
{
|
||||
Value = "detail",
|
||||
});
|
||||
dbContext.FakeEntities.Add(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Details);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cached_then_include_after_reference()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "child",
|
||||
GrandChildren =
|
||||
{
|
||||
new FakeGrandChildEntity
|
||||
{
|
||||
Value = "grand",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
dbContext.FakeEntities.Add(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cached_then_include_after_collection()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
var entity = new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
};
|
||||
entity.Details.Add(new FakeDetailEntity
|
||||
{
|
||||
Value = "detail",
|
||||
DetailChildren =
|
||||
{
|
||||
new FakeDetailChildEntity
|
||||
{
|
||||
Value = "childDetail",
|
||||
},
|
||||
},
|
||||
});
|
||||
dbContext.FakeEntities.Add(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
|
||||
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
|
||||
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cached_empty_includes()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
dbContext.FakeEntities.AddRange(
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "Alpha",
|
||||
},
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "Beta",
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
// --- Comparison ---
|
||||
|
||||
[Test]
|
||||
public async Task Default_and_cached_produce_same_results()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
dbContext.FakeEntities.Add(new FakeEntity
|
||||
{
|
||||
Name = "Test",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "child",
|
||||
},
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Child);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
var resultDefault = IncludeEvaluator.Default.Evaluate(query, spec).ToList();
|
||||
var resultCached = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(resultDefault).Count().IsEqualTo(1);
|
||||
await Assert.That(resultCached).Count().IsEqualTo(1);
|
||||
await Assert.That(resultDefault[0].Name).IsEqualTo(resultCached[0].Name);
|
||||
}
|
||||
|
||||
// --- Cache reuse ---
|
||||
|
||||
[Test]
|
||||
public async Task Cached_include_reuses_delegate()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
dbContext.FakeEntities.AddRange(
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "A",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "a",
|
||||
},
|
||||
},
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "B",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "b",
|
||||
},
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Child);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
_ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cached_then_include_reuses_delegate()
|
||||
{
|
||||
var dbContext = await CreateDbContext();
|
||||
var query = dbContext.FakeEntities.AsQueryable();
|
||||
dbContext.FakeEntities.AddRange(
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "A",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "a",
|
||||
GrandChildren =
|
||||
{
|
||||
new FakeGrandChildEntity
|
||||
{
|
||||
Value = "g1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new FakeEntity
|
||||
{
|
||||
Name = "B",
|
||||
Child = new FakeChildEntity
|
||||
{
|
||||
Value = "b",
|
||||
GrandChildren =
|
||||
{
|
||||
new FakeGrandChildEntity
|
||||
{
|
||||
Value = "g2",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var builder = new SpecificationBuilder<FakeEntity>();
|
||||
builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
|
||||
var spec = new IncludeOnlySpec(builder);
|
||||
|
||||
_ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
|
||||
|
||||
await Assert.That(result).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
private static async Task<Repository<FakeEntity>> CreateRepo()
|
||||
{
|
||||
var context = await CreateDbContext();
|
||||
return new Repository<FakeEntity>(context);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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(' '));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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!;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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!;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
|
||||
|
||||
internal sealed class NoSelectorSpec : Specification<FakeEntity, string>
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<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>
|
||||
174
src/request.persistence.entityframeworkcore/Repository.cs
Normal file
174
src/request.persistence.entityframeworkcore/Repository.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
8
src/request.persistence.tests/.editorconfig
Normal file
8
src/request.persistence.tests/.editorconfig
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
[*.{cs,vb}]
|
||||
dotnet_diagnostic.CA1822.severity = none
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.IDE0060.severity = none
|
||||
dotnet_diagnostic.IDE0005.severity = none
|
||||
dotnet_diagnostic.IDE0390.severity = none
|
||||
dotnet_diagnostic.IDE0391.severity = none
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<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>
|
||||
216
src/request.persistence.tests/SpecificationBuilderTests.cs
Normal file
216
src/request.persistence.tests/SpecificationBuilderTests.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
13
src/request.persistence.tests/_fixtures/FakeChildEntity.cs
Normal file
13
src/request.persistence.tests/_fixtures/FakeChildEntity.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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!;
|
||||
}
|
||||
13
src/request.persistence.tests/_fixtures/FakeDetailEntity.cs
Normal file
13
src/request.persistence.tests/_fixtures/FakeDetailEntity.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
12
src/request.persistence.tests/_fixtures/FakeEntity.cs
Normal file
12
src/request.persistence.tests/_fixtures/FakeEntity.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// 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; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 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!;
|
||||
}
|
||||
12
src/request.persistence.tests/_fixtures/FakeSpecification.cs
Normal file
12
src/request.persistence.tests/_fixtures/FakeSpecification.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Persistence.Tests;
|
||||
|
||||
internal sealed class SpecificationTestBuilder : Specification<FakeEntity>
|
||||
{
|
||||
}
|
||||
31
src/request.persistence/Geekeey.Request.Persistence.csproj
Normal file
31
src/request.persistence/Geekeey.Request.Persistence.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<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>
|
||||
155
src/request.persistence/IRepository.cs
Normal file
155
src/request.persistence/IRepository.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// 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);
|
||||
}
|
||||
71
src/request.persistence/ISpecification.cs
Normal file
71
src/request.persistence/ISpecification.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// 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; }
|
||||
}
|
||||
20
src/request.persistence/IncludeType.cs
Normal file
20
src/request.persistence/IncludeType.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// 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,
|
||||
}
|
||||
30
src/request.persistence/OrderType.cs
Normal file
30
src/request.persistence/OrderType.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// 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
|
||||
}
|
||||
103
src/request.persistence/Specification.cs
Normal file
103
src/request.persistence/Specification.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// 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;
|
||||
}
|
||||
101
src/request.persistence/_builders/IncludableBuilderExtensions.cs
Normal file
101
src/request.persistence/_builders/IncludableBuilderExtensions.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
72
src/request.persistence/_builders/SpecificationBuilder.cs
Normal file
72
src/request.persistence/_builders/SpecificationBuilder.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// 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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
45
src/request.persistence/_expressions/OrderExpressionInfo.cs
Normal file
45
src/request.persistence/_expressions/OrderExpressionInfo.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// 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;
|
||||
}
|
||||
53
src/request.persistence/_expressions/SearchExpressionInfo.cs
Normal file
53
src/request.persistence/_expressions/SearchExpressionInfo.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// 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;
|
||||
}
|
||||
38
src/request.persistence/_expressions/WhereExpressionInfo.cs
Normal file
38
src/request.persistence/_expressions/WhereExpressionInfo.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -166,48 +166,11 @@ internal sealed class ResultEqualityTests
|
|||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_get_hashcode_and_get_non_zero_for_failure()
|
||||
public async Task I_can_get_hashcode_and_get_zero_for_failure()
|
||||
{
|
||||
var result = Prelude.Failure<int>("error");
|
||||
|
||||
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);
|
||||
await Assert.That(result.GetHashCode()).IsZero();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -236,53 +199,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.Request.Result.Prelude;</c>.
|
||||
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Result.Prelude;</c>.
|
||||
/// Recommended to be imported globally via a global using statement.
|
||||
/// </remarks>
|
||||
public static class Prelude
|
||||
|
|
|
|||
|
|
@ -9,15 +9,7 @@ namespace Geekeey.Request.Result;
|
|||
|
||||
public partial class Result : IEquatable<Result>
|
||||
{
|
||||
/// <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>
|
||||
/// <inheritdoc/>
|
||||
[Pure]
|
||||
public bool Equals(Result? other)
|
||||
{
|
||||
|
|
@ -57,8 +49,7 @@ 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. Two
|
||||
/// failures are equal regardless of their error content.
|
||||
/// Checks whether two results are equal. Results are equal if they are both success or both failure.
|
||||
/// </summary>
|
||||
/// <param name="a">The first result to compare.</param>
|
||||
/// <param name="b">The second result to compare.</param>
|
||||
|
|
@ -88,11 +79,6 @@ 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)
|
||||
|
|
@ -195,20 +181,15 @@ 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. Two failures are equal regardless of their error content.
|
||||
/// values are equal, or if both results are failures.
|
||||
/// </summary>
|
||||
/// <param name="a">The first result to compare.</param>
|
||||
/// <param name="b">The second result to compare.</param>
|
||||
|
|
|
|||
|
|
@ -314,71 +314,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,7 @@ internal sealed class ValidatorTests
|
|||
|
||||
var result = validator.Validate(new Person { Name = "" });
|
||||
|
||||
await Assert.That(result.IsValid).IsTrue();
|
||||
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
|
||||
await Assert.That(result.IsValid).IsFalse();
|
||||
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||
|
||||
var problem = result.Problems.Single();
|
||||
|
|
@ -40,24 +39,12 @@ internal sealed class ValidatorTests
|
|||
|
||||
using (Assert.Multiple())
|
||||
{
|
||||
await Assert.That(invalid.IsValid).IsTrue();
|
||||
await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse();
|
||||
await Assert.That(invalid.IsValid).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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ internal abstract record Rule<T, TProperty> : Rule
|
|||
|
||||
if (context.Instance is null && default(T) is null)
|
||||
{
|
||||
return [];
|
||||
return Validate((T)context.Instance!, context);
|
||||
}
|
||||
|
||||
var actualType = context.Instance?.GetType().FullName ?? "null";
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue