Skip to content

[OptionsValidator] source generator: emit ValidateAsync() for IAsyncValidateOptions<T>#130263

Open
ViveliDuCh wants to merge 2 commits into
dotnet:mainfrom
ViveliDuCh:vivianad-optionsvalidator-sourcegen
Open

[OptionsValidator] source generator: emit ValidateAsync() for IAsyncValidateOptions<T>#130263
ViveliDuCh wants to merge 2 commits into
dotnet:mainfrom
ViveliDuCh:vivianad-optionsvalidator-sourcegen

Conversation

@ViveliDuCh

@ViveliDuCh ViveliDuCh commented Jul 6, 2026

Copy link
Copy Markdown
Member

Fixes #128882

Adds source-generator support for the async Options validation API. When [OptionsValidator] is applied to a type that implements IAsyncValidateOptions<T>, the generator now emits a ValidateAsync() method alongside the existing synchronous Validate().

Follow-up to:

This is the source-generator piece that #128788 explicitly deferred to the #128882 tracking issue. It is purely additive: no public API surface changes (gen/ + tests/ only), and pure-synchronous compilations produce byte-for-byte identical output (verified: zero baseline drift across the 60 SourceGenerationTests).

What's included

  • ValidateAsync(string?, TOptions, CancellationToken) emitted next to Validate(...) when the validator implements IAsyncValidateOptions<T> for that model.
  • Per-member async validation via Validator.TryValidateValueAsync(...) instead of the sync TryValidateValue.
  • Async self-validation: when the options type implements IAsyncValidatableObject, the generated method drives it via await foreach over ValidateAsync(...); when it implements only IValidatableObject, it falls back to the synchronous Validate(...).
  • Transitive / enumerated propagation: [ValidateObjectMembers] and [ValidateEnumeratedItems] children synthesized in an async context emit their own ValidateAsync, which the parent awaits.
  • Per-model decision: async is decided per validated model (IAsyncValidateOptions<TThisModel>), so a multi-model validator only gets ValidateAsync for the models that opted in.
  • Duplicate guard: if the author hand-wrote a matching ValidateAsync(string, TModel, CancellationToken), generation is skipped (mirrors the sync AlreadyImplementsValidateMethod behavior).
  • Downlevel-safe: the async symbols are loaded as optional; on TFMs without them the generator emits only the synchronous Validate exactly as before.
  • Two regression tests: shared sync+async synthesized child validator, and "manual ValidateAsync is not overwritten".

Not in scope (intentionally deferred to keep the change minimal)

  • Parallel Task.WhenAll fan-out (issue point 3). The generated body is sequential and deterministic, so failure ordering is stable and assertable. Parallelism would require per-task ValidationContext/scratch-list allocation (undoing the allocate-nothing-on-success design) and would only benefit genuinely I/O-bound validators; deferred pending a benchmarked scenario.
  • Async-only validators (a type implementing only IAsyncValidateOptions<T> without IValidateOptions<T>). Model discovery is still driven by IValidateOptions<T>; async emission is layered on top. Supporting async-only changes the SYSLIB1204 diagnostic semantics and is a natural follow-up.
  • A dedicated AlreadyImplementsValidateAsyncMethod diagnostic. The duplicate case is handled correctly (generation is skipped) but silently; surfacing a SYSLIB warning to match the sync UX needs a new resx string plus the localized .xlf fan-out and can follow separately.

Implementation notes

  • SymbolLoader: IAsyncValidatableObject and IAsyncValidateOptions`1 are resolved as optional symbols (null ⇒ feature skipped), stored as nullable on SymbolHolder.
  • Parser: threads an isAsync flag down the member/synthesized-validator recursion, and bubbles an emitsAsync flag back up. Because synthesized child validators are memoized by model type only, a cache hit reports the cached child's real async capability, so the parent awaits ValidateAsync only when the child actually emits it (otherwise it calls the child's sync Validate). New model facts: ValidatedModel.{GenerateAsyncValidateMethod, SelfValidatesAsync} and ValidatedMember.{TransValidatorEmitsAsync, EnumerationValidatorEmitsAsync}.
  • Emitter: GenAsyncModelValidationMethod mirrors the sync emitter. It elides the async modifier and returns Task.FromResult(...) when the body would contain no await (avoids CS1998). All other structure (lazy builder, reused scratch lists, #if NET trim-safe ValidationContext ctor) is unchanged from the sync path.

Note

This PR description was drafted with GitHub Copilot assistance.

Copilot AI review requested due to automatic review settings July 6, 2026 20:55
@ViveliDuCh ViveliDuCh requested a review from tarekgh July 6, 2026 20:55
@ViveliDuCh ViveliDuCh self-assigned this Jul 6, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-options
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the Microsoft.Extensions.Options [OptionsValidator] source generator to support async options validation by emitting a ValidateAsync(string?, TOptions, CancellationToken) method when the validator implements IAsyncValidateOptions<T>. It also adds unit tests that exercise async member validation, async self-validation (IAsyncValidatableObject), and the “manual ValidateAsync isn’t overwritten” guard.

Changes:

  • Add optional symbol loading for IAsyncValidateOptions<T> and IAsyncValidatableObject and thread async-generation decisions through the parser/model.
  • Emit generated ValidateAsync(...) methods that use Validator.TryValidateValueAsync(...) and async self-validation via await foreach.
  • Add new unit tests and test models validating async behavior (success/failure, shared synthesized validator, manual override).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs Adds async validation tests and new option/validator types used by those tests.
src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs Loads async validation symbols as optional inputs for downlevel-safe generation.
src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs Stores optional async symbol references used by parser/emitter.
src/libraries/Microsoft.Extensions.Options/gen/Parser.cs Propagates async generation requirements through member and synthesized-validator parsing.
src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs Tracks per-model async self-validation and async method emission flags.
src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs Tracks whether nested/synthesized validators emit async methods so the emitter can await conditionally.
src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs Emits generated ValidateAsync(...) methods and async member/transitive/enumeration/self-validation code.

Comment on lines +100 to +107
// Decide, per model, whether we additionally emit a ValidateAsync method. We do so when the
// validator type explicitly implements IAsyncValidateOptions<T> for this specific model (and the
// async symbols are available). A multi-model validator therefore only gets async validation for
// the models it opted into. If the user already hand-wrote a matching ValidateAsync, we skip
// generation to avoid emitting a duplicate member. The async requirement is propagated to any
// synthesized child validators reached from this model.
bool generateAsync = ValidatorImplementsAsyncInterfaceFor(validatorType, modelType)
&& !AlreadyImplementsValidateAsyncMethod(validatorType, modelType);
Comment on lines +219 to +228
// Detects a user-supplied ValidateAsync(string, TModel, CancellationToken) overload so the generator doesn't emit
// a duplicate member for a validator that already provides its own asynchronous implementation.
private static bool AlreadyImplementsValidateAsyncMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType)
=> validatorType
.GetMembers("ValidateAsync")
.Where(m => m.Kind == SymbolKind.Method)
.Select(m => (IMethodSymbol)m)
.Any(m => m.Parameters.Length == NumValidationMethodArgs + 1
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, modelType));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this suggestion in principle, though I don't think type comparisons should be done using ToDisplayString.

Also, the return type should probably not be checked here: if a method with the right parameters, but different return type exists, you can't implement the method anyway (at least not through implicit implementation).

Comment thread src/libraries/Microsoft.Extensions.Options/gen/Parser.cs Outdated
Comment on lines +111 to +112
bool generateAsync = ValidatorImplementsAsyncInterfaceFor(validatorType, modelType)
&& !AlreadyImplementsValidateAsyncMethod(validatorType, modelType);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should produce a diagnostic, instead of silently doing nothing, to stay symmetric with the sync case. I don't see a reason to delay this to a followup PR, as the PR description suggests.

{
if (vm.ValidationAttributes.Count > 0)
{
GenAsyncMemberValidation(vm, ref staticValidationAttributesDict, cleanListsBeforeUse);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all validation attributes on a member are sync-only, would it be worthwhile to perform their validation synchronously?

Comment on lines +832 to +839
if (willAwait)
{
OutLn($"return builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build();");
}
else
{
OutLn($"return global::System.Threading.Tasks.Task.FromResult(builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build());");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit: builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build() could be extracted to a local variable (either in the generator or in the generated code) and reused in both branches.

OutOpenBrace();

OutLn($"var count = 0;");
OutLn($"foreach (var o in options.{vm.Name}{valueAccess})");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered adding support for validating items of an IAsyncEnumerable<T>?

Though that would require also modifying DataAnnotationValidateOptions and so it's going to be out of scope for this PR.

{
OutLn($"context.MemberName = \"ValidateAsync\";");
OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"ValidateAsync\" : $\"{{name}}.ValidateAsync\";");
OutLn($"await foreach (var asyncValidationResult in global::System.Threading.Tasks.TaskAsyncEnumerableExtensions.ConfigureAwait(((global::System.ComponentModel.DataAnnotations.IAsyncValidatableObject)options).ValidateAsync(context, cancellationToken), false))");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: why is ConfigureAwait() called as a static method for IAsyncEnumerable<T> here but as an extension method for Task elsewhere?

Comment on lines +219 to +228
// Detects a user-supplied ValidateAsync(string, TModel, CancellationToken) overload so the generator doesn't emit
// a duplicate member for a validator that already provides its own asynchronous implementation.
private static bool AlreadyImplementsValidateAsyncMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType)
=> validatorType
.GetMembers("ValidateAsync")
.Where(m => m.Kind == SymbolKind.Method)
.Select(m => (IMethodSymbol)m)
.Any(m => m.Parameters.Length == NumValidationMethodArgs + 1
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, modelType));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this suggestion in principle, though I don't think type comparisons should be done using ToDisplayString.

Also, the return type should probably not be checked here: if a method with the right parameters, but different return type exists, you can't implement the method anyway (at least not through implicit implementation).

// a duplicate member for a validator that already provides its own asynchronous implementation.
private static bool AlreadyImplementsValidateAsyncMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType)
=> validatorType
.GetMembers("ValidateAsync")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this (and the existing sync version) should also check for the existence of an explicit interface implementation.

// the models it opted into. If the user already hand-wrote a matching ValidateAsync, we skip
// generation to avoid emitting a duplicate member. The async requirement is propagated to any
// synthesized child validators reached from this model.
bool generateAsync = ValidatorImplementsAsyncInterfaceFor(validatorType, modelType)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (optional, better-to-have): IAsyncValidateOptions<T> is compiled into every TFM of the Microsoft.Extensions.Options package (down to netstandard2.0), but IAsyncValidatableObject / Validator.TryValidateValueAsync are net11+. So when the package is referenced on net8/netstandard, a validator implementing IAsyncValidateOptions<T> sets generateAsync = true here, yet the emitter skips ValidateAsync because the async symbols are absent, and the class then fails to compile with a bare CS0535. This is not a regression (the same CS0535 happens today without this change), but emitting a SYSLIB diagnostic here (e.g. "source-generated ValidateAsync requires .NET 11 or later") would be friendlier than letting it surface as CS0535. Fine as a follow-up.


AsyncOptionsValidator validator = new();

ValidateOptionsResult asyncResult = await validator.ValidateAsync("AsyncOptions", options, default);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: consider adding a cancellation test. Every async test passes default here, so nothing verifies the generated ValidateAsync actually forwards cancellationToken into TryValidateValueAsync, the child ValidateAsync calls, and IAsyncValidatableObject.ValidateAsync. A future emitter change that dropped the token (e.g. emitting ValidateAsync(context) or ..., default) would still pass every current test. A model whose ValidateAsync observes the token, validated with a pre-canceled CancellationTokenSource and Assert.ThrowsAnyAsync<OperationCanceledException>, would close that gap deterministically.

}
else
{
OutLn($"return global::System.Threading.Tasks.Task.FromResult(builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build());");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this non-async Task.FromResult branch (willAwait == false) looks untested. It's reachable, e.g. a validator implementing IAsyncValidateOptions<T> for a model that only self-validates synchronously via IValidatableObject with no validation attributes and no async children. All current async test models set willAwait = true, so only the async path is covered. A small test over such a model would guard this wrapping.


#if NET
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))]
public async Task TestAsyncValidationSucceeds()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: these are behavioral tests only. The async emission isn't captured by either golden-file baseline, Main.cs's Baselines/*.g.cs comparisons or SourceGenerationTests/Baselines/*/Validators.g.cs (unchanged, since no existing input opts into IAsyncValidateOptions<T>, which is the "zero baseline drift"). So the emitted async shape (async modifier elision, Task.FromResult, await foreach + ConfigureAwait, [EnumeratorCancellation]) isn't locked anywhere. Consistent with the rest of this generator, one async golden baseline would guard against silent emission drift.

@tarekgh

tarekgh commented Jul 9, 2026

Copy link
Copy Markdown
Member

Suggestion: consider handling async-only validators in this PR rather than deferring it.

Today the generator discovers models only through IValidateOptions<T>, so a validator that implements just IAsyncValidateOptions<T> (no sync interface) is silently skipped and never gets a generated ValidateAsync. The runtime already supports that shape: OptionsBuilderExtensions resolves IEnumerable<IAsyncValidateOptions<TOptions>> from DI independently of the sync validators, so async-only registration is a real, supported scenario.

One more reason to fold it in here: with P7 being the last preview, there isn't much runway left to land it as a separate follow-up and still get preview coverage. Addressing it in this PR keeps the async support complete for the release rather than shipping a gap we'd have to fill later.

I prototyped it to gauge the cost and risk: tarekgh@b1d605c

The change is small and localized:

  • Union the model discovery over both the sync and async validate interfaces so async-only validators are picked up.
  • Add a GenerateValidateMethod flag that gates emission of the synchronous Validate. It stays true for sync-capable validators and for the synthesized child validators, and is false only for a top-level async-only validator (which would otherwise emit a dead public Validate).

One caveat worth emphasizing: this should be paired with the gate-mismatch fix I flagged earlier. IAsyncValidateOptions<T> has no TFM guard (it ships down to netstandard2.0), but IAsyncValidatableObject is .NET 11 only, and the emitter gates all async emission on the latter. So on a down-level consumer (for example a net8 app referencing the 11.0 package) an async-only validator would take the async path in the parser, emit nothing in the emitter, and surface as a bare CS0535 with no diagnostic. This isn't new to async-only: the both-interface case already hits the same bare CS0535 on down-level TFMs today. The right fix is to gate the parser's async decision on the same symbols the emitter needs (or emit a targeted "async validation requires .NET 11+" diagnostic) so down-level users get a clear message instead of CS0535. Option A and that gate fix are best done together.

Risk on .NET 11 itself looks low. Because the flag stays true everywhere the sync path is emitted today, existing output is unchanged, and the per-capability synthesized child types (__FooValidator__ / __FooAsyncValidator__) mean only top-level async-only validators are affected. The full unit suite passes with the existing golden-file baselines unchanged.

I also added two runtime tests that give good confidence here: one asserts an async-only validator generates ValidateAsync and no Validate, and one asserts nested async validation still aggregates all failures from an async-only root.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OptionsValidator] source generator: emit ValidateAsync() for IAsyncValidateOptions<T>

4 participants