From a672479b14facb222f07d0f007b4f19bbfad4cff Mon Sep 17 00:00:00 2001 From: forgnar Date: Sat, 14 Nov 2026 12:29:55 +0100 Subject: [PATCH] feat: add CombineAsync Validated.CombineAsync waits for two to eight validations that run as tasks and combines them like Combine, every error in argument order (V7, V10). Every input is a task (C# cannot infer types through conversions, see #16); completed tasks give a completed result without allocating, and every task is observed before combining. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012weRUz3QcTAaQw32ZmQkoK --- CLAUDE.md | 5 +- README.md | 15 + smoke/DotValid.AotSmoke/Program.cs | 13 + src/DotValid/PublicAPI.Unshipped.txt | 7 + src/DotValid/Validated.CombineAsync.cs | 608 ++++++++++++++++++ .../CombineAsyncContracts.cs | 58 ++ .../AllocationProperties.cs | 9 + .../CombineAsyncProperties.cs | 216 +++++++ 8 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/DotValid/Validated.CombineAsync.cs create mode 100644 tests/DotValid.CompilerContractTests/CombineAsyncContracts.cs create mode 100644 tests/DotValid.PropertyTests/CombineAsyncProperties.cs diff --git a/CLAUDE.md b/CLAUDE.md index a12c5eb..c0719c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,8 +69,9 @@ nested `IUnionMembers` explicitly so it stays out of the public API), `Valid.cs` - **V8 Dependent steps:** `Then`/`Ensure` short-circuit; deliberately not called `Bind`, and no `SelectMany`. - **V9 Zero allocation** on the valid path for value types in Release builds; an invalid validation allocates its errors (documented exclusion). -- **V10 Async, minimal:** `MatchAsync`, `MapAsync`, `ThenAsync`, `Validated.CombineAsync` over any mix of - `Validated` and `Task`; `ValueTask` results with dotResult's R8 semantics. +- **V10 Async, minimal:** `MatchAsync`, `MapAsync`, `ThenAsync`, and `Validated.CombineAsync` over 2–8 + `Task` inputs (a synchronous one goes in as `Task.FromResult(x)`; mixing bare validations and tasks cannot + infer types, see #16). `ValueTask` results with dotResult's R8 semantics. Split sync and async checks, then `Zip`. - **V11 Equality:** values by `EqualityComparer.Default`, errors as sequences; valid never equals invalid. No ordering. - **V12 Collections** accumulate all errors. **V13** Small helpers (`Check`, `FromNullable`, `ValidationError`). - **V14 Quality gates:** AOT/trimming clean, `AnalysisLevel latest-all` + `TreatWarningsAsErrors`, PublicAPI analyzer, diff --git a/README.md b/README.md index 7ba73d7..5d128da 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,21 @@ string message = await user.MatchAsync( Validated name = await user.MapAsync(found => LoadDisplayNameAsync(found)); ``` +Independent checks that need I/O combine with `Validated.CombineAsync`, the asynchronous `Combine`: the tasks run +concurrently and every error of every check comes back. Every input is a task, so a synchronous check goes in as +`Task.FromResult(...)` (C# cannot infer the types of a mix of plain validations and tasks). Often it reads better to +combine the synchronous checks on their own and join both parts with `Zip`: + +```csharp +var local = Validated.Combine(Name(dto.Name), Age(dto.Age), (name, age) => (name, age)); +var remote = await Validated.CombineAsync( + EmailIsFree(dto.Email), // Task> + LoginIsFree(dto.Login), + (email, login) => (email, login)); + +Validated user = local.Zip(remote, (l, r) => new User(l.name, l.age, r.email, r.login)); +``` + 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 diff --git a/smoke/DotValid.AotSmoke/Program.cs b/smoke/DotValid.AotSmoke/Program.cs index 5c6f151..0c819a2 100644 --- a/smoke/DotValid.AotSmoke/Program.cs +++ b/smoke/DotValid.AotSmoke/Program.cs @@ -63,6 +63,13 @@ 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"); +var remote = await Validated.CombineAsync( + Task.FromResult(Parse("x")), + Later(invalid("email taken")), + Task.FromResult(age), + static (a, b, c) => a + b + c); +Check(remote == invalid("not a number", "email taken"), "CombineAsync accumulates"); +Check(age.Zip(remote, static (a, b) => a + b) == remote, "Zip joins a sync and an async part"); if (failures.Count > 0) { @@ -83,6 +90,12 @@ Invalid(var errors) => $"{errors.Length} errors", }; +static async Task> Later(Validated validated) +{ + await Task.Yield(); + return validated; +} + static Validated Adult(int value) => value >= 18 ? valid(value) : invalid("too young"); static Validated Parse(string text) => diff --git a/src/DotValid/PublicAPI.Unshipped.txt b/src/DotValid/PublicAPI.Unshipped.txt index 8eb88e7..0faab25 100644 --- a/src/DotValid/PublicAPI.Unshipped.txt +++ b/src/DotValid/PublicAPI.Unshipped.txt @@ -73,6 +73,13 @@ static DotValid.Validated.Combine(DotValid. static DotValid.Validated.Combine(DotValid.Validated first, DotValid.Validated second, DotValid.Validated third, DotValid.Validated fourth, System.Func! combine) -> DotValid.Validated static DotValid.Validated.Combine(DotValid.Validated first, DotValid.Validated second, DotValid.Validated third, System.Func! combine) -> DotValid.Validated static DotValid.Validated.Combine(DotValid.Validated first, DotValid.Validated second, System.Func! combine) -> DotValid.Validated +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Threading.Tasks.Task>! fourth, System.Threading.Tasks.Task>! fifth, System.Threading.Tasks.Task>! sixth, System.Threading.Tasks.Task>! seventh, System.Threading.Tasks.Task>! eighth, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Threading.Tasks.Task>! fourth, System.Threading.Tasks.Task>! fifth, System.Threading.Tasks.Task>! sixth, System.Threading.Tasks.Task>! seventh, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Threading.Tasks.Task>! fourth, System.Threading.Tasks.Task>! fifth, System.Threading.Tasks.Task>! sixth, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Threading.Tasks.Task>! fourth, System.Threading.Tasks.Task>! fifth, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Threading.Tasks.Task>! fourth, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Threading.Tasks.Task>! third, System.Func! combine) -> System.Threading.Tasks.ValueTask> +static DotValid.Validated.CombineAsync(System.Threading.Tasks.Task>! first, System.Threading.Tasks.Task>! second, System.Func! combine) -> System.Threading.Tasks.ValueTask> static DotValid.Validated.Errors(this System.Collections.Generic.IEnumerable>! source) -> System.Collections.Generic.IEnumerable! static DotValid.Validated.FromNullable(T? value, System.Func! error) -> DotValid.Validated static DotValid.Validated.FromNullable(T? value, System.Func! error) -> DotValid.Validated diff --git a/src/DotValid/Validated.CombineAsync.cs b/src/DotValid/Validated.CombineAsync.cs new file mode 100644 index 0000000..e764bcf --- /dev/null +++ b/src/DotValid/Validated.CombineAsync.cs @@ -0,0 +1,608 @@ +// Generated from one template for 2..8 validations (a script kept outside the repository); change all overloads alike. +namespace DotValid; + +/// +/// CombineAsync: two to eight independent validations that run as tasks, every error accumulated (V7, V10). +/// +/// +/// Every input is a task: C# cannot infer the types of a mix of plain validations and tasks, so a synchronous one goes +/// in as Task.FromResult(validated). Every task is awaited and observed before combining (R8: an exception comes +/// out through the result, a cancellation for a requested token cancels it). +/// +public static partial class Validated +{ + /// + /// Waits for 2 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Func combine) + where T1 : notnull + where T2 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second).ConfigureAwait(false); + } + + return Completed(first, second, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Func combine) => + Combine(first.Result, second.Result, combine); + } + + /// + /// Waits for 3 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third).ConfigureAwait(false); + } + + return Completed(first, second, third, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Func combine) => + Combine(first.Result, second.Result, third.Result, combine); + } + + /// + /// Waits for 4 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the fourth value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// The fourth validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Task> fourth, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, fourth, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Task> fourth, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully + && fourth.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third, fourth).ConfigureAwait(false); + } + + return Completed(first, second, third, fourth, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Task> fourth, + Func combine) => + Combine(first.Result, second.Result, third.Result, fourth.Result, combine); + } + + /// + /// Waits for 5 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the fourth value. + /// The type of the fifth value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// The fourth validation, still running or already done. + /// The fifth validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, fourth, fifth, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully + && fourth.IsCompletedSuccessfully + && fifth.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third, fourth, fifth).ConfigureAwait(false); + } + + return Completed(first, second, third, fourth, fifth, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Func combine) => + Combine(first.Result, second.Result, third.Result, fourth.Result, fifth.Result, combine); + } + + /// + /// Waits for 6 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the fourth value. + /// The type of the fifth value. + /// The type of the sixth value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// The fourth validation, still running or already done. + /// The fifth validation, still running or already done. + /// The sixth validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(sixth); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, fourth, fifth, sixth, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully + && fourth.IsCompletedSuccessfully + && fifth.IsCompletedSuccessfully + && sixth.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third, fourth, fifth, sixth).ConfigureAwait(false); + } + + return Completed(first, second, third, fourth, fifth, sixth, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Func combine) => + Combine(first.Result, second.Result, third.Result, fourth.Result, fifth.Result, sixth.Result, combine); + } + + /// + /// Waits for 7 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the fourth value. + /// The type of the fifth value. + /// The type of the sixth value. + /// The type of the seventh value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// The fourth validation, still running or already done. + /// The fifth validation, still running or already done. + /// The sixth validation, still running or already done. + /// The seventh validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + where T7 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(sixth); + ArgumentNullException.ThrowIfNull(seventh); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, fourth, fifth, sixth, seventh, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully + && fourth.IsCompletedSuccessfully + && fifth.IsCompletedSuccessfully + && sixth.IsCompletedSuccessfully + && seventh.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third, fourth, fifth, sixth, seventh).ConfigureAwait(false); + } + + return Completed(first, second, third, fourth, fifth, sixth, seventh, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Func combine) => + Combine(first.Result, second.Result, third.Result, fourth.Result, fifth.Result, sixth.Result, seventh.Result, combine); + } + + /// + /// Waits for 8 independent validations and combines them like Combine: receives + /// every value when all of them are valid; otherwise the result holds every error, in argument order. + /// + /// + /// + /// The tasks run concurrently (they are started by the caller). A synchronous validation goes in as + /// Task.FromResult(validated), or combine the synchronous ones with Combine and join both results with + /// Zip. When every task has already succeeded, the result is complete at once and nothing is allocated. + /// + /// + /// When awaited: the exception of the first task that failed (every task is observed), a canceled result when one + /// was canceled, when a task produced an uninitialized validation, and + /// when returned . + /// + /// + /// The type of the first value. + /// The type of the second value. + /// The type of the third value. + /// The type of the fourth value. + /// The type of the fifth value. + /// The type of the sixth value. + /// The type of the seventh value. + /// The type of the eighth value. + /// The type of the combined value. + /// The type of the errors. + /// The first validation, still running or already done. + /// The second validation, still running or already done. + /// The third validation, still running or already done. + /// The fourth validation, still running or already done. + /// The fifth validation, still running or already done. + /// The sixth validation, still running or already done. + /// The seventh validation, still running or already done. + /// The eighth validation, still running or already done. + /// Combines the values. + /// A valid validation with the combined value, or every error in argument order. + /// A task or is null. + public static ValueTask> CombineAsync( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Task> eighth, + Func combine) + where T1 : notnull + where T2 : notnull + where T3 : notnull + where T4 : notnull + where T5 : notnull + where T6 : notnull + where T7 : notnull + where T8 : notnull + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(sixth); + ArgumentNullException.ThrowIfNull(seventh); + ArgumentNullException.ThrowIfNull(eighth); + ArgumentNullException.ThrowIfNull(combine); + return CombineAfter(first, second, third, fourth, fifth, sixth, seventh, eighth, combine); + + static async ValueTask> CombineAfter( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Task> eighth, + Func combine) + { + if (!(first.IsCompletedSuccessfully + && second.IsCompletedSuccessfully + && third.IsCompletedSuccessfully + && fourth.IsCompletedSuccessfully + && fifth.IsCompletedSuccessfully + && sixth.IsCompletedSuccessfully + && seventh.IsCompletedSuccessfully + && eighth.IsCompletedSuccessfully)) + { + await Task.WhenAll(first, second, third, fourth, fifth, sixth, seventh, eighth).ConfigureAwait(false); + } + + return Completed(first, second, third, fourth, fifth, sixth, seventh, eighth, combine); + } + + // Every task has succeeded here, so reading the results does not block. + static Validated Completed( + Task> first, + Task> second, + Task> third, + Task> fourth, + Task> fifth, + Task> sixth, + Task> seventh, + Task> eighth, + Func combine) => + Combine(first.Result, second.Result, third.Result, fourth.Result, fifth.Result, sixth.Result, seventh.Result, eighth.Result, combine); + } +} diff --git a/tests/DotValid.CompilerContractTests/CombineAsyncContracts.cs b/tests/DotValid.CompilerContractTests/CombineAsyncContracts.cs new file mode 100644 index 0000000..fd278c6 --- /dev/null +++ b/tests/DotValid.CompilerContractTests/CombineAsyncContracts.cs @@ -0,0 +1,58 @@ +namespace DotValid.CompilerContractTests; + +/// +/// How Validated.CombineAsync reads at the call site (V10, decided in #16): every input is a task. C# infers no +/// type arguments through conversions, so a bare validation does not fit; it goes in as Task.FromResult, or the +/// synchronous checks are combined on their own and joined with Zip. +/// +public sealed class CombineAsyncContracts +{ + private const string Checks = """ + private static Validated Age(int age) => age >= 0 ? valid(age) : invalid("age is negative"); + private static Validated Name(string name) => name.Length > 0 ? valid(name) : invalid("name is empty"); + private static Task> EmailFree(string email) => Task.FromResult>(valid(email)); + private static Task> LoginFree(string login) => Task.FromResult>(valid(login)); + """; + + [Fact] + public void Tasks_infer_every_type() + { + var result = Snippet.Compile(Consumer.File(Checks + """ + public static async Task> User(string email, string login) => + await Validated.CombineAsync(EmailFree(email), LoginFree(login), (e, l) => $"{e} {l}"); + """)); + + ContractAssert.CompilesCleanly(result); + } + + [Fact] + public void A_bare_validation_does_not_fit_but_Task_FromResult_does() + { + var bare = Snippet.Compile(Consumer.File(Checks + """ + public static async Task> User(int age, string email) => + await Validated.CombineAsync(Age(age), EmailFree(email), (a, e) => $"{e} {a}"); + """)); + var wrapped = Snippet.Compile(Consumer.File(Checks + """ + public static async Task> User(int age, string email) => + await Validated.CombineAsync(Task.FromResult(Age(age)), EmailFree(email), (a, e) => $"{e} {a}"); + """)); + + ContractAssert.FailsWith(bare, "CS0411"); + ContractAssert.CompilesCleanly(wrapped); + } + + [Fact] + public void Synchronous_and_asynchronous_checks_combine_separately_and_join_with_Zip() + { + var result = Snippet.Compile(Consumer.File(Checks + """ + public static async Task> User(string name, int age, string email, string login) + { + var local = Validated.Combine(Name(name), Age(age), (n, a) => (n, a)); + var remote = await Validated.CombineAsync(EmailFree(email), LoginFree(login), (e, l) => (e, l)); + return local.Zip(remote, (l, r) => $"{l.n} {l.a} {r.e} {r.l}"); + } + """)); + + ContractAssert.CompilesCleanly(result); + } +} diff --git a/tests/DotValid.PropertyTests/AllocationProperties.cs b/tests/DotValid.PropertyTests/AllocationProperties.cs index 2d903d9..cdd76dd 100644 --- a/tests/DotValid.PropertyTests/AllocationProperties.cs +++ b/tests/DotValid.PropertyTests/AllocationProperties.cs @@ -123,6 +123,15 @@ public void Async_members_with_completed_tasks_allocate_nothing(Validated _ = 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); + Allocations.AssertNone( + "CombineAsync(2)", + () => _ = Validated.CombineAsync(CompletedValidOne, CompletedValidOne, static (a, b) => a + b).IsCompleted); + Allocations.AssertNone( + "CombineAsync(8)", + () => _ = Validated.CombineAsync( + CompletedValidOne, CompletedValidOne, CompletedValidOne, CompletedValidOne, + CompletedValidOne, CompletedValidOne, CompletedValidOne, CompletedValidOne, + static (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h).IsCompleted); } [Property(Skip = Allocations.SkipInDebug)] diff --git a/tests/DotValid.PropertyTests/CombineAsyncProperties.cs b/tests/DotValid.PropertyTests/CombineAsyncProperties.cs new file mode 100644 index 0000000..335da49 --- /dev/null +++ b/tests/DotValid.PropertyTests/CombineAsyncProperties.cs @@ -0,0 +1,216 @@ +using FsCheck.Xunit; + +namespace DotValid.PropertyTests; + +/// +/// Validated.CombineAsync waits for two to eight validations that run as tasks and combines them like +/// Combine (V7, V10): every error of every input, in argument order. With dotResult's R8 semantics: missing +/// arguments throw at once, a failed task comes out when awaited (every task observed), a canceled one cancels the +/// result, and completed tasks give a completed result. +/// +public sealed class CombineAsyncProperties : IDisposable +{ + private readonly CancellationTokenSource _source = new(); + + public CombineAsyncProperties() => _source.Cancel(); + + public void Dispose() => _source.Dispose(); + + [Property] + public async Task Every_arity_is_awaiting_each_input_then_Combine( + Validated v1, + Validated v2, + Validated v3, + Validated v4, + Validated v5, + Validated v6, + Validated v7, + Validated v8, + bool yields) + { + Validated[] all = [v1, v2, v3, v4, v5, v6, v7, v8]; + for (var arity = 2; arity <= 8; arity++) + { + var inputs = all[..arity]; + var expected = Validations.Valid(0); + foreach (var input in inputs) + { + expected = expected.Zip(input, (sum, value) => sum + value); + } + + Assert.Equal(expected, await CombineAllAsync([.. inputs.Select(input => Deferred.Lift(input, yields))], values => values.Sum())); + } + } + + [Fact] + public async Task Completed_tasks_give_a_completed_result() + { + var done = Task.FromResult(Validations.Valid(1)); + var failed = Task.FromResult(Validations.Invalid("boom")); + + var combined = Validated.CombineAsync(done, failed, (a, b) => a + b); + + Assert.True(combined.IsCompletedSuccessfully); + ValidatedAssert.IsInvalid(["boom"], await combined); + } + + [Fact] + public async Task A_failed_task_comes_out_when_awaited_and_every_task_is_observed() + { + var first = new InvalidOperationException("first"); + var second = new InvalidOperationException("second"); + var calls = 0; + + var pending = Validated.CombineAsync( + Task.FromResult(Validations.Valid(1)), + Task.FromException>(first), + Task.FromException>(second), + (a, b, c) => ++calls); + + Assert.Same(first, await Assert.ThrowsAsync(async () => await pending)); + Assert.Equal(0, calls); + } + + [Fact] + public async Task A_task_that_fails_later_comes_out_as_itself() + { + var thrown = new InvalidOperationException("later"); + var later = Later(thrown); + + var pending = Validated.CombineAsync(Task.FromResult(Validations.Valid(1)), later, (a, b) => a + b); + + Assert.Same(thrown, await Assert.ThrowsAsync(async () => await pending)); + + static async Task> Later(Exception exception) + { + await Task.Yield(); + throw exception; + } + } + + [Fact] + public async Task A_canceled_task_cancels_the_result() + { + var task = Validated.CombineAsync( + Task.FromResult(Validations.Valid(1)), + Task.FromCanceled>(_source.Token), + (a, b) => a + b).AsTask(); + + await Assert.ThrowsAnyAsync(() => task); + Assert.True(task.IsCanceled); + } + + [Fact] + public async Task A_null_from_the_combiner_or_an_uninitialized_result_fails_when_awaited() + { + var done = Task.FromResult(Validations.Valid(1)); + + var nullResult = Validated.CombineAsync(done, done, (_, _) => (string)null!); + var uninitialized = Validated.CombineAsync(done, Task.FromResult(default(Validated)), (a, b) => a + b); + + await Assert.ThrowsAsync(async () => await nullResult); + Assert.Equal("second", (await Assert.ThrowsAsync(async () => await uninitialized)).ParamName); + } + + [Fact] + public async Task Every_arity_gives_the_exception_of_a_failed_task_at_any_position_as_itself() + { + for (var arity = 2; arity <= 8; arity++) + { + for (var position = 0; position < arity; position++) + { + var thrown = new InvalidOperationException($"failed at {position}"); + var inputs = Enumerable.Repeat(Task.FromResult(Validations.Valid(1)), arity).ToArray(); + inputs[position] = Task.FromException>(thrown); + + var pending = CombineAllAsync(inputs, values => values.Sum()); + + Assert.Same(thrown, await Assert.ThrowsAsync(async () => await pending)); + } + } + } + + [Fact] + public async Task No_arity_resumes_on_the_callers_synchronization_context() + { + for (var arity = 2; arity <= 8; arity++) + { + var context = new CountingContext(); + var first = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var inputs = Enumerable.Repeat(Task.FromResult(Validations.Valid(1)), arity).ToArray(); + inputs[0] = first.Task; + ValueTask> pending; + + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + pending = CombineAllAsync(inputs, values => values.Sum()); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + first.SetResult(Validations.Valid(1)); + + Assert.Equal(Validations.Valid(arity), await pending); + Assert.Equal(0, context.Posts); + } + } + + [Fact] + public void Every_arity_rejects_a_missing_task_by_its_name_and_a_missing_combiner() + { + string[] names = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth"]; + for (var arity = 2; arity <= 8; arity++) + { + for (var position = 0; position < arity; position++) + { + var inputs = Enumerable.Repeat(Task.FromResult(Validations.Valid(1)), arity).ToArray(); + inputs[position] = null!; + + Assert.Equal(names[position], Assert.Throws(() => CombineAllAsync(inputs, _ => 0)).ParamName); + } + + var done = Enumerable.Repeat(Task.FromResult(Validations.Valid(1)), arity).ToArray(); + Assert.Equal("combine", Assert.Throws(() => CombineAllAsync(done, null)).ParamName); + } + } + + // Calls the CombineAsync overload for inputs.Length (2..8); a null combine passes null to that overload. + private static ValueTask> CombineAllAsync(Task>[] v, Func? combine) => + v.Length switch + { + 2 => Validated.CombineAsync(v[0], v[1], combine is null ? null! : (Func)((a, b) => combine([a, b]))), + 3 => Validated.CombineAsync(v[0], v[1], v[2], combine is null ? null! : (Func)((a, b, c) => combine([a, b, c]))), + 4 => Validated.CombineAsync( + v[0], v[1], v[2], v[3], combine is null ? null! : (Func)((a, b, c, d) => combine([a, b, c, d]))), + 5 => Validated.CombineAsync( + v[0], v[1], v[2], v[3], v[4], + combine is null ? null! : (Func)((a, b, c, d, e) => combine([a, b, c, d, e]))), + 6 => Validated.CombineAsync( + v[0], v[1], v[2], v[3], v[4], v[5], + combine is null ? null! : (Func)((a, b, c, d, e, f) => combine([a, b, c, d, e, f]))), + 7 => Validated.CombineAsync( + v[0], v[1], v[2], v[3], v[4], v[5], v[6], + combine is null ? null! : (Func)((a, b, c, d, e, f, g) => combine([a, b, c, d, e, f, g]))), + 8 => Validated.CombineAsync( + v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], + combine is null ? null! : (Func)((a, b, c, d, e, f, g, h) => combine([a, b, c, d, e, f, g, h]))), + _ => throw new ArgumentOutOfRangeException(nameof(v)), + }; + + 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); + } + } +}