diff --git a/README.md b/README.md index 0df3822..7ba73d7 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,24 @@ independent parts separately and combine them with `Zip`. A `Then` lambda that r `invalid(...)` needs its type spelled out, `Then(...)`, because union conversions take no part in type inference; a step that already returns a validation needs nothing. +### Async + +```csharp +Validated user = await Login(form.Login) + .ThenAsync(login => repository.FindAsync(login)); // a dependent step that hits the database + +string message = await user.MatchAsync( + async found => await GreetAsync(found), + errors => string.Join("; ", errors)); // a synchronous branch is fine too + +Validated name = await user.MapAsync(found => LoadDisplayNameAsync(found)); +``` + +The async members are deliberately few. The delegates return `Task`; the methods return `ValueTask`, already +complete (and allocation-free) when the case passes through. Arguments are checked immediately; anything a delegate +throws comes out when the result is awaited, and an `OperationCanceledException` for a requested token gives a +canceled result. + ### The uninitialized validation `Validated` is a struct, so `default(Validated)` exists, for example in a new array. It is diff --git a/smoke/DotValid.AotSmoke/Program.cs b/smoke/DotValid.AotSmoke/Program.cs index 562f760..5c6f151 100644 --- a/smoke/DotValid.AotSmoke/Program.cs +++ b/smoke/DotValid.AotSmoke/Program.cs @@ -53,6 +53,16 @@ Check(mixed.Sequence() == invalid("empty", "too short") && mixed.Values().Sum() == 49 && mixed.Errors().Count() == 2, "Sequence, Values, Errors"); var (passed, problems) = mixed.Partition(); Check(passed is [42, 7] && problems is ["empty", "too short"], "Partition"); +var described = await age.MatchAsync( + static async value => + { + await Task.Yield(); + return $"valid {value}"; + }, + static errors => $"{errors.Length} errors"); +Check(described == "valid 42" && await failed.MatchAsync(static _ => Task.FromResult(0), static errors => errors.Length) == 2, "MatchAsync"); +Check(await age.MapAsync(static value => Task.FromResult(value + 1)) == valid(43) && await failed.MapAsync(static value => Task.FromResult(value)) == failed, "MapAsync"); +Check(await Parse("7").ThenAsync(static value => Task.FromResult(Adult(value))) == invalid("too young"), "ThenAsync"); if (failures.Count > 0) { diff --git a/src/DotValid/PublicAPI.Unshipped.txt b/src/DotValid/PublicAPI.Unshipped.txt index f50924c..8eb88e7 100644 --- a/src/DotValid/PublicAPI.Unshipped.txt +++ b/src/DotValid/PublicAPI.Unshipped.txt @@ -25,12 +25,16 @@ DotValid.Validated.IUnionMembers.TryGetValue(out DotValid.Invalid.IUnionMembers.TryGetValue(out DotValid.Valid value) -> bool DotValid.Validated.IUnionMembers.Value.get -> object? DotValid.Validated.Map(System.Func! map) -> DotValid.Validated +DotValid.Validated.MapAsync(System.Func!>! map) -> System.Threading.Tasks.ValueTask> DotValid.Validated.MapError(System.Func! map) -> DotValid.Validated DotValid.Validated.Match(System.Func! valid, System.Func, TResult>! invalid) -> TResult +DotValid.Validated.MatchAsync(System.Func!>! valid, System.Func, System.Threading.Tasks.Task!>! invalid) -> System.Threading.Tasks.ValueTask +DotValid.Validated.MatchAsync(System.Func!>! valid, System.Func, TResult>! invalid) -> System.Threading.Tasks.ValueTask DotValid.Validated.OrDefault(System.Func, T>! fallback) -> T DotValid.Validated.OrDefault(T fallback) -> T DotValid.Validated.Select(System.Func! selector) -> DotValid.Validated DotValid.Validated.Then(System.Func>! next) -> DotValid.Validated +DotValid.Validated.ThenAsync(System.Func>!>! next) -> System.Threading.Tasks.ValueTask> DotValid.Validated.Validated() -> void DotValid.Validated.Zip(DotValid.Validated other, System.Func! combine) -> DotValid.Validated DotValid.Validated.Zip(DotValid.Validated other) -> DotValid.Validated<(T, TOther), TError> diff --git a/src/DotValid/Validated.Async.cs b/src/DotValid/Validated.Async.cs new file mode 100644 index 0000000..62517be --- /dev/null +++ b/src/DotValid/Validated.Async.cs @@ -0,0 +1,154 @@ +using System.Collections.Immutable; + +namespace DotValid; + +/// +/// Asynchronous counterparts of Match, Map and Then (V10, minimal on purpose). +/// +/// +/// +/// The delegates return , so any async lambda or Task-returning method fits. The methods +/// return : when there is nothing to wait for, the result is already complete and +/// nothing is allocated. +/// +/// +/// Arguments and the uninitialized default are rejected immediately, when the method is called. Anything a delegate +/// does, including throwing before it returns a task, comes out when the result is awaited; an +/// for a requested token gives a canceled result. +/// +/// +public readonly partial struct Validated +{ + /// + /// Asynchronously reduces this validation to a single value: runs on the value, + /// on the errors. Exactly one of them runs, exactly once. + /// + /// The type of the value both branches produce. + /// Produces the value from the value of a valid validation. + /// Produces the value from the errors, in order. + /// The value produced by the branch that ran. + /// or is null. + /// The validation is uninitialized (default). + public ValueTask MatchAsync(Func> valid, Func, Task> invalid) + { + ArgumentNullException.ThrowIfNull(valid); + ArgumentNullException.ThrowIfNull(invalid); + return _case switch + { + ValidatedCase.Valid => Start(valid, _valid.Value), + ValidatedCase.Invalid => Start(invalid, _invalid.Errors), + _ => throw Uninitialized(), + }; + } + + /// + /// Asynchronously reduces this validation to a single value, with a synchronous branch + /// (typically turning the errors into a message). For an invalid validation the result is already complete. + /// + /// The type of the value both branches produce. + /// Produces the value from the value of a valid validation. + /// Produces the value from the errors, in order. + /// The value produced by the branch that ran. + /// or is null. + /// The validation is uninitialized (default). + public ValueTask MatchAsync(Func> valid, Func, TResult> invalid) + { + ArgumentNullException.ThrowIfNull(valid); + ArgumentNullException.ThrowIfNull(invalid); + return _case switch + { + ValidatedCase.Valid => Start(valid, _valid.Value), + ValidatedCase.Invalid => Evaluate(invalid, _invalid.Errors), + _ => throw Uninitialized(), + }; + } + + /// + /// Asynchronously transforms the value of a valid validation; the errors of an invalid one pass through at once, + /// without waiting. + /// + /// The type of the transformed value. + /// Transforms the value. + /// A valid validation with the transformed value, or the same errors. + /// is null. + /// + /// The validation is uninitialized (default); or, when awaited, produced . + /// + public ValueTask> MapAsync(Func> map) + where TResult : notnull + { + ArgumentNullException.ThrowIfNull(map); + return _case switch + { + ValidatedCase.Valid => MapValueAsync(_valid.Value, map), + ValidatedCase.Invalid => ValueTask.FromResult(new Validated(_invalid)), + _ => throw Uninitialized(), + }; + + static async ValueTask> MapValueAsync(T value, Func> map) => + new(new Valid(Produced(await map(value).ConfigureAwait(false), nameof(map)))); + } + + /// + /// Continues a valid validation with an asynchronous step that depends on the value, for example a database check; + /// the errors of an invalid one pass through at once, without waiting. Like , it never + /// accumulates errors. + /// + /// The type of the value produced by the next step. + /// The next step. + /// The validation produced by the next step, or the same errors. + /// is null. + /// + /// The validation is uninitialized (default); or, when awaited, produced an uninitialized + /// validation. + /// + public ValueTask> ThenAsync(Func>> next) + where TResult : notnull + { + ArgumentNullException.ThrowIfNull(next); + return _case switch + { + ValidatedCase.Valid => ThenValueAsync(_valid.Value, next), + ValidatedCase.Invalid => ValueTask.FromResult(new Validated(_invalid)), + _ => throw Uninitialized(), + }; + + static async ValueTask> ThenValueAsync( + T value, + Func>> next) => + Validated.Initialized(await next(value).ConfigureAwait(false), nameof(next)); + } + + // R8: a delegate's synchronous throw comes out through the ValueTask; a requested cancellation cancels it. + private static ValueTask Start(Func> step, TArg arg) + { + try + { + return new ValueTask(step(arg)); + } + catch (OperationCanceledException canceled) when (canceled.CancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(canceled.CancellationToken); + } + catch (Exception exception) + { + return ValueTask.FromException(exception); + } + } + + private static ValueTask Evaluate(Func step, TArg arg) + { + try + { + return ValueTask.FromResult(step(arg)); + } + catch (OperationCanceledException canceled) when (canceled.CancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(canceled.CancellationToken); + } + catch (Exception exception) + { + return ValueTask.FromException(exception); + } + } +} diff --git a/tests/DotValid.PropertyTests/AllocationProperties.cs b/tests/DotValid.PropertyTests/AllocationProperties.cs index 5dde1e3..2d903d9 100644 --- a/tests/DotValid.PropertyTests/AllocationProperties.cs +++ b/tests/DotValid.PropertyTests/AllocationProperties.cs @@ -112,6 +112,19 @@ public void Building_blocks_on_the_valid_path_allocate_nothing(int value) Allocations.AssertNone("ValidationError", () => _ = new ValidationError("Name", "is empty")); } + [Property(Skip = Allocations.SkipInDebug)] + public void Async_members_with_completed_tasks_allocate_nothing(Validated validated) + { + Allocations.AssertNone( + "MatchAsync(async, async)", + () => _ = validated.MatchAsync(static _ => CompletedOne, static _ => CompletedOne).IsCompleted); + Allocations.AssertNone( + "MatchAsync(async, sync)", + () => _ = validated.MatchAsync(static _ => CompletedOne, static errors => errors.Length).IsCompleted); + Allocations.AssertNone("MapAsync", () => _ = validated.MapAsync(static _ => CompletedOne).IsCompleted); + Allocations.AssertNone("ThenAsync", () => _ = validated.ThenAsync(static _ => CompletedValidOne).IsCompleted); + } + [Property(Skip = Allocations.SkipInDebug)] public void Comparing_allocates_nothing(Validated validated, Validated other) { @@ -124,6 +137,10 @@ public void Comparing_allocates_nothing(Validated validated, Validated private const string? Text = "text"; + // Tasks completed in advance: any allocation in the async tests comes from the library, not from the delegate. + private static readonly Task CompletedOne = Task.FromResult(1); + private static readonly Task> CompletedValidOne = Task.FromResult(Validations.Valid(1)); + private static Validated Succeed(int value) => valid(value); private static Validated Fail(Invalid failure) => failure; diff --git a/tests/DotValid.PropertyTests/AsyncProperties.cs b/tests/DotValid.PropertyTests/AsyncProperties.cs new file mode 100644 index 0000000..61a9af8 --- /dev/null +++ b/tests/DotValid.PropertyTests/AsyncProperties.cs @@ -0,0 +1,212 @@ +using System.Collections.Immutable; +using FsCheck; +using FsCheck.Xunit; + +namespace DotValid.PropertyTests; + +/// +/// V10: the asynchronous members agree with their synchronous counterparts, with dotResult's R8 semantics. Arguments +/// and the uninitialized default are checked immediately; whatever a delegate does, including throwing before it +/// returns a task, comes out when the result is awaited; cancellation for a requested token gives a canceled result; +/// and the case that passes through is already complete. +/// +public sealed class AsyncProperties : IDisposable +{ + private readonly CancellationTokenSource _source = new(); + + public AsyncProperties() => _source.Cancel(); + + private CancellationToken Canceled => _source.Token; + + public void Dispose() => _source.Dispose(); + + [Property] + public async Task MatchAsync_agrees_with_Match(Validated validated, bool yields) + { + var expected = validated.Match(value => $"valid {value}", errors => $"invalid {errors.Length}"); + + Assert.Equal( + expected, + await validated.MatchAsync(value => Deferred.Lift($"valid {value}", yields), errors => Deferred.Lift($"invalid {errors.Length}", yields))); + Assert.Equal( + expected, + await validated.MatchAsync(value => Deferred.Lift($"valid {value}", yields), errors => $"invalid {errors.Length}")); + } + + [Property] + public async Task Exactly_one_branch_of_MatchAsync_runs_exactly_once(Validated validated, bool yields) + { + var validCalls = 0; + var invalidCalls = 0; + + _ = await validated.MatchAsync(_ => Deferred.Lift(++validCalls, yields), _ => Deferred.Lift(++invalidCalls, yields)); + _ = await validated.MatchAsync(_ => Deferred.Lift(++validCalls, yields), _ => ++invalidCalls); + + Assert.Equal(validated is Valid ? 2 : 0, validCalls); + Assert.Equal(validated is Invalid ? 2 : 0, invalidCalls); + } + + [Property] + public async Task MapAsync_and_ThenAsync_agree_with_Map_and_Then( + Validated validated, + Func map, + Func> next, + bool yields) + { + Assert.Equal(validated.Map(map), await validated.MapAsync(value => Deferred.Lift(map(value), yields))); + Assert.Equal(validated.Then(next), await validated.ThenAsync(value => Deferred.Lift(next(value), yields))); + } + + [Property] + public void An_invalid_validation_passes_through_at_once_and_nothing_runs(NonEmptyArray errors) + { + var calls = 0; + var invalidated = Validations.Invalid(errors.Get); + + var mapped = invalidated.MapAsync(text => Task.FromResult(++calls)); + var continued = invalidated.ThenAsync(text => Task.FromResult(Validations.Valid(++calls))); + var matched = invalidated.MatchAsync(_ => Task.FromResult(++calls), all => all.Length); + + Assert.True(mapped.IsCompletedSuccessfully && continued.IsCompletedSuccessfully && matched.IsCompletedSuccessfully); + ValidatedAssert.IsInvalid(errors.Get, mapped.Result); + ValidatedAssert.IsInvalid(errors.Get, continued.Result); + Assert.Equal(errors.Get.Length, matched.Result); + Assert.Equal(0, calls); + } + + [Property] + public async Task What_a_delegate_throws_comes_out_only_when_awaited(Validated validated) + { + var thrown = new InvalidOperationException("thrown by the delegate"); + Func> throwingValid = _ => throw thrown; + Func, Task> throwingInvalid = _ => throw thrown; + + await AssertFailsOnlyWhenAwaited(thrown, () => validated.MatchAsync(throwingValid, throwingInvalid)); + if (validated is Valid) + { + await AssertFailsOnlyWhenAwaited(thrown, () => validated.MapAsync(throwingValid)); + await AssertFailsOnlyWhenAwaited(thrown, () => validated.ThenAsync(_ => throw thrown)); + } + } + + [Property] + public async Task A_throwing_synchronous_invalid_branch_comes_out_only_when_awaited(NonEmptyArray errors) + { + var thrown = new InvalidOperationException("thrown by the invalid branch"); + + await AssertFailsOnlyWhenAwaited( + thrown, + () => Validations.Invalid(errors.Get).MatchAsync(_ => Task.FromResult(0), (Func, int>)(_ => throw thrown))); + } + + [Fact] + public async Task The_library_never_resumes_on_the_callers_synchronization_context() + { + var context = new CountingContext(); + var validated = Validations.Valid(1); + var map = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var next = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + ValueTask> mapped; + ValueTask> continued; + + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + mapped = validated.MapAsync(_ => map.Task); + continued = validated.ThenAsync(_ => next.Task); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + map.SetResult(2); + next.SetResult(Validations.Valid(3)); + + Assert.Equal(Validations.Valid(2), await mapped); + Assert.Equal(Validations.Valid(3), await continued); + Assert.Equal(0, context.Posts); + } + + [Property] + public async Task A_null_or_uninitialized_result_of_a_step_fails_when_awaited(int value) + { + var validated = Validations.Valid(value); + + var mapped = validated.MapAsync(_ => Task.FromResult((string)null!)); + var continued = validated.ThenAsync(_ => Task.FromResult(default(Validated))); + + Assert.StartsWith("'map' returned null", (await Assert.ThrowsAsync(async () => await mapped)).Message, StringComparison.Ordinal); + Assert.StartsWith("'next' returned an uninitialized", (await Assert.ThrowsAsync(async () => await continued)).Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Cancellation_for_a_requested_token_gives_a_canceled_result() + { + var validated = Validations.Valid(1); + var invalidated = Validations.Invalid("boom"); + Func> throws = _ => + { + Canceled.ThrowIfCancellationRequested(); + return Task.FromResult(0); + }; + Func> canceled = _ => Task.FromCanceled(Canceled); + Func, int> throwsSync = _ => + { + Canceled.ThrowIfCancellationRequested(); + return 0; + }; + + await AssertCanceled(validated.MatchAsync(throws, _ => 0)); + await AssertCanceled(validated.MatchAsync(canceled, _ => Task.FromResult(0))); + await AssertCanceled(invalidated.MatchAsync(throws, throwsSync)); + await AssertCanceled(invalidated.MatchAsync(throws, _ => Task.FromCanceled(Canceled))); + await AssertCanceled(validated.MapAsync(throws)); + await AssertCanceled(validated.MapAsync(canceled)); + await AssertCanceled(validated.ThenAsync(_ => Task.FromCanceled>(Canceled))); + } + + [Property] + public void Missing_delegates_are_rejected_immediately_whatever_the_case(Validated validated) + { + Assert.Equal("valid", Assert.Throws(() => validated.MatchAsync((Func>)null!, (Func, Task>)(_ => Task.FromResult(0)))).ParamName); + Assert.Equal("invalid", Assert.Throws(() => validated.MatchAsync(_ => Task.FromResult(0), (Func, Task>)null!)).ParamName); + Assert.Equal("valid", Assert.Throws(() => validated.MatchAsync(null!, _ => 0)).ParamName); + Assert.Equal("invalid", Assert.Throws(() => validated.MatchAsync(_ => Task.FromResult(0), (Func, int>)null!)).ParamName); + Assert.Equal("map", Assert.Throws(() => validated.MapAsync((Func>)null!)).ParamName); + Assert.Equal("next", Assert.Throws(() => validated.ThenAsync((Func>>)null!)).ParamName); + } + + // Counts continuations posted back to it; ConfigureAwait(false) in the library means none. + private sealed class CountingContext : SynchronizationContext + { + private int _posts; + + public int Posts => Volatile.Read(ref _posts); + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _posts); + base.Post(d, state); + } + } + + private static async Task AssertFailsOnlyWhenAwaited(Exception expected, Func> call) + { + ValueTask pending = default; + + var thrownByTheCall = Record.Exception(() => { pending = call(); }); + + Assert.Null(thrownByTheCall); + Assert.Same(expected, await Assert.ThrowsAnyAsync(async () => await pending)); + } + + private static async Task AssertCanceled(ValueTask pending) + { + var task = pending.AsTask(); + + await Assert.ThrowsAnyAsync(() => task); + Assert.True(task.IsCanceled, "Expected the result to be canceled, not faulted or successful."); + } +} diff --git a/tests/DotValid.PropertyTests/UninitializedProperties.cs b/tests/DotValid.PropertyTests/UninitializedProperties.cs index 040081b..65d0b93 100644 --- a/tests/DotValid.PropertyTests/UninitializedProperties.cs +++ b/tests/DotValid.PropertyTests/UninitializedProperties.cs @@ -154,6 +154,20 @@ public void Collections_throw_when_they_read_one() Assert.Equal(2, calls); } + [Fact] + public void Async_members_throw_immediately_and_run_nothing() + { + var calls = 0; + + Assert.Throws( + () => Uninitialized.MatchAsync(_ => Task.FromResult(++calls), _ => Task.FromResult(++calls))); + Assert.Throws(() => Uninitialized.MatchAsync(_ => Task.FromResult(++calls), _ => ++calls)); + Assert.Throws(() => Uninitialized.MapAsync(value => Task.FromResult(++calls + value))); + Assert.Throws( + () => Uninitialized.ThenAsync(value => Task.FromResult(Validations.Valid(++calls)))); + Assert.Equal(0, calls); + } + [Fact] public void The_union_members_report_no_case() {