diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml index 51c054c..2796b83 100644 --- a/.forgejo/workflows/default.yml +++ b/.forgejo/workflows/default.yml @@ -1,6 +1,7 @@ name: default on: + workflow_dispatch: push: branches: [ "main", "develop" ] paths-ignore: diff --git a/CHANGELOG.md b/CHANGELOG.md index 240c251..a7052a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,19 @@ 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 799bb8a..235fddd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,8 +12,5 @@ - - - diff --git a/request.slnx b/request.slnx index 65e276c..cebc37c 100644 --- a/request.slnx +++ b/request.slnx @@ -5,8 +5,4 @@ - - - - diff --git a/src/request.dispatcher.tests/ScalarBehaviourTests.cs b/src/request.dispatcher.tests/ScalarBehaviourTests.cs index 9c10e4b..d2a6dd6 100644 --- a/src/request.dispatcher.tests/ScalarBehaviourTests.cs +++ b/src/request.dispatcher.tests/ScalarBehaviourTests.cs @@ -87,6 +87,26 @@ internal sealed class ScalarBehaviourTests await Assert.That(tracker.Executed).IsTrue(); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + 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 5c10998..f274da9 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_dispatch_a_request_async_with_an_interface_constrained_handler() + public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers() { var sc = new ServiceCollection(); sc.AddRequestDispatcher(builder => builder @@ -126,13 +126,18 @@ internal sealed class ScalarDispatcherTests var dispatcher = provider.GetRequiredService(); var request = new InterfaceInheritedScalarRequest { Name = "Constrained" }; - var result = await dispatcher.DispatchAsync(request); // Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match. // InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest. // InterfaceConstrainedScalarHandler is an open generic match. - // Currently Dispatcher.SendAsync checks concrete handlers first. - await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled"); + // Ambiguity is no longer resolved silently with .First(); it throws instead. + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler)); + await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler"); + } } [Test] @@ -234,6 +239,49 @@ internal sealed class ScalarDispatcherTests } } + [Test] + public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers() + { + var sc = new ServiceCollection(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(DuplicateScalarHandlerA)) + .Add(typeof(DuplicateScalarHandlerB))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + 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 c96b530..7e9377f 100644 --- a/src/request.dispatcher.tests/StreamBehaviourTests.cs +++ b/src/request.dispatcher.tests/StreamBehaviourTests.cs @@ -66,6 +66,26 @@ internal sealed class StreamBehaviourTests await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2"); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + 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 c06cb36..5fcc7b2 100644 --- a/src/request.dispatcher.tests/StreamDispatcherTests.cs +++ b/src/request.dispatcher.tests/StreamDispatcherTests.cs @@ -7,6 +7,26 @@ namespace Geekeey.Request.Dispatcher.Tests; public class StreamDispatcherTests { + [Test] + public async Task I_can_see_it_throw_on_ambiguous_stream_handlers() + { + var sc = new ServiceCollection(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(DuplicateStreamHandlerA)) + .Add(typeof(DuplicateStreamHandlerB))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + 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() { @@ -121,13 +141,18 @@ 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. - // Currently Dispatcher checks concrete handlers first. - await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]); + // Ambiguity is no longer resolved silently with .First(); it throws instead. + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler)); + await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler"); + } } [Test] diff --git a/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs new file mode 100644 index 0000000..6a0504e --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public sealed class DuplicateScalarRequest : IScalarRequest +{ +} + +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 new file mode 100644 index 0000000..d3f0414 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public sealed class DuplicateStreamRequest : IStreamRequest +{ +} + +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 new file mode 100644 index 0000000..1eab048 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + 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 new file mode 100644 index 0000000..0439f21 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs @@ -0,0 +1,28 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior +{ + 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 8d53e3b..c2b6976 100644 --- a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs +++ b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs @@ -70,6 +70,27 @@ 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 d18def9..f7f8c68 100644 --- a/src/request.dispatcher/RequestDispatcherOptions.cs +++ b/src/request.dispatcher/RequestDispatcherOptions.cs @@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher; internal sealed class RequestDispatcherOptions { - private readonly List _search = []; + private readonly List<(Type Type, int? Order)> _search = []; private readonly Lazy _behaviorsTypeIndex; private readonly Lazy _handlersTypeIndex; public RequestDispatcherOptions() { - _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.Distinct())); - _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.Distinct())); + _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type))); + _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type))); } - public void Inspect(IEnumerable assembly) + public void Inspect(IEnumerable assembly, int? order = null) { if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated) { throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); } - _search.AddRange(assembly); + foreach (var type in assembly) + { + _search.Add((type, order)); + } } public IEnumerable GetRequestBehaviors(IServiceProvider services) @@ -46,11 +49,22 @@ internal sealed class RequestDispatcherOptions protected readonly Dictionary> _closedTypeInfo = []; protected readonly List _openTypeInfo = []; + protected readonly Dictionary _order = []; + protected readonly Dictionary _explicitOrder = []; - protected TypeIndex(IEnumerable collection, Func predicate) + protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func predicate) { - foreach (var type in collection) + var index = 0; + + foreach (var (type, order) in collection) { + _order[type] = index++; + + if (order is { } explicitOrder) + { + _explicitOrder[type] = explicitOrder; + } + if (type.IsGenericTypeDefinition) { if (type.GetInterfaces().Any(predicate)) @@ -68,6 +82,11 @@ internal sealed class RequestDispatcherOptions } } + protected int EffectiveOrder(Type type) + { + return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type]; + } + public IEnumerable Resolve(IServiceProvider services) { return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); @@ -97,16 +116,19 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>)); } - private sealed class HandlerTypeIndex(IEnumerable collection) + private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestHandlerType) { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List(); + var result = new List<(int Order, Type Type)>(); if (_closedTypeInfo.TryGetValue(@interface, out var list)) { - result.AddRange(list); + foreach (var type in list) + { + result.Add((EffectiveOrder(type), type)); + } } var requestType = @interface.GetGenericArguments()[0]; @@ -121,7 +143,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((EffectiveOrder(type), impl)); } } catch (ArgumentException) @@ -137,7 +159,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((EffectiveOrder(type), impl)); } } } @@ -146,7 +168,7 @@ internal sealed class RequestDispatcherOptions } } - return result; + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } @@ -157,16 +179,19 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>)); } - private sealed class BehaviorTypeIndex(IEnumerable collection) + private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestBehaviorType) { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List(); + var result = new List<(int Order, Type Type)>(); if (_closedTypeInfo.TryGetValue(@interface, out var list)) { - result.AddRange(list); + foreach (var type in list) + { + result.Add((EffectiveOrder(type), type)); + } } var requestType = @interface.GetGenericArguments()[0]; @@ -180,7 +205,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType, responseType); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -193,7 +218,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -201,7 +226,7 @@ internal sealed class RequestDispatcherOptions } } - return result; + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } } diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs index 63de03e..2c6d129 100644 --- a/src/request.dispatcher/ScalarRequestInvoker.cs +++ b/src/request.dispatcher/ScalarRequestInvoker.cs @@ -43,7 +43,20 @@ internal sealed class ScalarRequestInvoker : ScalarRequestI Task Head(IScalarRequest r, CancellationToken ct) { - return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, 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); } } } diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs index 882d538..817b5b2 100644 --- a/src/request.dispatcher/StreamRequestInvoker.cs +++ b/src/request.dispatcher/StreamRequestInvoker.cs @@ -47,7 +47,20 @@ internal sealed class StreamRequestInvoker : StreamRequestI IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) { - return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, 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); } } } diff --git a/src/request.persistence.entityframeworkcore.tests/.editorconfig b/src/request.persistence.entityframeworkcore.tests/.editorconfig deleted file mode 100644 index 2300467..0000000 --- a/src/request.persistence.entityframeworkcore.tests/.editorconfig +++ /dev/null @@ -1,8 +0,0 @@ - -[*.{cs,vb}] -dotnet_diagnostic.CA1822.severity = none -dotnet_diagnostic.CA1707.severity = none -dotnet_diagnostic.IDE0060.severity = none -dotnet_diagnostic.IDE0005.severity = none -dotnet_diagnostic.IDE0390.severity = none -dotnet_diagnostic.IDE0391.severity = none diff --git a/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs b/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs deleted file mode 100644 index 97f8e11..0000000 --- a/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs +++ /dev/null @@ -1,507 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using Microsoft.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class EvaluationTests -{ - private static async Task 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 deleted file mode 100644 index 90df1c0..0000000 --- a/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - false - - - - - - - - - - - - - - - - diff --git a/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs b/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs deleted file mode 100644 index e10d098..0000000 --- a/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Linq.Expressions; - -using Microsoft.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class IncludeEvaluatorTests -{ - private static async Task 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 deleted file mode 100644 index 481b4bf..0000000 --- a/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using Microsoft.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class RepositoryTests -{ - private static async Task 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 deleted file mode 100644 index b51af0a..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class CombinedWhereOrderTakeSpec : Specification -{ - 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 deleted file mode 100644 index 160a5ef..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class ConcurrentSelectorSpec : Specification -{ - 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 deleted file mode 100644 index dad2040..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class DuplicateOrderSpec : Specification -{ - 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 deleted file mode 100644 index 24a8e02..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeEntityId { get; set; } - public FakeEntity FakeEntity { get; set; } = null!; - public List GrandChildren { get; set; } = []; -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs deleted file mode 100644 index c06fcb9..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using Microsoft.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeDbContext(DbContextOptions 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 deleted file mode 100644 index 7226cdc..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeDetailChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeDetailEntityId { get; set; } - public FakeDetailEntity FakeDetailEntity { get; set; } = null!; -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs deleted file mode 100644 index 652c605..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeDetailEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeEntityId { get; set; } - public FakeEntity FakeEntity { get; set; } = null!; - public List DetailChildren { get; set; } = []; -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs deleted file mode 100644 index 40feea0..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeEntity -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public FakeChildEntity Child { get; set; } = null!; - public List Details { get; set; } = []; -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs deleted file mode 100644 index c5bba1c..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FakeGrandChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeChildEntityId { get; set; } - public FakeChildEntity FakeChildEntity { get; set; } = null!; -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs deleted file mode 100644 index 9d8cf10..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class FirstOrderedSpec : Specification -{ - 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 deleted file mode 100644 index f918b06..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class IncludeChildSpec : Specification -{ - 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 deleted file mode 100644 index cd02049..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class IncludeChildThenGrandChildrenSpec : Specification -{ - 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 deleted file mode 100644 index 2587e48..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class IncludeDetailsSpec : Specification -{ - 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 deleted file mode 100644 index 5075d2b..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class IncludeOnlySpec : Specification -{ - 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 deleted file mode 100644 index 6419704..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class MultiSearchSpec : Specification -{ - 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 deleted file mode 100644 index d20f0a5..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class MultipleWhereSpec : Specification -{ - 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 deleted file mode 100644 index 76e8765..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class NameEqualSpec : Specification -{ - 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 deleted file mode 100644 index 6baa0ae..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class NameProjectionSpec : Specification -{ - 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 deleted file mode 100644 index 2860d86..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class NoSelectorSpec : Specification -{ -} diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs deleted file mode 100644 index fa8b4dc..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderByNameDescSpec : Specification -{ - 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 deleted file mode 100644 index be79f3b..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderByNameSpec : Specification -{ - 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 deleted file mode 100644 index a808164..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderByNameThenByIdDescSpec : Specification -{ - 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 deleted file mode 100644 index 1dab453..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderByNameThenByIdSpec : Specification -{ - 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 deleted file mode 100644 index 1969144..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderedSkipSpec : Specification -{ - 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 deleted file mode 100644 index 49aca1c..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderedSkipTakeSpec : Specification -{ - 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 deleted file mode 100644 index 387d640..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class OrderedTakeSpec : Specification -{ - 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 deleted file mode 100644 index fe189e2..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class PostProcessingSpec : Specification -{ - 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 deleted file mode 100644 index 99fc073..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class SearchSpec : Specification -{ - 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 deleted file mode 100644 index b762bff..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class SkipOnlySpec : Specification -{ - 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 deleted file mode 100644 index 235dc1e..0000000 --- a/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests; - -internal sealed class WhereSpec : Specification -{ - 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 deleted file mode 100644 index 120069c..0000000 --- a/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - 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 deleted file mode 100644 index 0d4767e..0000000 --- a/src/request.persistence.entityframeworkcore/Repository.cs +++ /dev/null @@ -1,174 +0,0 @@ -// 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 deleted file mode 100644 index cecdf32..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class SpecificationEvaluator -{ - private readonly IReadOnlyList _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 deleted file mode 100644 index 39d230a..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index 12cfef7..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Collections.Concurrent; -using System.Linq.Expressions; -using System.Reflection; - -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Query; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class IncludeEvaluator : IEvaluator -{ - private static readonly MethodInfo IncludeMethodInfo - = typeof(EntityFrameworkQueryableExtensions) - .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.Include)) - .Single(static mi => - mi.GetGenericArguments().Length is 2 && - mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) && - mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); - - private static readonly MethodInfo ThenIncludeAfterReferenceMethodInfo - = typeof(EntityFrameworkQueryableExtensions) - .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude)) - .Where(static mi => mi.GetGenericArguments().Length is 3) - .Single(static mi => - mi.GetParameters()[0].ParameterType.GenericTypeArguments[1].IsGenericParameter && - mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) && - mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); - - private static readonly MethodInfo ThenIncludeAfterEnumerableMethodInfo - = typeof(EntityFrameworkQueryableExtensions) - .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude)) - .Where(static mi => mi.GetGenericArguments().Length is 3) - .Single(static mi => - { - var typeInfo = mi.GetParameters()[0].ParameterType.GenericTypeArguments[1]; - - return typeInfo.IsGenericType && - typeInfo.GetGenericTypeDefinition() == typeof(IEnumerable<>) && - mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) && - mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>); - }); - - private static readonly ConcurrentDictionary<(Type EntityType, Type PropertyType, Type? PreviousPropertyType), Lazy>> 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 deleted file mode 100644 index f7a3e87..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using Geekeey.Request.Persistence.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class OrderEvaluator : IEvaluator -{ - private OrderEvaluator() { } - - internal static OrderEvaluator Instance { get; } = new(); - - public bool IsCriteriaEvaluator { get; } - - public IQueryable 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 deleted file mode 100644 index 489bcae..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class PaginationEvaluator : IEvaluator -{ - private PaginationEvaluator() { } - - public static PaginationEvaluator Instance { get; } = new(); - - public bool IsCriteriaEvaluator { get; } - - public IQueryable 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 deleted file mode 100644 index ef019c4..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Linq.Expressions; -using System.Reflection; - -using Microsoft.EntityFrameworkCore; - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class SearchEvaluator : IEvaluator -{ - private static readonly MemberExpression Functions - = Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions)) ?? throw new TargetException("The EF.Functions not found!")); - - private static readonly MethodInfo LikeMethodInfo - = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)]) - ?? throw new TargetException("The EF.Functions.Like not found"); - - private SearchEvaluator() { } - - public static SearchEvaluator Instance { get; } = new(); - - public bool IsCriteriaEvaluator { get; } = true; - - public IQueryable 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 deleted file mode 100644 index d876427..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.EntityFrameworkCore; - -internal sealed class WhereEvaluator : IEvaluator -{ - private WhereEvaluator() { } - - internal static WhereEvaluator Instance { get; } = new(); - - public bool IsCriteriaEvaluator { get; } = true; - - public IQueryable 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 deleted file mode 100644 index e18b8dd..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 deleted file mode 100644 index 7167e1c..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 deleted file mode 100644 index 3394db6..0000000 --- a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 deleted file mode 100644 index 2300467..0000000 --- a/src/request.persistence.tests/.editorconfig +++ /dev/null @@ -1,8 +0,0 @@ - -[*.{cs,vb}] -dotnet_diagnostic.CA1822.severity = none -dotnet_diagnostic.CA1707.severity = none -dotnet_diagnostic.IDE0060.severity = none -dotnet_diagnostic.IDE0005.severity = none -dotnet_diagnostic.IDE0390.severity = none -dotnet_diagnostic.IDE0391.severity = none diff --git a/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj b/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj deleted file mode 100644 index 633378d..0000000 --- a/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net10.0 - false - - - - - - - - - - - - - - - diff --git a/src/request.persistence.tests/SpecificationBuilderTests.cs b/src/request.persistence.tests/SpecificationBuilderTests.cs deleted file mode 100644 index 1addaff..0000000 --- a/src/request.persistence.tests/SpecificationBuilderTests.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class SpecificationBuilderTests -{ - [Test] - public async Task Where_adds_filter() - { - var builder = new SpecificationBuilder(); - 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 deleted file mode 100644 index 744d7ee..0000000 --- a/src/request.persistence.tests/_fixtures/FakeChildEntity.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeEntityId { get; set; } - public FakeEntity FakeEntity { get; set; } = null!; - public List GrandChildren { get; set; } = []; -} diff --git a/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs deleted file mode 100644 index c34377a..0000000 --- a/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeDetailChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeDetailEntityId { get; set; } - public FakeDetailEntity FakeDetailEntity { get; set; } = null!; -} diff --git a/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs deleted file mode 100644 index 2e76b36..0000000 --- a/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeDetailEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeEntityId { get; set; } - public FakeEntity FakeEntity { get; set; } = null!; - public List DetailChildren { get; set; } = []; -} diff --git a/src/request.persistence.tests/_fixtures/FakeEntity.cs b/src/request.persistence.tests/_fixtures/FakeEntity.cs deleted file mode 100644 index 7f5f1a7..0000000 --- a/src/request.persistence.tests/_fixtures/FakeEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeEntity -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public FakeChildEntity Child { get; set; } = null!; - public List Details { get; set; } = []; -} diff --git a/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs deleted file mode 100644 index 18ed480..0000000 --- a/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeGrandChildEntity -{ - public Guid Id { get; set; } - public string Value { get; set; } = string.Empty; - public Guid FakeChildEntityId { get; set; } - public FakeChildEntity FakeChildEntity { get; set; } = null!; -} diff --git a/src/request.persistence.tests/_fixtures/FakeSpecification.cs b/src/request.persistence.tests/_fixtures/FakeSpecification.cs deleted file mode 100644 index 05d2219..0000000 --- a/src/request.persistence.tests/_fixtures/FakeSpecification.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeSpecification : Specification -{ - 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 deleted file mode 100644 index 3512f5a..0000000 --- a/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class FakeSpecificationWithResult : Specification -{ - 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 deleted file mode 100644 index 57b6c55..0000000 --- a/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.Request.Persistence.Tests; - -internal sealed class SpecificationTestBuilder : Specification -{ -} diff --git a/src/request.persistence/Geekeey.Request.Persistence.csproj b/src/request.persistence/Geekeey.Request.Persistence.csproj deleted file mode 100644 index 55ba98c..0000000 --- a/src/request.persistence/Geekeey.Request.Persistence.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - 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 deleted file mode 100644 index 391592e..0000000 --- a/src/request.persistence/IRepository.cs +++ /dev/null @@ -1,155 +0,0 @@ -// 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 deleted file mode 100644 index 2a1038d..0000000 --- a/src/request.persistence/ISpecification.cs +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index 259ee1b..0000000 --- a/src/request.persistence/IncludeType.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index 50ac213..0000000 --- a/src/request.persistence/OrderType.cs +++ /dev/null @@ -1,30 +0,0 @@ -// 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 deleted file mode 100644 index 6017703..0000000 --- a/src/request.persistence/Specification.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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 deleted file mode 100644 index b348fe5..0000000 --- a/src/request.persistence/_builders/IncludableBuilderExtensions.cs +++ /dev/null @@ -1,101 +0,0 @@ -// 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 deleted file mode 100644 index 6a652fa..0000000 --- a/src/request.persistence/_builders/IncludableSpecificationBuilder.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 deleted file mode 100644 index 2f35721..0000000 --- a/src/request.persistence/_builders/OrderedBuilderExtensions.cs +++ /dev/null @@ -1,80 +0,0 @@ -// 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 deleted file mode 100644 index f24107c..0000000 --- a/src/request.persistence/_builders/OrderedSpecificationBuilder.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 deleted file mode 100644 index a1305c7..0000000 --- a/src/request.persistence/_builders/SpecificationBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -// 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 deleted file mode 100644 index 372be89..0000000 --- a/src/request.persistence/_builders/SpecificationBuilderExtensions.cs +++ /dev/null @@ -1,305 +0,0 @@ -// 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 deleted file mode 100644 index 6984ca9..0000000 --- a/src/request.persistence/_exceptions/DuplicateSkipException.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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 deleted file mode 100644 index 21e8b39..0000000 --- a/src/request.persistence/_exceptions/DuplicateTakeException.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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 deleted file mode 100644 index 903aded..0000000 --- a/src/request.persistence/_expressions/IncludeExpressionInfo.cs +++ /dev/null @@ -1,74 +0,0 @@ -// 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 deleted file mode 100644 index f4ee708..0000000 --- a/src/request.persistence/_expressions/OrderExpressionInfo.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100644 index bfc0d9f..0000000 --- a/src/request.persistence/_expressions/SearchExpressionInfo.cs +++ /dev/null @@ -1,53 +0,0 @@ -// 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 deleted file mode 100644 index 6b3fd7a..0000000 --- a/src/request.persistence/_expressions/WhereExpressionInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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 67233dd..99d7094 100644 --- a/src/request.result.tests/ResultEqualityTests.cs +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -166,11 +166,48 @@ internal sealed class ResultEqualityTests } [Test] - public async Task I_can_get_hashcode_and_get_zero_for_failure() + public async Task I_can_get_hashcode_and_get_non_zero_for_failure() { var result = Prelude.Failure("error"); - await Assert.That(result.GetHashCode()).IsZero(); + await Assert.That(result.GetHashCode()).IsNotEqualTo(0); + } + + [Test] + public async Task I_can_get_hashcode_and_not_collide_non_generic_success_and_failure() + { + await Assert.That(Prelude.Success().GetHashCode()) + .IsNotEqualTo(Prelude.Failure("error").GetHashCode()); + } + + [Test] + public async Task I_can_get_hashcode_and_not_collide_success_value_zero_with_failure() + { + var success = Prelude.Success(0); + var failure = Prelude.Failure("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); } [Test] @@ -199,4 +236,53 @@ internal sealed class ResultEqualityTests await Assert.That(a.Equals(b)).IsFalse(); } + + [Test] + public async Task I_can_use_equality_operator_and_get_false_for_success_and_failure() + { + var success = Prelude.Success(2); + var failure = Prelude.Failure("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 de48354..e59349d 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.Extensions.Result.Prelude;. +/// This class is meant to be imported statically, e.g. using static Geekeey.Request.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 0253b88..da9372d 100644 --- a/src/request.result/Result.Equality.cs +++ b/src/request.result/Result.Equality.cs @@ -9,7 +9,15 @@ 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) { @@ -49,7 +57,8 @@ 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. + /// 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. /// /// The first result to compare. /// The second result to compare. @@ -79,6 +88,11 @@ 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) @@ -181,7 +195,12 @@ public partial class Result : IEquatable>, IEquatable return comparer.GetHashCode(result.Value); } - return 0; + if (result is { IsSuccess: true, Value: null }) + { + return 0; + } + + return result.Error?.GetHashCode() ?? 0; } } @@ -189,7 +208,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. + /// values are equal, or if both results are failures. Two failures are equal regardless of their error content. /// /// 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 5b5271a..c0d2cae 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -314,6 +314,71 @@ 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 new file mode 100644 index 0000000..6f24c8d --- /dev/null +++ b/src/request.validation.tests/ValidationSeverityTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ValidationSeverityTests +{ + [Test] + public async Task I_can_have_warning_problems_not_invalidate_by_default() + { + var validator = new PropertyValidator(person + => 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 14239f7..820edc4 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -14,7 +14,8 @@ internal sealed class ValidatorTests var result = validator.Validate(new Person { Name = "" }); - await Assert.That(result.IsValid).IsFalse(); + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse(); await Assert.That(result.Problems).Count().IsEqualTo(1); var problem = result.Problems.Single(); @@ -39,12 +40,24 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(invalid.IsValid).IsFalse(); + await Assert.That(invalid.IsValid).IsTrue(); + await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse(); await Assert.That(valid.IsValid).IsTrue(); await Assert.That(valid.Problems).IsEmpty(); } } + [Test] + public async Task I_can_validate_with_a_null_instance_without_throwing() + { + var validator = new PropertyValidator(person + => 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 new file mode 100644 index 0000000..9178228 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ComparableModel.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ComparableModel +{ + public ComparableValue? Value { get; init; } +} + +internal sealed class ComparableValue : IComparable +{ + 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 0c65cc0..a6a397c 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 Validate((T)context.Instance!, context); + return []; } var actualType = context.Instance?.GetType().FullName ?? "null"; diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs index 528b387..7e1a5db 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) || value.CompareTo(comparisonValue) > 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || 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) || value.CompareTo(comparisonValue) >= 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || 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) || value.CompareTo(comparisonValue) < 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || 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) || value.CompareTo(comparisonValue) <= 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0, $"Value must be less than or equal to {comparisonValue}."); } @@ -254,7 +254,9 @@ public static class RuleBuilderExtensions "Maximum value must be greater than or equal to minimum value."); } - return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0), + return rule.Must(value => IsNull(value) + || ((minValue is null || value.CompareTo(minValue) >= 0) + && (maxValue is null || 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 fadadff..8e9c7c7 100644 --- a/src/request.validation/Validation.cs +++ b/src/request.validation/Validation.cs @@ -23,7 +23,27 @@ public sealed class Validation /// /// Whether the validation was successful. /// - public bool IsValid => Problems.Count is 0; + /// + /// 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); + } /// /// The problems that were found during validation.