diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 0fb48a3..2a6acc8 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -286,13 +286,18 @@ so `AssetId` is `IScalarValueObject`. Today the check is normally ```csharp // repeated on every Guid scalar -internal IEnumerable Validate() +partial void OnZodValidate(RefineCtx context) { - if (Value == Guid.Empty) - yield return ErrorFactory.InvalidAssetId; + if (context.Value.Value == Guid.Empty) + context.AddIssue("invalid_asset_id", "AssetId must not be empty.", [nameof(Value)]); } ``` +> [!NOTE] +> Refinements are written as the generator-declared `OnZodValidate` hook, not an +> `IEnumerable Validate()` method — see +> [Source Generator](Source-Generator.md#refinement-hook-onzodvalidate). + Type **one** rule on the value object and put the attribute on the **scalar type**: ```csharp diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index 3e56606..de5c06b 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -9,33 +9,31 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN004 | Unsupported `[Length]` target | | ZODSGEN005 | Invalid DataAnnotations error-message resource configuration (name without type, or type without name) | | ZODSGEN006 | Unsupported DataAnnotations usage (string-only attributes on non-string targets; `[AllowedValues]`/`[DeniedValues]` on unsupported types; `[RegularExpression]` on non-strings; `[Range]` on unsupported types) | -| ZODSGEN007 | Custom/synchronous validation method configured but not found (when a name is explicitly configured) | +| ZODSGEN007 | Custom validation method configured but not found (when a name is explicitly configured) | | ZODSGEN008 | Custom method return type is not `ValueTask>` | | ZODSGEN009 | Custom method parameter count is not 2 | | ZODSGEN010 | First custom method parameter is not the model type | | ZODSGEN011 | Second custom method parameter is not `CancellationToken` | -| ZODSGEN012 | Custom/synchronous method is generic | +| ZODSGEN012 | Custom method is generic | | ZODSGEN013 | Custom method must be static when defined on the model type | | ZODSGEN014 | Custom method is inaccessible from the generated validator (private/protected) | -| ZODSGEN015 | Ambiguous custom/synchronous method overloads (only when at least two valid candidates exist) | +| ZODSGEN015 | Ambiguous custom method overloads (only when at least two valid candidates exist) | | ZODSGEN016 | Configured method name is not a valid C# identifier | -| ZODSGEN017 | Custom/synchronous method is abstract | -| ZODSGEN018 | Custom/synchronous method is an unimplemented partial method | -| ZODSGEN019 | Custom/synchronous method uses `ref`/`in`/`out`/`params`/`scoped` parameters | +| ZODSGEN017 | Custom method is abstract | +| ZODSGEN018 | Custom method is an unimplemented partial method | +| ZODSGEN019 | Custom method uses `ref`/`in`/`out`/`params`/`scoped` parameters | | ZODSGEN020 | `[Compare]` references an unknown property | | ZODSGEN021 | `System.ComponentModel.DataAnnotations` reference missing | -| ZODSGEN022 | Synchronous refinement return type is not `IEnumerable` (arrays/derived assignable types accepted) | -| ZODSGEN023 | Synchronous refinement has more than one parameter | -| ZODSGEN024 | Synchronous refinement must be an instance method | -| ZODSGEN025 | Synchronous refinement must be public or internal | -| ZODSGEN026 | Synchronous refinement's single parameter must be `RefineCtx` matching the model | | ZODSGEN027 | `IValidateOptions` requested but `Microsoft.Extensions.Options` reference is missing | | ZODSGEN028 | `IValidateOptions` requested on a struct (requires a class) | -| ZODSGEN029 | A model declares both a synchronous refinement method and an async custom validation method (only one is allowed) | +| ZODSGEN029 | A model declares both the `OnZodValidate` refinement hook and an async custom validation method (only one is allowed) | | ZODSGEN030 | A custom rule mapped through `[ZodRule(typeof(...))]` does not implement `IValidationRule` for the property type (including an unbound generic rule that cannot be closed with it) | | ZODSGEN031 | A custom rule constructor parameter could not be mapped from the attribute (`[ZodRule]`) | | ZODSGEN032 | A validation attribute could not be generated for a rule marked `[ZodRule]` | | ZODSGEN033 | (warning) A rule-mapped attribute is applied to a type that gets no generated schema (no `[ZodSchema]` and not reachable as a complex property), so the rule never runs | +| ZODSGEN034 | The `OnZodValidate` refinement hook is implemented on a type that is not `partial` (or whose containing types are not all `partial`), so the generated declaration cannot be emitted | +| ZODSGEN035 | The `OnZodValidate` refinement hook is not declared as `partial void OnZodValidate(RefineCtx context)` (wrong modifiers, return type, or parameters) | +| ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable Validate()`); implement `OnZodValidate` instead | ## Suppressing diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 8783f1e..f67e84a 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -52,8 +52,7 @@ All options are optional. | `GenerateValidateMethod` | `true` | Set to `false` to omit `Validate` (and the members that depend on it). | | `GenerateParseMethod` | `true` | Set to `false` to omit `Parse`. `Parse` requires `Validate`, so it is also omitted when `GenerateValidateMethod = false`. | | `EnableComposition` | `true` | Emits `ApplyAnd`, `ApplyOr`, `ApplyRefine` value-first composition methods. | -| `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. Mutually exclusive with the synchronous refinement method. | -| `RefinementMethodName` | `null` | Name of a synchronous refinement method; default lookup name `Validate` (an instance method on the model). Mutually exclusive with the async custom validation method. | +| `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. Mutually exclusive with the synchronous `OnZodValidate` refinement hook. | | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | @@ -86,39 +85,51 @@ Requirements: merges the error sets. > [!WARNING] -> The async custom validation method is mutually exclusive with the synchronous refinement method. A +> The async custom validation method is mutually exclusive with the `OnZodValidate` refinement hook. A > model must declare exactly one of the two — declaring both is an error (ZODSGEN029). -## Synchronous refinement +## Refinement hook (`OnZodValidate`) -Declare an instance method on the model (default name `Validate`) returning `IEnumerable`: +Refinement rules are written as a **generator-declared partial method** on the model. The generator emits the +declaration, so the IDE offers the implementation with the correct signature and no name is resolved by +convention: ```csharp -[ZodSchema(RefinementMethodName = "Validate")] -public class Order +[ZodSchema] +public partial class Order { public decimal Total { get; set; } - public IEnumerable Validate() + partial void OnZodValidate(RefineCtx context) { - if (Total < 0) - yield return ValidationError.Create("invalid_range", "Total cannot be negative", []); + if (context.Value.Total < 0) + context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); } } ``` Requirements: -- The method must be an **instance** method on the model type (it is invoked on the value being - validated). A `static` method is an error (ZODSGEN024). -- Default lookup name `Validate` unless overridden with `RefinementMethodName` on the `[ZodSchema]` - attribute. -- Parameterless or `IEnumerable Validate(RefineCtx ctx)` variants are - supported. +- The target type **and every containing type** must be declared `partial` (ZODSGEN034). This is the only + type-shape requirement the hook adds. +- The signature must be `partial void OnZodValidate(RefineCtx context)`, where `T` is the model type + (ZODSGEN035). The parameter is a plain by-value `RefineCtx`. +- The hook runs for **every** entry point into the generated schema — `Validate`, `Parse`, the + `IZodSchemaValidator` adapter, `IValidateOptions`, and any factory that validates through the schema — so + a rule written here behaves exactly like an attribute rule. +- A type that does not implement the hook allocates nothing: the generated `Validate` only builds a + `RefineCtx` and calls the hook when a body exists. +- Issues are reported through `context.AddIssue(code, message, path)`, which is merged with the + attribute-rule issues into one result. +- Refinements state is reported by **ZODSGEN036** if a member still uses the retired + `IEnumerable Validate()` contract, which the generator no longer binds. -> [!WARNING] -> The synchronous refinement method is mutually exclusive with the async custom validation method. A -> model must declare exactly one of the two — declaring both is an error (ZODSGEN029). +> [!NOTE] +> Alongside the hook, the generator emits one `internal static` bridge member, +> `InvokeZodRefinementHook(T value, RefineCtx context)`, on the target type. A classic `partial` method is +> private and the generated `{Type}Schema` is a different type, so the bridge is what lets `Validate` reach +> the hook while keeping the hook itself optional. It is not part of the type's API and must not be +> implemented by hand. ## IValidateOptions support diff --git a/package.json b/package.json index e9b717d..7cc1249 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.26", + "version": "2.0.0-prerelease.27", "private": true, "license": "MIT", "author": { diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index fb564d0..57a70fd 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -4,18 +4,16 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------ ZODSGEN020 | ZodSharp.SourceGenerator | Error | CompareAttribute references an unknown property ZODSGEN021 | ZodSharp.SourceGenerator | Error | Add a reference to System.ComponentModel.DataAnnotations -ZODSGEN022 | ZodSharp.SourceGenerator | Error | Synchronous refinement method must return IEnumerable -ZODSGEN023 | ZodSharp.SourceGenerator | Error | Synchronous refinement method must not have more than one parameter -ZODSGEN024 | ZodSharp.SourceGenerator | Error | Synchronous refinement method must be an instance method -ZODSGEN025 | ZodSharp.SourceGenerator | Error | Synchronous refinement method must be public or internal -ZODSGEN026 | ZodSharp.SourceGenerator | Error | Synchronous refinement method context parameter must be RefineCtx ZODSGEN027 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference to Microsoft.Extensions.Options ZODSGEN028 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference type -ZODSGEN029 | ZodSharp.SourceGenerator | Error | Synchronous refinement and async custom validation methods are mutually exclusive +ZODSGEN029 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook and async custom validation methods are mutually exclusive ZODSGEN030 | ZodSharp.SourceGenerator | Error | Custom rule does not implement IValidationRule for the property type (or an unbound generic rule cannot be closed with it) ZODSGEN031 | ZodSharp.SourceGenerator | Error | Unable to map an attribute value to a custom rule constructor parameter ZODSGEN032 | ZodSharp.SourceGenerator | Error | Unable to generate a validation attribute for a custom rule ZODSGEN033 | ZodSharp.SourceGenerator | Warning | Rule attribute is applied to a type that gets no generated schema +ZODSGEN034 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook requires the target type (and its containing types) to be declared partial +ZODSGEN035 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook must be declared as 'partial void OnZodValidate(RefineCtx context)' +ZODSGEN036 | ZodSharp.SourceGenerator | Error | The synchronous refinement method contract has been replaced by the OnZodValidate hook ZODSASP001 | ZodSharp.SourceGenerator | Warning | MessageFormat placeholder is not declared in Parameters ZODSASP002 | ZodSharp.SourceGenerator | Warning | Error type containing type must be partial ZODSASP003 | ZodSharp.SourceGenerator | Warning | ErrorType field must be static readonly diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index e92cbe4..3ae4d32 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -95,7 +95,7 @@ static SourceText ZodSchemaAttribute() "generated {TypeName}SchemaValidator partial.", "If null, the default name CustomValidationAsync is used.", "No diagnostic is reported when the default name has no matching method.", - "Mutually exclusive with RefinementMethodName (ZODSGEN029)." + "Mutually exclusive with the OnZodValidate refinement hook (ZODSGEN029)." ) .Property( new( @@ -108,27 +108,6 @@ static SourceText ZodSchemaAttribute() } ); - body.XmlSummary( - "Optional name of a synchronous refinement method to invoke", - "during synchronous validation. When set, the generator looks for an", - "instance method on the model type with the signature:", - "IEnumerable<ValidationError> MethodName() or", - "IEnumerable<ValidationError> MethodName(RefineCtx<T> ctx).", - "If null, the default name Validate is used.", - "No diagnostic is reported when the default name has no matching method.", - "Mutually exclusive with CustomValidationMethodName (ZODSGEN029)." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.RefinementMethodName), - PurviewTypeLibrary.System.String.AsTypeReference().Nullable(body), - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - } - ); - body.XmlSummary( "Whether to generate an IValidateOptions<T> validator for this type.", "Generation is driven by auto-detection by default: enabled when the type name ends", diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 21e5056..98ca951 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -186,46 +186,27 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - public static readonly DiagnosticDescriptor SyncValidationInvalidReturnType = new( - id: "ZODSGEN022", - title: "Invalid synchronous refinement method return type", - messageFormat: "Synchronous refinement method '{0}' on schema type '{1}' must return 'IEnumerable'", + /// + /// ZODSGEN034: the OnZodValidate hook is implemented on a type that is not partial (or whose + /// containing types are not all partial), so the generated declaration cannot be emitted. + /// + public static readonly DiagnosticDescriptor ZodRefinementHookTypeNotPartial = new( + id: "ZODSGEN034", + title: "Zod refinement hook requires a partial type", + messageFormat: "The '{0}' refinement hook on '{1}' requires '{1}' (and every containing type) to be declared 'partial' so the generated hook declaration can be emitted", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); - public static readonly DiagnosticDescriptor SyncValidationInvalidParameterCount = new( - id: "ZODSGEN023", - title: "Invalid synchronous refinement method parameter count", - messageFormat: "Synchronous refinement method '{0}' on schema type '{1}' must not have more than one parameter", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - public static readonly DiagnosticDescriptor SyncValidationInvalidStaticInstance = new( - id: "ZODSGEN024", - title: "Invalid synchronous refinement method static/instance form", - messageFormat: "Synchronous refinement method '{0}' on schema type '{1}' must be an instance method", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - public static readonly DiagnosticDescriptor SyncValidationInaccessible = new( - id: "ZODSGEN025", - title: "Inaccessible synchronous refinement method", - messageFormat: "Synchronous refinement method '{0}' on schema type '{1}' must be public or internal", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - public static readonly DiagnosticDescriptor SyncValidationInvalidContextParameter = new( - id: "ZODSGEN026", - title: "Invalid synchronous refinement method context parameter", - messageFormat: "Synchronous refinement method '{0}' on schema type '{1}' must have a 'RefineCtx' parameter when one is supplied", + /// + /// ZODSGEN035: the hook member exists but is not the generator-declared + /// partial void OnZodValidate(RefineCtx<T> context) form. + /// + public static readonly DiagnosticDescriptor ZodRefinementHookInvalidSignature = new( + id: "ZODSGEN035", + title: "Invalid Zod refinement hook declaration", + messageFormat: "The '{0}' refinement hook on '{1}' must be declared as 'partial void OnZodValidate(RefineCtx context)'", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true @@ -294,6 +275,19 @@ static class DiagnosticLibrary isEnabledByDefault: true ); + /// + /// Reports when a [ZodSchema] target declares the retired synchronous refinement method + /// (IEnumerable<ValidationError> Validate()) instead of the OnZodValidate hook. + /// + public static readonly DiagnosticDescriptor SyncRefinementMethodRetired = new( + id: "ZODSGEN036", + title: "Synchronous refinement method has been replaced by the OnZodValidate hook", + messageFormat: "Member '{0}' on '{1}' uses the retired synchronous refinement contract; implement 'partial void OnZodValidate(RefineCtx context)' on a partial type instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + public static readonly DiagnosticDescriptor MessageFormatPlaceholderNotDeclared = new( id: "ZODSASP001", title: "MessageFormat placeholder is not declared in Parameters", diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.SyncValidationMethod.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.SyncValidationMethod.cs deleted file mode 100644 index 3ca25a3..0000000 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.SyncValidationMethod.cs +++ /dev/null @@ -1,326 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using ZodSharp.SourceGenerators.Models; - -namespace ZodSharp.SourceGenerators.Helpers; - -partial class SourceGenLibrary -{ - /// - /// Reads the RefinementMethodName property from the [ZodSchema] attribute, - /// discovers and validates the matching synchronous refinement method on the schema type, - /// and returns an immutable . - /// - /// - /// The refinement method is an instance method named Validate by default (or - /// RefinementMethodName when explicitly configured) with the signature - /// IEnumerable<ValidationError> Method() or - /// IEnumerable<ValidationError> Method(RefineCtx<T> ctx). It is invoked by the - /// generated synchronous Validate and its errors are merged with the attribute-based errors. - /// - internal static GeneratorResult ResolveSyncValidationMethod( - INamedTypeSymbol classSymbol, - ZodSchemaAttributeData zodSchemaAttributeData, - AttributeData zodSchemaAttribute - ) - { - var configuredName = zodSchemaAttributeData.RefinementMethodName; - var isExplicitlyConfigured = !string.IsNullOrEmpty(configuredName); - - var methodName = string.IsNullOrWhiteSpace(configuredName) - ? TypeLibraryGenerator.DefaultSyncValidationMethodName - : configuredName!; - - // Validate the configured name is a valid C# identifier. - if (isExplicitlyConfigured && !IsValidIdentifier(methodName)) - { - return GeneratorResult.Create( - SyncValidationMethodData.None, - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationInvalidMethodName, - true, - zodSchemaAttribute, - methodName, - classSymbol.Name - ) - ); - } - - // Discover candidate methods declared directly on the type (not inherited). - var candidates = GetMethodsByName(classSymbol, methodName); - if (candidates.Count == 0) - { - // No method found — only report a diagnostic if explicitly configured. - if (isExplicitlyConfigured) - { - return GeneratorResult.Create( - SyncValidationMethodData.None, - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationMethodNotFound, - true, - GetAttributeLocation(zodSchemaAttribute, classSymbol), - methodName, - classSymbol.Name - ) - ); - } - - // No method found and not explicitly configured — this is valid, so return a "none" result. - return SyncValidationMethodData.None; - } - - // Validate each candidate and collect valid ones + diagnostics. - List validCandidates = []; - var diagnostics = ImmutableArray.CreateBuilder(); - foreach (var candidate in candidates) - { - if (ValidateSyncMethodSignature(candidate, classSymbol, diagnostics)) - validCandidates.Add(candidate); - } - - if (validCandidates.Count == 0) - { - return GeneratorResult.Create( - SyncValidationMethodData.None, - diagnostics.ToImmutable() - ); - } - - if (validCandidates.Count > 1) - { - // Ambiguous — multiple valid overloads. - return GeneratorResult.Create( - SyncValidationMethodData.None, - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationAmbiguousOverloads, - true, - validCandidates[0].Locations.Length > 0 ? validCandidates[0].Locations[0] : null, - methodName, - classSymbol.Name - ) - ); - } - - // Exactly one valid method. - var validMethod = validCandidates[0]; - return new SyncValidationMethodData( - IsConfigured: true, - Exists: true, - IsValid: true, - MethodName: methodName, - InvocationKind: validMethod.Parameters.Length == 1 - ? SyncValidationInvocationKind.WithRefineContext - : SyncValidationInvocationKind.Parameterless - ); - } - - static bool ValidateSyncMethodSignature( - IMethodSymbol method, - INamedTypeSymbol classSymbol, - ImmutableArray.Builder diagnostics - ) - { - var typeName = classSymbol.Name; - var methodLocation = method.Locations.Length > 0 ? method.Locations[0] : null; - - // Must not be generic. - if (method.IsGenericMethod) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationGenericMethod, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Must not be abstract. - if (method.IsAbstract) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationAbstractMethod, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Must not be an unimplemented partial method. - if (method.PartialDefinitionPart is not null && method.PartialImplementationPart is null) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationUnimplementedPartial, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - else if (method.IsPartialDefinition && method.PartialImplementationPart is null) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationUnimplementedPartial, - false, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Must be an instance method — the generated schema invokes it on the value. - if (method.IsStatic) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.SyncValidationInvalidStaticInstance, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Check parameter modifiers (ref, in, out, params, scoped). - foreach (var param in method.Parameters) - { - if ( - param.RefKind is RefKind.Ref or RefKind.In or RefKind.Out - || param.IsParams - || param.ScopedKind != ScopedKind.None - ) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.CustomValidationInvalidParameterModifier, - false, - methodLocation, - method.Name, - typeName - ) - ); - break; - } - } - - // Must have zero parameters or a single RefineCtx parameter. - if (method.Parameters.Length > 1) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.SyncValidationInvalidParameterCount, - true, - methodLocation, - method.Name, - typeName - ) - ); - - // Can't validate individual params if count is wrong; return early. - return false; - } - - if (method.Parameters.Length == 1 && !IsRefineContextOf(method.Parameters[0].Type, classSymbol)) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.SyncValidationInvalidContextParameter, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Return type must be assignable to IEnumerable. - if (!ReturnsEnumerableOfValidationError(method)) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.SyncValidationInvalidReturnType, - true, - methodLocation, - method.Name, - typeName - ) - ); - } - - // Accessibility — the generated validator is in the same assembly + namespace, so - // internal and public are accessible. Private and protected are not. - if (method.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.SyncValidationInaccessible, - false, - methodLocation, - method.Name, - typeName - ) - ); - } - - return diagnostics.Count == 0; - } - - static bool IsRefineContextOf(ITypeSymbol type, INamedTypeSymbol classSymbol) - { - if ( - type is not INamedTypeSymbol named - || named.MetadataName != "RefineCtx`1" - || named.ContainingNamespace.ToDisplayString() != TypeLibraryGenerator.ZodSharpSchemasNamespace - || named.TypeArguments.Length != 1 - ) - { - return false; - } - - // The type argument must match the schema type. - return SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], classSymbol); - } - - static bool ReturnsEnumerableOfValidationError(IMethodSymbol method) - { - var returnType = method.ReturnType; - if (returnType is IArrayTypeSymbol arrayType) - return IsValidationErrorType(arrayType.ElementType); - - if ( - returnType is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } named - && named.TypeArguments.Length == 1 - ) - { - return IsValidationErrorType(named.TypeArguments[0]); - } - - foreach (var iface in returnType.AllInterfaces) - { - if ( - iface is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } enumerable - && enumerable.TypeArguments.Length == 1 - && IsValidationErrorType(enumerable.TypeArguments[0]) - ) - { - return true; - } - } - - return false; - } - - static bool IsValidationErrorType(ITypeSymbol type) => - type is INamedTypeSymbol { MetadataName: "ValidationError" } named - && named.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpCoreNamespace; -} diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index ac20bdb..de019f3 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -182,13 +182,12 @@ CancellationToken cancellationToken : TypeDeclarationAccessibility.Internal : symbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(); var customValidation = ResolveCustomValidationMethod(symbol, zodSchemaAttribute, attribute!); - var syncValidation = ResolveSyncValidationMethod(symbol, zodSchemaAttribute, attribute!); + var refinementHook = ZodRefinementHookResolver.Resolve(symbol); - // A model may declare either a synchronous refinement method or an async custom - // validation method, but not both (reported as ZODSGEN029 by the analyzer). When both - // are present, fall back to the synchronous refinement so the emitted validator does - // not reference two competing validation methods. - if (customValidation.Value.HasCustomValidation && syncValidation.Value.HasSyncValidation) + // A model may declare either the Zod refinement hook or an async custom validation method, + // but not both (reported as ZODSGEN029 by the analyzer). When both are present, the hook + // wins so the emitted validator does not reference two competing validation methods. + if (customValidation.Value.HasCustomValidation && refinementHook.Value.IsImplemented) customValidation = CustomValidationMethodData.None; var isValueType = symbol.TypeKind == TypeKind.Struct; bool? generateIValidateOptions = @@ -212,7 +211,7 @@ CancellationToken cancellationToken isValueType, properties, customValidation, - syncValidation, + refinementHook, generateIValidateOptions, zodSchemaAttribute.EnableComposition, generateValidateMethod, diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index c849af2..201cd98 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -9,11 +9,23 @@ public static partial class TypeLibraryGenerator public const string ZodSharpSchemasNamespace = ZodSharpNamespace + ".Schemas"; + // Simple name of the refinement context type declared in ZodSharpSchemasNamespace. + public const string ZodRefineContextName = "RefineCtx"; + // Default custom async validation method name when none is explicitly configured. public const string DefaultCustomValidationMethodName = "CustomValidationAsync"; - // Default synchronous refinement method name when none is explicitly configured. - public const string DefaultSyncValidationMethodName = "Validate"; + // Name of the generated partial refinement hook a [ZodSchema] target may implement. The generator + // declares it, so the IDE offers the implementation and the name is never resolved by convention. + public const string ZodRefinementHookName = "OnZodValidate"; + + // Name of the generated bridge that lets a schema class (a different type) invoke the private partial + // hook. Keeps the hook itself optional while giving the generated Validate a call target. + public const string ZodRefinementHookInvokerName = "InvokeZodRefinementHook"; + + // The pre-hook synchronous refinement method name. The generator no longer binds it; it is kept so the + // retirement diagnostic (ZODSGEN036) can recognise the old contract and point at the replacement. + public const string RetiredSyncRefinementMethodName = "Validate"; // This matches the name of the class, just so we can use the `nameof` for later... [TypeRef(ZodSharpNamespace)] diff --git a/src/src/SourceGenerators/Helpers/ZodRefinementHookResolver.cs b/src/src/SourceGenerators/Helpers/ZodRefinementHookResolver.cs new file mode 100644 index 0000000..c08f86a --- /dev/null +++ b/src/src/SourceGenerators/Helpers/ZodRefinementHookResolver.cs @@ -0,0 +1,287 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Discovers and validates the OnZodValidate refinement hook on a [ZodSchema] target. +/// +/// +/// +/// The hook is a generator-declared partial method — +/// partial void OnZodValidate(RefineCtx<T> context) — so the IDE offers the implementation +/// with the correct signature and the name is never resolved by convention. +/// +/// +/// Roslyn does not surface a method symbol for a partial method implementation that has no declaration, +/// so discovery and validation are syntax based (the same approach the Purview.ValueObjects +/// generator uses). A malformed hook is reported rather than bound, which keeps the emitted validator +/// from referencing a member the compiler would reject. +/// +/// +static class ZodRefinementHookResolver +{ + /// + /// Discovers the OnZodValidate refinement hook on a [ZodSchema] target and returns an + /// immutable describing how the generated schema declares and + /// invokes it. + /// + internal static GeneratorResult Resolve(INamedTypeSymbol classSymbol) + { + // A body-less partial declaration is the one the generator itself emits into the caller's type. In a real + // compilation analyzers run after generation, so that declaration is visible here and must not be + // mistaken for a malformed implementation. + var userDeclarations = GetHookDeclarations(classSymbol) + .Where(static declaration => !IsGeneratedHookDeclaration(declaration)) + .ToArray(); + var isImplemented = userDeclarations.Any(IsValidHookImplementation); + var hasMalformedDeclaration = userDeclarations.Any(declaration => !IsValidHookImplementation(declaration)); + var chainIsPartial = IsPartialWithContainingTypes(classSymbol); + var diagnostics = ImmutableArray.CreateBuilder(); + + if (hasMalformedDeclaration) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ZodRefinementHookInvalidSignature, + true, + userDeclarations[0].Identifier.GetLocation(), + TypeLibraryGenerator.ZodRefinementHookName, + classSymbol.Name + ) + ); + } + + if (isImplemented && !chainIsPartial) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ZodRefinementHookTypeNotPartial, + true, + GetHookLocation(classSymbol) ?? GetTypeLocation(classSymbol), + TypeLibraryGenerator.ZodRefinementHookName, + classSymbol.Name + ) + ); + } + + // The retired contract — an instance Validate method returning IEnumerable — is no + // longer bound, so report it rather than silently ignoring the rules it declares. + foreach (var retiredMethod in GetRetiredRefinementMethods(classSymbol)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.SyncRefinementMethodRetired, + true, + retiredMethod.Locations.FirstOrDefault(static location => location.IsInSource) + ?? GetTypeLocation(classSymbol), + retiredMethod.Name, + classSymbol.Name + ) + ); + } + + if (hasMalformedDeclaration || (isImplemented && !chainIsPartial)) + { + return GeneratorResult.Create(ZodRefinementHookData.None, diagnostics.ToImmutable()); + } + + // The declaration is emitted whenever the chain can be reopened as partial, so the IDE can offer + // the implementation. The call site is emitted only when a body actually exists. + var hook = chainIsPartial + ? new ZodRefinementHookData(isImplemented, BuildDeclarationChain(classSymbol)) + : ZodRefinementHookData.None; + + return GeneratorResult.Create(hook, diagnostics.ToImmutable()); + } + + /// + /// Builds the type declarations a generated hook file must reopen: the containing types, outermost + /// first, followed by the target type itself. + /// + /// + /// Generated attributes are suppressed so reopening the caller's own type does not decorate it with + /// generator markers. + /// + static EquatableArray BuildDeclarationChain(INamedTypeSymbol typeSymbol) + { + Stack containingTypes = new(); + for (var current = typeSymbol.ContainingType; current is not null; current = current.ContainingType) + containingTypes.Push(current); + + var chain = ImmutableArray.CreateBuilder(); + while (containingTypes.Count > 0) + { + chain.Add( + TypeHelpers.CreatePartialTypeDeclarationOptions(containingTypes.Pop()) with + { + IncludeGeneratedAttributes = false, + } + ); + } + + chain.Add( + TypeHelpers.CreatePartialTypeDeclarationOptions(typeSymbol) with + { + IncludeGeneratedAttributes = false, + } + ); + + return new(chain.ToImmutable()); + } + + /// + /// True when the target type and every containing type is declared partial (and none is + /// file-local, since a file-local type cannot be reopened from a generated file). + /// + static bool IsPartialWithContainingTypes(INamedTypeSymbol typeSymbol) + { + for (var current = typeSymbol; current is not null; current = current.ContainingType) + { + if (current.IsFileLocal) + return false; + + var isPartial = current + .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()) + .OfType() + .Any(declaration => declaration.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword))); + + if (!isPartial) + return false; + } + + return true; + } + + /// + /// True for a hook declaration that matches the generated signature: partial, non-static, + /// non-abstract, returning void, taking a single unmodified RefineCtx<T> + /// parameter, and supplying a body. + /// + /// + /// The parameter's type argument is checked by the compiler against the generated declaration, so only + /// the shape the emitted call site depends on is validated here. + /// + static bool IsValidHookImplementation(MethodDeclarationSyntax method) => + method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) + && !method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.StaticKeyword)) + && !method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AbstractKeyword)) + && (method.Body is not null || method.ExpressionBody is not null) + && IsVoidReturnType(method) + && method.ParameterList.Parameters.Count == 1 + && IsRefineContextParameter(method.ParameterList.Parameters[0]); + + static bool IsVoidReturnType(MethodDeclarationSyntax method) => + method.ReturnType is PredefinedTypeSyntax predefined && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword); + + /// + /// True for the body-less partial declaration the generator emits into the target type. It carries no + /// implementation, and a caller cannot write it by hand (it would collide with the generated declaration). + /// + static bool IsGeneratedHookDeclaration(MethodDeclarationSyntax method) => + method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) + && method.Body is null + && method.ExpressionBody is null; + + static bool IsRefineContextParameter(ParameterSyntax parameter) + { + // Rejects ref/in/out/scoped/params/this — the generated declaration takes a plain by-value context. + if (parameter.Modifiers.Count > 0 || parameter.Type is NullableTypeSyntax) + return false; + + var generic = parameter.Type switch + { + GenericNameSyntax direct => direct, + QualifiedNameSyntax { Right: GenericNameSyntax qualified } => qualified, + _ => null, + }; + + return generic is not null + && generic.Identifier.Text == TypeLibraryGenerator.ZodRefineContextName + && generic.TypeArgumentList.Arguments.Count == 1; + } + + static MethodDeclarationSyntax[] GetHookDeclarations(INamedTypeSymbol typeSymbol) => + [ + .. typeSymbol + .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()) + .OfType() + .SelectMany(declaration => declaration.Members.OfType()) + .Where(method => method.Identifier.Text == TypeLibraryGenerator.ZodRefinementHookName), + ]; + + static Location? GetHookLocation(INamedTypeSymbol typeSymbol) => + GetHookDeclarations(typeSymbol).FirstOrDefault(IsValidHookImplementation)?.Identifier.GetLocation(); + + static Location GetTypeLocation(INamedTypeSymbol typeSymbol) => + typeSymbol.Locations.FirstOrDefault(static location => location.IsInSource) ?? Location.None; + + /// + /// Finds members that still declare the retired synchronous refinement contract: an accessible instance + /// method named Validate returning IEnumerable<ValidationError>, taking either no + /// parameters or a single RefineCtx<T>. + /// + static IEnumerable GetRetiredRefinementMethods(INamedTypeSymbol typeSymbol) => + typeSymbol + .GetMembers(TypeLibraryGenerator.RetiredSyncRefinementMethodName) + .OfType() + .Where(method => + !method.IsStatic + && !method.IsImplicitlyDeclared + && method.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal + && method.Parameters.Length <= 1 + && ReturnsEnumerableOfValidationError(method) + && (method.Parameters.Length == 0 || IsRefineContextOf(method.Parameters[0].Type, typeSymbol)) + ); + + static bool IsRefineContextOf(ITypeSymbol type, INamedTypeSymbol classSymbol) + { + if ( + type is not INamedTypeSymbol named + || named.OriginalDefinition.MetadataName != $"{TypeLibraryGenerator.ZodRefineContextName}`1" + || named.ContainingNamespace.ToDisplayString() != TypeLibraryGenerator.ZodSharpSchemasNamespace + || named.TypeArguments.Length != 1 + ) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], classSymbol); + } + + static bool ReturnsEnumerableOfValidationError(IMethodSymbol method) + { + var returnType = method.ReturnType; + if (returnType is IArrayTypeSymbol arrayType) + return IsValidationErrorType(arrayType.ElementType); + + if ( + returnType is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } named + && named.TypeArguments.Length == 1 + ) + { + return IsValidationErrorType(named.TypeArguments[0]); + } + + foreach (var iface in returnType.AllInterfaces) + { + if ( + iface is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } enumerable + && enumerable.TypeArguments.Length == 1 + && IsValidationErrorType(enumerable.TypeArguments[0]) + ) + { + return true; + } + } + + return false; + } + + static bool IsValidationErrorType(ITypeSymbol type) => + type is INamedTypeSymbol { MetadataName: "ValidationError" } named + && named.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpCoreNamespace; +} diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 863eb74..1f3e904 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -37,7 +37,7 @@ enum PropertyValidationKind /// Indicates whether the target type is a struct. /// The validatable properties of the target type that will be included in the schema. /// The custom validation method data, if any. -/// The synchronous refinement method data, if any. +/// The OnZodValidate refinement hook data, if any. /// Requested IValidateOptions generation: null = auto, true = force, false = opt out. /// Whether the value-first composition methods (ApplyAnd/ApplyOr/ApplyRefine) are generated. /// Whether the static Validate method is generated. @@ -56,7 +56,7 @@ readonly record struct ZodSchemaDescriptor( bool IsValueType, EquatableArray> Properties, GeneratorResult CustomValidationMethod, - GeneratorResult SyncValidationMethod, + GeneratorResult RefinementHook, bool? GenerateIValidateOptions, bool EnableComposition, bool GenerateValidateMethod, diff --git a/src/src/SourceGenerators/Models/SyncValidationMethodData.cs b/src/src/SourceGenerators/Models/SyncValidationMethodData.cs deleted file mode 100644 index 5415799..0000000 --- a/src/src/SourceGenerators/Models/SyncValidationMethodData.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace ZodSharp.SourceGenerators.Models; - -/// -/// How the generated validator invokes the synchronous refinement method. -/// -enum SyncValidationInvocationKind -{ - /// No synchronous refinement — nothing is invoked. - None, - - /// Invoke as an instance method with no arguments: value.MethodName(). - Parameterless, - - /// Invoke as an instance method with a RefineCtx<T> argument: value.MethodName(refineCtx). - WithRefineContext, -} - -/// -/// Immutable result of synchronous refinement method discovery and validation. -/// Carries the method name, resolved symbol (if valid), and invocation kind. -/// -readonly record struct SyncValidationMethodData( - bool IsConfigured, - bool Exists, - bool IsValid, - string MethodName, - SyncValidationInvocationKind InvocationKind -) -{ - public static readonly SyncValidationMethodData None = new( - IsConfigured: false, - Exists: false, - IsValid: false, - MethodName: string.Empty, - InvocationKind: SyncValidationInvocationKind.None - ); - - public bool HasSyncValidation => IsValid && InvocationKind != SyncValidationInvocationKind.None; -} diff --git a/src/src/SourceGenerators/Models/ZodRefinementHookData.cs b/src/src/SourceGenerators/Models/ZodRefinementHookData.cs new file mode 100644 index 0000000..5dee549 --- /dev/null +++ b/src/src/SourceGenerators/Models/ZodRefinementHookData.cs @@ -0,0 +1,25 @@ +namespace ZodSharp.SourceGenerators.Models; + +/// +/// Immutable result of OnZodValidate refinement hook discovery for a [ZodSchema] target. +/// +/// +/// True when the target supplies a hook body, so the generated Validate invokes it. A target that +/// only leaves the generated declaration in place contributes no refinement and allocates nothing. +/// +/// +/// The containing types (outermost first) followed by the target type itself, when every type in the +/// chain is declared partial so the generator can reopen them to declare the hook. Empty when the +/// chain cannot be reopened, in which case no declaration is emitted. +/// +readonly record struct ZodRefinementHookData( + bool IsImplemented, + EquatableArray DeclarationChain +) +{ + /// No hook is implemented and no hook declaration can be emitted. + public static readonly ZodRefinementHookData None = new(false, []); + + /// True when the generator emits the hook declaration onto the target type. + public bool CanEmitDeclaration => DeclarationChain.Count > 0; +} diff --git a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs index b31e3a8..9736dfa 100644 --- a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs +++ b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs @@ -7,7 +7,6 @@ readonly partial record struct ZodSchemaAttributeData( [Property(DefaultValue = true)] bool GenerateParseMethod, [Property(DefaultValue = true)] bool EnableComposition, string? CustomValidationMethodName, - string? RefinementMethodName, [Property(DefaultValue = false)] bool GenerateIValidateOptions, [Property(DefaultValue = false)] bool SuppressIValidateOptions ); diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 822fa1e..66ba2f8 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -30,11 +30,9 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.CustomValidationInvalidParameterModifier, DiagnosticLibrary.ComparePropertyNotFound, DiagnosticLibrary.DataAnnotationsReferenceNotFound, - DiagnosticLibrary.SyncValidationInvalidReturnType, - DiagnosticLibrary.SyncValidationInvalidParameterCount, - DiagnosticLibrary.SyncValidationInvalidStaticInstance, - DiagnosticLibrary.SyncValidationInaccessible, - DiagnosticLibrary.SyncValidationInvalidContextParameter, + DiagnosticLibrary.ZodRefinementHookTypeNotPartial, + DiagnosticLibrary.ZodRefinementHookInvalidSignature, + DiagnosticLibrary.SyncRefinementMethodRetired, DiagnosticLibrary.IValidateOptionsReferenceNotFound, DiagnosticLibrary.IValidateOptionsValueTypeTarget, DiagnosticLibrary.AmbiguousValidationMethods, @@ -117,24 +115,20 @@ ImmutableHashSet schemaReachableTypes foreach (var diagnosticInfo in customValidationResult.Diagnostics) context.ReportDiagnostic(diagnosticInfo.ToDiagnostic()); - var syncValidationResult = SourceGenLibrary.ResolveSyncValidationMethod( - type, - zodSchemaData, - zodSchemaAttribute! - ); - foreach (var diagnosticInfo in syncValidationResult.Diagnostics) + var hookResult = ZodRefinementHookResolver.Resolve(type); + foreach (var diagnosticInfo in hookResult.Diagnostics) context.ReportDiagnostic(diagnosticInfo.ToDiagnostic()); - // A model may declare either a synchronous refinement method or an async custom - // validation method, but not both. - if (customValidationResult.Value.HasCustomValidation && syncValidationResult.Value.HasSyncValidation) + // A model may declare either the Zod refinement hook or an async custom validation method, + // but not both. + if (customValidationResult.Value.HasCustomValidation && hookResult.Value.IsImplemented) { context.ReportDiagnostic( Diagnostic.Create( DiagnosticLibrary.AmbiguousValidationMethods, typeLocation, type.Name, - syncValidationResult.Value.MethodName, + $"{TypeLibraryGenerator.ZodRefinementHookName}({TypeLibraryGenerator.ZodRefineContextName})", customValidationResult.Value.MethodName ) ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 0dfe965..4551571 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -19,6 +19,8 @@ bool isPrimary GenerateSchemaClass(outputContext, writer, isPrimary, context.CancellationToken); context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.g.cs", writer); + GenerateRefinementHookDeclaration(outputContext, context); + context.CancellationToken.ThrowIfCancellationRequested(); if (!isPrimary || outputContext.ZodSchema.ContainingTypes.Count > 0) @@ -38,6 +40,97 @@ bool isPrimary context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}Registration.g.cs", registrationWriter); } + /// + /// Emits the optional OnZodValidate refinement hook declaration onto the target type, so the IDE + /// offers the implementation with the generator's signature. Nothing is emitted when the target and its + /// containing types are not all declared partial. + /// + /// + /// This is the only place the generator writes to a caller-owned type; it mirrors the [ErrorType] + /// emitter, which also reopens a caller-owned partial class to add generated members. + /// + static void GenerateRefinementHookDeclaration( + SchemaGenerationOutputContext outputContext, + SourceProductionContext context + ) + { + var hook = outputContext.ZodSchema.RefinementHook.Value; + if (!hook.CanEmitDeclaration) + return; + + var writer = outputContext.Context.CreateCodeWriter(); + writer.AutoGeneratedHeader(); + + using (writer.BlockNamespaceScope(outputContext.ZodSchema.TargetType.AsTypeReference())) + { + List wrapperBlocks = []; + + // The chain is the containing types, outermost first, followed by the target itself. + for (var index = 0; index < hook.DeclarationChain.Count - 1; index++) + wrapperBlocks.Add(writer.TypeScope(hook.DeclarationChain[index])); + + writer.Type( + hook.DeclarationChain[hook.DeclarationChain.Count - 1], + body => EmitRefinementHook(body, outputContext.ZodSchema) + ); + + foreach (var block in wrapperBlocks) + block.Dispose(); + } + + context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.Hooks.g.cs", writer); + } + + /// + /// Writes the partial OnZodValidate declaration the caller implements. + /// + static void EmitRefinementHook(CodeWriter writer, ZodSchemaDescriptor schema) + { + var targetType = schema.TargetType.AsTypeReference(); + TypeReference refineContextType = new( + new TypeIdentity( + TypeLibraryGenerator.ZodRefineContextName, + TypeLibraryGenerator.ZodSharpSchemasNamespace, + 1 + ).MakeGeneric(targetType) + ); + + writer.XmlSummary( + "Optional ZodSharp refinement hook. Implement this partial method to add Zod-compatible", + $"validation rules to {schema.TargetType.Name}.", + "Issues added to the context are reported by the generated validator: Validate, Parse, the DI", + "adapter, IValidateOptions, and any factory that validates through the generated schema." + ); + writer.XmlParam( + "context", + "The refinement context carrying the value under validation and the issues it collects." + ); + + writer.PartialMethod( + new(TypeLibraryGenerator.ZodRefinementHookName) { Parameters = [new("context", refineContextType)] } + ); + + // A classic partial method is private, and the generated schema lives in a different type, so it + // cannot call the hook directly. This internal bridge keeps the hook optional (an unimplemented + // partial method elides the call entirely) while giving the schema a call target. + writer.NewLine(); + writer.XmlSummary( + "Invokes the optional ZodSharp refinement hook when it is implemented.", + "Generated for the schema validator to call; not part of the type's own API." + ); + writer.Method( + new MethodDeclarationOptions( + TypeLibraryGenerator.ZodRefinementHookInvokerName, + TypeDeclarationAccessibility.Internal + ) + { + IsStatic = true, + Parameters = [new("value", targetType), new("context", refineContextType)], + }, + body => body.MethodCall($"value.{TypeLibraryGenerator.ZodRefinementHookName}", "context") + ); + } + static void GenerateSchemaClass( SchemaGenerationOutputContext outputContext, CodeWriter writer, @@ -360,7 +453,7 @@ CancellationToken cancellationToken GenerateTypeRuleValidations(writer, schema); - GenerateSyncRefinement(schema, method); + GenerateZodRefinement(schema, method); method.IfBlock( "errors is not null", @@ -385,34 +478,31 @@ CancellationToken cancellationToken writer.NewLine(); } - static void GenerateSyncRefinement(ZodSchemaDescriptor schema, CodeWriter method) + static void GenerateZodRefinement(ZodSchemaDescriptor schema, CodeWriter method) { - var syncMethod = schema.SyncValidationMethod.Value; - if (!syncMethod.HasSyncValidation) + var hook = schema.RefinementHook.Value; + if (!hook.IsImplemented) return; -#pragma warning disable IDE0072 // Add missing cases - var invocation = syncMethod.InvocationKind switch - { - SyncValidationInvocationKind.WithRefineContext => "refineCtx", - _ => string.Empty, - }; -#pragma warning restore IDE0072 // Add missing cases - - if (syncMethod.InvocationKind == SyncValidationInvocationKind.WithRefineContext) - { - var refineCtxType = - $"global::{TypeLibraryGenerator.ZodSharpSchemasNamespace}.RefineCtx<{schema.TargetType.AsTypeReference()}>"; - method.Assignment(refineCtxType, "refineCtx", $"new(value, EmptyPath)"); - } - + // The hook runs after the attribute and type rules, so its issues merge into the same error set + // the generated Validate returns. Nothing is emitted when the target supplies no body, so a type + // that only leaves the generated declaration in place stays on the zero-allocation path. method.Line(); - method.Assignment("var", "refinementErrors", $"value.{syncMethod.MethodName}({invocation})"); - using (method.IfBlockScope("refinementErrors is not null")) + method.Assignment( + $"global::{TypeLibraryGenerator.ZodSharpSchemasNamespace}.RefineCtx<{schema.TargetType.AsTypeReference()}>", + "refineContext", + "new(value, EmptyPath)" + ); + method.MethodCall( + $"{schema.TargetType.AsTypeReference()}.{TypeLibraryGenerator.ZodRefinementHookInvokerName}", + "value", + "refineContext" + ); + using (method.IfBlockScope("refineContext.HasIssues")) { method.Foreach( - "var refinementError in refinementErrors", - foreachBody => foreachBody.MethodCall("AddError", "ref errors", "refinementError") + "var refinementIssue in refineContext.Issues", + foreachBody => foreachBody.MethodCall("AddError", "ref errors", "refinementIssue") ); } diff --git a/src/src/ZodSharp/Schemas/RefineCtx.cs b/src/src/ZodSharp/Schemas/RefineCtx.cs index bd8c9fe..86fbfc0 100644 --- a/src/src/ZodSharp/Schemas/RefineCtx.cs +++ b/src/src/ZodSharp/Schemas/RefineCtx.cs @@ -17,7 +17,9 @@ namespace ZodSharp.Schemas; /// The base path prepended to any added issues. public sealed class RefineCtx(T value, ImmutableArray path) { - readonly List _issues = []; + // The issue list is allocated on the first issue rather than up front, so a refinement that passes + // costs a single context allocation and a refinement that never runs costs none. + List? _issues; /// /// The value being validated. @@ -32,12 +34,12 @@ public sealed class RefineCtx(T value, ImmutableArray path) /// /// The issues added by the refinement so far. /// - public IReadOnlyList Issues => _issues; + public IReadOnlyList Issues => _issues ?? (IReadOnlyList)[]; /// /// True when at least one issue has been added. /// - public bool HasIssues => _issues.Count > 0; + public bool HasIssues => _issues is { Count: > 0 }; /// /// Adds a validation issue with the given code and message, prefixed by the @@ -54,7 +56,7 @@ public void AddIssue(string code, string message, string[]? path = null) throw new ArgumentException("Issue message must not be null or whitespace.", nameof(message)); var fullPath = BuildPath(Path, path); - _issues.Add(new ValidationError(code, message, fullPath)); + (_issues ??= []).Add(new ValidationError(code, message, fullPath)); } /// @@ -86,5 +88,5 @@ static string[] BuildPath(ImmutableArray basePath, string[]? relativePat return combined; } - internal ImmutableArray ToImmutable() => [.. _issues]; + internal ImmutableArray ToImmutable() => _issues is { Count: > 0 } ? [.. _issues] : []; } diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs index e20384a..accc552 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs @@ -32,7 +32,6 @@ public sealed class ZodSchemaAttribute : System.Attribute public bool GenerateParseMethod { get; init; } = true; public bool EnableComposition { get; init; } = true; public string? CustomValidationMethodName { get; init; } - public string? RefinementMethodName { get; init; } public bool GenerateIValidateOptions { get; init; } = false; public bool SuppressIValidateOptions { get; init; } = false; } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.SyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.SyncValidation.cs index 4c831ac..768e442 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.SyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.SyncValidation.cs @@ -5,115 +5,120 @@ namespace ZodSharp.SourceGenerators; public partial class ZodSchemaAnalyzerTests { [Test] - public async Task SyncValidation_GivenExplicitNameMissing_ProducesZODSGEN007(CancellationToken cancellationToken) - { - const string source = """ - namespace Testing - { - [ZodSchema(RefinementMethodName = "DoesNotExist")] - public class MissingMethod { public string? Name { get; set; } } - } - """; - - var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CustomValidationMethodNotFound); - } - - [Test] - public async Task SyncValidation_GivenVoidReturnType_ProducesZODSGEN022(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenImplementationOnPartialType_ProducesNoDiagnostics( + CancellationToken cancellationToken + ) { var source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + using ZodSharp.Schemas; + namespace Testing { [ZodSchema] - public class VoidReturn + public partial class Paging { - public string? Name { get; set; } + [Range(1, 100)] + public int DefaultPageSize { get; set; } = 50; + + // Mirrors the declaration the generator emits into the target type. Analyzers run after + // generation, so this is visible alongside the caller's implementation. + partial void OnZodValidate(RefineCtx context); - public void Validate() + partial void OnZodValidate(RefineCtx context) { + if (context.Value.DefaultPageSize > 100) + context.AddIssue("custom", "Too big.", [nameof(DefaultPageSize)]); } } } """; var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncValidationInvalidReturnType); + await Assert.That(result).HasNoDiagnostics(); } [Test] - public async Task SyncValidation_GivenTooManyParameters_ProducesZODSGEN023(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenImplementationOnNonPartialType_ProducesZODSGEN034( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; - using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class TooManyParams + public class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - public IEnumerable Validate(int extra, string another) => []; + partial void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Too big.", [nameof(DefaultPageSize)]); + } } } """; var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncValidationInvalidParameterCount); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementHookTypeNotPartial); } [Test] - public async Task SyncValidation_GivenStaticMethod_ProducesZODSGEN024(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenNonPartialDeclaration_ProducesZODSGEN035( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; - using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class StaticMethod + public partial class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - public static IEnumerable Validate() => []; + public void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Too big.", [nameof(DefaultPageSize)]); + } } } """; var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncValidationInvalidStaticInstance); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementHookInvalidSignature); } [Test] - public async Task SyncValidation_GivenPrivateMethod_ProducesZODSGEN025(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenParameterlessDeclaration_ProducesZODSGEN035( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; - using ZodSharp.Core; - namespace Testing { [ZodSchema] - public class PrivateMethod + public partial class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - IEnumerable Validate() => []; + partial void OnZodValidate() + { + } } } """; var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncValidationInaccessible); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementHookInvalidSignature); } [Test] - public async Task SyncValidation_GivenNonRefineContextParameter_ProducesZODSGEN026( - CancellationToken cancellationToken - ) + public async Task SyncRefinement_GivenRetiredValidateMethod_ProducesZODSGEN036(CancellationToken cancellationToken) { var source = """ using System.Collections.Generic; @@ -122,39 +127,33 @@ CancellationToken cancellationToken namespace Testing { [ZodSchema] - public class BadContext + public class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - public IEnumerable Validate(string ctx) => []; + public IEnumerable Validate() => []; } } """; var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncValidationInvalidContextParameter); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.SyncRefinementMethodRetired); } [Test] - public async Task SyncValidation_GivenValidParameterlessMethod_ProducesNoDiagnostics( + public async Task SyncRefinement_GivenUnrelatedValidateMethod_ProducesNoDiagnostics( CancellationToken cancellationToken ) { var source = """ - using System.Collections.Generic; - using ZodSharp.Core; - namespace Testing { [ZodSchema] - public class ValidSync + public class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - public IEnumerable Validate() - { - yield break; - } + public bool Validate() => true; } } """; @@ -164,26 +163,23 @@ public IEnumerable Validate() } [Test] - public async Task SyncValidation_GivenValidRefineContextMethod_ProducesNoDiagnostics( + public async Task ZodRefinementHook_GivenDeclarationWithoutImplementation_ProducesNoDiagnostics( CancellationToken cancellationToken ) { - var source = """ - using System.Collections.Generic; - using ZodSharp.Core; + // Mirrors a target type that leaves the generated declaration in place and supplies no body: the + // declaration must not be mistaken for a malformed hook. + const string source = """ using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class ValidCtxSync + public partial class Paging { - public string? Name { get; set; } + public int DefaultPageSize { get; set; } = 50; - public IEnumerable Validate(RefineCtx ctx) - { - yield break; - } + partial void OnZodValidate(RefineCtx context); } } """; @@ -193,23 +189,15 @@ public IEnumerable Validate(RefineCtx ctx) } [Test] - public async Task SyncValidation_GivenInternalMethod_ProducesNoDiagnostics(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenNoHook_ProducesNoDiagnostics(CancellationToken cancellationToken) { - var source = """ - using System.Collections.Generic; - using ZodSharp.Core; - + const string source = """ namespace Testing { [ZodSchema] - public class InternalSync + public partial class Paging { - public string? Name { get; set; } - - internal IEnumerable Validate() - { - yield break; - } + public int DefaultPageSize { get; set; } = 50; } } """; @@ -217,19 +205,4 @@ internal IEnumerable Validate() var result = await AnalyzeAsync(source, cancellationToken); await Assert.That(result).HasNoDiagnostics(); } - - [Test] - public async Task SyncValidation_GivenNoMethod_ProducesNoDiagnostics(CancellationToken cancellationToken) - { - const string source = """ - namespace Testing - { - [ZodSchema] - public class NoMethod { public string? Name { get; set; } } - } - """; - - var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).HasNoDiagnostics(); - } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationMethods.cs index cbfb2fa..543108c 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationMethods.cs @@ -5,22 +5,25 @@ namespace ZodSharp.SourceGenerators; public partial class ZodSchemaAnalyzerTests { [Test] - public async Task ValidationMethods_GivenBothSyncAndAsync_ProducesZODSGEN029(CancellationToken cancellationToken) + public async Task ValidationMethods_GivenHookAndAsync_ProducesZODSGEN029(CancellationToken cancellationToken) { var source = """ - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class Both + public partial class Both { public string? Name { get; set; } - public IEnumerable Validate() => []; + partial void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Nope.", [nameof(Name)]); + } internal static ValueTask> CustomValidationAsync( Both value, CancellationToken ct) => @@ -34,15 +37,15 @@ internal static ValueTask> CustomValidationAsync( } [Test] - public async Task ValidationMethods_GivenSyncOnModelAndAsyncOnSchemaValidator_ProducesZODSGEN029( + public async Task ValidationMethods_GivenHookOnModelAndAsyncOnSchemaValidator_ProducesZODSGEN029( CancellationToken cancellationToken ) { var source = """ - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { @@ -51,7 +54,10 @@ public partial class Both { public string? Name { get; set; } - public IEnumerable Validate() => []; + partial void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Nope.", [nameof(Name)]); + } } public partial class BothSchemaValidator @@ -68,20 +74,22 @@ public ValueTask> CustomValidationAsync( } [Test] - public async Task ValidationMethods_GivenOnlySyncMethod_HasNoDiagnostics(CancellationToken cancellationToken) + public async Task ValidationMethods_GivenOnlyHookMethod_HasNoDiagnostics(CancellationToken cancellationToken) { var source = """ - using System.Collections.Generic; - using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class OnlySync + public partial class OnlyHook { public string? Name { get; set; } - public IEnumerable Validate() => []; + partial void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Nope.", [nameof(Name)]); + } } } """; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs index 69f1754..346be42 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs @@ -60,7 +60,7 @@ public class Empty { } } [Test] - public async Task Generate_GivenAttributeFiles_ContainsRefinementMethodNameProperty( + public async Task Generate_GivenAttributeFiles_DoesNotContainRetiredRefinementMethodNameProperty( CancellationToken cancellationToken ) { @@ -76,10 +76,11 @@ public class Empty { } // Act var driverResult = await GenerateAsync(source, cancellationToken); - // Assert — the generated attribute exposes the refinement method name property + // Assert — the refinement name is no longer configured by convention; the OnZodValidate hook + // is declared by the generator instead. var attributeSources = driverResult.AllSyntaxTrees.Select(static t => t.GetText().ToString()).ToList(); var allAttributeSource = string.Join("\n", attributeSources); - await Assert.That(allAttributeSource).Contains("RefinementMethodName"); + await Assert.That(allAttributeSource).DoesNotContain("RefinementMethodName", StringComparison.Ordinal); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs index 54d5a99..d4a2c5d 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs @@ -7,47 +7,11 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGeneratorTests { [Test] - public async Task SyncValidation_GivenParameterlessMethod_GeneratesRefinementCall( + public async Task ZodRefinementHook_GivenImplementation_GeneratesInvocationAndDeclaration( CancellationToken cancellationToken ) { var source = """ - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - using ZodSharp.Core; - - namespace Testing - { - [ZodSchema] - public class Paging - { - [Range(1, 100)] - public int DefaultPageSize { get; set; } = 50; - - public IEnumerable Validate() - { - if (DefaultPageSize > 100) - yield return new ValidationError("custom", "Too big.", [nameof(DefaultPageSize)]); - } - } - } - """; - - var driverResult = await GenerateAsync(source, cancellationToken); - var generated = driverResult.GetSource("PagingSchema"); - - await Assert.That(generated).ContainsGeneratedCode("var refinementErrors = value.Validate();"); - await Assert.That(generated).ContainsGeneratedCode("foreach (var refinementError in refinementErrors)"); - await Assert.That(generated).ContainsGeneratedCode("AddError(ref errors, refinementError)"); - } - - [Test] - public async Task SyncValidation_GivenRefineContextMethod_GeneratesContextCreation( - CancellationToken cancellationToken - ) - { - var source = """ - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using ZodSharp.Core; using ZodSharp.Schemas; @@ -55,15 +19,15 @@ CancellationToken cancellationToken namespace Testing { [ZodSchema] - public class Paging + public partial class Paging { - [Range(1, 100)] + [Range(1, int.MaxValue)] public int DefaultPageSize { get; set; } = 50; - public IEnumerable Validate(RefineCtx ctx) + partial void OnZodValidate(RefineCtx context) { - if (ctx.Value.DefaultPageSize > 100) - yield return new ValidationError("custom", "Too big.", [nameof(DefaultPageSize)]); + if (context.Value.DefaultPageSize > 100) + context.AddIssue("custom", "Too big.", [nameof(DefaultPageSize)]); } } } @@ -75,102 +39,53 @@ public IEnumerable Validate(RefineCtx ctx) await Assert .That(generated) .ContainsGeneratedCode( - "global::ZodSharp.Schemas.RefineCtx refineCtx = new(value, EmptyPath)" + "global::ZodSharp.Schemas.RefineCtx refineContext = new(value, EmptyPath)" ); - await Assert.That(generated).ContainsGeneratedCode("var refinementErrors = value.Validate(refineCtx);"); - } - - [Test] - public async Task SyncValidation_GivenOverriddenMethodName_UsesConfiguredName(CancellationToken cancellationToken) - { - var source = """ - using System.Collections.Generic; - using ZodSharp.Core; - - namespace Testing - { - [ZodSchema(RefinementMethodName = nameof(ValidateRules))] - public class RulesModel - { - public string? Name { get; set; } - - public IEnumerable ValidateRules() - { - yield break; - } - } - } - """; - - var driverResult = await GenerateAsync(source, cancellationToken); - var generated = driverResult.GetSource("RulesModelSchema"); - - await Assert.That(generated).ContainsGeneratedCode("var refinementErrors = value.ValidateRules();"); + await Assert.That(generated).ContainsGeneratedCode("InvokeZodRefinementHook(value, refineContext)"); + await Assert.That(generated).ContainsGeneratedCode("refineContext.HasIssues"); + await Assert.That(generated).ContainsGeneratedCode("AddError(ref errors, refinementIssue)"); + + var hooks = driverResult.GetSource("PagingSchema.Hooks.g.cs", HintNameMatchMode.Suffix); + await Assert.That(hooks).Contains("partial class Paging"); + await Assert.That(hooks).Contains("partial void OnZodValidate"); + await Assert.That(hooks).Contains("RefineCtx"); + await Assert.That(hooks).Contains("InvokeZodRefinementHook"); } [Test] - public async Task SyncValidation_Runtime_CrossFieldErrorsMergeWithSchemaErrors(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenNoImplementation_DoesNotDeclareOrInvoke( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; - using ZodSharp.Core; namespace Testing { [ZodSchema] - public class Paging + public partial class Paging { [Range(1, int.MaxValue)] public int DefaultPageSize { get; set; } = 50; - - [Range(1, int.MaxValue)] - public int MaxPageSize { get; set; } = 200; - - public IEnumerable Validate() - { - if (DefaultPageSize > MaxPageSize) - yield return new ValidationError( - "custom", - "DefaultPageSize must be less than or equal to MaxPageSize.", - [nameof(DefaultPageSize), nameof(MaxPageSize)]); - } } } """; - var driverResult = await GenerateAsync( - source, - new ZodSourceGeneratorTestOptions().Compile(), - cancellationToken - ); - var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); - - var modelType = assembly.GetType("Testing.Paging")!; - var schemaType = assembly.GetType("Testing.PagingSchema")!; - - var instance = Activator.CreateInstance(modelType)!; - modelType.GetProperty("DefaultPageSize")!.SetValue(instance, 1000); - modelType.GetProperty("MaxPageSize")!.SetValue(instance, 500); - - var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; - var errors = (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("PagingSchema"); - await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); - await Assert.That(errors.Length).IsEqualTo(1); - await Assert.That(errors[0].Code).IsEqualTo("custom"); - await Assert.That(errors[0].Path.Length).IsEqualTo(2); - await Assert.That(errors[0].Path[0]).IsEqualTo("DefaultPageSize"); - await Assert.That(errors[0].Path[1]).IsEqualTo("MaxPageSize"); + // The validator neither allocates a context nor invokes the hook when no body is supplied. + await Assert.That(generated).DoesNotContain("OnZodValidate", StringComparison.Ordinal); + await Assert.That(generated).DoesNotContain("refineContext", StringComparison.Ordinal); } [Test] - public async Task SyncValidation_Runtime_ValidValueReturnsSuccess(CancellationToken cancellationToken) + public async Task ZodRefinementHook_GivenNonPartialTarget_DoesNotEmitDeclaration( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; - using ZodSharp.Core; namespace Testing { @@ -179,53 +94,31 @@ public class Paging { [Range(1, int.MaxValue)] public int DefaultPageSize { get; set; } = 50; - - [Range(1, int.MaxValue)] - public int MaxPageSize { get; set; } = 200; - - public IEnumerable Validate() - { - if (DefaultPageSize > MaxPageSize) - yield return new ValidationError( - "custom", - "DefaultPageSize must be less than or equal to MaxPageSize.", - [nameof(DefaultPageSize), nameof(MaxPageSize)]); - } } } """; - var driverResult = await GenerateAsync( - source, - new ZodSourceGeneratorTestOptions().Compile(), - cancellationToken - ); - var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); - - var modelType = assembly.GetType("Testing.Paging")!; - var schemaType = assembly.GetType("Testing.PagingSchema")!; - - var instance = Activator.CreateInstance(modelType)!; - modelType.GetProperty("DefaultPageSize")!.SetValue(instance, 1); - modelType.GetProperty("MaxPageSize")!.SetValue(instance, 500); + var driverResult = await GenerateAsync(source, cancellationToken); - var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; - await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsTrue(); + var emitted = driverResult.AllSyntaxTrees.Select(static tree => tree.GetText().ToString()); + await Assert + .That(emitted.Any(text => text.Contains("partial void OnZodValidate", StringComparison.Ordinal))) + .IsFalse(); } [Test] - public async Task SyncValidation_Runtime_RefineContextVariantSurfacesErrors(CancellationToken cancellationToken) + public async Task ZodRefinementHook_Runtime_CrossFieldErrorsMergeWithSchemaErrors( + CancellationToken cancellationToken + ) { var source = """ - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; - using ZodSharp.Core; using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class Paging + public partial class Paging { [Range(1, int.MaxValue)] public int DefaultPageSize { get; set; } = 50; @@ -233,10 +126,10 @@ public class Paging [Range(1, int.MaxValue)] public int MaxPageSize { get; set; } = 200; - public IEnumerable Validate(RefineCtx ctx) + partial void OnZodValidate(RefineCtx context) { - if (ctx.Value.DefaultPageSize > ctx.Value.MaxPageSize) - yield return new ValidationError( + if (context.Value.DefaultPageSize > context.Value.MaxPageSize) + context.AddIssue( "custom", "DefaultPageSize must be less than or equal to MaxPageSize.", [nameof(DefaultPageSize), nameof(MaxPageSize)]); @@ -265,20 +158,22 @@ public IEnumerable Validate(RefineCtx ctx) await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); await Assert.That(errors.Length).IsEqualTo(1); await Assert.That(errors[0].Code).IsEqualTo("custom"); + await Assert.That(errors[0].Path.Length).IsEqualTo(2); + await Assert.That(errors[0].Path[0]).IsEqualTo("DefaultPageSize"); + await Assert.That(errors[0].Path[1]).IsEqualTo("MaxPageSize"); } [Test] - public async Task SyncValidation_Runtime_AsyncPathIncludesRefinement(CancellationToken cancellationToken) + public async Task ZodRefinementHook_Runtime_ValidValueReturnsSuccess(CancellationToken cancellationToken) { var source = """ - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; - using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class Paging + public partial class Paging { [Range(1, int.MaxValue)] public int DefaultPageSize { get; set; } = 50; @@ -286,10 +181,10 @@ public class Paging [Range(1, int.MaxValue)] public int MaxPageSize { get; set; } = 200; - public IEnumerable Validate() + partial void OnZodValidate(RefineCtx context) { - if (DefaultPageSize > MaxPageSize) - yield return new ValidationError( + if (context.Value.DefaultPageSize > context.Value.MaxPageSize) + context.AddIssue( "custom", "DefaultPageSize must be less than or equal to MaxPageSize.", [nameof(DefaultPageSize), nameof(MaxPageSize)]); @@ -306,21 +201,13 @@ public IEnumerable Validate() var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); var modelType = assembly.GetType("Testing.Paging")!; - var validatorType = assembly.GetType("Testing.PagingSchemaValidator")!; + var schemaType = assembly.GetType("Testing.PagingSchema")!; var instance = Activator.CreateInstance(modelType)!; - modelType.GetProperty("DefaultPageSize")!.SetValue(instance, 1000); + modelType.GetProperty("DefaultPageSize")!.SetValue(instance, 1); modelType.GetProperty("MaxPageSize")!.SetValue(instance, 500); - dynamic validator = Activator.CreateInstance(validatorType)!; - dynamic dynInstance = instance; - dynamic task = validator.ValidateAsync(dynInstance, CancellationToken.None); - var result = await task; - dynamic dynResult = result; - var errors = (ImmutableArray)dynResult.Errors; - - await Assert.That((bool)dynResult.IsSuccess).IsFalse(); - await Assert.That(errors.Length).IsEqualTo(1); - await Assert.That(errors[0].Code).IsEqualTo("custom"); + var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsTrue(); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs index 27c6a61..fe911e0 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs @@ -3,24 +3,25 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGeneratorTests { [Test] - public async Task ValidationMethods_GivenBothSyncAndAsync_EmitsSyncRefinementOnly( - CancellationToken cancellationToken - ) + public async Task ValidationMethods_GivenHookAndAsync_EmitsHookOnly(CancellationToken cancellationToken) { var source = """ - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ZodSharp.Core; + using ZodSharp.Schemas; namespace Testing { [ZodSchema] - public class WithBoth + public partial class WithBoth { public string? Name { get; set; } - public IEnumerable Validate() => []; + partial void OnZodValidate(RefineCtx context) + { + context.AddIssue("custom", "Nope.", [nameof(Name)]); + } internal static ValueTask> CustomValidationAsync( WithBoth value, CancellationToken ct) => @@ -32,8 +33,8 @@ internal static ValueTask> CustomValidationAsync( var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithBothSchema"); - // The synchronous refinement is honoured... - await Assert.That(generated).ContainsGeneratedCode("var refinementErrors = value.Validate();"); + // The refinement hook is honoured... + await Assert.That(generated).ContainsGeneratedCode("InvokeZodRefinementHook(value, refineContext)"); // ...and the async custom method is dropped so the validator does not reference both. await Assert.That(generated).DoesNotContain("CustomValidationAsync", StringComparison.Ordinal);