diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml index 2796b83..51c054c 100644 --- a/.forgejo/workflows/default.yml +++ b/.forgejo/workflows/default.yml @@ -1,7 +1,6 @@ name: default on: - workflow_dispatch: push: branches: [ "main", "develop" ] paths-ignore: diff --git a/CHANGELOG.md b/CHANGELOG.md index a7052a6..240c251 100644 --- a/CHANGELOG.md +++ b/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` 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.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(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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 235fddd..799bb8a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,5 +12,8 @@ + + + diff --git a/request.slnx b/request.slnx index cebc37c..65e276c 100644 --- a/request.slnx +++ b/request.slnx @@ -5,4 +5,8 @@ + + + + diff --git a/src/request.dispatcher.tests/ScalarBehaviourTests.cs b/src/request.dispatcher.tests/ScalarBehaviourTests.cs index d2a6dd6..9c10e4b 100644 --- a/src/request.dispatcher.tests/ScalarBehaviourTests.cs +++ b/src/request.dispatcher.tests/ScalarBehaviourTests.cs @@ -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(); - sc.AddRequestDispatcher(builder => builder - .Add(typeof(ScalarTestHandler)) - .AddBehavior(2) - .AddBehavior(1)); - var provider = sc.BuildServiceProvider(); - var dispatcher = provider.GetRequiredService(); - var tracker = provider.GetRequiredService(); - - 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() { diff --git a/src/request.dispatcher.tests/ScalarDispatcherTests.cs b/src/request.dispatcher.tests/ScalarDispatcherTests.cs index f274da9..5c10998 100644 --- a/src/request.dispatcher.tests/ScalarDispatcherTests.cs +++ b/src/request.dispatcher.tests/ScalarDispatcherTests.cs @@ -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(); 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(); - - 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(); - - var request = new DuplicateScalarRequest(); - var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); - - 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(); - - 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(); - - 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() { diff --git a/src/request.dispatcher.tests/StreamBehaviourTests.cs b/src/request.dispatcher.tests/StreamBehaviourTests.cs index 7e9377f..c96b530 100644 --- a/src/request.dispatcher.tests/StreamBehaviourTests.cs +++ b/src/request.dispatcher.tests/StreamBehaviourTests.cs @@ -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(); - sc.AddRequestDispatcher(builder => builder - .Add(typeof(StreamTestHandler)) - .AddBehavior(2) - .AddBehavior(1)); - var provider = sc.BuildServiceProvider(); - var dispatcher = provider.GetRequiredService(); - var tracker = provider.GetRequiredService(); - - 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() { diff --git a/src/request.dispatcher.tests/StreamDispatcherTests.cs b/src/request.dispatcher.tests/StreamDispatcherTests.cs index 5fcc7b2..c06cb36 100644 --- a/src/request.dispatcher.tests/StreamDispatcherTests.cs +++ b/src/request.dispatcher.tests/StreamDispatcherTests.cs @@ -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(); - - var request = new DuplicateStreamRequest(); - var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws(); - - 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(); 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(); - - 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] diff --git a/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs deleted file mode 100644 index 6a0504e..0000000 --- a/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs +++ /dev/null @@ -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 -{ -} - -public sealed class DuplicateScalarHandlerA : IScalarRequestHandler -{ - public Task HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken) - { - return Task.FromResult("A"); - } -} - -public sealed class DuplicateScalarHandlerB : IScalarRequestHandler -{ - public Task HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken) - { - return Task.FromResult("B"); - } -} diff --git a/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs deleted file mode 100644 index d3f0414..0000000 --- a/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs +++ /dev/null @@ -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 -{ -} - -public sealed class DuplicateStreamHandlerA : IStreamRequestHandler -{ - public async IAsyncEnumerable HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - yield return "A"; - } -} - -public sealed class DuplicateStreamHandlerB : IStreamRequestHandler -{ - public async IAsyncEnumerable HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - yield return "B"; - } -} diff --git a/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs deleted file mode 100644 index 1eab048..0000000 --- a/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs +++ /dev/null @@ -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 -{ - public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) - { - tracker.Log.Add("OrderedA"); - return await next(request, cancellationToken); - } -} - -public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior -{ - public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) - { - tracker.Log.Add("OrderedB"); - return await next(request, cancellationToken); - } -} diff --git a/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs deleted file mode 100644 index 0439f21..0000000 --- a/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs +++ /dev/null @@ -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 -{ - public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate 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 -{ - public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - tracker.Log.Add("OrderedB"); - await foreach (var item in next(request, cancellationToken)) - { - yield return item; - } - } -} diff --git a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs index c2b6976..8d53e3b 100644 --- a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs +++ b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs @@ -70,27 +70,6 @@ public static class RequestDispatcherBuilderExtensions return builder; } - /// - /// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit - /// pipeline used to determine its position in the pipeline. Lower values run - /// first (outermost); behaviors with equal order run in registration order. - /// - /// The behavior type to add. Must implement a request behavior interface. - /// The to configure. - /// The pipeline order for this behavior. Defaults to 0. - /// The instance for further configuration. - public static IRequestDispatcherBuilder AddBehavior(this IRequestDispatcherBuilder builder, int order = 0) - where TBehavior : class - { - ArgumentNullException.ThrowIfNull(builder); - ValidateNoNestedRequestHandlers([typeof(TBehavior)]); - - builder.Services.AddOptions() - .Configure(options => options.Inspect([typeof(TBehavior)], order)); - - return builder; - } - /// /// Adds the specified type to the request dispatcher configuration for inspection. /// diff --git a/src/request.dispatcher/RequestDispatcherOptions.cs b/src/request.dispatcher/RequestDispatcherOptions.cs index f7f8c68..d18def9 100644 --- a/src/request.dispatcher/RequestDispatcherOptions.cs +++ b/src/request.dispatcher/RequestDispatcherOptions.cs @@ -10,27 +10,24 @@ namespace Geekeey.Request.Dispatcher; internal sealed class RequestDispatcherOptions { - private readonly List<(Type Type, int? Order)> _search = []; + private readonly List _search = []; private readonly Lazy _behaviorsTypeIndex; private readonly Lazy _handlersTypeIndex; public RequestDispatcherOptions() { - _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type))); - _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type))); + _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.Distinct())); + _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.Distinct())); } - public void Inspect(IEnumerable assembly, int? order = null) + public void Inspect(IEnumerable 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 GetRequestBehaviors(IServiceProvider services) @@ -49,22 +46,11 @@ internal sealed class RequestDispatcherOptions protected readonly Dictionary> _closedTypeInfo = []; protected readonly List _openTypeInfo = []; - protected readonly Dictionary _order = []; - protected readonly Dictionary _explicitOrder = []; - protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func predicate) + protected TypeIndex(IEnumerable collection, Func 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 Resolve(IServiceProvider services) { return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(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 collection) : TypeIndex(collection, IsRequestHandlerType) { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List<(int Order, Type Type)>(); + var result = new List(); 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 collection) : TypeIndex(collection, IsRequestBehaviorType) { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List<(int Order, Type Type)>(); + var result = new List(); 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; } } } diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs index 2c6d129..63de03e 100644 --- a/src/request.dispatcher/ScalarRequestInvoker.cs +++ b/src/request.dispatcher/ScalarRequestInvoker.cs @@ -43,20 +43,7 @@ internal sealed class ScalarRequestInvoker : ScalarRequestI Task Head(IScalarRequest r, CancellationToken ct) { - var handlers = options.GetRequestHandlers>(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>(serviceProvider).First().HandleAsync((TRequest)r, ct); } } } diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs index 817b5b2..882d538 100644 --- a/src/request.dispatcher/StreamRequestInvoker.cs +++ b/src/request.dispatcher/StreamRequestInvoker.cs @@ -47,20 +47,7 @@ internal sealed class StreamRequestInvoker : StreamRequestI IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) { - var handlers = options.GetRequestHandlers>(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>(serviceProvider).First().HandleAsync((TRequest)r, ct); } } } diff --git a/src/request.persistence.entityframeworkcore.tests/.editorconfig b/src/request.persistence.entityframeworkcore.tests/.editorconfig new file mode 100644 index 0000000..2300467 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/.editorconfig @@ -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 diff --git a/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs b/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs new file mode 100644 index 0000000..97f8e11 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs @@ -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 CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}") + .EnableServiceProviderCaching(false) + .Options; + + var context = new FakeDbContext(options); + await context.Database.EnsureCreatedAsync(); + return context; + } + + private static async Task> CreateRepo() + { + var context = await CreateDbContext(); + return new Repository(context); + } + + private static async Task SeedData(Repository 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(); + } + + [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(); + } + + [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(); + } + + [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); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj b/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000..90df1c0 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + diff --git a/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs b/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs new file mode 100644 index 0000000..e10d098 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs @@ -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 CreateDbContext() + { + var context = new FakeDbContext(new DbContextOptionsBuilder() + .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(); + 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(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(); + Expression>> expr = static d => d.DetailChildren; + builder.IncludeExpressions.Add(new IncludeExpressionInfo( + expr, typeof(FakeEntity), typeof(List), typeof(IEnumerable))); + 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(); + 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(); + 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(); + 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(); + Expression>> expr = static d => d.DetailChildren; + builder.IncludeExpressions.Add(new IncludeExpressionInfo( + expr, typeof(FakeEntity), typeof(List), typeof(IEnumerable))); + 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(); + 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(); + 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(); + 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(); + 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> CreateRepo() + { + var context = await CreateDbContext(); + return new Repository(context); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs b/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs new file mode 100644 index 0000000..481b4bf --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs @@ -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 CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .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(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(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(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(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(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(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(context); + + var any = await repo.AnyAsync(); + + await Assert.That(any).IsFalse(); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs new file mode 100644 index 0000000..b51af0a --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs @@ -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 +{ + public CombinedWhereOrderTakeSpec() + { + Query.Where(e => e.Name != "David") + .OrderBy(e => e.Name) + .Builder + .Take(2); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs new file mode 100644 index 0000000..160a5ef --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs @@ -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 +{ + public ConcurrentSelectorSpec() + { + Query.Select(e => e.Name); + Query.SelectMany(e => e.Name.Split(' ')); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs new file mode 100644 index 0000000..dad2040 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs @@ -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 +{ + public DuplicateOrderSpec() + { + Query.OrderBy(e => e.Name); + Query.OrderBy(e => e.Id); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs new file mode 100644 index 0000000..24a8e02 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs @@ -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 GrandChildren { get; set; } = []; +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs new file mode 100644 index 0000000..c06fcb9 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs @@ -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 options) : DbContext(options) +{ + public DbSet FakeEntities => Set(); + public DbSet FakeChildEntities => Set(); + public DbSet FakeDetailEntities => Set(); + public DbSet FakeGrandChildEntities => Set(); + public DbSet FakeDetailChildEntities => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(static c => c.FakeEntityId); + entity.HasMany(static e => e.Details) + .WithOne(static d => d.FakeEntity) + .HasForeignKey(static d => d.FakeEntityId); + }); + + modelBuilder.Entity(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(grandChild => + { + grandChild.HasKey(static g => g.Id); + grandChild.Property(static g => g.Value).IsRequired(); + }); + + modelBuilder.Entity(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(detailChild => + { + detailChild.HasKey(static d => d.Id); + detailChild.Property(static d => d.Value).IsRequired(); + }); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs new file mode 100644 index 0000000..7226cdc --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs @@ -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!; +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs new file mode 100644 index 0000000..652c605 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs @@ -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 DetailChildren { get; set; } = []; +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs new file mode 100644 index 0000000..40feea0 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs @@ -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 Details { get; set; } = []; +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs new file mode 100644 index 0000000..c5bba1c --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs @@ -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!; +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs new file mode 100644 index 0000000..9d8cf10 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs @@ -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 +{ + public FirstOrderedSpec() + { + Query.OrderBy(e => e.Name); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs new file mode 100644 index 0000000..f918b06 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs @@ -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 +{ + public IncludeChildSpec() + { + Query.Include(static e => e.Child); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs new file mode 100644 index 0000000..cd02049 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs @@ -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 +{ + public IncludeChildThenGrandChildrenSpec() + { + Query.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs new file mode 100644 index 0000000..2587e48 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs @@ -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 +{ + public IncludeDetailsSpec() + { + Query.Include(static e => e.Details); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs new file mode 100644 index 0000000..5075d2b --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs @@ -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 +{ + public IncludeOnlySpec(SpecificationBuilder builder) : base(builder) + { + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs new file mode 100644 index 0000000..6419704 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs @@ -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 +{ + public MultiSearchSpec(string term1, string term2) + { + Query.Search(e => e.Name, term1, searchGroup: 1) + .Search(e => e.Name, term2, searchGroup: 1); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs new file mode 100644 index 0000000..d20f0a5 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs @@ -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 +{ + public MultipleWhereSpec() + { + Query.Where(e => e.Name.StartsWith("A")) + .Where(e => e.Name.EndsWith("Beta")); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs new file mode 100644 index 0000000..76e8765 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs @@ -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 +{ + public NameEqualSpec(string name) + { + Query.Where(e => e.Name == name); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs new file mode 100644 index 0000000..6baa0ae --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs @@ -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 +{ + public NameProjectionSpec() + { + Query.Select(e => e.Name); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs new file mode 100644 index 0000000..2860d86 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs @@ -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 +{ +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs new file mode 100644 index 0000000..fa8b4dc --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs @@ -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 +{ + public OrderByNameDescSpec() + { + Query.OrderByDescending(e => e.Name); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs new file mode 100644 index 0000000..be79f3b --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs @@ -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 +{ + public OrderByNameSpec() + { + Query.OrderBy(e => e.Name); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs new file mode 100644 index 0000000..a808164 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs @@ -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 +{ + public OrderByNameThenByIdDescSpec() + { + Query.OrderBy(e => e.Name).ThenByDescending(e => e.Id); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs new file mode 100644 index 0000000..1dab453 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs @@ -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 +{ + public OrderByNameThenByIdSpec() + { + Query.OrderBy(e => e.Name).ThenBy(e => e.Id); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs new file mode 100644 index 0000000..1969144 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs @@ -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 +{ + public OrderedSkipSpec(int skip) + { + Query.OrderBy(e => e.Name); + Query.Skip(skip); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs new file mode 100644 index 0000000..49aca1c --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs @@ -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 +{ + public OrderedSkipTakeSpec(int skip, int take) + { + Query.OrderBy(e => e.Name); + Query.Skip(skip).Take(take); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs new file mode 100644 index 0000000..387d640 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs @@ -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 +{ + public OrderedTakeSpec(int take) + { + Query.OrderBy(e => e.Name); + Query.Take(take); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs new file mode 100644 index 0000000..fe189e2 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs @@ -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 +{ + public PostProcessingSpec() + { + Query.Where(e => e.Name.StartsWith("A")) + .PostProcessing(items => items.Take(1)); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs new file mode 100644 index 0000000..99fc073 --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs @@ -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 +{ + public SearchSpec(string term, int searchGroup = 1) + { + Query.Search(e => e.Name, term, searchGroup: searchGroup); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs new file mode 100644 index 0000000..b762bff --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs @@ -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 +{ + public SkipOnlySpec(int skip) + { + Query.Skip(skip); + } +} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs new file mode 100644 index 0000000..235dc1e --- /dev/null +++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs @@ -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 +{ + public WhereSpec() + { + Query.Where(e => e.Name == "Alpha"); + } +} diff --git a/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj b/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj new file mode 100644 index 0000000..120069c --- /dev/null +++ b/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj @@ -0,0 +1,40 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + Entity Framework Core implementation of the generic repository and specification pattern. + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence.entityframeworkcore + EUPL-1.2 + + + + + + + + + + + + + + + + + + diff --git a/src/request.persistence.entityframeworkcore/Repository.cs b/src/request.persistence.entityframeworkcore/Repository.cs new file mode 100644 index 0000000..0d4767e --- /dev/null +++ b/src/request.persistence.entityframeworkcore/Repository.cs @@ -0,0 +1,174 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.EntityFrameworkCore; + +namespace Geekeey.Request.Persistence.EntityFrameworkCore; + +/// +/// Represents a repository for . +/// +/// The type of entity. +public partial class Repository : IRepository + where TEntity : class +{ + private readonly SpecificationEvaluator _evaluator = SpecificationEvaluator.Default; + + /// + /// Creates a new repository. + /// + /// The used by the repository. + public Repository(DbContext dbContext) + { + DbContext = dbContext; + } + + /// + /// Gets the used by the repository. + /// + protected DbContext DbContext { get; } + + /// + public virtual async Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual async Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual async Task> ListAsync(CancellationToken cancellationToken = default) + { + return await DbContext.Set().ToListAsync(cancellationToken); + } + + /// + public virtual async Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken); + + return specification.PostProcessingAction is null + ? queryResult + : [.. specification.PostProcessingAction(queryResult)]; + } + + /// + public virtual async Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken); + + return specification.PostProcessingAction is null + ? queryResult + : [.. specification.PostProcessingAction(queryResult)]; + } + + /// + public virtual async Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification, true).CountAsync(cancellationToken); + } + + /// + public virtual async Task CountAsync(CancellationToken cancellationToken = default) + { + return await DbContext.Set().CountAsync(cancellationToken); + } + + /// + public virtual async Task AnyAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification, true).AnyAsync(cancellationToken); + } + + /// + public virtual async Task AnyAsync(CancellationToken cancellationToken = default) + { + return await DbContext.Set().AnyAsync(cancellationToken); + } + + /// + public virtual IAsyncEnumerable AsAsyncEnumerable(ISpecification specification) + { + return ApplySpecification(specification).AsAsyncEnumerable(); + } + + protected virtual IQueryable ApplySpecification(ISpecification specification, bool evaluateCriteriaOnly = false) + { + return _evaluator.Evaluate(DbContext.Set(), specification, evaluateCriteriaOnly); + } + + protected virtual IQueryable ApplySpecification(ISpecification specification) + { + return _evaluator.Evaluate(DbContext.Set(), specification); + } +} + +public partial class Repository +{ + /// + public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) + { + DbContext.Set().Add(entity); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + DbContext.Set().AddRange(entities); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + DbContext.Set().Update(entity); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + DbContext.Set().UpdateRange(entities); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + { + DbContext.Set().Remove(entity); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + DbContext.Set().RemoveRange(entities); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task DeleteRangeAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + var query = ApplySpecification(specification); + DbContext.Set().RemoveRange(query); + + await SaveChangesAsync(cancellationToken); + } + + /// + public virtual async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await DbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs new file mode 100644 index 0000000..cecdf32 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs @@ -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 _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 Evaluate(IQueryable query, ISpecification 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(query, specification); + + return specification.Selector is not null + ? query.Select(specification.Selector) + : query.SelectMany(specification.SelectorMany!); + } + + public IQueryable Evaluate(IQueryable query, ISpecification 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; + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs new file mode 100644 index 0000000..39d230a --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs @@ -0,0 +1,25 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence.EntityFrameworkCore; + +/// +/// Defines an evaluator that applies a specific aspect of a specification to a query. +/// +public interface IEvaluator +{ + /// + /// Gets a value indicating whether this evaluator filters the result set (criteria) + /// rather than shaping the query (e.g., includes, ordering, pagination). + /// + bool IsCriteriaEvaluator { get; } + + /// + /// Applies the evaluator's logic to the specified query based on the given specification. + /// + /// The type of entity being queried. + /// The query to evaluate. + /// The specification containing the expression to apply. + /// The modified query. + IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class; +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs new file mode 100644 index 0000000..12cfef7 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs @@ -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>> 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 Evaluate(IQueryable query, ISpecification specification) where T : class + { + foreach (var includeInfo in specification.IncludeExpressions) + { + if (includeInfo.Type == IncludeType.Include) + { + query = BuildInclude(query, includeInfo); + } + else if (includeInfo.Type == IncludeType.ThenInclude) + { + query = BuildThenInclude(query, includeInfo); + } + } + + return query; + } + + private IQueryable BuildInclude(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)result; + } + + var include = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, null), CreateIncludeDelegate).Value; + + return (IQueryable)include(query, includeInfo.LambdaExpression); + } + + private static Lazy> CreateIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey) + { + return new Lazy>(() => + { + 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>(call, sourceParameter, selectorParameter); + + return lambda.Compile(); + }); + } + + private IQueryable BuildThenInclude(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)result; + } + + var thenInclude = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, includeInfo.PreviousPropertyType), CreateThenIncludeDelegate).Value; + + return (IQueryable)thenInclude(query, includeInfo.LambdaExpression); + } + + private static Lazy> CreateThenIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey) + { + return new Lazy>(() => + { + 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>(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; + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs new file mode 100644 index 0000000..f7a3e87 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs @@ -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 Evaluate(IQueryable query, ISpecification specification) where T : class + { + if (specification.OrderExpressions.Count(static info => info.OrderType is OrderType.OrderBy or OrderType.OrderByDescending) > 1) + { + throw new DuplicateOrderChainException(); + } + + IOrderedQueryable? 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; + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs new file mode 100644 index 0000000..489bcae --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs @@ -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 Evaluate(IQueryable query, ISpecification 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; + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs new file mode 100644 index 0000000..ef019c4 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs @@ -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 Evaluate(IQueryable query, ISpecification specification) where T : class + { + foreach (var searchCriteria in specification.SearchCriteria.GroupBy(static info => info.SearchGroup)) + { + query = Search(query, searchCriteria); + } + + return query; + } + + private static IQueryable Search(IQueryable source, IEnumerable> 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>)(() => 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>(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 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; + } + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs new file mode 100644 index 0000000..d876427 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs @@ -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 Evaluate(IQueryable query, ISpecification specification) where T : class + { + foreach (var info in specification.WhereExpressions) + { + query = query.Where(info.Filter); + } + + return query; + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs new file mode 100644 index 0000000..e18b8dd --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs @@ -0,0 +1,33 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence.EntityFrameworkCore; + +/// +/// Represents an error that occurs when a specification defines both Select() and SelectMany() transforms. +/// +/// +/// Only one selector transform is permitted per specification. +/// +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!"; + + /// + /// Initializes a new instance of the class. + /// + public ConcurrentSelectorsException() + : base(ExceptionMessage) + { + } + + /// + /// Initializes a new instance of the class with a reference + /// to the inner exception that is the cause of this exception. + /// + /// The exception that is the cause of the current exception. + public ConcurrentSelectorsException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs new file mode 100644 index 0000000..7167e1c --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence.EntityFrameworkCore; + +/// +/// Represents an error that occurs when a specification contains more than one ordering chain. +/// +/// +/// Only a single OrderBy() or OrderByDescending() clause is permitted per specification. +/// Subsequent ordering must use ThenBy() or ThenByDescending(). +/// +public sealed class DuplicateOrderChainException : Exception +{ + private const string ExceptionMessage = "The specification contains more than one Order chain!"; + + /// + /// Initializes a new instance of the class. + /// + public DuplicateOrderChainException() + : base(ExceptionMessage) + { + } + + /// + /// Initializes a new instance of the class with a reference + /// to the inner exception that is the cause of this exception. + /// + /// The exception that is the cause of the current exception. + public DuplicateOrderChainException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs new file mode 100644 index 0000000..3394db6 --- /dev/null +++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs @@ -0,0 +1,33 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence.EntityFrameworkCore; + +/// +/// Represents an error that occurs when a specification has no selector transform defined. +/// +/// +/// A specification targeting a projected result must define either Select() or SelectMany(). +/// +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!"; + + /// + /// Initializes a new instance of the class. + /// + public SelectorNotFoundException() + : base(ExceptionMessage) + { + } + + /// + /// Initializes a new instance of the class with a reference + /// to the inner exception that is the cause of this exception. + /// + /// The exception that is the cause of the current exception. + public SelectorNotFoundException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence.tests/.editorconfig b/src/request.persistence.tests/.editorconfig new file mode 100644 index 0000000..2300467 --- /dev/null +++ b/src/request.persistence.tests/.editorconfig @@ -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 diff --git a/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj b/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj new file mode 100644 index 0000000..633378d --- /dev/null +++ b/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + diff --git a/src/request.persistence.tests/SpecificationBuilderTests.cs b/src/request.persistence.tests/SpecificationBuilderTests.cs new file mode 100644 index 0000000..1addaff --- /dev/null +++ b/src/request.persistence.tests/SpecificationBuilderTests.cs @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + builder.Take(10); + + await Assert.That(builder.Take).IsEqualTo(10); + } + + [Test] + public async Task DuplicateTake_throws() + { + var builder = new SpecificationBuilder(); + builder.Take(10); + + await Assert.That(() => builder.Take(5)).Throws(); + } + + [Test] + public async Task Skip_sets_value() + { + var builder = new SpecificationBuilder(); + builder.Skip(5); + + await Assert.That(builder.Skip).IsEqualTo(5); + } + + [Test] + public async Task DuplicateSkip_throws() + { + var builder = new SpecificationBuilder(); + builder.Skip(5); + + await Assert.That(() => builder.Skip(10)).Throws(); + } + + [Test] + public async Task Include_adds_include_expression() + { + var builder = new SpecificationBuilder(); + 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(); + 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(); + 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)); + } + + [Test] + public async Task Include_with_false_condition_does_not_add_expression() + { + var builder = new SpecificationBuilder(); + 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(); + 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(); + 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(); + 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(); + 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(); + builder.Select(static x => x.Name); + + await Assert.That(builder.Selector).IsNotNull(); + } + + [Test] + public async Task PostProcessing_sets_action() + { + var builder = new SpecificationBuilder(); + 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(); + builder.Search(static x => x.Name, "test"); + + await Assert.That(builder.SearchCriteria).Count().IsEqualTo(1); + } +} diff --git a/src/request.persistence.tests/_fixtures/FakeChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeChildEntity.cs new file mode 100644 index 0000000..744d7ee --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeChildEntity.cs @@ -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 GrandChildren { get; set; } = []; +} diff --git a/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs new file mode 100644 index 0000000..c34377a --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs @@ -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!; +} diff --git a/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs new file mode 100644 index 0000000..2e76b36 --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs @@ -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 DetailChildren { get; set; } = []; +} diff --git a/src/request.persistence.tests/_fixtures/FakeEntity.cs b/src/request.persistence.tests/_fixtures/FakeEntity.cs new file mode 100644 index 0000000..7f5f1a7 --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeEntity.cs @@ -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 Details { get; set; } = []; +} diff --git a/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs new file mode 100644 index 0000000..18ed480 --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs @@ -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!; +} diff --git a/src/request.persistence.tests/_fixtures/FakeSpecification.cs b/src/request.persistence.tests/_fixtures/FakeSpecification.cs new file mode 100644 index 0000000..05d2219 --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeSpecification.cs @@ -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 +{ + public FakeSpecification() + { + Query.Where(static x => x.Name.Length > 0); + } +} diff --git a/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs b/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs new file mode 100644 index 0000000..3512f5a --- /dev/null +++ b/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs @@ -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 +{ + public FakeSpecificationWithResult() + { + Query.Where(static x => x.Name.Length > 0); + Query.Select(static x => x.Name); + } +} diff --git a/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs b/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs new file mode 100644 index 0000000..57b6c55 --- /dev/null +++ b/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs @@ -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 +{ +} diff --git a/src/request.persistence/Geekeey.Request.Persistence.csproj b/src/request.persistence/Geekeey.Request.Persistence.csproj new file mode 100644 index 0000000..55ba98c --- /dev/null +++ b/src/request.persistence/Geekeey.Request.Persistence.csproj @@ -0,0 +1,31 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + Generic repository and specification pattern abstractions for .NET. + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence + EUPL-1.2 + + + + + + + + + diff --git a/src/request.persistence/IRepository.cs b/src/request.persistence/IRepository.cs new file mode 100644 index 0000000..391592e --- /dev/null +++ b/src/request.persistence/IRepository.cs @@ -0,0 +1,155 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Defines query operations for entities of type . +/// +/// The type of the entity. +public partial interface IRepository + where TEntity : class +{ + /// + /// Returns the first element that matches the specification, or . + /// + /// The specification. + /// A cancellation token. + /// The entity, or . + Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns the first projected result that matches the specification, or . + /// + /// The result type. + /// The specification. + /// A cancellation token. + /// The projected result, or . + Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns all entities. + /// + /// A cancellation token. + /// A list of entities. + Task> ListAsync(CancellationToken cancellationToken = default); + + /// + /// Returns entities that match the specification. + /// + /// The specification. + /// A cancellation token. + /// A list of entities. + Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns projected results that match the specification. + /// + /// The result type. + /// The specification. + /// A cancellation token. + /// A list of projected results. + Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns the number of entities that match the specification. + /// + /// The specification. + /// A cancellation token. + /// The count. + Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns the total number of entities. + /// + /// A cancellation token. + /// The count. + Task CountAsync(CancellationToken cancellationToken = default); + + /// + /// Returns whether any entity matches the specification. + /// + /// The specification. + /// A cancellation token. + /// if any entity matches; otherwise . + Task AnyAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Returns whether any entity exists. + /// + /// A cancellation token. + /// if any entity exists; otherwise . + Task AnyAsync(CancellationToken cancellationToken = default); + + /// + /// Returns an async enumerable for entities that match the specification. + /// + /// The specification. + /// An async enumerable of entities. + IAsyncEnumerable AsAsyncEnumerable(ISpecification specification); +} + +/// +/// Defines persistence operations for entities of type . +/// +/// The type of the entity. +public partial interface IRepository +{ + /// + /// Adds an entity. + /// + /// The entity to add. + /// A cancellation token. + /// The added entity. + Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// + /// Adds multiple entities. + /// + /// The entities to add. + /// A cancellation token. + /// The added entities. + Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// + /// Updates an entity. + /// + /// The entity to update. + /// A cancellation token. + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// + /// Updates multiple entities. + /// + /// The entities to update. + /// A cancellation token. + Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// + /// Deletes an entity. + /// + /// The entity to delete. + /// A cancellation token. + Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// + /// Deletes multiple entities. + /// + /// The entities to delete. + /// A cancellation token. + Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// + /// Deletes entities that match the specification. + /// + /// The specification. + /// A cancellation token. + Task DeleteRangeAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Persists all pending changes. + /// + /// A cancellation token. + /// The number of affected rows. + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/request.persistence/ISpecification.cs b/src/request.persistence/ISpecification.cs new file mode 100644 index 0000000..2a1038d --- /dev/null +++ b/src/request.persistence/ISpecification.cs @@ -0,0 +1,71 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Encapsulates query logic for . +/// +/// The type being queried against. +public interface ISpecification +{ + /// + /// Gets the collection of filters. + /// + IReadOnlyList> WhereExpressions { get; } + + /// + /// Gets the collection of order expressions. + /// + IReadOnlyList> OrderExpressions { get; } + + /// + /// Gets the collection of include expressions. + /// + IReadOnlyList IncludeExpressions { get; } + + /// + /// Gets the collection of search criteria. + /// + IReadOnlyList> SearchCriteria { get; } + + /// + /// Gets the post-processing action to apply to the result. + /// + Func, IEnumerable>? PostProcessingAction { get; } + + /// + /// Gets the number of elements to take. + /// + int? Take { get; } + + /// + /// Gets the number of elements to skip. + /// + int? Skip { get; } +} + +/// +/// Encapsulates query logic for and projects the result into . +/// +/// The type being queried against. +/// The type of the result. +public interface ISpecification : ISpecification +{ + /// + /// Gets the selector expression. + /// + Expression>? Selector { get; } + + /// + /// Gets the selector many expression. + /// + Expression>>? SelectorMany { get; } + + /// + /// Gets the post-processing action to apply to the projected result. + /// + new Func, IEnumerable>? PostProcessingAction { get; } +} diff --git a/src/request.persistence/IncludeType.cs b/src/request.persistence/IncludeType.cs new file mode 100644 index 0000000..259ee1b --- /dev/null +++ b/src/request.persistence/IncludeType.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Specifies the type of include operation. +/// +public enum IncludeType +{ + /// + /// Represents an Include operation. + /// + Include = 1, + + /// + /// Represents a ThenInclude operation. + /// + ThenInclude = 2, +} diff --git a/src/request.persistence/OrderType.cs b/src/request.persistence/OrderType.cs new file mode 100644 index 0000000..50ac213 --- /dev/null +++ b/src/request.persistence/OrderType.cs @@ -0,0 +1,30 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Specifies the ordering direction. +/// +public enum OrderType +{ + /// + /// Orders by ascending. + /// + OrderBy = 1, + + /// + /// Orders by descending. + /// + OrderByDescending = 2, + + /// + /// Then by ascending. + /// + ThenBy = 3, + + /// + /// Then by descending. + /// + ThenByDescending = 4 +} diff --git a/src/request.persistence/Specification.cs b/src/request.persistence/Specification.cs new file mode 100644 index 0000000..6017703 --- /dev/null +++ b/src/request.persistence/Specification.cs @@ -0,0 +1,103 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Represents a specification for filtering, ordering, and including related entities. +/// +/// The entity type. +public abstract class Specification : ISpecification +{ + /// + /// Creates a new specification. + /// + protected Specification() + : this(new SpecificationBuilder()) + { + } + + /// + /// Creates a new specification with the given builder. + /// + /// The specification builder. + protected Specification(SpecificationBuilder builder) + { + Query = builder; + } + + /// + /// Gets the specification builder. + /// + protected SpecificationBuilder Query { get; } + + /// + IReadOnlyList> ISpecification.WhereExpressions + => Query.WhereExpressions; + + /// + IReadOnlyList> ISpecification.OrderExpressions + => Query.OrderExpressions; + + /// + IReadOnlyList ISpecification.IncludeExpressions + => Query.IncludeExpressions; + + /// + IReadOnlyList> ISpecification.SearchCriteria + => Query.SearchCriteria; + + /// + Func, IEnumerable>? ISpecification.PostProcessingAction + => Query.PostProcessingAction; + + /// + int? ISpecification.Take => Query.Take; + + /// + int? ISpecification.Skip => Query.Skip; +} + +/// +/// Represents a specification for filtering, ordering, and including related entities, +/// with a projection to . +/// +/// The entity type. +/// The result type. +public abstract class Specification : Specification, ISpecification +{ + /// + /// Creates a new specification. + /// + protected Specification() : this(new SpecificationBuilder()) + { + } + + /// + /// Creates a new specification with the given builder. + /// + /// The specification builder. + protected Specification(SpecificationBuilder builder) : base(builder) + { + Query = builder; + } + + /// + /// Gets the specification builder. + /// + protected new SpecificationBuilder Query { get; } + + /// + Expression>? ISpecification.Selector + => Query.Selector; + + /// + Expression>>? ISpecification.SelectorMany + => Query.SelectorMany; + + /// + Func, IEnumerable>? ISpecification.PostProcessingAction + => Query.PostProcessingAction; +} diff --git a/src/request.persistence/_builders/IncludableBuilderExtensions.cs b/src/request.persistence/_builders/IncludableBuilderExtensions.cs new file mode 100644 index 0000000..b348fe5 --- /dev/null +++ b/src/request.persistence/_builders/IncludableBuilderExtensions.cs @@ -0,0 +1,101 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class IncludableBuilderExtensions +{ + /// + /// Specifies a ThenInclude operation on a reference navigation property. + /// + /// The includable builder. + /// The expression. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder builder, + Expression> expression) + where TEntity : class + { + return ThenInclude(builder, expression, condition: true); + } + + /// + /// Specifies a ThenInclude operation when is . + /// + /// The includable builder. + /// The expression. + /// Whether to apply the ThenInclude. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder builder, + Expression> 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(builder.Builder, !condition || builder.IsChainDiscarded); + + return includeBuilder; + } + + /// + /// Specifies a ThenInclude operation on a collection navigation property. + /// + /// The includable builder. + /// The expression. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder> previousBuilder, + Expression> expression) + where TEntity : class + { + return ThenInclude(previousBuilder, expression, condition: true); + } + + /// + /// Specifies a ThenInclude operation on a collection navigation property when is . + /// + /// The includable builder. + /// The expression. + /// Whether to apply the ThenInclude. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder> builder, + Expression> expression, + bool condition) + where TEntity : class + { + if (condition && !builder.IsChainDiscarded) + { + var info = new IncludeExpressionInfo(expression, typeof(TEntity), typeof(TProperty), typeof(IEnumerable)); + + builder.Builder.IncludeExpressions.Add(info); + } + + var includeBuilder = new IncludableSpecificationBuilder(builder.Builder, !condition || builder.IsChainDiscarded); + + return includeBuilder; + } +} diff --git a/src/request.persistence/_builders/IncludableSpecificationBuilder.cs b/src/request.persistence/_builders/IncludableSpecificationBuilder.cs new file mode 100644 index 0000000..6a652fa --- /dev/null +++ b/src/request.persistence/_builders/IncludableSpecificationBuilder.cs @@ -0,0 +1,33 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a builder for chaining ThenInclude operations. +/// +/// The entity type. +/// The property type. +public class IncludableSpecificationBuilder where T : class +{ + /// + /// Gets the underlying specification builder. + /// + public SpecificationBuilder Builder { get; } + + /// + /// Gets or sets whether the chain is discarded. + /// + public bool IsChainDiscarded { get; set; } + + /// + /// Creates a new includable specification builder. + /// + /// The specification builder. + /// Whether the chain is discarded. + public IncludableSpecificationBuilder(SpecificationBuilder builder, bool isChainDiscarded = false) + { + Builder = builder; + IsChainDiscarded = isChainDiscarded; + } +} diff --git a/src/request.persistence/_builders/OrderedBuilderExtensions.cs b/src/request.persistence/_builders/OrderedBuilderExtensions.cs new file mode 100644 index 0000000..2f35721 --- /dev/null +++ b/src/request.persistence/_builders/OrderedBuilderExtensions.cs @@ -0,0 +1,80 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class OrderedBuilderExtensions +{ + /// + /// Adds a ThenBy expression for secondary ordering. + /// + /// The ordered builder. + /// The expression. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenBy(this OrderedSpecificationBuilder builder, Expression> expression) + { + return ThenBy(builder, expression, condition: true); + } + + /// + /// Adds a ThenBy expression when is . + /// + /// The ordered builder. + /// The expression. + /// Whether to apply the ThenBy. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenBy(this OrderedSpecificationBuilder builder, Expression> expression, bool condition) + { + if (condition && !builder.IsChainDiscarded) + { + builder.Builder.OrderExpressions.Add(new OrderExpressionInfo(expression, OrderType.ThenBy)); + } + else + { + builder.IsChainDiscarded = true; + } + + return builder; + } + + /// + /// Adds a ThenByDescending expression for secondary ordering. + /// + /// The ordered builder. + /// The expression. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenByDescending(this OrderedSpecificationBuilder builder, Expression> expression) + { + return ThenByDescending(builder, expression, condition: true); + } + + /// + /// Adds a ThenByDescending expression when is . + /// + /// The ordered builder. + /// The expression. + /// Whether to apply the ThenByDescending. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenByDescending(this OrderedSpecificationBuilder builder, Expression> expression, bool condition) + { + if (condition && !builder.IsChainDiscarded) + { + builder.Builder.OrderExpressions.Add(new OrderExpressionInfo(expression, OrderType.ThenByDescending)); + } + else + { + builder.IsChainDiscarded = true; + } + + return builder; + } +} diff --git a/src/request.persistence/_builders/OrderedSpecificationBuilder.cs b/src/request.persistence/_builders/OrderedSpecificationBuilder.cs new file mode 100644 index 0000000..f24107c --- /dev/null +++ b/src/request.persistence/_builders/OrderedSpecificationBuilder.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a builder for chaining ThenBy / ThenByDescending operations. +/// +/// The entity type. +public sealed class OrderedSpecificationBuilder +{ + /// + /// Gets the underlying specification builder. + /// + public SpecificationBuilder Builder { get; } + + /// + /// Gets or sets whether the chain is discarded. + /// + public bool IsChainDiscarded { get; set; } + + /// + /// Creates a new ordered specification builder. + /// + /// The specification builder. + /// Whether the chain is discarded. + public OrderedSpecificationBuilder(SpecificationBuilder builder, bool isChainDiscarded = false) + { + Builder = builder; + IsChainDiscarded = isChainDiscarded; + } +} diff --git a/src/request.persistence/_builders/SpecificationBuilder.cs b/src/request.persistence/_builders/SpecificationBuilder.cs new file mode 100644 index 0000000..a1305c7 --- /dev/null +++ b/src/request.persistence/_builders/SpecificationBuilder.cs @@ -0,0 +1,72 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a mutable container for building a specification for . +/// +/// The entity type. +public class SpecificationBuilder +{ + /// + /// Gets the list of where expressions. + /// + public List> WhereExpressions { get; } = []; + + /// + /// Gets the list of order expressions. + /// + public List> OrderExpressions { get; } = []; + + /// + /// Gets the list of include expressions. + /// + public List IncludeExpressions { get; } = []; + + /// + /// Gets the list of search criteria. + /// + public List> SearchCriteria { get; } = []; + + /// + /// Gets or sets the post-processing action. + /// + public Func, IEnumerable>? PostProcessingAction { get; set; } + + /// + /// Gets or sets the number of elements to take. + /// + public int? Take { get; set; } + + /// + /// Gets or sets the number of elements to skip. + /// + public int? Skip { get; set; } +} + +/// +/// Provides a mutable container for building a specification for +/// with a projection to . +/// +/// The entity type. +/// The result type. +public class SpecificationBuilder : SpecificationBuilder +{ + /// + /// Gets or sets the selector expression. + /// + public Expression>? Selector { get; set; } + + /// + /// Gets or sets the selector many expression. + /// + public Expression>>? SelectorMany { get; set; } + + /// + /// Gets or sets the post-processing action for the projected result. + /// + public new Func, IEnumerable>? PostProcessingAction { get; set; } +} diff --git a/src/request.persistence/_builders/SpecificationBuilderExtensions.cs b/src/request.persistence/_builders/SpecificationBuilderExtensions.cs new file mode 100644 index 0000000..372be89 --- /dev/null +++ b/src/request.persistence/_builders/SpecificationBuilderExtensions.cs @@ -0,0 +1,305 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class SpecificationBuilderExtensions +{ + /// + /// Adds a filter predicate to the specification. + /// + /// The specification builder. + /// The filter expression. + /// The entity type. + /// The builder. + public static SpecificationBuilder Where(this SpecificationBuilder builder, Expression> criteria) + { + return Where(builder, criteria, condition: true); + } + + /// + /// Adds a filter predicate to the specification when is . + /// + /// The specification builder. + /// The filter expression. + /// Whether to apply the filter. + /// The entity type. + /// The builder. + public static SpecificationBuilder Where(this SpecificationBuilder builder, Expression> criteria, bool condition) + { + if (condition) + { + builder.WhereExpressions.Add(new WhereExpressionInfo(criteria)); + } + + return builder; + } + + /// + /// Adds an ascending order expression. + /// + /// The specification builder. + /// The order expression. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderBy(this SpecificationBuilder builder, Expression> orderExpression) + { + return OrderBy(builder, orderExpression, condition: true); + } + + /// + /// Adds an ascending order expression when is . + /// + /// The specification builder. + /// The order expression. + /// Whether to apply the order. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderBy(this SpecificationBuilder builder, Expression> orderExpression, bool condition) + { + if (condition) + { + builder.OrderExpressions.Add(new OrderExpressionInfo(orderExpression, OrderType.OrderBy)); + } + + var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder, !condition); + + return orderedSpecificationBuilder; + } + + /// + /// Adds a descending order expression. + /// + /// The specification builder. + /// The order expression. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderByDescending(this SpecificationBuilder builder, Expression> orderExpression) + { + return OrderByDescending(builder, orderExpression, condition: true); + } + + /// + /// Adds a descending order expression when is . + /// + /// The specification builder. + /// The order expression. + /// Whether to apply the order. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderByDescending(this SpecificationBuilder builder, Expression> orderExpression, bool condition) + { + if (condition) + { + builder.OrderExpressions.Add(new OrderExpressionInfo(orderExpression, OrderType.OrderByDescending)); + } + + var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder, !condition); + + return orderedSpecificationBuilder; + } + + /// + /// Adds an include expression to load a related entity. + /// + /// The specification builder. + /// The include expression. + /// The entity type. + /// The property type. + /// An includable specification builder for further chaining. + public static IncludableSpecificationBuilder Include(this SpecificationBuilder builder, Expression> includeExpression) where T : class + { + return Include(builder, includeExpression, condition: true); + } + + /// + /// Adds an include expression when is . + /// + /// The specification builder. + /// The include expression. + /// Whether to apply the include. + /// The entity type. + /// The property type. + /// An includable specification builder for further chaining. + public static IncludableSpecificationBuilder Include(this SpecificationBuilder builder, Expression> 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(builder, !condition); + + return includeBuilder; + } + + /// + /// Specifies the number of elements to return. + /// + /// The specification builder. + /// The number of elements to take. + /// The entity type. + /// The builder. + public static SpecificationBuilder Take(this SpecificationBuilder builder, int take) + { + return Take(builder, take, true); + } + + /// + /// Specifies the number of elements to return when is . + /// + /// The specification builder. + /// The number of elements to take. + /// Whether to apply the take. + /// The entity type. + /// The builder. + public static SpecificationBuilder Take(this SpecificationBuilder builder, int take, bool condition) + { + if (condition) + { + if (builder.Take is not null) + { + throw new DuplicateTakeException(); + } + + builder.Take = take; + } + + return builder; + } + + /// + /// Specifies the number of elements to skip before returning. + /// + /// The specification builder. + /// The number of elements to skip. + /// The entity type. + /// The builder. + public static SpecificationBuilder Skip(this SpecificationBuilder builder, int skip) + { + return Skip(builder, skip, condition: true); + } + + /// + /// Specifies the number of elements to skip when is . + /// + /// The specification builder. + /// The number of elements to skip. + /// Whether to apply the skip. + /// The entity type. + /// The builder. + public static SpecificationBuilder Skip(this SpecificationBuilder builder, int skip, bool condition) + { + if (condition) + { + if (builder.Skip is not null) + { + throw new DuplicateSkipException(); + } + + builder.Skip = skip; + } + + return builder; + } + + /// + /// Specifies a selector to project the result. + /// + /// The specification builder. + /// The selector expression. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder Select(this SpecificationBuilder builder, Expression> selector) + { + builder.Selector = selector; + + return builder; + } + + /// + /// Specifies a selector many to flatten the result. + /// + /// The specification builder. + /// The selector many expression. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder SelectMany(this SpecificationBuilder builder, Expression>> selector) + { + builder.SelectorMany = selector; + + return builder; + } + + /// + /// Specifies a post-processing action to apply to the result. + /// + /// The specification builder. + /// The post-processing action. + /// The entity type. + /// The builder. + public static SpecificationBuilder PostProcessing(this SpecificationBuilder builder, Func, IEnumerable> predicate) + { + builder.PostProcessingAction = predicate; + + return builder; + } + + /// + /// Specifies a post-processing action to apply to the projected result. + /// + /// The specification builder. + /// The post-processing action. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder PostProcessing(this SpecificationBuilder builder, Func, IEnumerable> predicate) + { + builder.PostProcessingAction = predicate; + + return builder; + } + + /// + /// Adds a search criteria (SQL LIKE) for the given selector. + /// + /// The specification builder. + /// The selector expression. + /// The search term. + /// The search group. + /// The entity type. + /// The builder. + public static SpecificationBuilder Search(this SpecificationBuilder builder, Expression> selector, string searchTerm, int searchGroup = 1) where T : class + { + return Search(builder, selector, searchTerm, condition: true, searchGroup); + } + + /// + /// Adds a search criteria when is . + /// + /// The specification builder. + /// The selector expression. + /// The search term. + /// Whether to apply the search. + /// The search group. + /// The entity type. + /// The builder. + public static SpecificationBuilder Search(this SpecificationBuilder builder, Expression> selector, string searchTerm, bool condition, int searchGroup = 1) where T : class + { + if (condition) + { + builder.SearchCriteria.Add(new SearchExpressionInfo(selector, searchTerm, searchGroup)); + } + + return builder; + } +} diff --git a/src/request.persistence/_exceptions/DuplicateSkipException.cs b/src/request.persistence/_exceptions/DuplicateSkipException.cs new file mode 100644 index 0000000..6984ca9 --- /dev/null +++ b/src/request.persistence/_exceptions/DuplicateSkipException.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Thrown when Skip is used more than once in the same specification. +/// +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!"; + + /// + /// Creates a new . + /// + public DuplicateSkipException() + : base(ExceptionMessage) + { + } + + /// + /// Creates a new with an inner exception. + /// + /// The inner exception. + public DuplicateSkipException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence/_exceptions/DuplicateTakeException.cs b/src/request.persistence/_exceptions/DuplicateTakeException.cs new file mode 100644 index 0000000..21e8b39 --- /dev/null +++ b/src/request.persistence/_exceptions/DuplicateTakeException.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Thrown when Take is used more than once in the same specification. +/// +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!"; + + /// + /// Creates a new . + /// + public DuplicateTakeException() + : base(ExceptionMessage) + { + } + + /// + /// Creates a new with an inner exception. + /// + /// The inner exception. + public DuplicateTakeException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence/_expressions/IncludeExpressionInfo.cs b/src/request.persistence/_expressions/IncludeExpressionInfo.cs new file mode 100644 index 0000000..903aded --- /dev/null +++ b/src/request.persistence/_expressions/IncludeExpressionInfo.cs @@ -0,0 +1,74 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about an include expression. +/// +public sealed class IncludeExpressionInfo +{ + /// + /// Gets the lambda expression. + /// + public LambdaExpression LambdaExpression { get; } + + /// + /// Gets the entity type. + /// + public Type EntityType { get; } + + /// + /// Gets the property type. + /// + public Type PropertyType { get; } + + /// + /// Gets the previous property type, or for an Include. + /// + public Type? PreviousPropertyType { get; } + + /// + /// Gets the include type. + /// + 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; + } + + /// + /// Creates an Include expression info. + /// + /// The lambda expression. + /// The entity type. + /// The property type. + public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType) + : this(expression, entityType, propertyType, null, IncludeType.Include) + { + } + + /// + /// Creates a ThenInclude expression info. + /// + /// The lambda expression. + /// The entity type. + /// The property type. + /// The previous property type. + public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType, Type previousPropertyType) + : this(expression, entityType, propertyType, previousPropertyType, IncludeType.ThenInclude) + { + ArgumentNullException.ThrowIfNull(previousPropertyType); + } +} diff --git a/src/request.persistence/_expressions/OrderExpressionInfo.cs b/src/request.persistence/_expressions/OrderExpressionInfo.cs new file mode 100644 index 0000000..f4ee708 --- /dev/null +++ b/src/request.persistence/_expressions/OrderExpressionInfo.cs @@ -0,0 +1,45 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about an order expression. +/// +/// The entity type. +public sealed class OrderExpressionInfo +{ + private readonly Lazy> _keySelectorFunc; + + /// + /// Creates a new order expression info. + /// + /// The key selector expression. + /// The order type. + public OrderExpressionInfo(Expression> keySelector, OrderType orderType) + { + ArgumentNullException.ThrowIfNull(keySelector); + + KeySelector = keySelector; + OrderType = orderType; + + _keySelectorFunc = new Lazy>(KeySelector.Compile); + } + + /// + /// Gets the key selector expression. + /// + public Expression> KeySelector { get; } + + /// + /// Gets the order type. + /// + public OrderType OrderType { get; } + + /// + /// Gets the compiled key selector function. + /// + public Func KeySelectorFunc => _keySelectorFunc.Value; +} diff --git a/src/request.persistence/_expressions/SearchExpressionInfo.cs b/src/request.persistence/_expressions/SearchExpressionInfo.cs new file mode 100644 index 0000000..bfc0d9f --- /dev/null +++ b/src/request.persistence/_expressions/SearchExpressionInfo.cs @@ -0,0 +1,53 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about a search expression. +/// +/// The entity type. +public sealed class SearchExpressionInfo +{ + private readonly Lazy> _selectorFunc; + + /// + /// Creates a new search expression info. + /// + /// The selector expression. + /// The search term. + /// The search group. + public SearchExpressionInfo(Expression> selector, string searchTerm, int searchGroup = 1) + { + ArgumentNullException.ThrowIfNull(selector); + ArgumentException.ThrowIfNullOrEmpty(searchTerm); + + Selector = selector; + SearchTerm = searchTerm; + SearchGroup = searchGroup; + + _selectorFunc = new Lazy>(Selector.Compile); + } + + /// + /// Gets the selector expression. + /// + public Expression> Selector { get; } + + /// + /// Gets the search term. + /// + public string SearchTerm { get; } + + /// + /// Gets the search group. + /// + public int SearchGroup { get; } + + /// + /// Gets the compiled selector function. + /// + public Func SelectorFunc => _selectorFunc.Value; +} diff --git a/src/request.persistence/_expressions/WhereExpressionInfo.cs b/src/request.persistence/_expressions/WhereExpressionInfo.cs new file mode 100644 index 0000000..6b3fd7a --- /dev/null +++ b/src/request.persistence/_expressions/WhereExpressionInfo.cs @@ -0,0 +1,38 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about a where expression. +/// +/// The entity type. +public sealed class WhereExpressionInfo +{ + private readonly Lazy> _filterFunc; + + /// + /// Creates a new where expression info. + /// + /// The filter expression. + public WhereExpressionInfo(Expression> filter) + { + ArgumentNullException.ThrowIfNull(filter); + + Filter = filter; + + _filterFunc = new Lazy>(Filter.Compile); + } + + /// + /// Gets the filter expression. + /// + public Expression> Filter { get; } + + /// + /// Gets the compiled filter function. + /// + public Func FilterFunc => _filterFunc.Value; +} diff --git a/src/request.result.tests/ResultEqualityTests.cs b/src/request.result.tests/ResultEqualityTests.cs index 99d7094..67233dd 100644 --- a/src/request.result.tests/ResultEqualityTests.cs +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -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("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("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("error 1"); - var b = Prelude.Failure("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("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("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(); - } } diff --git a/src/request.result/Prelude.cs b/src/request.result/Prelude.cs index e59349d..de48354 100644 --- a/src/request.result/Prelude.cs +++ b/src/request.result/Prelude.cs @@ -9,7 +9,7 @@ namespace Geekeey.Request.Result; /// A class containing various utility methods, a 'prelude' to the rest of the library. /// /// -/// This class is meant to be imported statically, e.g. using static Geekeey.Request.Result.Prelude;. +/// This class is meant to be imported statically, e.g. using static Geekeey.Extensions.Result.Prelude;. /// Recommended to be imported globally via a global using statement. /// public static class Prelude diff --git a/src/request.result/Result.Equality.cs b/src/request.result/Result.Equality.cs index da9372d..0253b88 100644 --- a/src/request.result/Result.Equality.cs +++ b/src/request.result/Result.Equality.cs @@ -9,15 +9,7 @@ namespace Geekeey.Request.Result; public partial class Result : IEquatable { - /// - /// Checks whether two results are equal. Results are equal if both are success or both are failure. - /// - /// - /// Two failures are considered equal regardless of their content. Equality therefore - /// ignores the error; if you need to branch on a specific failure, compare the values - /// directly instead of relying on equality. - /// - /// The result to check for equality with the current result. + /// [Pure] public bool Equals(Result? other) { @@ -57,8 +49,7 @@ public partial class Result : IEquatable public partial class Result : IEqualityOperators { /// - /// 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. /// /// The first result to compare. /// The second result to compare. @@ -88,11 +79,6 @@ public partial class Result : IEquatable>, IEquatable /// 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. /// - /// - /// Two failures are considered equal regardless of their content. Equality therefore - /// ignores the error; if you need to branch on a specific failure, compare the values - /// directly instead of relying on equality. - /// /// The result to check for equality with the current result. [Pure] public bool Equals(Result? other) @@ -195,12 +181,7 @@ public partial class Result : IEquatable>, IEquatable return comparer.GetHashCode(result.Value); } - if (result is { IsSuccess: true, Value: null }) - { - return 0; - } - - return result.Error?.GetHashCode() ?? 0; + return 0; } } @@ -208,7 +189,7 @@ public partial class Result : IEqualityOperators, Result, bool>, { /// /// 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. /// /// The first result to compare. /// The second result to compare. diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs index c0d2cae..5b5271a 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -314,71 +314,6 @@ internal sealed class RuleBuilderExtensionsTests .Throws(); } - [Test] - public async Task I_can_validate_greater_than_with_null_bound_without_throwing() - { - var validator = new PropertyValidator(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(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(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(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(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() { diff --git a/src/request.validation.tests/ValidationSeverityTests.cs b/src/request.validation.tests/ValidationSeverityTests.cs deleted file mode 100644 index 6f24c8d..0000000 --- a/src/request.validation.tests/ValidationSeverityTests.cs +++ /dev/null @@ -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 - => 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 - => 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 - => 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(); - } -} diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index 820edc4..14239f7 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -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 - => 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() { diff --git a/src/request.validation.tests/_fixtures/ComparableModel.cs b/src/request.validation.tests/_fixtures/ComparableModel.cs deleted file mode 100644 index 9178228..0000000 --- a/src/request.validation.tests/_fixtures/ComparableModel.cs +++ /dev/null @@ -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 -{ - public int Number { get; init; } - - public int CompareTo(ComparableValue? other) - { - return Number.CompareTo(other!.Number); - } -} diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs index a6a397c..0c65cc0 100644 --- a/src/request.validation/Rule.cs +++ b/src/request.validation/Rule.cs @@ -46,7 +46,7 @@ internal abstract record Rule : Rule if (context.Instance is null && default(T) is null) { - return []; + return Validate((T)context.Instance!, context); } var actualType = context.Instance?.GetType().FullName ?? "null"; diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs index 7e1a5db..528b387 100644 --- a/src/request.validation/RuleBuilderExtensions.cs +++ b/src/request.validation/RuleBuilderExtensions.cs @@ -170,7 +170,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) > 0, + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0, $"Value must be greater than {comparisonValue}."); } @@ -189,7 +189,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) >= 0, + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0, $"Value must be greater than or equal to {comparisonValue}."); } @@ -208,7 +208,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) < 0, + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0, $"Value must be less than {comparisonValue}."); } @@ -227,7 +227,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0, + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0, $"Value must be less than or equal to {comparisonValue}."); } @@ -254,9 +254,7 @@ public static class RuleBuilderExtensions "Maximum value must be greater than or equal to minimum value."); } - return rule.Must(value => IsNull(value) - || ((minValue is null || value.CompareTo(minValue) >= 0) - && (maxValue is null || value.CompareTo(maxValue) <= 0)), + return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0), $"Value must be between {minValue} and {maxValue}."); } diff --git a/src/request.validation/Validation.cs b/src/request.validation/Validation.cs index 8e9c7c7..fadadff 100644 --- a/src/request.validation/Validation.cs +++ b/src/request.validation/Validation.cs @@ -23,27 +23,7 @@ public sealed class Validation /// /// Whether the validation was successful. /// - /// - /// A validation is considered successful unless it contains at least one problem with - /// . Problems of lower severity ( or - /// ) do not invalidate the result. Use - /// to control which severities are treated as failures. - /// - public bool IsValid => IsValidFor(Severity.Error); - - /// - /// Whether the validation was successful, treating problems at or above the given severity as failures. - /// - /// The minimum severity that should be considered a failure. - /// - /// A problem invalidates the validation when its is at least as severe as - /// . For example, IsValidFor(Severity.Warning) fails on errors and - /// warnings but not on informational problems. - /// - public bool IsValidFor(Severity minimum) - { - return !Problems.Any(problem => problem.Severity <= minimum); - } + public bool IsValid => Problems.Count is 0; /// /// The problems that were found during validation.