Compare commits
23 changed files with 44 additions and 613 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -45,19 +45,6 @@ To have a consistent experience across all packages, some public interfaces have
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **request.result:** Document that `Result`/`Result<T>` equality treats any two failures as equal, ignoring error content; branch on the `Error` directly for failure-specific logic (see PLAN.md Inconsistencies#E)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`)
|
|
||||||
- **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0`
|
|
||||||
- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`)
|
|
||||||
- **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`)
|
|
||||||
- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold (see PLAN.md Inconsistencies#B)
|
|
||||||
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()` (matches MediatR). Also throws when no handler is registered (see PLAN.md Inconsistencies#C)
|
|
||||||
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order (see PLAN.md Inconsistencies#D)
|
|
||||||
- **request.dispatcher:** Add `AddBehavior<T>(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order (see PLAN.md Tips — Explicit pipeline ordering API)
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
|
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
|
||||||
|
|
|
||||||
|
|
@ -87,26 +87,6 @@ internal sealed class ScalarBehaviourTests
|
||||||
await Assert.That(tracker.Executed).IsTrue();
|
await Assert.That(tracker.Executed).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
|
||||||
{
|
|
||||||
var sc = new ServiceCollection();
|
|
||||||
sc.AddSingleton<ScalarTestTracker>();
|
|
||||||
sc.AddRequestDispatcher(builder => builder
|
|
||||||
.Add(typeof(ScalarTestHandler))
|
|
||||||
.AddBehavior<OrderedScalarBehaviorA>(2)
|
|
||||||
.AddBehavior<OrderedScalarBehaviorB>(1));
|
|
||||||
var provider = sc.BuildServiceProvider();
|
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
|
||||||
var tracker = provider.GetRequiredService<ScalarTestTracker>();
|
|
||||||
|
|
||||||
var request = new ScalarTestRequest { Value = "Hello" };
|
|
||||||
await dispatcher.DispatchAsync(request);
|
|
||||||
|
|
||||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
|
||||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
|
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers()
|
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
|
||||||
{
|
{
|
||||||
var sc = new ServiceCollection();
|
var sc = new ServiceCollection();
|
||||||
sc.AddRequestDispatcher(builder => builder
|
sc.AddRequestDispatcher(builder => builder
|
||||||
|
|
@ -126,18 +126,13 @@ internal sealed class ScalarDispatcherTests
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||||
|
|
||||||
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
||||||
|
var result = await dispatcher.DispatchAsync(request);
|
||||||
|
|
||||||
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
|
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
|
||||||
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
|
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
|
||||||
// InterfaceConstrainedScalarHandler is an open generic match.
|
// InterfaceConstrainedScalarHandler is an open generic match.
|
||||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
// Currently Dispatcher.SendAsync checks concrete handlers first.
|
||||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
|
||||||
|
|
||||||
using (Assert.Multiple())
|
|
||||||
{
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler));
|
|
||||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -239,49 +234,6 @@ internal sealed class ScalarDispatcherTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers()
|
|
||||||
{
|
|
||||||
var sc = new ServiceCollection();
|
|
||||||
sc.AddRequestDispatcher(builder => builder
|
|
||||||
.Add(typeof(DuplicateScalarHandlerA))
|
|
||||||
.Add(typeof(DuplicateScalarHandlerB)));
|
|
||||||
var provider = sc.BuildServiceProvider();
|
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
|
||||||
|
|
||||||
var request = new DuplicateScalarRequest();
|
|
||||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
|
||||||
|
|
||||||
using (Assert.Multiple())
|
|
||||||
{
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerA));
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerB));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_see_candidates_ordered_by_registration_not_closed_first()
|
|
||||||
{
|
|
||||||
var sc = new ServiceCollection();
|
|
||||||
sc.AddRequestDispatcher(builder => builder
|
|
||||||
.Add(typeof(InterfaceConstrainedScalarHandler<>))
|
|
||||||
.Add(typeof(InterfaceInheritedScalarHandler)));
|
|
||||||
var provider = sc.BuildServiceProvider();
|
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
|
||||||
|
|
||||||
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
|
|
||||||
|
|
||||||
// The open generic handler is registered before the closed handler, so candidates must be
|
|
||||||
// listed in registration order (open first), not the old fixed closed-before-open order.
|
|
||||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
|
|
||||||
|
|
||||||
var message = ex?.Message ?? string.Empty;
|
|
||||||
var openIndex = message.IndexOf("InterfaceConstrainedScalarHandler", StringComparison.Ordinal);
|
|
||||||
var closedIndex = message.IndexOf("InterfaceInheritedScalarHandler", StringComparison.Ordinal);
|
|
||||||
|
|
||||||
await Assert.That(openIndex >= 0 && closedIndex >= 0 && openIndex < closedIndex).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
|
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -66,26 +66,6 @@ internal sealed class StreamBehaviourTests
|
||||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
|
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
|
||||||
{
|
|
||||||
var sc = new ServiceCollection();
|
|
||||||
sc.AddSingleton<StreamTestTracker>();
|
|
||||||
sc.AddRequestDispatcher(builder => builder
|
|
||||||
.Add(typeof(StreamTestHandler))
|
|
||||||
.AddBehavior<OrderedStreamBehaviorA>(2)
|
|
||||||
.AddBehavior<OrderedStreamBehaviorB>(1));
|
|
||||||
var provider = sc.BuildServiceProvider();
|
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
|
||||||
var tracker = provider.GetRequiredService<StreamTestTracker>();
|
|
||||||
|
|
||||||
var request = new StreamTestRequest { Value = "Hello" };
|
|
||||||
await dispatcher.DispatchAsync(request).ToListAsync();
|
|
||||||
|
|
||||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
|
||||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
|
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,6 @@ namespace Geekeey.Request.Dispatcher.Tests;
|
||||||
|
|
||||||
public class StreamDispatcherTests
|
public class StreamDispatcherTests
|
||||||
{
|
{
|
||||||
[Test]
|
|
||||||
public async Task I_can_see_it_throw_on_ambiguous_stream_handlers()
|
|
||||||
{
|
|
||||||
var sc = new ServiceCollection();
|
|
||||||
sc.AddRequestDispatcher(builder => builder
|
|
||||||
.Add(typeof(DuplicateStreamHandlerA))
|
|
||||||
.Add(typeof(DuplicateStreamHandlerB)));
|
|
||||||
var provider = sc.BuildServiceProvider();
|
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
|
||||||
|
|
||||||
var request = new DuplicateStreamRequest();
|
|
||||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
|
|
||||||
|
|
||||||
using (Assert.Multiple())
|
|
||||||
{
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerA));
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerB));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
|
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
|
||||||
{
|
{
|
||||||
|
|
@ -141,18 +121,13 @@ public class StreamDispatcherTests
|
||||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||||
|
|
||||||
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
|
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
|
||||||
|
var results = await dispatcher.DispatchAsync(request).ToListAsync();
|
||||||
|
|
||||||
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
|
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
|
||||||
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
|
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
|
||||||
// InterfaceConstrainedStreamHandler is an open generic match.
|
// InterfaceConstrainedStreamHandler is an open generic match.
|
||||||
// Ambiguity is no longer resolved silently with .First(); it throws instead.
|
// Currently Dispatcher checks concrete handlers first.
|
||||||
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
|
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
|
||||||
|
|
||||||
using (Assert.Multiple())
|
|
||||||
{
|
|
||||||
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler));
|
|
||||||
await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Dispatcher.Tests;
|
|
||||||
|
|
||||||
public sealed class DuplicateScalarRequest : IScalarRequest<string>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DuplicateScalarHandlerA : IScalarRequestHandler<DuplicateScalarRequest, string>
|
|
||||||
{
|
|
||||||
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult("A");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DuplicateScalarHandlerB : IScalarRequestHandler<DuplicateScalarRequest, string>
|
|
||||||
{
|
|
||||||
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult("B");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Dispatcher.Tests;
|
|
||||||
|
|
||||||
public sealed class DuplicateStreamRequest : IStreamRequest<string>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DuplicateStreamHandlerA : IStreamRequestHandler<DuplicateStreamRequest, string>
|
|
||||||
{
|
|
||||||
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
yield return "A";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DuplicateStreamHandlerB : IStreamRequestHandler<DuplicateStreamRequest, string>
|
|
||||||
{
|
|
||||||
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
yield return "B";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Dispatcher.Tests;
|
|
||||||
|
|
||||||
public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
|
||||||
{
|
|
||||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
tracker.Log.Add("OrderedA");
|
|
||||||
return await next(request, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
|
||||||
{
|
|
||||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
tracker.Log.Add("OrderedB");
|
|
||||||
return await next(request, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Dispatcher.Tests;
|
|
||||||
|
|
||||||
public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
|
||||||
{
|
|
||||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
tracker.Log.Add("OrderedA");
|
|
||||||
await foreach (var item in next(request, cancellationToken))
|
|
||||||
{
|
|
||||||
yield return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
|
||||||
{
|
|
||||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
tracker.Log.Add("OrderedB");
|
|
||||||
await foreach (var item in next(request, cancellationToken))
|
|
||||||
{
|
|
||||||
yield return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -70,27 +70,6 @@ public static class RequestDispatcherBuilderExtensions
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
|
|
||||||
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
|
|
||||||
/// first (outermost); behaviors with equal order run in registration order.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
|
|
||||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
|
||||||
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
|
|
||||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
|
||||||
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
|
|
||||||
where TBehavior : class
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
|
||||||
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
|
|
||||||
|
|
||||||
builder.Services.AddOptions<RequestDispatcherOptions>()
|
|
||||||
.Configure(options => options.Inspect([typeof(TBehavior)], order));
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds the specified type to the request dispatcher configuration for inspection.
|
/// Adds the specified type to the request dispatcher configuration for inspection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,24 @@ namespace Geekeey.Request.Dispatcher;
|
||||||
|
|
||||||
internal sealed class RequestDispatcherOptions
|
internal sealed class RequestDispatcherOptions
|
||||||
{
|
{
|
||||||
private readonly List<(Type Type, int? Order)> _search = [];
|
private readonly List<Type> _search = [];
|
||||||
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
|
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
|
||||||
private readonly Lazy<TypeIndex> _handlersTypeIndex;
|
private readonly Lazy<TypeIndex> _handlersTypeIndex;
|
||||||
|
|
||||||
public RequestDispatcherOptions()
|
public RequestDispatcherOptions()
|
||||||
{
|
{
|
||||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
|
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
|
||||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
|
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Inspect(IEnumerable<Type> assembly, int? order = null)
|
public void Inspect(IEnumerable<Type> assembly)
|
||||||
{
|
{
|
||||||
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
|
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
|
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var type in assembly)
|
_search.AddRange(assembly);
|
||||||
{
|
|
||||||
_search.Add((type, order));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
|
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
|
||||||
|
|
@ -49,22 +46,11 @@ internal sealed class RequestDispatcherOptions
|
||||||
|
|
||||||
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
|
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
|
||||||
protected readonly List<Type> _openTypeInfo = [];
|
protected readonly List<Type> _openTypeInfo = [];
|
||||||
protected readonly Dictionary<Type, int> _order = [];
|
|
||||||
protected readonly Dictionary<Type, int> _explicitOrder = [];
|
|
||||||
|
|
||||||
protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate)
|
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
|
||||||
{
|
{
|
||||||
var index = 0;
|
foreach (var type in collection)
|
||||||
|
|
||||||
foreach (var (type, order) in collection)
|
|
||||||
{
|
{
|
||||||
_order[type] = index++;
|
|
||||||
|
|
||||||
if (order is { } explicitOrder)
|
|
||||||
{
|
|
||||||
_explicitOrder[type] = explicitOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type.IsGenericTypeDefinition)
|
if (type.IsGenericTypeDefinition)
|
||||||
{
|
{
|
||||||
if (type.GetInterfaces().Any(predicate))
|
if (type.GetInterfaces().Any(predicate))
|
||||||
|
|
@ -82,11 +68,6 @@ internal sealed class RequestDispatcherOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected int EffectiveOrder(Type type)
|
|
||||||
{
|
|
||||||
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<T> Resolve<T>(IServiceProvider services)
|
public IEnumerable<T> Resolve<T>(IServiceProvider services)
|
||||||
{
|
{
|
||||||
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
|
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
|
||||||
|
|
@ -116,19 +97,16 @@ internal sealed class RequestDispatcherOptions
|
||||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
|
||||||
: TypeIndex(collection, IsRequestHandlerType)
|
: TypeIndex(collection, IsRequestHandlerType)
|
||||||
{
|
{
|
||||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||||
{
|
{
|
||||||
var result = new List<(int Order, Type Type)>();
|
var result = new List<Type>();
|
||||||
|
|
||||||
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
||||||
{
|
{
|
||||||
foreach (var type in list)
|
result.AddRange(list);
|
||||||
{
|
|
||||||
result.Add((EffectiveOrder(type), type));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestType = @interface.GetGenericArguments()[0];
|
var requestType = @interface.GetGenericArguments()[0];
|
||||||
|
|
@ -143,7 +121,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
var impl = type.MakeGenericType(requestType);
|
var impl = type.MakeGenericType(requestType);
|
||||||
if (impl.IsAssignableTo(@interface))
|
if (impl.IsAssignableTo(@interface))
|
||||||
{
|
{
|
||||||
result.Add((EffectiveOrder(type), impl));
|
result.Add(impl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
|
|
@ -159,7 +137,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
var impl = type.MakeGenericType(requestType.GetGenericArguments());
|
var impl = type.MakeGenericType(requestType.GetGenericArguments());
|
||||||
if (impl.IsAssignableTo(@interface))
|
if (impl.IsAssignableTo(@interface))
|
||||||
{
|
{
|
||||||
result.Add((EffectiveOrder(type), impl));
|
result.Add(impl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +146,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,19 +157,16 @@ internal sealed class RequestDispatcherOptions
|
||||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
|
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
|
||||||
: TypeIndex(collection, IsRequestBehaviorType)
|
: TypeIndex(collection, IsRequestBehaviorType)
|
||||||
{
|
{
|
||||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||||
{
|
{
|
||||||
var result = new List<(int Order, Type Type)>();
|
var result = new List<Type>();
|
||||||
|
|
||||||
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
if (_closedTypeInfo.TryGetValue(@interface, out var list))
|
||||||
{
|
{
|
||||||
foreach (var type in list)
|
result.AddRange(list);
|
||||||
{
|
|
||||||
result.Add((EffectiveOrder(type), type));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestType = @interface.GetGenericArguments()[0];
|
var requestType = @interface.GetGenericArguments()[0];
|
||||||
|
|
@ -205,7 +180,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
var impl = behaviour.MakeGenericType(requestType, responseType);
|
var impl = behaviour.MakeGenericType(requestType, responseType);
|
||||||
if (impl.IsAssignableTo(@interface))
|
if (impl.IsAssignableTo(@interface))
|
||||||
{
|
{
|
||||||
result.Add((EffectiveOrder(behaviour), impl));
|
result.Add(impl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
|
|
@ -218,7 +193,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
||||||
if (impl.IsAssignableTo(@interface))
|
if (impl.IsAssignableTo(@interface))
|
||||||
{
|
{
|
||||||
result.Add((EffectiveOrder(behaviour), impl));
|
result.Add(impl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
|
|
@ -226,7 +201,7 @@ internal sealed class RequestDispatcherOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,20 +43,7 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
|
||||||
|
|
||||||
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
|
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,20 +47,7 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
|
||||||
|
|
||||||
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
|
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var handlers = options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
|
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,48 +166,11 @@ internal sealed class ResultEqualityTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_get_hashcode_and_get_non_zero_for_failure()
|
public async Task I_can_get_hashcode_and_get_zero_for_failure()
|
||||||
{
|
{
|
||||||
var result = Prelude.Failure<int>("error");
|
var result = Prelude.Failure<int>("error");
|
||||||
|
|
||||||
await Assert.That(result.GetHashCode()).IsNotEqualTo(0);
|
await Assert.That(result.GetHashCode()).IsZero();
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_get_hashcode_and_not_collide_non_generic_success_and_failure()
|
|
||||||
{
|
|
||||||
await Assert.That(Prelude.Success().GetHashCode())
|
|
||||||
.IsNotEqualTo(Prelude.Failure("error").GetHashCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_get_hashcode_and_not_collide_success_value_zero_with_failure()
|
|
||||||
{
|
|
||||||
var success = Prelude.Success(0);
|
|
||||||
var failure = Prelude.Failure<int>("error");
|
|
||||||
|
|
||||||
await Assert.That(success.Equals(failure)).IsFalse();
|
|
||||||
await Assert.That(success.GetHashCode()).IsNotEqualTo(failure.GetHashCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_equal_failure_and_failure_ignoring_error_content()
|
|
||||||
{
|
|
||||||
var a = Prelude.Failure<int>("error 1");
|
|
||||||
var b = Prelude.Failure<int>("error 2");
|
|
||||||
|
|
||||||
await Assert.That(a.Equals(b)).IsTrue();
|
|
||||||
await Assert.That(a.Error).IsNotEqualTo(b.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_equal_non_generic_failure_and_failure_ignoring_error_content()
|
|
||||||
{
|
|
||||||
var a = Prelude.Failure("error 1");
|
|
||||||
var b = Prelude.Failure("error 2");
|
|
||||||
|
|
||||||
await Assert.That(a.Equals(b)).IsTrue();
|
|
||||||
await Assert.That(a.Error).IsNotEqualTo(b.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -236,53 +199,4 @@ internal sealed class ResultEqualityTests
|
||||||
|
|
||||||
await Assert.That(a.Equals(b)).IsFalse();
|
await Assert.That(a.Equals(b)).IsFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_use_equality_operator_and_get_false_for_success_and_failure()
|
|
||||||
{
|
|
||||||
var success = Prelude.Success(2);
|
|
||||||
var failure = Prelude.Failure<int>("error");
|
|
||||||
|
|
||||||
await Assert.That(success == failure).IsFalse();
|
|
||||||
await Assert.That(success != failure).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_use_equality_operator_and_get_true_for_successes_with_equal_value()
|
|
||||||
{
|
|
||||||
await Assert.That(Prelude.Success(2) == Prelude.Success(2)).IsTrue();
|
|
||||||
await Assert.That(Prelude.Success(2) != Prelude.Success(3)).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_use_equality_operator_and_compare_result_to_value()
|
|
||||||
{
|
|
||||||
await Assert.That(Prelude.Success(2) == 2).IsTrue();
|
|
||||||
await Assert.That(Prelude.Success(2) != 3).IsTrue();
|
|
||||||
await Assert.That(Prelude.Failure<int>("x") == 2).IsFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_equal_object_and_get_false_for_null()
|
|
||||||
{
|
|
||||||
object result = Prelude.Success(2);
|
|
||||||
|
|
||||||
await Assert.That(result.Equals(null)).IsFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_equal_object_and_get_false_for_wrong_type()
|
|
||||||
{
|
|
||||||
object result = Prelude.Success(2);
|
|
||||||
|
|
||||||
await Assert.That(result.Equals("not a result")).IsFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_equal_object_and_get_true_for_boxed_equal_result()
|
|
||||||
{
|
|
||||||
object result = Prelude.Success(2);
|
|
||||||
|
|
||||||
await Assert.That(result.Equals(Prelude.Success(2))).IsTrue();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ namespace Geekeey.Request.Result;
|
||||||
/// A class containing various utility methods, a 'prelude' to the rest of the library.
|
/// A class containing various utility methods, a 'prelude' to the rest of the library.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Request.Result.Prelude;</c>.
|
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Result.Prelude;</c>.
|
||||||
/// Recommended to be imported globally via a global using statement.
|
/// Recommended to be imported globally via a global using statement.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class Prelude
|
public static class Prelude
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,7 @@ namespace Geekeey.Request.Result;
|
||||||
|
|
||||||
public partial class Result : IEquatable<Result>
|
public partial class Result : IEquatable<Result>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// Checks whether two results are equal. Results are equal if both are success or both are failure.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
|
|
||||||
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
|
|
||||||
/// directly instead of relying on equality.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="other">The result to check for equality with the current result.</param>
|
|
||||||
[Pure]
|
[Pure]
|
||||||
public bool Equals(Result? other)
|
public bool Equals(Result? other)
|
||||||
{
|
{
|
||||||
|
|
@ -57,8 +49,7 @@ public partial class Result : IEquatable<Result>
|
||||||
public partial class Result : IEqualityOperators<Result, Result, bool>
|
public partial class Result : IEqualityOperators<Result, Result, bool>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether two results are equal. Results are equal if they are both success or both failure. Two
|
/// Checks whether two results are equal. Results are equal if they are both success or both failure.
|
||||||
/// failures are equal regardless of their error content.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="a">The first result to compare.</param>
|
/// <param name="a">The first result to compare.</param>
|
||||||
/// <param name="b">The second result to compare.</param>
|
/// <param name="b">The second result to compare.</param>
|
||||||
|
|
@ -88,11 +79,6 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
|
||||||
/// Checks whether the result is equal to another result. Results are equal if both results are success values and
|
/// 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.
|
/// the success values are equal, or if both results are failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
|
|
||||||
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
|
|
||||||
/// directly instead of relying on equality.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="other">The result to check for equality with the current result.</param>
|
/// <param name="other">The result to check for equality with the current result.</param>
|
||||||
[Pure]
|
[Pure]
|
||||||
public bool Equals(Result<T>? other)
|
public bool Equals(Result<T>? other)
|
||||||
|
|
@ -195,20 +181,15 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
|
||||||
return comparer.GetHashCode(result.Value);
|
return comparer.GetHashCode(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result is { IsSuccess: true, Value: null })
|
|
||||||
{
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Error?.GetHashCode() ?? 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Result<T> : IEqualityOperators<Result<T>, Result<T>, bool>, IEqualityOperators<Result<T>, T, bool>
|
public partial class Result<T> : IEqualityOperators<Result<T>, Result<T>, bool>, IEqualityOperators<Result<T>, T, bool>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether two results are equal. Results are equal if both results are success values and the success
|
/// Checks whether two results are equal. Results are equal if both results are success values and the success
|
||||||
/// values are equal, or if both results are failures. Two failures are equal regardless of their error content.
|
/// values are equal, or if both results are failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="a">The first result to compare.</param>
|
/// <param name="a">The first result to compare.</param>
|
||||||
/// <param name="b">The second result to compare.</param>
|
/// <param name="b">The second result to compare.</param>
|
||||||
|
|
|
||||||
|
|
@ -314,71 +314,6 @@ internal sealed class RuleBuilderExtensionsTests
|
||||||
.Throws<ArgumentOutOfRangeException>();
|
.Throws<ArgumentOutOfRangeException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_greater_than_with_null_bound_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
|
|
||||||
=> model.Value, rule => rule.GreaterThan(null));
|
|
||||||
|
|
||||||
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
|
|
||||||
var ignoredNull = validator.Validate(new ComparableModel());
|
|
||||||
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
|
||||||
await Assert.That(ignoredNull.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
|
|
||||||
=> model.Value, rule => rule.GreaterThanOrEqualTo(null));
|
|
||||||
|
|
||||||
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
|
|
||||||
var ignoredNull = validator.Validate(new ComparableModel());
|
|
||||||
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
|
||||||
await Assert.That(ignoredNull.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_less_than_with_null_bound_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
|
|
||||||
=> model.Value, rule => rule.LessThan(null));
|
|
||||||
|
|
||||||
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
|
|
||||||
var ignoredNull = validator.Validate(new ComparableModel());
|
|
||||||
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
|
||||||
await Assert.That(ignoredNull.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
|
|
||||||
=> model.Value, rule => rule.LessThanOrEqualTo(null));
|
|
||||||
|
|
||||||
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
|
|
||||||
var ignoredNull = validator.Validate(new ComparableModel());
|
|
||||||
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
|
||||||
await Assert.That(ignoredNull.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_between_with_null_bounds_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
|
|
||||||
=> model.Value, rule => rule.Between(null, null));
|
|
||||||
|
|
||||||
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
|
|
||||||
var ignoredNull = validator.Validate(new ComparableModel());
|
|
||||||
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
|
||||||
await Assert.That(ignoredNull.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_see_it_throw_for_invalid_between_configuration()
|
public async Task I_can_see_it_throw_for_invalid_between_configuration()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Validation.Tests;
|
|
||||||
|
|
||||||
internal sealed class ValidationSeverityTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_have_warning_problems_not_invalidate_by_default()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<Person, string?>(person
|
|
||||||
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
|
|
||||||
.WithSeverity(Severity.Warning));
|
|
||||||
|
|
||||||
var result = validator.Validate(new Person { Name = "" });
|
|
||||||
|
|
||||||
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
|
||||||
await Assert.That(result.IsValid).IsTrue();
|
|
||||||
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
|
|
||||||
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_have_info_problems_only_invalidate_at_info_threshold()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<Person, string?>(person
|
|
||||||
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
|
|
||||||
.WithSeverity(Severity.Info));
|
|
||||||
|
|
||||||
var result = validator.Validate(new Person { Name = "" });
|
|
||||||
|
|
||||||
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
|
||||||
await Assert.That(result.IsValid).IsTrue();
|
|
||||||
await Assert.That(result.IsValidFor(Severity.Warning)).IsTrue();
|
|
||||||
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_have_error_problems_invalidate_by_default()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<Person, string?>(person
|
|
||||||
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required."));
|
|
||||||
|
|
||||||
var result = validator.Validate(new Person { Name = "" });
|
|
||||||
|
|
||||||
await Assert.That(result.IsValid).IsFalse();
|
|
||||||
await Assert.That(result.IsValidFor(Severity.Error)).IsFalse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,8 +14,7 @@ internal sealed class ValidatorTests
|
||||||
|
|
||||||
var result = validator.Validate(new Person { Name = "" });
|
var result = validator.Validate(new Person { Name = "" });
|
||||||
|
|
||||||
await Assert.That(result.IsValid).IsTrue();
|
await Assert.That(result.IsValid).IsFalse();
|
||||||
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
|
|
||||||
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
|
||||||
var problem = result.Problems.Single();
|
var problem = result.Problems.Single();
|
||||||
|
|
@ -40,24 +39,12 @@ internal sealed class ValidatorTests
|
||||||
|
|
||||||
using (Assert.Multiple())
|
using (Assert.Multiple())
|
||||||
{
|
{
|
||||||
await Assert.That(invalid.IsValid).IsTrue();
|
await Assert.That(invalid.IsValid).IsFalse();
|
||||||
await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse();
|
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
await Assert.That(valid.Problems).IsEmpty();
|
await Assert.That(valid.Problems).IsEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task I_can_validate_with_a_null_instance_without_throwing()
|
|
||||||
{
|
|
||||||
var validator = new PropertyValidator<Person?, string?>(person
|
|
||||||
=> person!.Name, rule => rule.Must(value => value is not null, "Name is required."));
|
|
||||||
|
|
||||||
var result = validator.Validate((Person?)null);
|
|
||||||
|
|
||||||
await Assert.That(result.IsValid).IsTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
|
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
namespace Geekeey.Request.Validation.Tests;
|
|
||||||
|
|
||||||
internal sealed class ComparableModel
|
|
||||||
{
|
|
||||||
public ComparableValue? Value { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class ComparableValue : IComparable<ComparableValue?>
|
|
||||||
{
|
|
||||||
public int Number { get; init; }
|
|
||||||
|
|
||||||
public int CompareTo(ComparableValue? other)
|
|
||||||
{
|
|
||||||
return Number.CompareTo(other!.Number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,7 +46,7 @@ internal abstract record Rule<T, TProperty> : Rule
|
||||||
|
|
||||||
if (context.Instance is null && default(T) is null)
|
if (context.Instance is null && default(T) is null)
|
||||||
{
|
{
|
||||||
return [];
|
return Validate((T)context.Instance!, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
var actualType = context.Instance?.GetType().FullName ?? "null";
|
var actualType = context.Instance?.GetType().FullName ?? "null";
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ public static class RuleBuilderExtensions
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(rule);
|
ArgumentNullException.ThrowIfNull(rule);
|
||||||
|
|
||||||
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) > 0,
|
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0,
|
||||||
$"Value must be greater than {comparisonValue}.");
|
$"Value must be greater than {comparisonValue}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,7 +189,7 @@ public static class RuleBuilderExtensions
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(rule);
|
ArgumentNullException.ThrowIfNull(rule);
|
||||||
|
|
||||||
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) >= 0,
|
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0,
|
||||||
$"Value must be greater than or equal to {comparisonValue}.");
|
$"Value must be greater than or equal to {comparisonValue}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ public static class RuleBuilderExtensions
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(rule);
|
ArgumentNullException.ThrowIfNull(rule);
|
||||||
|
|
||||||
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) < 0,
|
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0,
|
||||||
$"Value must be less than {comparisonValue}.");
|
$"Value must be less than {comparisonValue}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,7 +227,7 @@ public static class RuleBuilderExtensions
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(rule);
|
ArgumentNullException.ThrowIfNull(rule);
|
||||||
|
|
||||||
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0,
|
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0,
|
||||||
$"Value must be less than or equal to {comparisonValue}.");
|
$"Value must be less than or equal to {comparisonValue}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,9 +254,7 @@ public static class RuleBuilderExtensions
|
||||||
"Maximum value must be greater than or equal to minimum value.");
|
"Maximum value must be greater than or equal to minimum value.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return rule.Must(value => IsNull(value)
|
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0),
|
||||||
|| ((minValue is null || value.CompareTo(minValue) >= 0)
|
|
||||||
&& (maxValue is null || value.CompareTo(maxValue) <= 0)),
|
|
||||||
$"Value must be between {minValue} and {maxValue}.");
|
$"Value must be between {minValue} and {maxValue}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,27 +23,7 @@ public sealed class Validation
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the validation was successful.
|
/// Whether the validation was successful.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
public bool IsValid => Problems.Count is 0;
|
||||||
/// A validation is considered successful unless it contains at least one problem with
|
|
||||||
/// <see cref="Severity.Error"/>. Problems of lower severity (<see cref="Severity.Warning"/> or
|
|
||||||
/// <see cref="Severity.Info"/>) do not invalidate the result. Use <see cref="IsValidFor"/>
|
|
||||||
/// to control which severities are treated as failures.
|
|
||||||
/// </remarks>
|
|
||||||
public bool IsValid => IsValidFor(Severity.Error);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the validation was successful, treating problems at or above the given severity as failures.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="minimum">The minimum severity that should be considered a failure.</param>
|
|
||||||
/// <remarks>
|
|
||||||
/// A problem invalidates the validation when its <see cref="Severity"/> is at least as severe as
|
|
||||||
/// <paramref name="minimum"/>. For example, <c>IsValidFor(Severity.Warning)</c> fails on errors and
|
|
||||||
/// warnings but not on informational problems.
|
|
||||||
/// </remarks>
|
|
||||||
public bool IsValidFor(Severity minimum)
|
|
||||||
{
|
|
||||||
return !Problems.Any(problem => problem.Severity <= minimum);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The problems that were found during validation.
|
/// The problems that were found during validation.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue