diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 910314c..09a2b2f 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -18,7 +18,7 @@ ValidationResult result = schema.Validate(value); | `Validate(TInput value)` | Returns a `ValidationResult`. Never throws. | | `SafeParse(TInput value)` | Alias of `Validate`. | | `Parse(TInput value)` | Returns the validated `TOutput`, or throws `ZodException` on failure (via `GetValueOrThrow()`). | -| `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; this exists for interface symmetry and the source generator's custom async validation. | +| `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` when already cancelled. Genuinely async work only happens in the source generator's custom async validation. | ## ValidationResult @@ -64,7 +64,8 @@ catch (ZodException ex) - Schemas are classes deriving from `ZodType`; the fluent methods return `this` (or a wrapping schema) so chains read naturally. - Rules are `readonly record struct` implementations of `IValidationRule` (`bool IsValid(in T value)`, `string GetErrorMessage(in T value)`) — zero allocation. -- `ValidateSpan(ReadOnlySpan value)` is available on `ZodString` for span-based validation. +- Custom rules can be attached with the public `AddRule`/`Rule` methods and surfaced as DataAnnotations-style attributes; see [Custom Rules](Custom-Rules.md). +- `ValidateSpan(ReadOnlySpan value)` is available on `ZodString` for span-based validation; use `IsValidSpan(value, out errors)` for an allocation-free check. - A schema's `Description` is set with `.Describe("...")`. ## Composition model diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md new file mode 100644 index 0000000..0fb48a3 --- /dev/null +++ b/docs/wiki/Custom-Rules.md @@ -0,0 +1,377 @@ +# Custom Rules + +A **rule** is a `readonly record struct` (or class) implementing `ZodSharp.Core.IValidationRule`. Rules are evaluated after a schema's type/structural check succeeds; each failing rule adds a `ValidationError` to the result. + +Custom rules are first class: + +- attach them to a schema with the public `AddRule`/`Rule` API, or +- surface them as a `System.ComponentModel.DataAnnotations`-style attribute (for example `[NoWhitespace]`) that the `[ZodSchema]` source generator honours exactly like `[EmailAddress]` or `[Range]`. + +## The rule contract + +```csharp +namespace ZodSharp.Core; + +public interface IValidationRule +{ + bool IsValid(in T value); + string GetErrorMessage(in T value); +} +``` + +Implementations should be structs so validation does not allocate. `IsValid` is called only when the surrounding schema succeeded and (for nullable properties) the value is not `null`. + +For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan value)` / `string GetErrorMessage(ReadOnlySpan value)`) so the rule participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. Rules that only implement `IValidationRule` are still fully supported; they simply fall back to the string pipeline for span validation. + +## Defining a custom rule + +```csharp +using ZodSharp.Core; + +namespace MyRules; + +/// Rejects strings that contain whitespace. +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule +{ + public bool IsValid(in string value) + { + if (value is null) + return false; + + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + return false; + } + + return true; + } + + public string GetErrorMessage(in string value) => + Message ?? $"Whitespace is not allowed in '{value}'."; +} +``` + +The rule can be used standalone: + +```csharp +var rule = new NoWhitespaceRule(); +if (!rule.IsValid("John Doe")) + Console.WriteLine(rule.GetErrorMessage("John Doe")); +``` + +## Attaching a rule to a schema + +`ZodType.AddRule(IValidationRule)` and the generic `Rule(TRule)` helper are public, so a custom rule can be composed directly: + +```csharp +using ZodSharp; +using MyRules; + +var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed.")); + +var result = schema.Validate("John Doe"); +// result.IsSuccess == false +// result.Errors[0].Code == "validation_failed" +// result.Errors[0].Message == "No spaces allowed." +// result.Errors[0].Path is empty +``` + +Both methods mutate the receiver and return it for chaining; see [Guarantees and Limitations](Guarantees-and-Limitations.md#fluent-rule-methods-mutate-the-receiver). + +## Exposing a rule as a DataAnnotations attribute + +Built-in rules map to `System.ComponentModel.DataAnnotations` attributes (`EmailRule` ↔ `[EmailAddress]`). A custom rule gets the same treatment in two steps: + +1. Author a `ValidationAttribute` whose properties mirror the rule's constructor parameters. +2. Map it to the rule with `[ZodRule(typeof(...))]`. + +```csharp +using System; +using System.ComponentModel.DataAnnotations; +using ZodSharp.Core; +using MyRules; + +[ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + /// Overrides the rule's default error message. + public string? Message { get; set; } +} +``` + +Apply it to a `[ZodSchema]` model like any other annotation: + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +[ZodSchema] +public class User +{ + [Required] + [NoWhitespace(Message = "No spaces allowed.")] + public string Name { get; set; } = string.Empty; +} +``` + +The generator emits rule-based validation, so `UserSchema.Validate(user)` fails for `"John Doe"` with: + +```text +Code = "invalid_string" +Message = "No spaces allowed." +Origin = "string" +Path = ["Name"] +``` + +Because the attribute derives from `ValidationAttribute`, the property participates in the same "carries a data annotation" discovery as the built-in attributes. The default error code is `validation_failed` when `Code` is not set. + +> [!NOTE] +> The attribute's constructor arguments are mapped positionally and its named arguments by name (case-insensitive) to the rule's public constructor parameters. A parameter named `message` is supplied from the attribute's `ErrorMessage` when one is set. + +### Error identity: code and origin precedence + +One attribute type can serve many members that each need a different error code. The generator resolves `Code`/`Origin` in this order (first match wins): + +1. **Rule-owned** — the rule implements `ZodSharp.Core.IZodRule`, so `IZodRule.Code`/`IZodRule.Origin` are used at runtime (the mapped values are only a fallback when the rule returns `null`). +2. **Attribute-declared** — a `Code` / `Origin` named argument on the applied attribute (for example `[NoWhitespace(Code = "invalid_asset_id")]`). +3. **Attribute-type mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]`. +4. **Default** — `validation_failed` with no origin. + +```csharp +using ZodSharp.Core; + +public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where T : struct, IEquatable +{ + public bool IsValid(in T value) => !value.Equals(default(T)); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + + // The rule owns its identity, so callers can pass a per-member error code. + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; +} +``` + +## Generic rules + +Map an **unbound generic** rule type and the generator closes it with the property type, so one rule serves every underlying primitive: + +```csharp +[ZodRule(typeof(NotEmptyRule<>))] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class NotEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + + public string? Message { get; set; } +} +``` + +- `[NotEmpty]` on a `Guid` property instantiates `NotEmptyRule`; on an `int` property it instantiates `NotEmptyRule`. +- The rule must expose exactly one type parameter. A type argument that cannot satisfy the rule's constraints (for example `NotEmptyRule where T : struct` applied to a `string`) is reported as `ZODSGEN030` and no rule is emitted, so the generated code always compiles. + +## Generating the attribute from the rule + +If you do not want to hand-write the attribute, mark the rule itself with the parameterless `[ZodRule]` and the generator emits a matching attribute: + +```csharp +using ZodSharp.Core; + +namespace MyRules; + +[ZodRule(Code = "invalid_string", Origin = "string")] +public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) + : IValidationRule +{ + public bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; +} +``` + +This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like: + +```csharp +/// Validation attribute that applies NoWhitespaceRule. +[global::System.AttributeUsage( + global::System.AttributeTargets.Property + | global::System.AttributeTargets.Field + | global::System.AttributeTargets.Parameter, + Inherited = true, + AllowMultiple = false)] +[global::ZodSharp.Core.ZodRule(typeof(global::MyRules.NoWhitespaceRule), Code = "invalid_string", Origin = "string")] +public sealed class NoWhitespaceAttribute + : global::System.ComponentModel.DataAnnotations.ValidationAttribute +{ + public bool AllowEmpty { get; set; } = true; +} +``` + +Mapping rules: + +- The attribute name is the rule name with a trailing `Rule` replaced by `Attribute` (`NoWhitespaceRule` → `NoWhitespaceAttribute`). Override it with `[ZodRule(AttributeName = "…")]`. +- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. A parameter named `message` is omitted — use the inherited `ValidationAttribute.ErrorMessage` instead. +- The rule must be non-generic, non-nested, and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). + +> [!IMPORTANT] +> The generated attribute lives in the same assembly as the rule, but Roslyn generators cannot read another generator's output as a symbol. To *consume* the generated attribute with `[ZodSchema]`, reference the rule from a separate assembly (a rules library) — or hand-author the attribute and mark it with `[ZodRule(typeof(...))]`. + +## Type-level rules + +Rules can also be attached to the **`[ZodSchema]` type itself** instead of a property. They validate the whole value (the value object as a unit) and report an **empty path**, which is what you want for a scalar whose single `Value` *is* the value: + +```csharp +[ZodRule(typeof(NotEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NotEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} + +[NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +[ZodSchema] +public partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +The generator emits the rule against the value itself — no property local, and `EmptyPath` rather than a property path: + +```csharp +var assetIdCustomRule0 = new global::MyRules.NotEmptyRule("invalid_asset_id", "AssetId must not be empty."); +if (!assetIdCustomRule0.IsValid(value)) +{ + (errors ??= new List()).Add( + ValidationError.Create( + ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? "invalid_asset_id", + assetIdCustomRule0.GetErrorMessage(value), + EmptyPath, + origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin ?? null)); +} +``` + +- **Generic closure:** a type-level attribute closes an unbound generic rule with the **target type** (`NotEmptyRule`), so the rule sees the value object and can read its state through its own constraints. +- **Ordering** in the generated `Validate`: property rules → **type-level rules** → the synchronous `Validate()` refinement. +- Type-level attributes need `AttributeTargets.Class`/`Struct` on the attribute declaration; the property-level attributes above only need `Property`/`Field`. + +### When a type-level rule does not run + +- **The type gets no schema.** The generator is driven by `[ZodSchema]` and then walks nested complex property types. A rule attribute on a type that gets no schema is **ignored**, and the analyzer reports the warning **`ZODSGEN033`** so the mistake is visible. A type without `[ZodSchema]` that is referenced as a complex property of a schema *does* get a (secondary) schema, so its type-level rules run and no warning is raised. +- **`[ZodSchema(GenerateValidateMethod = false)]`.** Type-level rules live inside `Validate`, so they are omitted along with it. +- **`DisableZodSharpSourceGenerator`.** The generator — and therefore every rule — is skipped. + +## Validating scalar value objects + +A `Purview.ValueObjects` scalar **is** a single value, so validate it as a unit rather than through its `Value` property. Scalars implement the two-type-parameter contract: + +```csharp +public interface IScalarValueObject : IValueObject, IComparable, IComparable + where TSelf : IScalarValueObject +{ + TValue Value { get; } + static abstract TSelf Create(TValue value); + static abstract TSelf Hydrate(TValue value); + int CompareTo(TValue other); +} +``` + +so `AssetId` is `IScalarValueObject`. Today the check is normally repeated on every scalar: + +```csharp +// repeated on every Guid scalar +internal IEnumerable Validate() +{ + if (Value == Guid.Empty) + yield return ErrorFactory.InvalidAssetId; +} +``` + +Type **one** rule on the value object and put the attribute on the **scalar type**: + +```csharp +// MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjects +public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NotEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NotEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} +``` + +```csharp +// Purview.ChangeOps +using Purview.ValueObjects.Serialization; +using ZodSharp; + +[Scalar] +[ZodSchema] +[NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} + +[Scalar] +[ZodSchema] +[NotEmpty(Code = "invalid_external_identity_id", Message = "ExternalIdentityId must not be empty.")] +public readonly partial record struct ExternalIdentityId +{ + public Guid Value { get; init; } +} +``` + +The per-scalar `Validate()` refinements disappear, each scalar keeps its own `Code`/`Message`, and the reported error has an **empty path** because the rule applies to the value object itself: + +```text +Code = "invalid_asset_id" +Message = "AssetId must not be empty." +Origin = "value_object" +Path = [] +``` + +Because the rule is closed with `TSelf` (`NotEmptyRule`), it *sees the value object* and reads `Value` through the `IScalarValueObject` constraint. A generic rule must have exactly one type parameter, so the underlying value type is pinned by the constraint — define one rule per primitive (`NotEmptyRule where TSelf : IScalarValueObject`, a `long` variant, and so on). + +> [!NOTE] +> If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to a `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](#generic-rules). + +> [!TIP] +> If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit `[NotEmpty]` on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`). + +## Diagnostics + +| ID | Severity | Meaning | +|---|---|---| +| ZODSGEN030 | Error | The mapped rule does not implement `IValidationRule` for the property type (or the rule target type), or an unbound generic rule could not be closed with it. | +| ZODSGEN031 | Error | A rule constructor parameter could not be mapped from the attribute. | +| ZODSGEN032 | Error | A validation attribute could not be generated for the rule. | +| ZODSGEN033 | Warning | A rule-mapped attribute is applied to a type that gets no generated schema (no `[ZodSchema]` and not referenced as a complex property), so the rule never runs. | + +See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. + +## Related + +- [Fluent Schema API](Fluent-Schema-API.md) — `AddRule`/`Rule` live on `ZodType`. +- [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) — built-in attribute coverage. +- [Guarantees and Limitations](Guarantees-and-Limitations.md) — allocation and mutation semantics. + diff --git a/docs/wiki/Fluent-Schema-API.md b/docs/wiki/Fluent-Schema-API.md index e8d49d0..c0ea7ae 100644 --- a/docs/wiki/Fluent-Schema-API.md +++ b/docs/wiki/Fluent-Schema-API.md @@ -46,7 +46,11 @@ Each schema type has its own page: - `IZodSchema` — `Validate` / `ValidateAsync`; `IZodSchema` is the convenience form where input equals output. - `IZodSchemaValidator` (marker) and `IZodSchemaValidator` — the DI-facing adapter surface (see [Dependency Injection](Dependency-Injection.md)). -- `IValidationRule` — the rule contract implemented by every struct rule. +- `IValidationRule` — the rule contract implemented by every struct rule. `ZodType.AddRule(rule)` and `Rule(rule)` are public, so custom rules can be attached to any schema. `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path. +- `IZodRule` — implemented by rules that own their error identity (`Code`/`Origin`). When a mapped rule implements it, the generator prefers the rule's values over the attribute's, so one attribute can produce a per-member error code. +- `IStringValidationRule` — the span-based counterpart of `IValidationRule`. + +See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules). ## Convenience composition on any schema diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 5e56094..f58b8c2 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -2,59 +2,63 @@ ## Guarantees -- **Zero-allocation on valid inputs.** Primitives, strings, arrays, objects, discriminated unions, and first-option unions validate without allocating when the input is valid. See [Performance](Performance.md). -- **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator`; the source generator emits direct typed codegen. -- **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; no scope leaks in emitted code. +- **Zero-allocation on valid inputs for the core schema types.** Primitives, strings, arrays, and objects (and their generated `[ZodSchema]` validators) validate valid inputs without allocating. See [Performance](Performance.md) for the measurements. The exceptions are listed under [Limitations](#limitations). +- **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator` and to compile a one-off discriminator accessor per (type, discriminator) pair for `ZodDiscriminatedUnion`. After that first use, validation runs direct property access; the source generator emits direct typed codegen. +- **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; there are no scope leaks in emitted code. - **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](Cross-Platform-Interop.md)). - **Multi-targeting.** Packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. -- **Immutable, shareable schemas.** Composing returns new schemas; schemas are safe to cache and share across threads. +- **A fully built schema is safe to cache and share across threads.** Validation only reads the rule set and `Description`, so once construction is finished a schema can be reused concurrently. Building is *not* immutable — see the next section. ## Limitations -### Attribute flags that are not yet honoured +### Fluent rule methods mutate the receiver -`SchemaName`, `GenerateValidateMethod`, and `GenerateParseMethod` on `[ZodSchema]` are parsed but ignored: the schema class is always `{TypeName}Schema` and `Validate`/`Parse` are always emitted. `EnableComposition` and the `IValidateOptions` flags are honoured. +`Min`, `Max`, `Email`, `Regex`, `UUID`, `StartsWith`, `EndsWith`, `Describe`, and the other fluent rule/description methods append to the receiver's rule list (or set its description) **in place** and return `this`. Only the composition methods (`Transform`, `Refine`, `SuperRefine`, `Pipe`, `Catch`, `Default`, `Prefault`, `And`, `Or`) return a new schema. Finish configuring a schema before sharing it; two pieces of code holding the same instance share its rules. -### Generated size-failure Origin +### Span validation returns a string -The generator reports `Origin = "array"` for both arrays and collections — there is no `"collection"` origin in generated code, even though `ValidationError.Origin` supports it. +`ZodString.ValidateSpan(ReadOnlySpan)` validates the span directly whenever every accumulated rule implements `IStringValidationRule`, but its result carries a `string`, so a **successful** validation still allocates once when the value is materialised. Use `ZodString.IsValidSpan(ReadOnlySpan, out ImmutableArray)` when the value is not needed: it is allocation-free on success and only materialises the input when a rule (or a transform) cannot be evaluated over a span. `ValidateSpan` falls back to the string pipeline for schemas that involve string transforms (`Trim`, `ToLower`, `ToUpper`) or rules without a span implementation. -### Async validation is synchronous underneath +### Rules without a span implementation -`ValidateAsync` wraps the synchronous `Validate` pipeline in a `ValueTask`. The only genuinely async path is the source generator's custom async validation method (`CustomValidationAsync`), which is awaited after the sync validation. +`UrlRule` (its `Uri.TryCreate` fallback needs a string) and `Base64StringRule` (`Convert.FromBase64String`) do not implement `IStringValidationRule`; adding either to a `ZodString` disables the span fast path for that schema, and `ValidateSpan`/`IsValidSpan` fall back to materialising the input once. -### JSON Schema import scope +### Size-failure Origin values -`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options). - -### Referencing both JSON integration packages - -`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`. +The generator reports `Origin = "string"` for string size failures, `Origin = "array"` for arrays, and `Origin = "collection"` for countable/`IEnumerable` collections. `ZodArray` reports `"array"`. ### Typed union allocation -`ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure. Discriminated unions dispatch directly and stay zero-allocation. +`ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure; discriminated unions backed by dictionaries dispatch directly and stay zero-allocation. ### Rule errors -Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Minimum`/`Maximum`/`Inclusive`) come from the array schema and the source generator's size validators. +Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators. ### String transforms allocate `ToLower`, `ToUpper`, and `Trim` produce new strings on every validation (transform outputs are new strings by nature). -### `IStringValidationRule` - -The span-based `IStringValidationRule` interface is declared but not implemented by any shipped rule struct; span validation is available through `ZodString.ValidateSpan`. - ### Number semantics -`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `MultipleOf` uses a tolerance-based comparison and rejects a zero divisor. +`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `Positive()`/`Negative()` are strict (they reject `0`; use `NonNegative()`/`NonPositive()` for inclusive bounds). `MultipleOf` compares the quotient to its nearest integer with a relative tolerance (`1e-12`), so `0.3` is accepted for `MultipleOf(0.1)` while `0.3000000001` is not; NaN and infinity are rejected, and a zero divisor throws `ArgumentException`. ### Enum semantics `ZodEnum` (string values) and `ZodNativeEnum` validate against defined members; they do not parse or convert values. +### JSON Schema import scope + +`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options). + +### Referencing both JSON integration packages + +`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`. + +## Custom rules + +Custom rules and their DataAnnotations-style attributes are a first-class extension point. Rules can be attached to a property or to the schema type itself (validating the value object as a unit), and a generic rule can be closed with the target type so one rule serves every scalar of a given shape. See [Custom Rules](Custom-Rules.md) for the rule contract, the public `AddRule`/`Rule` API, and how to map a rule to a `ValidationAttribute` that the source generator honours. + ## Contract vs. underlying libraries - `System.ComponentModel.DataAnnotations` semantics are honoured where documented — e.g. `[Length]` treats `null` as valid unless `[Required]` is present; `[RegularExpression]` runs only on non-empty strings. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7865e34..34d6d40 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -22,6 +22,7 @@ This wiki is the project documentation hub for the core API, source generator, J - [Arrays and Other Schemas](Arrays-and-Other-Schemas.md) - [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md) - [Composition and Transforms](Composition-and-Transforms.md) +- [Custom Rules](Custom-Rules.md) - [Compiled Validators and Caching](Compiled-Validators-and-Caching.md) - [Dependency Injection](Dependency-Injection.md) diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index ba74389..5f077c4 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -16,19 +16,24 @@ var result = schema.Validate(30.0); | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | | `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` | -| `Positive` | `Positive()` | `MinValueRule(0.0)` | -| `Negative` | `Negative()` | `MaxValueRule(0.0)` | -| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; tolerance-based | +| `Positive` | `Positive()` | `GreaterThanRule(0.0)` — strictly greater than zero | +| `Negative` | `Negative()` | `LessThanRule(0.0)` — strictly less than zero | +| `NonNegative` | `NonNegative()` | `MinValueRule(0.0)` — greater than or equal to zero | +| `NonPositive` | `NonPositive()` | `MaxValueRule(0.0)` — less than or equal to zero | +| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) | | `Finite` | `Finite(string? message)` | `FiniteRule` — `double.IsFinite` | | `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue` | ## Examples ```csharp -var positive = Z.Number().Positive(); -var negative = Z.Number().Negative(); +var positive = Z.Number().Positive(); // > 0 +var negative = Z.Number().Negative(); // < 0 +var nonNegative = Z.Number().NonNegative(); // >= 0 +var nonPositive = Z.Number().NonPositive(); // <= 0 var multipleOf = Z.Number().MultipleOf(10); // multiples of 10 +var fractional = Z.Number().MultipleOf(0.1); // 0.3 is accepted (floating-point tolerance) var finite = Z.Number().Finite(); // rejects Infinity / NaN var safe = Z.Number().Safe(); // safe integer range var whole = Z.Number().Int(); // no fractional part diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index de50400..f01515d 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -90,7 +90,7 @@ Numbers are indicative; re-run on your own hardware for local planning. ## Memory (`MemoryPerformanceTests`) -All valid-input paths are zero-allocation: +All valid-input paths for the core schema types are zero-allocation: | Scenario | Mean | Ratio | |---|---|---| @@ -98,6 +98,9 @@ All valid-input paths are zero-allocation: | ValidateObject_Allocations | 94.28 ns | 2.06 | | ValidateArray_Allocations | 1,180.24 ns | 25.82 | +> [!NOTE] +> The string transforms, `ZodString.ValidateSpan` (its result carries a string), and non-first union options are the allocation exceptions. Use `ZodString.IsValidSpan` for an allocation-free span check. See [Guarantees and Limitations](Guarantees-and-Limitations.md). + ## UUID validation (`UuidPerformanceTests`) UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex: @@ -119,9 +122,9 @@ The char-scan is ~20% faster than the previous regex on the valid path, is versi ## Optimizations that make it fast 1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule`, so there is no per-validation object allocation. -2. **Zero-allocation helpers** — `Span`/`ReadOnlySpan` string validation (`ValidateSpan`) and `ArrayPool`-backed helpers. +2. **Span-aware helpers** — `Span`/`ReadOnlySpan` APIs and `ArrayPool`-backed helpers. The shipped string rules implement `IStringValidationRule` (except the URL and Base64 rules, which need a string), so `ZodString.ValidateSpan` validates the span directly and only materialises the value string for its result; `ZodString.IsValidSpan` avoids that allocation entirely. 3. **Compiled validators** — `CompiledValidator.Compile` removes interface dispatch (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)). 4. **Source generation** — `[ZodSchema]` emits direct property access and typed equality checks with no reflection (see [Source Generator](Source-Generator.md)). -5. **Fluent composition** — schemas are immutable and shareable, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)). +5. **Cacheable schema instances** — a fully built schema is safe to cache and share, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)). Building is not immutable: the fluent rule methods mutate the receiver. See [Custom Rules](Custom-Rules.md) for extending rules. -The only allocations on a successful validation are the string transforms (`ToLower`/`ToUpper`/`Trim` produce new strings) and the union non-first-option paths noted above. \ No newline at end of file +The allocations on a successful validation are limited to the string transforms (`ToLower`/`ToUpper`/`Trim`), `ZodString.ValidateSpan` (its result carries a string), and the union non-first-option paths. \ No newline at end of file diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index f53f519..3fd9937 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -38,7 +38,7 @@ Size attributes generate direct `Length` or `Count` access when possible: Structured size failures expose the same metadata as the runtime API: - `Code`: `too_small` or `too_big`. -- `Origin`: `string` for strings, `array` for arrays and collections. +- `Origin`: `string` for strings, `array` for arrays, `collection` for countable/`IEnumerable` collections. - `Minimum` / `Maximum`: the inclusive bound. - `Inclusive`: `true`. - `Path`: the property path. @@ -60,7 +60,7 @@ var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); ``` > [!NOTE] -> Today the generator reports `Origin = "array"` for both arrays and collections; there is no `"collection"` origin in generated code. +> The generator reports `Origin = "array"` for arrays and `Origin = "collection"` for countable/`IEnumerable` collections. ## Range @@ -84,4 +84,8 @@ Misuse is reported at compile time rather than silently ignored: - String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]`) on non-string targets, `[AllowedValues]`/`[DeniedValues]` on unsupported types, or `[Range]` on unsupported types → ZODSGEN006. - `[Compare]` referencing an unknown property → ZODSGEN020. -See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. \ No newline at end of file +See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. + +## Custom attributes + +The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md). \ No newline at end of file diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index 8b9fefe..3e56606 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -32,6 +32,10 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | 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) | +| 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 | ## Suppressing diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 9d620a7..8783f1e 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -30,8 +30,8 @@ For a `[ZodSchema]` target type `{TypeName}`, the generator emits: | Artifact | Shape | |---|---| -| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); contains `Validate`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | -| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; emitted only for the primary schema | +| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | +| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | | `{TypeName}Validator` | `sealed partial class {TypeName}Validator : IValidateOptions<{TypeName}>` — emitted only when `IValidateOptions` support is enabled (and the target is a class) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | @@ -48,9 +48,9 @@ All options are optional. | Property | Default | Purpose | |---|---|---| -| `SchemaName` | `null` | Reserved — the schema class is always named `{TypeName}Schema`. | -| `GenerateValidateMethod` | `true` | Reserved — `Validate` is always emitted. | -| `GenerateParseMethod` | `true` | Reserved — `Parse` is always emitted. | +| `SchemaName` | `null` | Overrides the generated schema class name (default `{TypeName}Schema`). The DI adapter becomes `{SchemaName}Validator`. | +| `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. | @@ -58,7 +58,7 @@ All options are optional. | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | > [!NOTE] -> `SchemaName`, `GenerateValidateMethod`, and `GenerateParseMethod` are parsed by the attribute but not yet honoured by the generator — the class is always `{TypeName}Schema` with `Validate` and `Parse`. +> `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together. ## Custom async validation @@ -144,4 +144,4 @@ MSBuild switches: - Nested complex types are discovered recursively and get their own generated `{TypeName}Schema`, even when the nested type does not itself carry `[ZodSchema]`. - Nullable properties are null-guarded before value-set/type validation; a nullable target rejects `null` with `invalid_type`. -See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the attribute coverage and structured issue shape, and [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the `ZODSGEN*` diagnostics. \ No newline at end of file +See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the attribute coverage and structured issue shape, [Custom Rules](Custom-Rules.md) for extending validation with your own rules and attributes, and [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the `ZODSGEN*` diagnostics. \ No newline at end of file diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md index d597f7e..fde1ebc 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -29,10 +29,11 @@ var result = schema.Validate("user@example.com"); | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | | `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) | | `Trim` | `Trim()` | wraps a transform (`Trim`) | -| `ValidateSpan` | `ValidateSpan(ReadOnlySpan value)` | zero-allocation span validation | +| `ValidateSpan` | `ValidateSpan(ReadOnlySpan value)` | validates the span directly; a successful result materialises the value string | +| `IsValidSpan` | `IsValidSpan(ReadOnlySpan value, out ImmutableArray errors)` | allocation-free on success; materialises the input only when a rule or transform has no span path | > [!NOTE] -> `ToLower`, `ToUpper`, and `Trim` produce a new string on every validation — these are the only string validations that allocate on a successful path. +> `ToLower`, `ToUpper`, and `Trim` produce a new string on every validation. `IsValidSpan` does not allocate when the value is valid; `ValidateSpan` allocates once because its result carries a `string`. ## Examples @@ -62,4 +63,8 @@ Rules produce `ValidationError` entries with code `validation_failed` and an emp ## Span validation -`ValidateSpan(ReadOnlySpan value)` avoids string allocations on the validation path. An empty span validates successfully as `""`. \ No newline at end of file +`ValidateSpan(ReadOnlySpan value)` validates the span directly using the rules' `IStringValidationRule` implementations; it materialises a `string` only for the returned value (and only falls back to the string pipeline for schemas with transforms or rules without a span implementation). `IsValidSpan(ReadOnlySpan value, out ImmutableArray errors)` is the allocation-free entry point when the value is not needed. An empty span is validated by the rules like an empty string. + +## Custom rules + +Custom rules implement `IValidationRule` and can be attached with `Z.String().Rule(new MyRule())`. Implement `IStringValidationRule` as well to keep them on the span path. They can also be exposed as DataAnnotations-style attributes; see [Custom Rules](Custom-Rules.md). \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index c760469..8cb102c 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [Arrays and Other Schemas](Arrays-and-Other-Schemas.md) - [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md) - [Composition and Transforms](Composition-and-Transforms.md) +- [Custom Rules](Custom-Rules.md) - [Compiled Validators and Caching](Compiled-Validators-and-Caching.md) - [JSON Schema Export](JsonSchema-Export.md) - [JSON Schema Import](JsonSchema-Import.md) diff --git a/package.json b/package.json index 20071a7..82f7396 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.24", + "version": "2.0.0-prerelease.25", "private": true, "license": "MIT", "author": { @@ -28,4 +28,4 @@ "bun": ">=1.4.2" }, "packageManager": "bun@1.4.2" -} \ No newline at end of file +} diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index a5f35d1..fb564d0 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -12,6 +12,10 @@ ZODSGEN026 | ZodSharp.SourceGenerator | Error | Synchronous refinement method co 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 +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 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/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs new file mode 100644 index 0000000..e88c1c8 --- /dev/null +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -0,0 +1,435 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves DataAnnotations-style validation attributes that carry [ZodRule(typeof(...))] into +/// values the schema generator can emit. +/// +/// +/// +/// Only attributes derived from System.ComponentModel.DataAnnotations.ValidationAttribute are +/// considered, so custom rules participate in the same "a property is validated when it carries a data +/// annotation" discovery as the built-in attributes. +/// +/// +/// The applied attribute's constructor arguments are mapped positionally and its named arguments by name +/// (case-insensitive) to the rule's public constructor parameters. A parameter named message is +/// supplied from the attribute's ErrorMessage when one is set. +/// +/// +static class CustomRuleResolver +{ + public static EquatableArray Resolve( + ISymbol symbol, + ITypeSymbol ruleTargetType, + ImmutableArray.Builder diagnostics + ) + { + ImmutableArray.Builder? builder = null; + + foreach (var attribute in symbol.GetAttributes()) + { + if (attribute.AttributeClass is not INamedTypeSymbol attributeClass) + continue; + + // The built-in DataAnnotations attributes have dedicated emitters. + if (IsBuiltInDataAnnotation(attributeClass)) + continue; + + if (!TryGetRuleMapping(attributeClass, out var mapping)) + continue; + + if (!TryResolveRuleType(mapping.RuleType, ruleTargetType, out var ruleType)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedCustomRuleTarget, + true, + GetAttributeLocation(attribute), + mapping.RuleType.Name, + attributeClass.Name, + ruleTargetType.ToDisplayString() + ) + ); + continue; + } + + if (!ImplementsRuleFor(ruleType, ruleTargetType)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedCustomRuleTarget, + true, + GetAttributeLocation(attribute), + ruleType.Name, + attributeClass.Name, + ruleTargetType.ToDisplayString() + ) + ); + continue; + } + + if (!TryBuildArguments(attribute, ruleType, out var arguments, out var unmappedParameterName)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnmappableCustomRuleArgument, + true, + GetAttributeLocation(attribute), + unmappedParameterName!, + ruleType.Name, + attributeClass.Name + ) + ); + continue; + } + + builder ??= ImmutableArray.CreateBuilder(); + builder.Add( + new CustomRuleDescriptor( + new TypeIdentity(ruleType), + GetAttributeString(attribute, "Code") ?? mapping.Code, + GetAttributeString(attribute, "Origin") ?? mapping.Origin, + ValidationAttributeData.FromAttributeData(attribute), + arguments, + IsZodRule(ruleType) + ) + ); + } + + return builder is null ? new(ImmutableArray.Empty) : new(builder.ToImmutable()); + } + + static bool IsBuiltInDataAnnotation(INamedTypeSymbol attributeClass) => + attributeClass.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.SystemDataAnnotations; + + /// + /// Determines whether is mapped to a validation rule through + /// [ZodRule(typeof(...))]. Used by the analyzer to flag rule attributes that can never run. + /// + /// The attribute type to test. + /// when the attribute is mapped to a rule. + internal static bool IsRuleMapped(INamedTypeSymbol attributeClass) => TryGetRuleMapping(attributeClass, out _); + + /// + /// Resolves the rule type to instantiate: a plain type is used as-is, an unbound generic + /// ([ZodRule(typeof(NotEmptyRule<>))]) is closed with the property type. + /// + /// The rule type declared by the mapping. + /// The property type used to close an unbound generic rule. + /// The rule type to instantiate. + /// when a usable rule type was resolved. + static bool TryResolveRuleType(INamedTypeSymbol ruleType, ITypeSymbol propertyType, out INamedTypeSymbol resolved) + { + resolved = ruleType; + + if (!IsOpenGeneric(ruleType)) + return true; + + var definition = ruleType.OriginalDefinition; + if (definition is null || definition.Arity != 1) + return false; + + if (!SatisfiesConstraints(definition, propertyType)) + return false; + + resolved = definition.Construct(propertyType); + return true; + } + + static bool IsOpenGeneric(INamedTypeSymbol type) => + type.IsGenericType + && ( + type.IsUnboundGenericType + || type.TypeArguments.Any(static argument => argument.TypeKind == TypeKind.TypeParameter) + ); + + static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) + { + foreach (var parameter in definition.TypeParameters) + { + if (parameter.HasValueTypeConstraint && !type.IsValueType) + return false; + + if (parameter.HasReferenceTypeConstraint && !type.IsReferenceType) + return false; + + if (parameter.HasUnmanagedTypeConstraint && !type.IsUnmanagedType) + return false; + + foreach (var constraint in parameter.ConstraintTypes) + { + // Self-referential constraints (for example where T : IEquatable) cannot be evaluated + // from the definition alone; a closed type satisfies them in practice. + if (ContainsTypeParameter(constraint)) + continue; + + if (!TypeHelpers.IsOrImplements(type, new TypeIdentity(constraint))) + return false; + } + } + + return true; + } + + static bool ContainsTypeParameter(ITypeSymbol type) => + type.TypeKind == TypeKind.TypeParameter + || (type is INamedTypeSymbol named && named.TypeArguments.Any(ContainsTypeParameter)); + + static bool IsZodRule(INamedTypeSymbol ruleType) => + TypeHelpers.IsOrImplements(ruleType, TypeLibrary.ZodSharp.Core.IZodRule); + + static string? GetAttributeString(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name) + return pair.Value.Value as string; + } + + return null; + } + + static bool TryGetRuleMapping(INamedTypeSymbol attributeClass, out RuleMapping mapping) + { + for (var current = (INamedTypeSymbol?)attributeClass; current is not null; current = current.BaseType) + { + foreach (var attribute in current.GetAttributes()) + { + if ( + attribute.AttributeClass is null + || attribute.AttributeClass.ToDisplayString() + != $"{TypeLibraryGenerator.ZodSharpCoreNamespace}.ZodRuleAttribute" + ) + { + continue; + } + + if (attribute.ConstructorArguments.Length == 0) + continue; + + if (attribute.ConstructorArguments[0].Value is not INamedTypeSymbol ruleType) + continue; + + string? code = null; + string? origin = null; + foreach (var pair in attribute.NamedArguments) + { + switch (pair.Key) + { + case "Code": + code = pair.Value.Value as string; + break; + case "Origin": + origin = pair.Value.Value as string; + break; + default: + break; + } + } + + mapping = new RuleMapping(ruleType, code, origin); + return true; + } + } + + mapping = default; + return false; + } + + static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyType) + { + // A rule that is still open (for example NotEmptyRule) cannot be closed from an attribute alone. + if (IsOpenGeneric(ruleType)) + return false; + + foreach (var iface in ruleType.AllInterfaces) + { + if (iface.TypeArguments.Length != 1) + continue; + + var definition = iface.OriginalDefinition; + if (definition.Name != "IValidationRule" || definition.Arity != 1) + continue; + + if (definition.ContainingNamespace.ToDisplayString() != TypeLibraryGenerator.ZodSharpCoreNamespace) + continue; + + if (TypeHelpers.IsSameType(iface.TypeArguments[0], propertyType)) + return true; + } + + return false; + } + + static bool TryBuildArguments( + AttributeData attribute, + INamedTypeSymbol ruleType, + out EquatableArray arguments, + out string? unmappedParameterName + ) + { + arguments = new(ImmutableArray.Empty); + unmappedParameterName = null; + + var constructor = ruleType + .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(static c => c.Parameters.Length) + .FirstOrDefault(); + + if (constructor is null) + return false; + + var validation = ValidationAttributeData.FromAttributeData(attribute); + var positional = attribute.ConstructorArguments; + Dictionary named = new(StringComparer.OrdinalIgnoreCase); + foreach (var pair in attribute.NamedArguments) + named[pair.Key] = pair.Value; + + var expressions = ImmutableArray.CreateBuilder(constructor.Parameters.Length); + + for (var i = 0; i < constructor.Parameters.Length; i++) + { + var parameter = constructor.Parameters[i]; + + if (named.TryGetValue(parameter.Name, out var namedValue)) + { + if (!TryConvertConstant(namedValue, parameter.Type, out var namedExpression)) + { + unmappedParameterName = parameter.Name; + return false; + } + + expressions.Add(namedExpression); + continue; + } + + if (i < positional.Length) + { + if (!TryConvertConstant(positional[i], parameter.Type, out var positionalExpression)) + { + unmappedParameterName = parameter.Name; + return false; + } + + expressions.Add(positionalExpression); + continue; + } + + if (IsMessageParameter(parameter) && validation.Exists && !string.IsNullOrEmpty(validation.ErrorMessage)) + { + expressions.Add(validation.ErrorMessage.StringLiteral()); + continue; + } + + if (parameter.HasExplicitDefaultValue) + { + if (!TryConvertValue(parameter.ExplicitDefaultValue, parameter.Type, out var defaultExpression)) + { + unmappedParameterName = parameter.Name; + return false; + } + + expressions.Add(defaultExpression); + continue; + } + + if (TypeHelpers.CanBeNull(parameter.Type)) + { + expressions.Add("null"); + continue; + } + + unmappedParameterName = parameter.Name; + return false; + } + + arguments = new(expressions.ToImmutable()); + return true; + } + + static bool IsMessageParameter(IParameterSymbol parameter) => + string.Equals(parameter.Name, "message", StringComparison.OrdinalIgnoreCase); + + static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, out string expression) + { + if (constant.IsNull) + { + expression = "null"; + return targetType.IsReferenceType || TypeHelpers.CanBeNull(targetType); + } + + if (constant.Kind == TypedConstantKind.Type) + { + if (constant.Value is ITypeSymbol typeSymbol) + { + expression = $"typeof({new TypeIdentity(typeSymbol).RenderFullName})"; + return true; + } + + expression = string.Empty; + return false; + } + + return TryConvertValue(constant.Value, targetType, out expression); + } + + internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out string expression) + { + if (value is null) + { + expression = "null"; + return targetType.IsReferenceType || TypeHelpers.CanBeNull(targetType); + } + + var unwrapped = TypeHelpers.UnwrapNullableType(targetType); + + if (unwrapped is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + expression = + $"({new TypeIdentity(enumType).RenderFullName}){Convert.ToString(value, CultureInfo.InvariantCulture)}"; + return true; + } + +#pragma warning disable IDE0072 // Add missing cases + expression = unwrapped.SpecialType switch + { + SpecialType.System_String when value is string text => text.StringLiteral(), + SpecialType.System_Char when value is char character => CodeGenHelpers.QuoteChar(character), + SpecialType.System_Boolean when value is bool boolean => boolean ? "true" : "false", + SpecialType.System_Byte when value is byte number => number.ToString(CultureInfo.InvariantCulture), + SpecialType.System_SByte when value is sbyte number => + $"(sbyte){number.ToString(CultureInfo.InvariantCulture)}", + SpecialType.System_Int16 when value is short number => + $"(short){number.ToString(CultureInfo.InvariantCulture)}", + SpecialType.System_UInt16 when value is ushort number => + $"(ushort){number.ToString(CultureInfo.InvariantCulture)}", + SpecialType.System_Int32 when value is int number => number.ToString(CultureInfo.InvariantCulture), + SpecialType.System_UInt32 when value is uint number => $"{number.ToString(CultureInfo.InvariantCulture)}U", + SpecialType.System_Int64 when value is long number => $"{number.ToString(CultureInfo.InvariantCulture)}L", + SpecialType.System_UInt64 when value is ulong number => + $"{number.ToString(CultureInfo.InvariantCulture)}UL", + SpecialType.System_Single when value is float number => + $"{number.ToString("R", CultureInfo.InvariantCulture)}F", + SpecialType.System_Double when value is double number => + $"{number.ToString("R", CultureInfo.InvariantCulture)}D", + SpecialType.System_Decimal when value is decimal number => + $"{number.ToString(CultureInfo.InvariantCulture)}M", + SpecialType.System_Object when value is string text => text.StringLiteral(), + _ => string.Empty, + }; +#pragma warning restore IDE0072 // Add missing cases + + return expression.Length > 0; + } + + static Location GetAttributeLocation(AttributeData attributeData) => + attributeData.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + + readonly record struct RuleMapping(INamedTypeSymbol RuleType, string? Code, string? Origin); +} diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 8cf94be..21e5056 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -258,6 +258,42 @@ static class DiagnosticLibrary isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor UnsupportedCustomRuleTarget = new( + id: "ZODSGEN030", + title: "Custom rule does not support the property type", + messageFormat: "The rule '{0}' configured by attribute '{1}' does not implement IValidationRule for property type '{2}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor UnmappableCustomRuleArgument = new( + id: "ZODSGEN031", + title: "Unable to map a custom rule argument", + messageFormat: "Unable to map a value for constructor parameter '{0}' of rule '{1}' from attribute '{2}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor UnsupportedRuleAttributeGeneration = new( + id: "ZODSGEN032", + title: "Unable to generate a validation attribute for a custom rule", + messageFormat: "Unable to generate a validation attribute for rule '{0}': {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor RuleAttributeWithoutSchema = new( + id: "ZODSGEN033", + title: "Rule attribute is applied to a type that gets no generated schema", + messageFormat: "The attribute '{0}' on '{1}' is mapped to a validation rule, but no schema is generated for '{1}', so the rule will not run. Apply [ZodSchema] to the type or reference it as a complex property of a schema.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + 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.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index d212264..ac20bdb 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -164,15 +164,23 @@ CancellationToken cancellationToken if (!seen.Add(target)) continue; - var schema = target with { Name = $"{target.Name}Schema" }; + var zodSchemaAttribute = ZodSchemaAttributeData.FromAttributeData(symbol, out var attribute); + var schemaName = zodSchemaAttribute.SchemaName is { Length: > 0 } customSchemaName + ? customSchemaName + : $"{target.Name}Schema"; + var schema = target with { Name = schemaName }; var targetCanBeNull = TypeHelpers.CanBeNull(symbol); var properties = GetZodProperties(symbol, externalSchemas); + + // Type-level rules ([ZodRule]-mapped attributes on the target itself) validate the whole value + // rather than a property, which is what makes a scalar value object validatable as a unit. + var typeRuleDiagnostics = ImmutableArray.CreateBuilder(); + var typeRules = CustomRuleResolver.Resolve(symbol, symbol, typeRuleDiagnostics); var accessibility = symbol.ContainingType is null ? symbol.DeclaredAccessibility == Accessibility.Public ? TypeDeclarationAccessibility.Public : TypeDeclarationAccessibility.Internal : symbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(); - var zodSchemaAttribute = ZodSchemaAttributeData.FromAttributeData(symbol, out var attribute); var customValidation = ResolveCustomValidationMethod(symbol, zodSchemaAttribute, attribute!); var syncValidation = ResolveSyncValidationMethod(symbol, zodSchemaAttribute, attribute!); @@ -188,6 +196,12 @@ CancellationToken cancellationToken : zodSchemaAttribute.SuppressIValidateOptions ? false : null; + // Nested complex types discovered without a [ZodSchema] attribute default to emitting both + // methods (the attribute's [Property(DefaultValue = true)] defaults only apply when the + // attribute is actually present; ZodSchemaAttributeData.Empty carries default(bool)). + var generateValidateMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateValidateMethod; + var generateParseMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateParseMethod; + schemas.Add( new( target, @@ -201,6 +215,9 @@ CancellationToken cancellationToken syncValidation, generateIValidateOptions, zodSchemaAttribute.EnableComposition, + generateValidateMethod, + generateParseMethod, + typeRules, isPrimary ) ); @@ -294,7 +311,9 @@ ExternalSchemaResolver externalSchemas var properties = symbol .GetMembers() .OfType() - .Where(property => property.DeclaredAccessibility == Accessibility.Public) + .Where(property => + property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer + ) .Select(property => GetValidatablePropertyDescriptor(property, externalSchemas)) .ToImmutableArray(); @@ -427,6 +446,8 @@ rangeAttribute with var isEnum = TypeHelpers.UnwrapNullableType(originalPropertyType) is INamedTypeSymbol { TypeKind: TypeKind.Enum }; + var customRules = CustomRuleResolver.Resolve(property, originalPropertyType, diagnostics); + var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable && originalPropertyType is INamedTypeSymbol namedPropertyType @@ -462,7 +483,8 @@ rangeAttribute with allowedValuesAttribute, lengthAttribute, rangeAttributeResult - ) + ), + customRules ), diagnostics.ToImmutable() ); @@ -594,18 +616,21 @@ externalSchemas is null static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) { - if (propertyType.SpecialType == SpecialType.System_String || propertyType is IArrayTypeSymbol) + if (propertyType.SpecialType == SpecialType.System_String) + return new("propertyValue.Length", "string", true); + + if (propertyType is IArrayTypeSymbol) return new("propertyValue.Length", "array", true); if (propertyType is INamedTypeSymbol namedType) { if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.Generic.ICollection)) - return new("propertyValue.Count", "array", true); + return new("propertyValue.Count", "collection", true); if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.IEnumerable)) return new( "global::ZodSharp.Optimizations.CollectionCountHelper.GetCount(propertyValue)", - "array", + "collection", true ); } @@ -837,7 +862,7 @@ static bool IsSchemaDefinedInSource(INamedTypeSymbol type) => && !type.IsAbstract && !type.IsStatic; - static bool TryGetNestedSchemaType( + internal static bool TryGetNestedSchemaType( IPropertySymbol property, ExternalSchemaResolver externalSchemas, out INamedTypeSymbol nested diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index 7e9defb..c849af2 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -26,6 +26,18 @@ public static partial class TypeLibraryGenerator [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity IZodSchemaValidator = default; + [TypeRef(ZodSharpCoreNamespace)] + static readonly TypeIdentity IValidationRule = default; + + [TypeRef(ZodSharpCoreNamespace)] + static readonly TypeIdentity IZodRule = default; + + [TypeRef(ZodSharpCoreNamespace)] + static readonly TypeIdentity ZodRuleAttribute = default; + + [TypeRef("System")] + static readonly TypeIdentity AttributeUsageAttribute = default; + [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity ValidationResult = default; diff --git a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs new file mode 100644 index 0000000..ecc42ad --- /dev/null +++ b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs @@ -0,0 +1,24 @@ +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators.Models; + +/// +/// Describes a validation rule that is bound to a property through a DataAnnotations-style attribute +/// carrying [ZodRule(typeof(...))]. +/// +/// The rule type to instantiate. +/// The error code to report when the rule fails; defaults to validation_failed. +/// The structured Origin to report when the rule fails. +/// The error-message configuration taken from the attribute. +/// The rule constructor argument expressions, in constructor parameter order. +/// +/// Whether the rule implements IZodRule and therefore supplies its own code/origin at runtime. +/// +readonly record struct CustomRuleDescriptor( + TypeIdentity RuleType, + string? Code, + string? Origin, + ValidationAttributeData Message, + EquatableArray Arguments, + bool RuleOwnsIdentity +); diff --git a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs new file mode 100644 index 0000000..3325a1e --- /dev/null +++ b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs @@ -0,0 +1,29 @@ +namespace ZodSharp.SourceGenerators.Models; + +/// +/// Describes the DataAnnotations-style validation attribute generated for a rule marked with +/// [ZodRule] (the parameterless form). +/// +/// The rule the attribute maps to. +/// The attribute type to emit. +/// The accessibility of the generated attribute. +/// The error code the mapping reports, when explicitly configured. +/// The structured origin the mapping reports, when explicitly configured. +/// The generated attribute properties, mirroring the rule constructor parameters. +readonly record struct RuleAttributeGenerationModel( + TypeIdentity RuleType, + TypeIdentity AttributeType, + TypeDeclarationAccessibility Accessibility, + string? Code, + string? Origin, + EquatableArray Properties +); + +/// +/// Describes a settable property on a generated validation attribute. +/// +/// The property type. +/// The property name. +/// The optional initializer expression. +/// Whether the property type is nullable. +readonly record struct GeneratedAttributeProperty(TypeIdentity Type, string Name, string? Initializer, bool IsNullable); diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 972c68f..863eb74 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -40,6 +40,12 @@ enum PropertyValidationKind /// The synchronous refinement method 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. +/// Whether the static Parse method is generated (requires Validate). +/// +/// Rules bound to the target type itself (type-level [ZodRule]-mapped attributes). They are +/// evaluated against the whole value with an empty path. +/// /// True if this is the primary schema for the target type, false if it is a secondary schema. readonly record struct ZodSchemaDescriptor( TypeIdentity TargetType, @@ -53,6 +59,9 @@ readonly record struct ZodSchemaDescriptor( GeneratorResult SyncValidationMethod, bool? GenerateIValidateOptions, bool EnableComposition, + bool GenerateValidateMethod, + bool GenerateParseMethod, + EquatableArray TypeRules, bool IsPrimary ); @@ -68,7 +77,8 @@ readonly record struct ZodPropertyDescriptor( TypeIdentity? NestedSchemaType, LengthAccessor LengthAccessor, bool CompareViaCompareTo, - ValidationAttributes ValidationAttributes + ValidationAttributes ValidationAttributes, + EquatableArray CustomRules ); [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 1ed9481..822fa1e 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -38,6 +38,9 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.IValidateOptionsReferenceNotFound, DiagnosticLibrary.IValidateOptionsValueTypeTarget, DiagnosticLibrary.AmbiguousValidationMethods, + DiagnosticLibrary.UnsupportedCustomRuleTarget, + DiagnosticLibrary.UnmappableCustomRuleArgument, + DiagnosticLibrary.RuleAttributeWithoutSchema, ]; public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; @@ -61,9 +64,19 @@ public override void Initialize(AnalysisContext context) ExternalSchemaResolver externalSchemas = new(compilationContext.Compilation); + // Types that will receive a generated schema: [ZodSchema] roots plus, transitively, the complex + // property types the generator discovers and emits secondary schemas for. + var schemaReachableTypes = BuildSchemaReachableTypes(compilationContext.Compilation, externalSchemas); + compilationContext.RegisterSymbolAction( symbolContext => - AnalyzeNamedType(symbolContext, hasDataAnnotations, hasIValidateOptions, externalSchemas), + AnalyzeNamedType( + symbolContext, + hasDataAnnotations, + hasIValidateOptions, + externalSchemas, + schemaReachableTypes + ), SymbolKind.NamedType ); }); @@ -73,12 +86,15 @@ static void AnalyzeNamedType( SymbolAnalysisContext context, bool hasDataAnnotations, bool hasIValidateOptions, - ExternalSchemaResolver externalSchemas + ExternalSchemaResolver externalSchemas, + ImmutableHashSet schemaReachableTypes ) { if (context.Symbol is not INamedTypeSymbol type) return; + ReportRuleAttributesWithoutSchema(context, type, schemaReachableTypes); + var zodSchemaData = ZodSchemaAttributeData.FromAttributeData(type, out var zodSchemaAttribute); if (!zodSchemaData.Exists) return; @@ -192,4 +208,118 @@ static Location GetMemberLocation(ISymbol member) return Location.None; } + + /// + /// Collects the types that will receive a generated schema: every [ZodSchema] type in this + /// assembly plus, transitively, the complex property types the generator discovers and emits secondary + /// schemas for. + /// + /// The compilation being analyzed. + /// The resolver that decides schema ownership. + /// The set of schema-reachable target types. + static ImmutableHashSet BuildSchemaReachableTypes( + Compilation compilation, + ExternalSchemaResolver externalSchemas + ) + { + HashSet reachable = []; + Queue queue = new(); + + foreach (var type in EnumerateNamedTypes(compilation.Assembly.GlobalNamespace)) + { + if (ZodSchemaAttributeData.FromAttributeData(type, out _).Exists) + queue.Enqueue(type); + } + + while (queue.Count > 0) + { + var symbol = queue.Dequeue(); + if (!reachable.Add(new TypeIdentity(symbol))) + continue; + + foreach (var property in symbol.GetMembers().OfType()) + { + if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) + continue; + + if (SourceGenLibrary.TryGetNestedSchemaType(property, externalSchemas, out var nested)) + queue.Enqueue(nested); + } + } + + return reachable.ToImmutableHashSet(); + } + + static IEnumerable EnumerateNamedTypes(INamespaceSymbol root) + { + foreach (var member in root.GetMembers()) + { + switch (member) + { + case INamespaceSymbol nestedNamespace: + foreach (var nested in EnumerateNamedTypes(nestedNamespace)) + yield return nested; + break; + case INamedTypeSymbol namedType: + yield return namedType; + break; + default: + break; + } + } + } + + /// + /// Reports ZODSGEN033 when a rule-mapped attribute is applied to a type (or one of its + /// properties) that never gets a generated schema, because the rule can then never run. + /// + static void ReportRuleAttributesWithoutSchema( + SymbolAnalysisContext context, + INamedTypeSymbol type, + ImmutableHashSet schemaReachableTypes + ) + { + if (type.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + return; + + if (schemaReachableTypes.Contains(new TypeIdentity(type))) + return; + + ReportRuleAttributes(context, type.GetAttributes(), type.Name); + + foreach (var property in type.GetMembers().OfType()) + { + if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) + continue; + + ReportRuleAttributes(context, property.GetAttributes(), type.Name); + } + } + + static void ReportRuleAttributes( + SymbolAnalysisContext context, + ImmutableArray attributes, + string typeName + ) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeClass is not INamedTypeSymbol attributeClass) + continue; + + // Only attributes explicitly mapped to a ZodSharp rule are ZodSharp-specific; plain + // DataAnnotations attributes are also used by other validators and must not be flagged. + if (!CustomRuleResolver.IsRuleMapped(attributeClass)) + continue; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.RuleAttributeWithoutSchema, + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None, + attributeClass.Name, + typeName + ) + ); + } + } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 208c971..0dfe965 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -17,20 +17,25 @@ bool isPrimary var writer = outputContext.Context.CreateCodeWriter(); GenerateSchemaClass(outputContext, writer, isPrimary, context.CancellationToken); - context.AddSource($"{outputContext.ZodSchema.TargetType.Name}Schema.g.cs", writer); + context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.g.cs", writer); context.CancellationToken.ThrowIfCancellationRequested(); if (!isPrimary || outputContext.ZodSchema.ContainingTypes.Count > 0) return; + // The registration marker advertises a validator that assembly scanning can resolve; without + // the generated Validate method there is nothing to register. + if (!outputContext.ZodSchema.GenerateValidateMethod) + return; + var registrationWriter = outputContext.Context.CreateCodeWriter(); registrationWriter.AutoGeneratedHeader(); registrationWriter.Line( $"[assembly: global::{TypeLibrary.ZodSharp.Core.ZodSchemaGeneratedAttribute.MetadataFullName}(typeof({outputContext.ZodSchema.TargetType.AsTypeReference()}))]" ); - context.AddSource($"{outputContext.ZodSchema.TargetType.Name}SchemaRegistration.g.cs", registrationWriter); + context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}Registration.g.cs", registrationWriter); } static void GenerateSchemaClass( @@ -92,16 +97,22 @@ CancellationToken cancellationToken GeneratePathFields(writer, outputContext.ZodSchema, cancellationToken); GenerateStaticAttributeFields(writer, outputContext.ZodSchema, cancellationToken); - GenerateValidationHelpers(writer, cancellationToken); - GenerateValidateMethod(writer, outputContext.ZodSchema, cancellationToken); - GenerateParseMethod(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateValidateMethod) + { + GenerateValidationHelpers(writer, cancellationToken); + + GenerateValidateMethod(writer, outputContext.ZodSchema, cancellationToken); - if (outputContext.ZodSchema.EnableComposition) - GenerateCompositionMethods(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateParseMethod) + GenerateParseMethod(writer, outputContext.ZodSchema, cancellationToken); + + if (outputContext.ZodSchema.EnableComposition) + GenerateCompositionMethods(writer, outputContext.ZodSchema, cancellationToken); + } } - if (isPrimary) + if (isPrimary && outputContext.ZodSchema.GenerateValidateMethod) { GenerateValidatorAdapter(writer, outputContext.ZodSchema, cancellationToken); @@ -128,7 +139,7 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); - var adapterName = $"{schema.TargetType.Name}SchemaValidator"; + var adapterName = $"{schema.SchemaType.Name}Validator"; var baseType = TypeLibrary.ZodSharp.Core.IZodSchemaValidator.MakeGeneric(schema.TargetType).AsTypeReference(); writer.XmlSummary( @@ -347,6 +358,8 @@ CancellationToken cancellationToken GeneratePropertyValidation(writer, property.Value, cancellationToken); } + GenerateTypeRuleValidations(writer, schema); + GenerateSyncRefinement(schema, method); method.IfBlock( @@ -618,6 +631,8 @@ static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescri default: break; } + + GenerateCustomRuleValidations(writer, property); } static void GenerateCompareValidation(CodeWriter writer, ZodPropertyDescriptor property) diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs new file mode 100644 index 0000000..81025d2 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -0,0 +1,113 @@ +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + /// + /// Emits validation for rules bound to the property through DataAnnotations-style attributes that + /// carry [ZodRule(typeof(...))]. The emitted call reuses the rule's own + /// IsValid/GetErrorMessage contract, so a custom rule behaves exactly like a built-in one. + /// + static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescriptor property) + { + if (property.CustomRules.Count == 0) + return; + + GenerateRuleValidations( + writer, + property.CustomRules, + property.Name, + $"value.{property.Name}", + CodeGenHelpers.GetPathFieldName(property.Name), + property.DisplayName, + declareValueLocal: true + ); + } + + /// + /// Emits type-level rules: [ZodRule]-mapped attributes applied to the target type itself. They + /// validate the whole value (the value object as a unit) and report an empty path. + /// + static void GenerateTypeRuleValidations(CodeWriter writer, ZodSchemaDescriptor schema) + { + if (schema.TypeRules.Count == 0) + return; + + GenerateRuleValidations( + writer, + schema.TypeRules, + schema.TargetType.Name, + "value", + "EmptyPath", + schema.TargetType.Name, + declareValueLocal: false + ); + } + + static void GenerateRuleValidations( + CodeWriter writer, + EquatableArray rules, + string localPrefix, + string valueExpression, + string pathExpression, + string displayName, + bool declareValueLocal + ) + { + for (var i = 0; i < rules.Count; i++) + { + var rule = rules[i]; + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{i}"); + var valueVariable = declareValueLocal + ? CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleValue{i}") + : valueExpression; + var arguments = rule.Arguments.Count == 0 ? string.Empty : $"({string.Join(", ", rule.Arguments)})"; + + if (declareValueLocal) + writer.Assignment("var", valueVariable, valueExpression); + + writer.Assignment("var", ruleVariable, $"new {rule.RuleType.AsTypeReference().RenderFullName}{arguments}"); + + var codeFallback = rule.Code is { Length: > 0 } customCode ? customCode : "validation_failed"; + var zodRuleInterface = TypeLibrary.ZodSharp.Core.IZodRule.AsTypeReference().RenderFullName; + + // A rule that implements IZodRule owns its error identity; the attribute-mapped value is only a + // fallback. The cast is required because the interface may be implemented explicitly. + var codeExpression = rule.RuleOwnsIdentity + ? $"(({zodRuleInterface}){ruleVariable}).Code ?? {codeFallback.Surround()}" + : codeFallback.Surround(); + var originFallback = rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() : "null"; + var originExpression = rule.RuleOwnsIdentity + ? $"(({zodRuleInterface}){ruleVariable}).Origin ?? {originFallback}" + : originFallback; + + var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) + ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) + : $"{ruleVariable}.GetErrorMessage({valueVariable})"; + + writer.IfBlock( + $"!{ruleVariable}.IsValid({valueVariable})", + ifBody => + { + ifBody.IfBlock( + "errors is null", + errorsBody => + errorsBody.Assignment( + "errors", + "new global::System.Collections.Generic.List()" + ) + ); + ifBody.MethodCallOn( + "errors", + "Add", + $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({codeExpression}, {message}, {pathExpression}, origin: {originExpression})" + ); + } + ); + + writer.NewLine(); + } + } +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs new file mode 100644 index 0000000..b663567 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -0,0 +1,286 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + /// + /// Builds the pipeline that turns rules marked with the parameterless [ZodRule] into + /// DataAnnotations-style validation attributes. + /// + internal static IncrementalValuesProvider> GetRuleAttributeProvider( + IncrementalGeneratorInitializationContext context + ) => + IncrementalPipeline.ForAttributeWithMetadataName( + context, + TypeLibrary.ZodSharp.Core.ZodRuleAttribute, + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (attributeContext, cancellationToken) => + GetRuleAttributeModel(attributeContext, cancellationToken) + ); + + internal static void EmitRuleAttributes( + SourceProductionContext context, + SchemaGenerationModel model, + ImmutableArray> rules + ) + { + foreach (var rule in rules) + { + foreach (var diagnostic in rule.Diagnostics) + context.ReportDiagnostic(diagnostic.ToDiagnostic()); + + if (!rule.ShouldProcess) + continue; + + var writer = model.Context.CreateCodeWriter(); + BuildRuleAttribute(writer, rule.Value); + context.AddSource($"{rule.Value.AttributeType.Namespace}.{rule.Value.AttributeType.Name}.g.cs", writer); + } + } + + static GeneratorResult GetRuleAttributeModel( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (context.TargetSymbol is not INamedTypeSymbol ruleType) + return default; + + // Only the parameterless form (marking a rule) is handled here; the mapping form carries a rule + // type and is resolved per property by CustomRuleResolver. + if (!IsValidationRule(ruleType)) + return default; + + var attribute = context.Attributes[0]; + if (attribute.ConstructorArguments.Length > 0) + return default; + + if (ruleType.IsGenericType || ruleType.ContainingType is not null || ruleType.IsAbstract) + { + return GeneratorResult.Create( + default(RuleAttributeGenerationModel), + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedRuleAttributeGeneration, + true, + ruleType, + ruleType.Name, + "only non-generic, non-nested, non-abstract rules are supported" + ) + ); + } + + var attributeName = ResolveAttributeName(ruleType, attribute); + if (attributeName is null) + return default; + + var properties = ImmutableArray.CreateBuilder(); + var constructor = ruleType + .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(static c => c.Parameters.Length) + .FirstOrDefault(); + + if (constructor is not null) + { + foreach (var parameter in constructor.Parameters) + { + if (IsMessageParameter(parameter) || IsCancellationToken(parameter.Type)) + continue; + + if (!IsSupportedAttributePropertyType(parameter.Type)) + { + return GeneratorResult.Create( + default(RuleAttributeGenerationModel), + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedRuleAttributeGeneration, + true, + ruleType, + ruleType.Name, + $"constructor parameter '{parameter.Name}' has type '{parameter.Type.ToDisplayString()}', which cannot be represented as an attribute property" + ) + ); + } + + properties.Add( + new GeneratedAttributeProperty( + new TypeIdentity(TypeHelpers.StripNullableAnnotations(parameter.Type)), + ToPascalCase(parameter.Name), + BuildInitializer(parameter), + TypeHelpers.CanBeNull(parameter.Type) + ) + ); + } + } + + return GeneratorResult.Create( + new RuleAttributeGenerationModel( + new TypeIdentity(ruleType), + new TypeIdentity(attributeName, ruleType.ContainingNamespace.ToDisplayString()), + ruleType.DeclaredAccessibility == Accessibility.Public + ? TypeDeclarationAccessibility.Public + : TypeDeclarationAccessibility.Internal, + GetNamedString(attribute, "Code"), + GetNamedString(attribute, "Origin"), + new(properties.ToImmutable()) + ) + ); + } + + static bool IsValidationRule(INamedTypeSymbol ruleType) => + ruleType.AllInterfaces.Any(static iface => + iface.OriginalDefinition is { Name: "IValidationRule", Arity: 1 } definition + && definition.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpCoreNamespace + ); + + static bool IsMessageParameter(IParameterSymbol parameter) => + string.Equals(parameter.Name, "message", StringComparison.OrdinalIgnoreCase); + + static bool IsCancellationToken(ITypeSymbol type) => type.ToDisplayString() == "System.Threading.CancellationToken"; + + static string BuildInitializer(IParameterSymbol parameter) + { + if ( + parameter.HasExplicitDefaultValue + && CustomRuleResolver.TryConvertValue(parameter.ExplicitDefaultValue, parameter.Type, out var literal) + ) + { + return literal; + } + + return parameter.Type.IsValueType ? "default!" : "null!"; + } + + static string ToPascalCase(string name) => + name.Length == 0 ? name : string.Concat(char.ToUpperInvariant(name[0]), name.AsSpan(1).ToString()); + + static string? GetNamedString(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name) + return pair.Value.Value as string; + } + + return null; + } + + static string? ResolveAttributeName(INamedTypeSymbol ruleType, AttributeData attribute) + { + const string ruleSuffix = "Rule"; + + var explicitName = GetNamedString(attribute, "AttributeName"); + var baseName = + explicitName is { Length: > 0 } ? explicitName + : ruleType.Name.EndsWith(ruleSuffix, StringComparison.Ordinal) && ruleType.Name.Length > ruleSuffix.Length + ? ruleType.Name.Substring(0, ruleType.Name.Length - ruleSuffix.Length) + : ruleType.Name; + var attributeName = $"{baseName}Attribute"; + + // A hand-authored attribute already claims the name: leave it untouched. + return ruleType.ContainingNamespace.GetTypeMembers(attributeName).Length > 0 ? null : attributeName; + } + + static bool IsSupportedAttributePropertyType(ITypeSymbol type) + { + var unwrapped = TypeHelpers.UnwrapNullableType(type); + if (unwrapped is INamedTypeSymbol { TypeKind: TypeKind.Enum }) + return true; + + if (unwrapped.ToDisplayString() == "System.Type") + return true; + + return unwrapped.SpecialType + is SpecialType.System_Boolean + or SpecialType.System_Byte + or SpecialType.System_SByte + or SpecialType.System_Char + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64 + or SpecialType.System_Single + or SpecialType.System_Double + or SpecialType.System_String; + } + + static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel model) + { + writer.AutoGeneratedHeader(); + writer.Using("System").NewLine(); + writer.FileScopedNamespace(model.AttributeType.Namespace); + + writer.XmlSummary( + $"Validation attribute that applies {CodeWriter.XmlSee(model.RuleType.Name)}.", + "Generated from the rule's [ZodRule] marker; the properties mirror the rule's constructor parameters." + ); + + using ( + writer.ClassScope( + new TypeDeclarationOptions(model.AttributeType, model.Accessibility) + { + IsSealed = true, + BaseType = TypeLibrary.System.ComponentModel.DataAnnotations.ValidationAttribute.AsTypeReference(), + Attributes = + [ + new AttributeDeclarationOptions(TypeLibrary.System.AttributeUsageAttribute) + { + Arguments = + [ + new AttributeArgumentOptions( + "global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter", + null, + false + ), + new AttributeArgumentOptions("true", "Inherited", true), + new AttributeArgumentOptions("false", "AllowMultiple", true), + ], + }, + new AttributeDeclarationOptions(TypeLibrary.ZodSharp.Core.ZodRuleAttribute) + { + Arguments = BuildRuleArguments(model), + }, + ], + } + ) + ) + { + foreach (var property in model.Properties) + { + var propertyType = property.IsNullable + ? property.Type.AsTypeReference().Nullable(writer) + : property.Type.AsTypeReference(); + + writer.Property( + new PropertyDeclarationOptions(property.Name, propertyType, TypeDeclarationAccessibility.Public) + { + HasGetter = true, + HasSetter = true, + Initializer = property.Initializer, + } + ); + } + } + } + + static ImmutableArray BuildRuleArguments(RuleAttributeGenerationModel model) + { + var builder = ImmutableArray.CreateBuilder(); + builder.Add(new($"typeof({model.RuleType.RenderFullName})", null, false)); + + if (model.Code is { Length: > 0 } code) + builder.Add(new(code.StringLiteral(), "Code", true)); + + if (model.Origin is { Length: > 0 } origin) + builder.Add(new(origin.StringLiteral(), "Origin", true)); + + return builder.ToImmutable(); + } +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs index ebe0850..fe78982 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs @@ -7,6 +7,11 @@ namespace ZodSharp.SourceGenerators; /// Source generator that creates optimized validators for classes marked with [ZodSchema]. /// Uses IIncrementalGenerator for better performance and incremental compilation support. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "The generator intentionally composes the framework emitters, the schema model and the rule library." +)] [Generator] public sealed partial class ZodSchemaGenerator : IIncrementalGenerator { @@ -60,5 +65,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } } ); + + context.RegisterSourceOutput( + generationValueProviders.Combine(GetRuleAttributeProvider(context).Collect()), + static (spc, pair) => + { + if (pair.Left.Context.Settings.IsSourceGeneratorDisabled) + return; + + EmitRuleAttributes(spc, pair.Left, pair.Right); + } + ); } } diff --git a/src/src/ZodSharp/Core/IZodRule.cs b/src/src/ZodSharp/Core/IZodRule.cs new file mode 100644 index 0000000..a93778c --- /dev/null +++ b/src/src/ZodSharp/Core/IZodRule.cs @@ -0,0 +1,27 @@ +namespace ZodSharp.Core; + +/// +/// Implemented by validation rules that describe their own error identity. When a mapped rule implements +/// this interface the generator prefers the rule's / over the values +/// declared on the mapped attribute, so a single attribute (for example [NotEmpty]) can produce a +/// different error code per annotated member. +/// +/// +/// Rules are constructed in the generated code, so the identity can depend on the rule's constructor +/// arguments. The interface may be implemented explicitly; the generated code casts to +/// when reading the values. +/// +public interface IZodRule +{ + /// + /// Gets the error code reported when the rule fails, or to fall back to the + /// attribute-mapped code (and then to "validation_failed"). + /// + string? Code { get; } + + /// + /// Gets the structured reported when the rule fails, or + /// to fall back to the attribute-mapped origin. + /// + string? Origin { get; } +} diff --git a/src/src/ZodSharp/Core/ValidationResult.cs b/src/src/ZodSharp/Core/ValidationResult.cs index aaf32e4..b320ae1 100644 --- a/src/src/ZodSharp/Core/ValidationResult.cs +++ b/src/src/ZodSharp/Core/ValidationResult.cs @@ -55,9 +55,18 @@ public static ValidationResult Merge(ValidationResult lhs, ValidationResul /// /// The validated value /// Thrown when validation fails + /// [SuppressMessage("Design", "CA1024:Use properties where appropriate")] public T GetValueOrThrow() => IsSuccess ? Value : throw new ZodException(Errors); + /// + /// Throws a if validation failed. + /// + /// The validated value + /// Thrown when validation fails + /// + public T ThrowOnError() => IsSuccess ? Value : throw new ZodException(Errors); + /// /// Creates a successful validation result. /// diff --git a/src/src/ZodSharp/Core/ZodRuleAttribute.cs b/src/src/ZodSharp/Core/ZodRuleAttribute.cs new file mode 100644 index 0000000..fb47275 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodRuleAttribute.cs @@ -0,0 +1,68 @@ +namespace ZodSharp.Core; + +/// +/// Connects a to the ZodSharp +/// validation rule that performs the validation, or marks a rule for generation of a matching +/// DataAnnotations-style attribute. +/// +/// +/// +/// The attribute has two roles: +/// +/// +/// +/// +/// Applied to a validation attribute (for example NoWhitespaceAttribute) it declares which rule +/// the attribute maps to: [ZodRule(typeof(NoWhitespaceRule))]. The [ZodSchema] generator +/// then emits rule-based validation for properties annotated with that attribute, exactly as it does for +/// the built-in DataAnnotations attributes. +/// +/// +/// +/// +/// Applied to a rule (for example NoWhitespaceRule) without a rule type it asks the generator to +/// emit a matching DataAnnotations-style attribute (NoWhitespaceAttribute) whose properties mirror +/// the rule's constructor parameters. +/// +/// +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class ZodRuleAttribute : Attribute +{ + /// + /// Initializes a new instance of the class that marks a rule for + /// validation-attribute generation. + /// + public ZodRuleAttribute() { } + + /// + /// Initializes a new instance of the class that maps a validation + /// attribute to . + /// + /// The IValidationRule<T> implementation the attribute maps to. + /// Thrown when is null. + public ZodRuleAttribute(Type ruleType) => RuleType = ruleType ?? throw new ArgumentNullException(nameof(ruleType)); + + /// + /// Gets the rule type the attribute maps to, or when the attribute marks a rule + /// for validation-attribute generation. + /// + public Type? RuleType { get; } + + /// + /// Gets the error code reported when the rule fails. When omitted, the generated validation reports + /// "validation_failed". + /// + public string? Code { get; init; } + + /// + /// Gets the reported when the rule fails. + public string? Origin { get; init; } + + /// + /// Gets the name of the validation attribute to generate when marking a rule. When omitted, the name is + /// derived from the rule name (a trailing Rule is replaced with Attribute). + /// + public string? AttributeName { get; init; } +} diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index ff8e148..1f3f743 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -64,10 +64,18 @@ public ValidationResult Validate(TInput value) /// Validates the input value asynchronously. /// Equivalent to Zod's safeParseAsync method. /// + /// The input value to validate. + /// A token that cancels the validation before it starts. + /// A validation result. + /// Thrown when the token is already cancelled. public ValueTask> ValidateAsync( TInput value, CancellationToken cancellationToken = default - ) => new(Validate(value)); + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return new(Validate(value)); + } /// /// Parses the input value to the output type. @@ -76,14 +84,45 @@ public ValueTask> ValidateAsync( protected abstract ValidationResult ParseInternal(TInput value); /// - /// Adds a validation rule to this schema. + /// Adds a validation rule to this schema. Rules are evaluated after the schema's + /// hook succeeds; a failing rule produces a + /// with code "validation_failed" and an empty path. /// - protected ZodType AddRule(IValidationRule rule) + /// The rule to add. Implementations are expected to be structs to avoid allocations. + /// This schema for method chaining. + /// Thrown when is null. + public virtual ZodType AddRule(IValidationRule rule) { + ArgumentNullException.ThrowIfNull(rule); _rules = _rules.Add(rule); return this; } + /// + /// Adds a validation rule using the fluent, strongly typed form. + /// + /// The rule type. + /// The rule to add. + /// This schema for method chaining. + /// + /// Convenience wrapper over that keeps the concrete + /// rule type in the chain, mirroring the built-in fluent rule methods (for example + /// Z.String().Min(3)). + /// + public ZodType Rule(TRule rule) + where TRule : IValidationRule + { + if (rule is null) + throw new ArgumentNullException(nameof(rule)); + + return AddRule(rule); + } + + /// + /// Gets the number of rules accumulated on this schema. + /// + protected int RuleCount => _rules.Length; + /// /// Sets the description of this schema. /// Equivalent to Zod's describe method. diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index fdd5c75..73881d1 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -5,7 +5,7 @@ namespace ZodSharp.Rules; /// Mirrors the behavior of System.ComponentModel.DataAnnotations.CreditCardAttribute. /// Uses struct to avoid allocations. /// -public readonly record struct CreditCardRule : Core.IValidationRule +public readonly record struct CreditCardRule : Core.IValidationRule, Core.IStringValidationRule { readonly string? _message; @@ -28,6 +28,19 @@ public bool IsValid(in string value) if (string.IsNullOrWhiteSpace(value)) return false; + return IsValid(value.AsSpan()); + } + + /// + /// Validates that the span is a valid credit card number without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsWhiteSpace()) + return false; + var sum = 0; var digitCount = 0; @@ -61,4 +74,12 @@ public bool IsValid(in string value) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => _message ?? $"Invalid credit card number format: {value}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? $"Invalid credit card number format: {value}"; } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 2f60cbe..22ea6e6 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// Validation rule for email format. /// Uses struct to avoid allocations. /// -public readonly record struct EmailRule : Core.IValidationRule +public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule { /// /// Regular expression for validating email format. @@ -24,10 +24,24 @@ namespace ZodSharp.Rules; /// True if valid, false otherwise public bool IsValid(in string value) => EmailRegex.IsMatch(value); + /// + /// Validates that the span matches the email format without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => EmailRegex.IsMatch(value); + /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => $"Invalid email format: {value}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => $"Invalid email format: {value}"; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index 14a0955..f18d2c6 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -4,7 +4,7 @@ namespace ZodSharp.Rules; /// Validation rule for string suffix. /// Uses struct to avoid allocations. /// -public readonly record struct EndsWithRule : Core.IValidationRule +public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule { readonly string _suffix; readonly string? _message; @@ -27,6 +27,13 @@ public EndsWithRule(string suffix, string? message = null) /// True if valid, false otherwise public bool IsValid(in string value) => value != null && value.EndsWith(_suffix, StringComparison.Ordinal); + /// + /// Validates that the span ends with the specified suffix without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.EndsWith(_suffix.AsSpan(), StringComparison.Ordinal); + /// /// Gets the error message for a failed validation. /// @@ -34,4 +41,12 @@ public EndsWithRule(string suffix, string? message = null) /// The error message public string GetErrorMessage(in string value) => _message ?? $"String must end with '{_suffix}', but got '{value}'"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? $"String must end with '{_suffix}', but got '{value}'"; } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs new file mode 100644 index 0000000..bc6937a --- /dev/null +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -0,0 +1,35 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for an exclusive minimum numeric value (strictly greater than). +/// Uses struct to avoid allocations. +/// +/// The numeric type +public readonly record struct GreaterThanRule : Core.IValidationRule + where T : IComparable +{ + readonly T _exclusiveMinimum; + + /// + /// Initializes a new instance of the GreaterThanRule struct. + /// + /// The value the input must be strictly greater than + public GreaterThanRule(T exclusiveMinimum) + { + _exclusiveMinimum = exclusiveMinimum; + } + + /// + /// Validates that the value is strictly greater than the configured bound. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => value.CompareTo(_exclusiveMinimum) > 0; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => $"Value must be greater than {_exclusiveMinimum}, but got {value}"; +} diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs new file mode 100644 index 0000000..b005a9c --- /dev/null +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -0,0 +1,35 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for an exclusive maximum numeric value (strictly less than). +/// Uses struct to avoid allocations. +/// +/// The numeric type +public readonly record struct LessThanRule : Core.IValidationRule + where T : IComparable +{ + readonly T _exclusiveMaximum; + + /// + /// Initializes a new instance of the LessThanRule struct. + /// + /// The value the input must be strictly less than + public LessThanRule(T exclusiveMaximum) + { + _exclusiveMaximum = exclusiveMaximum; + } + + /// + /// Validates that the value is strictly less than the configured bound. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => value.CompareTo(_exclusiveMaximum) < 0; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => $"Value must be less than {_exclusiveMaximum}, but got {value}"; +} diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 855672a..de344f3 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -4,7 +4,7 @@ namespace ZodSharp.Rules; /// Validation rule for maximum string length. /// Uses struct to avoid allocations. /// -public readonly record struct MaxLengthRule : Core.IValidationRule +public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule { readonly int _maxLength; @@ -24,6 +24,13 @@ public MaxLengthRule(int maxLength) /// True if valid, false otherwise public bool IsValid(in string value) => value.LengthOrDefault() <= _maxLength; + /// + /// Validates that the span meets the maximum length requirement without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Length <= _maxLength; + /// /// Gets the error message for a failed validation. /// @@ -31,4 +38,12 @@ public MaxLengthRule(int maxLength) /// The error message public string GetErrorMessage(in string value) => $"String must be at most {_maxLength} characters long, but got {value.LengthOrDefault()}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + $"String must be at most {_maxLength} characters long, but got {value.Length}"; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 337aaf8..4943baa 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -4,7 +4,7 @@ namespace ZodSharp.Rules; /// Validation rule for minimum string length. /// Uses struct to avoid allocations. /// -public readonly record struct MinLengthRule : Core.IValidationRule +public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule { readonly int _minLength; @@ -24,6 +24,13 @@ public MinLengthRule(int minLength) /// True if valid, false otherwise public bool IsValid(in string value) => value.LengthOrDefault() >= _minLength; + /// + /// Validates that the span meets the minimum length requirement without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Length >= _minLength; + /// /// Gets the error message for a failed validation. /// @@ -31,4 +38,12 @@ public MinLengthRule(int minLength) /// The error message public string GetErrorMessage(in string value) => $"String must be at least {_minLength} characters long, but got {value.LengthOrDefault()}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + $"String must be at least {_minLength} characters long, but got {value.Length}"; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index 51fd4a6..f277f83 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -6,6 +6,11 @@ namespace ZodSharp.Rules; /// public readonly record struct MultipleOfRule : Core.IValidationRule { + /// + /// The relative tolerance applied when comparing the quotient to its nearest integer. + /// + const double RelativeTolerance = 1e-12; + readonly double _divisor; readonly string? _message; @@ -30,8 +35,16 @@ public MultipleOfRule(double divisor, string? message = null) /// True if valid, false otherwise public bool IsValid(in double value) { - var remainder = Math.Abs(value % _divisor); - return remainder < double.Epsilon || Math.Abs(remainder - _divisor) < double.Epsilon; + if (double.IsNaN(value) || double.IsInfinity(value)) + return false; + + // Floating-point division is inexact (for example 0.3 / 0.1 == 2.9999999999999996), + // so compare the quotient against its nearest integer using a relative tolerance + // instead of testing the raw remainder. + var quotient = value / _divisor; + var nearestInteger = Math.Round(quotient); + var tolerance = RelativeTolerance * Math.Max(1.0, Math.Abs(quotient)); + return Math.Abs(quotient - nearestInteger) <= tolerance; } /// diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index ce94fcb..c6c8468 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// allows digits and the characters () . + -, and requires at least one digit. /// Uses struct to avoid allocations. /// -public readonly record struct PhoneRule : Core.IValidationRule +public readonly record struct PhoneRule : Core.IValidationRule, Core.IStringValidationRule { readonly string? _message; @@ -24,9 +24,16 @@ public PhoneRule(string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a valid phone number without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) { - if (string.IsNullOrWhiteSpace(value)) + if (value.IsWhiteSpace()) return false; const string additionalChars = "() .+-"; @@ -55,4 +62,11 @@ public bool IsValid(in string value) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => _message ?? $"Invalid phone number format: {value}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => _message ?? $"Invalid phone number format: {value}"; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 85e5048..63746a5 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// Validation rule for regex pattern matching. /// Uses struct to avoid allocations. /// -public readonly record struct RegexRule : Core.IValidationRule +public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule { readonly Regex _pattern; readonly string? _message; @@ -37,6 +37,13 @@ public RegexRule(string pattern, string? message = null) /// True if valid, false otherwise public bool IsValid(in string value) => _pattern.IsMatch(value); + /// + /// Validates that the span matches the regex pattern without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => _pattern.IsMatch(value); + /// /// Gets the error message for a failed validation. /// @@ -44,4 +51,11 @@ public RegexRule(string pattern, string? message = null) /// The error message public string GetErrorMessage(in string value) => _message ?? $"String does not match the required pattern: {_pattern}"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessage(value.ToString()); } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 5f57b9c..0a13389 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -4,7 +4,7 @@ namespace ZodSharp.Rules; /// Validation rule for string prefix. /// Uses struct to avoid allocations. /// -public readonly record struct StartsWithRule : Core.IValidationRule +public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule { readonly string _prefix; readonly string? _message; @@ -27,6 +27,13 @@ public StartsWithRule(string prefix, string? message = null) /// True if valid, false otherwise public bool IsValid(in string value) => value != null && value.StartsWith(_prefix, StringComparison.Ordinal); + /// + /// Validates that the span starts with the specified prefix without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.StartsWith(_prefix.AsSpan(), StringComparison.Ordinal); + /// /// Gets the error message for a failed validation. /// @@ -34,4 +41,12 @@ public StartsWithRule(string prefix, string? message = null) /// The error message public string GetErrorMessage(in string value) => _message ?? $"String must start with '{_prefix}', but got '{value}'"; + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? $"String must start with '{_prefix}', but got '{value}'"; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index cb230d4..36e9330 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -4,7 +4,7 @@ namespace ZodSharp.Rules; /// Validation rule for UUID format. /// Uses struct to avoid allocations. /// -public readonly record struct UUIDRule : Core.IValidationRule +public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule { readonly string? _message; readonly UuidVersion? _version; @@ -39,9 +39,16 @@ public UUIDRule(UuidVersion version, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a valid UUID without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) { - if (string.IsNullOrWhiteSpace(value) || value.Length != 36) + if (value.IsWhiteSpace() || value.Length != 36) return false; if (!HasValidStructure(value)) @@ -59,7 +66,19 @@ public bool IsValid(in string value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => + public string GetErrorMessage(in string value) => GetErrorMessageCore(value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessageCore(value.ToString()); + + const string NilUuid = "00000000-0000-0000-0000-000000000000"; + const string MaxUuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + + string GetErrorMessageCore(string value) => _message ?? ( _version is UuidVersion version @@ -67,12 +86,12 @@ _version is UuidVersion version : $"Invalid UUID format: {value}" ); - static bool IsValidVersionless(string value) + static bool IsValidVersionless(ReadOnlySpan value) { // nil and max are allowed regardless of version/variant (Zod parity). - if (value == "00000000-0000-0000-0000-000000000000") + if (value.SequenceEqual(NilUuid)) return true; - if (value == "ffffffff-ffff-ffff-ffff-ffffffffffff") + if (value.SequenceEqual(MaxUuid)) return true; var version = value[14]; @@ -83,7 +102,7 @@ static bool IsValidVersionless(string value) return IsValidVariant(value[19]); } - static bool HasValidStructure(string value) + static bool HasValidStructure(ReadOnlySpan value) { if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false; diff --git a/src/src/ZodSharp/Schemas/ZodArray.cs b/src/src/ZodSharp/Schemas/ZodArray.cs index 72a4e98..63fc373 100644 --- a/src/src/ZodSharp/Schemas/ZodArray.cs +++ b/src/src/ZodSharp/Schemas/ZodArray.cs @@ -74,7 +74,12 @@ protected override ValidationResult ParseInternal(T[] value) new ValidationError( "too_small", _errorMessage ?? $"Array must have at least {_minLength.Value} elements, but got {count}", - EmptyPath + EmptyPath, + parameters: null, + origin: "array", + minimum: _minLength.Value, + maximum: _maxLength, + inclusive: true ) ); } @@ -85,7 +90,12 @@ protected override ValidationResult ParseInternal(T[] value) new ValidationError( "too_big", _errorMessage ?? $"Array must have at most {_maxLength.Value} elements, but got {count}", - EmptyPath + EmptyPath, + parameters: null, + origin: "array", + minimum: _minLength, + maximum: _maxLength.Value, + inclusive: true ) ); } diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs index 2f2646f..cdcfbf4 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -1,5 +1,8 @@ -using System.Collections.Immutable; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -19,6 +22,16 @@ public class ZodDiscriminatedUnion( ImmutableDictionary> options ) : ZodType { + // Reflection on the discriminator is unavoidable for POCO inputs, but it only has to happen once per + // (type, discriminator) pair: the accessor is compiled and cached, so validation hot paths run direct + // property access with no reflection and no boxing for string discriminators. + static readonly ConditionalWeakTable< + Type, + ConcurrentDictionary> + > DiscriminatorAccessors = new(); + + static readonly Func MissingDiscriminatorAccessor = static _ => null; + /// /// Parses and validates the value using the discriminated union. /// @@ -69,11 +82,49 @@ protected override ValidationResult ParseInternal(object value) return TryGetDictionaryDiscriminatorValue(dictionary); } - var property = value - .GetType() - .GetProperty(discriminator, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + var type = value.GetType(); + var accessors = DiscriminatorAccessors.GetValue( + type, + static _ => new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase) + ); + var accessor = accessors.GetOrAdd( + discriminator, + static (name, candidateType) => + BuildDiscriminatorAccessor(candidateType, name) ?? MissingDiscriminatorAccessor, + type + ); + + return accessor(value); + } + + /// + /// Compiles a that reads the discriminator property from an instance, + /// so subsequent validations avoid reflection and, for discriminators, boxing. + /// + /// The runtime type of the value being validated. + /// The discriminator property name. + /// The compiled accessor, or when no readable property exists. + static Func? BuildDiscriminatorAccessor(Type type, string discriminator) + { + var property = type.GetProperty( + discriminator, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase + ); + + if (property is null || !property.CanRead || property.GetMethod is not { IsStatic: false }) + return null; + + var parameter = Expression.Parameter(typeof(object), "value"); + Expression access = Expression.Property(Expression.Convert(parameter, type), property); + Expression boxed = Expression.Convert(access, typeof(object)); + + var body = Expression.Condition( + Expression.NotEqual(boxed, Expression.Constant(null, typeof(object))), + Expression.Call(boxed, typeof(object).GetMethod(nameof(ToString))!), + Expression.Constant(null, typeof(string)) + ); - return property?.GetValue(value)?.ToString(); + return Expression.Lambda>(body, parameter).Compile(); } string? TryGetDictionaryDiscriminatorValue(IEnumerable> dictionary) diff --git a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs index 94fbdff..b568b87 100644 --- a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs @@ -18,17 +18,17 @@ public class ZodNativeEnum() : ZodType { static readonly string[] EmptyPath = []; + // Resolving the defined members once per closed generic type avoids the per-validation + // Enum.IsDefined reflection path. + static readonly HashSet DefinedValues = [.. Enum.GetValues()]; + /// /// Validates that the value is a defined enum member. /// /// The value to validate. /// A validation result. protected override ValidationResult ParseInternal(TEnum value) => -#if NET5_0_OR_GREATER - Enum.IsDefined(value) -#else - Enum.IsDefined(typeof(TEnum), value) -#endif + DefinedValues.Contains(value) ? ValidationResult.Success(value) : ValidationResult.Failure( new ValidationError( diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index c25a4c6..33678ca 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -57,20 +57,42 @@ public ZodNumber Int() } /// - /// Adds a positive number validation. + /// Adds a strictly positive number validation (value must be greater than zero). + /// Equivalent to Zod's z.number().positive(). /// /// This schema for method chaining public ZodNumber Positive() { - AddRule(new MinValueRule(0.0)); + AddRule(new GreaterThanRule(0.0)); return this; } /// - /// Adds a negative number validation. + /// Adds a strictly negative number validation (value must be less than zero). + /// Equivalent to Zod's z.number().negative(). /// /// This schema for method chaining public ZodNumber Negative() + { + AddRule(new LessThanRule(0.0)); + return this; + } + + /// + /// Adds a non-negative number validation (value must be greater than or equal to zero). + /// + /// This schema for method chaining + public ZodNumber NonNegative() + { + AddRule(new MinValueRule(0.0)); + return this; + } + + /// + /// Adds a non-positive number validation (value must be less than or equal to zero). + /// + /// This schema for method chaining + public ZodNumber NonPositive() { AddRule(new MaxValueRule(0.0)); return this; diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index e65f628..362e260 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Text.RegularExpressions; using ZodSharp.Core; using ZodSharp.Rules; @@ -12,6 +13,33 @@ public class ZodString : ZodType { static readonly string[] EmptyPath = []; + // Rules that also implement IStringValidationRule, so ValidateSpan/IsValidSpan can validate the + // incoming span without materialising a string. When any rule lacks the span contract the list is + // abandoned and span validation falls back to the string path. + ImmutableArray _spanRules = []; + bool _spanRulesSupported = true; + + /// + /// Gets whether span validation can bypass materialising the input string. + /// + protected virtual bool SupportsSpanValidation => true; + + /// + public override ZodType AddRule(IValidationRule rule) + { + base.AddRule(rule); + + if (_spanRulesSupported) + { + if (rule is IStringValidationRule spanRule) + _spanRules = _spanRules.Add(spanRule); + else + _spanRulesSupported = false; + } + + return this; + } + /// /// Parses and validates a string value. /// @@ -25,21 +53,68 @@ protected override ValidationResult ParseInternal(string value) => : ValidationResult.Success(value); /// - /// Validates a ReadOnlySpan of characters without allocating a string. + /// Validates a of characters using the accumulated rules' span path. /// - /// The span to validate - /// A validation result + /// The span to validate. + /// A validation result. The returned value is a string, so a successful validation still allocates once. + /// + /// When every accumulated rule implements the input is never + /// materialised; otherwise the span is converted to a string and validated through + /// Validate. Use when no validated value is + /// needed: it does not allocate on success. + /// public ValidationResult ValidateSpan(ReadOnlySpan value) { - if (value.IsEmpty && value.Length == 0) + // Transforms (and any rule without the span contract) must run through the string pipeline so the + // produced value is preserved. + if (!SupportsSpanRules) + return Validate(value.ToString()); + + return IsValidSpan(value, out var errors) + ? ValidationResult.Success(value.ToString()) + : ValidationResult.Failure(errors); + } + + /// + /// Validates a span without materialising the input string. + /// + /// The span to validate. + /// The validation errors when the span is invalid; empty when it is valid. + /// when the span is valid. + /// + /// This is the allocation-free span entry point: it validates the span directly when every rule + /// implements , and otherwise falls back to the string path once. + /// + public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray errors) + { + errors = []; + + if (!SupportsSpanRules) { - return ValidationResult.Success(string.Empty); + var result = Validate(value.ToString()); + errors = result.Errors; + return result.IsSuccess; } - var str = value.ToString(); - return Validate(str); + ImmutableArray.Builder? builder = null; + foreach (var rule in _spanRules) + { + if (rule.IsValid(value)) + continue; + + builder ??= ImmutableArray.CreateBuilder(); + builder.Add(new ValidationError("validation_failed", rule.GetErrorMessage(value), EmptyPath)); + } + + if (builder is null) + return true; + + errors = builder.ToImmutable(); + return false; } + bool SupportsSpanRules => _spanRulesSupported && SupportsSpanValidation && _spanRules.Length == RuleCount; + /// /// Adds a minimum length validation. /// @@ -238,6 +313,8 @@ public ZodString Trim() class ZodStringWrapper(ZodTransform transform) : ZodString { + protected override bool SupportsSpanValidation => false; + protected override ValidationResult ParseInternal(string value) => transform.Validate(value); } } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 1366fda..cd26825 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -70,6 +70,107 @@ var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); DataAnnotations attributes such as `[Required]`, `[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, and `[Compare]` are validated with direct, typed codegen (no reflection). +## Custom rules + +A rule is any struct implementing `ZodSharp.Core.IValidationRule`; attach it to a schema with the public `Rule`/`AddRule` API, or expose it as a DataAnnotations-style attribute that the source generator honours exactly like the built-ins: + +```csharp +using System; +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Core; + +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule +{ + public bool IsValid(in string value) => value.IndexOf(' ') < 0; + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; +} + +[ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} + +// Fluent usage: +var schema = Z.String().Rule(new NoWhitespaceRule()); + +[ZodSchema] +public class User +{ + [Required] + [NoWhitespace(Message = "No spaces allowed.")] + public string Name { get; set; } = string.Empty; +} +``` + +Marking the rule itself with the parameterless `[ZodRule]` makes the generator emit a matching `NoWhitespaceAttribute` whose properties mirror the rule's constructor parameters. + +Rules can be **generic**: map the unbound generic rule type and the generator closes it with the property type, so one rule serves every primitive. Implementing `IZodRule` lets the rule supply a per-member error code: + +```csharp +public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where T : struct, IEquatable +{ + public bool IsValid(in T value) => !value.Equals(default(T)); + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NotEmptyRule<>))] +public sealed class NotEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} + +[ZodSchema] +public partial record struct AssetId +{ + [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] + public Guid Value { get; init; } +} +``` + +`[NotEmpty]` on a `Guid` property emits `NotEmptyRule`; on an `int` property it emits `NotEmptyRule`. See the [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) page for the full precedence rules and the scalar value-object walkthrough. + +## Type-level rules (validating the value object) + +Rules can be attached to the **`[ZodSchema]` type itself**; they validate the whole value with an empty path, which is the right shape for a scalar whose single value *is* the value object: + +```csharp +public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NotEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NotEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} + +[Scalar] +[ZodSchema] +[NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. + ## Error factory `ErrorType` lets you define a user-facing error (code, optional category, description, optional diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs new file mode 100644 index 0000000..9880857 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -0,0 +1,155 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task CustomRule_GivenRuleThatDoesNotMatchPropertyType_ProducesZODSGEN030( + CancellationToken cancellationToken + ) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NumberOnlyRule : IValidationRule + { + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => "Must be positive."; + } + + [ZodRule(typeof(NumberOnlyRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberOnlyAttribute : ValidationAttribute { } + + [ZodSchema] + public sealed class MismatchedRuleModel + { + [Required] + [NumberOnly] + public string? Name { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + [Test] + public async Task CustomRule_GivenUnclosableGenericRule_ProducesZODSGEN030(CancellationToken cancellationToken) + { + // NotEmptyRule requires T : struct, so a string property cannot satisfy the constraint. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Message = null) + : IValidationRule + where T : struct, IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute { } + + [ZodSchema] + public sealed class StrictScalarModel + { + [NotEmpty] + public string? Name { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + [Test] + public async Task TypeRule_GivenRuleAttributeWithoutSchema_ProducesZODSGEN033(CancellationToken cancellationToken) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in TSelf value) => value is not null; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute { } + + [NotEmpty] + public partial record struct AssetId + { + public Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } + + [Test] + public async Task TypeRule_GivenNestedReachableType_DoesNotProduceZODSGEN033(CancellationToken cancellationToken) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in TSelf value) => value is not null; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute { } + + [NotEmpty] + public partial record struct AssetId + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class Order + { + public AssetId Id { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs new file mode 100644 index 0000000..b9245de --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs @@ -0,0 +1,155 @@ +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task SchemaName_GivenCustomName_UsesItForSchemaAndAdapter(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema(SchemaName = "UserZod")] + public class User + { + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + var schema = driverResult.GetSource("UserZod.g.cs", HintNameMatchMode.Suffix); + await Assert.That(schema).ContainsGeneratedCode("class UserZod"); + await Assert.That(schema).DoesNotContain("class UserSchema"); + await Assert.That(schema).ContainsGeneratedCode("class UserZodValidator"); + } + + [Test] + public async Task GenerateValidateMethod_GivenFalse_DoesNotEmitValidateOrDependentMembers( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema(GenerateValidateMethod = false)] + public class User + { + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); + + // Assert + await Assert.That(schema).DoesNotContain("Validate("); + await Assert.That(schema).DoesNotContain(" Parse("); + await Assert.That(schema).DoesNotContain("ApplyAnd"); + } + + [Test] + public async Task GenerateParseMethod_GivenFalse_StillEmitsValidate(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema(GenerateParseMethod = false)] + public class User + { + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); + + // Assert + await Assert.That(schema).ContainsGeneratedCode("ValidationResult Validate("); + await Assert.That(schema).DoesNotContain("global::Testing.User Parse("); + } + + [Test] + public async Task LengthValidation_GivenCollectionProperty_ReportsCollectionOrigin( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Basket + { + [Required] + [Length(2, 5)] + public List? Items { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); + + // Assert + await Assert.That(schema).ContainsGeneratedCode("origin: \"collection\""); + await Assert.That(schema).DoesNotContain("origin: \"array\""); + } + + [Test] + public async Task LengthValidation_GivenArrayProperty_ReportsArrayOrigin(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Basket + { + [Required] + [Length(2, 5)] + public string[]? Items { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); + + // Assert + await Assert.That(schema).ContainsGeneratedCode("origin: \"array\""); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs new file mode 100644 index 0000000..051a054 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -0,0 +1,127 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string CustomRuleSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.IndexOf(' ') < 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + + [ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public sealed class NoWhitespaceAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [ZodSchema] + public class User + { + [Required] + [NoWhitespace(Message = "No spaces allowed.")] + public string Name { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task CustomRule_GivenZodRuleMappedAttribute_EmitsRuleValidation(CancellationToken cancellationToken) + { + // Arrange / Act + var driverResult = await GenerateAsync(CustomRuleSource, cancellationToken); + var generated = driverResult.GetSource("UserSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NoWhitespaceRule("); + await Assert.That(generated).ContainsGeneratedCode("\"No spaces allowed.\""); + await Assert.That(generated).ContainsGeneratedCode(".IsValid("); + await Assert.That(generated).ContainsGeneratedCode("\"invalid_string\""); + await Assert.That(generated).ContainsGeneratedCode("origin: \"string\""); + } + + [Test] + public async Task CustomRule_GivenZodRuleMappedAttribute_FailsValidationAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + CustomRuleSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.User")!; + var schemaType = assembly.GetType("Testing.UserSchema")!; + + var instance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Name")!.SetValue(instance, "John Doe"); + + // Act + var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; + + // Assert + var isSuccess = (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + await Assert.That(isSuccess).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + result.GetType().GetProperty("Errors")!.GetValue(result)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("invalid_string"); + await Assert.That(errors[0].Origin).IsEqualTo("string"); + } + + [Test] + public async Task CustomRule_GivenMismatchedRuleType_ReportsDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NumberOnlyRule : IValidationRule + { + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => "Must be positive."; + } + + [ZodRule(typeof(NumberOnlyRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NumberOnlyAttribute : ValidationAttribute { } + + [ZodSchema] + public class Model + { + [Required] + [NumberOnly] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions { ThrowOnGenerationException = false }, + cancellationToken + ); + + // Assert + await Assert.That(driverResult.GetSource("ModelSchema")).DoesNotContain("NumberOnlyRule"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs new file mode 100644 index 0000000..f3937f8 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -0,0 +1,202 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string GenericRuleSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where T : struct, IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public sealed class NotEmptyAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [ZodSchema] + public partial record struct AssetId + { + [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] + public Guid Value { get; init; } + } + + [ZodSchema] + public partial record struct Sequence + { + [NotEmpty] + public int Value { get; init; } + } + } + """; + + [Test] + public async Task GenericRule_GivenUnboundGenericRule_ClosesWithPropertyType(CancellationToken cancellationToken) + { + // Act + var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + var assetSchema = driverResult.GetSource("AssetIdSchema"); + var sequenceSchema = driverResult.GetSource("SequenceSchema"); + + // Assert + await Assert.That(assetSchema).ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + await Assert.That(sequenceSchema).ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + } + + [Test] + public async Task GenericRule_GivenRuleOwningIdentity_EmitsRuleCodeExpression(CancellationToken cancellationToken) + { + // Act + var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert — the rule implements IZodRule, so its own identity is preferred over the attribute's. + await Assert + .That(generated) + .ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)valueCustomRule0).Code ?? \"invalid_asset_id\""); + await Assert + .That(generated) + .ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)valueCustomRule0).Origin ?? null"); + } + + [Test] + public async Task GenericRule_GivenScalarLikeStruct_FailsAtRuntimeForEmptyGuid(CancellationToken cancellationToken) + { + // Arrange + var driverResult = await GenerateAsync( + GenericRuleSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.AssetId")!; + var schemaType = assembly.GetType("Testing.AssetIdSchema")!; + var validate = schemaType.GetMethod("Validate")!; + + // Act — default(Guid) is Guid.Empty + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, Guid.NewGuid()); + var validResult = validate.Invoke(null, [validInstance])!; + + // Assert + await Assert.That((bool)emptyResult.GetType().GetProperty("IsSuccess")!.GetValue(emptyResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("invalid_asset_id"); + await Assert.That(errors[0].Origin).IsEqualTo("value_object"); + await Assert.That(errors[0].Message).IsEqualTo("AssetId must not be empty."); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task GenericRule_GivenReferenceTypeProperty_DoesNotEmitUnclosableRule( + CancellationToken cancellationToken + ) + { + // Arrange — NotEmptyRule requires T : struct, so a string property cannot be closed. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Message = null) + : IValidationRule + where T : struct, IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute { } + + [ZodSchema] + public class Broken + { + [NotEmpty] + public string? Name { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("BrokenSchema"); + + // Assert + await Assert.That(generated).DoesNotContain("NotEmptyRule"); + } + + [Test] + public async Task AttributeCode_GivenCodeNamedArgument_OverridesZodRuleCode(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NonEmptyGuidRule(string? Message = null) : IValidationRule + { + public bool IsValid(in Guid value) => value != Guid.Empty; + + public string GetErrorMessage(in Guid value) => Message ?? "Empty."; + } + + [ZodRule(typeof(NonEmptyGuidRule), Code = "rule_code")] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NonEmptyGuidAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [ZodSchema] + public partial record struct AssetId + { + [NonEmptyGuid(Code = "attr_code", Message = "AssetId must not be empty.")] + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert — the attribute-level Code wins over the [ZodRule] Code. + await Assert.That(generated).ContainsGeneratedCode("\"attr_code\""); + await Assert.That(generated).DoesNotContain("rule_code"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs new file mode 100644 index 0000000..78082f4 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -0,0 +1,129 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task RuleAttributeGeneration_GivenZodRuleOnRule_GeneratesMatchingAttribute( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule(Code = "invalid_string", Origin = "string")] + public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) + : IValidationRule + { + public bool IsValid(in string value) => AllowEmpty || value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("NoWhitespaceAttribute"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("namespace Testing.Rules"); + await Assert.That(generated).ContainsGeneratedCode("class NoWhitespaceAttribute"); + await Assert.That(generated).Contains("global::System.ComponentModel.DataAnnotations.ValidationAttribute"); + await Assert.That(generated).ContainsGeneratedCode("public bool AllowEmpty { get; set; } = true;"); + await Assert.That(generated).ContainsGeneratedCode("typeof(global::Testing.Rules.NoWhitespaceRule)"); + await Assert.That(generated).ContainsGeneratedCode("\"invalid_string\""); + await Assert.That(generated).ContainsGeneratedCode("\"string\""); + // The rule's 'message' parameter is represented by the inherited ValidationAttribute.ErrorMessage. + await Assert.That(generated).DoesNotContain("public string Message"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenZodRuleOnRule_GeneratedAttributeCompilesAndCarriesMapping( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule(Code = "invalid_string", Origin = "string")] + public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) + : IValidationRule + { + public bool IsValid(in string value) => AllowEmpty || value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + // Assert + var attributeType = assembly.GetType("Testing.Rules.NoWhitespaceAttribute"); + await Assert.That(attributeType).IsNotNull(); + await Assert + .That(attributeType!.BaseType!.FullName) + .IsEqualTo("System.ComponentModel.DataAnnotations.ValidationAttribute"); + await Assert.That(attributeType.GetProperty("AllowEmpty")).IsNotNull(); + + var mapping = attributeType + .GetCustomAttributes(inherit: false) + .FirstOrDefault(static a => a.GetType().FullName == "ZodSharp.Core.ZodRuleAttribute"); + await Assert.That(mapping).IsNotNull(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenHandAuthoredAttribute_DoesNotGenerateDuplicate( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.IndexOf(' ') < 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + + [ZodRule(typeof(NoWhitespaceRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NoWhitespaceAttribute : ValidationAttribute { } + + [ZodRule] + public readonly record struct OtherRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Invalid."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert — the hand-authored attribute is left alone, the unmapped rule still gets one. + await Assert.That(driverResult.GetSource("OtherAttribute")).Contains("class OtherAttribute"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs new file mode 100644 index 0000000..98bbf3f --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StaticProperties.cs @@ -0,0 +1,86 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task StaticProperties_GivenPublicStaticFactories_AreNotIncludedInSchema( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public readonly partial record struct ProviderType + { + [Required(AllowEmptyStrings = false)] + public string Value { get; init; } + + public static ProviderType GitHub => new() { Value = "GitHub" }; + + public static ProviderType AzureDevOps => new() { Value = "AzureDevOps" }; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("ProviderTypeSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("value.Value"); + await Assert.That(generated).DoesNotContain("GitHub"); + await Assert.That(generated).DoesNotContain("AzureDevOps"); + await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + } + + [Test] + public async Task IndexerProperties_GivenIndexer_AreNotIncludedInSchema(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class WithIndexer + { + [Required] + [StringLength(10, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + + public string this[int index] + { + get => Name; + set => Name = value; + } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("WithIndexerSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("value.Name"); + await Assert.That(generated).DoesNotContain("value.Item"); + await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs new file mode 100644 index 0000000..269023b --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -0,0 +1,173 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string TypeRuleSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] + [ZodSchema] + public partial record struct AssetId : IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + [Test] + public async Task TypeRule_GivenRuleAttributeOnSchemaType_EmitsWholeObjectRule(CancellationToken cancellationToken) + { + // Act + var driverResult = await GenerateAsync(TypeRuleSource, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert — the rule runs against the value object itself with an empty path. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + await Assert + .That(generated) + .ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? \"invalid_asset_id\""); + // The rule is bound to the value object, not to its Guid property. + await Assert.That(generated).DoesNotContain("NotEmptyRule"); + } + + [Test] + public async Task TypeRule_GivenEmptyValueObject_FailsAtRuntimeWithTypeLevelIdentity( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + TypeRuleSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.AssetId")!; + var schemaType = assembly.GetType("Testing.AssetIdSchema")!; + var validate = schemaType.GetMethod("Validate")!; + + // Act — default(Guid) is Guid.Empty + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, Guid.NewGuid()); + var validResult = validate.Invoke(null, [validInstance])!; + + // Assert + await Assert.That((bool)emptyResult.GetType().GetProperty("IsSuccess")!.GetValue(emptyResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("invalid_asset_id"); + await Assert.That(errors[0].Origin).IsEqualTo("value_object"); + await Assert.That(errors[0].Message).IsEqualTo("AssetId must not be empty."); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task TypeRule_GivenNestedTypeWithoutZodSchema_EmitsWholeObjectRule(CancellationToken cancellationToken) + { + // Arrange — AssetId carries the rule but no [ZodSchema]; it is reached through Order's property. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [NotEmpty(Code = "invalid_asset_id")] + public partial record struct AssetId : IScalarValueObject + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class Order + { + public AssetId Id { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodTypeTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodTypeTests.cs index 26e42b8..449308c 100644 --- a/src/tests/ZodSharp.UnitTests/Core/ZodTypeTests.cs +++ b/src/tests/ZodSharp.UnitTests/Core/ZodTypeTests.cs @@ -54,4 +54,24 @@ public async Task Parse_GivenInvalidData_ThrowsZodException() await Assert.That(exception).IsNotNull(); await Assert.That(exception.Errors).Count().IsEqualTo(1); } + + [Test] + public async Task ValidateAsync_GivenCancelledToken_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert + .That(async () => await Z.String().Min(3).ValidateAsync("value", cts.Token)) + .ThrowsExactly(); + } + + [Test] + public async Task ValidateAsync_GivenValidValue_ReturnsValidationResult() + { + var result = await Z.String().Min(3).ValidateAsync("value", CancellationToken.None); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("value"); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/CustomRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/CustomRuleTests.cs new file mode 100644 index 0000000..efe69ca --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/CustomRuleTests.cs @@ -0,0 +1,73 @@ +namespace ZodSharp.Rules; + +/// +/// Example custom rule used by the custom-rule tests and the documentation. Rejects strings that +/// contain whitespace. +/// +public readonly record struct NoWhitespaceRule(string? Message = null) : Core.IValidationRule +{ + /// + public bool IsValid(in string value) + { + if (value is null) + return false; + + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + return false; + } + + return true; + } + + /// + public string GetErrorMessage(in string value) => Message ?? $"Whitespace is not allowed in '{value}'."; +} + +public class CustomRuleTests +{ + [Test] + [Arguments("John", true)] + [Arguments("John Doe", false)] + [Arguments("John\tDoe", false)] + public async Task Rule_GivenCustomRule_ReturnsExpectedResult(string value, bool expected) + { + // Arrange + var schema = Z.String().Rule(new NoWhitespaceRule()); + + // Act + var result = schema.Validate(value); + + // Assert + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + public async Task AddRule_GivenCustomRule_ProducesRuleErrorMessage() + { + // Arrange + var schema = Z.String().AddRule(new NoWhitespaceRule("Spaces are not allowed.")); + + // Act + var result = schema.Validate("John Doe"); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(result.Errors[0].Message).IsEqualTo("Spaces are not allowed."); + await Assert.That(result.Errors[0].Path).IsEmpty(); + } + + [Test] + public async Task AddRule_GivenNull_ThrowsArgumentNullException() + { + // Arrange + var schema = Z.String(); + + // Act / Assert + var exception = Assert.Throws(() => schema.AddRule(null!)); + + await Assert.That(exception).IsNotNull(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs index c52fde8..292fefd 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs @@ -50,4 +50,42 @@ public async Task ArrayElementValidation_GivenEmptyArrayWithoutMin_ReturnsSucces await Assert.That(result.IsSuccess).IsTrue(); } + + [Test] + public async Task ArrayMin_GivenTooFewItems_ReturnsStructuredTooSmallIssue() + { + var result = Z.Array(Z.Number()).Min(2).Validate([1.0]); + + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors[0]; + await Assert.That(error.Code).IsEqualTo("too_small"); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Inclusive).IsTrue(); + } + + [Test] + public async Task ArrayMax_GivenTooManyItems_ReturnsStructuredTooBigIssue() + { + var result = Z.Array(Z.Number()).Max(2).Validate([1.0, 2.0, 3.0]); + + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors[0]; + await Assert.That(error.Code).IsEqualTo("too_big"); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Maximum).IsEqualTo(2); + await Assert.That(error.Inclusive).IsTrue(); + } + + [Test] + public async Task ArrayLength_GivenWrongCount_ReturnsStructuredBounds() + { + var result = Z.Array(Z.Number()).Length(2).Validate([1.0]); + + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors[0]; + await Assert.That(error.Code).IsEqualTo("too_small"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Maximum).IsEqualTo(2); + } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs index 63c62fc..d9cae92 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs @@ -81,6 +81,30 @@ public async Task DiscriminatedUnion_GivenUnknownDiscriminator_ReturnsFailure() await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_discriminator"); } + [Test] + public async Task DiscriminatedUnion_GivenNonStringDiscriminator_RoutesToMatchingSchema() + { + var union = Z.DiscriminatedUnion("kind").Option("1", new ObjectPassThroughSchema()).Build(); + + var result = union.Validate(new { kind = 1 }); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task DiscriminatedUnion_GivenRepeatedValidation_KeepsRoutingToMatchingSchema() + { + // Exercises the compiled discriminator accessor cache on repeat calls. + var union = CreateUnion(); + UnionUser user = new("user", "John"); + + var first = union.Validate(user); + var second = union.Validate(user); + + await Assert.That(first.IsSuccess).IsTrue(); + await Assert.That(second.IsSuccess).IsTrue(); + } + static ZodDiscriminatedUnion CreateUnion() { ObjectPassThroughSchema userSchema = new(); diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs index 83338ad..eba7cb1 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs @@ -49,7 +49,7 @@ public async Task NumberValidate_GivenNaN_ReturnsFailure() [Test] [Arguments(10.0, true)] [Arguments(0.1, true)] - [Arguments(0.0, true)] + [Arguments(0.0, false)] [Arguments(-1.0, false)] public async Task NumberPositive_GivenValue_ReturnsExpectedResult(double value, bool expected) { @@ -61,7 +61,7 @@ public async Task NumberPositive_GivenValue_ReturnsExpectedResult(double value, [Test] [Arguments(-5.0, true)] [Arguments(-0.1, true)] - [Arguments(0.0, true)] + [Arguments(0.0, false)] [Arguments(5.0, false)] public async Task NumberNegative_GivenValue_ReturnsExpectedResult(double value, bool expected) { @@ -70,6 +70,28 @@ public async Task NumberNegative_GivenValue_ReturnsExpectedResult(double value, await Assert.That(result.IsSuccess).IsEqualTo(expected); } + [Test] + [Arguments(0.0, true)] + [Arguments(5.0, true)] + [Arguments(-0.1, false)] + public async Task NumberNonNegative_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().NonNegative().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(0.0, true)] + [Arguments(-5.0, true)] + [Arguments(0.1, false)] + public async Task NumberNonPositive_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().NonPositive().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + [Test] [Arguments(30.0, true)] [Arguments(25.0, false)] @@ -81,6 +103,17 @@ public async Task NumberMultipleOf_GivenValue_ReturnsExpectedResult(double value await Assert.That(result.IsSuccess).IsEqualTo(expected); } + [Test] + [Arguments(0.3, true)] + [Arguments(0.3000000001, false)] + [Arguments(-0.3, true)] + public async Task NumberMultipleOf_GivenFractionalDivisor_HandlesFloatingPointRounding(double value, bool expected) + { + var result = Z.Number().MultipleOf(0.1).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + [Test] public async Task NumberMultipleOf_GivenZeroDivisor_ThrowsArgumentException() { @@ -89,6 +122,16 @@ public async Task NumberMultipleOf_GivenZeroDivisor_ThrowsArgumentException() await Assert.That(exception).IsNotNull(); } + [Test] + [Arguments(double.PositiveInfinity, false)] + [Arguments(double.NaN, false)] + public async Task NumberMultipleOf_GivenNonFiniteValue_ReturnsFailure(double value, bool expected) + { + var result = Z.Number().MultipleOf(10).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + [Test] [Arguments(42.0, true)] [Arguments(-1000.0, true)] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs new file mode 100644 index 0000000..3e4548d --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs @@ -0,0 +1,88 @@ +namespace ZodSharp.Schemas; + +public class ZodStringSpanTests +{ + [Test] + public async Task IsValidSpan_GivenValidValue_ReturnsTrueWithoutErrors() + { + var schema = Z.String().Min(3).Max(50).Email(); + + var isValid = schema.IsValidSpan("user@example.com".AsSpan(), out var errors); + + await Assert.That(isValid).IsTrue(); + await Assert.That(errors.Length).IsEqualTo(0); + } + + [Test] + public async Task IsValidSpan_GivenTooShortValue_ReturnsFalseWithErrors() + { + var schema = Z.String().Min(3); + + var isValid = schema.IsValidSpan("AB".AsSpan(), out var errors); + + await Assert.That(isValid).IsFalse(); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("validation_failed"); + } + + [Test] + public async Task ValidateSpan_GivenEmptySpan_AppliesRules() + { + // Regression: an empty span previously bypassed the rules and always succeeded. + var schema = Z.String().Min(3); + + var result = schema.ValidateSpan(ReadOnlySpan.Empty); + + await Assert.That(result.IsSuccess).IsFalse(); + } + + [Test] + public async Task ValidateSpan_GivenValidValue_ReturnsMaterialisedValue() + { + var schema = Z.String().Min(3).Max(50); + + var result = schema.ValidateSpan("John".AsSpan()); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("John"); + } + + [Test] + public async Task ValidateSpan_GivenRuleWithoutSpanContract_FallsBackToRules() + { + // NoWhitespaceRule only implements IValidationRule, so the span path falls back. + var schema = Z.String(); + schema.Rule(new Rules.NoWhitespaceRule()); + + var valid = schema.IsValidSpan("John".AsSpan(), out var validErrors); + var invalid = schema.IsValidSpan("John Doe".AsSpan(), out var invalidErrors); + + await Assert.That(valid).IsTrue(); + await Assert.That(validErrors.Length).IsEqualTo(0); + await Assert.That(invalid).IsFalse(); + await Assert.That(invalidErrors).HasSingleItem(); + } + + [Test] + public async Task ValidateSpan_GivenTransformSchema_AppliesTransform() + { + var schema = Z.String().Trim().ToUpper(); + + var result = schema.ValidateSpan(" hi ".AsSpan()); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("HI"); + } + + [Test] + public async Task IsValidSpan_GivenUuidRule_ValidatesWithoutString() + { + var schema = Z.String().UUID(); + + var valid = schema.IsValidSpan("550e8400-e29b-41d4-a716-446655440000".AsSpan(), out _); + var invalid = schema.IsValidSpan("not-a-uuid".AsSpan(), out _); + + await Assert.That(valid).IsTrue(); + await Assert.That(invalid).IsFalse(); + } +}