Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/wiki/Custom-Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,18 @@ so `AssetId` is `IScalarValueObject<AssetId, Guid>`. Today the check is normally

```csharp
// repeated on every Guid scalar
internal IEnumerable<ValidationError> Validate()
partial void OnZodValidate(RefineCtx<AssetId> 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<ValidationError> 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
Expand Down
22 changes: 10 additions & 12 deletions docs/wiki/Source-Generator-Diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationResult<T>>` |
| 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<ValidationError>` (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<T>` 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<T>` 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<T> context)` (wrong modifiers, return type, or parameters) |
| ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable<ValidationError> Validate()`); implement `OnZodValidate` instead |

## Suppressing

Expand Down
49 changes: 30 additions & 19 deletions docs/wiki/Source-Generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` generation. |
| `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. |

Expand Down Expand Up @@ -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<ValidationError>`:
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<ValidationError> Validate()
partial void OnZodValidate(RefineCtx<Order> 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<ValidationError> Validate(RefineCtx<Order> 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<T> context)`, where `T` is the model type
(ZODSGEN035). The parameter is a plain by-value `RefineCtx<T>`.
- 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<T>` 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<ValidationError> 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<T> 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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.0.0-prerelease.26",
"version": "2.0.0-prerelease.27",
"private": true,
"license": "MIT",
"author": {
Expand Down
10 changes: 4 additions & 6 deletions src/src/SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationError>
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<T>
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<T> 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<T> 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
Expand Down
23 changes: 1 addition & 22 deletions src/src/SourceGenerators/Helpers/AttributeGenHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ static SourceText ZodSchemaAttribute()
"generated <c>{TypeName}SchemaValidator</c> partial.",
"If null, the default name <c>CustomValidationAsync</c> is used.",
"No diagnostic is reported when the default name has no matching method.",
"Mutually exclusive with <c>RefinementMethodName</c> (ZODSGEN029)."
"Mutually exclusive with the <c>OnZodValidate</c> refinement hook (ZODSGEN029)."
)
.Property(
new(
Expand All @@ -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:",
"<c>IEnumerable&lt;ValidationError&gt; MethodName()</c> or",
"<c>IEnumerable&lt;ValidationError&gt; MethodName(RefineCtx&lt;T&gt; ctx)</c>.",
"If null, the default name <c>Validate</c> is used.",
"No diagnostic is reported when the default name has no matching method.",
"Mutually exclusive with <c>CustomValidationMethodName</c> (ZODSGEN029)."
)
.Property(
new(
nameof(ZodSchemaAttributeData.RefinementMethodName),
PurviewTypeLibrary.System.String.AsTypeReference().Nullable(body),
TypeDeclarationAccessibility.Public
)
{
IsInitOnly = true,
}
);

body.XmlSummary(
"Whether to generate an <c>IValidateOptions&lt;T&gt;</c> validator for this type.",
"Generation is driven by auto-detection by default: enabled when the type name ends",
Expand Down
64 changes: 29 additions & 35 deletions src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationError>'",
/// <summary>
/// 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.
/// </summary>
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<T>' parameter when one is supplied",
/// <summary>
/// ZODSGEN035: the hook member exists but is not the generator-declared
/// <c>partial void OnZodValidate(RefineCtx&lt;T&gt; context)</c> form.
/// </summary>
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<T> context)'",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
Expand Down Expand Up @@ -294,6 +275,19 @@ static class DiagnosticLibrary
isEnabledByDefault: true
);

/// <summary>
/// Reports when a <c>[ZodSchema]</c> target declares the retired synchronous refinement method
/// (<c>IEnumerable&lt;ValidationError&gt; Validate()</c>) instead of the <c>OnZodValidate</c> hook.
/// </summary>
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<T> 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",
Expand Down
Loading
Loading