From ed74f57fd005ab343e44f5a9edc8e4d03452c722 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 22:46:32 +0100 Subject: [PATCH 01/26] feat: zodrules now supports non-generic+open generic on one attribute --- docs/wiki/Custom-Rules.md | 93 ++- docs/wiki/Source-Generator-Diagnostics.md | 4 + package.json | 2 +- .../AnalyzerReleases.Unshipped.md | 8 +- .../Helpers/CustomRuleResolver.cs | 361 ++++++++++- .../Helpers/DiagnosticLibrary.cs | 58 ++ .../Models/RuleAttributeGenerationModel.cs | 5 + src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 1 + .../ZodSchemaGenerator.RuleAttributes.cs | 302 ++++++++- src/src/ZodSharp/Core/IZodRule.cs | 8 + src/src/ZodSharp/Core/IZodRuleAttribute.cs | 40 ++ src/src/ZodSharp/Core/ZodRuleAttribute.cs | 27 +- src/src/ZodSharp/Sdk/README.md | 52 ++ .../AnalyzerReleaseTrackingTests.cs | 13 +- .../ZodSchemaAnalyzerTests.CustomRules.cs | 226 +++++++ .../ZodSchemaGeneratorTests.GenericRules.cs | 174 ++++++ ...aGeneratorTests.RuleAttributeGeneration.cs | 587 ++++++++++++++++++ 17 files changed, 1922 insertions(+), 39 deletions(-) create mode 100644 src/src/ZodSharp/Core/IZodRuleAttribute.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 2a6acc8..01fde2c 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -157,6 +157,28 @@ public readonly record struct NotEmptyRule(string? Code = null, string? Messa } ``` +### Declaring the identity contract + +A hand-authored attribute supplies those named arguments by exposing `Code` / `Origin` properties. Implement `ZodSharp.Core.IZodRuleAttribute` to make that contract explicit — the interface requires both members, so the compiler guarantees the properties the generator reads are present, and any member added to the interface is treated as identity rather than as an unconsumed argument: + +```csharp +public sealed class NoWhitespaceAttribute : ValidationAttribute, IZodRuleAttribute +{ + public string? Code { get; set; } + + public string? Origin { get; set; } +} +``` + +Attributes that do not implement the interface are still read through the documented `Code` / `Origin` names, so existing declarations keep working. + +Two warnings keep the identity honest rather than silently dropped: + +| Situation | Diagnostic | +|---|---| +| A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the generated validation supplies the value and never reads it back. | `ZODSGEN039` | +| An attribute supplies an argument the resolved rule never consumes: no matching constructor parameter, and not part of the error identity (`Code`/`Origin`/`IZodRuleAttribute` members) or the inherited `ErrorMessage`/`ErrorMessageResourceName`/`ErrorMessageResourceType` members. | `ZODSGEN040` | + ## Generic rules Map an **unbound generic** rule type and the generator closes it with the property type, so one rule serves every underlying primitive: @@ -175,6 +197,47 @@ public sealed class NotEmptyAttribute : ValidationAttribute - `[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. +### Rule families: one attribute for a primitive and a scalar value object + +A constraint can also be *self-referential* (`where TSelf : IScalarValueObject`), which a primitive can never satisfy — `string` does not implement `IScalarValueObject`. Declare both halves of the rule side by side and the generator resolves the member that fits the annotated type: + +```csharp +namespace MyRules; + +// Member-level half: validates the underlying primitive. +public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule +{ + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; +} + +// Value-object half: validates the scalar as a unit. +public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; +} + +[ZodRule(typeof(NonWhiteSpaceStringRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute +{ + public string? Code { get; set; } + + public string? Message { get; set; } +} +``` + +- `[NonWhiteSpaceString]` on a `string`/`string?` member resolves to `NonWhiteSpaceStringRule` (the non-generic sibling). +- `[NonWhiteSpaceString]` on a scalar type resolves to `NonWhiteSpaceStringRule`. +- Resolution is symmetric: mapping the attribute to the *non-generic* rule still resolves the generic member for a scalar target. +- When no member of the family can validate the target type, `ZODSGEN030` is reported and nothing is emitted. + ## 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: @@ -216,7 +279,31 @@ 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`). +- The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). +- An **arity-1 generic rule** can be marked as well: the generated attribute maps to the open generic (`[ZodRule(typeof(NonWhiteSpaceStringRule<>))]`), which is the form that serves both a primitive member and a scalar value object. Rules with two or more type parameters are rejected (`ZODSGEN032`). +- When both halves of a [rule family](#rule-families-one-attribute-for-a-primitive-and-a-scalar-value-object) are marked, only the generic half emits the attribute; the two mappings would otherwise claim the same name. +- If a hand-authored type already declares the derived name, the generated attribute is suppressed and reported as `ZODSGEN037`. The hand-authored declaration's own `[ZodRule]` mapping then governs every usage of that attribute name, so confirm it still matches what the call sites expect. +- `[ZodRule(AllowMultiple = true)]` emits `AttributeUsage(..., AllowMultiple = true)`, so the attribute may be applied to a member more than once. Each application becomes its own rule, configured from that application's arguments and evaluated in source order: + +```csharp +[ZodRule(AllowMultiple = true)] +public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule +{ + public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; + + public string GetErrorMessage(in int value) => Message ?? "Not a multiple."; +} + +[ZodSchema] +public partial class Sample +{ + [MultipleOf(Factor = 3)] + [MultipleOf(Factor = 5)] + public int Value { get; set; } // must be a multiple of both 3 and 5 +} +``` + +- A **hand-authored** attribute whose name encodes a rule name (`XAttribute` → `XRule`) must map to a rule that addresses every rule declared under that name. A mapping that declares only a non-generic rule while an arity-1 generic sibling exists (or that declares an unrelated rule) is reported as `ZODSGEN038`, because some usages of the attribute would resolve to no rule. An attribute name that does not encode a declared rule family is left alone, so free-form names remain valid. > [!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(...))]`. @@ -371,6 +458,10 @@ Because the rule is closed with `TSelf` (`NotEmptyRule`), it *sees the | 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. | +| ZODSGEN037 | Warning | A rule marked `[ZodRule]` derives an attribute name that is already declared by hand, so the generated attribute is suppressed and the hand-authored declaration's own `[ZodRule]` mapping governs every usage. | +| ZODSGEN038 | Warning | A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), leaving some usages of the attribute unresolved. | +| ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter without implementing `IZodRule`, so the value never reaches the reported error identity. | +| ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index 0cbbac4..0f1b4be 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -34,6 +34,10 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN034 | The `OnZodValidate` refinement hook is implemented on a type that is not `partial` (or whose containing types are not all `partial`), so the generated declaration cannot be emitted | | ZODSGEN035 | The `OnZodValidate` refinement hook is not declared as `partial void OnZodValidate(RefineCtx context)` (wrong modifiers, return type, or parameters) | | ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable Validate()`); implement `OnZodValidate` instead | +| ZODSGEN037 | (warning) A rule marked `[ZodRule]` derives an attribute name (`XRule` → `XAttribute`) that a hand-authored type already declares, so no attribute is generated and that declaration's own `[ZodRule]` mapping governs every usage | +| ZODSGEN038 | (warning) A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), so some usages of the attribute resolve to no rule | +| ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity | +| ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. diff --git a/package.json b/package.json index 3f9b711..e6ca1dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0", + "version": "2.0.1", "private": true, "license": "MIT", "author": { diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 7b845f4..b3dbef1 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -3,5 +3,9 @@ ### New Rules -Rule ID | Category | Severity | Notes ---------|----------|----------|------- +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSGEN037 | ZodSharp.SourceGenerator | Warning | A rule's derived validation attribute name is already declared by a hand-authored type, so the generated attribute is suppressed in favour of that declaration's own [ZodRule] mapping | +| ZODSGEN038 | ZodSharp.SourceGenerator | Warning | A hand-authored rule attribute's [ZodRule] mapping does not address every rule declared under the name the attribute derives from | +| ZODSGEN039 | ZodSharp.SourceGenerator | Warning | A rule accepts a code/origin constructor parameter without implementing IZodRule, so the value never reaches the reported error identity | +| ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index aff2f78..f53e56c 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -100,6 +100,20 @@ ImmutableArray.Builder diagnostics IsZodRule(ruleType) ) ); + + foreach (var unusedArgument in GetUnusedNamedArguments(attribute, attributeClass, ruleType)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnusedRuleAttributeArgument, + false, + GetAttributeLocation(attribute), + unusedArgument, + attributeClass.Name, + ruleType.Name + ) + ); + } } return builder is null ? new(ImmutableArray.Empty) : new(builder.ToImmutable()); @@ -116,9 +130,40 @@ static bool IsBuiltInDataAnnotation(INamedTypeSymbol attributeClass) => /// when the attribute is mapped to a rule. internal static bool IsRuleMapped(INamedTypeSymbol attributeClass) => TryGetRuleMapping(attributeClass, out _); + /// + /// Determines whether carries the parameterless [ZodRule] marker that + /// asks the generator to emit a matching validation attribute. + /// + /// The rule type to test. + /// when the rule is marked for validation-attribute generation. + internal static bool IsRuleMarker(INamedTypeSymbol ruleType) + { + foreach (var attribute in ruleType.GetAttributes()) + { + if (IsRuleMarkerAttribute(attribute)) + return true; + } + + return false; + } + + /// + /// Determines whether is the parameterless [ZodRule] marker (the form + /// that marks a rule) rather than the mapping form that carries a rule type. + /// + /// The attribute to test. + /// when the attribute marks a rule for attribute generation. + internal static bool IsRuleMarkerAttribute(AttributeData attribute) => + attribute.AttributeClass is not null + && attribute.AttributeClass.ToDisplayString() + == $"{TypeLibraryGenerator.ZodSharpCoreNamespace}.ZodRuleAttribute" + && attribute.ConstructorArguments.Length == 0; + /// /// 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. + /// ([ZodRule(typeof(NotEmptyRule<>))]) is closed with the property type, and a rule that cannot + /// validate the property type falls back to another member of its family (the same base name declared + /// alongside it) so a single attribute can serve both primitive members and scalar value objects. /// /// The rule type declared by the mapping. /// The property type used to close an unbound generic rule. @@ -128,18 +173,70 @@ static bool TryResolveRuleType(INamedTypeSymbol ruleType, ITypeSymbol propertyTy { resolved = ruleType; - if (!IsOpenGeneric(ruleType)) + if (IsOpenGeneric(ruleType)) + { + var definition = ruleType.OriginalDefinition; + if (definition is null) + return false; + + if (definition.Arity == 1 && SatisfiesConstraints(definition, propertyType)) + { + resolved = definition.Construct(propertyType); + return true; + } + } + else if (ImplementsRuleFor(ruleType, propertyType)) + { return true; + } - var definition = ruleType.OriginalDefinition; - if (definition is null || definition.Arity != 1) - return false; + // The declared rule cannot validate the property type. Prefer another member of the rule family under + // the same base name: NonWhiteSpaceStringRule applied to a string member resolves to + // NonWhiteSpaceStringRule, and NonWhiteSpaceStringRule applied to a scalar value object resolves to + // NonWhiteSpaceStringRule. The caller still verifies the resolved rule implements + // IValidationRule. + return TryResolveFamilyMember(ruleType.OriginalDefinition ?? ruleType, propertyType, out resolved); + } - if (!SatisfiesConstraints(definition, propertyType)) - return false; + /// + /// Finds the rule family member that can validate : the non-generic rule + /// (for example NotEmptyRule) or an arity-1 generic rule closed with the property type (for example + /// NotEmptyRule<TScalar>). + /// + /// The rule declared by the mapping. + /// The property type the rule must validate. + /// The family member that validates the property type, when one exists. + /// when a usable family member was found. + static bool TryResolveFamilyMember( + INamedTypeSymbol definition, + ITypeSymbol propertyType, + out INamedTypeSymbol resolved + ) + { + foreach (var candidate in definition.ContainingNamespace.GetTypeMembers(definition.Name)) + { + if (candidate.IsGenericType) + { + if (candidate.Arity != 1 || !SatisfiesConstraints(candidate, propertyType)) + continue; - resolved = definition.Construct(propertyType); - return true; + var closed = candidate.Construct(propertyType); + if (!ImplementsRuleFor(closed, propertyType)) + continue; + + resolved = closed; + return true; + } + + if (!ImplementsRuleFor(candidate, propertyType)) + continue; + + resolved = candidate; + return true; + } + + resolved = definition; + return false; } static bool IsOpenGeneric(INamedTypeSymbol type) => @@ -164,12 +261,18 @@ static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) 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)) + // Substitute the candidate type into the constraint so self-referential constraints (for + // example where TSelf : IScalarValueObject) are checked rather than assumed. + // Assuming them lets an unsatisfiable rule reach emission, and the consumer then fails to + // compile the generated validator (CS0311) instead of receiving a diagnostic. + var substituted = SubstituteConstraint(constraint, parameter, type); + + // A constraint that references a type parameter which cannot be replaced from the definition + // alone is treated as satisfied rather than rejecting a usable rule. + if (substituted is null) continue; - if (!TypeHelpers.IsOrImplements(type, new TypeIdentity(constraint))) + if (!IsOrImplements(type, substituted)) return false; } } @@ -177,13 +280,243 @@ static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) return true; } + /// + /// Replaces every occurrence of inside with + /// . + /// + /// The declared constraint type. + /// The rule type parameter being closed. + /// The candidate type used to close the rule. + /// + /// The substituted constraint, or when it references a type parameter that is not + /// and therefore cannot be evaluated from the definition alone. + /// + static ITypeSymbol? SubstituteConstraint(ITypeSymbol constraint, ITypeParameterSymbol parameter, ITypeSymbol type) + { + if (constraint.TypeKind == TypeKind.TypeParameter) + return SymbolEqualityComparer.Default.Equals(constraint, parameter) ? type : null; + + if (constraint is not INamedTypeSymbol named) + return constraint; + + var arguments = named.TypeArguments; + if (arguments.Length == 0) + return constraint; + + // An unbound generic constraint (for example IEquatable<>) carries no argument to substitute. + if (named.IsUnboundGenericType) + return null; + + var substituted = new ITypeSymbol[arguments.Length]; + var changed = false; + + for (var index = 0; index < arguments.Length; index++) + { + var argument = arguments[index]; + if (!ContainsTypeParameter(argument)) + { + substituted[index] = argument; + continue; + } + + var replacement = SubstituteConstraint(argument, parameter, type); + if (replacement is null) + return null; + + substituted[index] = replacement; + changed = true; + } + + // A constructed constraint must be re-created from its definition; constructing from an already + // constructed type (for example IScalarValueObject) is rejected by Roslyn. + return changed ? named.OriginalDefinition.Construct(substituted) : named; + } + + /// + /// Determines whether is, implements or inherits , + /// including the contract's generic arguments. + /// + /// + /// Unlike TypeHelpers.IsOrImplements, which matches on the metadata name alone, this compares the + /// constructed contract so + /// IScalarValueObject<T, string> is not satisfied by a different construction. + /// + static bool IsOrImplements(ITypeSymbol type, ITypeSymbol contract) + { + if (contract.TypeKind == TypeKind.TypeParameter) + return true; + + if (SymbolEqualityComparer.Default.Equals(type, contract)) + return true; + + for (var baseType = (type as INamedTypeSymbol)?.BaseType; baseType is not null; baseType = baseType.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(baseType, contract)) + return true; + } + + foreach (var @interface in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(@interface, contract)) + return true; + } + + return false; + } + static bool ContainsTypeParameter(ITypeSymbol type) => type.TypeKind == TypeKind.TypeParameter || (type is INamedTypeSymbol named && named.TypeArguments.Any(ContainsTypeParameter)); - static bool IsZodRule(INamedTypeSymbol ruleType) => + /// + /// Determines whether owns its error identity by implementing + /// ZodSharp.Core.IZodRule. + /// + /// The resolved rule type. + /// when the rule supplies Code/Origin itself. + internal static bool IsZodRule(INamedTypeSymbol ruleType) => TypeHelpers.IsOrImplements(ruleType, TypeLibrary.ZodSharp.Core.IZodRule); + /// + /// Finds an identity constructor parameter (code or origin) declared by + /// . + /// + /// The rule type to inspect. + /// The first identity parameter found. + /// when the rule declares an identity constructor parameter. + /// + /// Such a parameter is only surfaced when the rule implements IZodRule; otherwise the generated + /// validation supplies the value and never reads it back, which the generator reports as ZODSGEN039. + /// + internal static bool TryGetIdentityParameter(INamedTypeSymbol ruleType, out string parameterName) + { + foreach (var parameter in MappedParameters(ruleType)) + { + if ( + string.Equals(parameter.Name, "code", StringComparison.OrdinalIgnoreCase) + || string.Equals(parameter.Name, "origin", StringComparison.OrdinalIgnoreCase) + ) + { + parameterName = parameter.Name; + return true; + } + } + + parameterName = string.Empty; + return false; + } + + /// + /// Gets the named arguments an applied attribute supplies that the resolved rule never consumes: the + /// argument matches no public constructor parameter and is not part of the reported error identity, so the + /// value has no effect on validation. + /// + /// The applied attribute. + /// The attribute type. + /// The rule the attribute resolves to. + /// The names of the arguments that have no effect, in declaration order. + static ImmutableArray GetUnusedNamedArguments( + AttributeData attribute, + INamedTypeSymbol attributeClass, + INamedTypeSymbol ruleType + ) + { + if (attribute.NamedArguments.IsDefaultOrEmpty) + return []; + + HashSet consumed = new(StringComparer.OrdinalIgnoreCase); + + foreach (var parameter in MappedParameters(ruleType)) + consumed.Add(parameter.Name); + + // The inherited ValidationAttribute error-message members feed the emitted message directly. + consumed.Add("ErrorMessage"); + consumed.Add("ErrorMessageResourceName"); + consumed.Add("ErrorMessageResourceType"); + + // Identity properties are read from the applied attribute rather than passed to the rule. + foreach (var identityProperty in GetIdentityPropertyNames(attributeClass)) + consumed.Add(identityProperty); + + ImmutableArray.Builder? unused = null; + foreach (var pair in attribute.NamedArguments) + { + if (consumed.Contains(pair.Key)) + continue; + + unused ??= ImmutableArray.CreateBuilder(); + unused.Add(pair.Key); + } + + return unused is null ? [] : unused.ToImmutable(); + } + + /// + /// Gets the error-identity property names the generator reads from an applied attribute. + /// + /// The attribute type, when known. + /// The identity property names. + /// + /// The contract is declared by ZodSharp.Core.IZodRuleAttribute: an attribute that implements it + /// contributes its members, so a member added to the interface is treated as identity rather than as an + /// unconsumed argument. Attributes that do not implement the interface are still read through the + /// documented Code/Origin names so existing declarations keep working. + /// + static ImmutableArray GetIdentityPropertyNames(INamedTypeSymbol? attributeClass) + { + if (attributeClass is not null) + { + foreach (var @interface in attributeClass.AllInterfaces) + { + var definition = @interface.OriginalDefinition; + if ( + definition.Name != IdentityAttributeInterfaceName + || definition.Arity != 0 + || definition.ContainingNamespace.ToDisplayString() != TypeLibraryGenerator.ZodSharpCoreNamespace + ) + { + continue; + } + + var names = @interface + .GetMembers() + .OfType() + .Select(static property => property.Name) + .ToImmutableArray(); + if (names.Length > 0) + return names; + } + } + + return DocumentedIdentityPropertyNames; + } + + /// + /// Gets the public constructor parameters used to build the rule's arguments: the constructor with the most + /// parameters, matching the rule instantiation contract. + /// + /// The resolved rule type. + /// The mapped constructor parameters. + static ImmutableArray MappedParameters(INamedTypeSymbol ruleType) => + ruleType + .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(static c => c.Parameters.Length) + .FirstOrDefault() + ?.Parameters + ?? []; + + /// + /// The attribute contract that declares the error-identity properties, matched by name so the generator + /// does not take a package reference on the public API. + /// + const string IdentityAttributeInterfaceName = "IZodRuleAttribute"; + + /// + /// The identity property names read from attributes that do not implement + /// ZodSharp.Core.IZodRuleAttribute: the members that interface declares. + /// + static readonly ImmutableArray DocumentedIdentityPropertyNames = ["Code", "Origin"]; + static string? GetAttributeString(AttributeData attribute, string name) { foreach (var pair in attribute.NamedArguments) diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 98ca951..9c54d8f 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -288,6 +288,64 @@ static class DiagnosticLibrary isEnabledByDefault: true ); + /// + /// ZODSGEN037: a rule marked with [ZodRule] derives the validation attribute name from its own + /// name (XRule → XAttribute), which is the same name for both halves of a generic / + /// non-generic rule pair. When the compilation already declares a type with that name the generated + /// attribute is suppressed, so the hand-authored declaration's own mapping governs every usage. + /// + public static readonly DiagnosticDescriptor RuleAttributeNameAlreadyDeclared = new( + id: "ZODSGEN037", + title: "Rule attribute name is already declared", + messageFormat: "The rule '{0}' maps to validation attribute '{1}', but '{2}' already declares that name, so no attribute is generated and the existing declaration's own [ZodRule] mapping is used instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN038: a hand-authored rule attribute whose name encodes a rule name (XAttribute → + /// XRule) must map to a rule that addresses every rule declared under that name. A mapping to a + /// single non-generic rule while an arity-1 generic sibling also exists (or to a rule outside the family) + /// leaves some usages of the attribute unresolved. + /// + public static readonly DiagnosticDescriptor RuleAttributeMappingIncomplete = new( + id: "ZODSGEN038", + title: "Rule attribute mapping does not cover the whole rule family", + messageFormat: "The attribute '{0}' maps to rule '{1}', which does not cover every rule declared as '{2}'; map the attribute to '{3}' so that every usage of '{0}' resolves", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN039: a rule accepts an identity constructor parameter (code/origin) but does not + /// implement IZodRule. The value is passed by the generated validation but never surfaced, so the + /// parameter is inert: implement IZodRule to report it, or remove the parameter. + /// + public static readonly DiagnosticDescriptor RuleIdentityParameterNotImplemented = new( + id: "ZODSGEN039", + title: "Rule identity parameter is never surfaced", + messageFormat: "The rule '{0}' accepts a '{1}' constructor parameter but does not implement 'ZodSharp.Core.IZodRule', so the value never reaches the reported error identity; implement 'IZodRule' or remove the parameter", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN040: an attribute supplies an argument that the resolved rule never consumes — it is neither a + /// constructor parameter nor part of the error identity declared by IZodRuleAttribute, so the value + /// has no effect on validation. + /// + public static readonly DiagnosticDescriptor UnusedRuleAttributeArgument = new( + id: "ZODSGEN040", + title: "Rule attribute argument has no effect", + messageFormat: "'{0}' on attribute '{1}' has no effect: rule '{2}' has no matching constructor parameter and the value is not part of the reported error identity", + 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/Models/RuleAttributeGenerationModel.cs b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs index 3325a1e..a3d5de8 100644 --- a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs +++ b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs @@ -9,6 +9,10 @@ namespace ZodSharp.SourceGenerators.Models; /// The accessibility of the generated attribute. /// The error code the mapping reports, when explicitly configured. /// The structured origin the mapping reports, when explicitly configured. +/// +/// Whether the generated attribute may be applied to a member more than once, taken from the rule marker's +/// [ZodRule(AllowMultiple = ...)]. +/// /// The generated attribute properties, mirroring the rule constructor parameters. readonly record struct RuleAttributeGenerationModel( TypeIdentity RuleType, @@ -16,6 +20,7 @@ readonly record struct RuleAttributeGenerationModel( TypeDeclarationAccessibility Accessibility, string? Code, string? Origin, + bool AllowMultiple, EquatableArray Properties ); diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 7edde34..6906e98 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -38,6 +38,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.AmbiguousValidationMethods, DiagnosticLibrary.UnsupportedCustomRuleTarget, DiagnosticLibrary.UnmappableCustomRuleArgument, + DiagnosticLibrary.UnusedRuleAttributeArgument, DiagnosticLibrary.RuleAttributeWithoutSchema, ]; diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index bd9ec96..c71dfca 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -50,19 +50,25 @@ 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)) + if (context.TargetSymbol is not INamedTypeSymbol target) return default; var attribute = context.Attributes[0]; + + // The mapping form ([ZodRule(typeof(...))]) declares which rule an attribute addresses rather than + // marking a rule; CustomRuleResolver resolves it per property. A hand-authored attribute must still + // address every rule declared under the name it derives from, otherwise some usages of that attribute + // resolve to no rule at all. if (attribute.ConstructorArguments.Length > 0) + return GetMappingFormResult(target, attribute); + + var ruleType = target; + + // Only the parameterless form (marking a rule) is handled here. + if (!IsValidationRule(ruleType)) return default; - if (ruleType.IsGenericType || ruleType.ContainingType is not null || ruleType.IsAbstract) + if (ruleType.ContainingType is not null || ruleType.IsAbstract) { return GeneratorResult.Create( default(RuleAttributeGenerationModel), @@ -71,15 +77,74 @@ CancellationToken cancellationToken true, ruleType, ruleType.Name, - "only non-generic, non-nested, non-abstract rules are supported" + "only non-nested, non-abstract rules are supported" + ) + ); + } + + // A generic rule is supported when it has a single type parameter: the generated attribute maps to the + // open generic, so the same attribute serves a primitive member (resolved to the non-generic family + // member) and a scalar value object (closed with that type). + if (ruleType.IsGenericType && ruleType.Arity != 1) + { + return GeneratorResult.Create( + default(RuleAttributeGenerationModel), + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedRuleAttributeGeneration, + true, + ruleType, + ruleType.Name, + "only non-generic rules and generic rules with a single type parameter are supported" ) ); } var attributeName = ResolveAttributeName(ruleType, attribute); - if (attributeName is null) + + // A hand-authored declaration of the same name wins: the generator cannot emit a duplicate type and the + // hand-authored declaration's own [ZodRule] mapping governs every usage. Report it so that re-mapping + // is never silent. + if (ruleType.ContainingNamespace.GetTypeMembers(attributeName) is { Length: > 0 } claimed) + { + return GeneratorResult.Create( + default(RuleAttributeGenerationModel), + ReportableDiagnostic.Create( + DiagnosticLibrary.RuleAttributeNameAlreadyDeclared, + false, + ruleType, + ruleType.Name, + attributeName, + claimed[0].ToDisplayString() + ) + ); + } + + // Both halves of a rule pair derive the same attribute name. The open generic mapping subsumes the + // non-generic one, so only the generic rule emits the attribute. + if (!ruleType.IsGenericType && HasMarkedGenericSibling(ruleType, attributeName)) return default; + // A code/origin constructor parameter is only surfaced through IZodRule: the generated validation + // supplies the value as a constructor argument and reads it back through the interface, so without the + // interface the parameter is inert. Report it and keep generating, so the attribute still exists. + ImmutableArray identityWarnings = []; + if ( + !CustomRuleResolver.IsZodRule(ruleType) + && CustomRuleResolver.TryGetIdentityParameter(ruleType, out var identityParameter) + ) + { + identityWarnings = + [ + ReportableDiagnostic.Create( + DiagnosticLibrary.RuleIdentityParameterNotImplemented, + false, + ruleType, + ruleType.Name, + identityParameter + ), + ]; + } + var properties = ImmutableArray.CreateBuilder(); var constructor = ruleType .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) @@ -95,15 +160,21 @@ CancellationToken cancellationToken if (!IsSupportedAttributePropertyType(parameter.Type)) { - return GeneratorResult.Create( - default(RuleAttributeGenerationModel), + ImmutableArray propertyDiagnostics = + [ 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" - ) + ), + .. identityWarnings, + ]; + + return GeneratorResult.Create( + default(RuleAttributeGenerationModel), + propertyDiagnostics ); } @@ -127,8 +198,10 @@ CancellationToken cancellationToken : TypeDeclarationAccessibility.Internal, GetNamedString(attribute, "Code"), GetNamedString(attribute, "Origin"), + GetNamedBool(attribute, "AllowMultiple"), new(properties.ToImmutable()) - ) + ), + identityWarnings ); } @@ -171,7 +244,167 @@ static string ToPascalCase(string name) => return null; } - static string? ResolveAttributeName(INamedTypeSymbol ruleType, AttributeData attribute) + static bool GetNamedBool(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name && pair.Value.Value is bool value) + return value; + } + + return false; + } + + /// + /// Validates the [ZodRule(typeof(...))] mapping form: an attribute whose name encodes a rule name + /// (XAttribute → XRule) must address every rule declared under that name. + /// + /// The type carrying the mapping. + /// The [ZodRule(typeof(...))] mapping. + /// + /// A result carrying ZODSGEN038 when the mapping leaves family members unaddressed, and ZODSGEN039 when the + /// mapped rule declares an identity parameter it cannot surface. + /// + static GeneratorResult GetMappingFormResult( + INamedTypeSymbol attributeClass, + AttributeData attribute + ) + { + if ( + !TypeHelpers.InheritsFrom( + attributeClass, + TypeLibrary.System.ComponentModel.DataAnnotations.ValidationAttribute + ) || attribute.ConstructorArguments[0].Value is not INamedTypeSymbol mapped + ) + { + return default; + } + + ImmutableArray.Builder? diagnostics = null; + + if (TryGetIncompleteFamilyCoverage(attributeClass, mapped, out var expectedMapping)) + { + diagnostics = ImmutableArray.CreateBuilder(); + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.RuleAttributeMappingIncomplete, + false, + attributeClass, + attributeClass.Name, + mapped.Name, + expectedMapping.Name, + expectedMapping.RenderFullName + ) + ); + } + + // The mapped rule is the one that must surface the identity. A rule marked with [ZodRule] is already + // checked at its own declaration, so only unmarked rules are reported here. + var ruleDefinition = mapped.OriginalDefinition ?? mapped; + if ( + !CustomRuleResolver.IsRuleMarker(ruleDefinition) + && !CustomRuleResolver.IsZodRule(ruleDefinition) + && CustomRuleResolver.TryGetIdentityParameter(ruleDefinition, out var identityParameter) + ) + { + diagnostics ??= ImmutableArray.CreateBuilder(); + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.RuleIdentityParameterNotImplemented, + false, + ruleDefinition, + ruleDefinition.Name, + identityParameter + ) + ); + } + + return diagnostics is null + ? default + : GeneratorResult.Create( + default(RuleAttributeGenerationModel), + diagnostics.ToImmutable() + ); + } + + /// + /// Determines whether a rule attribute's mapping fails to address every rule declared as + /// {attribute-name-without-Attribute}Rule. + /// + /// The attribute carrying the mapping. + /// The rule type the mapping declares. + /// + /// The mapping that would address the whole family: the arity-1 open generic when the family declares one, + /// otherwise the non-generic rule. + /// + /// + /// when the mapping leaves a family member unaddressed; when + /// it covers the family or the attribute name does not encode a declared rule family. + /// + /// + /// The check is skipped when no rule is declared under the encoded name, so an attribute may be named + /// freely and mapped to any rule. When a family does exist, only the open generic reaches both halves of a + /// primitive / scalar value-object pair, and an arity of two or more can never be closed from an attribute. + /// + static bool TryGetIncompleteFamilyCoverage( + INamedTypeSymbol attributeClass, + INamedTypeSymbol mapped, + out TypeIdentity expectedMapping + ) + { + const string attributeSuffix = "Attribute"; + const string ruleSuffix = "Rule"; + + var attributeName = attributeClass.Name; + var baseName = + attributeName.EndsWith(attributeSuffix, StringComparison.Ordinal) + && attributeName.Length > attributeSuffix.Length + ? attributeName.Substring(0, attributeName.Length - attributeSuffix.Length) + : attributeName; + + expectedMapping = default; + + var definition = mapped.OriginalDefinition ?? mapped; + var namespaceName = definition.ContainingNamespace.ToDisplayString(); + var family = definition.ContainingNamespace.GetTypeMembers($"{baseName}{ruleSuffix}"); + + // The attribute name does not encode a rule family, so there is nothing for the mapping to cover. + if (family.Length == 0) + return false; + + var hasOpenGeneric = family.Any(static member => member.Arity == 1); + expectedMapping = new TypeIdentity($"{baseName}{ruleSuffix}", namespaceName, hasOpenGeneric ? 1 : 0); + + var isOpenGeneric = + mapped.IsUnboundGenericType + || ( + mapped.IsGenericType + && mapped.TypeArguments.Any(static argument => argument.TypeKind == TypeKind.TypeParameter) + ); + + // A differently named rule, or a family member whose arity can never be closed from an attribute, is + // not addressable through this attribute. + if ( + !string.Equals(definition.Name, expectedMapping.Name, StringComparison.Ordinal) + || family.Any(static member => member.Arity > 1) + ) + { + return true; + } + + // A single closed instantiation cannot stand in for the open generic the rest of the family needs. + return hasOpenGeneric && !isOpenGeneric; + } + + /// + /// Resolves the validation attribute name for a rule marked with [ZodRule]: the explicit + /// AttributeName when set, otherwise the rule name with a trailing Rule replaced by + /// Attribute. + /// + /// The marked rule. + /// The [ZodRule] marker. + /// The validation attribute name declared by the marker. + static string ResolveAttributeName(INamedTypeSymbol ruleType, AttributeData attribute) { const string ruleSuffix = "Rule"; @@ -181,10 +414,39 @@ static string ToPascalCase(string name) => : 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; + return $"{baseName}Attribute"; + } + + /// + /// Determines whether the non-generic rule is the primitive half of a rule pair whose arity-1 generic + /// sibling is marked with [ZodRule] for the same attribute name. The generic marker maps that + /// attribute to the open generic rule, which serves both forms, so the non-generic rule must not emit a + /// competing attribute (the two would share a hint name). + /// + /// The marked non-generic rule. + /// The attribute name the non-generic rule derives. + /// when a marked generic sibling emits the same attribute name. + static bool HasMarkedGenericSibling(INamedTypeSymbol ruleType, string attributeName) + { + foreach (var candidate in ruleType.ContainingNamespace.GetTypeMembers(ruleType.Name)) + { + if (candidate.Arity != 1 || !CustomRuleResolver.IsRuleMarker(candidate)) + continue; + + foreach (var attribute in candidate.GetAttributes()) + { + if (!CustomRuleResolver.IsRuleMarkerAttribute(attribute)) + continue; + + if (string.Equals(ResolveAttributeName(candidate, attribute), attributeName, StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; } static bool IsSupportedAttributePropertyType(ITypeSymbol type) @@ -242,7 +504,11 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m false ), new AttributeArgumentOptions("true", "Inherited", true), - new AttributeArgumentOptions("false", "AllowMultiple", true), + new AttributeArgumentOptions( + model.AllowMultiple ? "true" : "false", + "AllowMultiple", + true + ), ], }, new AttributeDeclarationOptions(TypeLibrary.ZodSharp.Core.ZodRuleAttribute) diff --git a/src/src/ZodSharp/Core/IZodRule.cs b/src/src/ZodSharp/Core/IZodRule.cs index a93778c..e5d38d1 100644 --- a/src/src/ZodSharp/Core/IZodRule.cs +++ b/src/src/ZodSharp/Core/IZodRule.cs @@ -7,9 +7,17 @@ namespace ZodSharp.Core; /// 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. +/// +/// +/// This interface is the only path for a constructor argument to reach the reported identity: a rule that +/// accepts a code or origin parameter without implementing it is reported as ZODSGEN039, +/// because the value would be supplied by the generated validation and then never read. See +/// for the attribute-side counterpart. +/// /// public interface IZodRule { diff --git a/src/src/ZodSharp/Core/IZodRuleAttribute.cs b/src/src/ZodSharp/Core/IZodRuleAttribute.cs new file mode 100644 index 0000000..51ed215 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodRuleAttribute.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.Core; + +/// +/// Declares that a DataAnnotations-style validation attribute participates in the rule error identity, so the +/// [ZodSchema] generator reads its and from the applied attribute. +/// +/// +/// +/// A rule attribute mapped with [ZodRule(typeof(...))] supplies the error identity through named +/// arguments: Code is the reported error code and Origin the structured origin. The generator +/// reads those arguments by name, so a hand-authored attribute that exposes them works without this interface +/// (see ZodSharp.Core.ZodRuleAttribute). +/// +/// +/// Implementing this interface makes the contract explicit and compiler-enforced: the members below must be +/// present, so the generated validation is guaranteed to find them, and it lets the generator treat any future +/// member added here as an identity property rather than an unconsumed attribute argument. +/// +/// +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "This interface is the contract that validation attributes implement, so naming it an attribute is intentional." +)] +public interface IZodRuleAttribute +{ + /// + /// Gets the error code reported when the rule fails, or to fall back to the code + /// declared by [ZodRule(Code = "...")] and then to "validation_failed". + /// + string? Code { get; } + + /// + /// Gets the structured ValidationError.Origin reported when the rule fails, or + /// to fall back to the origin declared by [ZodRule(Origin = "...")]. + /// + string? Origin { get; } +} diff --git a/src/src/ZodSharp/Core/ZodRuleAttribute.cs b/src/src/ZodSharp/Core/ZodRuleAttribute.cs index fb47275..a880890 100644 --- a/src/src/ZodSharp/Core/ZodRuleAttribute.cs +++ b/src/src/ZodSharp/Core/ZodRuleAttribute.cs @@ -22,10 +22,23 @@ namespace ZodSharp.Core; /// /// 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. +/// the rule's constructor parameters. An arity-1 generic rule may be marked as well: the generated +/// attribute maps to the open generic, which is how one attribute can serve both a primitive member and a +/// scalar value object. /// /// /// +/// +/// The derived attribute name is the same for both halves of a generic / non-generic rule pair, so if the +/// compilation already declares a type with that name the generated attribute is suppressed and the +/// generator reports ZODSGEN037; the hand-authored declaration's own mapping then governs every +/// usage of that name. A hand-authored attribute whose name encodes a rule name (XAttribute → +/// XRule) must map to a rule that addresses every rule declared under that name, otherwise the +/// generator reports ZODSGEN038. Implement on a hand-authored +/// attribute to declare its Code/Origin as the reported error identity; an argument an +/// attribute supplies that the resolved rule cannot consume is reported as ZODSGEN040 rather than +/// being dropped silently. +/// /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] public sealed class ZodRuleAttribute : Attribute @@ -65,4 +78,16 @@ public ZodRuleAttribute() { } /// derived from the rule name (a trailing Rule is replaced with Attribute). /// public string? AttributeName { get; init; } + + /// + /// Gets a value indicating whether the generated validation attribute may be applied to a member more than + /// once, so a single rule can be configured differently per application. + /// + /// + /// Only affects generated attributes; a hand-authored attribute declares its own + /// . Every application is emitted as its own + /// validation, evaluated in source order. Defaults to , which is the + /// default. + /// + public bool AllowMultiple { get; init; } } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index cd26825..033513d 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -137,6 +137,58 @@ public partial record struct AssetId `[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. +### One attribute for a primitive and a scalar value object + +A constraint can be *self-referential* (`where TSelf : IScalarValueObject`), which a primitive can never satisfy. Declare both halves of the rule side by side and the generator resolves the member that fits the annotated type — so a single attribute works on a `string` member (non-generic sibling) and on a scalar value object (generic closed with that type): + +```csharp +public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule +{ + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; +} + +public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject +{ + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; +} + +[ZodRule(typeof(NonWhiteSpaceStringRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} +``` + +Resolution is symmetric (mapping the attribute to the non-generic rule still finds the generic member for a scalar target). When nothing in the family can validate the target type, `ZODSGEN030` is reported and no rule is emitted. Marking an arity-1 **generic** rule with the parameterless `[ZodRule]` generates the attribute mapped to the open generic for you; if a hand-authored type already claims the derived name, the generated attribute is suppressed and reported as `ZODSGEN037` rather than silently re-mapping every usage; a hand-authored attribute whose mapping does not address the whole family is reported as `ZODSGEN038`; and a rule that accepts a `code`/`origin` constructor parameter without implementing `IZodRule` is reported as `ZODSGEN039`, because the value would be supplied and never read back. + +Implement `ZodSharp.Core.IZodRuleAttribute` on a hand-authored attribute to declare its `Code`/`Origin` as the reported error identity (the compiler then guarantees the properties the generator reads exist). Any argument an attribute supplies that the resolved rule cannot consume — no matching constructor parameter and not identity — is reported as `ZODSGEN040` instead of being dropped silently. + +Add `[ZodRule(AllowMultiple = true)]` to emit `AttributeUsage(..., AllowMultiple = true)` so the generated attribute may be applied more than once. Each application becomes its own rule, configured from that application's arguments and evaluated in source order: + +```csharp +[ZodRule(AllowMultiple = true)] +public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule +{ + public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; + public string GetErrorMessage(in int value) => Message ?? "Not a multiple."; +} + +[ZodSchema] +public partial class Sample +{ + [MultipleOf(Factor = 3)] + [MultipleOf(Factor = 5)] + public int Value { get; set; } // must be a multiple of both 3 and 5 +} +``` + ## 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: diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index a8a3d5d..bc23db6 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -61,6 +61,12 @@ public partial class AnalyzerReleaseTrackingTests "ZODSASP101", ]; + /// + /// Rules added since the last stable release. Each identifier must also appear in + /// AnalyzerReleases.Unshipped.md so the Roslyn release-tracking analyzers stay satisfied. + /// + static readonly string[] ExpectedUnshippedRuleIds = ["ZODSGEN037", "ZODSGEN038", "ZODSGEN039", "ZODSGEN040"]; + [Test] public async Task ShippedReleases_GivenStableRelease_DeclareRelease2_0_0WithEveryDiagnosticId( CancellationToken cancellationToken @@ -78,7 +84,9 @@ CancellationToken cancellationToken } [Test] - public async Task UnshippedRelease_GivenStableRelease_DeclaresNoNewRules(CancellationToken cancellationToken) + public async Task UnshippedRelease_GivenRulesAddedSinceTheStableRelease_DeclaresOnlyThoseRules( + CancellationToken cancellationToken + ) { // Arrange var markdown = await ReadReleaseFileAsync("AnalyzerReleases.Unshipped.md", cancellationToken); @@ -89,7 +97,7 @@ public async Task UnshippedRelease_GivenStableRelease_DeclaresNoNewRules(Cancell // Assert await Assert.That(markdown).Contains("### New Rules"); await Assert.That(markdown).Contains("; Unshipped analyzer release"); - await Assert.That(ruleIds.Length).IsEqualTo(0); + await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedUnshippedRuleIds)); } [Test] @@ -117,6 +125,7 @@ await ReadReleaseFileAsync("AnalyzerReleases.Unshipped.md", cancellationToken) await Assert.That(string.Join(",", duplicatedShippedRuleIds)).IsEqualTo(string.Empty); await Assert.That(string.Join(",", rulesInBothFiles)).IsEqualTo(string.Empty); await Assert.That(shippedRuleIds.Length).IsEqualTo(ExpectedShippedRuleIds.Length); + await Assert.That(unshippedRuleIds.Length).IsEqualTo(ExpectedUnshippedRuleIds.Length); } static async Task ReadReleaseFileAsync(string fileName, CancellationToken cancellationToken) => diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs index 9880857..8ffc71a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -152,4 +152,230 @@ public class Order var result = await AnalyzeAsync(source, cancellationToken); await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); } + + [Test] + public async Task CustomRule_GivenValueObjectConstraintUnsatisfiableByPropertyType_ProducesZODSGEN030( + CancellationToken cancellationToken + ) + { + // The rule is constrained to a scalar value object, so a `string` property cannot satisfy it and no + // non-generic sibling exists to fall back to. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NonWhiteSpaceStringRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute { } + + [ZodSchema] + public sealed class Repository + { + [NonWhiteSpaceString] + public string? Name { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + [Test] + public async Task CustomRule_GivenValueObjectConstraintWithNonGenericSibling_ProducesNoDiagnostic( + CancellationToken cancellationToken + ) + { + // The same rule shape plus a non-generic sibling that validates the string member. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + } + + public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NonWhiteSpaceStringRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute { } + + [ZodSchema] + public sealed class Repository + { + [NonWhiteSpaceString] + public string? Name { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + [Test] + public async Task CustomRule_GivenArgumentTheRuleCannotConsume_ProducesZODSGEN040( + CancellationToken cancellationToken + ) + { + // `Threshold` is neither a rule constructor parameter nor part of the error identity, so the value has + // no effect on validation. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RankRule(string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public int Threshold { get; set; } + } + + [ZodSchema] + public sealed class Sample + { + [Rank(Threshold = 3)] + public int Value { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnusedRuleAttributeArgument); + } + + [Test] + public async Task CustomRule_GivenArgumentsTheRuleConsumes_ProducesNoZODSGEN040(CancellationToken cancellationToken) + { + // `Code` is error identity, `Message` and `ErrorMessage` feed the message, so nothing is unused. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RankRule(string? Code = null, string? Message = null) + : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [ZodSchema] + public sealed class Sample + { + [Rank(Code = "invalid_rank", Message = "Bad.")] + public int Value { get; set; } + + [Rank(ErrorMessage = "Also bad.")] + public int Other { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnusedRuleAttributeArgument); + } + + [Test] + public async Task CustomRule_GivenIdentityContractMembers_ProducesNoZODSGEN040(CancellationToken cancellationToken) + { + // The attribute implements IZodRuleAttribute, so its members are identity properties by contract. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RankRule(string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute, IZodRuleAttribute + { + public string? Code { get; set; } + + public string? Origin { get; set; } + } + + [ZodSchema] + public sealed class Sample + { + [Rank(Code = "invalid_rank", Origin = "ranking")] + public int Value { get; set; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnusedRuleAttributeArgument); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index 7346e4a..7ac0d2f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -155,6 +155,180 @@ public class Broken await Assert.That(generated).DoesNotContain("NotEmptyRule"); } + /// + /// A rule pair: a non-generic rule for the primitive and an arity-1 generic rule constrained to the scalar + /// value object, addressed through a single attribute mapped to the open generic. This mirrors the shape + /// used by consumers such as NonWhiteSpaceStringRule. + /// + const string RuleFamilySource = """ + 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 NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + } + + public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NonWhiteSpaceStringRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute + { + public string? Code { get; set; } + + public string? Message { get; set; } + } + + [ZodSchema] + public partial class Repository + { + [NonWhiteSpaceString] + public string? Name { get; set; } + } + + [ZodSchema] + [NonWhiteSpaceString] + public readonly partial record struct ExternalUserId : IScalarValueObject + { + public string Value { get; init; } + } + } + """; + + [Test] + public async Task GenericRule_GivenValueObjectRuleOnPrimitiveMember_ResolvesNonGenericSibling( + CancellationToken cancellationToken + ) + { + // Arrange — the attribute maps to a value-object-only rule and is applied to a `string` member and to + // a scalar value object. + // Act + var driverResult = await GenerateAsync( + RuleFamilySource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var repositorySchema = driverResult.GetSource("RepositorySchema"); + var externalUserIdSchema = driverResult.GetSource("ExternalUserIdSchema"); + + // Assert — the primitive member resolves to the non-generic sibling, the scalar closes the generic, and + // the generated validators compile. Closing the generic over `string` used to leak CS0311 into the + // consumer's generated code. + await Assert.That(repositorySchema).ContainsGeneratedCode("new global::Testing.NonWhiteSpaceStringRule("); + await Assert.That(repositorySchema).DoesNotContain("NonWhiteSpaceStringRule<"); + await Assert + .That(externalUserIdSchema) + .ContainsGeneratedCode("new global::Testing.NonWhiteSpaceStringRule("); + // The hand-authored attribute maps to the open generic, so it covers the whole family. + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task GenericRule_GivenClosedRuleOnScalarValueObject_ResolvesGenericFamilyMember( + CancellationToken cancellationToken + ) + { + // Arrange — the attribute maps to the non-generic rule but is applied to the scalar value object, so the + // resolution has to walk up to the generic family member. + var source = RuleFamilySource.Replace( + "typeof(NonWhiteSpaceStringRule<>)", + "typeof(NonWhiteSpaceStringRule)", + StringComparison.Ordinal + ); + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var externalUserIdSchema = driverResult.GetSource("ExternalUserIdSchema"); + + // Assert + await Assert + .That(externalUserIdSchema) + .ContainsGeneratedCode("new global::Testing.NonWhiteSpaceStringRule("); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task GenericRule_GivenUnsatisfiableValueObjectConstraint_DoesNotEmitUnclosableRule( + CancellationToken cancellationToken + ) + { + // Arrange — only the value-object rule exists, so nothing can validate the `string` member. + 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 NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [ZodRule(typeof(NonWhiteSpaceStringRule<>))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute { } + + [ZodSchema] + public partial class Repository + { + [NonWhiteSpaceString] + public string? Name { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var repositorySchema = driverResult.GetSource("RepositorySchema"); + + // Assert — the unclosable rule is dropped rather than emitted, so the generated validator still + // compiles. ZODSGEN030 reports it through the analyzer (see ZodSchemaAnalyzerTests.CustomRules). + await Assert.That(repositorySchema).DoesNotContain("NonWhiteSpaceStringRule<"); + driverResult.AssertNoCompilationErrors(); + } + [Test] public async Task AttributeCode_GivenCodeNamedArgument_OverridesZodRuleCode(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index 78082f4..62a0283 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -125,5 +125,592 @@ public readonly record struct OtherRule(string? Message = null) : IValidationRul // Assert — the hand-authored attribute is left alone, the unmapped rule still gets one. await Assert.That(driverResult.GetSource("OtherAttribute")).Contains("class OtherAttribute"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN037"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenHandAuthoredAttributeOfDerivedName_ReportsCollision( + CancellationToken cancellationToken + ) + { + // Arrange — the rule derives `NoWhitespaceAttribute`, which is already declared by hand. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + 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."; + } + + [AttributeUsage(AttributeTargets.Property)] + public sealed class NoWhitespaceAttribute : ValidationAttribute { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert — the hand-authored declaration wins, nothing is generated, and the suppression is reported + // rather than silent (previously the hand-authored mapping re-mapped the name with no notice). + await Assert.That(driverResult).HasDiagnostic("ZODSGEN037"); + await Assert.That(driverResult.GetSource("NoWhitespaceAttribute")).IsNull(); + } + + /// + /// A rule pair where the arity-1 generic rule carries the [ZodRule] marker, so the generator emits a + /// single attribute mapped to the open generic — the form that serves both the primitive member (resolved + /// through the non-generic sibling) and the scalar value object (closed with that type). + /// + const string GenericRuleMarkerSource = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + } + + [ZodRule] + public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenZodRuleOnGenericRule_GeneratesAttributeMappedToOpenGeneric( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync( + GenericRuleMarkerSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("NonWhiteSpaceStringAttribute"); + + // Assert — the attribute is generated in the rule's namespace and maps to the unbound generic so its + // consumers resolve the primitive or the scalar form automatically. + await Assert.That(generated).ContainsGeneratedCode("namespace Testing.Rules"); + await Assert.That(generated).ContainsGeneratedCode("class NonWhiteSpaceStringAttribute"); + await Assert.That(generated).ContainsGeneratedCode("typeof(global::Testing.Rules.NonWhiteSpaceStringRule<>)"); + await Assert.That(generated).Contains("Code { get; set; }"); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenBothRuleHalvesMarked_GeneratesSingleAttribute( + CancellationToken cancellationToken + ) + { + // Arrange — both halves of the pair are marked, so both would derive `NonWhiteSpaceStringAttribute`. + var source = GenericRuleMarkerSource.Replace( + """ + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + """, + """ + [ZodRule] + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + """, + StringComparison.Ordinal + ); + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("NonWhiteSpaceStringAttribute"); + + // Assert — one attribute is emitted (a duplicate hint name would fail the generator) and it keeps the + // open generic mapping. + await Assert.That(generated).ContainsGeneratedCode("class NonWhiteSpaceStringAttribute"); + await Assert.That(generated).ContainsGeneratedCode("typeof(global::Testing.Rules.NonWhiteSpaceStringRule<>)"); + driverResult.AssertNoGenerationExceptions(); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenAllowMultipleMarker_MirrorsItOnTheGeneratedAttribute( + CancellationToken cancellationToken + ) + { + // Arrange — one rule opts into repeated application, one keeps the AttributeUsage default. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule(AllowMultiple = true)] + public readonly record struct RepeatingRule(int Step = 1, string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => Step != 0 && value % Step == 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid."; + } + + [ZodRule] + public readonly record struct SingleRule(int Step = 1, string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => Step != 0 && value % Step == 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid."; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var repeating = driverResult.GetSource("RepeatingAttribute"); + var single = driverResult.GetSource("SingleAttribute"); + + // Assert + await Assert.That(repeating).ContainsGeneratedCode("AllowMultiple = true"); + await Assert.That(single).ContainsGeneratedCode("AllowMultiple = false"); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenAttributeAppliedTwice_EmitsEveryApplicationAsItsOwnRule( + CancellationToken cancellationToken + ) + { + // Arrange — a hand-authored attribute that permits repeated application. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) + : IValidationRule + { + public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; + + public string GetErrorMessage(in int value) => Message ?? "Not a multiple."; + } + + [ZodRule(typeof(MultipleOfRule), Code = "not_multiple")] + [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] + public sealed class MultipleOfAttribute : ValidationAttribute + { + public int Factor { get; set; } + } + + [ZodSchema] + public partial class Sample + { + [MultipleOf(Factor = 3)] + [MultipleOf(Factor = 5)] + public int Value { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("SampleSchema"); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Sample")!; + var validate = assembly.GetType("Testing.SampleSchema")!.GetMethod("Validate")!; + + // Both applications are emitted as their own rule, in source order. + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(3, null)"); + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(5, null)"); + + // Act — 15 satisfies both applications, 3 satisfies only the first. + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, 15); + var validResult = validate.Invoke(null, [validInstance])!; + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(invalidInstance, 3); + var invalidResult = validate.Invoke(null, [invalidInstance])!; + + // Assert + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + invalidResult.GetType().GetProperty("Errors")!.GetValue(invalidResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("not_multiple"); + } + + /// + /// A rule family whose members share the name the attribute encodes: the non-generic primitive rule and an + /// arity-1 generic value-object rule, with a placeholder for the attribute's mapping. + /// + const string RuleFamilyAttributeSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + { + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + } + + public readonly record struct NonWhiteSpaceStringRule(string? Message = null) + : IValidationRule + where TSelf : IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); + + public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + } + + [MAPPING] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute { } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenMappingThatSkipsTheGenericSibling_ReportsIncompleteCoverage( + CancellationToken cancellationToken + ) + { + // Arrange — the attribute name encodes `NonWhiteSpaceStringRule`, but the mapping declares only the + // non-generic member while an arity-1 generic sibling also exists. + var source = RuleFamilyAttributeSource.Replace( + "[MAPPING]", + "[ZodRule(typeof(NonWhiteSpaceStringRule))]", + StringComparison.Ordinal + ); + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert — the generic half of the family is not covered by the declared mapping. + await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenMappingToOpenGeneric_ReportsNoIncompleteCoverage( + CancellationToken cancellationToken + ) + { + // Arrange — the open generic addresses both the primitive member (through the non-generic sibling) and + // the scalar value object, so the family is covered. + var source = RuleFamilyAttributeSource.Replace( + "[MAPPING]", + "[ZodRule(typeof(NonWhiteSpaceStringRule<>))]", + StringComparison.Ordinal + ); + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenMappingToUnrelatedRule_ReportsIncompleteCoverage( + CancellationToken cancellationToken + ) + { + // Arrange — `WidgetAttribute` encodes `WidgetRule`, but the mapping declares an unrelated rule. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + public readonly record struct WidgetRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Invalid widget."; + } + + public readonly record struct GadgetRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Invalid gadget."; + } + + [ZodRule(typeof(GadgetRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class WidgetAttribute : ValidationAttribute { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenFreeFormAttributeName_ReportsNoIncompleteCoverage( + CancellationToken cancellationToken + ) + { + // Arrange — `NoSpacesAttribute` does not encode a declared rule name, so any mapping is accepted. + 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 NoSpacesAttribute : ValidationAttribute { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenIdentityParameterWithoutZodRule_ReportsInertParameter( + CancellationToken cancellationToken + ) + { + // Arrange — the rule accepts `Code`, but only IZodRule surfaces it, so the value is inert. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct RankRule(string? Code = null, string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("RankAttribute"); + + // Assert — the warning is raised and the attribute is still generated. + await Assert.That(driverResult).HasDiagnostic("ZODSGEN039"); + await Assert.That(generated).ContainsGeneratedCode("class RankAttribute"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenIdentityParameterWithZodRule_ReportsNoInertParameter( + CancellationToken cancellationToken + ) + { + // Arrange — the same rule, now owning its identity. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct RankRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + + string? IZodRule.Code => Code; + + string? IZodRule.Origin => "ranking"; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN039"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenAttributeImplementingIdentityContract_EmitsDeclaredIdentity( + CancellationToken cancellationToken + ) + { + // Arrange — the attribute implements IZodRuleAttribute, so the identity properties are compiler-enforced + // and read from the applied attribute. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RankRule(string? Message = null) : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute, IZodRuleAttribute + { + public string? Code { get; set; } + + public string? Origin { get; set; } + } + + [ZodSchema] + public partial class Sample + { + [Rank(Code = "invalid_rank", Origin = "ranking")] + public int Value { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("SampleSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("\"invalid_rank\""); + await Assert.That(generated).ContainsGeneratedCode("\"ranking\""); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenMappedRuleWithIdentityParameter_ReportsInertParameter( + CancellationToken cancellationToken + ) + { + // Arrange — the rule is only ever mapped (never marked) and accepts `Code` without implementing IZodRule. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + public readonly record struct RankRule(string? Code = null, string? Message = null) + : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).HasDiagnostic("ZODSGEN039"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenMarkedAndMappedRuleWithIdentityParameter_ReportsOnce( + CancellationToken cancellationToken + ) + { + // Arrange — the rule is marked (checked at its declaration) and mapped (checked at the attribute), so the + // mapping path must not report the same rule twice. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct RankRule(string? Code = null, string? Message = null) + : IValidationRule + { + public bool IsValid(in int value) => value > 0; + + public string GetErrorMessage(in int value) => Message ?? "Invalid rank."; + } + + [ZodRule(typeof(RankRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RankAttribute : ValidationAttribute { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(driverResult).HasDiagnostics("ZODSGEN039", 1); } } From e611a14124857583bdc2bab4af9506190fad8291 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 23:11:46 +0100 Subject: [PATCH 02/26] fix: lost ability to generate scalars --- package.json | 2 +- .../Helpers/CustomRuleResolver.cs | 61 +++++++++- .../Helpers/SourceGenLibrary.cs | 7 +- src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 33 ++++++ .../ZodSchemaAnalyzerTests.CustomRules.cs | 109 ++++++++++++++++++ .../ZodSchemaGeneratorTests.TypeRules.cs | 74 ++++++++++++ 6 files changed, 280 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e6ca1dc..3c53c98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.1", + "version": "2.0.1-prerelease.1", "private": true, "license": "MIT", "author": { diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index f53e56c..bb1cfb0 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using System.Globalization; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using ZodSharp.SourceGenerators.Models; using ZodSharp.SourceGenerators.Models.DataAttributes; @@ -24,6 +26,21 @@ namespace ZodSharp.SourceGenerators.Helpers; /// static class CustomRuleResolver { + /// + /// Resolves the custom rules declared by , discarding the diagnostics a failed + /// resolution produces. + /// + /// The symbol whose rule attributes are resolved. + /// The type the rules are applied to. + /// The resolved rule descriptors. + /// + /// Used by the generator, which only needs the descriptors: the same resolution runs in + /// ZodSchemaAnalyzer, which reports the diagnostics, so a rule that resolves to nothing is still + /// visible in the build. + /// + public static EquatableArray Resolve(ISymbol symbol, ITypeSymbol ruleTargetType) => + Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder()); + public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, @@ -272,14 +289,54 @@ static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) if (substituted is null) continue; - if (!IsOrImplements(type, substituted)) - return false; + if (IsOrImplements(type, substituted)) + continue; + + // A constraint can be satisfied by a declaration this generator cannot see: a type declared + // partial in this compilation may receive the interface implementation from another generator + // (a scalar value object gets IScalarValueObject from the value object + // generator, which does not run in this compilation's view). Defer those to the compiler + // instead of dropping a rule that the consumer's compilation can satisfy. + if (ContainsTypeParameter(constraint) && CanReceiveGeneratedMembers(type)) + continue; + + return false; } } return true; } + /// + /// Determines whether can be completed by another source generator, which means + /// it can end up satisfying a constraint this compilation does not yet show. + /// + /// The candidate type to close the rule with. + /// when the type is declared partial in this compilation. + /// + /// Only a type declared in the compilation being generated for can receive further declarations, and + /// only a partial declaration can be extended. A primitive or a type from a referenced assembly + /// can never gain the members a rule's constraint asks for, so it keeps the strict check. + /// + static bool CanReceiveGeneratedMembers(ITypeSymbol type) + { + if (type is not INamedTypeSymbol named) + return false; + + foreach (var reference in named.DeclaringSyntaxReferences) + { + if ( + reference.GetSyntax() is TypeDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) + ) + { + return true; + } + } + + return false; + } + /// /// Replaces every occurrence of inside with /// . diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index de019f3..1ba3055 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -173,9 +173,10 @@ CancellationToken cancellationToken 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); + // rather than a property, which is what makes a scalar value object validatable as a unit. The + // generator only needs the descriptors: ZodSchemaAnalyzer resolves the same attributes and + // reports the diagnostics, so a dropped rule is visible in the build instead of failing silently. + var typeRules = CustomRuleResolver.Resolve(symbol, symbol); var accessibility = symbol.ContainingType is null ? symbol.DeclaredAccessibility == Accessibility.Public ? TypeDeclarationAccessibility.Public diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 6906e98..45e6487 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -100,6 +100,10 @@ ImmutableHashSet schemaReachableTypes var typeLocation = GetTypeLocation(type); + // Type-level rules validate the whole value. The generator resolves the same attributes to emit those + // validations, so a rule that resolves to nothing is reported here rather than dropped silently. + ReportTypeRuleDiagnostics(context, type, typeLocation); + if (!hasDataAnnotations) { context.ReportDiagnostic( @@ -182,6 +186,35 @@ ImmutableHashSet schemaReachableTypes } } + /// + /// Reports the diagnostics produced while resolving the type-level rule attributes on + /// . The generator resolves the same attributes to emit the whole-value + /// validations, so reporting them here keeps a rule that never runs visible in the build. + /// + /// The analysis context the diagnostics are reported to. + /// The schema type whose type-level rules are resolved. + /// The location to fall back to when a diagnostic carries none. + static void ReportTypeRuleDiagnostics(SymbolAnalysisContext context, INamedTypeSymbol type, Location typeLocation) + { + var diagnostics = ImmutableArray.CreateBuilder(); + _ = CustomRuleResolver.Resolve(type, type, diagnostics); + + foreach (var diagnosticInfo in diagnostics) + { + var diagnostic = diagnosticInfo.ToDiagnostic(); + if (diagnostic.Location == Location.None) + { + diagnostic = Diagnostic.Create( + diagnostic.Descriptor, + typeLocation, + diagnosticInfo.MessageArgs.ToArray() + ); + } + + context.ReportDiagnostic(diagnostic); + } + } + static Location GetTypeLocation(INamedTypeSymbol type) { foreach (var location in type.Locations) diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs index 8ffc71a..54925d9 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -378,4 +378,113 @@ public sealed class Sample var result = await AnalyzeAsync(source, cancellationToken); await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnusedRuleAttributeArgument); } + + /// + /// A type-level rule on a value object whose interface implementation is contributed by another generator + /// resolves without a diagnostic: the rule is emitted and the consumer's compilation verifies the + /// constraint, because the generator cannot see the completed type. + /// + [Test] + public async Task TypeRule_GivenPartialValueObjectWhoseInterfaceIsGenerated_ProducesNoDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + 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; + } + + [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; } + } + + // The interface implementation comes from the value object generator, so this compilation + // cannot see it. + [NotEmpty(Code = "invalid_asset_id")] + [ZodSchema] + public readonly partial record struct AssetId + { + public Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + /// + /// A type-level rule on a type no generator can complete still reports ZODSGEN030, so a rule that can + /// never run is never dropped silently. + /// + [Test] + public async Task TypeRule_GivenTypeThatCannotImplementTheConstraint_ProducesZODSGEN030( + CancellationToken cancellationToken + ) + { + // The type is not partial, so nothing can add the interface the rule's constraint asks for. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + 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; + } + + [ZodRule(typeof(NotEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyAttribute : ValidationAttribute { } + + [NotEmpty] + [ZodSchema] + public readonly record struct NotAValueObject + { + public Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index a3458b8..7abac79 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -69,6 +69,80 @@ await Assert await Assert.That(generated).DoesNotContain("NotEmptyRule"); } + /// + /// A value object whose interface implementation is contributed by another generator still gets its + /// type-level rule. The scalar value-object generators add + /// IScalarValueObject<TSelf, TValue> in a separate generator run this generator cannot see, + /// so the self-referential constraint cannot be verified from the declarations here. It is deferred to + /// the compiler (which does see the completed type) instead of dropping the rule, which is what made a + /// generated validator silently validate nothing. + /// + [Test] + public async Task TypeRule_GivenPartialValueObjectWhoseInterfaceIsGenerated_EmitsTheRule( + CancellationToken cancellationToken + ) + { + 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 => null; + } + + [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; } + } + + // The interface implementation comes from the value object generator, so the partial + // declaration here is all this compilation shows. + [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] + [ZodSchema] + public readonly partial record struct AssetId + { + public Guid Value { get; init; } + } + } + """; + + // Act — the harness normally compiles the generated code, which a single synthetic compilation cannot + // do for this shape: the interface the constraint asks for is contributed by another generator, so the + // emitted rule is asserted instead of compiled. + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert — the rule still closes over the value object and validates the whole value. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath"); + } + [Test] public async Task TypeRule_GivenEmptyValueObject_FailsAtRuntimeWithTypeLevelIdentity( CancellationToken cancellationToken From e90e05dfc1b385e24bfe5db9698d54c43ddb9539 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 13:59:21 +0100 Subject: [PATCH 03/26] feat: native C# 15 union --- .agents/agents/sdk-consumer-setup.md | 8 +- .agents/agents/sdk-repository-rationaliser.md | 38 ++++ .../prompts/sdk-diagnose-agent-folder-copy.md | 21 ++- .../prompts/sdk-review-repository-shape.md | 33 ++++ .../sdk-engineering-principles/.gitignore | 8 + .csharpierignore | 3 + .editorconfig | 132 +++++++------- AGENTS.md | 2 +- Directory.Packages.props | 3 +- Justfile | 3 +- README.md | 2 +- docs/wiki/Arrays-and-Other-Schemas.md | 17 ++ docs/wiki/AspNetCore-Integration.md | 29 ++++ docs/wiki/Core-Concepts.md | 2 +- docs/wiki/Fluent-Schema-API.md | 2 + docs/wiki/Guarantees-and-Limitations.md | 4 +- docs/wiki/Home.md | 3 +- docs/wiki/Number-Validation.md | 4 + docs/wiki/Source-Generator-Diagnostics.md | 3 +- docs/wiki/String-Validation.md | 16 +- docs/wiki/Unions-and-Discriminated-Unions.md | 26 +++ global.json | 6 +- purview-build.json | 5 +- src/Directory.Build.props | 10 +- src/ZodSharp.slnx | 1 + .../Builder/RouteHandlerBuilderExtensions.cs | 25 +++ .../ServiceCollectionExtensions.cs | 4 +- .../ZodSharp/ProblemDetailsExtensions.cs | 19 ++ src/src/AspNetCore/ProblemDetailsMapper.cs | 2 +- src/src/AspNetCore/Sdk/README.md | 2 + src/src/AspNetCore/ZodExceptionHandler.cs | 4 +- src/src/AspNetCore/ZodValidationFilter.cs | 47 +++++ src/src/Benchmarks/Benchmarks.csproj | 2 +- src/src/Benchmarks/Program.cs | 7 +- src/src/CodeFixes/CodeFixes.csproj | 17 ++ .../CodeFixes/NativeUnionCodeFixProvider.cs | 158 +++++++++++++++++ src/src/Examples.CLI/User.cs | 2 +- .../AnalyzerReleases.Unshipped.md | 1 + .../ErrorTypeMessageFormatAnalyzer.cs | 12 +- .../ErrorTypePartialClassAnalyzer.cs | 4 +- .../Helpers/CustomRuleResolver.cs | 4 +- .../Helpers/DiagnosticLibrary.cs | 14 ++ src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 54 +++++- .../ZodSchemaGenerator.RuleAttributes.cs | 10 +- .../ZodSchemaGenerator.ValueValidators.cs | 4 +- .../SourceGenerators/ZodSchemaGenerator.cs | 4 +- .../JsonSchemaSerializerOptions.cs | 4 +- src/src/ZodSharp/Core/ErrorTypeParameters.cs | 10 +- .../ZodSharp/Core/IStringValidationRule.cs | 6 + src/src/ZodSharp/Core/IValidationRule.cs | 6 + src/src/ZodSharp/Core/ZodRuleAttribute.cs | 4 +- src/src/ZodSharp/Core/ZodType.cs | 2 +- src/src/ZodSharp/Rules/Base64StringRule.cs | 3 + src/src/ZodSharp/Rules/Base64UrlRule.cs | 61 +++++++ src/src/ZodSharp/Rules/CreditCardRule.cs | 3 + src/src/ZodSharp/Rules/Cuid2Rule.cs | 59 +++++++ src/src/ZodSharp/Rules/DateStringRule.cs | 56 ++++++ src/src/ZodSharp/Rules/DatetimeStringRule.cs | 110 ++++++++++++ src/src/ZodSharp/Rules/E164Rule.cs | 63 +++++++ src/src/ZodSharp/Rules/EmailRule.cs | 3 + src/src/ZodSharp/Rules/EndsWithRule.cs | 3 + src/src/ZodSharp/Rules/FiniteRule.cs | 3 + src/src/ZodSharp/Rules/GreaterThanRule.cs | 3 + src/src/ZodSharp/Rules/HexRule.cs | 56 ++++++ src/src/ZodSharp/Rules/IPAddressRule.cs | 58 +++++++ src/src/ZodSharp/Rules/IncludesRule.cs | 55 ++++++ src/src/ZodSharp/Rules/JWTRule.cs | 79 +++++++++ src/src/ZodSharp/Rules/LessThanRule.cs | 3 + src/src/ZodSharp/Rules/MaxLengthRule.cs | 3 + src/src/ZodSharp/Rules/MaxValueRule.cs | 3 + src/src/ZodSharp/Rules/MinLengthRule.cs | 3 + src/src/ZodSharp/Rules/MinValueRule.cs | 3 + src/src/ZodSharp/Rules/MultipleOfRule.cs | 3 + src/src/ZodSharp/Rules/NanoidRule.cs | 59 +++++++ src/src/ZodSharp/Rules/PhoneRule.cs | 3 + src/src/ZodSharp/Rules/RegexRule.cs | 3 + src/src/ZodSharp/Rules/StartsWithRule.cs | 3 + src/src/ZodSharp/Rules/TimeStringRule.cs | 95 ++++++++++ src/src/ZodSharp/Rules/ULIDRule.cs | 80 +++++++++ src/src/ZodSharp/Rules/UrlRule.cs | 3 + src/src/ZodSharp/Rules/UuidRule.cs | 3 + src/src/ZodSharp/Schemas/ZodBigInt.cs | 134 +++++++++++++++ src/src/ZodSharp/Schemas/ZodDate.cs | 43 +++++ src/src/ZodSharp/Schemas/ZodNumber.cs | 48 ++++++ src/src/ZodSharp/Schemas/ZodString.cs | 152 +++++++++++++++- .../ZodSharp/Schemas/ZodTypedNativeUnion.cs | 54 ++++++ src/src/ZodSharp/Sdk/README.md | 21 +++ src/src/ZodSharp/Unions/NativeUnion.cs | 21 +++ src/src/ZodSharp/Z.cs | 29 ++++ src/src/ZodSharp/ZodSharp.csproj | 23 ++- .../Fixtures/ValidatedRequest.cs | 11 ++ .../ZodValidationFilterTests.cs | 94 ++++++++++ .../Json/NewtonsoftJsonImportTests.cs | 4 +- .../Json/SystemTextJsonImportTests.cs | 4 +- .../AnalyzerReleaseTrackingTests.cs | 9 +- .../Infra/ZodCodeFixTestBase.cs | 9 + .../Infra/ZodCodeFixTestOptions.cs | 41 +++++ .../NativeUnionCodeFixTests.cs | 87 ++++++++++ .../SourceGenerators.UnitTests.csproj | 6 + .../ZodSchemaAnalyzerTests.NativeUnion.cs | 54 ++++++ .../Core/MultiTargetingTests.cs | 3 +- .../Schemas/ZodBigIntTests.cs | 92 ++++++++++ .../Schemas/ZodDateTests.cs | 37 ++++ .../Schemas/ZodNumberTests.cs | 44 +++++ .../Schemas/ZodPipeTests.cs | 4 +- .../Schemas/ZodStringSpanTests.cs | 38 +++- .../Schemas/ZodStringTests.cs | 162 ++++++++++++++++++ .../Schemas/ZodSuperRefinementTests.cs | 2 +- .../Schemas/ZodTypedNativeUnionTests.cs | 59 +++++++ 109 files changed, 2831 insertions(+), 145 deletions(-) create mode 100644 .agents/agents/sdk-repository-rationaliser.md create mode 100644 .agents/prompts/sdk-review-repository-shape.md create mode 100644 .agents/skills/sdk-engineering-principles/.gitignore create mode 100644 .csharpierignore create mode 100644 src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs create mode 100644 src/src/AspNetCore/ZodValidationFilter.cs create mode 100644 src/src/CodeFixes/CodeFixes.csproj create mode 100644 src/src/CodeFixes/NativeUnionCodeFixProvider.cs create mode 100644 src/src/ZodSharp/Rules/Base64UrlRule.cs create mode 100644 src/src/ZodSharp/Rules/Cuid2Rule.cs create mode 100644 src/src/ZodSharp/Rules/DateStringRule.cs create mode 100644 src/src/ZodSharp/Rules/DatetimeStringRule.cs create mode 100644 src/src/ZodSharp/Rules/E164Rule.cs create mode 100644 src/src/ZodSharp/Rules/HexRule.cs create mode 100644 src/src/ZodSharp/Rules/IPAddressRule.cs create mode 100644 src/src/ZodSharp/Rules/IncludesRule.cs create mode 100644 src/src/ZodSharp/Rules/JWTRule.cs create mode 100644 src/src/ZodSharp/Rules/NanoidRule.cs create mode 100644 src/src/ZodSharp/Rules/TimeStringRule.cs create mode 100644 src/src/ZodSharp/Rules/ULIDRule.cs create mode 100644 src/src/ZodSharp/Schemas/ZodBigInt.cs create mode 100644 src/src/ZodSharp/Schemas/ZodDate.cs create mode 100644 src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs create mode 100644 src/src/ZodSharp/Unions/NativeUnion.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs create mode 100644 src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestBase.cs create mode 100644 src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs create mode 100644 src/tests/SourceGenerators.UnitTests/NativeUnionCodeFixTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.NativeUnion.cs create mode 100644 src/tests/ZodSharp.UnitTests/Schemas/ZodBigIntTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Schemas/ZodDateTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Schemas/ZodTypedNativeUnionTests.cs diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md index 8e5066c..c3cc84f 100644 --- a/.agents/agents/sdk-consumer-setup.md +++ b/.agents/agents/sdk-consumer-setup.md @@ -14,11 +14,14 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of silently skipping resolution. 4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via + (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via `EnsureAgentFolderInPackageTarget`. 5. For test-framework or project-shape questions, confirm the project follows repo naming and placement conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm +6. For naming, layout, and test readability questions, start from the engineering principles: naming and + placement are configuration, short project names are preferred, the detected test type becomes the baseline + category, and subject-based tests should be named for the subject they own. +7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm the fix. ## Constraints @@ -32,3 +35,4 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, ## Related skill See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. +See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/.agents/agents/sdk-repository-rationaliser.md b/.agents/agents/sdk-repository-rationaliser.md new file mode 100644 index 0000000..d41060b --- /dev/null +++ b/.agents/agents/sdk-repository-rationaliser.md @@ -0,0 +1,38 @@ +# sdk-repository-rationaliser (generic agent spec) + +## Goal + +Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. + +## Workflow + +1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, + and current project layout. +2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. +3. Compare the repo's structure against the engineering principles: + - short project names + - source/test split where practical + - exact shared/shared-testing names when SDK behavior is expected + - readable, scalable test naming +4. Separate findings into three groups: + - already aligned + - misaligned but harmless + - misaligned and blocking SDK automatic behavior +5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. +6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based + suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. +7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Testcontainers` are genuinely needed + rather than treating them as universal defaults. + +## Constraints + +- Do not assume every older repo should be renamed wholesale. +- Preserve meaningful established structure unless it interferes with SDK inference. +- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and + `TargetProjectName` instead of arguing from taste. + +## Related skills + +- `../skills/sdk-engineering-principles/SKILL.md` +- `../skills/project-placement-defaults/SKILL.md` +- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md index 85ad444..e234df9 100644 --- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ b/.agents/prompts/sdk-diagnose-agent-folder-copy.md @@ -11,16 +11,27 @@ destination in a consuming repository. 3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, `Directory.Build.props`, or command-line `-p:` overrides). 4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`. + `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` + folder (override with `PurviewAgentFolderSourcePath`). 5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control root metadata. -6. Re-run the build and confirm the destination folder now contains the copied files (including the +6. If the destination looks stale or incomplete, inspect the change-detection manifest + (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a + matching entry with a present destination file means the sync was skipped as up to date. Delete the + manifest (or the affected destination file) to force a fresh copy. +7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. + A copy that still fails after every retry is reported as an error naming the source, destination and + OS error - search the build log for `The Purview SDK could not copy`. Temporarily set + `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and + `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. +8. Re-run the build and confirm the destination folder now contains the copied files (including the generated `.gitignore` for skill/prompt/agent subfolders). ## Suggested output -- A short root-cause explanation (missing import, disabled flag, wrong destination override, or repo-root - discovery miss). +- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root + discovery miss, or a destination held open by another process). - The exact command used to reproduce/verify the fix (for example `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path. +- Confirmation that the expected files exist at the resolved destination path, plus whether the + manifest skipped the sync (in which case the content was already up to date). diff --git a/.agents/prompts/sdk-review-repository-shape.md b/.agents/prompts/sdk-review-repository-shape.md new file mode 100644 index 0000000..eb089c9 --- /dev/null +++ b/.agents/prompts/sdk-review-repository-shape.md @@ -0,0 +1,33 @@ +# sdk-review-repository-shape (generic prompt spec) + +Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. + +## Required behaviour + +1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and + project layout before making assumptions. +2. Identify whether the repository follows the SDK-friendly structure: + - source projects under `src/` + - test projects under `tests/` + - `.csproj` filenames matching directory names + - short project names with `NamespacePrefix` carrying the repo identity +3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing + projects use exact SDK-recognised names. +4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, + `PackageId`, `TargetProjectName`, and automatic project references. +5. Review test readability conventions: + - subject-based `{SubjectName}Tests` + - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names + - non-subject-based suites named clearly for their broader role + - appropriate use of TUnit categories and display names +6. Distinguish between: + - acceptable existing variance worth preserving + - structural debt that blocks the SDK's automatic behavior + - incremental rationalisation opportunities + +## Suggested output + +- A concise summary of whether the repo broadly fits the SDK conventions. +- A list of concrete mismatches, ordered by impact. +- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. +- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/.agents/skills/sdk-engineering-principles/.gitignore b/.agents/skills/sdk-engineering-principles/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/.agents/skills/sdk-engineering-principles/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 0000000..f9b1858 --- /dev/null +++ b/.csharpierignore @@ -0,0 +1,3 @@ +# CSharpier 1.3.0 (latest) cannot parse the C# 15 `union` keyword, so files containing native +# union declarations are excluded from formatting. Revisit once CSharpier supports C# 15 unions. +src/src/ZodSharp/Unions/NativeUnion.cs diff --git a/.editorconfig b/.editorconfig index 959b462..120d509 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,8 +27,10 @@ dotnet_search_reference_assemblies = true # Nullability settings dotnet_build_property.Nullable = enable -# Enable or disable the analyzers -dotnet_analyzer_diagnostic.severity = warning +# Per-rule severity is authoritative in this file. The SDK sets AnalysisMode/AnalysisLevel as +# MSBuild properties, and the .NET SDK ignores bulk 'dotnet_analyzer_diagnostic.*' severity +# configuration whenever those properties are present. Do not re-add a bulk entry here: it +# silently does nothing and hides which severities are actually enforced. # Visual Studio XML Project Files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] @@ -76,23 +78,20 @@ indent_style = tab dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields -dotnet_naming_rule.private_fields.severity = warning -dotnet_naming_rule.private_fields.style = camel_case_underscore -dotnet_naming_rule.private_fields.symbols = private_fields -dotnet_naming_rule.private_fields_style.severity = warning -dotnet_naming_rule.private_fields_style.style = camel_case -dotnet_naming_rule.private_fields_style.symbols = private_fields dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case +# NOTE: the private-field rules that used to sit here referenced a style ('camel_case_underscore') +# and a symbol group ('private_fields') that were never defined, so they never applied. The real +# private-field rules are declared in the "StyleCop Field Naming Rules" section below. dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field dotnet_naming_symbols.non_private_static_fields.required_modifiers = static -# Constants are PascalCase +# Constants are PascalCase (field constants; local constants are locals and stay camelCase) dotnet_naming_rule.constants_should_be_pascal_case.severity = warning dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants dotnet_naming_style.constant_style.capitalization = pascal_case -dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.applicable_kinds = field dotnet_naming_symbols.constants.required_modifiers = const # Locals and parameters are camelCase @@ -102,9 +101,7 @@ dotnet_naming_rule.locals_should_be_camel_case.severity = warning # camel_case_style - Define the camelCase style dotnet_naming_style.camel_case_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = s_ dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local -dotnet_naming_symbols.static_fields.required_modifiers = static # first_upper_style - The first character must start with an upper-case character dotnet_naming_style.first_upper_style.capitalization = first_word_upper @@ -152,22 +149,22 @@ dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = fie # StyleCop Field Naming Rules -# All constant fields must be PascalCase -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = warning -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field +# All non-private constant fields must be PascalCase. Private constants are owned by +# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the +# same violation is reported once per matching rule. dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -# All static readonly fields must be PascalCase +# All non-private static readonly fields must be PascalCase. Private static readonly fields are +# owned by 'private_static_fields_group' below (see the note on the constant rule above). dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly @@ -178,12 +175,22 @@ dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = style dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -# Private fields must be camelCase -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group -dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private -dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field +# Private static fields are PascalCase: they are type-level state and are never qualified with 'this.' +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.symbols = private_static_fields_group +dotnet_naming_symbols.private_static_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_group.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_group.required_modifiers = static + +# Private instance fields must be camelCase with a leading underscore: '_name', never 'name'. The +# prefix keeps field access unambiguous (so the noisy 'this.' qualifier is never needed) and keeps +# fields distinguishable from locals and parameters. +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.severity = warning +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.style = camel_case_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.symbols = private_instance_fields_group +dotnet_naming_symbols.private_instance_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_group.applicable_kinds = field # Local variables must be camelCase dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent @@ -232,25 +239,14 @@ dotnet_naming_style.type_parameter_style.required_prefix = T dotnet_naming_symbols.type_parameter_symbol.applicable_accessibilities = * dotnet_naming_symbols.type_parameter_symbol.applicable_kinds = type_parameter -# Instance fields are camelCase and start with _ -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.instance_fields_should_be_camel_case.style = camel_case_underscore_style -dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +# Naming style shared by the private-field rules: camelCase with a required '_' prefix. dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.instance_field_style.capitalization = camel_case -dotnet_naming_style.instance_field_style.required_prefix = _ -dotnet_naming_symbols.instance_fields.applicable_kinds = field -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning -dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions dotnet_naming_style.local_function_style.capitalization = pascal_case dotnet_naming_symbols.local_functions.applicable_kinds = local_function @@ -264,12 +260,10 @@ dotnet_style_qualification_for_property = false:silent dotnet_style_operator_placement_when_wrapping = end_of_line # Naming styles -dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning -dotnet_naming_rule.interface_should_be_begins_with_i.style = prefix_interface_with_i_style -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# NOTE: a 'types_should_be_pascal_case' rule (symbol group 'types') and an +# 'interface_should_be_begins_with_i' rule (symbol group 'interface') used to sit here, but neither +# symbol group was ever defined, so neither rule applied. Types are covered by 'element_rule' and +# interfaces by 'interface_rule' above. # By default, name items with PascalCase dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning @@ -278,7 +272,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m # pascal_case_style - Define the PascalCase style dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_symbols.all_members.applicable_kinds = * # Symbol specifications dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected @@ -286,7 +279,6 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.required_modifiers = * # Naming styles -dotnet_naming_style._fieldname.capitalization = camel_case dotnet_naming_style.begins_with_i.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.required_suffix = @@ -314,9 +306,11 @@ dotnet_code_quality.prefer_const = true dotnet_code_quality.prefer_inferred_anonymous_type_member_names = true dotnet_code_quality.prefer_inferred_tuple_names = true dotnet_code_quality.prefer_readonly = true -dotnet_code_quality.require_accessibility_modifiers = true +# The real modifier policy is 'dotnet_style_require_accessibility_modifiers' (declared under +# "Modifier preferences" below). The 'require_accessibility_modifiers' and +# 'require_explicit_visibility' entries that used to live here are not .NET analyzer options: +# declaring them only pretended to enforce a policy that was never applied, so they were removed. dotnet_code_quality.require_explicit_type_arguments = true -dotnet_code_quality.require_explicit_visibility = true dotnet_code_quality.require_variable_declaration_for_explicit_type = false dotnet_code_quality_unused_parameters = all:warning dotnet_enable_roslyn_analyzers = true @@ -587,10 +581,14 @@ dotnet_diagnostic.CA1030.severity = suggestion dotnet_diagnostic.CA1031.severity = error # Implement standard exception constructors -dotnet_diagnostic.CA1032.severity = suggestion +# Raised to warning: the public API surface of exceptions is part of the accessibility policy and +# must not be invisible to command-line builds. +dotnet_diagnostic.CA1032.severity = warning # Interface methods should be callable by child types -dotnet_diagnostic.CA1033.severity = suggestion +# Raised to warning: explicit interface implementations that hide the member from derived types are +# an accessibility problem, not a style preference. +dotnet_diagnostic.CA1033.severity = warning # Nested types should not be visible dotnet_diagnostic.CA1034.severity = error @@ -599,7 +597,9 @@ dotnet_diagnostic.CA1034.severity = error dotnet_diagnostic.CA1036.severity = suggestion # Avoid empty interfaces -dotnet_diagnostic.CA1040.severity = suggestion +# Raised to warning: empty interfaces are unenforceable contracts and were previously invisible to +# command-line builds. +dotnet_diagnostic.CA1040.severity = warning # Provide ObsoleteAttribute message dotnet_diagnostic.CA1041.severity = warning @@ -794,7 +794,9 @@ dotnet_diagnostic.CA1513.severity = warning dotnet_diagnostic.CA1514.severity = warning # Consider making public types internal -dotnet_diagnostic.CA1515.severity = suggestion +# Reported as a real build warning (not a suggestion) so accessibility hygiene cannot be ignored: +# a public type in an application or test assembly should be made internal rather than suppressed. +dotnet_diagnostic.CA1515.severity = warning # Naming Rules (CA1700-CA1727 and IDE0130) # Naming rules support adherence to the naming conventions of the .NET design guidelines @@ -973,8 +975,9 @@ dotnet_diagnostic.CA1845.severity = error # Prefer AsSpan over Substring dotnet_diagnostic.CA1846.severity = error -dotnet_diagnostic.ignore_internalsvisibleto = true -dotnet_diagnostic.CA1852.ignore_internalsvisibleto = true +# CA1852 (seal internal types) is intentionally not suppressed for internal types that are exposed +# to other assemblies through InternalsVisibleTo: those types must still be sealed. The blanket +# suppression that used to sit here relied on a malformed key, so it never even applied. # Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks dotnet_diagnostic.CA2352.severity = error @@ -1319,8 +1322,9 @@ dotnet_diagnostic.IDE0038.severity = warning # Use local function instead of lambda dotnet_diagnostic.IDE0039.severity = suggestion -# Add accessibility modifiers -dotnet_diagnostic.IDE0040.severity = error +# IDE0040 is declared exactly once, in the "Accessibility modifiers" section above. A second entry +# here used to override that value (later entries win), which made the effective modifier policy +# ambiguous and let repos believe the rule had been turned off. # Use is null check dotnet_diagnostic.IDE0041.severity = suggestion @@ -1557,8 +1561,9 @@ dotnet_diagnostic.IDE0380.severity = warning # Remove unnecessary suppression (null-forgiving operator) dotnet_diagnostic.IDE0370.severity = warning -# Naming rule violation -dotnet_diagnostic.IDE1006.severity = silent +# Naming rule violation - must stay visible: the field-naming policy is only enforced when this +# diagnostic is reported, because naming rules without their own severity inherit this one. +dotnet_diagnostic.IDE1006.severity = warning # Embedded statements must be on their own line dotnet_diagnostic.IDE2001.severity = warning @@ -2638,7 +2643,7 @@ trim_trailing_whitespace = false # If it's a settings, model, dto, etc file, ignore the 'properties' cannot have arrays # and other annoying rules - the following section is duplicated -[**/*{ValueObjects,Settings,Options,Model,Models,DTO,Entity,Response,Request}.{cs,vb},] +[**/*{ValueObjects,Settings,Options,Model,Models,DTO,Entity,Response,Request}{.Nav,}.{cs,vb}] dotnet_diagnostic.CA1002.severity = none dotnet_diagnostic.CA1024.severity = none dotnet_diagnostic.CA1056.severity = none @@ -2671,17 +2676,14 @@ dotnet_diagnostic.PDS0004.severity = none [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; -# suppress the namespace-conflict diagnostics that arise from declaring framework-style namespaces. +# suppress only the namespace-conflict diagnostics that arise from declaring framework-style +# namespaces. Accessibility/design rules (for example CA1034) are deliberately not suppressed here. dotnet_diagnostic.IDE0130.severity = none -dotnet_diagnostic.CA1034.severity = none dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CS0436.severity = none dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.IDE0005.severity = none -[**/{Extension,Extensions}.{cs,vb}] -dotnet_diagnostic.CA1034.severity = none - [**/Generated/**/*.{cs,vb}] generated_code = true dotnet_diagnostic.CS8602.severity = none diff --git a/AGENTS.md b/AGENTS.md index 34a9451..066e5ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Purview.ZodSharp is a high-performance schema validation library for C#, ported - The project is maintained at `purview-dev/zodsharp`. - Public API namespaces are `ZodSharp.*`; packages and assemblies are published under the `Purview.ZodSharp.*` package IDs. -- Multi-targets `net8.0`, `net9.0` and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. +- Multi-targets `net8.0`, `net9.0`, `net10.0` and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. ## Repository layout diff --git a/Directory.Packages.props b/Directory.Packages.props index d3b5995..924a50f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,12 +24,13 @@ + - + ()` to validate the request DTO at the endpoint and short-circuit to a standard +validation-problem response, or `ToValidationProblem()` to return a `Results.ValidationProblem` `IResult` +directly from a handler: + +```csharp +using Microsoft.AspNetCore.Builder; +using ZodSharp; +using ZodSharp.AspNetCore; + +// Validate the bound request DTO automatically (requires AddZodSharp to register the validator): +app.MapPost("/users", (UserDto dto) => dto) + .WithZodSharpValidation(); + +// Or validate manually and return a minimal-API IResult: +app.MapPost("/users", (UserDto dto) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); +}); +``` + +`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type `T` +against the `IZodSchemaFactory` registered by `AddZodSharp` (or `AddZodSharpFactory`). On failure it returns +the same `HttpValidationProblemDetails` payload produced by `ToHttpValidationProblemDetails()`; on success it +passes through to the handler. `ToValidationProblem()` is the handler-side equivalent, returning a status-400 +`Results.ValidationProblem` `IResult`. + ## Exception handling A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 09a2b2f..421b5f7 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -5,7 +5,7 @@ Every schema derives from `ZodType` (namespace `ZodSharp.Core`). Validation is a two-phase pipeline: 1. **ParseInternal** — each schema overrides this hook to perform its type check and traversal (rejecting `null` where not allowed, coercing types, walking objects/arrays/tuples/unions, producing structured failures). -2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` with code `"validation_failed"`. +2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` carrying the rule's Zod-compatible code (`too_small`, `too_big`, `not_multiple_of`, `not_finite`, or `invalid_string`; `validation_failed` for custom rules without a code). ```csharp ValidationResult result = schema.Validate(value); diff --git a/docs/wiki/Fluent-Schema-API.md b/docs/wiki/Fluent-Schema-API.md index c0ea7ae..42dddfb 100644 --- a/docs/wiki/Fluent-Schema-API.md +++ b/docs/wiki/Fluent-Schema-API.md @@ -10,6 +10,8 @@ | `Number()` | `Z.Number()` | `ZodNumber` | | `Boolean()` | `Z.Boolean()` | `ZodBoolean` | | `Null()` | `Z.Null()` | `ZodNull` | +| `Date()` | `Z.Date()` | `ZodDate` | +| `BigInt()` | `Z.BigInt()` | `ZodBigInt` | | `Array` | `Z.Array(IZodSchema elementSchema)` | `ZodArray` | | `Optional` | `Z.Optional(IZodSchema schema)` — `T : class` | `ZodOptional` | | `Nullable` | `Z.Nullable(IZodSchema schema)` — `T : struct` | `ZodNullable` | diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index e557e15..6d25dbf 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -6,7 +6,7 @@ - **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. +- **Multi-targeting.** Packages target `net8.0`, `net9.0`, `net10.0`, and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. - **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 @@ -33,7 +33,7 @@ The generator reports `Origin = "string"` for string size failures, `Origin = "a ### Rule errors -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. +Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, and `invalid_string` for string-format validations. Custom rules that do not declare a code default to `validation_failed`. 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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 34d6d40..93ef651 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -51,9 +51,10 @@ This wiki is the project documentation hub for the core API, source generator, J - **Zero-allocation validation** — validation rules are `readonly record struct`s and hot paths use `Span`; every valid input path validates without allocating. - **Fluent API** — `Z.String().Min(3).Max(50).Email()`, composable objects, arrays, unions, tuples, records, discriminators, and more. +- **Native C# 15 unions (.NET 11+)** — `Z.NativeUnion` returns an allocation-free native union for reference-type cases, with exhaustive pattern matching; `Z.Union` stays the zero-allocation choice for value-type cases. The analyzer reports `ZODSGEN041` and offers a code fix. - **Structured issues** — failures carry machine-readable `Code`, `Path`, `Origin`, `Minimum`/`Maximum`, and `Inclusive` metadata in addition to a human message. - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. - **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`. - **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails). - **Cross-platform tests** — a shared TypeScript/Zod fixture set is generated into the repo and asserted against from both the C# test suite and a vitest suite. -- **Multi-target** — packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. \ No newline at end of file +- **Multi-target** — packages target `net8.0`, `net9.0`, `net10.0`, and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. \ No newline at end of file diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index 5f077c4..82e3509 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -15,6 +15,10 @@ var result = schema.Validate(30.0); |---|---|---| | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | +| `Gt` | `Gt(double value)` | `GreaterThanRule` — strictly greater than `value` | +| `Gte` | `Gte(double value)` | `MinValueRule` — greater than or equal to `value` | +| `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` | +| `Lte` | `Lte(double value)` | `MaxValueRule` — less than or equal to `value` | | `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` | | `Positive` | `Positive()` | `GreaterThanRule(0.0)` — strictly greater than zero | | `Negative` | `Negative()` | `LessThanRule(0.0)` — strictly less than zero | diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index 0f1b4be..ad9c7ba 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -1,6 +1,6 @@ # Source Generator Diagnostics -The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033` is a warning and the rest are errors. +The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` are warnings, `ZODSGEN041` is an informational suggestion, and the rest are errors. | ID | Meaning | |---|---| @@ -38,6 +38,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN038 | (warning) A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), so some usages of the attribute resolve to no rule | | ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity | | ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | +| ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md index fde1ebc..274df18 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -13,7 +13,7 @@ var result = schema.Validate("user@example.com"); | Method | Signature | Rule added | |---|---|---| -| `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` via `validation_failed` when too short | +| `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` when too short | | `Max` | `Max(int maxLength)` | `MaxLengthRule` | | `Length` | `Length(int length)` | exact length (both bounds) | | `Email` | `Email()` | `EmailRule` — static compiled regex | @@ -26,6 +26,18 @@ var result = schema.Validate("user@example.com"); | `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected | | `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison | | `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison | +| `Includes` | `Includes(string substring, string? message)` | `IncludesRule` — ordinal substring containment | +| `IP` | `IP(string? message)` | `IPAddressRule` — IPv4 or IPv6 address | +| `JWT` | `JWT(string? message)` | `JWTRule` — three base64url-encoded segments | +| `Hex` | `Hex(string? message)` | `HexRule` — hexadecimal characters (empty string is valid, matching Zod) | +| `Base64Url` | `Base64Url(string? message)` | `Base64UrlRule` — URL-safe base64, no padding (groups of 4 plus a 2-3 character tail) | +| `ULID` | `ULID(string? message)` | `ULIDRule` — 26 Crockford base32 characters, first character `0`-`7` | +| `Datetime` | `Datetime(string? message)` | `DatetimeStringRule` — ISO 8601 date-time (`yyyy-MM-ddTHH:mm:ss[.fff]Z`) | +| `Date` | `Date(string? message)` | `DateStringRule` — ISO 8601 date (`yyyy-MM-dd`) | +| `Time` | `Time(string? message)` | `TimeStringRule` — ISO 8601 time (`HH:mm`, optionally `:ss` and fractional seconds) | +| `Nanoid` | `Nanoid(string? message)` | `NanoidRule` — 21 URL-safe characters | +| `Cuid2` | `Cuid2(string? message)` | `Cuid2Rule` — lowercase alphanumeric characters | +| `E164` | `E164(string? message)` | `E164Rule` — `+` followed by 7-15 digits | | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | | `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) | | `Trim` | `Trim()` | wraps a transform (`Trim`) | @@ -59,7 +71,7 @@ var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); ## Error messages -Rules produce `ValidationError` entries with code `validation_failed` and an empty path. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. +Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. ## Span validation diff --git a/docs/wiki/Unions-and-Discriminated-Unions.md b/docs/wiki/Unions-and-Discriminated-Unions.md index 2455ef5..9cdbe30 100644 --- a/docs/wiki/Unions-and-Discriminated-Unions.md +++ b/docs/wiki/Unions-and-Discriminated-Unions.md @@ -41,6 +41,32 @@ The `Union<...>` value type (namespace `ZodSharp.Unions`) is the result of a typ - `Match(Func, Func)` and `Switch(Action, Action)`. - `==` / `!=`, `Equals`, `GetHashCode`, `ToString`. +## Native unions (.NET 11+) + +When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union +(`ZodSharp.Unions.NativeUnion`) instead of `Union`: + +```csharp +var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); +var result = schema.Validate("hello"); + +if (result.IsSuccess) +{ + var length = result.Value switch + { + string s => s.Length, + Dictionary o => o.Count, + _ => -1, + }; +} +``` + +Prefer this when **both option types are reference types**: the native union is allocation-free and +supports exhaustive pattern matching. Value-type cases box, so keep using `Z.Union` (the +hand-rolled `Union`) when a case is a value type, or when you need +`Tag`/`Match`/`Switch`/`TryGetValue`/equality. The analyzer reports `ZODSGEN041` (Info) and offers a +code fix when a reference-type-only `Z.Union` can be switched to `Z.NativeUnion`. + ## Discriminated union `ZodDiscriminatedUnion` dispatches on a discriminator value read from the input — a dictionary key or a public instance property — resolved case-insensitively. diff --git a/global.json b/global.json index 0961f09..c7ee220 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "allowPrerelease": false + "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0-prerelease.60" + "Purview.BuildSdk": "1.0.2.2" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/purview-build.json b/purview-build.json index e4e8798..4d182da 100644 --- a/purview-build.json +++ b/purview-build.json @@ -3,13 +3,14 @@ "Solution": "src/ZodSharp.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*" + "TestFilter": "/*/*/*/*[Category!=Unit]" }, "PackValidation": { "RequireSymbolPackage": true, "RequireSymbolFiles": true, "RequiredContent": { "purview.zodsharp": [ + "analyzers/dotnet/cs/Purview.ZodSharp.CodeFixes.dll", "analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll", "buildTransitive/Purview.ZodSharp.props", "lib/$(TFM)/Purview.ZodSharp.dll", @@ -43,4 +44,4 @@ "Release": { "Mode": "None" } -} \ No newline at end of file +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8e79a84..82a61d8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,7 @@ - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0;net11.0 + preview $(ZodSharpNetTargetFrameworks) ZodSharp @@ -10,11 +11,16 @@ - $(NoWarn);CA1031;CA1510; + $(NoWarn);CA1031;CA1307;CA1510; + + + + + $(ZodSharpNetTargetFrameworks) diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index f6e8183..e5db138 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -22,6 +22,7 @@ + diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs new file mode 100644 index 0000000..0f3dd81 --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using ZodSharp.AspNetCore; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Minimal API integration for ZodSharp validation. +/// +public static class ZodSharpRouteHandlerBuilderExtensions +{ + /// + /// Adds an endpoint filter that validates the first bound argument of type against + /// the registered , returning a validation-problem response when + /// validation fails. + /// + /// The request type to validate. + /// The route handler builder. + /// The builder for chaining. + public static RouteHandlerBuilder WithZodSharpValidation(this RouteHandlerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.AddEndpointFilter>(); + return builder; + } +} diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 638c853..d2f44fe 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -160,7 +160,7 @@ static IEnumerable GetAssembliesToScan( ZodSchemaFactoryConfiguration? configuration ) { - HashSet seenAssemblyNames = new(StringComparer.Ordinal); + HashSet seenAssemblyNames = [with(StringComparer.Ordinal)]; foreach (var assembly in configuration?.ScanAssemblies ?? []) if (TryMarkAssemblySeen(assembly, seenAssemblyNames)) @@ -202,7 +202,7 @@ HashSet seenAssemblyNames ) { Queue pendingAssemblies = new(rootAssemblies); - HashSet queuedAssemblies = new(StringComparer.Ordinal); + HashSet queuedAssemblies = [with(StringComparer.Ordinal)]; foreach (var assembly in pendingAssemblies) queuedAssemblies.Add(GetAssemblyIdentity(assembly)); diff --git a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs index ecd4c53..6ba41b1 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs @@ -88,6 +88,25 @@ public ValidationProblemDetails ToValidationProblemDetails( var details = result.ToHttpValidationProblemDetails(lookup, statusCode); return ToValidationProblemDetails(details); } + + /// + /// Converts a failed validation result into a minimal-API that renders the + /// standard ASP.NET Core validation-problem payload (status 400, errors keyed by path, and the + /// structured issues extension). + /// + /// An suitable for returning from a minimal-API handler. + public IResult ToValidationProblem() + { + var details = result.ToHttpValidationProblemDetails(); + return Results.ValidationProblem( + details.Errors, + detail: details.Detail, + instance: details.Instance, + title: details.Title, + type: details.Type, + extensions: details.Extensions + ); + } } static void EnsureFailed(ValidationResult result) diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs index 419bef5..c1f758a 100644 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ b/src/src/AspNetCore/ProblemDetailsMapper.cs @@ -22,7 +22,7 @@ bool formatMessages { var errorCount = errors.IsDefault ? 0 : errors.Length; - Dictionary> groupedMessages = new(StringComparer.Ordinal); + Dictionary> groupedMessages = [with(StringComparer.Ordinal)]; Dictionary errorDictionary = new(errorCount, StringComparer.Ordinal); var issues = new ValidationIssue[errorCount]; diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index c8cfef9..8ecbff1 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -38,6 +38,8 @@ A `ValidationProblemDetails` overload is also available: var problem = result.ToValidationProblemDetails(); ``` +For minimal APIs, `ToValidationProblem()` returns a `Results.ValidationProblem` `IResult` directly, and `WithZodSharpValidation()` validates the bound request DTO automatically (see the [ASP.NET Core Integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide). + ## Exception handling Thrown `ZodException`s (e.g. from a value object's strict deserialization) are converted automatically by diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs index 2792fde..9dbc184 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -19,7 +19,7 @@ namespace ZodSharp.AspNetCore; /// public sealed class ZodExceptionHandler : IExceptionHandler { - static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web); + static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); readonly ZodProblemDetailsOptions _options; @@ -53,7 +53,7 @@ CancellationToken cancellationToken httpContext.Response.StatusCode = problem.Status!.Value; await httpContext.Response.WriteAsJsonAsync( problem, - s_jsonOptions, + JsonOptions, "application/problem+json", cancellationToken ); diff --git a/src/src/AspNetCore/ZodValidationFilter.cs b/src/src/AspNetCore/ZodValidationFilter.cs new file mode 100644 index 0000000..d6bbf23 --- /dev/null +++ b/src/src/AspNetCore/ZodValidationFilter.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// An endpoint filter that validates the first bound argument of type against the +/// registered . When validation fails the request is short-circuited with a +/// standard validation-problem response. +/// +/// The request type to validate. +public sealed class ZodValidationFilter : IEndpointFilter +{ + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var factory = + context.HttpContext.RequestServices.GetService() + ?? throw new InvalidOperationException( + "No IZodSchemaFactory is registered. Call AddZodSharp (or AddZodSharpFactory) before applying WithZodSharpValidation." + ); + + foreach (var argument in context.Arguments) + { + if (argument is not T value) + continue; + + var validator = + factory.Resolve() + ?? throw new InvalidOperationException( + $"No Zod schema validator is registered for type '{typeof(T).FullName}'." + ); + + var result = await validator.ValidateAsync(value, context.HttpContext.RequestAborted); + if (!result.IsSuccess) + return result.ToValidationProblem(); + + break; + } + + return await next(context); + } +} diff --git a/src/src/Benchmarks/Benchmarks.csproj b/src/src/Benchmarks/Benchmarks.csproj index 3ea44c9..9afa044 100644 --- a/src/src/Benchmarks/Benchmarks.csproj +++ b/src/src/Benchmarks/Benchmarks.csproj @@ -1,6 +1,6 @@  - net10.0 + net10.0;net11.0 enable enable ZodSharp diff --git a/src/src/Benchmarks/Program.cs b/src/src/Benchmarks/Program.cs index fb053a2..c57b80e 100644 --- a/src/src/Benchmarks/Program.cs +++ b/src/src/Benchmarks/Program.cs @@ -3,7 +3,12 @@ Console.WriteLine("=== ZodSharp Performance Tests ===\n"); Console.WriteLine("Running performance benchmarks...\n"); -BenchmarkRunner.Run(typeof(Program).Assembly); +// Preserve the original run-all behaviour when no arguments are supplied (for `just perf-tests`), +// but honour BenchmarkDotNet arguments such as `--filter` when they are provided. +if (args.Length == 0) + BenchmarkRunner.Run(typeof(Program).Assembly); +else + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); Console.WriteLine("\n=== Performance Test Summary ==="); Console.WriteLine("Check the 'BenchmarkDotNet.Artifacts' folder for detailed results."); diff --git a/src/src/CodeFixes/CodeFixes.csproj b/src/src/CodeFixes/CodeFixes.csproj new file mode 100644 index 0000000..03df7ec --- /dev/null +++ b/src/src/CodeFixes/CodeFixes.csproj @@ -0,0 +1,17 @@ + + + netstandard2.0 + Purview.ZodSharp.CodeFixes + + true + false + + + + + + diff --git a/src/src/CodeFixes/NativeUnionCodeFixProvider.cs b/src/src/CodeFixes/NativeUnionCodeFixProvider.cs new file mode 100644 index 0000000..1da0212 --- /dev/null +++ b/src/src/CodeFixes/NativeUnionCodeFixProvider.cs @@ -0,0 +1,158 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZodSharp.CodeFixes; + +/// +/// Code fix for ZODSGEN041. Rewrites Z.Union<T1, T2>(...) to +/// Z.NativeUnion<T1, T2>(...) and new ZodTypedUnion<T1, T2>(...) to +/// new ZodTypedNativeUnion<T1, T2>(...), retargeting an explicit +/// ZodTypedUnion<...> declaration so the fixed code still compiles. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NativeUnionCodeFixProvider))] +[Shared] +public sealed class NativeUnionCodeFixProvider : CodeFixProvider +{ + /// The diagnostic id reported by the ZodSharp analyzer. + const string DiagnosticId = "ZODSGEN041"; + + const string Title = "Use native C# 15 union"; + + /// + public override ImmutableArray FixableDiagnosticIds => [DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + + var replacement = node switch + { + InvocationExpressionSyntax invocation when IsUnionMethod(invocation) => "NativeUnion", + ObjectCreationExpressionSyntax creation when IsTypedUnionType(creation.Type) => "ZodTypedNativeUnion", + _ => null, + }; + + if (replacement is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => RewriteAsync(context.Document, node, replacement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + + static bool IsUnionMethod(InvocationExpressionSyntax invocation) => + invocation.Expression + is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Union" } + or IdentifierNameSyntax { Identifier.ValueText: "Union" }; + + static bool IsTypedUnionType(TypeSyntax? type) => + type + is GenericNameSyntax { Identifier.ValueText: "ZodTypedUnion" } + or IdentifierNameSyntax { Identifier.ValueText: "ZodTypedUnion" }; + + static async Task RewriteAsync( + Document document, + SyntaxNode target, + string replacement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var renamed = target switch + { + InvocationExpressionSyntax invocation => invocation.WithExpression( + RenameExpression(invocation.Expression, replacement) + ), + ObjectCreationExpressionSyntax creation => creation.WithType(RenameType(creation.Type, replacement)), + _ => target, + }; + + var newRoot = root.ReplaceNode(target, renamed); + + // An explicit `ZodTypedUnion<...>` declaration must also move to `ZodTypedNativeUnion<...>`, + // otherwise the fixed assignment no longer compiles. The declared type precedes the + // invocation/creation, so its span is unaffected by the rename above. + var declarationType = FindDeclarationType(target); + if (declarationType is not null) + { + var declarationTypeInNewRoot = + newRoot.FindNode(declarationType.Span, getInnermostNodeForTie: true) as TypeSyntax; + + if (declarationTypeInNewRoot is not null && IsTypedUnionType(declarationTypeInNewRoot)) + { + newRoot = newRoot.ReplaceNode( + declarationTypeInNewRoot, + RenameType(declarationTypeInNewRoot, "ZodTypedNativeUnion") + ); + } + } + + return document.WithSyntaxRoot(newRoot); + } + + static TypeSyntax? FindDeclarationType(SyntaxNode target) + { + if ( + target.Ancestors().OfType().FirstOrDefault() is { } declaration + && IsTypedUnionType(declaration.Type) + ) + { + return declaration.Type; + } + + if ( + target.Ancestors().OfType().FirstOrDefault() is { } property + && IsTypedUnionType(property.Type) + ) + { + return property.Type; + } + + // Handle cases where the target is part of a field declaration + return null; + } + + static ExpressionSyntax RenameExpression(ExpressionSyntax expression, string replacement) => + expression switch + { + MemberAccessExpressionSyntax member => member.WithName(RenameSimpleName(member.Name, replacement)), + SimpleNameSyntax simple => RenameSimpleName(simple, replacement), + _ => expression, + }; + + static SimpleNameSyntax RenameSimpleName(SimpleNameSyntax name, string replacement) => + name switch + { + GenericNameSyntax generic => generic.WithIdentifier(SyntaxFactory.Identifier(replacement)), + _ => SyntaxFactory.IdentifierName(replacement), + }; + + static TypeSyntax RenameType(TypeSyntax type, string replacement) => + type switch + { + GenericNameSyntax generic => generic.WithIdentifier(SyntaxFactory.Identifier(replacement)), + _ => SyntaxFactory.IdentifierName(replacement), + }; +} diff --git a/src/src/Examples.CLI/User.cs b/src/src/Examples.CLI/User.cs index 39137f9..b3be04f 100644 --- a/src/src/Examples.CLI/User.cs +++ b/src/src/Examples.CLI/User.cs @@ -20,7 +20,7 @@ sealed class User partial class UserSchemaValidator { - public bool WasCalled; + public bool WasCalled { get; set; } [System.Diagnostics.CodeAnalysis.SuppressMessage( "Style", diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index b3dbef1..3aff5e3 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -9,3 +9,4 @@ | ZODSGEN038 | ZodSharp.SourceGenerator | Warning | A hand-authored rule attribute's [ZodRule] mapping does not address every rule declared under the name the attribute derives from | | ZODSGEN039 | ZodSharp.SourceGenerator | Warning | A rule accepts a code/origin constructor parameter without implementing IZodRule, so the value never reaches the reported error identity | | ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | +| ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | diff --git a/src/src/SourceGenerators/ErrorTypeMessageFormatAnalyzer.cs b/src/src/SourceGenerators/ErrorTypeMessageFormatAnalyzer.cs index cb309c6..c4f1d0a 100644 --- a/src/src/SourceGenerators/ErrorTypeMessageFormatAnalyzer.cs +++ b/src/src/SourceGenerators/ErrorTypeMessageFormatAnalyzer.cs @@ -21,14 +21,14 @@ public sealed class ErrorTypeMessageFormatAnalyzer : DiagnosticAnalyzer const string ErrorTypeMetadataName = "ZodSharp.Core.ErrorType"; - static readonly Regex s_placeholderRegex = new(@"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled); + static readonly Regex PlaceholderRegex = new(@"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled); - static readonly ImmutableArray s_supportedDiagnostics = + static readonly ImmutableArray SupportedDiagnosticsList = [ DiagnosticLibrary.MessageFormatPlaceholderNotDeclared, ]; - public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; + public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; public override void Initialize(AnalysisContext context) { @@ -70,7 +70,7 @@ static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSy if (location is null) return; - var placeholders = s_placeholderRegex + var placeholders = PlaceholderRegex .Matches(messageFormat) .Cast() .Select(static m => m.Groups[1].Value) @@ -154,7 +154,7 @@ initializer is ISimpleAssignmentOperation { case IArrayCreationOperation array when array.Initializer is not null: { - HashSet names = new(StringComparer.Ordinal); + HashSet names = [with(StringComparer.Ordinal)]; foreach (var element in array.Initializer.ElementValues) AddParameterName(element, names); @@ -163,7 +163,7 @@ initializer is ISimpleAssignmentOperation case ICollectionExpressionOperation collection: { - HashSet names = new(StringComparer.Ordinal); + HashSet names = [with(StringComparer.Ordinal)]; foreach (var element in collection.Elements) AddParameterName(element, names); diff --git a/src/src/SourceGenerators/ErrorTypePartialClassAnalyzer.cs b/src/src/SourceGenerators/ErrorTypePartialClassAnalyzer.cs index 8d68e6c..5a9a5ea 100644 --- a/src/src/SourceGenerators/ErrorTypePartialClassAnalyzer.cs +++ b/src/src/SourceGenerators/ErrorTypePartialClassAnalyzer.cs @@ -22,13 +22,13 @@ public sealed class ErrorTypePartialClassAnalyzer : DiagnosticAnalyzer const string ErrorTypeAttributeMetadataName = "ZodSharp.Core.ErrorTypeAttribute"; - static readonly ImmutableArray s_supportedDiagnostics = + static readonly ImmutableArray SupportedDiagnosticsList = [ DiagnosticLibrary.ErrorTypeContainingTypeNotPartial, DiagnosticLibrary.ErrorTypeFieldInvalid, ]; - public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; + public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; public override void Initialize(AnalysisContext context) { diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index bb1cfb0..d505375 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -481,7 +481,7 @@ INamedTypeSymbol ruleType if (attribute.NamedArguments.IsDefaultOrEmpty) return []; - HashSet consumed = new(StringComparer.OrdinalIgnoreCase); + HashSet consumed = [with(StringComparer.OrdinalIgnoreCase)]; foreach (var parameter in MappedParameters(ruleType)) consumed.Add(parameter.Name); @@ -677,7 +677,7 @@ out string? unmappedParameterName var validation = ValidationAttributeData.FromAttributeData(attribute); var positional = attribute.ConstructorArguments; - Dictionary named = new(StringComparer.OrdinalIgnoreCase); + Dictionary named = [with(StringComparer.OrdinalIgnoreCase)]; foreach (var pair in attribute.NamedArguments) named[pair.Key] = pair.Value; diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 9c54d8f..03fd264 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -390,4 +390,18 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// + /// ZODSGEN041: a typed union whose case types are all reference types can use the native C# 15 + /// union returned by Z.NativeUnion on net11+, which is allocation-free and supports + /// exhaustive pattern matching. Value-type cases are excluded because they box. + /// + public static readonly DiagnosticDescriptor NativeUnionRecommended = new( + id: "ZODSGEN041", + title: "Consider a native C# 15 union", + messageFormat: "'{0}' has only reference-type cases ({1}); on .NET 11+ 'Z.NativeUnion' returns a native union that is allocation-free and supports exhaustive pattern matching", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 45e6487..7d4c531 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; using ZodSharp.SourceGenerators.Helpers; using ZodSharp.SourceGenerators.Models; @@ -9,7 +10,7 @@ namespace ZodSharp.SourceGenerators; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer { - static readonly ImmutableArray s_supportedDiagnostics = + static readonly ImmutableArray SupportedDiagnosticsList = [ DiagnosticLibrary.InvalidLengthAttribute, DiagnosticLibrary.UnsupportedLengthAttributeTarget, @@ -40,9 +41,10 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.UnmappableCustomRuleArgument, DiagnosticLibrary.UnusedRuleAttributeArgument, DiagnosticLibrary.RuleAttributeWithoutSchema, + DiagnosticLibrary.NativeUnionRecommended, ]; - public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; + public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; public override void Initialize(AnalysisContext context) { @@ -61,6 +63,21 @@ public override void Initialize(AnalysisContext context) compilationContext.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Options.IValidateOptions`1") is not null; + // Native C# 15 unions are only meaningful when the referenced ZodSharp build exposes the + // native-union API, which only the net11.0+ assembly does. + var nativeUnionApi = compilationContext.Compilation.GetTypeByMetadataName("ZodSharp.Unions.NativeUnion`2"); + var zodTypedUnion = compilationContext.Compilation.GetTypeByMetadataName( + "ZodSharp.Schemas.ZodTypedUnion`2" + ); + if (nativeUnionApi is not null && zodTypedUnion is not null) + { + compilationContext.RegisterOperationAction( + operationContext => AnalyzeTypedUnionConstruction(operationContext, zodTypedUnion), + OperationKind.Invocation, + OperationKind.ObjectCreation + ); + } + ExternalSchemaResolver externalSchemas = new(compilationContext.Compilation); // Types that will receive a generated schema: [ZodSchema] roots plus, transitively, the complex @@ -350,4 +367,37 @@ string typeName ); } } + + /// + /// Reports ZODSGEN041 when a typed union is constructed whose option types are all + /// reference types, so the allocation-free native union is a viable alternative on net11+. + /// + static void AnalyzeTypedUnionConstruction(OperationAnalysisContext context, INamedTypeSymbol zodTypedUnion) + { + var resultType = context.Operation switch + { + IInvocationOperation invocation => invocation.Type, + IObjectCreationOperation creation => creation.Type, + _ => null, + }; + + if ( + resultType is not INamedTypeSymbol named + || named.TypeArguments.Length != 2 + || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, zodTypedUnion) + || !named.TypeArguments.All(static argument => argument.IsReferenceType) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.NativeUnionRecommended, + context.Operation.Syntax.GetLocation(), + named.Name, + string.Join(", ", named.TypeArguments.Select(static argument => argument.Name)) + ) + ); + } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index c71dfca..ebbda84 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -172,10 +172,7 @@ CancellationToken cancellationToken .. identityWarnings, ]; - return GeneratorResult.Create( - default(RuleAttributeGenerationModel), - propertyDiagnostics - ); + return GeneratorResult.Create(default, propertyDiagnostics); } properties.Add( @@ -321,10 +318,7 @@ AttributeData attribute return diagnostics is null ? default - : GeneratorResult.Create( - default(RuleAttributeGenerationModel), - diagnostics.ToImmutable() - ); + : GeneratorResult.Create(default, diagnostics.ToImmutable()); } /// diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs index 1d23b46..3dff02e 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs @@ -122,7 +122,7 @@ static bool TryBuildValueSetComparison( out string displayValues ) { - List comparisons = new(values.Count); + List comparisons = [with(values.Count)]; var propertyTypeReference = property.PropertyType.AsTypeReference(); var propertyTypeForComparer = property.CanBeNull ? propertyTypeReference.Nullable(writer) @@ -153,7 +153,7 @@ static string BuildValueListDisplay(ImmutableArray values) if (values.IsDefaultOrEmpty) return string.Empty; - List parts = new(values.Length); + List parts = [with(values.Length)]; for (var i = 0; i < values.Length; i++) { var value = values[i]; diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs index fe78982..56fbf37 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs @@ -21,8 +21,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .RegisterEmbeddedAttribute() .RegisterPostInitializationOutput(static ctx => { - foreach (var (HintName, SourceText) in AttributeGenHelper.GenerateMarkers()) - ctx.AddSource($"{HintName}.g.cs", SourceText); + foreach (var (hintName, sourceText) in AttributeGenHelper.GenerateMarkers()) + ctx.AddSource($"{hintName}.g.cs", sourceText); }); var generationValueProviders = SourceGenLibrary.GetGeneratorValueProviders(context); diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs index c8443e5..a4decb5 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs @@ -17,9 +17,7 @@ sealed class JsonSchemaNamingPolicy : JsonNamingPolicy }; public override string ConvertName(string name) => - KeywordNames.TryGetValue(name, out var keywordName) - ? keywordName - : JsonNamingPolicy.CamelCase.ConvertName(name); + KeywordNames.TryGetValue(name, out var keywordName) ? keywordName : CamelCase.ConvertName(name); } /// diff --git a/src/src/ZodSharp/Core/ErrorTypeParameters.cs b/src/src/ZodSharp/Core/ErrorTypeParameters.cs index 60e3407..5413569 100644 --- a/src/src/ZodSharp/Core/ErrorTypeParameters.cs +++ b/src/src/ZodSharp/Core/ErrorTypeParameters.cs @@ -16,7 +16,7 @@ namespace ZodSharp.Core; )] public sealed class ErrorTypeParameters : IReadOnlyDictionary { - static readonly IReadOnlyDictionary s_emptyDeclarations = + static readonly IReadOnlyDictionary EmptyDeclarations = new Dictionary(); readonly IReadOnlyDictionary _values; @@ -45,14 +45,14 @@ public static ErrorTypeParameters Create( { ArgumentNullException.ThrowIfNull(values); - Dictionary declarationMap = new(StringComparer.Ordinal); + Dictionary declarationMap = [with(StringComparer.Ordinal)]; if (declarations is not null) { foreach (var declaration in declarations) declarationMap[declaration.Name] = declaration; } - Dictionary valueMap = new(StringComparer.Ordinal); + Dictionary valueMap = [with(StringComparer.Ordinal)]; foreach (var pair in values) { if ( @@ -79,11 +79,11 @@ public static ErrorTypeParameters Create(IEnumerable valueMap = new(StringComparer.Ordinal); + Dictionary valueMap = [with(StringComparer.Ordinal)]; foreach (var pair in values) valueMap[pair.Key] = pair.Value; - return new ErrorTypeParameters(valueMap, s_emptyDeclarations); + return new ErrorTypeParameters(valueMap, EmptyDeclarations); } /// diff --git a/src/src/ZodSharp/Core/IStringValidationRule.cs b/src/src/ZodSharp/Core/IStringValidationRule.cs index c33a878..0d46d97 100644 --- a/src/src/ZodSharp/Core/IStringValidationRule.cs +++ b/src/src/ZodSharp/Core/IStringValidationRule.cs @@ -19,4 +19,10 @@ public interface IStringValidationRule /// The value that failed validation /// The error message string GetErrorMessage(ReadOnlySpan value); + + /// + /// Gets the Zod-compatible error code reported when the rule fails. Defaults to + /// "validation_failed". + /// + string Code => "validation_failed"; } diff --git a/src/src/ZodSharp/Core/IValidationRule.cs b/src/src/ZodSharp/Core/IValidationRule.cs index 76d7988..b24aeb7 100644 --- a/src/src/ZodSharp/Core/IValidationRule.cs +++ b/src/src/ZodSharp/Core/IValidationRule.cs @@ -20,4 +20,10 @@ public interface IValidationRule /// The value that failed validation /// The error message string GetErrorMessage(in T value); + + /// + /// Gets the Zod-compatible error code reported when the rule fails. Defaults to + /// "validation_failed". + /// + string Code => "validation_failed"; } diff --git a/src/src/ZodSharp/Core/ZodRuleAttribute.cs b/src/src/ZodSharp/Core/ZodRuleAttribute.cs index a880890..375b634 100644 --- a/src/src/ZodSharp/Core/ZodRuleAttribute.cs +++ b/src/src/ZodSharp/Core/ZodRuleAttribute.cs @@ -85,9 +85,9 @@ public ZodRuleAttribute() { } /// /// /// Only affects generated attributes; a hand-authored attribute declares its own - /// . Every application is emitted as its own + /// . Every application is emitted as its own /// validation, evaluated in source order. Defaults to , which is the - /// default. + /// default. /// public bool AllowMultiple { get; init; } } diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index 6f349fc..21be64b 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -51,7 +51,7 @@ public ValidationResult Validate(TInput value) if (!rule.IsValid(validatedValue)) { errors ??= [with(rulesCount)]; - errors.Add(new ValidationError("validation_failed", rule.GetErrorMessage(validatedValue), EmptyPath)); + errors.Add(new ValidationError(rule.Code, rule.GetErrorMessage(validatedValue), EmptyPath)); } } diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index e93f1d1..6d7b596 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -45,4 +45,7 @@ public bool IsValid(in string value) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => _message ?? $"Invalid Base64 string format: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs new file mode 100644 index 0000000..695266c --- /dev/null +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -0,0 +1,61 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for base64url (URL-safe base64) string format. +/// Uses struct to avoid allocations. +/// +public readonly record struct Base64UrlRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the Base64UrlRule struct. + /// + /// Optional error message + public Base64UrlRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a base64url string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a base64url string without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + // Zod base64url: groups of 4 URL-safe base64 characters, optionally followed by a 2-3 character + // tail. No padding is allowed. + if (value.Length % 4 == 1) + return false; + + foreach (var c in value) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_') + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid base64url string: {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 base64url string: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index 16f54da..d62d67a 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -83,4 +83,7 @@ public bool IsValid(ReadOnlySpan value) /// The error message public string GetErrorMessage(ReadOnlySpan value) => _message ?? $"Invalid credit card number format: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs new file mode 100644 index 0000000..bad78c9 --- /dev/null +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -0,0 +1,59 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for CUID2 format (lowercase alphanumeric characters). +/// Uses struct to avoid allocations. +/// +public readonly record struct Cuid2Rule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the Cuid2Rule struct. + /// + /// Optional error message + public Cuid2Rule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a CUID2. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a CUID2 without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty) + return false; + + foreach (var c in value) + { + if (!char.IsAsciiLetterLower(c) && !char.IsAsciiDigit(c)) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid CUID2: {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 CUID2: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs new file mode 100644 index 0000000..8cfa3cc --- /dev/null +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -0,0 +1,56 @@ +using System.Globalization; + +namespace ZodSharp.Rules; + +/// +/// Validation rule for ISO 8601 date string format (yyyy-MM-dd). +/// Uses struct to avoid allocations. +/// +public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the DateStringRule struct. + /// + /// Optional error message + public DateStringRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is an ISO 8601 date. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an ISO 8601 date without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty || value.IsWhiteSpace()) + return false; + + // Use DateOnly.TryParseExact to validate the date format without throwing exceptions + return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid date: {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 date: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs new file mode 100644 index 0000000..503b114 --- /dev/null +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -0,0 +1,110 @@ +using System.Globalization; + +namespace ZodSharp.Rules; + +/// +/// Validation rule for ISO 8601 date-time string format (Zod's z.string().datetime() default: a +/// calendar date, a T separator, seconds, optional fractional seconds, and a Z suffix). +/// Uses struct to avoid allocations. +/// +public readonly record struct DatetimeStringRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the DatetimeStringRule struct. + /// + /// Optional error message + public DatetimeStringRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is an ISO 8601 date-time. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an ISO 8601 date-time without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length < 20) + return false; + + if (value[^1] is not ('Z' or 'z')) + return false; + + if (value[10] is not ('T' or 't')) + return false; + + if ( + !DateOnly.TryParseExact(value[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _) + ) + { + return false; + } + + // Validate the time portion (HH:mm:ss[.fff]) with seconds required + return IsIsoTime(value[11..^1], secondsRequired: true); + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid date-time: {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 date-time: {value}"; + + static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) + { + if (value.Length < 5 || !IsTwoDigitNumber(value, 0, 24) || value[2] != ':' || !IsTwoDigitNumber(value, 3, 60)) + return false; + + if (value.Length == 5) + return !secondsRequired; + + if (value[5] != ':' || value.Length < 8 || !IsTwoDigitNumber(value, 6, 60)) + return false; + + if (value.Length == 8) + return true; + + if (value[8] != '.' || value.Length == 9) + return false; + + for (var i = 9; i < value.Length; i++) + { + if (!char.IsAsciiDigit(value[i])) + return false; + } + + return true; + } + + static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusive) + { + if (index + 1 >= value.Length) + return false; + + var a = value[index]; + var b = value[index + 1]; + if (!char.IsAsciiDigit(a) || !char.IsAsciiDigit(b)) + return false; + + var numeric = ((a - '0') * 10) + (b - '0'); + return numeric < maxExclusive; + } + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs new file mode 100644 index 0000000..305119b --- /dev/null +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -0,0 +1,63 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for E.164 phone number format (+ followed by 7-15 digits, first digit 1-9). +/// Uses struct to avoid allocations. +/// +public readonly record struct E164Rule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the E164Rule struct. + /// + /// Optional error message + public E164Rule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is an E.164 phone number. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an E.164 phone number without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + // E.164: '+' then 1-9 then 6-14 more digits (7-15 digits total). + if (value.Length is < 8 or > 16) + return false; + + if (value[0] is not '+' || value[1] is < '1' or > '9') + return false; + + for (var i = 2; i < value.Length; i++) + { + if (!char.IsAsciiDigit(value[i])) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid E.164 phone number: {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 E.164 phone number: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 22ea6e6..6e41b5d 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -44,4 +44,7 @@ namespace ZodSharp.Rules; /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => $"Invalid email format: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index f18d2c6..35aea96 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -49,4 +49,7 @@ public string GetErrorMessage(in string value) => /// The error message public string GetErrorMessage(ReadOnlySpan value) => _message ?? $"String must end with '{_suffix}', but got '{value}'"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index 3742653..e787044 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -30,4 +30,7 @@ public FiniteRule(string? message = null) /// The value that failed validation /// The error message public string GetErrorMessage(in double value) => _message ?? $"Number must be finite, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "not_finite"; } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index bc6937a..e581ad0 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -32,4 +32,7 @@ public GreaterThanRule(T exclusiveMinimum) /// The value that failed validation /// The error message public string GetErrorMessage(in T value) => $"Value must be greater than {_exclusiveMinimum}, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_small"; } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs new file mode 100644 index 0000000..fdb7825 --- /dev/null +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -0,0 +1,56 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for hexadecimal string format. +/// Uses struct to avoid allocations. +/// +public readonly record struct HexRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the HexRule struct. + /// + /// Optional error message + public HexRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a hexadecimal string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a hexadecimal string without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + foreach (var c in value) + { + if (!char.IsAsciiHexDigit(c)) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid hexadecimal string: {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 hexadecimal string: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs new file mode 100644 index 0000000..8f47f54 --- /dev/null +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Sockets; + +namespace ZodSharp.Rules; + +/// +/// Validation rule for IPv4 and IPv6 address format. +/// Uses struct to avoid allocations. +/// +public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the IPAddressRule struct. + /// + /// Optional error message + public IPAddressRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is an IPv4 or IPv6 address. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an IPv4 or IPv6 address without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty || value.IsWhiteSpace()) + return false; + + // Use IPAddress.TryParse to validate the IP address format without throwing exceptions + return IPAddress.TryParse(value, out var address) + && address.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid IP address: {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 IP address: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs new file mode 100644 index 0000000..ddf1fcf --- /dev/null +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -0,0 +1,55 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for string containment. +/// Uses struct to avoid allocations. +/// +public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string _substring; + readonly string? _message; + + /// + /// Initializes a new instance of the IncludesRule struct. + /// + /// The required substring + /// Optional error message + public IncludesRule(string substring, string? message = null) + { + _substring = substring ?? throw new ArgumentNullException(nameof(substring)); + _message = message.OrNull(); + } + + /// + /// Validates that the value contains the configured substring. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && value.Contains(_substring, StringComparison.Ordinal); + + /// + /// Validates that the span contains the configured substring without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Contains(_substring.AsSpan(), StringComparison.Ordinal); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => + _message ?? $"String must contain '{_substring}', 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 contain '{_substring}', but got '{value}'"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs new file mode 100644 index 0000000..f72192d --- /dev/null +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -0,0 +1,79 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for JSON Web Token (JWT) format: three base64url-encoded segments separated by periods. +/// Uses struct to avoid allocations. +/// +public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the JWTRule struct. + /// + /// Optional error message + public JWTRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a JWT. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a JWT without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty || value.IsWhiteSpace()) + return false; + + var firstDot = value.IndexOf('.'); + if (firstDot <= 0) + return false; + + var secondDot = value[(firstDot + 1)..].IndexOf('.'); + if (secondDot <= 0) + return false; + + secondDot += firstDot + 1; + + return IsBase64Url(value[..firstDot]) + && IsBase64Url(value[(firstDot + 1)..secondDot]) + && IsBase64Url(value[(secondDot + 1)..]); + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid JWT: {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 JWT: {value}"; + + static bool IsBase64Url(ReadOnlySpan segment) + { + if (segment.IsEmpty) + return false; + + foreach (var c in segment) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_') + return false; + } + + return true; + } + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index b005a9c..0f2c3be 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -32,4 +32,7 @@ public LessThanRule(T exclusiveMaximum) /// The value that failed validation /// The error message public string GetErrorMessage(in T value) => $"Value must be less than {_exclusiveMaximum}, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_big"; } diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index de344f3..0014a36 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -46,4 +46,7 @@ public string GetErrorMessage(in string value) => /// The error message public string GetErrorMessage(ReadOnlySpan value) => $"String must be at most {_maxLength} characters long, but got {value.Length}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_big"; } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index a98c436..0e6f4f4 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -32,4 +32,7 @@ public MaxValueRule(T maxValue) /// The value that failed validation /// The error message public string GetErrorMessage(in T value) => $"Value must be at most {_maxValue}, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_big"; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 4943baa..0773242 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -46,4 +46,7 @@ public string GetErrorMessage(in string value) => /// The error message public string GetErrorMessage(ReadOnlySpan value) => $"String must be at least {_minLength} characters long, but got {value.Length}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_small"; } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index b7d1c6a..cfd06d4 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -32,4 +32,7 @@ public MinValueRule(T minValue) /// The value that failed validation /// The error message public string GetErrorMessage(in T value) => $"Value must be at least {_minValue}, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "too_small"; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index f277f83..8d66cca 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -54,4 +54,7 @@ public bool IsValid(in double value) /// The error message public string GetErrorMessage(in double value) => _message ?? $"Number must be a multiple of {_divisor}, but got {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "not_multiple_of"; } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs new file mode 100644 index 0000000..35944af --- /dev/null +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -0,0 +1,59 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for nanoid format (21 URL-safe characters). +/// Uses struct to avoid allocations. +/// +public readonly record struct NanoidRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the NanoidRule struct. + /// + /// Optional error message + public NanoidRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a nanoid. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a nanoid without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length != 21) + return false; + + foreach (var c in value) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_') + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid nanoid: {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 nanoid: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index c6c8468..cf9dc4e 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -69,4 +69,7 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => _message ?? $"Invalid phone number format: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 63746a5..ce80859 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -58,4 +58,7 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessage(value.ToString()); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 0a13389..4c329dc 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -49,4 +49,7 @@ public string GetErrorMessage(in string value) => /// The error message public string GetErrorMessage(ReadOnlySpan value) => _message ?? $"String must start with '{_prefix}', but got '{value}'"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs new file mode 100644 index 0000000..4a83d63 --- /dev/null +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -0,0 +1,95 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for ISO 8601 time string format (HH:MM, optionally with :SS and +/// fractional seconds). +/// Uses struct to avoid allocations. +/// +public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the TimeStringRule struct. + /// + /// Optional error message + public TimeStringRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is an ISO 8601 time. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an ISO 8601 time without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty || value.IsWhiteSpace()) + return false; + + // Validate the time portion (HH:mm[:ss[.fff]]) with seconds optional + return IsIsoTime(value, secondsRequired: false); + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid time: {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 time: {value}"; + + static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) + { + if (value.Length < 5 || !IsTwoDigitNumber(value, 0, 24) || value[2] != ':' || !IsTwoDigitNumber(value, 3, 60)) + return false; + + if (value.Length == 5) + return !secondsRequired; + + if (value[5] != ':' || value.Length < 8 || !IsTwoDigitNumber(value, 6, 60)) + return false; + + if (value.Length == 8) + return true; + + if (value[8] != '.' || value.Length == 9) + return false; + + for (var i = 9; i < value.Length; i++) + { + if (!char.IsAsciiDigit(value[i])) + return false; + } + + return true; + } + + static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusive) + { + if (index + 1 >= value.Length) + return false; + + var a = value[index]; + var b = value[index + 1]; + if (!char.IsAsciiDigit(a) || !char.IsAsciiDigit(b)) + return false; + + var numeric = ((a - '0') * 10) + (b - '0'); + return numeric < maxExclusive; + } + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs new file mode 100644 index 0000000..54f50c9 --- /dev/null +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -0,0 +1,80 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for ULID format: 26 Crockford base32 characters. +/// Uses struct to avoid allocations. +/// +public readonly record struct ULIDRule : Core.IValidationRule, Core.IStringValidationRule +{ + readonly string? _message; + + /// + /// Initializes a new instance of the ULIDRule struct. + /// + /// Optional error message + public ULIDRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is a ULID. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a ULID without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length != 26) + return false; + + // The first character encodes the 48-bit timestamp, so it is always 0-7 (Zod parity). + if (value[0] is < '0' or > '7') + return false; + + foreach (var c in value) + { + if (!IsCrockfordBase32(c)) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => _message ?? $"Invalid ULID: {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 ULID: {value}"; + + static bool IsCrockfordBase32(char c) => + c switch + { + >= '0' and <= '9' => true, + >= 'A' and <= 'H' => true, + >= 'J' and <= 'K' => true, + >= 'M' and <= 'N' => true, + >= 'P' and <= 'T' => true, + >= 'V' and <= 'Z' => true, + >= 'a' and <= 'h' => true, + >= 'j' and <= 'k' => true, + >= 'm' and <= 'n' => true, + >= 'p' and <= 't' => true, + >= 'v' and <= 'z' => true, + _ => false, + }; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; +} diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 8635a30..e9be87f 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -51,4 +51,7 @@ public bool IsValid(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => _message ?? $"Invalid URL format: {value}"; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 36e9330..66a8f68 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -120,4 +120,7 @@ static bool HasValidStructure(ReadOnlySpan value) } static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => "invalid_string"; } diff --git a/src/src/ZodSharp/Schemas/ZodBigInt.cs b/src/src/ZodSharp/Schemas/ZodBigInt.cs new file mode 100644 index 0000000..624c91a --- /dev/null +++ b/src/src/ZodSharp/Schemas/ZodBigInt.cs @@ -0,0 +1,134 @@ +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Schemas; + +/// +/// Schema for 64-bit integer (bigint) validation. +/// Equivalent to Zod's z.bigint(). +/// +public class ZodBigInt : ZodType +{ + /// + /// Parses and validates a 64-bit integer value. + /// + /// The value to validate + /// A validation result + protected override ValidationResult ParseInternal(long value) => ValidationResult.Success(value); + + /// + /// Adds a minimum value validation. + /// Equivalent to Zod's z.bigint().min(value). + /// + /// The minimum value (inclusive) + /// This schema for method chaining + public ZodBigInt Min(long minValue) + { + AddRule(new MinValueRule(minValue)); + return this; + } + + /// + /// Adds a maximum value validation. + /// Equivalent to Zod's z.bigint().max(value). + /// + /// The maximum value (inclusive) + /// This schema for method chaining + public ZodBigInt Max(long maxValue) + { + AddRule(new MaxValueRule(maxValue)); + return this; + } + + /// + /// Adds a strictly-greater-than validation. + /// Equivalent to Zod's z.bigint().gt(value). + /// + /// The exclusive lower bound + /// This schema for method chaining + public ZodBigInt Gt(long value) + { + AddRule(new GreaterThanRule(value)); + return this; + } + + /// + /// Adds a greater-than-or-equal validation. + /// Equivalent to Zod's z.bigint().gte(value). + /// + /// The inclusive lower bound + /// This schema for method chaining + public ZodBigInt Gte(long value) + { + AddRule(new MinValueRule(value)); + return this; + } + + /// + /// Adds a strictly-less-than validation. + /// Equivalent to Zod's z.bigint().lt(value). + /// + /// The exclusive upper bound + /// This schema for method chaining + public ZodBigInt Lt(long value) + { + AddRule(new LessThanRule(value)); + return this; + } + + /// + /// Adds a less-than-or-equal validation. + /// Equivalent to Zod's z.bigint().lte(value). + /// + /// The inclusive upper bound + /// This schema for method chaining + public ZodBigInt Lte(long value) + { + AddRule(new MaxValueRule(value)); + return this; + } + + /// + /// Adds a strictly positive validation (value must be greater than zero). + /// Equivalent to Zod's z.bigint().positive(). + /// + /// This schema for method chaining + public ZodBigInt Positive() + { + AddRule(new GreaterThanRule(0)); + return this; + } + + /// + /// Adds a strictly negative validation (value must be less than zero). + /// Equivalent to Zod's z.bigint().negative(). + /// + /// This schema for method chaining + public ZodBigInt Negative() + { + AddRule(new LessThanRule(0)); + return this; + } + + /// + /// Adds a non-negative validation (value must be greater than or equal to zero). + /// Equivalent to Zod's z.bigint().nonnegative(). + /// + /// This schema for method chaining + public ZodBigInt NonNegative() + { + AddRule(new MinValueRule(0)); + return this; + } + + /// + /// Adds a non-positive validation (value must be less than or equal to zero). + /// Equivalent to Zod's z.bigint().nonpositive(). + /// + /// This schema for method chaining + public ZodBigInt NonPositive() + { + AddRule(new MaxValueRule(0)); + return this; + } +} diff --git a/src/src/ZodSharp/Schemas/ZodDate.cs b/src/src/ZodSharp/Schemas/ZodDate.cs new file mode 100644 index 0000000..d772fbf --- /dev/null +++ b/src/src/ZodSharp/Schemas/ZodDate.cs @@ -0,0 +1,43 @@ +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Schemas; + +/// +/// Schema for date validation. +/// Equivalent to Zod's z.date(). +/// +public class ZodDate : ZodType +{ + /// + /// Parses and validates a date value. + /// + /// The value to validate + /// A validation result + protected override ValidationResult ParseInternal(DateTime value) => + ValidationResult.Success(value); + + /// + /// Adds a minimum date validation. + /// Equivalent to Zod's z.date().min(value). + /// + /// The earliest allowed date (inclusive) + /// This schema for method chaining + public ZodDate Min(DateTime minValue) + { + AddRule(new MinValueRule(minValue)); + return this; + } + + /// + /// Adds a maximum date validation. + /// Equivalent to Zod's z.date().max(value). + /// + /// The latest allowed date (inclusive) + /// This schema for method chaining + public ZodDate Max(DateTime maxValue) + { + AddRule(new MaxValueRule(maxValue)); + return this; + } +} diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index 33678ca..9c5e470 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -45,6 +45,54 @@ public ZodNumber Max(double maxValue) return this; } + /// + /// Adds a strictly-greater-than validation. + /// Equivalent to Zod's z.number().gt(value). + /// + /// The exclusive lower bound + /// This schema for method chaining + public ZodNumber Gt(double value) + { + AddRule(new GreaterThanRule(value)); + return this; + } + + /// + /// Adds a greater-than-or-equal validation. + /// Equivalent to Zod's z.number().gte(value). + /// + /// The inclusive lower bound + /// This schema for method chaining + public ZodNumber Gte(double value) + { + AddRule(new MinValueRule(value)); + return this; + } + + /// + /// Adds a strictly-less-than validation. + /// Equivalent to Zod's z.number().lt(value). + /// + /// The exclusive upper bound + /// This schema for method chaining + public ZodNumber Lt(double value) + { + AddRule(new LessThanRule(value)); + return this; + } + + /// + /// Adds a less-than-or-equal validation. + /// Equivalent to Zod's z.number().lte(value). + /// + /// The inclusive upper bound + /// This schema for method chaining + public ZodNumber Lte(double value) + { + AddRule(new MaxValueRule(value)); + return this; + } + /// /// Adds an integer validation (must be a whole number). /// diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 51bc6a0..351e484 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -105,7 +105,7 @@ public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray(); - builder.Add(new ValidationError("validation_failed", rule.GetErrorMessage(value), EmptyPath)); + builder.Add(new ValidationError(rule.Code, rule.GetErrorMessage(value), EmptyPath)); } if (builder is null) @@ -282,6 +282,156 @@ public ZodString EndsWith(string suffix, string? message = null) return this; } + /// + /// Adds a validation that the string contains the specified substring. + /// Equivalent to Zod's z.string().includes(value). + /// + /// The required substring + /// Optional error message + /// This schema for method chaining + public ZodString Includes(string substring, string? message = null) + { + AddRule(new IncludesRule(substring, message)); + return this; + } + + /// + /// Adds an IPv4 or IPv6 address format validation. + /// Equivalent to Zod's z.string().ip(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString IP(string? message = null) + { + AddRule(new IPAddressRule(message)); + return this; + } + + /// + /// Adds a JSON Web Token (JWT) format validation: three base64url-encoded segments separated by periods. + /// Equivalent to Zod's z.string().jwt(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString JWT(string? message = null) + { + AddRule(new JWTRule(message)); + return this; + } + + /// + /// Adds a hexadecimal string format validation. + /// Equivalent to Zod's z.string().hex(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Hex(string? message = null) + { + AddRule(new HexRule(message)); + return this; + } + + /// + /// Adds a base64url (URL-safe base64) string format validation. + /// Equivalent to Zod's z.string().base64url(). + /// + /// Optional error message + /// This schema for method chaining + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Naming", + "PDS0004:Use correct acronym capitalization", + Justification = "Name matches Zod's base64url() method." + )] + public ZodString Base64Url(string? message = null) + { + AddRule(new Base64UrlRule(message)); + return this; + } + + /// + /// Adds a ULID format validation (26 Crockford base32 characters). + /// Equivalent to Zod's z.string().ulid(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString ULID(string? message = null) + { + AddRule(new ULIDRule(message)); + return this; + } + + /// + /// Adds an ISO 8601 date-time format validation. + /// Equivalent to Zod's z.string().datetime(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Datetime(string? message = null) + { + AddRule(new DatetimeStringRule(message)); + return this; + } + + /// + /// Adds an ISO 8601 date (yyyy-MM-dd) format validation. + /// Equivalent to Zod's z.string().date(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Date(string? message = null) + { + AddRule(new DateStringRule(message)); + return this; + } + + /// + /// Adds an ISO 8601 time format validation. + /// Equivalent to Zod's z.string().time(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Time(string? message = null) + { + AddRule(new TimeStringRule(message)); + return this; + } + + /// + /// Adds a nanoid format validation (21 URL-safe characters). + /// Equivalent to Zod's z.string().nanoid(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Nanoid(string? message = null) + { + AddRule(new NanoidRule(message)); + return this; + } + + /// + /// Adds a CUID2 format validation (lowercase alphanumeric characters). + /// Equivalent to Zod's z.string().cuid2(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString Cuid2(string? message = null) + { + AddRule(new Cuid2Rule(message)); + return this; + } + + /// + /// Adds an E.164 phone number format validation (+ followed by 7-15 digits). + /// Equivalent to Zod's z.string().e164(). + /// + /// Optional error message + /// This schema for method chaining + public ZodString E164(string? message = null) + { + AddRule(new E164Rule(message)); + return this; + } + /// /// Transforms the string to lowercase. /// diff --git a/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs new file mode 100644 index 0000000..a583b4f --- /dev/null +++ b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs @@ -0,0 +1,54 @@ +#if NET11_0_OR_GREATER +using ZodSharp.Core; +using ZodSharp.Unions; + +namespace ZodSharp.Schemas; + +/// +/// Schema for a typed union of two schemas that yields a native C# 15 +/// on success. +/// +/// The first option's type. +/// The second option's type. +/// +/// Only available when targeting net11.0 or later. Prefer this over +/// when both option types are reference types: the native union +/// is allocation-free and supports exhaustive pattern matching, whereas value-type cases box. +/// +/// The first option schema. +/// The second option schema. +public class ZodTypedNativeUnion(IZodSchema option1, IZodSchema option2) + : ZodType, object> +{ + /// + /// Validates the value against each option, returning the first match as a + /// . + /// + /// The value to validate. + /// A validation result containing a native union or errors. + protected override ValidationResult> ParseInternal(object value) + { + if (value is T1 typed1) + { + var result = option1.Validate(typed1); + if (result.IsSuccess) + return ValidationResult>.Success(result.Value); + } + + if (value is T2 typed2) + { + var result = option2.Validate(typed2); + if (result.IsSuccess) + return ValidationResult>.Success(result.Value); + } + + return ValidationResult>.Failure( + new ValidationError( + "invalid_union", + $"Value does not match any of the union options ({typeof(T1).Name}, {typeof(T2).Name})", + [] + ) + ); + } +} +#endif diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 033513d..32359fa 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -39,6 +39,27 @@ var userSchema = Z.Object() .Build(); ``` +## Native unions (.NET 11+) + +When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union instead of the hand-rolled `ZodSharp.Unions.Union`: + +```csharp +var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); +var result = schema.Validate("hello"); + +if (result.IsSuccess) +{ + var length = result.Value switch + { + string s => s.Length, + Dictionary o => o.Count, + _ => -1, + }; +} +``` + +Use it when **both option types are reference types**: the native union is allocation-free and supports exhaustive pattern matching. Value-type cases box, so keep `Z.Union` when a case is a value type, or when you need `Tag`/`Match`/`Switch`/`TryGetValue`/equality. The analyzer reports `ZODSGEN041` and offers a code fix when a reference-type-only `Z.Union` can be switched to `Z.NativeUnion`. + ## Source generator Mark a class, struct, or record with `[ZodSchema]` and a zero-allocation validator is generated at compile time: diff --git a/src/src/ZodSharp/Unions/NativeUnion.cs b/src/src/ZodSharp/Unions/NativeUnion.cs new file mode 100644 index 0000000..41389dc --- /dev/null +++ b/src/src/ZodSharp/Unions/NativeUnion.cs @@ -0,0 +1,21 @@ +#if NET11_0_OR_GREATER +#pragma warning disable CA1815 // Native unions do not synthesize equality; the schema result does not need it. +namespace ZodSharp.Unions; + +/// +/// A native C# 15 union of two types, produced by Z.NativeUnion<T1, T2>. +/// +/// The first case type. +/// The second case type. +/// +/// +/// Only available when targeting net11.0 or later, where the C# 15 union keyword and +/// exist. The compiler lowers this to a struct +/// with a single backing field: reference-type cases are allocation-free, while +/// value-type cases box. Prefer the hand-rolled when a case is a value +/// type, or when Tag/Match/Switch/equality are required. +/// +/// +public readonly union NativeUnion(T1, T2); +#pragma warning restore CA1815 +#endif diff --git a/src/src/ZodSharp/Z.cs b/src/src/ZodSharp/Z.cs index fda3b88..f2be543 100644 --- a/src/src/ZodSharp/Z.cs +++ b/src/src/ZodSharp/Z.cs @@ -31,6 +31,18 @@ public static class Z /// public static ZodNull Null() => new(); + /// + /// Creates a date schema. + /// Equivalent to Zod's z.date(). + /// + public static ZodDate Date() => new(); + + /// + /// Creates a 64-bit integer (bigint) schema. + /// Equivalent to Zod's z.bigint(). + /// + public static ZodBigInt BigInt() => new(); + /// /// Creates an array schema for the specified element type. /// @@ -69,6 +81,23 @@ public static ZodNullable Nullable(IZodSchema schema) public static ZodTypedUnion Union(IZodSchema option1, IZodSchema option2) => new(option1, option2); +#if NET11_0_OR_GREATER + /// + /// Creates a typed union schema of two options that yields a native C# 15 + /// on success. Equivalent to + /// but allocation-free for reference-type cases and pattern-matchable. Only available on + /// net11.0 and later. + /// + /// The first option's type. + /// The second option's type. + /// The first option schema. + /// The second option schema. + public static ZodTypedNativeUnion NativeUnion( + IZodSchema option1, + IZodSchema option2 + ) => new(option1, option2); +#endif + /// /// Creates an intersection schema requiring both and /// to pass. Equivalent to Zod's diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index fb5be3f..7b5a29d 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -17,7 +17,7 @@ GASF-based analyzer packing (which would also ship the loose framework DLL) is disabled. --> false - $(TargetsForTfmSpecificContentInPackage);PackZodSchemaGenerator + $(TargetsForTfmSpecificContentInPackage);PackZodSchemaGenerator;PackZodSharpCodeFixes @@ -56,4 +56,25 @@ + + + + + + + + + analyzers/dotnet/cs/ + + + diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs b/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs new file mode 100644 index 0000000..ff1d42a --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ZodSharp.AspNetCore.Fixtures; + +[ZodSchema] +public class ValidatedRequest +{ + [Required] + [MinLength(3)] + public string? Name { get; set; } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs new file mode 100644 index 0000000..cf16ce0 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore.Fixtures; + +namespace ZodSharp.AspNetCore; + +public class ZodValidationFilterTests +{ + const string NextResult = "next-ran"; + + [Test] + public async Task InvokeAsync_GivenValidRequest_InvokesNext() + { + var context = CreateContext(CreateProvider(), new ValidatedRequest { Name = "abc" }); + + var result = await new ZodValidationFilter().InvokeAsync( + context, + static _ => ValueTask.FromResult(NextResult) + ); + + await Assert.That(result).IsEqualTo(NextResult); + } + + [Test] + public async Task InvokeAsync_GivenInvalidRequest_ReturnsValidationProblem() + { + var context = CreateContext(CreateProvider(), new ValidatedRequest { Name = "a" }); + + var result = await new ZodValidationFilter().InvokeAsync( + context, + static _ => ValueTask.FromResult(NextResult) + ); + + await Assert.That(result is IResult).IsTrue(); + } + + [Test] + public async Task InvokeAsync_GivenNoFactoryRegistered_Throws() + { + ServiceCollection services = new(); + var context = CreateContext(services.BuildServiceProvider(), new ValidatedRequest { Name = "abc" }); + + InvalidOperationException? exception = null; + try + { + await new ZodValidationFilter().InvokeAsync( + context, + static _ => ValueTask.FromResult(NextResult) + ); + } + catch (InvalidOperationException ex) + { + exception = ex; + } + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task InvokeAsync_GivenNoValidatorForType_Throws() + { + ServiceCollection services = new(); + services.AddZodSharp(); + var context = CreateContext(services.BuildServiceProvider(), new ValidatedRequest { Name = "abc" }); + + InvalidOperationException? exception = null; + try + { + await new ZodValidationFilter().InvokeAsync( + context, + static _ => ValueTask.FromResult(NextResult) + ); + } + catch (InvalidOperationException ex) + { + exception = ex; + } + + await Assert.That(exception).IsNotNull(); + } + + static ServiceProvider CreateProvider() + { + ServiceCollection services = new(); + services.AddZodSharp(static opts => opts.ScanAssemblies.Add(typeof(ValidatedRequest).Assembly)); + return services.BuildServiceProvider(); + } + + static EndpointFilterInvocationContext CreateContext(IServiceProvider provider, object? argument) + { + DefaultHttpContext httpContext = new() { RequestServices = provider }; + return EndpointFilterInvocationContext.Create(httpContext, argument); + } +} diff --git a/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs index b2f5326..b9f4951 100644 --- a/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs +++ b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs @@ -38,7 +38,7 @@ public async Task ImportTypes_GivenBothJsonPackagesReferenced_ExposeDistinctFull { // Arrange var newtonsoftParser = typeof(FromJsonSchemaParser); - var systemTextJsonParser = typeof(ZodSharp.JsonSchema.SystemTextJson.FromJsonSchemaParser); + var systemTextJsonParser = typeof(JsonSchema.SystemTextJson.FromJsonSchemaParser); // Act var newtonsoftFullName = newtonsoftParser.FullName; @@ -55,7 +55,7 @@ public async Task JsonSchemaSerializerOptions_GivenBothJsonPackagesReferenced_Bi { // Arrange var newtonsoftOptions = JsonSchemaSerializerOptions.Default; - var systemTextJsonOptions = ZodSharp.JsonSchema.SystemTextJson.JsonSchemaSerializerOptions.Default; + var systemTextJsonOptions = JsonSchema.SystemTextJson.JsonSchemaSerializerOptions.Default; // Act var newtonsoftOptionsType = newtonsoftOptions.GetType(); diff --git a/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs index e8bd2df..5d56860 100644 --- a/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs +++ b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs @@ -40,7 +40,7 @@ public async Task ImportTypes_GivenBothJsonPackagesReferenced_ExposeDistinctFull { // Arrange var systemTextJsonParser = typeof(FromJsonSchemaParser); - var newtonsoftParser = typeof(ZodSharp.JsonSchema.NewtonsoftJson.FromJsonSchemaParser); + var newtonsoftParser = typeof(JsonSchema.NewtonsoftJson.FromJsonSchemaParser); // Act var systemTextJsonFullName = systemTextJsonParser.FullName; @@ -57,7 +57,7 @@ public async Task JsonSchemaSerializerOptions_GivenBothJsonPackagesReferenced_Bi { // Arrange var systemTextJsonOptions = JsonSchemaSerializerOptions.Default; - var newtonsoftOptions = ZodSharp.JsonSchema.NewtonsoftJson.JsonSchemaSerializerOptions.Default; + var newtonsoftOptions = JsonSchema.NewtonsoftJson.JsonSchemaSerializerOptions.Default; // Act var systemTextJsonOptionsType = systemTextJsonOptions.GetType(); diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index bc23db6..440b1bb 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -65,7 +65,14 @@ public partial class AnalyzerReleaseTrackingTests /// Rules added since the last stable release. Each identifier must also appear in /// AnalyzerReleases.Unshipped.md so the Roslyn release-tracking analyzers stay satisfied. /// - static readonly string[] ExpectedUnshippedRuleIds = ["ZODSGEN037", "ZODSGEN038", "ZODSGEN039", "ZODSGEN040"]; + static readonly string[] ExpectedUnshippedRuleIds = + [ + "ZODSGEN037", + "ZODSGEN038", + "ZODSGEN039", + "ZODSGEN040", + "ZODSGEN041", + ]; [Test] public async Task ShippedReleases_GivenStableRelease_DeclareRelease2_0_0WithEveryDiagnosticId( diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestBase.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestBase.cs new file mode 100644 index 0000000..c2c9804 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestBase.cs @@ -0,0 +1,9 @@ +using ZodSharp.CodeFixes; + +namespace ZodSharp.SourceGenerators.Infra; + +public abstract class ZodCodeFixTestBase + : TUnitCodeFixTestBase +{ + // Empty +} diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs new file mode 100644 index 0000000..edf6777 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs @@ -0,0 +1,41 @@ +namespace ZodSharp.SourceGenerators.Infra; + +public sealed record ZodCodeFixTestOptions : CodeFixTestOptions +{ + public ZodCodeFixTestOptions() + { + AdditionalAssemblyTypes = + [ + typeof(Z), + typeof(System.Collections.Immutable.ImmutableArray), + typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), + typeof(System.Text.Json.JsonSerializer), + typeof(System.Text.RegularExpressions.Regex), + typeof(Core.ValidationResult<>), + typeof(ValueTask), + typeof(CancellationToken), + typeof(Schemas.RefineCtx<>), + typeof(Microsoft.Extensions.Options.IValidateOptions<>), + typeof(Microsoft.Extensions.Options.ValidateOptionsResult), + ]; + AdditionalNamespaces = ["ZodSharp"]; + AdditionalSources = + [ + """ + namespace ZodSharp; + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] + public sealed class ZodSchemaAttribute : System.Attribute + { + public string? SchemaName { get; init; } + public bool GenerateValidateMethod { get; init; } = true; + public bool GenerateParseMethod { get; init; } = true; + public bool EnableComposition { get; init; } = true; + public string? CustomValidationMethodName { get; init; } + public bool GenerateIValidateOptions { get; init; } = false; + public bool SuppressIValidateOptions { get; init; } = false; + } + """, + ]; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/NativeUnionCodeFixTests.cs b/src/tests/SourceGenerators.UnitTests/NativeUnionCodeFixTests.cs new file mode 100644 index 0000000..de74355 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/NativeUnionCodeFixTests.cs @@ -0,0 +1,87 @@ +#if NET11_0_OR_GREATER +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public class NativeUnionCodeFixTests : ZodCodeFixTestBase +{ + [Test] + public async Task CodeFix_GivenZUnion_ReplacesWithNativeUnion(CancellationToken cancellationToken) + { + const string source = """ + using ZodSharp; + + namespace Testing + { + public static class Schemas + { + public static void Build() + { + var schema = Z.Union(Z.String(), Z.Object().Build()); + } + } + } + """; + + var result = await ApplyCodeFixAsync(source, new ZodCodeFixTestOptions(), cancellationToken); + + await Assert.That(result.FixedSource).Contains("Z.NativeUnion("); + await Assert.That(result.FixedSource).DoesNotContain("Z.Union("); + } + + [Test] + public async Task CodeFix_GivenExplicitTypedUnionDeclaration_RetargetsDeclarationAndCall( + CancellationToken cancellationToken + ) + { + const string source = """ + using System.Collections.Generic; + using ZodSharp; + using ZodSharp.Schemas; + + namespace Testing + { + public static class Schemas + { + public static void Build() + { + ZodTypedUnion> schema = Z.Union(Z.String(), Z.Object().Build()); + } + } + } + """; + + var result = await ApplyCodeFixAsync(source, new ZodCodeFixTestOptions(), cancellationToken); + + await Assert.That(result.FixedSource).Contains("ZodTypedNativeUnion>"); + await Assert.That(result.FixedSource).Contains("= Z.NativeUnion("); + } + + [Test] + public async Task CodeFix_GivenNewZodTypedUnion_ReplacesWithNativeSchema(CancellationToken cancellationToken) + { + const string source = """ + using ZodSharp; + using ZodSharp.Schemas; + + namespace Testing + { + public static class Schemas + { + public static void Build() + { + var schema = new ZodTypedUnion>( + Z.String(), + Z.Object().Build() + ); + } + } + } + """; + + var result = await ApplyCodeFixAsync(source, new ZodCodeFixTestOptions(), cancellationToken); + + await Assert.That(result.FixedSource).Contains("new ZodTypedNativeUnion<"); + } +} +#endif diff --git a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj index 2d10643..977ffb1 100644 --- a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj +++ b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj @@ -10,6 +10,12 @@ + + /// Verifies the multi-targeting guarantee for the core package: every test run resolves a /// for the core assembly that matches one of the supported target -/// frameworks (net8.0, net9.0, net10.0). +/// frameworks (net8.0, net9.0, net10.0, net11.0). /// public class MultiTargetingTests { @@ -15,6 +15,7 @@ public class MultiTargetingTests ".NETCoreApp,Version=v8.0", ".NETCoreApp,Version=v9.0", ".NETCoreApp,Version=v10.0", + ".NETCoreApp,Version=v11.0", ]; [Test] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodBigIntTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodBigIntTests.cs new file mode 100644 index 0000000..ef517c2 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodBigIntTests.cs @@ -0,0 +1,92 @@ +namespace ZodSharp.Schemas; + +public class ZodBigIntTests +{ + [Test] + [Arguments(10L, 5L, true)] + [Arguments(5L, 5L, false)] + [Arguments(4L, 5L, false)] + public async Task BigIntGt_GivenValue_ReturnsExpectedResult(long value, long bound, bool expected) + { + var result = Z.BigInt().Gt(bound).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5L, 5L, true)] + [Arguments(6L, 5L, true)] + [Arguments(4L, 5L, false)] + public async Task BigIntGte_GivenValue_ReturnsExpectedResult(long value, long bound, bool expected) + { + var result = Z.BigInt().Gte(bound).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5L, 5L, false)] + [Arguments(6L, 5L, false)] + [Arguments(4L, 5L, true)] + public async Task BigIntLt_GivenValue_ReturnsExpectedResult(long value, long bound, bool expected) + { + var result = Z.BigInt().Lt(bound).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5L, 5L, true)] + [Arguments(6L, 5L, false)] + [Arguments(4L, 5L, true)] + public async Task BigIntLte_GivenValue_ReturnsExpectedResult(long value, long bound, bool expected) + { + var result = Z.BigInt().Lte(bound).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(10L, 0L, true)] + [Arguments(0L, 0L, true)] + [Arguments(-10L, 0L, false)] + public async Task BigIntMin_GivenValue_ReturnsExpectedResult(long value, long minValue, bool expected) + { + var result = Z.BigInt().Min(minValue).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(1L, true)] + [Arguments(0L, false)] + [Arguments(-1L, false)] + public async Task BigIntPositive_GivenValue_ReturnsExpectedResult(long value, bool expected) + { + var result = Z.BigInt().Positive().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(-1L, true)] + [Arguments(0L, false)] + [Arguments(1L, false)] + public async Task BigIntNegative_GivenValue_ReturnsExpectedResult(long value, bool expected) + { + var result = Z.BigInt().Negative().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(0L, true)] + [Arguments(1L, true)] + [Arguments(-1L, false)] + public async Task BigIntNonNegative_GivenValue_ReturnsExpectedResult(long value, bool expected) + { + var result = Z.BigInt().NonNegative().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodDateTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodDateTests.cs new file mode 100644 index 0000000..4e2b43f --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodDateTests.cs @@ -0,0 +1,37 @@ +namespace ZodSharp.Schemas; + +public class ZodDateTests +{ + [Test] + public async Task DateValidate_GivenValue_ReturnsSuccess() + { + var result = Z.Date().Validate(new DateTime(2020, 1, 1)); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(new DateTime(2020, 1, 1)); + } + + [Test] + public async Task DateMin_GivenEarlierValue_ReturnsFailure() + { + var result = Z.Date().Min(new DateTime(2020, 1, 1)).Validate(new DateTime(2019, 12, 31)); + + await Assert.That(result.IsSuccess).IsFalse(); + } + + [Test] + public async Task DateMin_GivenEqualValue_ReturnsSuccess() + { + var result = Z.Date().Min(new DateTime(2020, 1, 1)).Validate(new DateTime(2020, 1, 1)); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task DateMax_GivenLaterValue_ReturnsFailure() + { + var result = Z.Date().Max(new DateTime(2020, 1, 1)).Validate(new DateTime(2020, 1, 2)); + + await Assert.That(result.IsSuccess).IsFalse(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs index eba7cb1..adef4bc 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs @@ -156,4 +156,48 @@ public async Task NumberSafe_GivenValue_ReturnsExpectedResult(double value, bool await Assert.That(result.IsSuccess).IsEqualTo(expected); } + + [Test] + [Arguments(10.0, true)] + [Arguments(5.0, false)] + [Arguments(5.1, true)] + public async Task NumberGt_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().Gt(5).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5.0, true)] + [Arguments(5.1, true)] + [Arguments(4.9, false)] + public async Task NumberGte_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().Gte(5).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5.0, false)] + [Arguments(5.1, false)] + [Arguments(4.9, true)] + public async Task NumberLt_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().Lt(5).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(5.0, true)] + [Arguments(5.1, false)] + [Arguments(4.9, true)] + public async Task NumberLte_GivenValue_ReturnsExpectedResult(double value, bool expected) + { + var result = Z.Number().Lte(5).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodPipeTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodPipeTests.cs index 6aab246..4b14155 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodPipeTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodPipeTests.cs @@ -31,7 +31,7 @@ public async Task Pipe_GivenSourceFails_PropagatesSourceErrors() // Assert await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(result.Errors[0].Code).IsEqualTo("too_small"); } [Test] @@ -48,7 +48,7 @@ public async Task Pipe_GivenTargetFails_PropagatesTargetErrors() // Assert await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(result.Errors[0].Code).IsEqualTo("too_small"); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs index 3aca0c1..567f5c7 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringSpanTests.cs @@ -22,7 +22,7 @@ public async Task IsValidSpan_GivenTooShortValue_ReturnsFalseWithErrors() await Assert.That(isValid).IsFalse(); await Assert.That(errors).HasSingleItem(); - await Assert.That(errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(errors[0].Code).IsEqualTo("too_small"); } [Test] @@ -85,4 +85,40 @@ public async Task IsValidSpan_GivenUuidRule_ValidatesWithoutString() await Assert.That(valid).IsTrue(); await Assert.That(invalid).IsFalse(); } + + [Test] + public async Task IsValidSpan_GivenIpRule_ValidatesWithoutString() + { + var schema = Z.String().IP(); + + var valid = schema.IsValidSpan("192.168.1.1".AsSpan(), out _); + var invalid = schema.IsValidSpan("999.1.1.1".AsSpan(), out _); + + await Assert.That(valid).IsTrue(); + await Assert.That(invalid).IsFalse(); + } + + [Test] + public async Task IsValidSpan_GivenHexRule_ValidatesWithoutString() + { + var schema = Z.String().Hex(); + + var valid = schema.IsValidSpan("deadBEEF".AsSpan(), out _); + var invalid = schema.IsValidSpan("zz".AsSpan(), out _); + + await Assert.That(valid).IsTrue(); + await Assert.That(invalid).IsFalse(); + } + + [Test] + public async Task IsValidSpan_GivenDatetimeRule_ValidatesWithoutString() + { + var schema = Z.String().Datetime(); + + var valid = schema.IsValidSpan("2022-10-13T09:52:31.816Z".AsSpan(), out _); + var invalid = schema.IsValidSpan("2020-10-14T17:42:29+00:00".AsSpan(), out _); + + await Assert.That(valid).IsTrue(); + await Assert.That(invalid).IsFalse(); + } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs index 8909952..4304326 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs @@ -205,4 +205,166 @@ public async Task Parse_GivenInvalidString_ThrowsZodExceptionWithErrors() await Assert.That(exception).IsNotNull(); await Assert.That(exception.Errors).Count().IsEqualTo(1); } + + [Test] + [Arguments("hello world", "world", true)] + [Arguments("hello world", "planet", false)] + [Arguments("", "x", false)] + public async Task StringIncludes_GivenValue_ReturnsExpectedResult(string value, string substring, bool expected) + { + var result = Z.String().Includes(substring).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("192.168.1.1", true)] + [Arguments("255.255.255.255", true)] + [Arguments("::1", true)] + [Arguments("2001:db8::8a2e:370:7334", true)] + [Arguments("999.1.1.1", false)] + [Arguments("not-an-ip", false)] + public async Task StringIP_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().IP().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", true)] + [Arguments("a.b.c", true)] + [Arguments("abc", false)] + [Arguments("a..c", false)] + [Arguments(".b.c", false)] + public async Task StringJwt_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().JWT().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("deadBEEF", true)] + [Arguments("0x1A", false)] + [Arguments("", true)] + [Arguments("zz", false)] + public async Task StringHex_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Hex().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("SGVsbG8", true)] + [Arguments("a_b-c9", true)] + [Arguments("", true)] + [Arguments("SGVsbG8=", false)] + [Arguments("SGVsbG8==", false)] + [Arguments("a", false)] + [Arguments("not+a+base64", false)] + public async Task StringBase64Url_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Base64Url().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("01ARZ3NDEKTSV4RRFFQ69G5FAV", true)] + [Arguments("01arz3ndektsv4rrffq69g5fav", true)] + [Arguments("01ARZ3NDEKTSV4RRFFQ69G5FA", false)] + [Arguments("01ARZ3NDEKTSV4RRFFQ69G5FAVZ", false)] + [Arguments("01ARZ3NDEKTSV4RRFFQ69G5FAU", false)] + [Arguments("81ARZ3NDEKTSV4RRFFQ69G5FAV", false)] + public async Task StringUlid_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().ULID().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("1970-01-01T00:00:00.000Z", true)] + [Arguments("2022-10-13T09:52:31.8162314Z", true)] + [Arguments("1970-01-01T00:00:00Z", true)] + [Arguments("2022-10-13T09:52:31Z", true)] + [Arguments("2020-10-14", false)] + [Arguments("2020-10-14T17:42:29+00:00", false)] + [Arguments("2020-13-01T00:00:00Z", false)] + [Arguments("not-a-datetime", false)] + public async Task StringDatetime_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Datetime().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("2020-01-01", true)] + [Arguments("2020-12-31", true)] + [Arguments("2020-1-1", false)] + [Arguments("2020-13-01", false)] + [Arguments("2000-02-30", false)] + [Arguments("2020-01-01T00:00:00Z", false)] + public async Task StringDate_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Date().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("00:00:00", true)] + [Arguments("23:59:59", true)] + [Arguments("23:59:59.9999999", true)] + [Arguments("00:00", true)] + [Arguments("24:00:00", false)] + [Arguments("00:60:00", false)] + [Arguments("00:00:60", false)] + [Arguments("0:00:00", false)] + public async Task StringTime_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Time().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("V1StGXR8_Z5jdHi6B-myT", true)] + [Arguments("V1StGXR8_Z5jdHi6B-my", false)] + [Arguments("V1StGXR8_Z5jdHi6B-myTT", false)] + [Arguments("V1StGXR8_Z5jdHi6B+myt", false)] + public async Task StringNanoid_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Nanoid().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("tz4a98xxat96iws9zmbrgj3a", true)] + [Arguments("a", true)] + [Arguments("", false)] + [Arguments("TZ4A98XXAT96IWS9ZMBRGJ3A", false)] + public async Task StringCuid2_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Cuid2().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments("+14155552671", true)] + [Arguments("+1234567", true)] + [Arguments("123456789", false)] + [Arguments("+01234567", false)] + [Arguments("+123456", false)] + public async Task StringE164_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().E164().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodSuperRefinementTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodSuperRefinementTests.cs index 93c2f47..52dd173 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodSuperRefinementTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodSuperRefinementTests.cs @@ -82,7 +82,7 @@ public async Task SuperRefine_GivenBaseSchemaFails_PropagatesBaseErrorsOnly() // Assert await Assert.That(result.IsSuccess).IsFalse(); await Assert.That(result.Errors.Count).IsEqualTo(1); - await Assert.That(result.Errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(result.Errors[0].Code).IsEqualTo("too_small"); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodTypedNativeUnionTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodTypedNativeUnionTests.cs new file mode 100644 index 0000000..f6e75be --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodTypedNativeUnionTests.cs @@ -0,0 +1,59 @@ +#if NET11_0_OR_GREATER +namespace ZodSharp.Schemas; + +/// +/// Runtime coverage for the net11-only native union schema. The analyzer and code-fix that point +/// consumers at are covered in SourceGenerators.UnitTests. +/// +public class ZodTypedNativeUnionTests +{ + [Test] + public async Task NativeUnion_GivenReferenceTypeMatch_ReturnsNativeUnion() + { + // Arrange + var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); + + // Act + var result = schema.Validate("hello"); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value is string).IsTrue(); + } + + [Test] + public async Task NativeUnion_GivenNoMatch_ReturnsInvalidUnionFailure() + { + // Arrange + var schema = Z.NativeUnion(Z.String().Min(10), Z.Object().Build()); + + // Act + var result = schema.Validate("hi"); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_union"); + } + + [Test] + public async Task NativeUnion_GivenConsumerSwitch_UnwrapsTheCase() + { + // Arrange + var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); + + // Act + var result = schema.Validate("hello"); + var length = result.IsSuccess + ? result.Value switch + { + string value => value.Length, + Dictionary value => value.Count, + _ => -1, + } + : -1; + + // Assert + await Assert.That(length).IsEqualTo(5); + } +} +#endif From adb491052459f9235301499de4bebf0350974080 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 14:46:29 +0100 Subject: [PATCH 04/26] chore: formatting --- purview-build.json | 8 +++++--- src/Directory.Build.targets | 13 ------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/purview-build.json b/purview-build.json index 4d182da..f8d356c 100644 --- a/purview-build.json +++ b/purview-build.json @@ -3,7 +3,7 @@ "Solution": "src/ZodSharp.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*[Category!=Unit]" + "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { "RequireSymbolPackage": true, @@ -38,10 +38,12 @@ ] }, "ForbiddenContent": { - "*": ["analyzers/**/Purview.SourceGeneratorFramework.dll"] + "*": [ + "analyzers/**/Purview.SourceGeneratorFramework.dll" + ] } }, "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ea93586..ab68169 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,16 +1,3 @@ - - - - - From de5d78292ce8624b17a0ce1f0254064b38885ee5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 18:52:05 +0100 Subject: [PATCH 05/26] fix: nullability on IZodRule props --- docs/wiki/Custom-Rules.md | 2 +- .../Helpers/CustomRuleResolver.cs | 18 ++++- .../ZodSchemaGenerator.CustomRules.cs | 8 +- .../ZodSchemaAnalyzerTests.CustomRules.cs | 75 +++++++++++++++++++ .../ZodSchemaGeneratorTests.CustomRules.cs | 40 ++++++++++ .../ZodSchemaGeneratorTests.GenericRules.cs | 4 +- ...aGeneratorTests.RuleAttributeGeneration.cs | 4 +- .../ZodSchemaGeneratorTests.TypeRules.cs | 68 +++++++++++++++++ 8 files changed, 206 insertions(+), 13 deletions(-) diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 01fde2c..3b3a73a 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -340,7 +340,7 @@ if (!assetIdCustomRule0.IsValid(value)) ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? "invalid_asset_id", assetIdCustomRule0.GetErrorMessage(value), EmptyPath, - origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin ?? null)); + origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin)); } ``` diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index d505375..f20ca18 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -725,11 +725,11 @@ out string? unmappedParameterName return false; } - expressions.Add(defaultExpression); + expressions.Add(parameter.ExplicitDefaultValue is null ? "null!" : defaultExpression); continue; } - if (TypeHelpers.CanBeNull(parameter.Type)) + if (CanPassNull(parameter.Type)) { expressions.Add("null"); continue; @@ -743,6 +743,16 @@ out string? unmappedParameterName return true; } + /// + /// Determines whether a bare literal may be emitted for a value of + /// without producing CS8625. A non-nullable reference type is excluded even though + /// it can hold at runtime, so such a value is reported as unmappable instead. + /// + static bool CanPassNull(ITypeSymbol type) => + type.NullableAnnotation == NullableAnnotation.Annotated + || type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.None); + static bool IsMessageParameter(IParameterSymbol parameter) => string.Equals(parameter.Name, "message", StringComparison.OrdinalIgnoreCase); @@ -751,7 +761,7 @@ static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, o if (constant.IsNull) { expression = "null"; - return targetType.IsReferenceType || TypeHelpers.CanBeNull(targetType); + return CanPassNull(targetType); } if (constant.Kind == TypedConstantKind.Type) @@ -774,7 +784,7 @@ internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out if (value is null) { expression = "null"; - return targetType.IsReferenceType || TypeHelpers.CanBeNull(targetType); + return CanPassNull(targetType); } var unwrapped = TypeHelpers.UnwrapNullableType(targetType); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 81025d2..601b08c 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -78,10 +78,12 @@ bool declareValueLocal var codeExpression = rule.RuleOwnsIdentity ? $"(({zodRuleInterface}){ruleVariable}).Code ?? {codeFallback.Surround()}" : codeFallback.Surround(); - var originFallback = rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() : "null"; + var originFallback = rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() : null; var originExpression = rule.RuleOwnsIdentity - ? $"(({zodRuleInterface}){ruleVariable}).Origin ?? {originFallback}" - : originFallback; + ? originFallback is null + ? $"(({zodRuleInterface}){ruleVariable}).Origin" + : $"(({zodRuleInterface}){ruleVariable}).Origin ?? {originFallback}" + : originFallback ?? "null"; var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs index 54925d9..0760771 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -41,6 +41,81 @@ public sealed class MismatchedRuleModel await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); } + [Test] + public async Task CustomRule_GivenRequiredCodeWithoutValue_ProducesZODSGEN031(CancellationToken cancellationToken) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RequiredCodeRule(string Code, string? Message = null, string? Origin = null) + : IValidationRule, IZodRule + { + public bool IsValid(in string value) => !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string value) => Message ?? "Invalid."; + } + + [ZodRule(typeof(RequiredCodeRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RequiredCodeAttribute : ValidationAttribute { } + + [ZodSchema] + public sealed class Model + { + [RequiredCode] + public string Name { get; set; } = string.Empty; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnmappableCustomRuleArgument); + } + + [Test] + public async Task CustomRule_GivenExplicitNullForNonNullableParameter_ProducesZODSGEN031( + CancellationToken cancellationToken + ) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RequiredCodeRule(string Code, string? Message = null, string? Origin = null) + : IValidationRule, IZodRule + { + public bool IsValid(in string value) => !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string value) => Message ?? "Invalid."; + } + + [ZodRule(typeof(RequiredCodeRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RequiredCodeAttribute : ValidationAttribute + { + public string? Code { get; set; } + } + + [ZodSchema] + public sealed class Model + { + [RequiredCode(Code = null)] + public string Name { get; set; } = string.Empty; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnmappableCustomRuleArgument); + } + [Test] public async Task CustomRule_GivenUnclosableGenericRule_ProducesZODSGEN030(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs index a8a19f6..18b9621 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -125,4 +125,44 @@ public class Model // Assert await Assert.That(driverResult.GetSource("ModelSchema")).DoesNotContain("NumberOnlyRule"); } + + [Test] + public async Task CustomRule_GivenRequiredCodeWithoutValue_DoesNotEmitNull(CancellationToken cancellationToken) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct RequiredCodeRule(string Code, string? Message = null, string? Origin = null) + : IValidationRule, IZodRule + { + public bool IsValid(in string value) => !string.IsNullOrWhiteSpace(value); + + public string GetErrorMessage(in string value) => Message ?? "Invalid."; + } + + [ZodRule(typeof(RequiredCodeRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class RequiredCodeAttribute : ValidationAttribute { } + + [ZodSchema] + public class Model + { + [RequiredCode] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert — a required, non-nullable Code cannot be defaulted to null, so the rule is not emitted. + await Assert.That(generated).DoesNotContain("RequiredCodeRule"); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index 7ac0d2f..fa6b97e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -73,9 +73,7 @@ public async Task GenericRule_GivenRuleOwningIdentity_EmitsRuleCodeExpression(Ca 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"); + await Assert.That(generated).ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)valueCustomRule0).Origin"); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index 62a0283..0f1a58c 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -351,8 +351,8 @@ public partial class Sample var validate = assembly.GetType("Testing.SampleSchema")!.GetMethod("Validate")!; // Both applications are emitted as their own rule, in source order. - await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(3, null)"); - await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(5, null)"); + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(3, null!)"); + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.MultipleOfRule(5, null!)"); // Act — 15 satisfies both applications, 3 satisfies only the first. var validInstance = Activator.CreateInstance(modelType)!; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index 7abac79..5b622fa 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -69,6 +69,74 @@ await Assert await Assert.That(generated).DoesNotContain("NotEmptyRule"); } + /// + /// A type-level rule that owns its error identity (IZodRule) and leaves its nullable + /// Origin constructor parameter at its default must not emit the redundant ?? null or a + /// bare null constructor argument (both of which produce CS8625). + /// + [Test] + public async Task TypeRule_GivenRuleOwningIdentityWithoutConfiguredOrigin_EmitsNoNullLiterals( + CancellationToken cancellationToken + ) + { + 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 NotEmptyGuidRule(string Code, string? Message = null, string? Origin = 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."; + } + + [ZodRule(typeof(NotEmptyGuidRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotEmptyGuidAttribute : ValidationAttribute + { + public string? Code { get; init; } + + public string? Message { get; init; } + } + + [NotEmptyGuid(Code = "invalid_asset_id", Message = "AssetId must be a non-empty GUID.")] + [ZodSchema] + public partial record struct AssetId : IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert + await Assert.That(generated).DoesNotContain("?? null"); + await Assert.That(generated).DoesNotContain(", null)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Testing.NotEmptyGuidRule(\"invalid_asset_id\", \"AssetId must be a non-empty GUID.\", null!)" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin"); + } + /// /// A value object whose interface implementation is contributed by another generator still gets its /// type-level rule. The scalar value-object generators add From 923db4fa69105cd49cde311f1776caa071719bf8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 11:19:44 +0100 Subject: [PATCH 06/26] refactor: added Zod IZodRule organisation and analyser --- docs/wiki/Custom-Rules.md | 39 +++ docs/wiki/Number-Validation.md | 4 +- docs/wiki/Source-Generator-Diagnostics.md | 3 +- docs/wiki/String-Validation.md | 8 + .../AnalyzerReleases.Unshipped.md | 1 + .../Helpers/DiagnosticLibrary.cs | 15 + .../ValidationRuleConventionsAnalyzer.cs | 130 +++++++++ src/src/ZodSharp/Rules/Base64StringRule.cs | 10 +- src/src/ZodSharp/Rules/Base64UrlRule.cs | 13 +- src/src/ZodSharp/Rules/CreditCardRule.cs | 12 +- src/src/ZodSharp/Rules/Cuid2Rule.cs | 13 +- src/src/ZodSharp/Rules/DateStringRule.cs | 13 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 13 +- src/src/ZodSharp/Rules/E164Rule.cs | 13 +- src/src/ZodSharp/Rules/EmailRule.cs | 12 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 13 +- src/src/ZodSharp/Rules/FiniteRule.cs | 10 +- src/src/ZodSharp/Rules/GreaterThanRule.cs | 10 +- src/src/ZodSharp/Rules/HexRule.cs | 13 +- src/src/ZodSharp/Rules/IPAddressRule.cs | 13 +- src/src/ZodSharp/Rules/IncludesRule.cs | 13 +- src/src/ZodSharp/Rules/IntRule.cs | 11 +- src/src/ZodSharp/Rules/JWTRule.cs | 13 +- src/src/ZodSharp/Rules/LessThanRule.cs | 10 +- src/src/ZodSharp/Rules/MaxLengthRule.cs | 12 +- src/src/ZodSharp/Rules/MaxValueRule.cs | 10 +- src/src/ZodSharp/Rules/MinLengthRule.cs | 12 +- src/src/ZodSharp/Rules/MinValueRule.cs | 10 +- src/src/ZodSharp/Rules/MultipleOfRule.cs | 11 +- src/src/ZodSharp/Rules/NanoidRule.cs | 13 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 112 ++++++++ src/src/ZodSharp/Rules/PhoneRule.cs | 13 +- src/src/ZodSharp/Rules/RegexRule.cs | 11 +- src/src/ZodSharp/Rules/RuleMessage.cs | 38 +++ src/src/ZodSharp/Rules/SafeIntegerRule.cs | 11 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 13 +- src/src/ZodSharp/Rules/TimeStringRule.cs | 13 +- src/src/ZodSharp/Rules/ULIDRule.cs | 13 +- src/src/ZodSharp/Rules/UriRule.cs | 11 +- src/src/ZodSharp/Rules/UrlRule.cs | 10 +- src/src/ZodSharp/Rules/UuidRule.cs | 15 +- src/src/ZodSharp/Sdk/README.md | 10 +- .../AnalyzerReleaseTrackingTests.cs | 1 + ...lidationRuleConventionsAnalyzerTestBase.cs | 7 + ...ationRuleConventionsAnalyzerTestOptions.cs | 12 + .../ValidationRuleConventionsAnalyzerTests.cs | 209 ++++++++++++++ .../Rules/NonSentinelRuleTests.cs | 262 ++++++++++++++++++ .../Rules/RuleConstantsTests.cs | 61 ++++ 48 files changed, 1204 insertions(+), 91 deletions(-) create mode 100644 src/src/SourceGenerators/ValidationRuleConventionsAnalyzer.cs create mode 100644 src/src/ZodSharp/Rules/NonSentinelRule.cs create mode 100644 src/src/ZodSharp/Rules/RuleMessage.cs create mode 100644 src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestBase.cs create mode 100644 src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestOptions.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ValidationRuleConventionsAnalyzerTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 3b3a73a..53fb290 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -23,6 +23,45 @@ Implementations should be structs so validation does not allocate. `IsValid` is 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. +## Error code and message constants + +Every rule **should** expose its error identity as public constants so code (and tests) can assert against the rule rather than re-typing literals: + +```csharp +public readonly record struct EmailRule : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Invalid email format: {0}"; + + public string Code => ErrorCode; + public string GetErrorMessage(in string value) => + string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +`ErrorCode` is the value reported in `ValidationError.Code`; `MessageFormat` is a `string.Format` template whose `{0}`-style placeholders are the offending value (and any rule-specific arguments). A custom `message` supplied to the rule still wins over `MessageFormat`. A `[Test]` can therefore assert without duplicating strings: + +```csharp +await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); +await Assert.That(error.Message).IsEqualTo( + string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); +``` + +The convention is enforced by an analyzer: a source-declared rule that does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat` is reported as **ZODSGEN042**. The constants may be inherited from a base rule class, and abstract bases are exempt so a shared base can host them for its concrete derivations. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. + +## Non-sentinel values (EF-friendly) + +`ZodSharp.Rules.NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to represent "no value" — `Guid.Empty`, `DateTime.MinValue`/`MaxValue`, `DateTimeOffset.MinValue`/`MaxValue`, `DateOnly.MinValue`/`MaxValue`, `TimeOnly.MinValue`/`MaxValue`, and `null`/empty/whitespace strings: + +```csharp +var schema = Z.Date().AddRule(new NonSentinelRule()); + +var result = schema.Validate(DateTime.MinValue); +// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") +``` + +Close it with the property type to use it through an attribute (`[ZodRule(typeof(NonSentinelRule<>))]` on a matching `NonSentinelAttribute`). Types without a known sentinel always pass, so the rule never rejects a type it does not understand. + ## Defining a custom rule ```csharp diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index 82e3509..57caabd 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -19,14 +19,14 @@ var result = schema.Validate(30.0); | `Gte` | `Gte(double value)` | `MinValueRule` — greater than or equal to `value` | | `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` | | `Lte` | `Lte(double value)` | `MaxValueRule` — less than or equal to `value` | -| `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` | +| `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)`; failure code `invalid_type` | | `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` | +| `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue`; failure code `too_big` | ## Examples diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index ad9c7ba..fe47646 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -1,6 +1,6 @@ # Source Generator Diagnostics -The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` are warnings, `ZODSGEN041` is an informational suggestion, and the rest are errors. +The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, and `ZODSGEN042` are warnings, `ZODSGEN041` is an informational suggestion, and the rest are errors. | ID | Meaning | |---|---| @@ -39,6 +39,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity | | ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | | ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | +| ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md index 274df18..1c567ed 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -73,6 +73,14 @@ var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. +Every rule exposes its error identity as public constants — `public const string ErrorCode` and a `public const string MessageFormat` (a `{0}`-style template) — so tests and consumers can assert against the rule instead of duplicating literals: + +```csharp +var result = Z.String().Email().Validate("not-an-email"); +// result.Errors[0].Code == EmailRule.ErrorCode ("invalid_string") +// result.Errors[0].Message == string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, "not-an-email") +``` + ## Span validation `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. diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 3aff5e3..40fbf49 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -10,3 +10,4 @@ | ZODSGEN039 | ZodSharp.SourceGenerator | Warning | A rule accepts a code/origin constructor parameter without implementing IZodRule, so the value never reaches the reported error identity | | ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | | ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | +| ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 03fd264..b5186f1 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -404,4 +404,19 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true ); + + /// + /// ZODSGEN042: a validation rule (a type implementing ZodSharp.Core.IValidationRule<T>) does not + /// expose its error identity as public constants. Every rule should declare public const string ErrorCode + /// and public const string MessageFormat so its code and message can be asserted in tests without + /// duplicating literals. + /// + public static readonly DiagnosticDescriptor RuleMissingErrorIdentityConstants = new( + id: "ZODSGEN042", + title: "Validation rule should expose public error identity constants", + messageFormat: "Rule '{0}' should declare {1}: expose 'public const string ErrorCode' and 'public const string MessageFormat' so tests can assert against the rule instead of duplicating literals", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/ValidationRuleConventionsAnalyzer.cs b/src/src/SourceGenerators/ValidationRuleConventionsAnalyzer.cs new file mode 100644 index 0000000..97eff7d --- /dev/null +++ b/src/src/SourceGenerators/ValidationRuleConventionsAnalyzer.cs @@ -0,0 +1,130 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +/// +/// Reports when a validation rule (a type implementing ZodSharp.Core.IValidationRule<T>) does not +/// expose its error identity as public constants. Every rule is expected to declare +/// public const string ErrorCode and public const string MessageFormat so that a test (or a +/// consumer) can assert against the rule rather than duplicating the literal code and message. +/// +/// +/// The check applies to source-declared rules only; the built-in rules in the referenced assembly are +/// metadata and are therefore ignored. A rule that inherits the constants from a base class satisfies the +/// convention, and abstract rule bases are skipped so that a shared base can host the constants for its +/// concrete derivations. Types that do not implement the rule interface are never considered. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ValidationRuleConventionsAnalyzer : DiagnosticAnalyzer +{ + /// The diagnostic id for a rule that does not expose its error identity constants. + public const string DiagnosticId = "ZODSGEN042"; + + /// The name of the error-code constant a rule should expose. + public const string ErrorCodeConstantName = "ErrorCode"; + + /// The name of the message-format constant a rule should expose. + public const string MessageFormatConstantName = "MessageFormat"; + + static readonly ImmutableArray SupportedDiagnosticsList = + [ + DiagnosticLibrary.RuleMissingErrorIdentityConstants, + ]; + + public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction(AnalyzeType, SymbolKind.NamedType); + } + + static void AnalyzeType(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + // Only concrete classes and structs are rules a consumer actually instantiates; interfaces, enums + // and abstract bases never carry the constants themselves. + if (type.TypeKind is not (TypeKind.Class or TypeKind.Struct) || type.IsAbstract || type.IsImplicitlyDeclared) + return; + + if (!IsValidationRule(type)) + return; + + var location = GetLocation(type); + if (location is null) + return; + + var missing = ImmutableArray.CreateBuilder(2); + if (!HasPublicStringConstant(type, ErrorCodeConstantName)) + missing.Add(ErrorCodeConstantName); + if (!HasPublicStringConstant(type, MessageFormatConstantName)) + missing.Add(MessageFormatConstantName); + + if (missing.Count == 0) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.RuleMissingErrorIdentityConstants, + location, + type.Name, + DescribeMissing(missing) + ) + ); + } + + static bool IsValidationRule(INamedTypeSymbol type) => + type.AllInterfaces.Any(static @interface => + @interface.OriginalDefinition is { Name: "IValidationRule", Arity: 1 } definition + && definition.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpCoreNamespace + ); + + /// + /// Returns when a public const string field named + /// is reachable on the type or one of its base types. + /// + static bool HasPublicStringConstant(INamedTypeSymbol type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers(name)) + { + if ( + member is IFieldSymbol { IsConst: true, DeclaredAccessibility: Accessibility.Public } field + && field.Type.SpecialType == SpecialType.System_String + ) + { + return true; + } + } + } + + return false; + } + + static string DescribeMissing(ImmutableArray.Builder missing) => + missing.Count == 1 + ? $"a public '{missing[0]}' constant" + : $"public '{missing[0]}' and '{missing[1]}' constants"; + + static Location? GetLocation(INamedTypeSymbol type) + { + foreach (var location in type.Locations) + { + if (location.IsInSource) + return location; + } + + return null; + } +} diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 6d7b596..4bce914 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -7,6 +7,12 @@ namespace ZodSharp.Rules; /// public readonly record struct Base64StringRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid Base64 string format: {0}"; + readonly string? _message; /// @@ -44,8 +50,8 @@ public bool IsValid(in string value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid Base64 string format: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index 695266c..411b32a 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct Base64UrlRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid base64url string: {0}"; + readonly string? _message; /// @@ -47,15 +53,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid base64url string: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 base64url string: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index d62d67a..bc183cc 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -7,6 +7,12 @@ namespace ZodSharp.Rules; /// public readonly record struct CreditCardRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid credit card number format: {0}"; + readonly string? _message; /// @@ -74,7 +80,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid credit card number format: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -82,8 +88,8 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? $"Invalid credit card number format: {value}"; + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs index bad78c9..9055a64 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct Cuid2Rule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid CUID2: {0}"; + readonly string? _message; /// @@ -45,15 +51,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid CUID2: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 CUID2: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index 8cfa3cc..97dd186 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; /// public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid date: {0}"; + readonly string? _message; /// @@ -42,15 +48,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid date: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 date: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index 503b114..d5aec77 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -9,6 +9,12 @@ namespace ZodSharp.Rules; /// public readonly record struct DatetimeStringRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid date-time: {0}"; + readonly string? _message; /// @@ -56,14 +62,15 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid date-time: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 date-time: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) { @@ -106,5 +113,5 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 305119b..6f5f177 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct E164Rule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid E.164 phone number: {0}"; + readonly string? _message; /// @@ -49,15 +55,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid E.164 phone number: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 E.164 phone number: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 6e41b5d..d0939e1 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; /// public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid email format: {0}"; + /// /// Regular expression for validating email format. /// @@ -36,15 +42,15 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => $"Invalid email format: {value}"; + public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, 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}"; + public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index 35aea96..006a2da 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the required suffix and {1} the value. + public const string MessageFormat = "String must end with '{0}', but got '{1}'"; + readonly string _suffix; readonly string? _message; @@ -39,8 +45,7 @@ public EndsWithRule(string suffix, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - _message ?? $"String must end with '{_suffix}', but got '{value}'"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _suffix, value); /// /// Gets the error message for a failed span validation. @@ -48,8 +53,8 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? $"String must end with '{_suffix}', but got '{value}'"; + _message ?? RuleMessage.Format(MessageFormat, _suffix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index e787044..f1072e7 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct FiniteRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "not_finite"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Number must be finite, but got {0}"; + readonly string? _message; /// @@ -29,8 +35,8 @@ public FiniteRule(string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? $"Number must be finite, but got {value}"; + public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "not_finite"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index e581ad0..faf1243 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; public readonly record struct GreaterThanRule : Core.IValidationRule where T : IComparable { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_small"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be greater than {0}, but got {1}"; + readonly T _exclusiveMinimum; /// @@ -31,8 +37,8 @@ public GreaterThanRule(T exclusiveMinimum) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => $"Value must be greater than {_exclusiveMinimum}, but got {value}"; + public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _exclusiveMinimum, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_small"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs index fdb7825..63ffa45 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct HexRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid hexadecimal string: {0}"; + readonly string? _message; /// @@ -42,15 +48,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid hexadecimal string: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 hexadecimal string: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 8f47f54..5d52902 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -9,6 +9,12 @@ namespace ZodSharp.Rules; /// public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid IP address: {0}"; + readonly string? _message; /// @@ -44,15 +50,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid IP address: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 IP address: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index ddf1fcf..47949fc 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the required substring and {1} the value. + public const string MessageFormat = "String must contain '{0}', but got '{1}'"; + readonly string _substring; readonly string? _message; @@ -39,8 +45,7 @@ public IncludesRule(string substring, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - _message ?? $"String must contain '{_substring}', but got '{value}'"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _substring, value); /// /// Gets the error message for a failed span validation. @@ -48,8 +53,8 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? $"String must contain '{_substring}', but got '{value}'"; + _message ?? RuleMessage.Format(MessageFormat, _substring, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index e6551b6..46f9651 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct IntRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_type"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Expected integer, but got {0}"; + /// /// Validates that the value is an integer. /// @@ -18,5 +24,8 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => $"Expected integer, but got {value}"; + public string GetErrorMessage(in double value) => RuleMessage.Format(MessageFormat, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index f72192d..d194084 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid JWT: {0}"; + readonly string? _message; /// @@ -51,14 +57,15 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid JWT: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 JWT: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); static bool IsBase64Url(ReadOnlySpan segment) { @@ -75,5 +82,5 @@ static bool IsBase64Url(ReadOnlySpan segment) } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 0f2c3be..7daa20d 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; public readonly record struct LessThanRule : Core.IValidationRule where T : IComparable { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be less than {0}, but got {1}"; + readonly T _exclusiveMaximum; /// @@ -31,8 +37,8 @@ public LessThanRule(T exclusiveMaximum) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => $"Value must be less than {_exclusiveMaximum}, but got {value}"; + public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _exclusiveMaximum, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_big"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 0014a36..fbdfd2e 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the maximum length and {1} the actual length. + public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; + readonly int _maxLength; /// @@ -37,7 +43,7 @@ public MaxLengthRule(int maxLength) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - $"String must be at most {_maxLength} characters long, but got {value.LengthOrDefault()}"; + RuleMessage.Format(MessageFormat, _maxLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -45,8 +51,8 @@ public string GetErrorMessage(in string value) => /// 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}"; + RuleMessage.Format(MessageFormat, _maxLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_big"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index 0e6f4f4..63d8c03 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; public readonly record struct MaxValueRule : Core.IValidationRule where T : IComparable { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be at most {0}, but got {1}"; + readonly T _maxValue; /// @@ -31,8 +37,8 @@ public MaxValueRule(T maxValue) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => $"Value must be at most {_maxValue}, but got {value}"; + public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _maxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_big"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 0773242..4b2cde0 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_small"; + + /// Gets the message format; {0} is the minimum length and {1} the actual length. + public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; + readonly int _minLength; /// @@ -37,7 +43,7 @@ public MinLengthRule(int minLength) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - $"String must be at least {_minLength} characters long, but got {value.LengthOrDefault()}"; + RuleMessage.Format(MessageFormat, _minLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -45,8 +51,8 @@ public string GetErrorMessage(in string value) => /// 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}"; + RuleMessage.Format(MessageFormat, _minLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_small"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index cfd06d4..ccceb3a 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; public readonly record struct MinValueRule : Core.IValidationRule where T : IComparable { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_small"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be at least {0}, but got {1}"; + readonly T _minValue; /// @@ -31,8 +37,8 @@ public MinValueRule(T minValue) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => $"Value must be at least {_minValue}, but got {value}"; + public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _minValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "too_small"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index 8d66cca..e381314 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct MultipleOfRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "not_multiple_of"; + + /// Gets the message format; {0} is the divisor and {1} the offending value. + public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; + /// /// The relative tolerance applied when comparing the quotient to its nearest integer. /// @@ -52,9 +58,8 @@ public bool IsValid(in double value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => - _message ?? $"Number must be a multiple of {_divisor}, but got {value}"; + public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _divisor, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "not_multiple_of"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs index 35944af..6549832 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct NanoidRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid nanoid: {0}"; + readonly string? _message; /// @@ -45,15 +51,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid nanoid: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 nanoid: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs new file mode 100644 index 0000000..a43ba30 --- /dev/null +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -0,0 +1,112 @@ +using System.Runtime.CompilerServices; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that rejects "sentinel" values — the framework default or boundary values that are +/// commonly persisted by an ORM to represent an unset field (for example or +/// /). +/// +/// The value type; only the supported sentinel types are inspected. +/// +/// +/// Built-in detection covers the types that map to database columns with column-level sentinels: +/// +/// +/// — . +/// — and . +/// — and . +/// — and . +/// — and . +/// — , empty, or whitespace. +/// +/// +/// Every other type is treated as valid, so the rule can be closed with a type it does not know about +/// without rejecting that type's entire value range. Detection uses a typeof(T) dispatch and +/// reinterprets the value in place, so no boxing occurs on the validation path. +/// +/// +public readonly record struct NonSentinelRule : Core.IValidationRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Value is a sentinel value, but got {0}"; + + readonly string? _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message + public NonSentinelRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is not a sentinel value. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => !SentinelValues.IsSentinel(value); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + + /// Gets the error code reported when the rule fails. + public string Code => ErrorCode; +} + +/// +/// Detects the sentinel value(s) for a supported type without boxing. Types that are not known to have a +/// sentinel are treated as never sentinel. +/// +/// The value type. +static class SentinelValues +{ + /// + /// Determines whether is a sentinel value for . + /// + /// The value to inspect. + /// when the value is a known sentinel; otherwise . + public static bool IsSentinel(in T value) + { + if (typeof(T) == typeof(Guid)) + return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty; + + if (typeof(T) == typeof(DateTime)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTime.MinValue || typed == DateTime.MaxValue; + } + + if (typeof(T) == typeof(DateTimeOffset)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTimeOffset.MinValue || typed == DateTimeOffset.MaxValue; + } + + if (typeof(T) == typeof(DateOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateOnly.MinValue || typed == DateOnly.MaxValue; + } + + if (typeof(T) == typeof(TimeOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == TimeOnly.MinValue || typed == TimeOnly.MaxValue; + } + + if (typeof(T) == typeof(string)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return string.IsNullOrWhiteSpace(typed); + } + + return false; + } +} diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index cf9dc4e..3e0a715 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; /// public readonly record struct PhoneRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid phone number format: {0}"; + readonly string? _message; /// @@ -61,15 +67,16 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid phone number format: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index ce80859..5588b5c 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -8,6 +8,12 @@ namespace ZodSharp.Rules; /// public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the required pattern. + public const string MessageFormat = "String does not match the required pattern: {0}"; + readonly Regex _pattern; readonly string? _message; @@ -49,8 +55,7 @@ public RegexRule(string pattern, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - _message ?? $"String does not match the required pattern: {_pattern}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _pattern); /// /// Gets the error message for a failed span validation. @@ -60,5 +65,5 @@ public string GetErrorMessage(in string value) => public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessage(value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs new file mode 100644 index 0000000..99a6792 --- /dev/null +++ b/src/src/ZodSharp/Rules/RuleMessage.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; + +namespace ZodSharp.Rules; + +/// +/// Formats rule error messages from the public message-format constants exposed by each rule, caching the +/// parsed so a format string is compiled once rather than on every failure. +/// +/// +/// Centralising formatting keeps the culture provider in one place (satisfying CA1305) and lets every rule +/// expose its message as a public const string without re-parsing that constant on each failure. +/// +static class RuleMessage +{ + static readonly ConcurrentDictionary Formats = new(StringComparer.Ordinal); + + /// Formats a rule message with a single argument. + /// The argument type. + /// The message format (a rule's MessageFormat constant). + /// The format argument. + /// The formatted message using . + internal static string Format(string format, TArg0 arg0) => + string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0); + + /// Formats a rule message with two arguments. + /// The first argument type. + /// The second argument type. + /// The message format (a rule's MessageFormat constant). + /// The first format argument. + /// The second format argument. + /// The formatted message using . + internal static string Format(string format, TArg0 arg0, TArg1 arg1) => + string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1); + + static CompositeFormat GetFormat(string format) => Formats.GetOrAdd(format, CompositeFormat.Parse); +} diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index a565432..1da5417 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct SafeIntegerRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Number must be a safe integer, but got {0}"; + readonly string? _message; /// @@ -30,5 +36,8 @@ public bool IsValid(in double value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? $"Number must be a safe integer, but got {value}"; + public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 4c329dc..2acd62b 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the required prefix and {1} the value. + public const string MessageFormat = "String must start with '{0}', but got '{1}'"; + readonly string _prefix; readonly string? _message; @@ -39,8 +45,7 @@ public StartsWithRule(string prefix, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - _message ?? $"String must start with '{_prefix}', but got '{value}'"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _prefix, value); /// /// Gets the error message for a failed span validation. @@ -48,8 +53,8 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? $"String must start with '{_prefix}', but got '{value}'"; + _message ?? RuleMessage.Format(MessageFormat, _prefix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 4a83d63..0525643 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -7,6 +7,12 @@ namespace ZodSharp.Rules; /// public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid time: {0}"; + readonly string? _message; /// @@ -41,14 +47,15 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid time: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 time: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) { @@ -91,5 +98,5 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index 54f50c9..256ceae 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct ULIDRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid ULID: {0}"; + readonly string? _message; /// @@ -49,14 +55,15 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid ULID: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, 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 ULID: {value}"; + public string GetErrorMessage(ReadOnlySpan value) => + _message ?? RuleMessage.Format(MessageFormat, value.ToString()); static bool IsCrockfordBase32(char c) => c switch @@ -76,5 +83,5 @@ static bool IsCrockfordBase32(char c) => }; /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index c86a837..add5576 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -6,6 +6,12 @@ namespace ZodSharp.Rules; /// public readonly record struct UriRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the and {1} the value. + public const string MessageFormat = "Invalid Uri, kind: {0}, format: {1}"; + readonly string? _message; readonly UriKind _uriKind; @@ -33,5 +39,8 @@ public bool IsValid(in string value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid Uri, kind: {_uriKind}), format: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _uriKind, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index e9be87f..c8fba8e 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -13,6 +13,12 @@ namespace ZodSharp.Rules; )] public readonly record struct UrlRule : Core.IValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid URL format: {0}"; + static readonly Regex UrlRegex = new( @"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase, @@ -50,8 +56,8 @@ public bool IsValid(in string value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid URL format: {value}"; + public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 66a8f68..53de787 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -6,6 +6,15 @@ namespace ZodSharp.Rules; /// public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule { + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid UUID format: {0}"; + + /// Gets the version-specific message format; {0} is the version and {1} the value. + public const string VersionedMessageFormat = "Invalid UUID v{0} format: {1}"; + readonly string? _message; readonly UuidVersion? _version; @@ -82,8 +91,8 @@ string GetErrorMessageCore(string value) => _message ?? ( _version is UuidVersion version - ? $"Invalid UUID v{(int)version} format: {value}" - : $"Invalid UUID format: {value}" + ? RuleMessage.Format(VersionedMessageFormat, (int)version, value) + : RuleMessage.Format(MessageFormat, value) ); static bool IsValidVersionless(ReadOnlySpan value) @@ -122,5 +131,5 @@ static bool HasValidStructure(ReadOnlySpan value) static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => "invalid_string"; + public string Code => ErrorCode; } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 32359fa..42ea5d1 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -93,7 +93,9 @@ DataAnnotations attributes such as `[Required]`, `[Length]`, `[StringLength]`, ` ## 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: +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. + +Every rule should also expose its error identity as public `const string ErrorCode` and `const string MessageFormat` constants — the analyzer reports `ZODSGEN042` when a rule omits them, so tests can assert against the rule rather than duplicating literals: ```csharp using System; @@ -103,8 +105,12 @@ using ZodSharp.Core; public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + public bool IsValid(in string value) => value.IndexOf(' ') < 0; - public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + public string GetErrorMessage(in string value) => + Message ?? string.Format(MessageFormat, value); } [ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index 440b1bb..a17ab6f 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -72,6 +72,7 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN039", "ZODSGEN040", "ZODSGEN041", + "ZODSGEN042", ]; [Test] diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestBase.cs b/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestBase.cs new file mode 100644 index 0000000..86f6ea8 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestBase.cs @@ -0,0 +1,7 @@ +namespace ZodSharp.SourceGenerators.Infra; + +public abstract class ValidationRuleConventionsAnalyzerTestBase + : TUnitDiagnosticAnalyzerTestBase +{ + // Empty +} diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestOptions.cs new file mode 100644 index 0000000..d64dcdd --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/ValidationRuleConventionsAnalyzerTestOptions.cs @@ -0,0 +1,12 @@ +using ZodSharp.Core; + +namespace ZodSharp.SourceGenerators.Infra; + +public sealed record ValidationRuleConventionsAnalyzerTestOptions : AnalyzerTestOptions +{ + public ValidationRuleConventionsAnalyzerTestOptions() + { + AdditionalNamespaces = ["ZodSharp.Core"]; + AdditionalAssemblyTypes = [typeof(IValidationRule<>)]; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ValidationRuleConventionsAnalyzerTests.cs b/src/tests/SourceGenerators.UnitTests/ValidationRuleConventionsAnalyzerTests.cs new file mode 100644 index 0000000..b4f61ea --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ValidationRuleConventionsAnalyzerTests.cs @@ -0,0 +1,209 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public class ValidationRuleConventionsAnalyzerTests : ValidationRuleConventionsAnalyzerTestBase +{ + [Test] + public async Task GivenRuleWithBothConstants_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public readonly record struct PositiveRule : ZodSharp.Core.IValidationRule + { + public const string ErrorCode = "too_small"; + public const string MessageFormat = "Value must be positive, but got {0}"; + + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => string.Format(MessageFormat, value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenRuleMissingBothConstants_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public readonly record struct BareRule : ZodSharp.Core.IValidationRule + { + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => "Must be positive."; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ValidationRuleConventionsAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenRuleMissingMessageFormat_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public readonly record struct CodeOnlyRule : ZodSharp.Core.IValidationRule + { + public const string ErrorCode = "too_small"; + + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => "Must be positive."; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ValidationRuleConventionsAnalyzer.DiagnosticId); + await Assert.That(result).HasDiagnostics(1); + } + + [Test] + public async Task GivenRuleWithStaticReadonlyFieldsNotConstants_ReportsDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing; + + public readonly record struct MutableRule : ZodSharp.Core.IValidationRule + { + public static readonly string ErrorCode = "too_small"; + public static readonly string MessageFormat = "Must be positive, but got {0}"; + + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => string.Format(MessageFormat, value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ValidationRuleConventionsAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenRuleWithNonPublicConstants_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public readonly record struct HiddenRule : ZodSharp.Core.IValidationRule + { + const string ErrorCode = "too_small"; + const string MessageFormat = "Must be positive, but got {0}"; + + public bool IsValid(in double value) => value > 0; + + public string GetErrorMessage(in double value) => string.Format(MessageFormat, value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ValidationRuleConventionsAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenRuleInheritingConstantsFromBase_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public abstract class RuleBase : ZodSharp.Core.IValidationRule + where T : IComparable + { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Invalid value: {0}"; + + public abstract bool IsValid(in T value); + + public string GetErrorMessage(in T value) => string.Format(MessageFormat, value); + } + + public sealed class PositiveDoubleRule : RuleBase + { + public override bool IsValid(in double value) => value > 0; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenConcreteRuleDerivedFromBareBase_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public abstract class BareRuleBase : ZodSharp.Core.IValidationRule + { + public abstract bool IsValid(in T value); + + public string GetErrorMessage(in T value) => "Invalid."; + } + + public sealed class DerivedBareRule : BareRuleBase + { + public override bool IsValid(in double value) => true; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + // The abstract base is skipped; only the concrete derivation is reported. + await Assert.That(result).HasDiagnostic(ValidationRuleConventionsAnalyzer.DiagnosticId); + await Assert.That(result).HasDiagnostics(1); + } + + [Test] + public async Task GivenGenericRuleWithConstants_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public readonly record struct RangeRule : ZodSharp.Core.IValidationRule + where T : IComparable + { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Out of range: {0}"; + + public bool IsValid(in T value) => true; + + public string GetErrorMessage(in T value) => string.Format(MessageFormat, value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenNonRuleType_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public sealed class NotARule + { + public string? Name { get; set; } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs new file mode 100644 index 0000000..07472f0 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs @@ -0,0 +1,262 @@ +using System.Globalization; +using System.Text; + +namespace ZodSharp.Rules; + +public class NonSentinelRuleTests +{ + [Test] + public async Task IsValid_GivenGuidEmpty_ReturnsFalse() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(Guid.Empty); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenNonEmptyGuid_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(Guid.NewGuid()); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenDateTimeBounds_ReturnsFalse() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var minIsValid = rule.IsValid(DateTime.MinValue); + var maxIsValid = rule.IsValid(DateTime.MaxValue); + + // Assert + await Assert.That(minIsValid).IsFalse(); + await Assert.That(maxIsValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenRegularDateTime_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenDateTimeOffsetBounds_ReturnsFalse() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var minIsValid = rule.IsValid(DateTimeOffset.MinValue); + var maxIsValid = rule.IsValid(DateTimeOffset.MaxValue); + + // Assert + await Assert.That(minIsValid).IsFalse(); + await Assert.That(maxIsValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenRegularDateTimeOffset_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero)); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenDateOnlyBounds_ReturnsFalse() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var minIsValid = rule.IsValid(DateOnly.MinValue); + var maxIsValid = rule.IsValid(DateOnly.MaxValue); + + // Assert + await Assert.That(minIsValid).IsFalse(); + await Assert.That(maxIsValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenRegularDateOnly_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(new DateOnly(2024, 1, 2)); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenTimeOnlyBounds_ReturnsFalse() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var minIsValid = rule.IsValid(TimeOnly.MinValue); + var maxIsValid = rule.IsValid(TimeOnly.MaxValue); + + // Assert + await Assert.That(minIsValid).IsFalse(); + await Assert.That(maxIsValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenRegularTimeOnly_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(new TimeOnly(12, 30)); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + public async Task IsValid_GivenNullEmptyOrWhitespaceString_ReturnsFalse(string? value) + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(value!); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenNonEmptyString_ReturnsTrue() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid("value"); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenTypeWithoutKnownSentinel_ReturnsTrue() + { + // Arrange — int has no built-in sentinel, so its default value is accepted. + NonSentinelRule rule = new(); + + // Act + var isValid = rule.IsValid(0); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task GetErrorMessage_GivenNoCustomMessage_FormatsDefaultMessage() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var message = rule.GetErrorMessage(Guid.Empty); + var expected = string.Format( + CultureInfo.CurrentCulture, + CompositeFormat.Parse(NonSentinelRule.MessageFormat), + Guid.Empty + ); + + // Assert + await Assert.That(message).IsEqualTo(expected); + } + + [Test] + public async Task GetErrorMessage_GivenCustomMessage_ReturnsCustomMessage() + { + // Arrange + NonSentinelRule rule = new("Custom sentinel message."); + + // Act + var message = rule.GetErrorMessage(Guid.Empty); + + // Assert + await Assert.That(message).IsEqualTo("Custom sentinel message."); + } + + [Test] + public async Task Code_GivenRule_ReturnsErrorCodeConstant() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var code = rule.Code; + + // Assert + await Assert.That(code).IsEqualTo(NonSentinelRule.ErrorCode); + } + + [Test] + public async Task Validate_GivenSentinelDate_ReportsErrorCodeAndEmptyPath() + { + // Arrange + var schema = Z.Date().AddRule(new NonSentinelRule()); + + // Act + var result = schema.Validate(DateTime.MinValue); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors).HasSingleItem(); + await Assert.That(result.Errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(result.Errors[0].Path).IsEmpty(); + } + + [Test] + public async Task Validate_GivenWhitespaceString_ReportsErrorCode() + { + // Arrange + var schema = Z.String().AddRule(new NonSentinelRule()); + + // Act + var result = schema.Validate(" "); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors).HasSingleItem(); + await Assert.That(result.Errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs new file mode 100644 index 0000000..df8db09 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; + +namespace ZodSharp.Rules; + +/// +/// Guards the public error-identity constants every built-in rule exposes, so tests (and consumers) can +/// assert against a single source of truth instead of duplicating literal codes and message formats. +/// +public class RuleConstantsTests +{ + /// + /// Every public struct in that implements + /// . + /// + public static IEnumerable RuleTypes() => + typeof(IntRule) + .Assembly.GetTypes() + .Where(type => type is { IsPublic: true, IsValueType: true } && type.Namespace == "ZodSharp.Rules") + .Where(type => + type.GetInterfaces() + .Any(@interface => + @interface.IsGenericType + && @interface.GetGenericTypeDefinition() == typeof(Core.IValidationRule<>) + ) + ); + + /// The rule types that can be instantiated without closing an open generic. + public static IEnumerable ConcreteRuleTypes() => RuleTypes().Where(type => !type.IsGenericTypeDefinition); + + [Test] + [MethodDataSource(nameof(RuleTypes))] + public async Task Rule_Always_ExposesErrorCodeAndMessageFormatConstants(Type ruleType) + { + // Act + var errorCode = ruleType.GetField("ErrorCode", BindingFlags.Public | BindingFlags.Static); + var messageFormat = ruleType.GetField("MessageFormat", BindingFlags.Public | BindingFlags.Static); + + // Assert — the constants exist, are compile-time literals, and are non-empty. + await Assert.That(errorCode).IsNotNull(); + await Assert.That(messageFormat).IsNotNull(); + await Assert.That(errorCode!.IsLiteral).IsTrue(); + await Assert.That(messageFormat!.IsLiteral).IsTrue(); + await Assert.That((string)errorCode.GetRawConstantValue()!).IsNotEmpty(); + await Assert.That((string)messageFormat.GetRawConstantValue()!).IsNotEmpty(); + } + + [Test] + [MethodDataSource(nameof(ConcreteRuleTypes))] + public async Task Rule_Always_ReportsErrorCodeConstant(Type ruleType) + { + // Arrange — a default instance is enough: the Code property only returns the constant. + var instance = Activator.CreateInstance(ruleType); + + // Act + var code = (string?)ruleType.GetProperty("Code")!.GetValue(instance); + var expected = (string)ruleType.GetField("ErrorCode")!.GetRawConstantValue()!; + + // Assert + await Assert.That(code).IsEqualTo(expected); + } +} From 6c809399e8d6ed19939e8b710289f0443bdb4ea5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 11:46:23 +0100 Subject: [PATCH 07/26] docs: updated with custom rules --- docs/wiki/Custom-Rules.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 53fb290..65a0f52 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -65,6 +65,7 @@ Close it with the property type to use it through an attribute (`[ZodRule(typeof ## Defining a custom rule ```csharp +using System.Globalization; using ZodSharp.Core; namespace MyRules; @@ -72,6 +73,9 @@ namespace MyRules; /// Rejects strings that contain whitespace. public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + public bool IsValid(in string value) { if (value is null) @@ -86,11 +90,15 @@ public readonly record struct NoWhitespaceRule(string? Message = null) : IValida return true; } + public string Code => ErrorCode; + public string GetErrorMessage(in string value) => - Message ?? $"Whitespace is not allowed in '{value}'."; + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); } ``` +The public `ErrorCode`/`MessageFormat` constants are optional but recommended (see [Error code and message constants](#error-code-and-message-constants)); omitting them is reported as `ZODSGEN042`, and a rule without them reports the fallback code `validation_failed` when it does not also implement `IZodRule`. + The rule can be used standalone: ```csharp @@ -111,7 +119,7 @@ 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].Code == NoWhitespaceRule.ErrorCode ("invalid_string") // result.Errors[0].Message == "No spaces allowed." // result.Errors[0].Path is empty ``` @@ -501,6 +509,7 @@ Because the rule is closed with `TSelf` (`NotEmptyRule`), it *sees the | ZODSGEN038 | Warning | A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), leaving some usages of the attribute unresolved. | | ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter without implementing `IZodRule`, so the value never reaches the reported error identity. | | ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | +| ZODSGEN042 | Warning | A validation rule does not expose public `const string ErrorCode` / `MessageFormat` constants, so its error identity cannot be asserted in tests without duplicating literals. | See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. From f70b08b39a6b7dc61376e2436797b7bc11d511ce Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 11:48:27 +0100 Subject: [PATCH 08/26] docs: updated with custom rules --- AGENTS.md | 2 ++ README.md | 5 +++++ docs/wiki/Core-Concepts.md | 2 +- docs/wiki/Fluent-Schema-API.md | 6 ++++-- docs/wiki/Guarantees-and-Limitations.md | 2 +- docs/wiki/Home.md | 1 + 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 066e5ec..938b648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ The generator and analyzer are built with `Purview.SourceGeneratorFramework`: - Use `ForAttributeWithMetadataName` for attribute-driven discovery. - Test incrementally, not just generated text (see the skills above). +Validation rules follow a conventions analyzer (`ValidationRuleConventionsAnalyzer`, diagnostic `ZODSGEN042`): a source-declared rule (a type implementing `ZodSharp.Core.IValidationRule`) must expose its error identity as public `const string ErrorCode` and `const string MessageFormat` constants, so tests can assert against the rule rather than duplicating literals. New built-in rules must follow the same convention; keep `AnalyzerReleases.Shipped.md`/`AnalyzerReleases.Unshipped.md` in sync when a diagnostic is added or changed. + ## Packing and package READMEs Each package ships its own `README.md`, placed in the project's `Sdk/` folder (for example `src/src/ZodSharp/Sdk/README.md`). The SDK's `PurviewAutoSdkPack` automatically maps `Sdk/*.md` to the package root and `Sdk/buildTransitive/**` to `buildTransitive/`, and the repo-root `README.md` is skipped when a package already packs its own README. Packages also ship `purview-logo-light.png` (linked via `src/Directory.Build.props`) and the core package ships `buildTransitive/Purview.ZodSharp.props`. diff --git a/README.md b/README.md index 01f7d55..7a947b7 100644 --- a/README.md +++ b/README.md @@ -250,10 +250,15 @@ All validation rules are structs: ```csharp public readonly struct MinLengthRule : IValidationRule { + public const string ErrorCode = "too_small"; + public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; + // Zero allocation validation } ``` +Every rule exposes its reported code and message template as public `ErrorCode`/`MessageFormat` constants so tests can assert against the rule instead of duplicating literals (`ZODSGEN042` enforces this convention — see [Custom Rules](docs/wiki/Custom-Rules.md)). + #### 3. Compiled Validators Use expression trees to compile validators at runtime for maximum speed: diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 421b5f7..bdab450 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -5,7 +5,7 @@ Every schema derives from `ZodType` (namespace `ZodSharp.Core`). Validation is a two-phase pipeline: 1. **ParseInternal** — each schema overrides this hook to perform its type check and traversal (rejecting `null` where not allowed, coercing types, walking objects/arrays/tuples/unions, producing structured failures). -2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` carrying the rule's Zod-compatible code (`too_small`, `too_big`, `not_multiple_of`, `not_finite`, or `invalid_string`; `validation_failed` for custom rules without a code). +2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` carrying the rule's Zod-compatible code (`too_small`, `too_big`, `not_multiple_of`, `not_finite`, `invalid_string`, `invalid_type`, or `invalid_value`; `validation_failed` for custom rules without a code). Each rule exposes its reported code and message as public `const string ErrorCode` / `MessageFormat` constants, so tests can assert against the rule instead of duplicating literals (a rule that omits them is reported as `ZODSGEN042` — see [Custom Rules](Custom-Rules.md)). ```csharp ValidationResult result = schema.Validate(value); diff --git a/docs/wiki/Fluent-Schema-API.md b/docs/wiki/Fluent-Schema-API.md index 42dddfb..953fd17 100644 --- a/docs/wiki/Fluent-Schema-API.md +++ b/docs/wiki/Fluent-Schema-API.md @@ -48,11 +48,13 @@ 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. `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. +- `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. Every rule exposes its reported code and message template as public `const string ErrorCode` / `MessageFormat` constants (enforced by `ZODSGEN042`). `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). +The shipped `ZodSharp.Rules` namespace provides the built-in rules (for example `EmailRule`, `MinLengthRule`, `NonSentinelRule`); each is a `readonly record struct` usable standalone or through the fluent API. + +See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules and the `ErrorCode`/`MessageFormat` constants convention). ## Convenience composition on any schema diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 6d25dbf..60798cb 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -33,7 +33,7 @@ The generator reports `Origin = "string"` for string size failures, `Origin = "a ### Rule errors -Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, and `invalid_string` for string-format validations. Custom rules that do not declare a code default to `validation_failed`. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators. +Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, `invalid_string` for string-format validations, `invalid_type` for `IntRule`, and `invalid_value` for `NonSentinelRule`. Custom rules that do not declare a code default to `validation_failed`. Every rule exposes its correlated code and message template as public `const string ErrorCode` / `MessageFormat` constants so tests can assert against the rule rather than duplicating literals; a source-declared rule that omits them is reported as `ZODSGEN042`. 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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 93ef651..f4b24c4 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -53,6 +53,7 @@ This wiki is the project documentation hub for the core API, source generator, J - **Fluent API** — `Z.String().Min(3).Max(50).Email()`, composable objects, arrays, unions, tuples, records, discriminators, and more. - **Native C# 15 unions (.NET 11+)** — `Z.NativeUnion` returns an allocation-free native union for reference-type cases, with exhaustive pattern matching; `Z.Union` stays the zero-allocation choice for value-type cases. The analyzer reports `ZODSGEN041` and offers a code fix. - **Structured issues** — failures carry machine-readable `Code`, `Path`, `Origin`, `Minimum`/`Maximum`, and `Inclusive` metadata in addition to a human message. +- **Test-friendly rule identity** — every rule exposes its code and message template as public `ErrorCode`/`MessageFormat` constants (the analyzer reports `ZODSGEN042` when one is missing), so tests assert against the rule instead of duplicating literals. `NonSentinelRule` rejects the ORM sentinel values (`Guid.Empty`, `DateTime.MinValue`/`MaxValue`, and more). - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. - **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`. - **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails). From 4207f256bd02910038161b85b3b97b193195f763 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 17:48:38 +0100 Subject: [PATCH 09/26] feat: added support for more expansive downstream rule generation (vo) --- docs/wiki/Custom-Rules.md | 64 +++- docs/wiki/Guarantees-and-Limitations.md | 2 +- .../Helpers/CustomRuleResolver.cs | 160 +++++++++- .../SourceGenerators/Helpers/TypeLibrary.cs | 9 + .../Models/CustomRuleDescriptor.cs | 9 +- .../ZodSchemaGenerator.CustomRules.cs | 32 +- src/src/ZodSharp/Sdk/README.md | 2 +- .../ZodSchemaAnalyzerTests.CustomRules.cs | 61 ++++ ...odSchemaGeneratorTests.ScalarAdaptation.cs | 295 ++++++++++++++++++ 9 files changed, 621 insertions(+), 13 deletions(-) create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 65a0f52..e491f11 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -391,7 +391,7 @@ if (!assetIdCustomRule0.IsValid(value)) } ``` -- **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. +- **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. On a `[Scalar]` type, a rule written against the underlying value is instead closed with that value and wrapped — see [Validating scalar value objects](#validating-scalar-value-objects). - **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`. @@ -491,11 +491,69 @@ 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). +### Reusing a rule written against the underlying value + +A rule written against the underlying value (`IValidationRule`) is **adapted automatically** when it is applied to a `[Scalar]` type: the generator reads the scalar's `Value` property, closes the rule with the underlying value, and wraps it so the rule still runs against the value object as a unit. One rule then serves every scalar backed by the same primitive, with no per-primitive rule family. + +```csharp +// MyRules/NonSentinelRule.cs — a rule written against the value, not the value object +public readonly record struct NonSentinelRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable +{ + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; + + string? IZodRule.Code => "invalid_value"; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NonSentinelRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NonSentinelAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +The generator emits the rule against `Guid` and wraps it in the adapter the value-objects generator emits: + +```csharp +var assetIdCustomRuleInner0 = new global::MyRules.NonSentinelRule("AssetId must not be empty."); +var assetIdCustomRule0 = new global::Purview.ValueObjects.ScalarRuleAdapter>(assetIdCustomRuleInner0); +if (!assetIdCustomRule0.IsValid(value)) +{ + (errors ??= new List()).Add( + ValidationError.Create( + ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Code ?? "validation_failed", + assetIdCustomRule0.GetErrorMessage(value), + EmptyPath, + origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Origin)); +} +``` + +The reported error keeps the **empty path**, and the error identity (`Code`/`Origin`) is read from the wrapped rule. A rule whose constraint asks for the scalar itself (the `IScalarValueObject` form above) is **not** adapted — it keeps closing over the value object. + +> [!IMPORTANT] +> `Purview.ValueObjects.ScalarRuleAdapter` is emitted into your compilation by the **Purview.ValueObjects** source generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. + > [!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). +> 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 an `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`). +> If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit the attribute on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`). ## Diagnostics diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 60798cb..6626baf 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -57,7 +57,7 @@ Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_ ## 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. +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. A rule written against a `Purview.ValueObjects` scalar's underlying value is adapted automatically when it is applied to a `[Scalar]` type, so one rule also serves every scalar backed by the same primitive. 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 diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index f20ca18..461a9a4 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -61,7 +61,15 @@ ImmutableArray.Builder diagnostics if (!TryGetRuleMapping(attributeClass, out var mapping)) continue; - if (!TryResolveRuleType(mapping.RuleType, ruleTargetType, out var ruleType)) + if ( + !TryResolveRule( + mapping.RuleType, + ruleTargetType, + out var ruleType, + out var scalarTarget, + out var scalarValue + ) + ) { diagnostics.Add( ReportableDiagnostic.Create( @@ -76,7 +84,10 @@ ImmutableArray.Builder diagnostics continue; } - if (!ImplementsRuleFor(ruleType, ruleTargetType)) + // An adapted rule is a ScalarRuleAdapter the value-object generator emits into this compilation, + // which this generator cannot see. Its contract is the underlying rule validating the scalar's + // value, already verified during resolution, so only the direct path is checked here. + if (scalarTarget is null && !ImplementsRuleFor(ruleType, ruleTargetType)) { diagnostics.Add( ReportableDiagnostic.Create( @@ -109,7 +120,10 @@ ImmutableArray.Builder diagnostics builder ??= ImmutableArray.CreateBuilder(); builder.Add( new CustomRuleDescriptor( - new TypeIdentity(ruleType), + scalarTarget is null + ? new TypeIdentity(ruleType) + : BuildScalarAdapterIdentity(scalarTarget, scalarValue!, ruleType), + scalarTarget is null ? null : new TypeIdentity(ruleType), GetAttributeString(attribute, "Code") ?? mapping.Code, GetAttributeString(attribute, "Origin") ?? mapping.Origin, ValidationAttributeData.FromAttributeData(attribute), @@ -176,6 +190,146 @@ attribute.AttributeClass is not null == $"{TypeLibraryGenerator.ZodSharpCoreNamespace}.ZodRuleAttribute" && attribute.ConstructorArguments.Length == 0; + /// + /// Resolves the rule to instantiate for . A scalar value object is + /// validated through its underlying value, so a rule that validates that value is adapted to the value + /// object; a rule written against the value object itself keeps the direct path. + /// + /// The rule type declared by the mapping. + /// The type the rule is applied to. + /// The rule type to instantiate (the wrapped rule when adapted). + /// The scalar value object to adapt for, or . + /// The scalar's underlying value type, or . + /// when a usable rule was resolved. + static bool TryResolveRule( + INamedTypeSymbol ruleType, + ITypeSymbol targetType, + out INamedTypeSymbol instantiated, + out INamedTypeSymbol? scalarTarget, + out ITypeSymbol? scalarValue + ) + { + scalarTarget = null; + scalarValue = null; + + if ( + TryGetScalarUnderlyingValue(targetType, out var valueType) + && TryResolveUnderlyingRule(ruleType, valueType, out var underlyingRule) + ) + { + instantiated = underlyingRule; + scalarTarget = (INamedTypeSymbol)targetType; + scalarValue = valueType; + return true; + } + + return TryResolveRuleType(ruleType, targetType, out instantiated); + } + + /// + /// Resolves against a scalar's underlying value: an unbound generic is closed + /// with the value type and a plain rule is used as-is, in both cases only when the result validates that + /// value. + /// + /// The rule type declared by the mapping. + /// The scalar's underlying value type. + /// The rule type that validates the value. + /// when a usable rule was resolved. + static bool TryResolveUnderlyingRule( + INamedTypeSymbol ruleType, + ITypeSymbol valueType, + out INamedTypeSymbol resolved + ) + { + resolved = ruleType; + + if (IsOpenGeneric(ruleType)) + { + var definition = ruleType.OriginalDefinition; + if (definition is null || definition.Arity != 1 || !SatisfiesConstraints(definition, valueType)) + return false; + + var closed = definition.Construct(valueType); + if (!ImplementsRuleFor(closed, valueType)) + return false; + + resolved = closed; + return true; + } + + return ImplementsRuleFor(ruleType, valueType); + } + + /// + /// Gets the underlying value type of a scalar value object: a type marked with the Purview.ValueObjects + /// [Scalar] attribute that exposes a public property for the underlying value. The interface + /// implementation is contributed by another generator, so the property is the source-visible contract. + /// + /// The type to inspect. + /// The scalar's underlying value type, when it is a scalar. + /// when is a scalar value object. + static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) + { + valueType = null!; + + if (type is not INamedTypeSymbol named) + return false; + + var propertyName = GetScalarPropertyName(named); + if (propertyName is null) + return false; + + foreach (var member in named.GetMembers(propertyName)) + { + if (member is IPropertySymbol { IsStatic: false, DeclaredAccessibility: Accessibility.Public } property) + { + valueType = property.Type; + return true; + } + } + + return false; + } + + /// + /// Gets the name of the member holding a scalar's underlying value: the [Scalar] attribute's + /// propertyName argument ([Scalar("Id")]), defaulting to Value. + /// + /// The type to inspect. + /// The property name, or when the type is not a scalar. + static string? GetScalarPropertyName(INamedTypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != TypeLibraryGenerator.ScalarAttributeFullName) + continue; + + if ( + attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0].Value is string name + && !string.IsNullOrWhiteSpace(name) + ) + { + return name; + } + + return "Value"; + } + + return null; + } + + static TypeIdentity BuildScalarAdapterIdentity( + INamedTypeSymbol scalar, + ITypeSymbol valueType, + INamedTypeSymbol underlyingRule + ) => + new TypeIdentity( + TypeLibraryGenerator.ScalarRuleAdapterName, + TypeLibraryGenerator.ValueObjectsNamespace, + 3 + ).MakeGeneric(new TypeIdentity(scalar), new TypeIdentity(valueType), new TypeIdentity(underlyingRule)); + /// /// 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, and a rule that cannot diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index 7a43ceb..73b6d6b 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -14,6 +14,15 @@ static partial class TypeLibraryGenerator public const string ZodSharpSchemasNamespace = ZodSharpNamespace + ".Schemas"; + // The Purview.ValueObjects scalar contracts. A scalar value object is validated through its underlying + // value, so a rule written against that value is adapted by the ScalarRuleAdapter the value-object + // generator emits into the consuming compilation; the adapter is referenced by name, not by a package. + public const string ValueObjectsNamespace = "Purview.ValueObjects"; + + public const string ScalarAttributeFullName = ValueObjectsNamespace + ".Serialization.ScalarAttribute"; + + public const string ScalarRuleAdapterName = "ScalarRuleAdapter"; + // Simple name of the refinement context type declared in ZodSharpSchemasNamespace. public const string ZodRefineContextName = "RefineCtx"; diff --git a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs index ecc42ad..05b8754 100644 --- a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs +++ b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs @@ -7,15 +7,22 @@ namespace ZodSharp.SourceGenerators.Models; /// carrying [ZodRule(typeof(...))]. /// /// The rule type to instantiate. +/// +/// The rule wrapped by when it is a scalar rule adapter, otherwise +/// . A scalar value object is validated through its underlying value, so a rule +/// written against that value is constructed first and passed to the adapter. +/// /// 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. +/// Whether the rule implements IZodRule and therefore supplies its own code/origin at runtime. When +/// the rule is adapted, this reflects the wrapped rule, which is what supplies the identity. /// readonly record struct CustomRuleDescriptor( TypeIdentity RuleType, + TypeIdentity? AdaptedFrom, string? Code, string? Origin, ValidationAttributeData Message, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 601b08c..a3c3e8c 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -68,7 +68,31 @@ bool declareValueLocal if (declareValueLocal) writer.Assignment("var", valueVariable, valueExpression); - writer.Assignment("var", ruleVariable, $"new {rule.RuleType.AsTypeReference().RenderFullName}{arguments}"); + // A scalar rule adapter wraps a rule written against the scalar's underlying value. The wrapped + // rule is constructed first so it can supply the error identity (the adapter itself has none). + var identityVariable = ruleVariable; + if (rule.AdaptedFrom is { } adaptedRuleType) + { + identityVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleInner{i}"); + writer.Assignment( + "var", + identityVariable, + $"new {adaptedRuleType.AsTypeReference().RenderFullName}{arguments}" + ); + writer.Assignment( + "var", + ruleVariable, + $"new {rule.RuleType.AsTypeReference().RenderFullName}({identityVariable})" + ); + } + else + { + 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; @@ -76,13 +100,13 @@ bool declareValueLocal // 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()}" + ? $"(({zodRuleInterface}){identityVariable}).Code ?? {codeFallback.Surround()}" : codeFallback.Surround(); var originFallback = rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() : null; var originExpression = rule.RuleOwnsIdentity ? originFallback is null - ? $"(({zodRuleInterface}){ruleVariable}).Origin" - : $"(({zodRuleInterface}){ruleVariable}).Origin ?? {originFallback}" + ? $"(({zodRuleInterface}){identityVariable}).Origin" + : $"(({zodRuleInterface}){identityVariable}).Origin ?? {originFallback}" : originFallback ?? "null"; var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 42ea5d1..9d94905 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -248,7 +248,7 @@ public readonly partial record struct AssetId } ``` -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. +The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule written against the underlying value (for example `NonSentinelRule`) is instead adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by the same primitive. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. ## Error factory diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs index 0760771..9dada9e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -562,4 +562,65 @@ public readonly record struct NotAValueObject var result = await AnalyzeAsync(source, cancellationToken); await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); } + + /// + /// A rule written against a scalar value object's underlying value resolves without a diagnostic: it is + /// adapted to the value object, so the rule is not dropped as an unsupported target. + /// + [Test] + public async Task TypeRule_GivenRuleWrittenAgainstScalarUnderlyingValue_ProducesNoDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Core; + + namespace Purview.ValueObjects.Serialization + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : Attribute { } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + } + + namespace Testing + { + public readonly record struct NonSentinelRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; + } + + [ZodRule(typeof(NonSentinelRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonSentinelAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel] + [ZodSchema] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs new file mode 100644 index 0000000..00f0131 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -0,0 +1,295 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + /// + /// A scalar value object whose rules are written against its underlying value. The + /// Purview.ValueObjects types are declared here because this repository does not reference the + /// value-objects package; in a real consumer the adapter is emitted by the value-object generator. + /// + const string ScalarAdaptationSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Purview.ValueObjects.Serialization + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : Attribute + { + public ScalarAttribute(string propertyName = "Value") => PropertyName = propertyName; + + public string PropertyName { get; } + } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + // Stands in for the adapter the value-object generator emits into a real consumer. + public readonly record struct ScalarRuleAdapter(TRule Rule) + : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule + { + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); + } + } + + namespace Testing + { + public readonly record struct NonSentinelRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; + + string? IZodRule.Code => "invalid_value"; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NonSentinelRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonSentinelAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "TenantId must not be the default.")] + [ZodSchema] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + [Test] + public async Task ScalarAdaptation_GivenRuleWrittenAgainstTheUnderlyingValue_EmitsAdapter( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(ScalarAdaptationSource, cancellationToken); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert — the rule is constructed for the Guid and wrapped in the scalar adapter, which validates + // the value object as a unit. + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NonSentinelRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + // The wrapped rule owns the identity, not the adapter. + await Assert + .That(generated) + .ContainsGeneratedCode( + "((global::ZodSharp.Core.IZodRule)tenantIdCustomRuleInner0).Code ?? \"validation_failed\"" + ); + await Assert.That(generated).DoesNotContain("((global::ZodSharp.Core.IZodRule)tenantIdCustomRule0)"); + } + + [Test] + public async Task ScalarAdaptation_GivenRuleWrittenAgainstTheUnderlyingValue_FailsAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + ScalarAdaptationSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.TenantId")!; + var schemaType = assembly.GetType("Testing.TenantIdSchema")!; + var validate = schemaType.GetMethod("Validate")!; + + // Act — the default Guid is Guid.Empty, which the underlying rule rejects. + 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_value"); + await Assert.That(errors[0].Origin).IsEqualTo("value_object"); + await Assert.That(errors[0].Message).IsEqualTo("TenantId must not be the default."); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task ScalarAdaptation_GivenRuleWrittenAgainstTheScalar_KeepsTheDirectRule( + CancellationToken cancellationToken + ) + { + // A rule whose constraint asks for the scalar itself is a scalar rule family member: it must keep + // closing over the value object rather than being adapted. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Purview.ValueObjects.Serialization + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : Attribute { } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + } + + namespace Testing + { + public readonly record struct NotEmptyRule(string? Code = null) + : IValidationRule, IZodRule + where TSelf : Purview.ValueObjects.IScalarValueObject + { + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => "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; } + } + + [Purview.ValueObjects.Serialization.Scalar] + [NotEmpty(Code = "invalid_asset_id")] + [ZodSchema] + public partial record struct AssetId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AssetIdSchema"); + + // Assert — the rule closes over the value object and is not adapted. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::Testing.NotEmptyRule("); + await Assert.That(generated).DoesNotContain("ScalarRuleAdapter"); + } + + [Test] + public async Task ScalarAdaptation_GivenRenamedScalarProperty_AdaptsFromThatProperty( + CancellationToken cancellationToken + ) + { + // The scalar's underlying member can be renamed with [Scalar("Id")], so the adapter must read the + // value from the declared property rather than assuming Value. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Purview.ValueObjects.Serialization + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : Attribute + { + public ScalarAttribute(string propertyName = "Value") => PropertyName = propertyName; + + public string PropertyName { get; } + } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + } + + namespace Testing + { + public readonly record struct NonSentinelRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; + + string? IZodRule.Code => "invalid_value"; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NonSentinelRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonSentinelAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [Purview.ValueObjects.Serialization.Scalar("Id")] + [NonSentinel(Message = "TenantId must not be the default.")] + [ZodSchema] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Id { get; init; } + } + } + """; + + // Act — the stand-in adapter reads Value, which this renamed scalar does not declare, so the emitted + // rule is asserted instead of compiled. + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NonSentinelRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + } +} From 11455ffaa4d446cf55866cfb529d105d4af5fe43 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 4 Oct 2026 13:32:37 +0100 Subject: [PATCH 10/26] docs: listing rules and custom rules --- README.md | 3 +- docs/wiki/Custom-Rules.md | 253 +++++++-------- docs/wiki/Fluent-Schema-API.md | 6 +- docs/wiki/Guarantees-and-Limitations.md | 2 +- docs/wiki/Home.md | 3 + docs/wiki/Number-Validation.md | 6 +- docs/wiki/Source-Generator.md | 2 + docs/wiki/String-Validation.md | 4 +- docs/wiki/Validation-Rules-Reference.md | 194 +++++++++++ docs/wiki/Value-Objects-Integration.md | 305 ++++++++++++++++++ docs/wiki/_Sidebar.md | 2 + .../Helpers/CustomRuleResolver.cs | 1 + src/src/ZodSharp/Core/ZodType.cs | 19 ++ src/src/ZodSharp/Schemas/ZodDate.cs | 12 + src/src/ZodSharp/Schemas/ZodString.cs | 30 ++ src/src/ZodSharp/Sdk/README.md | 38 ++- .../Rules/NonSentinelRuleTests.cs | 52 +++ .../Rules/RuleConstantsTests.cs | 2 +- .../Schemas/ZodStringTests.cs | 36 +++ 19 files changed, 819 insertions(+), 151 deletions(-) create mode 100644 docs/wiki/Validation-Rules-Reference.md create mode 100644 docs/wiki/Value-Objects-Integration.md diff --git a/README.md b/README.md index 7a947b7..81737b1 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ public readonly struct MinLengthRule : IValidationRule } ``` -Every rule exposes its reported code and message template as public `ErrorCode`/`MessageFormat` constants so tests can assert against the rule instead of duplicating literals (`ZODSGEN042` enforces this convention — see [Custom Rules](docs/wiki/Custom-Rules.md)). +Every rule exposes its reported code and message template as public `ErrorCode`/`MessageFormat` constants so tests can assert against the rule instead of duplicating literals (`ZODSGEN042` enforces this convention — see the [Validation Rules Reference](docs/wiki/Validation-Rules-Reference.md) for the catalogue of built-in rules, [Custom Rules](docs/wiki/Custom-Rules.md) for the rule contract and [Value Objects Integration](docs/wiki/Value-Objects-Integration.md) for scalar value objects). #### 3. Compiled Validators @@ -554,6 +554,7 @@ var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult or a mi - Zero-reflection, zero-allocation validators - Value-first composition methods (`.ApplyAnd()`, `.ApplyOr()`, `.ApplyRefine()`) plus instance schema-composing composition (`.Refine()`, `.SuperRefine()`, `.Pipe()`, `.Catch()`, `.Prefault()`, `.Default()`) - Supports classes, structs, and records +- Validates `Purview.ValueObjects` `[Scalar]` types as a unit, adapting a rule written against the underlying value automatically ([Value Objects Integration](docs/wiki/Value-Objects-Integration.md)) #### Supported DataAnnotations size validators diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index e491f11..a28fe84 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -23,7 +23,7 @@ Implementations should be structs so validation does not allocate. `IsValid` is 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. -## Error code and message constants +## Error code and message definitions Every rule **should** expose its error identity as public constants so code (and tests) can assert against the rule rather than re-typing literals: @@ -39,7 +39,13 @@ public readonly record struct EmailRule : IValidationRule } ``` -`ErrorCode` is the value reported in `ValidationError.Code`; `MessageFormat` is a `string.Format` template whose `{0}`-style placeholders are the offending value (and any rule-specific arguments). A custom `message` supplied to the rule still wins over `MessageFormat`. A `[Test]` can therefore assert without duplicating strings: +- **`ErrorCode`** is the rule's canonical code — the value a test compares against (`error.Code`). The interface member `IValidationRule.Code` defaults to `"validation_failed"`; built-in rules override it as `public string Code => ErrorCode;`. +- **`MessageFormat`** is a `string.Format` template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments; format it with `string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, …)`. +- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. + +The convention is enforced by an analyzer: a source-declared rule that does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat` is reported as **ZODSGEN042**. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. + +A `[Test]` can therefore assert without duplicating strings: ```csharp await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); @@ -47,7 +53,31 @@ await Assert.That(error.Message).IsEqualTo( string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); ``` -The convention is enforced by an analyzer: a source-declared rule that does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat` is reported as **ZODSGEN042**. The constants may be inherited from a base rule class, and abstract bases are exempt so a shared base can host them for its concrete derivations. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. +### Runtime identity: `IZodRule` + +The constants are the rule's *static* default. When one attribute must produce a different code per annotated member, the rule implements `ZodSharp.Core.IZodRule` and supplies its own code/origin at runtime: + +```csharp +public interface IZodRule +{ + string? Code { get; } // null falls back to the attribute-mapped code + string? Origin { get; } // null falls back to the attribute-mapped origin +} +``` + +The generated code casts to `IZodRule` when reading the values, so the identity can depend on the rule's constructor arguments. A rule that accepts a `code`/`origin` constructor parameter **without** implementing `IZodRule` never reaches the reported error identity and is reported as **ZODSGEN039**. + +The reported code and origin are resolved from the rule (when it implements `IZodRule`), then the applied attribute, then the `[ZodRule(typeof(...))]` mapping, and finally default to `validation_failed` — see [Error identity: code and origin precedence](#error-identity-code-and-origin-precedence) for the full order. + +### Message overrides + +The reported message is resolved in this order: + +1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. +2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped to a constructor parameter named `message`. +3. `GetErrorMessage(value)`, which formats `MessageFormat` with the offending value. + +`MessageFormat` is therefore the fallback, not the only message. ## Non-sentinel values (EF-friendly) @@ -97,7 +127,7 @@ public readonly record struct NoWhitespaceRule(string? Message = null) : IValida } ``` -The public `ErrorCode`/`MessageFormat` constants are optional but recommended (see [Error code and message constants](#error-code-and-message-constants)); omitting them is reported as `ZODSGEN042`, and a rule without them reports the fallback code `validation_failed` when it does not also implement `IZodRule`. +Every rule **should** declare the public `ErrorCode`/`MessageFormat` constants (see [Error code and message definitions](#error-code-and-message-definitions)); omitting them is reported as `ZODSGEN042`, and a rule without them reports the fallback code `validation_failed` when it does not also implement `IZodRule`. The rule can be used standalone: @@ -126,6 +156,54 @@ var result = schema.Validate("John Doe"); Both methods mutate the receiver and return it for chaining; see [Guarantees and Limitations](Guarantees-and-Limitations.md#fluent-rule-methods-mutate-the-receiver). +## Extending the fluent interface + +Every built-in rule has a dedicated method on its schema — `Z.String().Email()`, `Z.Number().Min(...)`, `Z.Date().NonSentinel()`, and so on. A custom rule gets the same ergonomics with an **extension method** that adds the rule to the receiver and returns it: + +```csharp +using ZodSharp.Schemas; + +namespace MyRules; + +public static class ZodStringRuleExtensions +{ + /// Rejects strings that contain whitespace. + public static ZodString NoWhitespace(this ZodString schema, string? message = null) + { + schema.AddRule(new NoWhitespaceRule(message)); + return schema; + } +} +``` + +The call site then mirrors the built-in surface and stays chainable: + +```csharp +var schema = Z.String().Min(3).NoWhitespace().ToUpper(); +``` + +Two details keep the chain intact: + +- **Return the concrete schema type** (`ZodString`, `ZodDate`, …), not the base `ZodType`. The generic `Rule` helper and `AddRule` return the base type, so write `schema.Rule(new MyRule()); return schema;` (or call `AddRule` and return `schema`) rather than returning the result of `Rule(...)` directly. +- **Target the schema whose output type the rule validates.** A rule implementing `IValidationRule` extends `ZodString`; a rule implementing `IValidationRule` extends whichever schema produces a `Guid`. For a rule that applies to *any* schema, extend the single-type-argument base `ZodType`: + +```csharp +using ZodSharp.Core; + +public static class ZodTypeRuleExtensions +{ + /// Rejects the default value of the schema's output type. + public static ZodType NotEmpty(this ZodType schema, string? message = null) + where T : struct, IEquatable + { + schema.AddRule(new NotEmptyRule(Message: message)); + return schema; + } +} +``` + +A fluent extension only covers the runtime API. To make the same rule usable from `[ZodSchema]` models, map it to an attribute with `[ZodRule]` (see below). The built-in catalogue — including each rule's `ErrorCode` and `MessageFormat` — is in [Validation Rules Reference](Validation-Rules-Reference.md). + ## 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: @@ -193,9 +271,13 @@ public readonly record struct NotEmptyRule(string? Code = null, string? Messa : IValidationRule, IZodRule where T : struct, IEquatable { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in T value) => !value.Equals(default(T)); - public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in T value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); // The rule owns its identity, so callers can pass a per-member error code. string? IZodRule.Code => Code; @@ -255,19 +337,30 @@ namespace MyRules; public readonly record struct NonWhiteSpaceStringRule(string? Message = null) : IValidationRule { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); - public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in string? value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); } // Value-object half: validates the scalar as a unit. public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) - : IValidationRule + : IValidationRule, IZodRule where TSelf : IScalarValueObject { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); - public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in TSelf value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); + + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; } [ZodRule(typeof(NonWhiteSpaceStringRule<>))] @@ -298,9 +391,13 @@ namespace MyRules; public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) : IValidationRule { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed."; + public bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0; - public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + public string GetErrorMessage(in string value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); } ``` @@ -336,9 +433,13 @@ Mapping rules: [ZodRule(AllowMultiple = true)] public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule { + public const string ErrorCode = "not_multiple_of"; + public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; + public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; - public string GetErrorMessage(in int value) => Message ?? "Not a multiple."; + public string GetErrorMessage(in int value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, Factor, value); } [ZodSchema] @@ -391,7 +492,7 @@ if (!assetIdCustomRule0.IsValid(value)) } ``` -- **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. On a `[Scalar]` type, a rule written against the underlying value is instead closed with that value and wrapped — see [Validating scalar value objects](#validating-scalar-value-objects). +- **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. On a `[Scalar]` type, a rule written against the underlying value is instead closed with that value and wrapped — see [Value Objects Integration](Value-Objects-Integration.md). - **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`. @@ -403,66 +504,9 @@ if (!assetIdCustomRule0.IsValid(value)) ## 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 -partial void OnZodValidate(RefineCtx context) -{ - if (context.Value.Value == Guid.Empty) - context.AddIssue("invalid_asset_id", "AssetId must not be empty.", [nameof(Value)]); -} -``` - -> [!NOTE] -> Refinements are written as the generator-declared `OnZodValidate` hook, not an -> `IEnumerable Validate()` method — see -> [Source Generator](Source-Generator.md#refinement-hook-onzodvalidate). - -Type **one** rule on the value object and put the attribute on the **scalar type**: - -```csharp -// 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; } -} -``` +A `Purview.ValueObjects` scalar **is** a single value, so validate it as a unit rather than through its `Value` property. Attach a rule at the **type level** (on the scalar type, not on its `Value` property) and the reported error has an **empty path**: ```csharp -// Purview.ChangeOps -using Purview.ValueObjects.Serialization; -using ZodSharp; - [Scalar] [ZodSchema] [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] @@ -470,18 +514,8 @@ 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." @@ -489,62 +523,7 @@ 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). - -### Reusing a rule written against the underlying value - -A rule written against the underlying value (`IValidationRule`) is **adapted automatically** when it is applied to a `[Scalar]` type: the generator reads the scalar's `Value` property, closes the rule with the underlying value, and wraps it so the rule still runs against the value object as a unit. One rule then serves every scalar backed by the same primitive, with no per-primitive rule family. - -```csharp -// MyRules/NonSentinelRule.cs — a rule written against the value, not the value object -public readonly record struct NonSentinelRule(string? Message = null) - : IValidationRule, IZodRule - where T : IEquatable -{ - public bool IsValid(in T value) => !value.Equals(default(T)!); - - public string GetErrorMessage(in T value) => Message ?? "Value must not be the default."; - - string? IZodRule.Code => "invalid_value"; - - string? IZodRule.Origin => "value_object"; -} - -[ZodRule(typeof(NonSentinelRule<>))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] -public sealed class NonSentinelAttribute : ValidationAttribute -{ - public string? Message { get; set; } -} -``` - -```csharp -[Scalar] -[ZodSchema] -[NonSentinel(Message = "AssetId must not be empty.")] -public readonly partial record struct AssetId -{ - public Guid Value { get; init; } -} -``` - -The generator emits the rule against `Guid` and wraps it in the adapter the value-objects generator emits: - -```csharp -var assetIdCustomRuleInner0 = new global::MyRules.NonSentinelRule("AssetId must not be empty."); -var assetIdCustomRule0 = new global::Purview.ValueObjects.ScalarRuleAdapter>(assetIdCustomRuleInner0); -if (!assetIdCustomRule0.IsValid(value)) -{ - (errors ??= new List()).Add( - ValidationError.Create( - ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Code ?? "validation_failed", - assetIdCustomRule0.GetErrorMessage(value), - EmptyPath, - origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Origin)); -} -``` - -The reported error keeps the **empty path**, and the error identity (`Code`/`Origin`) is read from the wrapped rule. A rule whose constraint asks for the scalar itself (the `IScalarValueObject` form above) is **not** adapted — it keeps closing over the value object. +A rule can be written against the value object itself (`where TSelf : IScalarValueObject`), or against the underlying value (`IValidationRule`) and adapted automatically by the value-objects generator's `ScalarRuleAdapter`. > [!IMPORTANT] > `Purview.ValueObjects.ScalarRuleAdapter` is emitted into your compilation by the **Purview.ValueObjects** source generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. @@ -555,6 +534,8 @@ The reported error keeps the **empty path**, and the error identity (`Code`/`Ori > [!TIP] > If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit the attribute on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`). +See [Value Objects Integration](Value-Objects-Integration.md) for the full walkthrough: wiring `[Scalar]` + `[ZodSchema]`, both rule shapes, the generated code, the error code / message definitions, and testing. + ## Diagnostics | ID | Severity | Meaning | @@ -573,7 +554,9 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full ## Related +- [Validation Rules Reference](Validation-Rules-Reference.md) — the catalogue of built-in rules. - [Fluent Schema API](Fluent-Schema-API.md) — `AddRule`/`Rule` live on `ZodType`. +- [Value Objects Integration](Value-Objects-Integration.md) — `[Scalar]` value objects and the `ScalarRuleAdapter`. - [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 953fd17..7988ae3 100644 --- a/docs/wiki/Fluent-Schema-API.md +++ b/docs/wiki/Fluent-Schema-API.md @@ -48,13 +48,13 @@ 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. `ZodType.AddRule(rule)` and `Rule(rule)` are public, so custom rules can be attached to any schema. Every rule exposes its reported code and message template as public `const string ErrorCode` / `MessageFormat` constants (enforced by `ZODSGEN042`). `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path. +- `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, and a fluent extension method can give them a first-class method like the built-in rules. Every rule exposes its reported code and message template as public `const string ErrorCode` / `MessageFormat` constants (enforced by `ZODSGEN042`). `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`. -The shipped `ZodSharp.Rules` namespace provides the built-in rules (for example `EmailRule`, `MinLengthRule`, `NonSentinelRule`); each is a `readonly record struct` usable standalone or through the fluent API. +The shipped `ZodSharp.Rules` namespace provides the built-in rules (for example `EmailRule`, `MinLengthRule`, `NonSentinelRule`); each is a `readonly record struct` usable standalone or through the fluent API. Every rule has a fluent method on its schema — see [Validation Rules Reference](Validation-Rules-Reference.md) for the catalogue. -See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules and the `ErrorCode`/`MessageFormat` constants convention). +See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules, the `ErrorCode`/`MessageFormat` constants convention, and [fluent extension methods](Custom-Rules.md#extending-the-fluent-interface)). ## Convenience composition on any schema diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 6626baf..06cacbb 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -57,7 +57,7 @@ Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_ ## 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. A rule written against a `Purview.ValueObjects` scalar's underlying value is adapted automatically when it is applied to a `[Scalar]` type, so one rule also serves every scalar backed by the same primitive. 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. +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. A rule written against a `Purview.ValueObjects` scalar's underlying value is adapted automatically when it is applied to a `[Scalar]` type, so one rule also serves every scalar backed by the same primitive. 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, and [Value Objects Integration](Value-Objects-Integration.md) for the `[Scalar]` walkthrough and error code / message definitions. ## Contract vs. underlying libraries diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f4b24c4..cd5846b 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -23,6 +23,8 @@ This wiki is the project documentation hub for the core API, source generator, J - [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md) - [Composition and Transforms](Composition-and-Transforms.md) - [Custom Rules](Custom-Rules.md) +- [Validation Rules Reference](Validation-Rules-Reference.md) +- [Value Objects Integration](Value-Objects-Integration.md) - [Compiled Validators and Caching](Compiled-Validators-and-Caching.md) - [Dependency Injection](Dependency-Injection.md) @@ -56,6 +58,7 @@ This wiki is the project documentation hub for the core API, source generator, J - **Test-friendly rule identity** — every rule exposes its code and message template as public `ErrorCode`/`MessageFormat` constants (the analyzer reports `ZODSGEN042` when one is missing), so tests assert against the rule instead of duplicating literals. `NonSentinelRule` rejects the ORM sentinel values (`Guid.Empty`, `DateTime.MinValue`/`MaxValue`, and more). - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. - **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`. +- **Value object scalars** — a `Purview.ValueObjects` `[Scalar]` can be validated as a unit with `[ZodSchema]`; a rule written against the scalar's underlying value is adapted automatically. See [Value Objects Integration](Value-Objects-Integration.md). - **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails). - **Cross-platform tests** — a shared TypeScript/Zod fixture set is generated into the repo and asserted against from both the C# test suite and a vitest suite. - **Multi-target** — packages target `net8.0`, `net9.0`, `net10.0`, and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. \ No newline at end of file diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index 57caabd..05c1cf1 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -47,4 +47,8 @@ var age = Z.Number().Min(0).Max(120).Int().Validate(25.0); ## Numeric coercion -When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`. \ No newline at end of file +When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`. + +## See also + +- [Validation Rules Reference](Validation-Rules-Reference.md) — every built-in rule with its error code and message format. \ No newline at end of file diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index f67e84a..1781bf7 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -155,4 +155,6 @@ 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`. +- A `Purview.ValueObjects` scalar marked with `[Scalar]` can carry `[ZodSchema]` on the same type; the generated validator validates the scalar as a unit and reports an empty path. A rule written against the scalar's underlying value is adapted automatically — see [Value Objects Integration](Value-Objects-Integration.md). + 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 1c567ed..1205424 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -19,6 +19,7 @@ var result = schema.Validate("user@example.com"); | `Email` | `Email()` | `EmailRule` — static compiled regex | | `Regex` | `Regex(Regex pattern, string? message)` / `Regex(string pattern, string? message)` | `RegexRule`; the string overload compiles with a 100 ms timeout | | `Url` | `Url(string? message)` | `UrlRule` — regex or absolute `http`/`https` URI | +| `Uri` | `Uri(string? message)` / `Uri(UriKind uriKind, string? message)` | `UriRule` — `Uri.TryCreate` against the supplied `UriKind` (defaults to `RelativeOrAbsolute`) | | `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit | | `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm | | `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` | @@ -38,6 +39,7 @@ var result = schema.Validate("user@example.com"); | `Nanoid` | `Nanoid(string? message)` | `NanoidRule` — 21 URL-safe characters | | `Cuid2` | `Cuid2(string? message)` | `Cuid2Rule` — lowercase alphanumeric characters | | `E164` | `E164(string? message)` | `E164Rule` — `+` followed by 7-15 digits | +| `NonSentinel` | `NonSentinel(string? message)` | `NonSentinelRule` — rejects `null`/empty/whitespace; inherited from `ZodType` and overridden to keep `ZodString` in the chain | | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | | `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) | | `Trim` | `Trim()` | wraps a transform (`Trim`) | @@ -71,7 +73,7 @@ var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); ## Error messages -Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. +Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. For the full catalogue — including rules without a fluent method — see [Validation Rules Reference](Validation-Rules-Reference.md). Every rule exposes its error identity as public constants — `public const string ErrorCode` and a `public const string MessageFormat` (a `{0}`-style template) — so tests and consumers can assert against the rule instead of duplicating literals: diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md new file mode 100644 index 0000000..537c017 --- /dev/null +++ b/docs/wiki/Validation-Rules-Reference.md @@ -0,0 +1,194 @@ +# Validation Rules Reference + +Every built-in validation rule lives in the `ZodSharp.Rules` namespace and is a `readonly record struct` implementing `ZodSharp.Core.IValidationRule`. Rules are the smallest unit of validation: a schema is a type/structural check followed by an ordered list of rules, and each failing rule adds a `ValidationError` to the result. + +This page is the catalogue of the rules that ship with the core package. For authoring your own, see [Custom Rules](Custom-Rules.md). + +## The rule contract + +```csharp +namespace ZodSharp.Core; + +public interface IValidationRule +{ + bool IsValid(in T value); + string GetErrorMessage(in T value); + string Code => "validation_failed"; +} +``` + +- String rules additionally implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so `ZodString.ValidateSpan`/`IsValidSpan` can validate without materialising the input. +- Every rule exposes its error identity as public constants: `public const string ErrorCode` and `public const string MessageFormat` (a `{0}`-style `string.Format` template). `Code` returns `ErrorCode`, and `GetErrorMessage` formats `MessageFormat` with `RuleMessage.Format` (which caches the parsed `CompositeFormat`). +- A constructor `message` parameter, when supplied, overrides the formatted `MessageFormat`. `MessageFormat` is the fallback, not the only message. +- The analyzer reports `ZODSGEN042` when a source-declared rule omits `ErrorCode`/`MessageFormat`. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md). + +Rules can be used three ways: + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +// 1. Standalone +var rule = new EmailRule(); +if (!rule.IsValid("not-an-email")) + Console.WriteLine(rule.GetErrorMessage("not-an-email")); + +// 2. Attached to a schema (AddRule or the generic Rule helper) +var schema = Z.String().AddRule(new EmailRule()); +var same = Z.String().Rule(new EmailRule()); + +// 3. Surfaced as a DataAnnotations attribute the [ZodSchema] generator honours +// (built-in mappings, or a custom rule via [ZodRule]). See Source Generator DataAnnotations. +``` + +Tests can assert against the rule's constants instead of duplicating literals: + +```csharp +var result = Z.String().Email().Validate("not-an-email"); +await Assert.That(result.Errors[0].Code).IsEqualTo(EmailRule.ErrorCode); // "invalid_string" +await Assert.That(result.Errors[0].Message).IsEqualTo( + string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, "not-an-email")); +``` + +## Error codes + +The built-in rules report one of the following Zod-compatible codes: + +| Code | Meaning | Emitted by | +|---|---|---| +| `invalid_string` | A string did not match the required format | all string format rules | +| `invalid_type` | A value was not the expected type/shape (e.g. not a whole number) | `IntRule` | +| `too_small` | A length or value fell below a lower bound | `MinLengthRule`, `MinValueRule`, `GreaterThanRule` | +| `too_big` | A length or value exceeded an upper bound | `MaxLengthRule`, `MaxValueRule`, `LessThanRule`, `SafeIntegerRule` | +| `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | +| `not_finite` | A number was `NaN` or infinite | `FiniteRule` | +| `invalid_value` | A value was a rejected sentinel | `NonSentinelRule` | +| `validation_failed` | Fallback for rules that do not declare an identity | any rule without `ErrorCode`/`IZodRule` | + +## String rules + +Fluent methods live on `ZodString` (`ZodSharp.Schemas`). The `Span` column marks rules that implement `IStringValidationRule` and therefore stay on the zero-allocation span path. + +### Length + +| Rule | Constructor | Fluent method | Code | Message format | Span | +|---|---|---|---|---|---| +| `MinLengthRule` | `(int minLength)` | `Min(n)`, `Length(n)` | `too_small` | `String must be at least {0} characters long, but got {1}` | yes | +| `MaxLengthRule` | `(int maxLength)` | `Max(n)`, `Length(n)` | `too_big` | `String must be at most {0} characters long, but got {1}` | yes | + +`Z.String().Length(n)` adds **both** rules, so the string must be exactly `n` characters. + +### Format + +| Rule | Constructor | Fluent method | Code | Message format | Span | +|---|---|---|---|---|---| +| `EmailRule` | `()` | `Email()` | `invalid_string` | `Invalid email format: {0}` | yes | +| `RegexRule` | `(Regex pattern, string? message)` or `(string pattern, string? message)` | `Regex(...)` | `invalid_string` | `String does not match the required pattern: {0}` | yes | +| `UrlRule` | `(string? message)` | `Url()` | `invalid_string` | `Invalid URL format: {0}` | no | +| `UriRule` | `(UriKind uriKind, string? message)` | `Uri()`, `Uri(uriKind)` | `invalid_string` | `Invalid Uri, kind: {0}, format: {1}` | no | +| `PhoneRule` | `(string? message)` | `Phone()` | `invalid_string` | `Invalid phone number format: {0}` | yes | +| `E164Rule` | `(string? message)` | `E164()` | `invalid_string` | `Invalid E.164 phone number: {0}` | yes | +| `CreditCardRule` | `(string? message)` | `CreditCard()` | `invalid_string` | `Invalid credit card number format: {0}` | yes | +| `Base64StringRule` | `(string? message)` | `Base64String()` | `invalid_string` | `Invalid Base64 string format: {0}` | no | +| `Base64UrlRule` | `(string? message)` | `Base64Url()` | `invalid_string` | `Invalid base64url string: {0}` | yes | +| `UUIDRule` | `(string? message)` or `(UuidVersion version, string? message)` | `UUID()` / `UUID(version)` | `invalid_string` | `Invalid UUID format: {0}` (or `Invalid UUID v{0} format: {1}`) | yes | +| `ULIDRule` | `(string? message)` | `ULID()` | `invalid_string` | `Invalid ULID: {0}` | yes | +| `JWTRule` | `(string? message)` | `JWT()` | `invalid_string` | `Invalid JWT: {0}` | yes | +| `IPAddressRule` | `(string? message)` | `IP()` | `invalid_string` | `Invalid IP address: {0}` | yes | +| `HexRule` | `(string? message)` | `Hex()` | `invalid_string` | `Invalid hexadecimal string: {0}` | yes | +| `DateStringRule` | `(string? message)` | `Date()` | `invalid_string` | `Invalid date: {0}` | yes | +| `TimeStringRule` | `(string? message)` | `Time()` | `invalid_string` | `Invalid time: {0}` | yes | +| `DatetimeStringRule` | `(string? message)` | `Datetime()` | `invalid_string` | `Invalid date-time: {0}` | yes | +| `NanoidRule` | `(string? message)` | `Nanoid()` | `invalid_string` | `Invalid nanoid: {0}` | yes | +| `Cuid2Rule` | `(string? message)` | `Cuid2()` | `invalid_string` | `Invalid CUID2: {0}` | yes | +| `StartsWithRule` | `(string prefix, string? message)` | `StartsWith(prefix)` | `invalid_string` | `String must start with '{0}', but got '{1}'` | yes | +| `EndsWithRule` | `(string suffix, string? message)` | `EndsWith(suffix)` | `invalid_string` | `String must end with '{0}', but got '{1}'` | yes | +| `IncludesRule` | `(string substring, string? message)` | `Includes(substring)` | `invalid_string` | `String must contain '{0}', but got '{1}'` | yes | + +Behaviour notes: + +- **`EmailRule`** matches a compiled, case-insensitive regex (`^[^@\s]+@[^@\s]+\.[^@\s]+$`). +- **`RegexRule`** uses the supplied `Regex`; the `Z.String().Regex(string)` overload compiles the pattern with a 100 ms timeout. Its message's `{0}` is the pattern, not the value. +- **`UrlRule`** accepts the compiled HTTP(S) regex **or** an absolute `http`/`https` URI (`Uri.TryCreate`), so it may allocate when the fast regex misses. +- **`UriRule`** validates with `Uri.TryCreate` against the supplied `UriKind` and therefore allocates. `Z.String().Uri()` defaults to `UriKind.RelativeOrAbsolute`; pass an explicit `UriKind` to require an absolute or relative URI. +- **`PhoneRule`** mirrors `[Phone]`: digits plus `() .+-`, with at least one digit. +- **`E164Rule`** requires `+`, a first digit `1`-`9`, and 7-15 digits in total. +- **`CreditCardRule`** mirrors `[CreditCard]`: Luhn check, ignoring spaces and hyphens. +- **`Base64StringRule`** mirrors `[Base64String]`: `Convert.FromBase64String` must succeed. +- **`Base64UrlRule`** accepts URL-safe base64 (`A-Z a-z 0-9 - _`) with no padding; a length of `4n+1` is rejected. +- **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. +- **`ULIDRule`** requires 26 Crockford base32 characters, with the first character `0`-`7`. +- **`JWTRule`** requires three non-empty base64url-encoded segments separated by periods. +- **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`). +- **`HexRule`** accepts any run of ASCII hex digits; the empty string is valid, matching Zod. +- **`DateStringRule`** parses `yyyy-MM-dd` (invariant culture); **`TimeStringRule`** accepts `HH:mm`, optionally `:ss` and fractional seconds; **`DatetimeStringRule`** requires `yyyy-MM-dd` + `T` + `HH:mm:ss[.fff]` + `Z`. +- **`NanoidRule`** requires exactly 21 URL-safe characters; **`Cuid2Rule`** requires non-empty lowercase alphanumerics. +- **`StartsWithRule`**, **`EndsWithRule`**, and **`IncludesRule`** use ordinal comparison. + +## Number rules + +Fluent methods live on `ZodNumber` (`ZodSharp.Schemas`), which validates `double`. + +| Rule | Constructor | Fluent method | Code | Message format | +|---|---|---|---|---| +| `MinValueRule` | `(T minValue)` | `Min(v)`, `Gte(v)`, `NonNegative()` | `too_small` | `Value must be at least {0}, but got {1}` | +| `MaxValueRule` | `(T maxValue)` | `Max(v)`, `Lte(v)`, `NonPositive()` | `too_big` | `Value must be at most {0}, but got {1}` | +| `GreaterThanRule` | `(T exclusiveMinimum)` | `Gt(v)`, `Positive()` | `too_small` | `Value must be greater than {0}, but got {1}` | +| `LessThanRule` | `(T exclusiveMaximum)` | `Lt(v)`, `Negative()` | `too_big` | `Value must be less than {0}, but got {1}` | +| `MultipleOfRule` | `(double divisor, string? message)` | `MultipleOf(divisor)` | `not_multiple_of` | `Number must be a multiple of {0}, but got {1}` | +| `FiniteRule` | `(string? message)` | `Finite()` | `not_finite` | `Number must be finite, but got {0}` | +| `SafeIntegerRule` | `(string? message)` | `Safe()` | `too_big` | `Number must be a safe integer, but got {0}` | +| `IntRule` | `()` | `Int()` | `invalid_type` | `Expected integer, but got {0}` | + +Behaviour notes: + +- The bound rules are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). +- `Positive()` is `GreaterThanRule(0.0)`, `Negative()` is `LessThanRule(0.0)`, `NonNegative()` is `MinValueRule(0.0)`, and `NonPositive()` is `MaxValueRule(0.0)`. +- **`MultipleOfRule`** throws `ArgumentException` when the divisor is `0`, and compares the quotient to its nearest integer with a relative tolerance of `1e-12` (so `0.3` is accepted for a divisor of `0.1`). +- **`FiniteRule`** rejects `NaN` and infinities via `double.IsFinite`. +- **`SafeIntegerRule`** requires a whole number within `int.MinValue`..`int.MaxValue`. +- **`IntRule`** requires `value == Math.Truncate(value)`. + +## Value rules + +| Rule | Constructor | Fluent method | Code | Message format | +|---|---|---|---|---| +| `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value is a sentinel value, but got {0}` | + +`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. + +The fluent `NonSentinel()` method is declared on `ZodType`, so it is available on every schema and closes the rule with the schema's output type. `ZodString` and `ZodDate` override it with a covariant return type so the fluent chain keeps the concrete schema: + +```csharp +var schema = Z.Date().NonSentinel(); +var result = schema.Validate(DateTime.MinValue); +// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") + +// Covariant overrides keep the concrete schema, so later fluent calls still compile. +var chained = Z.String().NonSentinel().Min(3); +``` + +The same rule can be attached directly with `AddRule`/`Rule`, or closed with a property type through an attribute (`[ZodRule(typeof(NonSentinelRule<>))]` on a matching attribute) — see [Custom Rules](Custom-Rules.md#non-sentinel-values-ef-friendly). + +## Exposed as DataAnnotations attributes + +The `[ZodSchema]` generator maps several built-in rules to their `System.ComponentModel.DataAnnotations` attributes, and any custom rule can be mapped with `[ZodRule(typeof(...))]`: + +| Attribute | Rule | Failure code | +|---|---|---| +| `[EmailAddress]` | `EmailRule` | `invalid_string` | +| `[Url]` | `UrlRule` | `invalid_string` | +| `[Phone]` | `PhoneRule` | `invalid_string` | +| `[CreditCard]` | `CreditCardRule` | `invalid_string` | +| `[Base64String]` | `Base64StringRule` | `invalid_string` | +| `[RegularExpression]` | compiled `Regex`, not `RegexRule` | `invalid_string` | + +Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. + +## Related + +- [Custom Rules](Custom-Rules.md) — the `IValidationRule` contract, `IZodRule`, and attribute mapping. +- [String Validation](String-Validation.md) — the `ZodString` fluent surface and span validation. +- [Number Validation](Number-Validation.md) — the `ZodNumber` fluent surface. +- [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) — attributes the generator understands. +- [Source Generator Diagnostics](Source-Generator-Diagnostics.md) — `ZODSGEN042` and the rule conventions analyzer. diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md new file mode 100644 index 0000000..0e56d5e --- /dev/null +++ b/docs/wiki/Value-Objects-Integration.md @@ -0,0 +1,305 @@ +# Value Objects Integration + +A [Purview.ValueObjects](https://www.nuget.org/packages/Purview.ValueObjects) **scalar** is a strongly typed wrapper around a single value (`AssetId` around a `Guid`, `TenantId` around a `Guid`, `EmailAddress` around a `string`, and so on). Because a scalar *is* one value, ZodSharp validates it as a unit — not through its `Value` property — and reports the failure with an **empty path**. + +This page shows how to wire the `[Scalar]` generation to the `[ZodSchema]` generation, the two shapes a custom rule can take, and how the rule's error code and message are defined. For the general rule contract, see [Custom Rules](Custom-Rules.md). + +## Prerequisites and wiring + +Reference both packages: + +```bash +dotnet add package Purview.ValueObjects +dotnet add package Purview.ZodSharp +``` + +A scalar is declared with the `[Scalar]` attribute (from `Purview.ValueObjects.Serialization`) and given a ZodSharp schema with `[ZodSchema]`. The type must be `partial`: + +```csharp +using Purview.ValueObjects.Serialization; +using ZodSharp; + +namespace ChangeOps; + +[Scalar] +[ZodSchema] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +Each attribute brings in a different generator: + +| Attribute | Generator | What it emits | +|---|---|---| +| `[Scalar]` | **Purview.ValueObjects** | the `IScalarValueObject` implementation (and, when `Purview.ZodSharp` is referenced, the `ScalarRuleAdapter` used below) | +| `[ZodSchema]` | **Purview.ZodSharp** | the static `AssetIdSchema` validator (`Validate`/`Parse`, and so on) | + +The underlying member defaults to a public property named `Value`. Rename it with the attribute argument and the value-objects generator follows: + +```csharp +[Scalar("Id")] +[ZodSchema] +public readonly partial record struct TenantId +{ + public Guid Id { get; init; } +} +``` + +Scalars implement the two-type-parameter contract: + +```csharp +namespace Purview.ValueObjects; + +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`. + +> [!IMPORTANT] +> The ZodSharp generator discovers a scalar through the source-visible `[Scalar]` attribute and the public property it names — it does not depend on the `IScalarValueObject` interface, because that interface is contributed by another generator and may not be visible as a symbol in the same compilation pass. + +> [!NOTE] +> The `ScalarRuleAdapter` type is emitted into your compilation by the **Purview.ValueObjects** generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. + +## Two ways to attach a rule to a scalar + +There are two shapes, and the generator picks the right one from the rule's own constraints: + +1. **A rule written against the value object** (`where TSelf : IScalarValueObject`) closes over the scalar itself, so it can read state through the value-object contract. +2. **A rule written against the underlying value** (`IValidationRule`) is **adapted automatically**: the generator reads the scalar's property, closes the rule with the underlying value, and wraps it in `ScalarRuleAdapter` so the rule still runs against the value object as a unit. One rule then serves every scalar backed by the same primitive. + +In both cases the rule is attached at the **type level** — put the attribute on the scalar type, not on its `Value` property — and the reported error has an **empty path**: + +```text +Code = "invalid_asset_id" +Message = "AssetId must not be empty." +Origin = "value_object" +Path = [] +``` + +Type-level attributes need `AttributeTargets.Class | AttributeTargets.Struct` (and usually `Property`/`Field` too, so the same attribute can validate a primitive member): + +```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; } +} +``` + +## Error code and message definitions + +Every rule **should** expose its error identity as public constants so consumers and tests can assert against the rule rather than re-typing literals: + +```csharp +public const string ErrorCode = "invalid_value"; +public const string MessageFormat = "Value must not be empty."; +``` + +- `ErrorCode` is the rule's canonical code — the value a test compares against (`Assert.That(error.Code).IsEqualTo(NotEmptyRule.ErrorCode)`). +- `MessageFormat` is a `string.Format` template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments; use it with `string.Format(CultureInfo.CurrentCulture, MessageFormat, …)`. +- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. + +A rule that omits either constant is reported as **ZODSGEN042** (a warning). + +### Runtime identity: `IZodRule` + +The constants are the *static* default. When a single attribute must produce a different code per annotated scalar, the rule implements `ZodSharp.Core.IZodRule` and supplies its own code/origin at runtime: + +```csharp +public interface IZodRule +{ + string? Code { get; } // null falls back to the attribute-mapped code + string? Origin { get; } // null falls back to the attribute-mapped origin +} +``` + +The generated code reads the identity through an `IZodRule` cast, preferring the rule over the mapped attribute values. A rule that accepts a `code`/`origin` constructor parameter **without** implementing `IZodRule` never reaches the reported error identity and is reported as **ZODSGEN039**. + +The full precedence for the reported code and origin (first match wins): + +1. **Rule-owned** — the rule implements `IZodRule`, so its `Code`/`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 `[NotEmpty(Code = "invalid_asset_id")]`). +3. **Attribute-type mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]`. +4. **Default** — `validation_failed`, with no origin. + +### Message overrides + +The reported message is resolved in this order: + +1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. +2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped to a constructor parameter named `message`. +3. `GetErrorMessage(value)`, which formats `MessageFormat` with the offending value. + +`MessageFormat` is therefore the fallback, not the only message. + +### Rule against the value object + +Define one generic rule per underlying primitive, close it over the scalar, and implement `IZodRule` so each scalar keeps its own code: + +```csharp +// MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjects +using System.Globalization; +using ZodSharp.Core; + +namespace MyRules; + +public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat); + + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; +} +``` + +```csharp +// Purview.ChangeOps +using MyRules; +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; } +} +``` + +Because the rule closes over `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). This rule is **not** adapted: a rule whose constraint asks for the scalar keeps closing over the value object. + +### Rule against the underlying value (adapted) + +A rule written against the value (`IValidationRule`) is adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by that primitive: + +```csharp +// MyRules/NonSentinelRule.cs — a rule written against the value, not the value object +using System; +using System.Globalization; +using ZodSharp.Core; + +namespace MyRules; + +public readonly record struct NonSentinelRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value is a sentinel value, but got {0}"; + + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); + + string? IZodRule.Code => ErrorCode; + string? IZodRule.Origin => "value_object"; +} +``` + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +The generator emits the rule against `Guid` and wraps it in the adapter the value-objects generator emits: + +```csharp +var assetIdCustomRuleInner0 = new global::MyRules.NonSentinelRule("AssetId must not be empty."); +var assetIdCustomRule0 = new global::Purview.ValueObjects.ScalarRuleAdapter>(assetIdCustomRuleInner0); +if (!assetIdCustomRule0.IsValid(value)) +{ + (errors ??= new List()).Add( + ValidationError.Create( + ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Code ?? "validation_failed", + assetIdCustomRule0.GetErrorMessage(value), + EmptyPath, + origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Origin)); +} +``` + +The reported error keeps the **empty path**, and the error identity (`Code`/`Origin`) is read from the **wrapped** rule, not the adapter. + +> [!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 an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](Custom-Rules.md#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](Custom-Rules.md#generic-rules). + +## Testing + +Assert against the rule's constants instead of duplicating literals, and remember that an adapted scalar rule reads its identity from the wrapped rule: + +```csharp +// Direct scalar rule — the runtime identity comes from IZodRule or the attribute mapping. +var directError = AssetIdSchema.Validate(default).Errors[0]; + +await Assert.That(directError.Code).IsEqualTo("invalid_asset_id"); +await Assert.That(directError.Message).IsEqualTo("AssetId must not be empty."); +await Assert.That(directError.Origin).IsEqualTo("value_object"); +await Assert.That(directError.Path.Length).IsEqualTo(0); + +// Adapted underlying-value rule — the canonical identity is the wrapped rule's constant. +var adaptedError = TenantIdSchema.Validate(default).Errors[0]; + +await Assert.That(adaptedError.Code).IsEqualTo(NonSentinelRule.ErrorCode); // "invalid_value" +await Assert.That(adaptedError.Message).IsEqualTo("TenantId must not be empty."); // attribute override + +// With no Message override, the rule's MessageFormat is the message: +await Assert.That( + string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, Guid.Empty)) + .IsEqualTo("Value is a sentinel value, but got 00000000-0000-0000-0000-000000000000"); +``` + +## Diagnostics and troubleshooting + +| ID | Severity | Meaning | +|---|---|---| +| ZODSGEN030 | Error | The mapped rule does not implement `IValidationRule` for the target type, or an unbound generic rule could not be closed with it. | +| 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. | +| ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity. | +| ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | +| ZODSGEN042 | Warning | A rule does not expose public `const string ErrorCode` / `MessageFormat` constants. | + +Common causes: + +- **The rule never runs.** The scalar is missing `[ZodSchema]` (ZODSGEN033), or `[ZodSchema(GenerateValidateMethod = false)]` omitted the `Validate` method that hosts type-level rules. +- **The validator does not compile.** `DisableValueObjectsSourceGenerator` disabled the adapter; re-enable it, or express the rule against the value object so no adapter is needed. +- **The wrong rule shape resolved.** A rule constrained to `IScalarValueObject` is never adapted; a rule written against the underlying value is always adapted. If a family member cannot validate the target type, ZODSGEN030 is reported and nothing is emitted. + +## Related + +- [Custom Rules](Custom-Rules.md) — the rule contract, the public `AddRule`/`Rule` API, rule families, and attribute generation. +- [Source Generator](Source-Generator.md) — the `[ZodSchema]` generator, generated types, and the refinement hook. +- [Source Generator Diagnostics](Source-Generator-Diagnostics.md) — the full `ZODSGEN*` catalog. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 8cb102c..40a0e84 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -9,6 +9,8 @@ - [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md) - [Composition and Transforms](Composition-and-Transforms.md) - [Custom Rules](Custom-Rules.md) +- [Validation Rules Reference](Validation-Rules-Reference.md) +- [Value Objects Integration](Value-Objects-Integration.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/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 461a9a4..bbc488d 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -313,6 +313,7 @@ static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueT return name; } + // The attribute is present but no property name was supplied, so the default is used. return "Value"; } diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index 21be64b..f8d3b7c 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using ZodSharp.Rules; namespace ZodSharp.Core; @@ -119,6 +120,24 @@ public ZodType Rule(TRule rule) return AddRule(rule); } + /// + /// Adds a that rejects the framework default/boundary values + /// an ORM commonly stores to represent "no value" (for example or + /// ). Types without a known sentinel always pass. + /// + /// Optional error message. + /// This schema for method chaining. + /// + /// Concrete schemas whose output type has a known sentinel (for example + /// and ) override this method with a + /// covariant return type so the fluent chain keeps the concrete schema. + /// + public virtual ZodType NonSentinel(string? message = null) + { + AddRule(new NonSentinelRule(message)); + return this; + } + /// /// Gets the number of rules accumulated on this schema. /// diff --git a/src/src/ZodSharp/Schemas/ZodDate.cs b/src/src/ZodSharp/Schemas/ZodDate.cs index d772fbf..027245e 100644 --- a/src/src/ZodSharp/Schemas/ZodDate.cs +++ b/src/src/ZodSharp/Schemas/ZodDate.cs @@ -40,4 +40,16 @@ public ZodDate Max(DateTime maxValue) AddRule(new MaxValueRule(maxValue)); return this; } + + /// + /// Adds a non-sentinel validation that rejects and + /// . + /// + /// Optional error message + /// This schema for method chaining + public override ZodDate NonSentinel(string? message = null) + { + AddRule(new NonSentinelRule(message)); + return this; + } } diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 351e484..861f33d 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -201,6 +201,25 @@ public ZodString Url(string? message = null) return this; } + /// + /// Adds a URI format validation that accepts a relative or absolute URI. + /// + /// Optional error message + /// This schema for method chaining + public ZodString Uri(string? message = null) => Uri(UriKind.RelativeOrAbsolute, message); + + /// + /// Adds a URI format validation requiring the specified . + /// + /// The kind of URI the value must be + /// Optional error message + /// This schema for method chaining + public ZodString Uri(UriKind uriKind, string? message = null) + { + AddRule(new UriRule(uriKind, message)); + return this; + } + /// /// Adds a phone number format validation. /// @@ -432,6 +451,17 @@ public ZodString E164(string? message = null) return this; } + /// + /// Adds a non-sentinel validation that rejects null, empty, and whitespace strings. + /// + /// Optional error message + /// This schema for method chaining + public override ZodString NonSentinel(string? message = null) + { + AddRule(new NonSentinelRule(message)); + return this; + } + /// /// Transforms the string to lowercase. /// diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 9d94905..670ee77 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -141,8 +141,12 @@ public readonly record struct NotEmptyRule(string? Code = null, string? Messa : IValidationRule, IZodRule where T : struct, IEquatable { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in T value) => !value.Equals(default(T)); - public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in T value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } @@ -162,7 +166,7 @@ public partial record struct AssetId } ``` -`[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. +`[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 [Value Objects Integration](https://purview.dev/docs/zodsharp/value-objects-integration/) for the scalar value-object walkthrough and the error code / message definitions. ### One attribute for a primitive and a scalar value object @@ -172,16 +176,26 @@ A constraint can be *self-referential* (`where TSelf : IScalarValueObject { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); - public string GetErrorMessage(in string? value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in string? value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); } public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) - : IValidationRule + : IValidationRule, IZodRule where TSelf : IScalarValueObject { + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); - public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in TSelf value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; } [ZodRule(typeof(NonWhiteSpaceStringRule<>))] @@ -203,8 +217,12 @@ Add `[ZodRule(AllowMultiple = true)]` to emit `AttributeUsage(..., AllowMultiple [ZodRule(AllowMultiple = true)] public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule { + public const string ErrorCode = "not_multiple_of"; + public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; + public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; - public string GetErrorMessage(in int value) => Message ?? "Not a multiple."; + public string GetErrorMessage(in int value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, Factor, value); } [ZodSchema] @@ -225,8 +243,12 @@ public readonly record struct NotEmptyRule(string? Code = null, string? M : IValidationRule, IZodRule where TSelf : IScalarValueObject { + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; - public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; + public string GetErrorMessage(in TSelf value) => + Message ?? string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat); string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } @@ -248,7 +270,7 @@ public readonly partial record struct AssetId } ``` -The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule written against the underlying value (for example `NonSentinelRule`) is instead adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by the same primitive. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. +The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule written against the underlying value (for example `NonSentinelRule`) is instead adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by the same primitive. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. See [Value Objects Integration](https://purview.dev/docs/zodsharp/value-objects-integration/) for the full `[Scalar]` walkthrough and the error code / message definitions. ## Error factory diff --git a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs index 07472f0..9c4672b 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs @@ -259,4 +259,56 @@ public async Task Validate_GivenWhitespaceString_ReportsErrorCode() await Assert.That(result.Errors).HasSingleItem(); await Assert.That(result.Errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); } + + [Test] + public async Task NonSentinel_GivenFluentStringSchema_RejectsWhitespace() + { + // Arrange + var schema = Z.String().NonSentinel(); + + // Act + var result = schema.Validate(" "); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors).HasSingleItem(); + await Assert.That(result.Errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); + } + + [Test] + public async Task NonSentinel_GivenFluentDateSchema_RejectsMinValue() + { + // Arrange + var schema = Z.Date().NonSentinel(); + + // Act + var result = schema.Validate(DateTime.MinValue); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); + } + + [Test] + public async Task NonSentinel_GivenFluentChain_KeepsConcreteSchema() + { + // Arrange / Act — the covariant override keeps ZodString so later fluent calls still compile. + var result = Z.String().NonSentinel().Min(3).Validate("ab"); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + } + + [Test] + public async Task NonSentinel_GivenTypeWithoutKnownSentinel_AlwaysPasses() + { + // Arrange + var schema = Z.Number().NonSentinel(); + + // Act + var result = schema.Validate(42.0); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs index df8db09..9e61730 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs @@ -9,7 +9,7 @@ namespace ZodSharp.Rules; public class RuleConstantsTests { /// - /// Every public struct in that implements + /// Every public struct in that implements /// . /// public static IEnumerable RuleTypes() => diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs index 4304326..c1720d6 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; +using ZodSharp.Rules; namespace ZodSharp.Schemas; @@ -367,4 +368,39 @@ public async Task StringE164_GivenValue_ReturnsExpectedResult(string value, bool await Assert.That(result.IsSuccess).IsEqualTo(expected); } + + [Test] + [Arguments("https://example.com/path?x=1", true)] + [Arguments("/relative/path", true)] + [Arguments("relative/path", true)] + [Arguments("", false)] + [Arguments(" ", false)] + [Arguments("http://[invalid", false)] + public async Task StringUri_GivenValue_ReturnsExpectedResult(string value, bool expected) + { + var result = Z.String().Uri().Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + [Arguments(UriKind.Absolute, "https://example.com", true)] + [Arguments(UriKind.Absolute, "relative/path", false)] + [Arguments(UriKind.Relative, "relative/path", true)] + public async Task StringUri_GivenUriKind_ReturnsExpectedResult(UriKind uriKind, string value, bool expected) + { + var result = Z.String().Uri(uriKind).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + + [Test] + public async Task StringUri_GivenCustomMessage_ReportsMessage() + { + var result = Z.String().Uri(UriKind.Absolute, "Must be an absolute URI.").Validate("relative/path"); + + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo(UriRule.ErrorCode); + await Assert.That(result.Errors[0].Message).IsEqualTo("Must be an absolute URI."); + } } From a1e5cfd9aff9b827f63cfe5fe5b7c08d20709e30 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 4 Oct 2026 17:26:15 +0100 Subject: [PATCH 11/26] feat: auto geneated attributes for all rule types --- docs/wiki/Custom-Rules.md | 47 +- docs/wiki/Number-Validation.md | 12 +- docs/wiki/Source-Generator-DataAnnotations.md | 4 +- docs/wiki/Source-Generator-Diagnostics.md | 3 +- docs/wiki/Source-Generator.md | 1 + docs/wiki/Validation-Rules-Reference.md | 63 ++- docs/wiki/Value-Objects-Integration.md | 5 +- .../AnalyzerReleases.Unshipped.md | 1 + .../Helpers/CustomRuleResolver.cs | 174 +++++++- .../Helpers/DiagnosticLibrary.cs | 16 + .../SourceGenerators/Helpers/TypeLibrary.cs | 2 + .../ZodSchemaGenerator.CustomRules.cs | 2 +- .../ZodSchemaGenerator.RuleAttributes.cs | 152 ++++--- .../ZodSharp/Expressions/CompiledValidator.cs | 2 +- .../JsonSchema/ToJsonSchemaConverter.cs | 40 +- .../JsonSchema/ToJsonSchemaOptions.cs | 23 + .../Options/MissingValidatorBehavior.cs | 22 + .../Options/ZodSchemaOptionsValidator.cs | 49 +-- src/src/ZodSharp/Rules/Base64StringRule.cs | 12 +- src/src/ZodSharp/Rules/Base64UrlRule.cs | 7 +- src/src/ZodSharp/Rules/CreditCardRule.cs | 12 +- src/src/ZodSharp/Rules/Cuid2Rule.cs | 7 +- src/src/ZodSharp/Rules/DateStringRule.cs | 7 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 10 +- src/src/ZodSharp/Rules/E164Rule.cs | 7 +- src/src/ZodSharp/Rules/EmailRule.cs | 7 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 7 +- src/src/ZodSharp/Rules/EvenRule.cs | 48 +++ src/src/ZodSharp/Rules/FiniteRule.cs | 7 +- .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 50 +++ src/src/ZodSharp/Rules/GreaterThanRule.cs | 7 +- src/src/ZodSharp/Rules/HexRule.cs | 7 +- src/src/ZodSharp/Rules/IPAddressRule.cs | 7 +- src/src/ZodSharp/Rules/IncludesRule.cs | 7 +- src/src/ZodSharp/Rules/IntRule.cs | 7 +- src/src/ZodSharp/Rules/JWTRule.cs | 7 +- src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 50 +++ src/src/ZodSharp/Rules/LessThanRule.cs | 7 +- src/src/ZodSharp/Rules/MaxLengthRule.cs | 19 +- src/src/ZodSharp/Rules/MaxValueRule.cs | 7 +- src/src/ZodSharp/Rules/MinLengthRule.cs | 19 +- src/src/ZodSharp/Rules/MinValueRule.cs | 7 +- src/src/ZodSharp/Rules/MultipleOfRule.cs | 7 +- src/src/ZodSharp/Rules/NanoidRule.cs | 7 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 7 +- src/src/ZodSharp/Rules/OddRule.cs | 48 +++ src/src/ZodSharp/Rules/PhoneRule.cs | 12 +- src/src/ZodSharp/Rules/RegexRule.cs | 7 +- src/src/ZodSharp/Rules/RuleMessage.cs | 4 +- src/src/ZodSharp/Rules/SafeIntegerRule.cs | 7 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 7 +- src/src/ZodSharp/Rules/TimeStringRule.cs | 7 +- src/src/ZodSharp/Rules/ULIDRule.cs | 7 +- src/src/ZodSharp/Rules/UriRule.cs | 7 +- src/src/ZodSharp/Rules/UrlRule.cs | 12 +- src/src/ZodSharp/Rules/UuidRule.cs | 7 +- src/src/ZodSharp/Schemas/ZodNumber.cs | 26 +- .../AnalyzerReleaseTrackingTests.cs | 1 + ...emaGeneratorTests.BuiltInRuleAttributes.cs | 406 ++++++++++++++++++ ...aGeneratorTests.RuleAttributeGeneration.cs | 8 +- .../Rules/GeneratedRuleAttributesTests.cs | 181 ++++++++ .../Rules/NumericRulesTests.cs | 185 ++++++++ 62 files changed, 1704 insertions(+), 199 deletions(-) create mode 100644 src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs create mode 100644 src/src/ZodSharp/Options/MissingValidatorBehavior.cs create mode 100644 src/src/ZodSharp/Rules/EvenRule.cs create mode 100644 src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs create mode 100644 src/src/ZodSharp/Rules/LessThanOrEqualRule.cs create mode 100644 src/src/ZodSharp/Rules/OddRule.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index a28fe84..df191ca 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -406,7 +406,9 @@ 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.Class + | global::System.AttributeTargets.Struct + | global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter, Inherited = true, @@ -416,13 +418,17 @@ public sealed class NoWhitespaceAttribute : global::System.ComponentModel.DataAnnotations.ValidationAttribute { public bool AllowEmpty { get; set; } = true; + + public string? Message { get; set; } = null; } ``` 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. +- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument); a parameter named `code` becomes a `Code` property; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. +- If the derived name collides with a `System.ComponentModel.DataAnnotations` attribute, the generated attribute is emitted under a `Zod` suffix (`MinLengthAttribute` → `MinLengthZodAttribute`, used as `[MinLengthZod]`). +- The attribute is always decorated with `AttributeTargets.Class | Struct | Property | Field | Parameter`, so it can annotate a member or a scalar value object. (`Class`/`Struct` are what make the [type-level form](#type-level-rules) possible.) - The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). - An **arity-1 generic rule** can be marked as well: the generated attribute maps to the open generic (`[ZodRule(typeof(NonWhiteSpaceStringRule<>))]`), which is the form that serves both a primitive member and a scalar value object. Rules with two or more type parameters are rejected (`ZODSGEN032`). - When both halves of a [rule family](#rule-families-one-attribute-for-a-primitive-and-a-scalar-value-object) are marked, only the generic half emits the attribute; the two mappings would otherwise claim the same name. @@ -454,7 +460,41 @@ public partial class Sample - A **hand-authored** attribute whose name encodes a rule name (`XAttribute` → `XRule`) must map to a rule that addresses every rule declared under that name. A mapping that declares only a non-generic rule while an arity-1 generic sibling exists (or that declares an unrelated rule) is reported as `ZODSGEN038`, because some usages of the attribute would resolve to no rule. An attribute name that does not encode a declared rule family is left alone, so free-form names remain valid. > [!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(...))]`. +> 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* a 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(...))]`. The built-in rules below already satisfy this: their attributes ship inside `Purview.ZodSharp`. + +## Built-in attributes (shipped with Purview.ZodSharp) + +Every built-in rule that can be expressed as an attribute is generated once into the `Purview.ZodSharp` assembly, in the `ZodSharp.Rules` namespace, so a consumer can annotate a member or a scalar without hand-authoring anything: + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[ZodSchema] +public partial class Contact +{ + [Email] + public string Email { get; set; } = string.Empty; + + [E164] + public string Phone { get; set; } = string.Empty; + + [Regex(Pattern = "^[a-z]+$")] + public string Code { get; set; } = string.Empty; + + [NonSentinel(Message = "Id must not be the default.")] + public Guid Id { get; set; } +} +``` + +Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string; a rule's `message` parameter is surfaced as a `Message` property and its `code` parameter (where present) as a `Code` property. + +Two adjustments keep every rule addressable: + +- **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, and `Base64StringRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute. +- **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`) surface their type-parameter bound as a `double`, so `[MinValue(MinValue = 3)]` works on an `int` or a `double` member (the value is converted to the member type). + +The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(MinValue = …)]`, `[LessThanOrEqual(MaxValue = …)]`, `[Even]`, and `[Odd]`. ## Type-level rules @@ -549,6 +589,7 @@ See [Value Objects Integration](Value-Objects-Integration.md) for the full walkt | ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter without implementing `IZodRule`, so the value never reaches the reported error identity. | | ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | | ZODSGEN042 | Warning | A validation rule does not expose public `const string ErrorCode` / `MessageFormat` constants, so its error identity cannot be asserted in tests without duplicating literals. | +| ZODSGEN043 | Info | A built-in rule marked `[ZodRule]` does not generate a validation attribute (its name is taken by a `System.ComponentModel.DataAnnotations` attribute, or a constructor parameter cannot be represented as an attribute property). | See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list. diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index 05c1cf1..35efce1 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -16,9 +16,9 @@ var result = schema.Validate(30.0); | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | | `Gt` | `Gt(double value)` | `GreaterThanRule` — strictly greater than `value` | -| `Gte` | `Gte(double value)` | `MinValueRule` — greater than or equal to `value` | +| `Gte` | `Gte(double value)` | `GreaterThanOrEqualRule` — greater than or equal to `value` | | `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` | -| `Lte` | `Lte(double value)` | `MaxValueRule` — less than or equal to `value` | +| `Lte` | `Lte(double value)` | `LessThanOrEqualRule` — less than or equal to `value` | | `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)`; failure code `invalid_type` | | `Positive` | `Positive()` | `GreaterThanRule(0.0)` — strictly greater than zero | | `Negative` | `Negative()` | `LessThanRule(0.0)` — strictly less than zero | @@ -27,6 +27,10 @@ var result = schema.Validate(30.0); | `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`; failure code `too_big` | +| `Even` | `Even(string? message)` | `EvenRule` — `value % 2 == 0`; failure code `invalid_value` | +| `Odd` | `Odd(string? message)` | `OddRule` — `value % 2 != 0`; failure code `invalid_value` | + +`GreaterThanOrEqualRule`/`LessThanOrEqualRule` (the rules behind `Gte`/`Lte`) and `EvenRule`/`OddRule` are available directly too. `EvenRule`/`OddRule` are generic over `T : INumber`, so they close with any numeric type (`int`, `long`, `decimal`, …) — not just `double`. ## Examples @@ -41,8 +45,12 @@ var fractional = Z.Number().MultipleOf(0.1); // 0.3 is accepted (floating-point var finite = Z.Number().Finite(); // rejects Infinity / NaN var safe = Z.Number().Safe(); // safe integer range var whole = Z.Number().Int(); // no fractional part +var even = Z.Number().Even(); // 0, 2, 4, ... +var odd = Z.Number().Odd(); // 1, 3, 5, ... var age = Z.Number().Min(0).Max(120).Int().Validate(25.0); +var atLeastTen = Z.Number().Gte(10); // >= 10 +var atMostTen = Z.Number().Lte(10); // <= 10 ``` ## Numeric coercion diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 3fd9937..5a65fc5 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -88,4 +88,6 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full ## 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 +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). + +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index fe47646..ad965bb 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -1,6 +1,6 @@ # Source Generator Diagnostics -The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, and `ZODSGEN042` are warnings, `ZODSGEN041` is an informational suggestion, and the rest are errors. +The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, and `ZODSGEN042` are warnings, `ZODSGEN041` and `ZODSGEN043` are informational, and the rest are errors. | ID | Meaning | |---|---| @@ -40,6 +40,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | | ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | | ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals | +| ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 1781bf7..d0ea03c 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -156,5 +156,6 @@ MSBuild switches: - Nullable properties are null-guarded before value-set/type validation; a nullable target rejects `null` with `invalid_type`. - A `Purview.ValueObjects` scalar marked with `[Scalar]` can carry `[ZodSchema]` on the same type; the generated validator validates the scalar as a unit and reports an empty path. A rule written against the scalar's underlying value is adapted automatically — see [Value Objects Integration](Value-Objects-Integration.md). +- A rule marked with the parameterless `[ZodRule]` generates a matching validation attribute. Every built-in rule ships its attribute inside `Purview.ZodSharp` (in the `ZodSharp.Rules` namespace — `[Email]`, `[E164]`, `[Regex]`, `[NonSentinel]`, `[MinValue]`, `[Even]`, …; names that collide with `System.ComponentModel.DataAnnotations` use a `Zod` suffix such as `[MinLengthZod]`), so they can annotate a member or a scalar value object directly — see [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). 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/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 537c017..6f0329e 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -131,10 +131,14 @@ Fluent methods live on `ZodNumber` (`ZodSharp.Schemas`), which validates `double | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| -| `MinValueRule` | `(T minValue)` | `Min(v)`, `Gte(v)`, `NonNegative()` | `too_small` | `Value must be at least {0}, but got {1}` | -| `MaxValueRule` | `(T maxValue)` | `Max(v)`, `Lte(v)`, `NonPositive()` | `too_big` | `Value must be at most {0}, but got {1}` | +| `MinValueRule` | `(T minValue)` | `Min(v)`, `NonNegative()` | `too_small` | `Value must be at least {0}, but got {1}` | +| `MaxValueRule` | `(T maxValue)` | `Max(v)`, `NonPositive()` | `too_big` | `Value must be at most {0}, but got {1}` | | `GreaterThanRule` | `(T exclusiveMinimum)` | `Gt(v)`, `Positive()` | `too_small` | `Value must be greater than {0}, but got {1}` | | `LessThanRule` | `(T exclusiveMaximum)` | `Lt(v)`, `Negative()` | `too_big` | `Value must be less than {0}, but got {1}` | +| `GreaterThanOrEqualRule` | `(double minValue, string? message)` | `Gte(v)` | `too_small` | `Value must be greater than or equal to {0}, but got {1}` | +| `LessThanOrEqualRule` | `(double maxValue, string? message)` | `Lte(v)` | `too_big` | `Value must be less than or equal to {0}, but got {1}` | +| `EvenRule` | `(string? message)` | `Even()` | `invalid_value` | `Number must be even, but got {0}` | +| `OddRule` | `(string? message)` | `Odd()` | `invalid_value` | `Number must be odd, but got {0}` | | `MultipleOfRule` | `(double divisor, string? message)` | `MultipleOf(divisor)` | `not_multiple_of` | `Number must be a multiple of {0}, but got {1}` | | `FiniteRule` | `(string? message)` | `Finite()` | `not_finite` | `Number must be finite, but got {0}` | | `SafeIntegerRule` | `(string? message)` | `Safe()` | `too_big` | `Number must be a safe integer, but got {0}` | @@ -142,8 +146,9 @@ Fluent methods live on `ZodNumber` (`ZodSharp.Schemas`), which validates `double Behaviour notes: -- The bound rules are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). +- The bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`) are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). `ZodNumber`'s `Gte`/`Lte` use the non-generic `GreaterThanOrEqualRule`/`LessThanOrEqualRule`; `ZodBigInt` and `ZodDate` close the generic rules with `long` and `DateTime`. - `Positive()` is `GreaterThanRule(0.0)`, `Negative()` is `LessThanRule(0.0)`, `NonNegative()` is `MinValueRule(0.0)`, and `NonPositive()` is `MaxValueRule(0.0)`. +- **`EvenRule`** and **`OddRule`** are generic over `T : INumber`, so they close with `int`, `long`, `double`, `decimal`, and every other numeric type; `Even()`/`Odd()` close them with `double`. A value is even when `value % 2 == 0`. - **`MultipleOfRule`** throws `ArgumentException` when the divisor is `0`, and compares the quotient to its nearest integer with a relative tolerance of `1e-12` (so `0.3` is accepted for a divisor of `0.1`). - **`FiniteRule`** rejects `NaN` and infinities via `double.IsFinite`. - **`SafeIntegerRule`** requires a whole number within `int.MinValue`..`int.MaxValue`. @@ -168,7 +173,55 @@ var result = schema.Validate(DateTime.MinValue); var chained = Z.String().NonSentinel().Min(3); ``` -The same rule can be attached directly with `AddRule`/`Rule`, or closed with a property type through an attribute (`[ZodRule(typeof(NonSentinelRule<>))]` on a matching attribute) — see [Custom Rules](Custom-Rules.md#non-sentinel-values-ef-friendly). +The same rule can be attached directly with `AddRule`/`Rule`, or through the shipped `[NonSentinel]` attribute, which closes the open generic with the annotated member/scalar type — see [Custom Rules](Custom-Rules.md#non-sentinel-values-ef-friendly). + +## Generated attributes + +Each built-in rule that can be expressed as an attribute ships a generated `ValidationAttribute` in the `ZodSharp.Rules` namespace (inside the `Purview.ZodSharp` assembly), so a member or a scalar can be annotated directly: + +| Attribute | Rule | Notes | +|---|---|---| +| `[Email]` | `EmailRule` | | +| `[E164]` | `E164Rule` | | +| `[Regex(Pattern = "…")]` | `RegexRule` | mirrors the `(string pattern, string? message)` overload | +| `[UUID]` / `[UUID(Version = …)]` | `UUIDRule` | | +| `[ULID]` | `ULIDRule` | | +| `[JWT]` | `JWTRule` | | +| `[IPAddress]` | `IPAddressRule` | | +| `[Hex]` | `HexRule` | | +| `[Base64Url]` | `Base64UrlRule` | | +| `[Nanoid]` | `NanoidRule` | | +| `[Cuid2]` | `Cuid2Rule` | | +| `[DateString]` | `DateStringRule` | | +| `[TimeString]` | `TimeStringRule` | | +| `[DatetimeString]` | `DatetimeStringRule` | | +| `[StartsWith(Prefix = "…")]` | `StartsWithRule` | | +| `[EndsWith(Suffix = "…")]` | `EndsWithRule` | | +| `[Includes(Substring = "…")]` | `IncludesRule` | | +| `[MultipleOf(Divisor = …)]` | `MultipleOfRule` | | +| `[Finite]` | `FiniteRule` | | +| `[SafeInteger]` | `SafeIntegerRule` | | +| `[Int]` | `IntRule` | | +| `[Uri(UriKind = …)]` | `UriRule` | | +| `[NonSentinel]` | `NonSentinelRule` | closes the open generic with the member/scalar type | +| `[MinValue(MinValue = …)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | +| `[MaxValue(MaxValue = …)]` | `MaxValueRule` | as above | +| `[GreaterThan(ExclusiveMinimum = …)]` | `GreaterThanRule` | as above | +| `[LessThan(ExclusiveMaximum = …)]` | `LessThanRule` | as above | +| `[GreaterThanOrEqual(MinValue = …)]` | `GreaterThanOrEqualRule` | | +| `[LessThanOrEqual(MaxValue = …)]` | `LessThanOrEqualRule` | | +| `[Even]` | `EvenRule` | closes the open generic with the member type | +| `[Odd]` | `OddRule` | closes the open generic with the member type | +| `[MinLengthZod(MinLength = …)]` | `MinLengthRule` | suffixed to avoid the DataAnnotations name; exposes `Code`/`Message` | +| `[MaxLengthZod(MaxLength = …)]` | `MaxLengthRule` | as above | +| `[UrlZod]` | `UrlRule` | as above | +| `[PhoneZod]` | `PhoneRule` | as above | +| `[CreditCardZod]` | `CreditCardRule` | as above | +| `[Base64StringZod]` | `Base64StringRule` | as above | + +Each generated attribute exposes a `Message` property when its rule has a `message` constructor parameter, and reports the rule's own `ErrorCode`/`Origin` because every built-in rule implements `IZodRule`. + +Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(MinValue = 3)]` works on an `int` or a `double` member. ## Exposed as DataAnnotations attributes @@ -183,7 +236,7 @@ The `[ZodSchema]` generator maps several built-in rules to their `System.Compone | `[Base64String]` | `Base64StringRule` | `invalid_string` | | `[RegularExpression]` | compiled `Regex`, not `RegexRule` | `invalid_string` | -Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. +Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. The rule-identity variants of the colliding names are the suffixed [generated attributes](#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex(Pattern = "…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. ## Related diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index 0e56d5e..2660493 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -86,7 +86,10 @@ Origin = "value_object" Path = [] ``` -Type-level attributes need `AttributeTargets.Class | AttributeTargets.Struct` (and usually `Property`/`Field` too, so the same attribute can validate a primitive member): +> [!TIP] +> `ZodSharp.Rules.NonSentinelRule` and its generated `ZodSharp.Rules.NonSentinelAttribute` ship with `Purview.ZodSharp`, so the canonical non-sentinel case is available as `[NonSentinel(Message = "…")]` on a `[Scalar]` type without defining anything yourself. The walkthroughs below use local rules (`NotEmptyRule`, `MyRules.NonSentinelRule`) to show the full shape. + +Type-level attributes need `AttributeTargets.Class | AttributeTargets.Struct` (and usually `Property`/`Field` too, so the same attribute can validate a primitive member). A generated attribute already carries all five targets: ```csharp [ZodRule(typeof(NotEmptyRule<>))] diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 40fbf49..534cf0f 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -11,3 +11,4 @@ | ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | | ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | | ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | +| ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index bbc488d..fb90fd3 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -704,18 +704,80 @@ static ImmutableArray GetIdentityPropertyNames(INamedTypeSymbol? attribu } /// - /// Gets the public constructor parameters used to build the rule's arguments: the constructor with the most - /// parameters, matching the rule instantiation contract. + /// Gets the public constructor parameters used to build the rule's arguments: the constructor the generated + /// attribute mirrors, matching the rule instantiation contract. /// /// The resolved rule type. /// The mapped constructor parameters. static ImmutableArray MappedParameters(INamedTypeSymbol ruleType) => - ruleType + SelectAttributeConstructor(ruleType)?.Parameters ?? []; + + /// + /// Selects the public constructor a rule attribute mirrors and the generated validation instantiates: the + /// one whose parameters are all representable as attribute properties (a message or + /// CancellationToken parameter is consumed rather than mirrored), preferring the one with the most + /// parameters. Rules that declare a second overload only to accept a non-attribute-argument type (for + /// example ) are still attribute-addressable through the + /// compatible overload. + /// + /// The rule type to inspect. + /// The selected constructor, or when the rule declares none. + internal static IMethodSymbol? SelectAttributeConstructor(INamedTypeSymbol ruleType) + { + var candidates = ruleType .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) - .OrderByDescending(static c => c.Parameters.Length) - .FirstOrDefault() - ?.Parameters - ?? []; + .ToArray(); + + // A struct's implicit parameterless constructor would otherwise satisfy "every parameter is + // attribute-mappable" vacuously and silently drop the rule's real constructor arguments. It is only a + // candidate when the rule declares no constructor of its own (for example a parameterless rule). + var considered = candidates.Any(static c => !c.IsImplicitlyDeclared) + ? candidates.Where(static c => !c.IsImplicitlyDeclared).ToArray() + : candidates; + + return considered + .Where(static c => c.Parameters.All(IsAttributeMappableParameter)) + .OrderByDescending(static c => c.Parameters.Length) + .FirstOrDefault() + ?? considered.OrderByDescending(static c => c.Parameters.Length).FirstOrDefault(); + } + + static bool IsAttributeMappableParameter(IParameterSymbol parameter) => + IsMessageParameter(parameter) + || IsCancellationToken(parameter.Type) + || IsSupportedAttributePropertyType(parameter.Type); + + internal static bool IsMessageParameter(IParameterSymbol parameter) => + string.Equals(parameter.Name, "message", StringComparison.OrdinalIgnoreCase); + + internal static bool IsCancellationToken(ITypeSymbol type) => + type.ToDisplayString() == "System.Threading.CancellationToken"; + + internal 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; + + // Only primitive types and string are supported as attribute properties. + 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; + } /// /// The attribute contract that declares the error-identity properties, matched by name so the generator @@ -822,10 +884,7 @@ out string? unmappedParameterName arguments = new([]); unmappedParameterName = null; - var constructor = ruleType - .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) - .OrderByDescending(static c => c.Parameters.Length) - .FirstOrDefault(); + var constructor = SelectAttributeConstructor(ruleType); if (constructor is null) return false; @@ -908,9 +967,6 @@ static bool CanPassNull(ITypeSymbol type) => || type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.None); - 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) @@ -951,12 +1007,39 @@ internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out return true; } + var specialType = unwrapped.SpecialType; + #pragma warning disable IDE0072 // Add missing cases - expression = unwrapped.SpecialType switch + expression = 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_Object when value is string text => text.StringLiteral(), + _ => string.Empty, + }; +#pragma warning restore IDE0072 // Add missing cases + + if (expression.Length > 0) + return true; + + if (TryFormatNumericLiteral(value, specialType, out expression)) + return true; + + // A value whose runtime type differs from the target is converted numerically. This is what lets the + // double bound of a generated bound-rule attribute (for example `[MinValue(MinValue = 3)]`) be closed + // with an `int`, `long`, or `decimal` member. + return TryConvertNumeric(value, specialType, out expression); + } + + /// + /// Formats a numeric value that already matches as a target-typed literal. + /// + static bool TryFormatNumericLiteral(object value, SpecialType specialType, out string expression) + { +#pragma warning disable IDE0072 // Add missing cases + expression = specialType switch + { 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)}", @@ -975,7 +1058,6 @@ internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out $"{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 @@ -983,6 +1065,66 @@ internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out return expression.Length > 0; } + /// + /// Converts a numeric value to a different numeric target type and formats the result as a literal. + /// + static bool TryConvertNumeric(object value, SpecialType targetSpecialType, out string expression) + { + expression = string.Empty; + + var targetClrType = GetNumericClrType(targetSpecialType); + if (targetClrType is null || value is string || value is bool || value is char) + return false; + + var valueClrType = value.GetType(); + if (GetNumericClrType(valueClrType) is null || valueClrType == targetClrType) + return false; + + try + { + var converted = Convert.ChangeType(value, targetClrType, CultureInfo.InvariantCulture); + return converted is not null && TryFormatNumericLiteral(converted, targetSpecialType, out expression); + } + catch (Exception exception) when (exception is InvalidCastException or FormatException or OverflowException) + { + return false; + } + } + + static Type? GetNumericClrType(SpecialType specialType) => + specialType switch + { + SpecialType.System_Byte => typeof(byte), + SpecialType.System_SByte => typeof(sbyte), + SpecialType.System_Int16 => typeof(short), + SpecialType.System_UInt16 => typeof(ushort), + SpecialType.System_Int32 => typeof(int), + SpecialType.System_UInt32 => typeof(uint), + SpecialType.System_Int64 => typeof(long), + SpecialType.System_UInt64 => typeof(ulong), + SpecialType.System_Single => typeof(float), + SpecialType.System_Double => typeof(double), + SpecialType.System_Decimal => typeof(decimal), + _ => null, + }; + + static Type? GetNumericClrType(Type type) => + Type.GetTypeCode(type) switch + { + TypeCode.Byte => typeof(byte), + TypeCode.SByte => typeof(sbyte), + TypeCode.Int16 => typeof(short), + TypeCode.UInt16 => typeof(ushort), + TypeCode.Int32 => typeof(int), + TypeCode.UInt32 => typeof(uint), + TypeCode.Int64 => typeof(long), + TypeCode.UInt64 => typeof(ulong), + TypeCode.Single => typeof(float), + TypeCode.Double => typeof(double), + TypeCode.Decimal => typeof(decimal), + _ => null, + }; + static Location GetAttributeLocation(AttributeData attributeData) => attributeData.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index b5186f1..7b0188d 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -419,4 +419,20 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true ); + + /// + /// ZODSGEN043: a built-in rule (one declared in the runtime assembly, under ZodSharp.Rules) is marked + /// with [ZodRule] but cannot produce a validation attribute because a constructor parameter cannot be + /// represented as an attribute property, so the rule is only reachable through the runtime/fluent API. + /// Reported as informational so the gap stays visible rather than silent; a derived name that collides with + /// a System.ComponentModel.DataAnnotations attribute is emitted under a "Zod" suffix instead. + /// + public static readonly DiagnosticDescriptor BuiltInRuleAttributeNotGenerated = new( + id: "ZODSGEN043", + title: "Built-in rule does not generate a validation attribute", + messageFormat: "The built-in rule '{0}' does not generate a validation attribute: {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index 73b6d6b..2e1f5f7 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -12,6 +12,8 @@ static partial class TypeLibraryGenerator public const string ZodSharpCoreNamespace = ZodSharpNamespace + ".Core"; + public const string ZodSharpRulesNamespace = ZodSharpNamespace + ".Rules"; + public const string ZodSharpSchemasNamespace = ZodSharpNamespace + ".Schemas"; // The Purview.ValueObjects scalar contracts. A scalar value object is validated through its underlying diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index a3c3e8c..4122c5a 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -63,7 +63,7 @@ bool declareValueLocal var valueVariable = declareValueLocal ? CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleValue{i}") : valueExpression; - var arguments = rule.Arguments.Count == 0 ? string.Empty : $"({string.Join(", ", rule.Arguments)})"; + var arguments = $"({string.Join(", ", rule.Arguments)})"; if (declareValueLocal) writer.Assignment("var", valueVariable, valueExpression); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index ebbda84..d9c1a5a 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -72,13 +72,7 @@ CancellationToken cancellationToken { return GeneratorResult.Create( default(RuleAttributeGenerationModel), - ReportableDiagnostic.Create( - DiagnosticLibrary.UnsupportedRuleAttributeGeneration, - true, - ruleType, - ruleType.Name, - "only non-nested, non-abstract rules are supported" - ) + ReportUnsupportedRuleAttributeGeneration(ruleType, "only non-nested, non-abstract rules are supported") ); } @@ -89,11 +83,8 @@ CancellationToken cancellationToken { return GeneratorResult.Create( default(RuleAttributeGenerationModel), - ReportableDiagnostic.Create( - DiagnosticLibrary.UnsupportedRuleAttributeGeneration, - true, + ReportUnsupportedRuleAttributeGeneration( ruleType, - ruleType.Name, "only non-generic rules and generic rules with a single type parameter are supported" ) ); @@ -119,6 +110,22 @@ CancellationToken cancellationToken ); } + // A generated attribute sharing its name with a System.ComponentModel.DataAnnotations attribute would be + // ambiguous for any consumer importing both namespaces. Keep the DataAnnotations name and generate ours + // under a "Zod" suffix (for example `[MinLengthZod]`), so the rule's own error identity (Code/Message) + // stays usable instead of the rule being skipped entirely. + if ( + context.SemanticModel.Compilation.GetTypeByMetadataName( + $"{TypeLibraryGenerator.SystemDataAnnotations}.{attributeName}" + ) + is not null + ) + { + attributeName = attributeName.EndsWith("Attribute", StringComparison.Ordinal) + ? attributeName.Substring(0, attributeName.Length - "Attribute".Length) + "ZodAttribute" + : attributeName + "Zod"; + } + // Both halves of a rule pair derive the same attribute name. The open generic mapping subsumes the // non-generic one, so only the generic rule emits the attribute. if (!ruleType.IsGenericType && HasMarkedGenericSibling(ruleType, attributeName)) @@ -146,27 +153,40 @@ CancellationToken cancellationToken } var properties = ImmutableArray.CreateBuilder(); - var constructor = ruleType - .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) - .OrderByDescending(static c => c.Parameters.Length) - .FirstOrDefault(); + var constructor = CustomRuleResolver.SelectAttributeConstructor(ruleType); if (constructor is not null) { + IParameterSymbol? messageParameter = null; + foreach (var parameter in constructor.Parameters) { - if (IsMessageParameter(parameter) || IsCancellationToken(parameter.Type)) + if (CustomRuleResolver.IsMessageParameter(parameter)) + { + // The rule's message flows through the inherited ValidationAttribute.ErrorMessage, which the + // resolver maps onto this parameter. The generated attribute additionally exposes a Message + // alias, so the rule's own parameter name stays usable at the call site. + messageParameter = parameter; + continue; + } + + if (CustomRuleResolver.IsCancellationToken(parameter.Type)) continue; - if (!IsSupportedAttributePropertyType(parameter.Type)) + // A type-parameter constructor argument (for example `MinValueRule(T minValue)`) cannot be + // mirrored directly; it is surfaced as a double, the numeric type the schema pipeline uses and + // the type a `[MinValue]`-style attribute can carry. + var propertyType = + parameter.Type.TypeKind == TypeKind.TypeParameter + ? context.SemanticModel.Compilation.GetSpecialType(SpecialType.System_Double) + : parameter.Type; + + if (!CustomRuleResolver.IsSupportedAttributePropertyType(propertyType)) { ImmutableArray propertyDiagnostics = [ - ReportableDiagnostic.Create( - DiagnosticLibrary.UnsupportedRuleAttributeGeneration, - true, + ReportUnsupportedRuleAttributeGeneration( ruleType, - ruleType.Name, $"constructor parameter '{parameter.Name}' has type '{parameter.Type.ToDisplayString()}', which cannot be represented as an attribute property" ), .. identityWarnings, @@ -177,10 +197,22 @@ CancellationToken cancellationToken properties.Add( new GeneratedAttributeProperty( - new TypeIdentity(TypeHelpers.StripNullableAnnotations(parameter.Type)), + new TypeIdentity(TypeHelpers.StripNullableAnnotations(propertyType)), ToPascalCase(parameter.Name), - BuildInitializer(parameter), - TypeHelpers.CanBeNull(parameter.Type) + BuildInitializer(parameter, propertyType), + TypeHelpers.CanBeNull(propertyType) + ) + ); + } + + if (messageParameter is not null) + { + properties.Add( + new GeneratedAttributeProperty( + new TypeIdentity(TypeHelpers.StripNullableAnnotations(messageParameter.Type)), + "Message", + BuildInitializer(messageParameter, messageParameter.Type), + TypeHelpers.CanBeNull(messageParameter.Type) ) ); } @@ -208,23 +240,49 @@ static bool IsValidationRule(INamedTypeSymbol ruleType) => && definition.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpCoreNamespace ); - static bool IsMessageParameter(IParameterSymbol parameter) => - string.Equals(parameter.Name, "message", StringComparison.OrdinalIgnoreCase); + /// + /// Determines whether is a built-in rule shipped in the runtime assembly. A + /// generation failure for one of those is a documented library decision, so it is reported as ZODSGEN043 + /// rather than as the error a custom rule's author would need. + /// + static bool IsBuiltInRule(INamedTypeSymbol ruleType) => + ruleType.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpRulesNamespace; - static bool IsCancellationToken(ITypeSymbol type) => type.ToDisplayString() == "System.Threading.CancellationToken"; + /// + /// Reports a rule that cannot produce a validation attribute: ZODSGEN043 (informational, not blocking) + /// for a built-in rule whose gap is visible in the shipped build, and ZODSGEN032 (error) for a custom + /// rule whose author marked it deliberately. + /// + /// The marked rule. + /// Why the attribute cannot be generated. + /// The diagnostic to carry out of the pipeline. + static ReportableDiagnostic ReportUnsupportedRuleAttributeGeneration(INamedTypeSymbol ruleType, string reason) + { + var isBuiltIn = IsBuiltInRule(ruleType); + + return ReportableDiagnostic.Create( + isBuiltIn + ? DiagnosticLibrary.BuiltInRuleAttributeNotGenerated + : DiagnosticLibrary.UnsupportedRuleAttributeGeneration, + !isBuiltIn, + ruleType, + ruleType.Name, + reason + ); + } - static string BuildInitializer(IParameterSymbol parameter) + static string BuildInitializer(IParameterSymbol parameter, ITypeSymbol propertyType) { if ( parameter.HasExplicitDefaultValue - && CustomRuleResolver.TryConvertValue(parameter.ExplicitDefaultValue, parameter.Type, out var literal) + && CustomRuleResolver.TryConvertValue(parameter.ExplicitDefaultValue, propertyType, out var literal) ) { return literal; } // If the parameter has no default value, we still need to provide an initializer for the attribute property. - return parameter.Type.IsValueType ? "default!" : "null!"; + return propertyType.IsValueType ? "default!" : "null!"; } static string ToPascalCase(string name) => @@ -443,32 +501,6 @@ static bool HasMarkedGenericSibling(INamedTypeSymbol ruleType, string attributeN return false; } - 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; - - // Only primitive types and string are supported as attribute properties. - 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(); @@ -476,7 +508,7 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m writer.FileScopedNamespace(model.AttributeType.Namespace); writer.XmlSummary( - $"Validation attribute that applies {CodeWriter.XmlSee(model.RuleType.Name)}.", + $"Validation attribute that applies {CodeWriter.XmlSee(model.RuleType)}.", "Generated from the rule's [ZodRule] marker; the properties mirror the rule's constructor parameters." ); @@ -493,7 +525,11 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m Arguments = [ new AttributeArgumentOptions( - "global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter", + "global::System.AttributeTargets.Class" + + " | global::System.AttributeTargets.Struct" + + " | global::System.AttributeTargets.Property" + + " | global::System.AttributeTargets.Field" + + " | global::System.AttributeTargets.Parameter", null, false ), diff --git a/src/src/ZodSharp/Expressions/CompiledValidator.cs b/src/src/ZodSharp/Expressions/CompiledValidator.cs index e7cae6a..0430013 100644 --- a/src/src/ZodSharp/Expressions/CompiledValidator.cs +++ b/src/src/ZodSharp/Expressions/CompiledValidator.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using ZodSharp.Core; namespace ZodSharp.Expressions; diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 2e03254..0d4affc 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -4,28 +4,6 @@ namespace ZodSharp.JsonSchema; -/// -/// Options for converting ZodSharp schemas to JSON Schema. -/// -public class ToJsonSchemaOptions -{ - /// - /// Whether to include the $schema property in the output. - /// Default: true - /// - public bool IncludeSchema { get; set; } = true; - - /// - /// Custom $id for the schema. - /// - public string? Id { get; set; } - - /// - /// Custom title for the schema. - /// - public string? Title { get; set; } -} - /// /// Converts ZodSharp schemas to JSON Schema (Draft 2020-12). /// @@ -216,6 +194,24 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) if (maxValueField?.GetValue(rule) is double maxValue) result.Maximum = maxValue; } + else if (ruleName == "GreaterThanOrEqualRule") + { + var minValueField = ruleType.GetField( + "_minValue", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance + ); + if (minValueField?.GetValue(rule) is double minValue) + result.Minimum = minValue; + } + else if (ruleName == "LessThanOrEqualRule") + { + var maxValueField = ruleType.GetField( + "_maxValue", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance + ); + if (maxValueField?.GetValue(rule) is double maxValue) + result.Maximum = maxValue; + } else if (ruleName == "IntRule") { result.Type = "integer"; diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs new file mode 100644 index 0000000..89dbac6 --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs @@ -0,0 +1,23 @@ +namespace ZodSharp.JsonSchema; + +/// +/// Options for converting ZodSharp schemas to JSON Schema. +/// +public sealed class ToJsonSchemaOptions +{ + /// + /// Whether to include the $schema property in the output. + /// Default: true + /// + public bool IncludeSchema { get; set; } = true; + + /// + /// Custom $id for the schema. + /// + public string? Id { get; set; } + + /// + /// Custom title for the schema. + /// + public string? Title { get; set; } +} diff --git a/src/src/ZodSharp/Options/MissingValidatorBehavior.cs b/src/src/ZodSharp/Options/MissingValidatorBehavior.cs new file mode 100644 index 0000000..54963c8 --- /dev/null +++ b/src/src/ZodSharp/Options/MissingValidatorBehavior.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Options; +using ZodSharp.Core; + +namespace ZodSharp.Options; + +/// +/// Defines how behaves when no schema validator is +/// registered for the options type. +/// +public enum MissingValidatorBehavior +{ + /// + /// Throws via + /// when no validator is registered for the options type. + /// + Throw, + + /// + /// Treats an unregistered options type as valid, returning . + /// + Ignore, +} diff --git a/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs b/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs index 0598539..7039f26 100644 --- a/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs +++ b/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs @@ -3,57 +3,28 @@ namespace ZodSharp.Options; -/// -/// Defines how behaves when no schema validator is -/// registered for the options type. -/// -public enum MissingValidatorBehavior -{ - /// - /// Throws via - /// when no validator is registered for the options type. - /// - Throw, - - /// - /// Treats an unregistered options type as valid, returning . - /// - Ignore, -} - /// /// A generic adapter that resolves the source-generated schema /// validator for through at runtime, /// allowing a single hand-written class to validate any registered options type. /// /// The options type to validate. -public sealed class ZodSchemaOptionsValidator : IValidateOptions +/// Initializes a new instance with the schema factory used to resolve validators. +/// The schema factory. +/// How to behave when no validator is registered for . +public sealed class ZodSchemaOptionsValidator( + IZodSchemaFactory factory, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw +) : IValidateOptions where T : class { - readonly IZodSchemaFactory _factory; - readonly MissingValidatorBehavior _missingValidatorBehavior; - - /// Initializes a new instance with the schema factory used to resolve validators. - /// The schema factory. - /// How to behave when no validator is registered for . - public ZodSchemaOptionsValidator( - IZodSchemaFactory factory, - MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw - ) - { - ArgumentNullException.ThrowIfNull(factory); - - _factory = factory; - _missingValidatorBehavior = missingValidatorBehavior; - } - /// public ValidateOptionsResult Validate(string? name, T options) { var validator = - _missingValidatorBehavior == MissingValidatorBehavior.Throw - ? _factory.ResolveRequired() - : _factory.Resolve(); + missingValidatorBehavior == MissingValidatorBehavior.Throw + ? factory.ResolveRequired() + : factory.Resolve(); if (validator is null) return ValidateOptionsResult.Success; diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 4bce914..6060454 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Mirrors the behavior of System.ComponentModel.DataAnnotations.Base64StringAttribute. /// Uses struct to avoid allocations. /// -public readonly record struct Base64StringRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct Base64StringRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -14,14 +15,17 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid Base64 string format: {0}"; readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the Base64StringRule struct. /// /// Optional error message - public Base64StringRule(string? message = null) + /// Optional error code override + public Base64StringRule(string? message = null, string? code = null) { _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -54,4 +58,8 @@ public bool IsValid(in string value) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index 411b32a..39c61df 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for base64url (URL-safe base64) string format. /// Uses struct to avoid allocations. /// -public readonly record struct Base64UrlRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct Base64UrlRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -65,4 +66,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index bc183cc..cea50c2 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Mirrors the behavior of System.ComponentModel.DataAnnotations.CreditCardAttribute. /// Uses struct to avoid allocations. /// -public readonly record struct CreditCardRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct CreditCardRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -14,14 +15,17 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid credit card number format: {0}"; readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the CreditCardRule struct. /// /// Optional error message - public CreditCardRule(string? message = null) + /// Optional error code override + public CreditCardRule(string? message = null, string? code = null) { _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -92,4 +96,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs index 9055a64..13a67ab 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for CUID2 format (lowercase alphanumeric characters). /// Uses struct to avoid allocations. /// -public readonly record struct Cuid2Rule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct Cuid2Rule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -63,4 +64,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index 97dd186..b78ae00 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -6,7 +6,8 @@ namespace ZodSharp.Rules; /// Validation rule for ISO 8601 date string format (yyyy-MM-dd). /// Uses struct to avoid allocations. /// -public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -60,4 +61,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index d5aec77..b77397c 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// calendar date, a T separator, seconds, optional fractional seconds, and a Z suffix). /// Uses struct to avoid allocations. /// -public readonly record struct DatetimeStringRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct DatetimeStringRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -114,4 +118,8 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 6f5f177..6900f3f 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for E.164 phone number format (+ followed by 7-15 digits, first digit 1-9). /// Uses struct to avoid allocations. /// -public readonly record struct E164Rule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct E164Rule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -67,4 +68,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index d0939e1..99e1c50 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -6,7 +6,8 @@ namespace ZodSharp.Rules; /// Validation rule for email format. /// Uses struct to avoid allocations. /// -public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -53,4 +54,8 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index 006a2da..6090fc3 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for string suffix. /// Uses struct to avoid allocations. /// -public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -57,4 +58,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs new file mode 100644 index 0000000..88fac6b --- /dev/null +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -0,0 +1,48 @@ +using System.Numerics; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires an even number (a multiple of two). +/// Uses struct to avoid allocations. +/// +/// The numeric type; any is supported. +[Core.ZodRule] +public readonly record struct EvenRule : Core.IValidationRule, Core.IZodRule + where T : INumber +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Number must be even, but got {0}"; + + readonly string? _message; + + /// + /// Initializes a new instance of the EvenRule struct. + /// + /// Optional error message + public EvenRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is even. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => value % (T.One + T.One) == T.Zero; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index f1072e7..938e171 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for finite number check. /// Uses struct to avoid allocations. /// -public readonly record struct FiniteRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_finite"; @@ -39,4 +40,8 @@ public FiniteRule(string? message = null) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs new file mode 100644 index 0000000..d1e8601 --- /dev/null +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -0,0 +1,50 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for an inclusive lower numeric bound (greater than or equal to). +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct GreaterThanOrEqualRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_small"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be greater than or equal to {0}, but got {1}"; + + readonly double _minValue; + readonly string? _message; + + /// + /// Initializes a new instance of the GreaterThanOrEqualRule struct. + /// + /// The inclusive lower bound + /// Optional error message + public GreaterThanOrEqualRule(double minValue, string? message = null) + { + _minValue = minValue; + _message = message.OrNull(); + } + + /// + /// Validates that the value is greater than or equal to the configured bound. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in double value) => value >= _minValue; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _minValue, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index faf1243..fef301e 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// /// The numeric type -public readonly record struct GreaterThanRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct GreaterThanRule : Core.IValidationRule, Core.IZodRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -41,4 +42,8 @@ public GreaterThanRule(T exclusiveMinimum) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs index 63ffa45..6c15103 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for hexadecimal string format. /// Uses struct to avoid allocations. /// -public readonly record struct HexRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct HexRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -60,4 +61,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 5d52902..6971b0f 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -7,7 +7,8 @@ namespace ZodSharp.Rules; /// Validation rule for IPv4 and IPv6 address format. /// Uses struct to avoid allocations. /// -public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -62,4 +63,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index 47949fc..c42579c 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for string containment. /// Uses struct to avoid allocations. /// -public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -57,4 +58,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index 46f9651..3259d43 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for integer values. /// Uses struct to avoid allocations. /// -public readonly record struct IntRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_type"; @@ -28,4 +29,8 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index d194084..04c268f 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for JSON Web Token (JWT) format: three base64url-encoded segments separated by periods. /// Uses struct to avoid allocations. /// -public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -83,4 +84,8 @@ static bool IsBase64Url(ReadOnlySpan segment) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs new file mode 100644 index 0000000..f5164ab --- /dev/null +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -0,0 +1,50 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for an inclusive upper numeric bound (less than or equal to). +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct LessThanOrEqualRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the bound and {1} the offending value. + public const string MessageFormat = "Value must be less than or equal to {0}, but got {1}"; + + readonly double _maxValue; + readonly string? _message; + + /// + /// Initializes a new instance of the LessThanOrEqualRule struct. + /// + /// The inclusive upper bound + /// Optional error message + public LessThanOrEqualRule(double maxValue, string? message = null) + { + _maxValue = maxValue; + _message = message.OrNull(); + } + + /// + /// Validates that the value is less than or equal to the configured bound. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in double value) => value <= _maxValue; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _maxValue, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 7daa20d..96ba12c 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// /// The numeric type -public readonly record struct LessThanRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct LessThanRule : Core.IValidationRule, Core.IZodRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -41,4 +42,8 @@ public LessThanRule(T exclusiveMaximum) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index fbdfd2e..2db87bd 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for maximum string length. /// Uses struct to avoid allocations. /// -public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -13,14 +14,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; readonly int _maxLength; + readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the MaxLengthRule struct. /// /// The maximum length - public MaxLengthRule(int maxLength) + /// Optional error message + /// Optional error code override + public MaxLengthRule(int maxLength, string? message = null, string? code = null) { _maxLength = maxLength; + _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -43,7 +50,7 @@ public MaxLengthRule(int maxLength) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - RuleMessage.Format(MessageFormat, _maxLength, value.LengthOrDefault()); + _message ?? RuleMessage.Format(MessageFormat, _maxLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -51,8 +58,12 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format(MessageFormat, _maxLength, value.Length); + _message ?? RuleMessage.Format(MessageFormat, _maxLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index 63d8c03..66f146d 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// /// The numeric type -public readonly record struct MaxValueRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct MaxValueRule : Core.IValidationRule, Core.IZodRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -41,4 +42,8 @@ public MaxValueRule(T maxValue) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 4b2cde0..3c35acc 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for minimum string length. /// Uses struct to avoid allocations. /// -public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; @@ -13,14 +14,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; readonly int _minLength; + readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the MinLengthRule struct. /// /// The minimum length - public MinLengthRule(int minLength) + /// Optional error message + /// Optional error code override + public MinLengthRule(int minLength, string? message = null, string? code = null) { _minLength = minLength; + _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -43,7 +50,7 @@ public MinLengthRule(int minLength) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - RuleMessage.Format(MessageFormat, _minLength, value.LengthOrDefault()); + _message ?? RuleMessage.Format(MessageFormat, _minLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -51,8 +58,12 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format(MessageFormat, _minLength, value.Length); + _message ?? RuleMessage.Format(MessageFormat, _minLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index ccceb3a..6d4ec12 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// /// The numeric type -public readonly record struct MinValueRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct MinValueRule : Core.IValidationRule, Core.IZodRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -41,4 +42,8 @@ public MinValueRule(T minValue) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index e381314..e2068a6 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for multiple-of check. /// Uses struct to avoid allocations. /// -public readonly record struct MultipleOfRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_multiple_of"; @@ -62,4 +63,8 @@ public bool IsValid(in double value) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs index 6549832..8af0a91 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for nanoid format (21 URL-safe characters). /// Uses struct to avoid allocations. /// -public readonly record struct NanoidRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct NanoidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -63,4 +64,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index a43ba30..ecddc1e 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -26,7 +26,8 @@ namespace ZodSharp.Rules; /// reinterprets the value in place, so no boxing occurs on the validation path. /// /// -public readonly record struct NonSentinelRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -58,6 +59,10 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } /// diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs new file mode 100644 index 0000000..aa9372b --- /dev/null +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -0,0 +1,48 @@ +using System.Numerics; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires an odd number (not a multiple of two). +/// Uses struct to avoid allocations. +/// +/// The numeric type; any is supported. +[Core.ZodRule] +public readonly record struct OddRule : Core.IValidationRule, Core.IZodRule + where T : INumber +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Number must be odd, but got {0}"; + + readonly string? _message; + + /// + /// Initializes a new instance of the OddRule struct. + /// + /// Optional error message + public OddRule(string? message = null) => _message = message.OrNull(); + + /// + /// Validates that the value is odd. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => value % (T.One + T.One) != T.Zero; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index 3e0a715..baf313f 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -6,7 +6,8 @@ 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, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct PhoneRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -15,14 +16,17 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid phone number format: {0}"; readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the PhoneRule struct. /// /// Optional error message - public PhoneRule(string? message = null) + /// Optional error code override + public PhoneRule(string? message = null, string? code = null) { _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -79,4 +83,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 5588b5c..8f6c410 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -6,7 +6,8 @@ namespace ZodSharp.Rules; /// Validation rule for regex pattern matching. /// Uses struct to avoid allocations. /// -public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -66,4 +67,8 @@ public RegexRule(string pattern, string? message = null) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs index 99a6792..ae10ec4 100644 --- a/src/src/ZodSharp/Rules/RuleMessage.cs +++ b/src/src/ZodSharp/Rules/RuleMessage.cs @@ -21,7 +21,7 @@ static class RuleMessage /// The message format (a rule's MessageFormat constant). /// The format argument. /// The formatted message using . - internal static string Format(string format, TArg0 arg0) => + public static string Format(string format, TArg0 arg0) => string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0); /// Formats a rule message with two arguments. @@ -31,7 +31,7 @@ internal static string Format(string format, TArg0 arg0) => /// The first format argument. /// The second format argument. /// The formatted message using . - internal static string Format(string format, TArg0 arg0, TArg1 arg1) => + public static string Format(string format, TArg0 arg0, TArg1 arg1) => string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1); static CompositeFormat GetFormat(string format) => Formats.GetOrAdd(format, CompositeFormat.Parse); diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index 1da5417..3d2abc1 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for safe integer check. /// Uses struct to avoid allocations. /// -public readonly record struct SafeIntegerRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -40,4 +41,8 @@ public bool IsValid(in double value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 2acd62b..f2700c3 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for string prefix. /// Uses struct to avoid allocations. /// -public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -57,4 +58,8 @@ public string GetErrorMessage(ReadOnlySpan value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 0525643..9bd27b7 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -5,7 +5,8 @@ namespace ZodSharp.Rules; /// fractional seconds). /// Uses struct to avoid allocations. /// -public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -99,4 +100,8 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index 256ceae..d3ad207 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for ULID format: 26 Crockford base32 characters. /// Uses struct to avoid allocations. /// -public readonly record struct ULIDRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct ULIDRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -84,4 +85,8 @@ static bool IsCrockfordBase32(char c) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index add5576..7a005e2 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for a Uri. /// This will allocate by utilising /// -public readonly record struct UriRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct UriRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -43,4 +44,8 @@ public bool IsValid(in string value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index c8fba8e..5c2b40c 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -11,7 +11,8 @@ namespace ZodSharp.Rules; "PDS0004:Use correct acronym capitalization", Justification = "Name is real" )] -public readonly record struct UrlRule : Core.IValidationRule +[Core.ZodRule] +public readonly record struct UrlRule : Core.IValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -26,14 +27,17 @@ namespace ZodSharp.Rules; ); readonly string? _message; + readonly string? _code; /// /// Initializes a new instance of the UrlRule struct. /// /// Optional error message - public UrlRule(string? message = null) + /// Optional error code override + public UrlRule(string? message = null, string? code = null) { _message = message.OrNull(); + _code = code.OrNull(); } /// @@ -60,4 +64,8 @@ public bool IsValid(in string value) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code ?? ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 53de787..5371465 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -4,7 +4,8 @@ namespace ZodSharp.Rules; /// Validation rule for UUID format. /// Uses struct to avoid allocations. /// -public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule +[Core.ZodRule] +public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -132,4 +133,8 @@ static bool HasValidStructure(ReadOnlySpan value) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; + + string? Core.IZodRule.Code => ErrorCode; + + string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index 9c5e470..50e95bd 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -65,7 +65,7 @@ public ZodNumber Gt(double value) /// This schema for method chaining public ZodNumber Gte(double value) { - AddRule(new MinValueRule(value)); + AddRule(new GreaterThanOrEqualRule(value)); return this; } @@ -89,7 +89,7 @@ public ZodNumber Lt(double value) /// This schema for method chaining public ZodNumber Lte(double value) { - AddRule(new MaxValueRule(value)); + AddRule(new LessThanOrEqualRule(value)); return this; } @@ -179,4 +179,26 @@ public ZodNumber Safe(string? message = null) AddRule(new SafeIntegerRule(message)); return this; } + + /// + /// Adds an even-number validation (the value must be a multiple of two). + /// + /// Optional error message + /// This schema for method chaining + public ZodNumber Even(string? message = null) + { + AddRule(new EvenRule(message)); + return this; + } + + /// + /// Adds an odd-number validation (the value must not be a multiple of two). + /// + /// Optional error message + /// This schema for method chaining + public ZodNumber Odd(string? message = null) + { + AddRule(new OddRule(message)); + return this; + } } diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index a17ab6f..45b794c 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -73,6 +73,7 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN040", "ZODSGEN041", "ZODSGEN042", + "ZODSGEN043", ]; [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs new file mode 100644 index 0000000..7a32200 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -0,0 +1,406 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + /// + /// A rule that a consumer would write under the shipped ZodSharp.Rules namespace, used to exercise the + /// built-in classification without depending on the runtime assembly's actual rule set. + /// + [Test] + public async Task RuleAttributeGeneration_GivenBuiltInRuleCollidingWithDataAnnotations_GeneratesSuffixedAttribute( + CancellationToken cancellationToken + ) + { + // Arrange - `RequiredRule` would derive `RequiredAttribute`, which System.ComponentModel.DataAnnotations + // already ships (and which already maps to the equivalent built-in rule). + const string source = """ + using ZodSharp.Core; + + namespace ZodSharp.Rules + { + [ZodRule] + public readonly record struct RequiredRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.Length != 0; + + public string GetErrorMessage(in string value) => Message ?? "Value is required."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("RequiredZodAttribute"); + + // Assert - the DataAnnotations name is left alone and the rule's attribute is emitted under the "Zod" + // suffix, so its identity and message stay usable. + await Assert.That(generated).ContainsGeneratedCode("class RequiredZodAttribute"); + await Assert.That(generated).ContainsGeneratedCode("typeof(global::ZodSharp.Rules.RequiredRule)"); + await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); + await Assert.That(driverResult.GetSource("RequiredAttribute")).IsNull(); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN043"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN032"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenGenericBoundRule_MirrorsTheDoubleBound( + CancellationToken cancellationToken + ) + { + // Arrange - a generic bound rule; the type-parameter bound is surfaced as a double on the attribute. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct ThresholdRule(T threshold) : IValidationRule + where T : IComparable + { + public bool IsValid(in T value) => value.CompareTo(threshold) >= 0; + + public string GetErrorMessage(in T value) => "Too small."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ThresholdAttribute"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("public double Threshold { get; set; } = default!;"); + await Assert.That(generated).ContainsGeneratedCode("typeof(global::Testing.Rules.ThresholdRule<>)"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN043"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN032"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenBuiltInRuleWithUnrepresentableParameter_ReportsZodsgen043( + CancellationToken cancellationToken + ) + { + // Arrange - a constructor parameter that cannot be represented as an attribute property. + const string source = """ + using System; + using ZodSharp.Core; + + namespace ZodSharp.Rules + { + [ZodRule] + public readonly record struct BlobRule(Guid Blob, 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 + await Assert.That(driverResult).HasDiagnostic("ZODSGEN043"); + await Assert.That(driverResult.GetSource("BlobAttribute")).IsNull(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenCustomRuleWithUnrepresentableParameter_ReportsZodsgen032( + CancellationToken cancellationToken + ) + { + // Arrange - the same shape, but authored by a consumer: it stays an error so the author notices. + const string source = """ + using System; + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct BlobRule(Guid Blob, 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 + await Assert.That(driverResult).HasDiagnostic("ZODSGEN032"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN043"); + } + + /// + /// A [ZodSchema] class that uses the validation attributes generated into the shipped + /// Purview.ZodSharp assembly for the built-in rules. + /// + const string BuiltInAttributeSource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Contact + { + [Email] + public string Email { get; set; } = string.Empty; + + [E164] + public string Phone { get; set; } = string.Empty; + + [Regex(Pattern = "^[a-z]+$")] + public string Code { get; set; } = string.Empty; + + [NonSentinel(Message = "Value must not be the default.")] + public Guid Id { get; set; } + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenShippedAttributes_ResolvesTheBuiltInRules( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(BuiltInAttributeSource, cancellationToken); + var generated = driverResult.GetSource("ContactSchema"); + + // Assert - each shipped attribute resolves to its built-in rule. The regex attribute mirrors the + // string overload (not the Regex one) and the open generic is closed with the member type. + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EmailRule()"); + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.E164Rule(null!)"); + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.RegexRule(\"^[a-z]+$\", null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(\"Value must not be the default.\")" + ); + // The built-in rules own their identity, so the generated validation reads it from the rule. + await Assert.That(generated).Contains("((global::ZodSharp.Core.IZodRule)"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenShippedAttributes_ReportsTheRuleErrorCode( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + BuiltInAttributeSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Contact")!; + var validate = assembly.GetType("Testing.ContactSchema")!.GetMethod("Validate")!; + + // Act - an invalid email address is rejected by the built-in EmailRule. + var invalid = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Email")!.SetValue(invalid, "not-an-email"); + var invalidResult = validate.Invoke(null, [invalid])!; + + // Assert + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + invalidResult.GetType().GetProperty("Errors")!.GetValue(invalidResult)!; + await Assert.That(errors.Any(error => error.Code == Rules.EmailRule.ErrorCode)).IsTrue(); + } + + /// + /// The acceptance path: a scalar value object annotated with the shipped [NonSentinel] attribute, + /// adapted through the value-object scalar rule adapter. + /// + const string BuiltInScalarAttributeSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + using ZodSharp.Rules; + + namespace Purview.ValueObjects.Serialization + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : Attribute + { + public ScalarAttribute(string propertyName = "Value") => PropertyName = propertyName; + + public string PropertyName { get; } + } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + // Stands in for the adapter the value-object generator emits into a real consumer. + public readonly record struct ScalarRuleAdapter(TRule Rule) + : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule + { + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); + } + } + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "TenantId must not be the default.")] + [ZodSchema] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenShippedNonSentinelOnScalar_AdaptsAndKeepsTheRuleIdentity( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(BuiltInScalarAttributeSource, cancellationToken); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert - the shipped open-generic attribute closes over the scalar's underlying value and the wrapped + // built-in rule owns the reported identity. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert + .That(generated) + .ContainsGeneratedCode( + "((global::ZodSharp.Core.IZodRule)tenantIdCustomRuleInner0).Code ?? \"validation_failed\"" + ); + } + + [Test] + public async Task RuleAttributeGeneration_GivenShippedNonSentinelOnScalar_ReportsEmptyPathAndRuleCode( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + BuiltInScalarAttributeSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.TenantId")!; + var validate = assembly.GetType("Testing.TenantIdSchema")!.GetMethod("Validate")!; + + // Act - the default Guid is Guid.Empty, which the built-in rule rejects. + var invalidResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + // Assert - the error is reported at the scalar itself (empty path) with the built-in rule's own code. + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + invalidResult.GetType().GetProperty("Errors")!.GetValue(invalidResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + await Assert.That(errors[0].Message).IsEqualTo("TenantId must not be the default."); + } + + /// + /// A [ZodSchema] class that uses the new numeric rules, a generic bound rule closed with an + /// int, and a collision rule's suffixed attribute. + /// + const string NewBuiltInAttributeSource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Metrics + { + [MinValue(MinValue = 3)] + public int Count { get; set; } + + [Even] + public int EvenCount { get; set; } + + [GreaterThanOrEqual(MinValue = 1.5)] + public double Ratio { get; set; } + + [MinLengthZod(MinLength = 3, Code = "too_short", Message = "Too short.")] + public string Code { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenNewAndCollisionAttributes_ResolvesTheRules( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); + var generated = driverResult.GetSource("MetricsSchema"); + + // Assert - the generic bound rule is closed with the member type (its double bound converted to int), + // the generic even rule is closed with int, and the suffixed length attribute carries code and message. + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.MinValueRule(3)"); + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EvenRule(null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.GreaterThanOrEqualRule(1.5D, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.MinLengthRule(3, \"Too short.\", \"too_short\")"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenNewAndCollisionAttributes_ReportsTheirCodes( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + NewBuiltInAttributeSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Metrics")!; + var validate = assembly.GetType("Testing.MetricsSchema")!.GetMethod("Validate")!; + + // Act - the default metrics are all invalid; the even member is set odd. + var instance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("EvenCount")!.SetValue(instance, 3); + var result = validate.Invoke(null, [instance])!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + result.GetType().GetProperty("Errors")!.GetValue(result)!; + await Assert.That(errors.Any(error => error.Code == Rules.MinValueRule.ErrorCode)).IsTrue(); + await Assert.That(errors.Any(error => error.Code == Rules.EvenRule.ErrorCode)).IsTrue(); + await Assert.That(errors.Any(error => error.Code == Rules.GreaterThanOrEqualRule.ErrorCode)).IsTrue(); + await Assert.That(errors.Any(error => error.Code == "too_short")).IsTrue(); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index 0f1a58c..7c4c475 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -38,8 +38,12 @@ public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? M 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"); + // The rule's 'message' parameter is surfaced as a Message alias; the inherited + // ValidationAttribute.ErrorMessage remains the fallback the resolver maps onto it. + await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); + // A generated attribute can annotate a scalar value object as well as a member. + await Assert.That(generated).Contains("global::System.AttributeTargets.Class"); + await Assert.That(generated).Contains("global::System.AttributeTargets.Struct"); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs new file mode 100644 index 0000000..d9204cc --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs @@ -0,0 +1,181 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +/// +/// Guards the validation attributes the generator emits into the shipped Purview.ZodSharp assembly for +/// the built-in rules, so a consumer can annotate a member (or a scalar value object) with the rule's own +/// attribute instead of hand-authoring one. +/// +public class GeneratedRuleAttributesTests +{ + static Assembly RulesAssembly => typeof(IntRule).Assembly; + + /// The attribute name the rule derives: a trailing Rule becomes Attribute. + static string AttributeName(string ruleName) => + (ruleName.EndsWith("Rule", StringComparison.Ordinal) ? ruleName[..^"Rule".Length] : ruleName) + "Attribute"; + + static string AttributeName(Type ruleType) => AttributeName(ruleType.Name.Split('`')[0]); + + /// + /// The rules whose derived attribute name is already taken by a System.ComponentModel.DataAnnotations + /// attribute, so the generator emits theirs under the Zod suffix (for example [MinLengthZod]). + /// + static readonly Type[] CollisionRuleTypes = + [ + typeof(MinLengthRule), + typeof(MaxLengthRule), + typeof(UrlRule), + typeof(PhoneRule), + typeof(CreditCardRule), + typeof(Base64StringRule), + ]; + + static string GeneratedAttributeName(Type ruleType) + { + var name = ruleType.Name.Split('`')[0]; + return AttributeName(CollisionRuleTypes.Contains(ruleType) ? name[..^"Rule".Length] + "ZodRule" : name); + } + + static Type? FindAttribute(Type ruleType) => + RulesAssembly.GetType("ZodSharp.Rules." + GeneratedAttributeName(ruleType)); + + /// + /// Every built-in rule that generates a validation attribute. All built-in rules implement + /// , so each one owns its error identity and reports its own ErrorCode. + /// + public static IEnumerable AttributeBackedRuleTypes() => + RulesAssembly + .GetTypes() + .Where(static type => type is { IsPublic: true, IsValueType: true } && type.Namespace == "ZodSharp.Rules") + .Where(static type => type.GetInterfaces().Contains(typeof(IZodRule))) + .Where(static type => !type.IsGenericTypeDefinition || type.GetGenericArguments().Length == 1); + + public static IEnumerable DataAnnotationsCollisionRuleTypes() => CollisionRuleTypes; + + [Test] + [MethodDataSource(nameof(AttributeBackedRuleTypes))] + public async Task Rule_Always_HasGeneratedValidationAttribute(Type ruleType) + { + // Act + var attributeType = FindAttribute(ruleType); + + // Assert + await Assert.That(attributeType).IsNotNull(); + await Assert.That(attributeType!.IsSubclassOf(typeof(ValidationAttribute))).IsTrue(); + await Assert.That(attributeType.IsSealed).IsTrue(); + + // The attribute maps back to the rule (the open generic for a generic rule), so the generator resolves + // the rule from the applied attribute. + var mapping = attributeType.GetCustomAttribute(); + await Assert.That(mapping).IsNotNull(); + await Assert.That(mapping!.RuleType).IsEqualTo(ruleType); + } + + [Test] + [MethodDataSource(nameof(AttributeBackedRuleTypes))] + public async Task Attribute_Always_TargetsMembersAndScalarValueObjects(Type ruleType) + { + // Act + var usage = FindAttribute(ruleType)!.GetCustomAttribute(); + + // Assert + await Assert.That(usage).IsNotNull(); + await Assert.That(usage!.ValidOn.HasFlag(AttributeTargets.Class)).IsTrue(); + await Assert.That(usage.ValidOn.HasFlag(AttributeTargets.Struct)).IsTrue(); + await Assert.That(usage.ValidOn.HasFlag(AttributeTargets.Property)).IsTrue(); + await Assert.That(usage.ValidOn.HasFlag(AttributeTargets.Field)).IsTrue(); + await Assert.That(usage.ValidOn.HasFlag(AttributeTargets.Parameter)).IsTrue(); + await Assert.That(usage.Inherited).IsTrue(); + await Assert.That(usage.AllowMultiple).IsFalse(); + } + + [Test] + [MethodDataSource(nameof(DataAnnotationsCollisionRuleTypes))] + public async Task Rule_GivenDataAnnotationsNameCollision_GeneratesSuffixedAttribute(Type ruleType) + { + // Act + var attributeType = FindAttribute(ruleType); + + // Assert - the plain name stays with DataAnnotations, and the rule's attribute uses the "Zod" suffix. + await Assert + .That( + typeof(MinLengthAttribute).Assembly.GetType( + "System.ComponentModel.DataAnnotations." + AttributeName(ruleType) + ) + ) + .IsNotNull(); + await Assert.That(attributeType).IsNotNull(); + await Assert.That(attributeType!.Name).EndsWith("ZodAttribute", StringComparison.Ordinal); + await Assert.That(attributeType.GetCustomAttribute()!.RuleType).IsEqualTo(ruleType); + } + + [Test] + [MethodDataSource(nameof(DataAnnotationsCollisionRuleTypes))] + public async Task Rule_GivenDataAnnotationsNameCollision_ExposesCodeAndMessage(Type ruleType) + { + // Act + var attributeType = FindAttribute(ruleType)!; + + // Assert - the suffixed attribute carries the rule's identity and message, so both can be overridden. + await Assert.That(attributeType.GetProperty("Code")).IsNotNull(); + await Assert.That(attributeType.GetProperty("Message")).IsNotNull(); + await Assert.That(typeof(IZodRule).IsAssignableFrom(ruleType)).IsTrue(); + } + + [Test] + [Arguments(typeof(MinValueRule<>), "MinValue")] + [Arguments(typeof(MaxValueRule<>), "MaxValue")] + [Arguments(typeof(GreaterThanRule<>), "ExclusiveMinimum")] + [Arguments(typeof(LessThanRule<>), "ExclusiveMaximum")] + public async Task Attribute_GivenGenericBoundRule_ExposesDoubleBound(Type ruleType, string propertyName) + { + // Act - the type-parameter bound cannot be mirrored directly, so it is surfaced as a double. + var property = FindAttribute(ruleType)!.GetProperty(propertyName); + + // Assert + await Assert.That(property).IsNotNull(); + await Assert.That(property!.PropertyType).IsEqualTo(typeof(double)); + } + + [Test] + [Arguments(typeof(RegexRule))] + [Arguments(typeof(E164Rule))] + [Arguments(typeof(NonSentinelRule<>))] + [Arguments(typeof(GreaterThanOrEqualRule))] + [Arguments(typeof(LessThanOrEqualRule))] + [Arguments(typeof(EvenRule<>))] + [Arguments(typeof(OddRule<>))] + public async Task Attribute_GivenRuleWithMessageParameter_ExposesMessageAlias(Type ruleType) + { + // Act + var message = FindAttribute(ruleType)!.GetProperty("Message"); + + // Assert - the resolver maps Message onto the rule's message argument (ErrorMessage remains the fallback). + await Assert.That(message).IsNotNull(); + await Assert.That(message!.PropertyType).IsEqualTo(typeof(string)); + } + + [Test] + public async Task Attribute_GivenRuleWithoutMessageParameter_HasNoMessageAlias() + { + // Act + var message = FindAttribute(typeof(EmailRule))!.GetProperty("Message"); + + // Assert + await Assert.That(message).IsNull(); + } + + [Test] + public async Task Attribute_GivenRegexRule_MirrorsTheStringPatternOverload() + { + // Act - the Regex overload is not representable as an attribute property, so the string overload is used. + var pattern = FindAttribute(typeof(RegexRule))!.GetProperty("Pattern"); + + // Assert + await Assert.That(pattern).IsNotNull(); + await Assert.That(pattern!.PropertyType).IsEqualTo(typeof(string)); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs new file mode 100644 index 0000000..c92673f --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using System.Text; +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +/// +/// Covers the inclusive comparison and parity rules added alongside the existing generic bound rules. +/// +public class NumericRulesTests +{ + [Test] + [Arguments(3.0, true)] + [Arguments(4.0, true)] + [Arguments(2.9, false)] + public async Task GreaterThanOrEqualRule_GivenValue_ComparesInclusively(double value, bool expected) + { + // Arrange + GreaterThanOrEqualRule rule = new(3.0); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); + } + + [Test] + public async Task GreaterThanOrEqualRule_GivenFailure_FormatsTheBoundAndValue() + { + // Arrange + GreaterThanOrEqualRule rule = new(3.0); + + // Act + var message = rule.GetErrorMessage(1.0); + var expected = string.Format( + CultureInfo.CurrentCulture, + CompositeFormat.Parse(GreaterThanOrEqualRule.MessageFormat), + 3.0, + 1.0 + ); + + // Assert + await Assert.That(message).IsEqualTo(expected); + } + + [Test] + [Arguments(3.0, true)] + [Arguments(2.0, true)] + [Arguments(3.1, false)] + public async Task LessThanOrEqualRule_GivenValue_ComparesInclusively(double value, bool expected) + { + // Arrange + LessThanOrEqualRule rule = new(3.0); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); + } + + [Test] + [Arguments(0.0, true)] + [Arguments(2.0, true)] + [Arguments(-4.0, true)] + [Arguments(3.0, false)] + [Arguments(-3.0, false)] + public async Task EvenRule_GivenDouble_ChecksParity(double value, bool expected) + { + // Arrange + EvenRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(EvenRule.ErrorCode); + } + + [Test] + [Arguments(0, true)] + [Arguments(2, true)] + [Arguments(7, false)] + public async Task EvenRule_GivenInteger_ChecksParity(int value, bool expected) + { + // Arrange - the rule is generic over any INumber, so it closes with int as well as double. + EvenRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + [Arguments(1.0, true)] + [Arguments(3.0, true)] + [Arguments(-5.0, true)] + [Arguments(4.0, false)] + [Arguments(0.0, false)] + public async Task OddRule_GivenDouble_ChecksParity(double value, bool expected) + { + // Arrange + OddRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(OddRule.ErrorCode); + } + + [Test] + [Arguments(1, true)] + [Arguments(3, true)] + [Arguments(2, false)] + public async Task OddRule_GivenInteger_ChecksParity(int value, bool expected) + { + // Arrange + OddRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + public async Task EvenRule_GivenMessage_OverridesTheFormattedMessage() + { + // Arrange + EvenRule rule = new("Number must be even."); + + // Act + var message = rule.GetErrorMessage(3.0); + + // Assert + await Assert.That(message).IsEqualTo("Number must be even."); + } + + [Test] + public async Task Rule_GivenCodeOverride_ReportsItThroughIZodRule() + { + // Arrange + MinLengthRule rule = new(3, code: "too_short"); + + // Act + var code = ((IZodRule)rule).Code; + + // Assert - the canonical constant is unchanged, the per-usage override is reported at runtime. + await Assert.That(rule.Code).IsEqualTo(MinLengthRule.ErrorCode); + await Assert.That(code).IsEqualTo("too_short"); + } + + [Test] + public async Task ZodNumber_GivenEvenAndOdd_ReportsTheRuleCodes() + { + // Act + var even = Z.Number().Even().Validate(3.0); + var odd = Z.Number().Odd().Validate(4.0); + + // Assert + await Assert.That(even.Errors[0].Code).IsEqualTo(EvenRule.ErrorCode); + await Assert.That(odd.Errors[0].Code).IsEqualTo(OddRule.ErrorCode); + } + + [Test] + public async Task ZodNumber_GivenGteAndLte_ReportsTheRuleCodes() + { + // Act + var gte = Z.Number().Gte(3.0).Validate(2.0); + var lte = Z.Number().Lte(3.0).Validate(4.0); + + // Assert + await Assert.That(gte.Errors[0].Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); + await Assert.That(lte.Errors[0].Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); + } +} From 18f8b97aaccdc3966e77e77a014b31a18bb707b4 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 4 Oct 2026 17:46:47 +0100 Subject: [PATCH 12/26] refactor: code smell --- src/src/SourceGenerators/Helpers/CustomRuleResolver.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index fb90fd3..9ac6cbd 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -732,7 +732,7 @@ static ImmutableArray MappedParameters(INamedTypeSymbol ruleTy // attribute-mappable" vacuously and silently drop the rule's real constructor arguments. It is only a // candidate when the rule declares no constructor of its own (for example a parameterless rule). var considered = candidates.Any(static c => !c.IsImplicitlyDeclared) - ? candidates.Where(static c => !c.IsImplicitlyDeclared).ToArray() + ? [.. candidates.Where(static c => !c.IsImplicitlyDeclared)] : candidates; return considered @@ -1091,6 +1091,7 @@ static bool TryConvertNumeric(object value, SpecialType targetSpecialType, out s } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] static Type? GetNumericClrType(SpecialType specialType) => specialType switch { @@ -1108,6 +1109,7 @@ static bool TryConvertNumeric(object value, SpecialType targetSpecialType, out s _ => null, }; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] static Type? GetNumericClrType(Type type) => Type.GetTypeCode(type) switch { From f974ea1d2152cc9385771ce7949e3528f2db379b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 4 Oct 2026 23:08:25 +0100 Subject: [PATCH 13/26] refactor: all rules now have standard ctor, extension, and attribs --- AGENTS.md | 12 + docs/wiki/Custom-Rules.md | 18 +- docs/wiki/Guarantees-and-Limitations.md | 4 +- docs/wiki/Number-Validation.md | 14 +- docs/wiki/Source-Generator-DataAnnotations.md | 2 +- docs/wiki/String-Validation.md | 12 +- docs/wiki/Validation-Rules-Reference.md | 87 +++---- .../Helpers/CustomRuleResolver.cs | 41 +++- .../Models/RuleAttributeGenerationModel.cs | 28 ++- .../ZodSchemaGenerator.RuleAttributes.cs | 147 +++++++++++- src/src/ZodSharp/Core/ZodType.cs | 17 +- .../Extensions/System/StringExtensions.cs | 4 +- .../JsonSchema/ToJsonSchemaConverter.cs | 8 +- src/src/ZodSharp/Rules/Base64StringRule.cs | 12 +- src/src/ZodSharp/Rules/Base64UrlRule.cs | 16 +- src/src/ZodSharp/Rules/CreditCardRule.cs | 14 +- src/src/ZodSharp/Rules/Cuid2Rule.cs | 16 +- src/src/ZodSharp/Rules/DateStringRule.cs | 16 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 16 +- src/src/ZodSharp/Rules/E164Rule.cs | 16 +- src/src/ZodSharp/Rules/EmailRule.cs | 16 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 29 ++- src/src/ZodSharp/Rules/EvenRule.cs | 14 +- src/src/ZodSharp/Rules/FiniteRule.cs | 21 +- .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 21 +- src/src/ZodSharp/Rules/GreaterThanRule.cs | 14 +- src/src/ZodSharp/Rules/HexRule.cs | 16 +- src/src/ZodSharp/Rules/IPAddressRule.cs | 72 +++++- src/src/ZodSharp/Rules/IncludesRule.cs | 15 +- src/src/ZodSharp/Rules/IntRule.cs | 25 +- src/src/ZodSharp/Rules/JWTRule.cs | 16 +- src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 21 +- src/src/ZodSharp/Rules/LessThanRule.cs | 14 +- src/src/ZodSharp/Rules/MaxLengthRule.cs | 14 +- src/src/ZodSharp/Rules/MaxValueRule.cs | 12 +- src/src/ZodSharp/Rules/MinLengthRule.cs | 14 +- src/src/ZodSharp/Rules/MinValueRule.cs | 12 +- src/src/ZodSharp/Rules/MultipleOfRule.cs | 46 ++-- src/src/ZodSharp/Rules/NanoidRule.cs | 16 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 14 +- src/src/ZodSharp/Rules/OddRule.cs | 14 +- src/src/ZodSharp/Rules/PhoneRule.cs | 14 +- src/src/ZodSharp/Rules/RegexRule.cs | 18 +- src/src/ZodSharp/Rules/SafeIntegerRule.cs | 13 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 29 ++- src/src/ZodSharp/Rules/TimeStringRule.cs | 16 +- src/src/ZodSharp/Rules/ULIDRule.cs | 16 +- src/src/ZodSharp/Rules/UriRule.cs | 15 +- src/src/ZodSharp/Rules/UrlRule.cs | 12 +- src/src/ZodSharp/Rules/UuidRule.cs | 34 +-- src/src/ZodSharp/Schemas/ZodBigInt.cs | 60 +++-- src/src/ZodSharp/Schemas/ZodDate.cs | 17 +- src/src/ZodSharp/Schemas/ZodNumber.cs | 91 +++++--- src/src/ZodSharp/Schemas/ZodString.cs | 191 +++++++++++----- src/src/ZodSharp/Sdk/README.md | 2 +- src/src/ZodSharp/UuidVersion.cs | 2 +- ...emaGeneratorTests.BuiltInRuleAttributes.cs | 215 ++++++++++++++++-- .../Rules/GeneratedRuleAttributesTests.cs | 32 ++- .../Rules/NumericRulesTests.cs | 153 ++++++++++++- .../Rules/RuleConstantsTests.cs | 2 +- .../Schemas/ZodNumberTests.cs | 10 + .../Schemas/ZodStringTests.cs | 47 ++++ 62 files changed, 1465 insertions(+), 460 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 938b648..0b49120 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,18 @@ The generator and analyzer are built with `Purview.SourceGeneratorFramework`: Validation rules follow a conventions analyzer (`ValidationRuleConventionsAnalyzer`, diagnostic `ZODSGEN042`): a source-declared rule (a type implementing `ZodSharp.Core.IValidationRule`) must expose its error identity as public `const string ErrorCode` and `const string MessageFormat` constants, so tests can assert against the rule rather than duplicating literals. New built-in rules must follow the same convention; keep `AnalyzerReleases.Shipped.md`/`AnalyzerReleases.Unshipped.md` in sync when a diagnostic is added or changed. +## Numeric rules + +The numeric rules are split by the constraint each family needs — pick the constraint that matches the operations, never widen a rule unnecessarily: + +- **Bound rules** — `MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule` — are generic over `T : IComparable`. Keep this constraint: `ZodDate` closes the bound rules with `DateTime`, which does **not** implement `INumber`. Do not change them to `INumber`. +- **Arithmetic rules** — `IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule` — are generic over `T : INumber` so they close with any numeric type (`int`, `long`, `double`, `decimal`, …). +- **`SafeIntegerRule`** is intentionally `double`-only, because "safe integer" is a JavaScript `Number` concept (`int.MinValue`..`int.MaxValue`); do not make it generic. +- `ZodNumber` closes the rules with `double`, `ZodBigInt` with `long`, and `ZodDate` with `DateTime`. Fluent methods on `ZodNumber` therefore use `XxxRule`. +- Any code that matches a rule by name (for example the JSON Schema converter) must tolerate the generic arity suffix: a generic rule's `Type.Name` is `IntRule\`1`, so match with `StartsWith` rather than equality. + +Generated rule attributes carry the rule's **value** parameters as a constructor: a parameter declared without a default is a required constructor argument (so it cannot be silently omitted), a parameter with a default keeps it, and `message`/`code`/`origin` stay properties. Attribute usages for required values are positional (`[MinValue(3)]`, `[Regex("^[a-z]+$")]`); defaulted values may still be set by property name. When changing rule constructor parameters, update the affected generator tests and the wiki attribute tables. + ## Packing and package READMEs Each package ships its own `README.md`, placed in the project's `Sdk/` folder (for example `src/src/ZodSharp/Sdk/README.md`). The SDK's `PurviewAutoSdkPack` automatically maps `Sdk/*.md` to the package root and `Sdk/buildTransitive/**` to `buildTransitive/`, and the repo-root `README.md` is skipped when a package already packs its own README. Packages also ship `purview-logo-light.png` (linked via `src/Directory.Build.props`) and the core package ships `buildTransitive/Purview.ZodSharp.props`. diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index df191ca..1b717cc 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -253,7 +253,7 @@ 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. +> The attribute's arguments are mapped to the rule's public constructor parameters: named arguments by name (case-insensitive), and positional arguments by the applied attribute's own constructor parameter names first (with the raw position as a fallback for hand-authored attributes whose parameter names differ from the rule's). A parameter named `message` is supplied from the attribute's `ErrorMessage` when one is set. ### Error identity: code and origin precedence @@ -417,6 +417,12 @@ This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like: public sealed class NoWhitespaceAttribute : global::System.ComponentModel.DataAnnotations.ValidationAttribute { + /// Initializes the attribute with the values required by NoWhitespaceRule. + public NoWhitespaceAttribute(bool allowEmpty = true) + { + AllowEmpty = allowEmpty; + } + public bool AllowEmpty { get; set; } = true; public string? Message { get; set; } = null; @@ -426,7 +432,7 @@ public sealed class NoWhitespaceAttribute 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` becomes a `Message` property (the resolver maps it onto the rule's `message` argument); a parameter named `code` becomes a `Code` property; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. +- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. The rule's **value** parameters are additionally emitted as a constructor: a parameter the rule declares without a default becomes a required constructor argument, so an attribute like `[MinValue]` cannot be applied without its bound, while a parameter with a default keeps that default (so `[NoWhitespace]` still works). A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument) and defaults to the rule's `MessageFormat`; a parameter named `code`/`origin` becomes a `Code`/`Origin` property and `Code` defaults to the rule's `ErrorCode`; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. - If the derived name collides with a `System.ComponentModel.DataAnnotations` attribute, the generated attribute is emitted under a `Zod` suffix (`MinLengthAttribute` → `MinLengthZodAttribute`, used as `[MinLengthZod]`). - The attribute is always decorated with `AttributeTargets.Class | Struct | Property | Field | Parameter`, so it can annotate a member or a scalar value object. (`Class`/`Struct` are what make the [type-level form](#type-level-rules) possible.) - The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). @@ -479,7 +485,7 @@ public partial class Contact [E164] public string Phone { get; set; } = string.Empty; - [Regex(Pattern = "^[a-z]+$")] + [Regex("^[a-z]+$")] public string Code { get; set; } = string.Empty; [NonSentinel(Message = "Id must not be the default.")] @@ -487,14 +493,14 @@ public partial class Contact } ``` -Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string; a rule's `message` parameter is surfaced as a `Message` property and its `code` parameter (where present) as a `Code` property. +Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). Two adjustments keep every rule addressable: - **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, and `Base64StringRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute. -- **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`) surface their type-parameter bound as a `double`, so `[MinValue(MinValue = 3)]` works on an `int` or a `double` member (the value is converted to the member type). +- **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). -The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(MinValue = …)]`, `[LessThanOrEqual(MaxValue = …)]`, `[Even]`, and `[Odd]`. +The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(…)]`, `[LessThanOrEqual(…)]`, `[Even]`, and `[Odd]`. ## Type-level rules diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 06cacbb..68c4f51 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -33,7 +33,7 @@ The generator reports `Origin = "string"` for string size failures, `Origin = "a ### Rule errors -Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, `invalid_string` for string-format validations, `invalid_type` for `IntRule`, and `invalid_value` for `NonSentinelRule`. Custom rules that do not declare a code default to `validation_failed`. Every rule exposes its correlated code and message template as public `const string ErrorCode` / `MessageFormat` constants so tests can assert against the rule rather than duplicating literals; a source-declared rule that omits them is reported as `ZODSGEN042`. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators. +Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_small`/`too_big` for bounds, `not_multiple_of`/`not_finite` for numbers, `invalid_string` for string-format validations, `invalid_type` for `IntRule`, and `invalid_value` for `NonSentinelRule`. Custom rules that do not declare a code default to `validation_failed`. Every rule exposes its correlated code and message template as public `const string ErrorCode` / `MessageFormat` constants so tests can assert against the rule rather than duplicating literals; a source-declared rule that omits them is reported as `ZODSGEN042`. 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 @@ -41,7 +41,7 @@ Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_ ### Number semantics -`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`. +`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 distance to the nearest multiple against 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 diff --git a/docs/wiki/Number-Validation.md b/docs/wiki/Number-Validation.md index 35efce1..f03cae1 100644 --- a/docs/wiki/Number-Validation.md +++ b/docs/wiki/Number-Validation.md @@ -16,21 +16,23 @@ var result = schema.Validate(30.0); | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | | `Gt` | `Gt(double value)` | `GreaterThanRule` — strictly greater than `value` | -| `Gte` | `Gte(double value)` | `GreaterThanOrEqualRule` — greater than or equal to `value` | +| `Gte` | `Gte(double value)` | `GreaterThanOrEqualRule` — greater than or equal to `value` | | `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` | -| `Lte` | `Lte(double value)` | `LessThanOrEqualRule` — less than or equal to `value` | -| `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)`; failure code `invalid_type` | +| `Lte` | `Lte(double value)` | `LessThanOrEqualRule` — less than or equal to `value` | +| `Int` | `Int()` | `IntRule` — `value % 1 == 0`; failure code `invalid_type` | | `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` | +| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) | +| `Finite` | `Finite(string? message)` | `FiniteRule` — `T.IsFinite` | | `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue`; failure code `too_big` | | `Even` | `Even(string? message)` | `EvenRule` — `value % 2 == 0`; failure code `invalid_value` | | `Odd` | `Odd(string? message)` | `OddRule` — `value % 2 != 0`; failure code `invalid_value` | -`GreaterThanOrEqualRule`/`LessThanOrEqualRule` (the rules behind `Gte`/`Lte`) and `EvenRule`/`OddRule` are available directly too. `EvenRule`/`OddRule` are generic over `T : INumber`, so they close with any numeric type (`int`, `long`, `decimal`, …) — not just `double`. +All six bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) are generic over `T : IComparable`, and the arithmetic rules (`IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule`) are generic over `T : INumber`, so they close with any numeric type (`int`, `long`, `decimal`, …) — not just `double`. `ZodNumber` closes them with `double`; `ZodBigInt` and `ZodDate` close the bound rules with `long` and `DateTime`. `SafeIntegerRule` is intentionally `double`-only, because "safe integer" is a JavaScript `Number` concept. + +Every numeric method also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). ## Examples diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 5a65fc5..1d38ff8 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -90,4 +90,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full 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). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md index 1205424..1571c26 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -25,10 +25,10 @@ var result = schema.Validate("user@example.com"); | `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` | | `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max | | `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected | -| `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison | -| `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison | -| `Includes` | `Includes(string substring, string? message)` | `IncludesRule` — ordinal substring containment | -| `IP` | `IP(string? message)` | `IPAddressRule` — IPv4 or IPv6 address | +| `StartsWith` | `StartsWith(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWithRule` — comparison defaults to `Ordinal` | +| `EndsWith` | `EndsWith(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWithRule` — comparison defaults to `Ordinal` | +| `Includes` | `Includes(string substring, string? message, string? code)` | `IncludesRule` — ordinal substring containment | +| `IP` | `IP(string? message, string? code)` / `IP(IPAddressRuleType ruleType, string? message, string? code)` | `IPAddressRule` — IPv4/IPv6, or only the requested family (`IPv4`/`IPv6`/`Any`; defaults to `Any`) | | `JWT` | `JWT(string? message)` | `JWTRule` — three base64url-encoded segments | | `Hex` | `Hex(string? message)` | `HexRule` — hexadecimal characters (empty string is valid, matching Zod) | | `Base64Url` | `Base64Url(string? message)` | `Base64UrlRule` — URL-safe base64, no padding (groups of 4 plus a 2-3 character tail) | @@ -49,6 +49,8 @@ var result = schema.Validate("user@example.com"); > [!NOTE] > `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`. +Every method that adds a rule also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). The rule's default message format is used when `message` is omitted. + ## Examples ```csharp @@ -73,7 +75,7 @@ var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); ## Error messages -Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. For the full catalogue — including rules without a fluent method — see [Validation Rules Reference](Validation-Rules-Reference.md). +Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big`; string-format validations emit `invalid_string`. Many methods accept a custom `message` and `code` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. For the full catalogue — including rules without a fluent method — see [Validation Rules Reference](Validation-Rules-Reference.md). Every rule exposes its error identity as public constants — `public const string ErrorCode` and a `public const string MessageFormat` (a `{0}`-style template) — so tests and consumers can assert against the rule instead of duplicating literals: diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 6f0329e..03ed3e4 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -57,11 +57,11 @@ The built-in rules report one of the following Zod-compatible codes: | Code | Meaning | Emitted by | |---|---|---| | `invalid_string` | A string did not match the required format | all string format rules | -| `invalid_type` | A value was not the expected type/shape (e.g. not a whole number) | `IntRule` | +| `invalid_type` | A value was not the expected type/shape (e.g. not a whole number) | `IntRule` | | `too_small` | A length or value fell below a lower bound | `MinLengthRule`, `MinValueRule`, `GreaterThanRule` | | `too_big` | A length or value exceeded an upper bound | `MaxLengthRule`, `MaxValueRule`, `LessThanRule`, `SafeIntegerRule` | -| `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | -| `not_finite` | A number was `NaN` or infinite | `FiniteRule` | +| `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | +| `not_finite` | A number was `NaN` or infinite | `FiniteRule` | | `invalid_value` | A value was a rejected sentinel | `NonSentinelRule` | | `validation_failed` | Fallback for rules that do not declare an identity | any rule without `ErrorCode`/`IZodRule` | @@ -94,16 +94,18 @@ Fluent methods live on `ZodString` (`ZodSharp.Schemas`). The `Span` column marks | `UUIDRule` | `(string? message)` or `(UuidVersion version, string? message)` | `UUID()` / `UUID(version)` | `invalid_string` | `Invalid UUID format: {0}` (or `Invalid UUID v{0} format: {1}`) | yes | | `ULIDRule` | `(string? message)` | `ULID()` | `invalid_string` | `Invalid ULID: {0}` | yes | | `JWTRule` | `(string? message)` | `JWT()` | `invalid_string` | `Invalid JWT: {0}` | yes | -| `IPAddressRule` | `(string? message)` | `IP()` | `invalid_string` | `Invalid IP address: {0}` | yes | +| `IPAddressRule` | `(string? message, string? code)` or `(IPAddressRuleType ruleType, string? message, string? code)` | `IP()` / `IP(ruleType)` | `invalid_string` | `Invalid {1} address: {0}` | yes | | `HexRule` | `(string? message)` | `Hex()` | `invalid_string` | `Invalid hexadecimal string: {0}` | yes | | `DateStringRule` | `(string? message)` | `Date()` | `invalid_string` | `Invalid date: {0}` | yes | | `TimeStringRule` | `(string? message)` | `Time()` | `invalid_string` | `Invalid time: {0}` | yes | | `DatetimeStringRule` | `(string? message)` | `Datetime()` | `invalid_string` | `Invalid date-time: {0}` | yes | | `NanoidRule` | `(string? message)` | `Nanoid()` | `invalid_string` | `Invalid nanoid: {0}` | yes | | `Cuid2Rule` | `(string? message)` | `Cuid2()` | `invalid_string` | `Invalid CUID2: {0}` | yes | -| `StartsWithRule` | `(string prefix, string? message)` | `StartsWith(prefix)` | `invalid_string` | `String must start with '{0}', but got '{1}'` | yes | -| `EndsWithRule` | `(string suffix, string? message)` | `EndsWith(suffix)` | `invalid_string` | `String must end with '{0}', but got '{1}'` | yes | -| `IncludesRule` | `(string substring, string? message)` | `Includes(substring)` | `invalid_string` | `String must contain '{0}', but got '{1}'` | yes | +| `StartsWithRule` | `(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWith(prefix, comparison)` | `invalid_string` | `String must start with '{0}', but got '{1}'` | yes | +| `EndsWithRule` | `(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWith(suffix, comparison)` | `invalid_string` | `String must end with '{0}', but got '{1}'` | yes | +| `IncludesRule` | `(string substring, string? message, string? code)` | `Includes(substring)` | `invalid_string` | `String must contain '{0}', but got '{1}'` | yes | + +Every rule constructor also accepts an optional `string? code = null` after `message`; when set it overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). Behaviour notes: @@ -119,11 +121,11 @@ Behaviour notes: - **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. - **`ULIDRule`** requires 26 Crockford base32 characters, with the first character `0`-`7`. - **`JWTRule`** requires three non-empty base64url-encoded segments separated by periods. -- **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`). +- **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`); pass an `IPAddressRuleType` to require `IPv4`, `IPv6`, or `Any` (the default). - **`HexRule`** accepts any run of ASCII hex digits; the empty string is valid, matching Zod. - **`DateStringRule`** parses `yyyy-MM-dd` (invariant culture); **`TimeStringRule`** accepts `HH:mm`, optionally `:ss` and fractional seconds; **`DatetimeStringRule`** requires `yyyy-MM-dd` + `T` + `HH:mm:ss[.fff]` + `Z`. - **`NanoidRule`** requires exactly 21 URL-safe characters; **`Cuid2Rule`** requires non-empty lowercase alphanumerics. -- **`StartsWithRule`**, **`EndsWithRule`**, and **`IncludesRule`** use ordinal comparison. +- **`StartsWithRule`** and **`EndsWithRule`** use the supplied `StringComparison` (default `Ordinal`); **`IncludesRule`** uses ordinal comparison. ## Number rules @@ -135,24 +137,25 @@ Fluent methods live on `ZodNumber` (`ZodSharp.Schemas`), which validates `double | `MaxValueRule` | `(T maxValue)` | `Max(v)`, `NonPositive()` | `too_big` | `Value must be at most {0}, but got {1}` | | `GreaterThanRule` | `(T exclusiveMinimum)` | `Gt(v)`, `Positive()` | `too_small` | `Value must be greater than {0}, but got {1}` | | `LessThanRule` | `(T exclusiveMaximum)` | `Lt(v)`, `Negative()` | `too_big` | `Value must be less than {0}, but got {1}` | -| `GreaterThanOrEqualRule` | `(double minValue, string? message)` | `Gte(v)` | `too_small` | `Value must be greater than or equal to {0}, but got {1}` | -| `LessThanOrEqualRule` | `(double maxValue, string? message)` | `Lte(v)` | `too_big` | `Value must be less than or equal to {0}, but got {1}` | +| `GreaterThanOrEqualRule` | `(T minValue, string? message)` | `Gte(v)` | `too_small` | `Value must be greater than or equal to {0}, but got {1}` | +| `LessThanOrEqualRule` | `(T maxValue, string? message)` | `Lte(v)` | `too_big` | `Value must be less than or equal to {0}, but got {1}` | | `EvenRule` | `(string? message)` | `Even()` | `invalid_value` | `Number must be even, but got {0}` | | `OddRule` | `(string? message)` | `Odd()` | `invalid_value` | `Number must be odd, but got {0}` | -| `MultipleOfRule` | `(double divisor, string? message)` | `MultipleOf(divisor)` | `not_multiple_of` | `Number must be a multiple of {0}, but got {1}` | -| `FiniteRule` | `(string? message)` | `Finite()` | `not_finite` | `Number must be finite, but got {0}` | +| `MultipleOfRule` | `(T divisor, string? message)` | `MultipleOf(divisor)` | `not_multiple_of` | `Number must be a multiple of {0}, but got {1}` | +| `FiniteRule` | `(string? message)` | `Finite()` | `not_finite` | `Number must be finite, but got {0}` | | `SafeIntegerRule` | `(string? message)` | `Safe()` | `too_big` | `Number must be a safe integer, but got {0}` | -| `IntRule` | `()` | `Int()` | `invalid_type` | `Expected integer, but got {0}` | +| `IntRule` | `()` | `Int()` | `invalid_type` | `Expected integer, but got {0}` | Behaviour notes: -- The bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`) are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). `ZodNumber`'s `Gte`/`Lte` use the non-generic `GreaterThanOrEqualRule`/`LessThanOrEqualRule`; `ZodBigInt` and `ZodDate` close the generic rules with `long` and `DateTime`. +- All six bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). `ZodBigInt` and `ZodDate` close the generic rules with `long` and `DateTime`. +- The arithmetic rules (`IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule`) are generic over `T : INumber`, so they close with `int`, `long`, `double`, `decimal`, and every other numeric type; the fluent methods close them with `double`. - `Positive()` is `GreaterThanRule(0.0)`, `Negative()` is `LessThanRule(0.0)`, `NonNegative()` is `MinValueRule(0.0)`, and `NonPositive()` is `MaxValueRule(0.0)`. -- **`EvenRule`** and **`OddRule`** are generic over `T : INumber`, so they close with `int`, `long`, `double`, `decimal`, and every other numeric type; `Even()`/`Odd()` close them with `double`. A value is even when `value % 2 == 0`. -- **`MultipleOfRule`** throws `ArgumentException` when the divisor is `0`, and compares the quotient to its nearest integer with a relative tolerance of `1e-12` (so `0.3` is accepted for a divisor of `0.1`). -- **`FiniteRule`** rejects `NaN` and infinities via `double.IsFinite`. -- **`SafeIntegerRule`** requires a whole number within `int.MinValue`..`int.MaxValue`. -- **`IntRule`** requires `value == Math.Truncate(value)`. +- **`EvenRule`** and **`OddRule`** treat a value as even when `value % 2 == 0`. +- **`MultipleOfRule`** throws `ArgumentException` when the divisor is `0`, and compares the distance to the nearest multiple against a relative tolerance of `1e-12` (so `0.3` is accepted for a divisor of `0.1`). Integer types cannot represent the tolerance, so their comparison is exact. +- **`FiniteRule`** rejects `NaN` and infinities via `T.IsFinite`. +- **`SafeIntegerRule`** is intentionally `double`-only ("safe integer" is a JavaScript `Number` concept); it requires a whole number within `int.MinValue`..`int.MaxValue`. +- **`IntRule`** requires `value % T.One == T.Zero`, which also rejects infinities (a truncate comparison would accept them). ## Value rules @@ -183,11 +186,11 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali |---|---|---| | `[Email]` | `EmailRule` | | | `[E164]` | `E164Rule` | | -| `[Regex(Pattern = "…")]` | `RegexRule` | mirrors the `(string pattern, string? message)` overload | -| `[UUID]` / `[UUID(Version = …)]` | `UUIDRule` | | +| `[Regex("…")]` | `RegexRule` | mirrors the `(string pattern, string? message)` overload | +| `[UUID(UuidVersion.…)]` | `UUIDRule` | the version is required | | `[ULID]` | `ULIDRule` | | | `[JWT]` | `JWTRule` | | -| `[IPAddress]` | `IPAddressRule` | | +| `[IPAddress]` / `[IPAddress(IPAddressRuleType.…)]` | `IPAddressRule` | defaults to `Any`; pass a type to require IPv4/IPv6 | | `[Hex]` | `HexRule` | | | `[Base64Url]` | `Base64UrlRule` | | | `[Nanoid]` | `NanoidRule` | | @@ -195,33 +198,35 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[DateString]` | `DateStringRule` | | | `[TimeString]` | `TimeStringRule` | | | `[DatetimeString]` | `DatetimeStringRule` | | -| `[StartsWith(Prefix = "…")]` | `StartsWithRule` | | -| `[EndsWith(Suffix = "…")]` | `EndsWithRule` | | -| `[Includes(Substring = "…")]` | `IncludesRule` | | -| `[MultipleOf(Divisor = …)]` | `MultipleOfRule` | | -| `[Finite]` | `FiniteRule` | | +| `[StartsWith("…")]` | `StartsWithRule` | | +| `[EndsWith("…")]` | `EndsWithRule` | | +| `[Includes("…")]` | `IncludesRule` | | +| `[MultipleOf(…)]` | `MultipleOfRule` | closes the open generic with the member type | +| `[Finite]` | `FiniteRule` | closes the open generic with the member type | | `[SafeInteger]` | `SafeIntegerRule` | | -| `[Int]` | `IntRule` | | -| `[Uri(UriKind = …)]` | `UriRule` | | +| `[Int]` | `IntRule` | closes the open generic with the member type | +| `[Uri(UriKind.…)]` | `UriRule` | the `UriKind` is required | | `[NonSentinel]` | `NonSentinelRule` | closes the open generic with the member/scalar type | -| `[MinValue(MinValue = …)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | -| `[MaxValue(MaxValue = …)]` | `MaxValueRule` | as above | -| `[GreaterThan(ExclusiveMinimum = …)]` | `GreaterThanRule` | as above | -| `[LessThan(ExclusiveMaximum = …)]` | `LessThanRule` | as above | -| `[GreaterThanOrEqual(MinValue = …)]` | `GreaterThanOrEqualRule` | | -| `[LessThanOrEqual(MaxValue = …)]` | `LessThanOrEqualRule` | | +| `[MinValue(…)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | +| `[MaxValue(…)]` | `MaxValueRule` | as above | +| `[GreaterThan(…)]` | `GreaterThanRule` | as above | +| `[LessThan(…)]` | `LessThanRule` | as above | +| `[GreaterThanOrEqual(…)]` | `GreaterThanOrEqualRule` | as above | +| `[LessThanOrEqual(…)]` | `LessThanOrEqualRule` | as above | | `[Even]` | `EvenRule` | closes the open generic with the member type | | `[Odd]` | `OddRule` | closes the open generic with the member type | -| `[MinLengthZod(MinLength = …)]` | `MinLengthRule` | suffixed to avoid the DataAnnotations name; exposes `Code`/`Message` | -| `[MaxLengthZod(MaxLength = …)]` | `MaxLengthRule` | as above | +| `[MinLengthZod(…)]` | `MinLengthRule` | suffixed to avoid the DataAnnotations name; exposes `Code`/`Message` | +| `[MaxLengthZod(…)]` | `MaxLengthRule` | as above | | `[UrlZod]` | `UrlRule` | as above | | `[PhoneZod]` | `PhoneRule` | as above | | `[CreditCardZod]` | `CreditCardRule` | as above | | `[Base64StringZod]` | `Base64StringRule` | as above | -Each generated attribute exposes a `Message` property when its rule has a `message` constructor parameter, and reports the rule's own `ErrorCode`/`Origin` because every built-in rule implements `IZodRule`. +A generated attribute's constructor mirrors the rule's value parameters: a parameter the rule declares without a default becomes a required constructor argument, and a parameter with a default keeps that default (so an optional value such as `[StartsWith("…", StringComparison.OrdinalIgnoreCase)]` still has a usable default). The value parameters are also exposed as settable properties. The rule's `message` and identity (`code`/`origin`) parameters stay properties only — `Message` and, where present, `Code` — and default to the rule's own `MessageFormat`/`ErrorCode`, so the attribute is self-describing. + +Each generated attribute reports the rule's own `ErrorCode`/`Origin` because every built-in rule implements `IZodRule`. -Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(MinValue = 3)]` works on an `int` or a `double` member. +Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). ## Exposed as DataAnnotations attributes @@ -236,7 +241,7 @@ The `[ZodSchema]` generator maps several built-in rules to their `System.Compone | `[Base64String]` | `Base64StringRule` | `invalid_string` | | `[RegularExpression]` | compiled `Regex`, not `RegexRule` | `invalid_string` | -Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. The rule-identity variants of the colliding names are the suffixed [generated attributes](#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex(Pattern = "…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. +Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. The rule-identity variants of the colliding names are the suffixed [generated attributes](#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex("…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. ## Related diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 9ac6cbd..89eeabc 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -604,10 +604,7 @@ internal static bool TryGetIdentityParameter(INamedTypeSymbol ruleType, out stri { foreach (var parameter in MappedParameters(ruleType)) { - if ( - string.Equals(parameter.Name, "code", StringComparison.OrdinalIgnoreCase) - || string.Equals(parameter.Name, "origin", StringComparison.OrdinalIgnoreCase) - ) + if (IsIdentityParameter(parameter)) { parameterName = parameter.Name; return true; @@ -618,6 +615,15 @@ internal static bool TryGetIdentityParameter(INamedTypeSymbol ruleType, out stri return false; } + /// + /// Determines whether the parameter declares the rule's error identity (code/origin). + /// + /// The rule constructor parameter to test. + /// when the parameter is an identity parameter. + internal static bool IsIdentityParameter(IParameterSymbol parameter) => + string.Equals(parameter.Name, "code", StringComparison.OrdinalIgnoreCase) + || string.Equals(parameter.Name, "origin", StringComparison.OrdinalIgnoreCase); + /// /// Gets the named arguments an applied attribute supplies that the resolved rule never consumes: the /// argument matches no public constructor parameter and is not part of the reported error identity, so the @@ -895,6 +901,21 @@ out string? unmappedParameterName foreach (var pair in attribute.NamedArguments) named[pair.Key] = pair.Value; + // The applied attribute's positional arguments are matched by its own constructor parameter names + // first, so a generated constructor that omits the rule's message/identity parameters keeps the rule + // parameters aligned. The raw index remains the fallback for hand-authored attributes whose parameter + // names differ from the rule's. + Dictionary positionalByName = [with(StringComparer.OrdinalIgnoreCase)]; + if (attribute.AttributeConstructor is { } appliedConstructor) + { + for (var i = 0; i < appliedConstructor.Parameters.Length && i < positional.Length; i++) + { + var appliedName = appliedConstructor.Parameters[i].Name; + if (!positionalByName.ContainsKey(appliedName)) + positionalByName.Add(appliedName, positional[i]); + } + } + var expressions = ImmutableArray.CreateBuilder(constructor.Parameters.Length); for (var i = 0; i < constructor.Parameters.Length; i++) @@ -913,6 +934,18 @@ out string? unmappedParameterName continue; } + if (positionalByName.TryGetValue(parameter.Name, out var positionalValue)) + { + if (!TryConvertConstant(positionalValue, parameter.Type, out var namedPositionalExpression)) + { + unmappedParameterName = parameter.Name; + return false; + } + + expressions.Add(namedPositionalExpression); + continue; + } + if (i < positional.Length) { if (!TryConvertConstant(positional[i], parameter.Type, out var positionalExpression)) diff --git a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs index a3d5de8..c4a8b11 100644 --- a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs +++ b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs @@ -14,6 +14,11 @@ namespace ZodSharp.SourceGenerators.Models; /// [ZodRule(AllowMultiple = ...)]. /// /// The generated attribute properties, mirroring the rule constructor parameters. +/// +/// The attribute constructor parameters carrying the rule's constructor values, in the rule's declaration +/// order. A parameter without a is required, so the +/// attribute cannot be applied without it. +/// readonly record struct RuleAttributeGenerationModel( TypeIdentity RuleType, TypeIdentity AttributeType, @@ -21,7 +26,8 @@ readonly record struct RuleAttributeGenerationModel( string? Code, string? Origin, bool AllowMultiple, - EquatableArray Properties + EquatableArray Properties, + EquatableArray ConstructorParameters = default ); /// @@ -32,3 +38,23 @@ EquatableArray Properties /// The optional initializer expression. /// Whether the property type is nullable. readonly record struct GeneratedAttributeProperty(TypeIdentity Type, string Name, string? Initializer, bool IsNullable); + +/// +/// Describes a constructor parameter on a generated validation attribute that carries a rule's constructor +/// value. +/// +/// The parameter type. +/// The parameter name, taken from the rule's constructor parameter. +/// The generated property the parameter assigns. +/// +/// The default-value expression for an optional parameter, or when the parameter is +/// required and must be supplied at the attribute's usage site. +/// +/// Whether the parameter type is nullable. +readonly record struct GeneratedAttributeParameter( + TypeIdentity Type, + string Name, + string PropertyName, + string? DefaultValue, + bool IsNullable +); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index d9c1a5a..cf60d86 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -153,6 +153,7 @@ is not null } var properties = ImmutableArray.CreateBuilder(); + var constructorParameters = ImmutableArray.CreateBuilder(); var constructor = CustomRuleResolver.SelectAttributeConstructor(ruleType); if (constructor is not null) @@ -173,6 +174,8 @@ is not null if (CustomRuleResolver.IsCancellationToken(parameter.Type)) continue; + var isIdentityParameter = CustomRuleResolver.IsIdentityParameter(parameter); + // A type-parameter constructor argument (for example `MinValueRule(T minValue)`) cannot be // mirrored directly; it is surfaced as a double, the numeric type the schema pipeline uses and // the type a `[MinValue]`-style attribute can carry. @@ -195,12 +198,32 @@ is not null return GeneratorResult.Create(default, propertyDiagnostics); } + TypeIdentity propertyTypeIdentity = new(TypeHelpers.StripNullableAnnotations(propertyType)); + var propertyName = ToPascalCase(parameter.Name); + var initializer = BuildPropertyInitializer(parameter, propertyType, ruleType, isIdentityParameter); + var isNullable = TypeHelpers.CanBeNull(propertyType); + properties.Add( - new GeneratedAttributeProperty( - new TypeIdentity(TypeHelpers.StripNullableAnnotations(propertyType)), - ToPascalCase(parameter.Name), - BuildInitializer(parameter, propertyType), - TypeHelpers.CanBeNull(propertyType) + new GeneratedAttributeProperty(propertyTypeIdentity, propertyName, initializer, isNullable) + ); + + // The attribute constructor mirrors the rule's value parameters so a required value (for + // example the `maxValue` of `LessThanOrEqualRule`) has to be supplied at the usage site + // instead of silently falling back to the type's default. A defaulted parameter keeps its + // default, so `[Even]`-style attributes stay applicable without arguments. + // + // Identity parameters stay properties only: the resolver reads the reported error identity from + // the applied attribute's named arguments, so a constructor parameter would never feed it. + if (isIdentityParameter) + continue; + + constructorParameters.Add( + new GeneratedAttributeParameter( + propertyTypeIdentity, + parameter.Name, + propertyName, + parameter.HasExplicitDefaultValue ? initializer : null, + isNullable ) ); } @@ -211,7 +234,7 @@ is not null new GeneratedAttributeProperty( new TypeIdentity(TypeHelpers.StripNullableAnnotations(messageParameter.Type)), "Message", - BuildInitializer(messageParameter, messageParameter.Type), + BuildMessageInitializer(messageParameter, ruleType), TypeHelpers.CanBeNull(messageParameter.Type) ) ); @@ -228,7 +251,8 @@ is not null GetNamedString(attribute, "Code"), GetNamedString(attribute, "Origin"), GetNamedBool(attribute, "AllowMultiple"), - new(properties.ToImmutable()) + new(properties.ToImmutable()), + new(constructorParameters.ToImmutable()) ), identityWarnings ); @@ -288,6 +312,65 @@ static string BuildInitializer(IParameterSymbol parameter, ITypeSymbol propertyT static string ToPascalCase(string name) => name.Length == 0 ? name : string.Concat(char.ToUpperInvariant(name[0]), name.AsSpan(1).ToString()); + /// + /// Reads a public const string member (for example a rule's ErrorCode or + /// MessageFormat) so the generated attribute can mirror the rule's own default. + /// + /// The rule type to inspect. + /// The name of the constant member. + /// The constant's value, or when no such constant exists. + static string? GetRuleConstString(INamedTypeSymbol ruleType, string memberName) + { + foreach (var member in ruleType.GetMembers(memberName)) + { + if (member is IFieldSymbol { IsConst: true, ConstantValue: string value }) + return value; + } + + return null; + } + + /// + /// Builds the initializer for a generated attribute property. The identity Code property mirrors + /// the rule's own ErrorCode default so the generated attribute is self-describing; every other + /// property keeps the parameter's own default. + /// + /// The rule constructor parameter the property mirrors. + /// The property type. + /// The rule type. + /// Whether the parameter is the rule's code/origin. + /// The initializer expression. + static string BuildPropertyInitializer( + IParameterSymbol parameter, + ITypeSymbol propertyType, + INamedTypeSymbol ruleType, + bool isIdentityParameter + ) + { + if ( + isIdentityParameter + && string.Equals(parameter.Name, "code", StringComparison.OrdinalIgnoreCase) + && GetRuleConstString(ruleType, "ErrorCode") is { } errorCode + ) + { + return errorCode.StringLiteral(); + } + + return BuildInitializer(parameter, propertyType); + } + + /// + /// Builds the initializer for the generated Message property, mirroring the rule's own + /// MessageFormat default when the rule exposes one. + /// + /// The rule's message constructor parameter. + /// The rule type. + /// The initializer expression. + static string BuildMessageInitializer(IParameterSymbol messageParameter, INamedTypeSymbol ruleType) => + GetRuleConstString(ruleType, "MessageFormat") is { } messageFormat + ? messageFormat.StringLiteral() + : BuildInitializer(messageParameter, messageParameter.Type); + static string? GetNamedString(AttributeData attribute, string name) { foreach (var pair in attribute.NamedArguments) @@ -550,6 +633,8 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m ) ) { + BuildAttributeConstructor(writer, model); + foreach (var property in model.Properties) { var propertyType = property.IsNullable @@ -568,6 +653,54 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m } } + /// + /// Emits the attribute constructor that carries the rule's value parameters. A parameter the rule declares + /// without a default has no default here either, so the attribute cannot be applied without it; the rule's + /// message and identity (code/origin) parameters stay properties. Rules whose only + /// constructor parameters are message/identity (or none) emit no constructor and keep the implicit + /// parameterless one. + /// + /// The writer positioned inside the attribute's class scope. + /// The generation model describing the attribute. + static void BuildAttributeConstructor(CodeWriter writer, RuleAttributeGenerationModel model) + { + if (model.ConstructorParameters.IsEmpty) + return; + + writer.XmlSummary( + $"Initializes the attribute with the values required by {CodeWriter.XmlSee(model.RuleType)}.", + "A required rule constructor value cannot be omitted; optional values keep their rule default." + ); + + var parameters = ImmutableArray.CreateBuilder(model.ConstructorParameters.Count); + + foreach (var constructorParameter in model.ConstructorParameters) + { + var parameterType = constructorParameter.IsNullable + ? constructorParameter.Type.AsTypeReference().Nullable(writer) + : constructorParameter.Type.AsTypeReference(); + + parameters.Add( + new ParameterDeclarationOptions(constructorParameter.Name, parameterType) + { + DefaultValue = constructorParameter.DefaultValue, + } + ); + } + + writer.Constructor( + new ConstructorDeclarationOptions(model.AttributeType, TypeDeclarationAccessibility.Public) + { + Parameters = parameters.ToImmutable(), + }, + constructorBody => + { + foreach (var constructorParameter in model.ConstructorParameters) + constructorBody.Assignment(constructorParameter.PropertyName, constructorParameter.Name); + } + ); + } + static ImmutableArray BuildRuleArguments(RuleAttributeGenerationModel model) { var builder = ImmutableArray.CreateBuilder(); diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index f8d3b7c..01a33dc 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -52,7 +52,7 @@ public ValidationResult Validate(TInput value) if (!rule.IsValid(validatedValue)) { errors ??= [with(rulesCount)]; - errors.Add(new ValidationError(rule.Code, rule.GetErrorMessage(validatedValue), EmptyPath)); + errors.Add(new ValidationError(ResolveCode(rule), rule.GetErrorMessage(validatedValue), EmptyPath)); } } @@ -126,18 +126,29 @@ public ZodType Rule(TRule rule) /// ). Types without a known sentinel always pass. /// /// Optional error message. + /// Optional error code override. /// This schema for method chaining. /// /// Concrete schemas whose output type has a known sentinel (for example /// and ) override this method with a /// covariant return type so the fluent chain keeps the concrete schema. /// - public virtual ZodType NonSentinel(string? message = null) + public virtual ZodType NonSentinel(string? message = null, string? code = null) { - AddRule(new NonSentinelRule(message)); + AddRule(new NonSentinelRule(message, code)); return this; } + /// + /// Resolves the error code to report for a failed rule: a rule that implements + /// and supplies a code (typically from its constructor) wins over the rule's intrinsic + /// . + /// + /// The rule that failed. + /// The code to report. + static string ResolveCode(IValidationRule rule) => + rule is IZodRule zodRule && zodRule.Code is { } code ? code : rule.Code; + /// /// Gets the number of rules accumulated on this schema. /// diff --git a/src/src/ZodSharp/Extensions/System/StringExtensions.cs b/src/src/ZodSharp/Extensions/System/StringExtensions.cs index 94e684e..6d113b0 100644 --- a/src/src/ZodSharp/Extensions/System/StringExtensions.cs +++ b/src/src/ZodSharp/Extensions/System/StringExtensions.cs @@ -1,11 +1,11 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; namespace System; /// [EditorBrowsable(EditorBrowsableState.Never)] -public static class StringExtensions +static class StringExtensions { /// /// Returns the string if it is not null or empty; otherwise, returns null. diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 0d4affc..60cde18 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -194,7 +194,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) if (maxValueField?.GetValue(rule) is double maxValue) result.Maximum = maxValue; } - else if (ruleName == "GreaterThanOrEqualRule") + else if (ruleName.StartsWith("GreaterThanOrEqualRule", StringComparison.Ordinal)) { var minValueField = ruleType.GetField( "_minValue", @@ -203,7 +203,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) if (minValueField?.GetValue(rule) is double minValue) result.Minimum = minValue; } - else if (ruleName == "LessThanOrEqualRule") + else if (ruleName.StartsWith("LessThanOrEqualRule", StringComparison.Ordinal)) { var maxValueField = ruleType.GetField( "_maxValue", @@ -212,11 +212,11 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) if (maxValueField?.GetValue(rule) is double maxValue) result.Maximum = maxValue; } - else if (ruleName == "IntRule") + else if (ruleName.StartsWith("IntRule", StringComparison.Ordinal)) { result.Type = "integer"; } - else if (ruleName == "MultipleOfRule") + else if (ruleName.StartsWith("MultipleOfRule", StringComparison.Ordinal)) { var divisorField = ruleType.GetField( "_divisor", diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 6060454..2ef9862 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -14,8 +14,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid Base64 string format: {0}"; - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the Base64StringRule struct. @@ -24,8 +24,8 @@ namespace ZodSharp.Rules; /// Optional error code override public Base64StringRule(string? message = null, string? code = null) { - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -54,12 +54,12 @@ public bool IsValid(in string value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index 39c61df..d62d7df 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid base64url string: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the Base64UrlRule struct. /// /// Optional error message - public Base64UrlRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public Base64UrlRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a base64url string. @@ -54,7 +60,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -62,12 +68,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index cea50c2..b69b446 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -14,8 +14,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid credit card number format: {0}"; - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the CreditCardRule struct. @@ -24,8 +24,8 @@ namespace ZodSharp.Rules; /// Optional error code override public CreditCardRule(string? message = null, string? code = null) { - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -84,7 +84,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -92,12 +92,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs index 13a67ab..183bd03 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid CUID2: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the Cuid2Rule struct. /// /// Optional error message - public Cuid2Rule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public Cuid2Rule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a CUID2. @@ -52,7 +58,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -60,12 +66,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index b78ae00..0565ace 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -15,13 +15,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid date: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the DateStringRule struct. /// /// Optional error message - public DateStringRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public DateStringRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is an ISO 8601 date. @@ -49,7 +55,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -57,12 +63,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index b77397c..5868160 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -19,13 +19,19 @@ public readonly record struct DatetimeStringRule /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid date-time: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the DatetimeStringRule struct. /// /// Optional error message - public DatetimeStringRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public DatetimeStringRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is an ISO 8601 date-time. @@ -66,7 +72,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -74,7 +80,7 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) { @@ -119,7 +125,7 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 6900f3f..47f7a60 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid E.164 phone number: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the E164Rule struct. /// /// Optional error message - public E164Rule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public E164Rule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is an E.164 phone number. @@ -56,7 +62,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -64,12 +70,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 99e1c50..1ab0dd1 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -24,6 +24,15 @@ namespace ZodSharp.Rules; TimeSpan.FromMilliseconds(100) ); + readonly string _message; + readonly string _code; + + public EmailRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } + /// /// Validates that the value matches the email format. /// @@ -43,19 +52,20 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(MessageFormat, value.ToString()); + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index 6090fc3..3d4709b 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -14,17 +14,28 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must end with '{0}', but got '{1}'"; readonly string _suffix; - readonly string? _message; + readonly StringComparison _comparison; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the EndsWithRule struct. /// /// The required suffix + /// The string comparison type /// Optional error message - public EndsWithRule(string suffix, string? message = null) + /// Optional error code override + public EndsWithRule( + string suffix, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) { - _suffix = suffix ?? throw new ArgumentNullException(nameof(suffix)); - _message = message.OrNull(); + _suffix = suffix.OrNull() ?? throw new ArgumentNullException(nameof(suffix)); + _comparison = comparison; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -32,21 +43,21 @@ public EndsWithRule(string suffix, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => value != null && value.EndsWith(_suffix, StringComparison.Ordinal); + public bool IsValid(in string value) => value != null && value.EndsWith(_suffix, _comparison); /// /// 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); + public bool IsValid(ReadOnlySpan value) => value.EndsWith(_suffix.AsSpan(), _comparison); /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _suffix, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _suffix, value); /// /// Gets the error message for a failed span validation. @@ -54,12 +65,12 @@ public EndsWithRule(string suffix, string? message = null) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, _suffix, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, _suffix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs index 88fac6b..2dfa856 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -17,13 +17,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be even, but got {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the EvenRule struct. /// /// Optional error message - public EvenRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public EvenRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is even. @@ -37,12 +43,12 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index 938e171..f2cb948 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -1,11 +1,15 @@ +using System.Numerics; + namespace ZodSharp.Rules; /// /// Validation rule for finite number check. /// Uses struct to avoid allocations. /// +/// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule +public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule + where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_finite"; @@ -13,15 +17,18 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be finite, but got {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the FiniteRule struct. /// /// Optional error message - public FiniteRule(string? message = null) + /// Optional error code override + public FiniteRule(string? message = null, string? code = null) { - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -29,19 +36,19 @@ public FiniteRule(string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in double value) => double.IsFinite(value); + public bool IsValid(in T value) => T.IsFinite(value); /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index d1e8601..1b8a616 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -4,8 +4,10 @@ namespace ZodSharp.Rules; /// Validation rule for an inclusive lower numeric bound (greater than or equal to). /// Uses struct to avoid allocations. /// +/// The comparable type [Core.ZodRule] -public readonly record struct GreaterThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct GreaterThanOrEqualRule : Core.IValidationRule, Core.IZodRule + where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; @@ -13,18 +15,21 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be greater than or equal to {0}, but got {1}"; - readonly double _minValue; - readonly string? _message; + readonly T _minValue; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the GreaterThanOrEqualRule struct. /// /// The inclusive lower bound /// Optional error message - public GreaterThanOrEqualRule(double minValue, string? message = null) + /// Optional error code override + public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = null) { _minValue = minValue; - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -32,19 +37,19 @@ public GreaterThanOrEqualRule(double minValue, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in double value) => value >= _minValue; + public bool IsValid(in T value) => value.CompareTo(_minValue) >= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _minValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index fef301e..d0e73a8 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -17,13 +17,20 @@ namespace ZodSharp.Rules; readonly T _exclusiveMinimum; + readonly string _message; + readonly string _code; + /// /// Initializes a new instance of the GreaterThanRule struct. /// /// The value the input must be strictly greater than - public GreaterThanRule(T exclusiveMinimum) + /// Optional error message + /// Optional error code override + public GreaterThanRule(T exclusiveMinimum, string? message = null, string? code = null) { _exclusiveMinimum = exclusiveMinimum; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -38,12 +45,13 @@ public GreaterThanRule(T exclusiveMinimum) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _exclusiveMinimum, value); + public string GetErrorMessage(in T value) => + RuleMessage.Format(_message ?? MessageFormat, _exclusiveMinimum, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs index 6c15103..a0f3d3f 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid hexadecimal string: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the HexRule struct. /// /// Optional error message - public HexRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public HexRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a hexadecimal string. @@ -49,7 +55,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -57,12 +63,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 6971b0f..4f633ab 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -3,6 +3,27 @@ namespace ZodSharp.Rules; +/// +/// Specifies the type of IP address to validate against. +/// +public enum IPAddressRuleType +{ + /// + /// Validates that the value is an IPv4 address. + /// + IPv4, + + /// + /// Validates that the value is an IPv6 address. + /// + IPv6, + + /// + /// Validates that the value is either an IPv4 or IPv6 address. + /// + Any, +} + /// /// Validation rule for IPv4 and IPv6 address format. /// Uses struct to avoid allocations. @@ -14,15 +35,36 @@ namespace ZodSharp.Rules; public const string ErrorCode = "invalid_string"; /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid IP address: {0}"; + public const string MessageFormat = "Invalid {1} address: {0}"; - readonly string? _message; + readonly IPAddressRuleType _ruleType; + readonly string _message; + readonly string _code; + + /// + /// Initializes a new instance of the IPAddressRule struct with default rule type (Any). + /// + /// Optional error message + /// Optional error code override + public IPAddressRule(string? message = null, string? code = null) + : this(IPAddressRuleType.Any, message, code) { } /// /// Initializes a new instance of the IPAddressRule struct. /// + /// The type of IP address to validate against (defaults to ) /// Optional error message - public IPAddressRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public IPAddressRule( + IPAddressRuleType ruleType = IPAddressRuleType.Any, + string? message = null, + string? code = null + ) + { + _ruleType = ruleType; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is an IPv4 or IPv6 address. @@ -42,8 +84,17 @@ public bool IsValid(ReadOnlySpan value) return false; // Use IPAddress.TryParse to validate the IP address format without throwing exceptions - return IPAddress.TryParse(value, out var address) - && address.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6; + if (IPAddress.TryParse(value, out var address)) + { + if (_ruleType == IPAddressRuleType.Any) + return address.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6; + else if (_ruleType == IPAddressRuleType.IPv4) + return address.AddressFamily == AddressFamily.InterNetwork; + else if (_ruleType == IPAddressRuleType.IPv6) + return address.AddressFamily == AddressFamily.InterNetworkV6; + } + + return false; } /// @@ -51,7 +102,8 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => + RuleMessage.Format(_message, value, _ruleType == IPAddressRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString()); /// /// Gets the error message for a failed span validation. @@ -59,12 +111,16 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format( + _message, + value.ToString(), + _ruleType == IPAddressRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() + ); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index c42579c..259ae76 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -14,17 +14,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must contain '{0}', but got '{1}'"; readonly string _substring; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the IncludesRule struct. /// /// The required substring /// Optional error message - public IncludesRule(string substring, string? message = null) + /// Optional error code override + public IncludesRule(string substring, string? message = null, string? code = null) { _substring = substring ?? throw new ArgumentNullException(nameof(substring)); - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -46,7 +49,7 @@ public IncludesRule(string substring, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _substring, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _substring, value); /// /// Gets the error message for a failed span validation. @@ -54,12 +57,12 @@ public IncludesRule(string substring, string? message = null) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, _substring, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, _substring, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index 3259d43..f83c6a9 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -1,11 +1,15 @@ +using System.Numerics; + namespace ZodSharp.Rules; /// /// Validation rule for integer values. /// Uses struct to avoid allocations. /// +/// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule +public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule + where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_type"; @@ -13,24 +17,37 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Expected integer, but got {0}"; + readonly string _message; + readonly string _code; + + public IntRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } + /// /// Validates that the value is an integer. /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in double value) => value == Math.Truncate(value); + /// + /// The remainder formulation also rejects infinities, which a truncate comparison would accept + /// (double.PositiveInfinity == Math.Truncate(double.PositiveInfinity)). + /// + public bool IsValid(in T value) => value % T.One == T.Zero; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index 04c268f..a8f0fa0 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid JWT: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the JWTRule struct. /// /// Optional error message - public JWTRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public JWTRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a JWT. @@ -58,7 +64,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -66,7 +72,7 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); static bool IsBase64Url(ReadOnlySpan segment) { @@ -85,7 +91,7 @@ static bool IsBase64Url(ReadOnlySpan segment) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index f5164ab..166b644 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -4,8 +4,10 @@ namespace ZodSharp.Rules; /// Validation rule for an inclusive upper numeric bound (less than or equal to). /// Uses struct to avoid allocations. /// +/// The comparable type [Core.ZodRule] -public readonly record struct LessThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct LessThanOrEqualRule : Core.IValidationRule, Core.IZodRule + where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -13,18 +15,21 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be less than or equal to {0}, but got {1}"; - readonly double _maxValue; - readonly string? _message; + readonly T _maxValue; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the LessThanOrEqualRule struct. /// /// The inclusive upper bound /// Optional error message - public LessThanOrEqualRule(double maxValue, string? message = null) + /// Optional error code override + public LessThanOrEqualRule(T maxValue, string? message = null, string? code = null) { _maxValue = maxValue; - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -32,19 +37,19 @@ public LessThanOrEqualRule(double maxValue, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in double value) => value <= _maxValue; + public bool IsValid(in T value) => value.CompareTo(_maxValue) <= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _maxValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 96ba12c..a33b205 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -17,13 +17,20 @@ namespace ZodSharp.Rules; readonly T _exclusiveMaximum; + readonly string _message; + readonly string _code; + /// /// Initializes a new instance of the LessThanRule struct. /// /// The value the input must be strictly less than - public LessThanRule(T exclusiveMaximum) + /// Optional error message + /// Optional error code override + public LessThanRule(T exclusiveMaximum, string? message = null, string? code = null) { _exclusiveMaximum = exclusiveMaximum; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -38,12 +45,13 @@ public LessThanRule(T exclusiveMaximum) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _exclusiveMaximum, value); + public string GetErrorMessage(in T value) => + RuleMessage.Format(_message ?? MessageFormat, _exclusiveMaximum, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 2db87bd..c94c9ea 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -14,8 +14,8 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; readonly int _maxLength; - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the MaxLengthRule struct. @@ -26,8 +26,8 @@ namespace ZodSharp.Rules; public MaxLengthRule(int maxLength, string? message = null, string? code = null) { _maxLength = maxLength; - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -50,7 +50,7 @@ public MaxLengthRule(int maxLength, string? message = null, string? code = null) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - _message ?? RuleMessage.Format(MessageFormat, _maxLength, value.LengthOrDefault()); + RuleMessage.Format(_message ?? MessageFormat, _maxLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -58,12 +58,12 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, _maxLength, value.Length); + RuleMessage.Format(_message ?? MessageFormat, _maxLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index 66f146d..c2d451b 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -16,14 +16,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be at most {0}, but got {1}"; readonly T _maxValue; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the MaxValueRule struct. /// /// The maximum value - public MaxValueRule(T maxValue) + /// Optional error message + /// Optional error code override + public MaxValueRule(T maxValue, string? message = null, string? code = null) { _maxValue = maxValue; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -38,12 +44,12 @@ public MaxValueRule(T maxValue) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _maxValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 3c35acc..4311e03 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -14,8 +14,8 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; readonly int _minLength; - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the MinLengthRule struct. @@ -26,8 +26,8 @@ namespace ZodSharp.Rules; public MinLengthRule(int minLength, string? message = null, string? code = null) { _minLength = minLength; - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -50,7 +50,7 @@ public MinLengthRule(int minLength, string? message = null, string? code = null) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - _message ?? RuleMessage.Format(MessageFormat, _minLength, value.LengthOrDefault()); + RuleMessage.Format(_message ?? MessageFormat, _minLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -58,12 +58,12 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, _minLength, value.Length); + RuleMessage.Format(_message ?? MessageFormat, _minLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 6d4ec12..6ed166b 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -16,14 +16,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be at least {0}, but got {1}"; readonly T _minValue; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the MinValueRule struct. /// /// The minimum value - public MinValueRule(T minValue) + /// Optional error message + /// Optional error code override + public MinValueRule(T minValue, string? message = null, string? code = null) { _minValue = minValue; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -38,12 +44,12 @@ public MinValueRule(T minValue) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(MessageFormat, _minValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index e2068a6..b883932 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -1,11 +1,15 @@ +using System.Numerics; + namespace ZodSharp.Rules; /// /// Validation rule for multiple-of check. /// Uses struct to avoid allocations. /// +/// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule + where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_multiple_of"; @@ -14,25 +18,28 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; /// - /// The relative tolerance applied when comparing the quotient to its nearest integer. + /// The relative tolerance applied when comparing the distance to the nearest multiple. /// const double RelativeTolerance = 1e-12; - readonly double _divisor; - readonly string? _message; + readonly T _divisor; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the MultipleOfRule struct. /// /// The divisor /// Optional error message - public MultipleOfRule(double divisor, string? message = null) + /// Optional error code override + public MultipleOfRule(T divisor, string? message = null, string? code = null) { - if (divisor == 0) + if (divisor == T.Zero) throw new ArgumentException("Divisor cannot be zero", nameof(divisor)); _divisor = divisor; - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -40,18 +47,21 @@ public MultipleOfRule(double divisor, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in double value) + /// + /// Floating-point division is inexact (for example 0.3 / 0.1 == 2.9999999999999996), so the + /// distance to the nearest multiple is compared against a relative tolerance instead of testing the raw + /// remainder. The tolerance saturates to zero for types that cannot represent it (the integer types), so + /// those compare exactly. + /// + public bool IsValid(in T value) { - if (double.IsNaN(value) || double.IsInfinity(value)) + if (!T.IsFinite(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; + var remainder = T.Abs(value % _divisor); + var distance = T.Min(remainder, T.Abs(_divisor) - remainder); + var tolerance = T.CreateSaturating(RelativeTolerance) * T.Max(T.Abs(_divisor), T.Abs(value)); + return distance <= tolerance; } /// @@ -59,12 +69,12 @@ public bool IsValid(in double value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, _divisor, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _divisor, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs index 8af0a91..9c84866 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid nanoid: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the NanoidRule struct. /// /// Optional error message - public NanoidRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public NanoidRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a nanoid. @@ -52,7 +58,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -60,12 +66,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index ecddc1e..57c2424 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -35,13 +35,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Value is a sentinel value, but got {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the struct. /// /// Optional error message - public NonSentinelRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public NonSentinelRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is not a sentinel value. @@ -55,12 +61,12 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs index aa9372b..f22e350 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -17,13 +17,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be odd, but got {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the OddRule struct. /// /// Optional error message - public OddRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public OddRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is odd. @@ -37,12 +43,12 @@ namespace ZodSharp.Rules; /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index baf313f..4541dd2 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -15,8 +15,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid phone number format: {0}"; - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the PhoneRule struct. @@ -25,8 +25,8 @@ namespace ZodSharp.Rules; /// Optional error code override public PhoneRule(string? message = null, string? code = null) { - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -71,7 +71,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -79,12 +79,12 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 8f6c410..6a1660d 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -16,17 +16,20 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String does not match the required pattern: {0}"; readonly Regex _pattern; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the RegexRule struct. /// /// The regex pattern /// Optional error message - public RegexRule(Regex pattern, string? message = null) + /// Optional error code override + public RegexRule(Regex pattern, string? message = null, string? code = null) { _pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -34,8 +37,9 @@ public RegexRule(Regex pattern, string? message = null) /// /// The regex pattern /// Optional error message - public RegexRule(string pattern, string? message = null) - : this(new Regex(pattern, RegexOptions.Compiled), message) { } + /// Optional error code override + public RegexRule(string pattern, string? message = null, string? code = null) + : this(new Regex(pattern, RegexOptions.Compiled), message, code) { } /// /// Validates that the value matches the regex pattern. @@ -56,7 +60,7 @@ public RegexRule(string pattern, string? message = null) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _pattern); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -68,7 +72,7 @@ public RegexRule(string pattern, string? message = null) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index 3d2abc1..f4797b2 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -13,15 +13,18 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be a safe integer, but got {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the SafeIntegerRule struct. /// /// Optional error message - public SafeIntegerRule(string? message = null) + /// Optional error code override + public SafeIntegerRule(string? message = null, string? code = null) { - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -37,12 +40,12 @@ public bool IsValid(in double value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in double value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in double value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index f2700c3..92464e5 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -14,17 +14,28 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must start with '{0}', but got '{1}'"; readonly string _prefix; - readonly string? _message; + readonly StringComparison _comparison; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the StartsWithRule struct. /// /// The required prefix + /// The string comparison type /// Optional error message - public StartsWithRule(string prefix, string? message = null) + /// Optional error code override + public StartsWithRule( + string prefix, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) { - _prefix = prefix ?? throw new ArgumentNullException(nameof(prefix)); - _message = message.OrNull(); + _prefix = prefix.OrNull() ?? throw new ArgumentNullException(nameof(prefix)); + _comparison = comparison; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -32,21 +43,21 @@ public StartsWithRule(string prefix, string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => value != null && value.StartsWith(_prefix, StringComparison.Ordinal); + public bool IsValid(in string value) => value != null && value.StartsWith(_prefix, _comparison); /// /// 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); + public bool IsValid(ReadOnlySpan value) => value.StartsWith(_prefix.AsSpan(), _comparison); /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _prefix, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _prefix, value); /// /// Gets the error message for a failed span validation. @@ -54,12 +65,12 @@ public StartsWithRule(string prefix, string? message = null) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, _prefix, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, _prefix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 9bd27b7..636df93 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -14,13 +14,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid time: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the TimeStringRule struct. /// /// Optional error message - public TimeStringRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public TimeStringRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is an ISO 8601 time. @@ -48,7 +54,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -56,7 +62,7 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); static bool IsIsoTime(ReadOnlySpan value, bool secondsRequired) { @@ -101,7 +107,7 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index d3ad207..f821352 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -13,13 +13,19 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid ULID: {0}"; - readonly string? _message; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the ULIDRule struct. /// /// Optional error message - public ULIDRule(string? message = null) => _message = message.OrNull(); + /// Optional error code override + public ULIDRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } /// /// Validates that the value is a ULID. @@ -56,7 +62,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// /// Gets the error message for a failed span validation. @@ -64,7 +70,7 @@ public bool IsValid(ReadOnlySpan value) /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - _message ?? RuleMessage.Format(MessageFormat, value.ToString()); + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); static bool IsCrockfordBase32(char c) => c switch @@ -86,7 +92,7 @@ static bool IsCrockfordBase32(char c) => /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index 7a005e2..ac13bdc 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -13,18 +13,21 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the and {1} the value. public const string MessageFormat = "Invalid Uri, kind: {0}, format: {1}"; - readonly string? _message; readonly UriKind _uriKind; + readonly string _message; + readonly string _code; /// - /// Initializes a new instance of the UrlRule struct. + /// Initializes a new instance of the UriRule struct. /// /// The uri kind to validate against /// Optional error message - public UriRule(UriKind uriKind, string? message = null) + /// Optional error code override + public UriRule(UriKind uriKind, string? message = null, string? code = null) { _uriKind = uriKind; - _message = message.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -40,12 +43,12 @@ public bool IsValid(in string value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, _uriKind, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _uriKind, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 5c2b40c..ce1674a 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -26,8 +26,8 @@ namespace ZodSharp.Rules; TimeSpan.FromMilliseconds(100) ); - readonly string? _message; - readonly string? _code; + readonly string _message; + readonly string _code; /// /// Initializes a new instance of the UrlRule struct. @@ -36,8 +36,8 @@ namespace ZodSharp.Rules; /// Optional error code override public UrlRule(string? message = null, string? code = null) { - _message = message.OrNull(); - _code = code.OrNull(); + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); } /// @@ -60,12 +60,12 @@ public bool IsValid(in string value) => /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? RuleMessage.Format(MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => _code ?? ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 5371465..d739c8a 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -16,18 +16,24 @@ namespace ZodSharp.Rules; /// Gets the version-specific message format; {0} is the version and {1} the value. public const string VersionedMessageFormat = "Invalid UUID v{0} format: {1}"; - readonly string? _message; + const string NilUuid = "00000000-0000-0000-0000-000000000000"; + const string MaxUuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + readonly UuidVersion? _version; + readonly string? _message; + readonly string _code; /// /// Initializes a new instance of the UuidRule struct with Zod-parity semantics /// (version 1-8, variant 8-9/a-b, plus the nil and max UUIDs). /// /// Optional error message - public UUIDRule(string? message = null) + /// Optional error code override + public UUIDRule(string? message = null, string? code = null) { _version = null; _message = message.OrNull(); + _code = code.Or(ErrorCode); } /// @@ -35,13 +41,15 @@ public UUIDRule(string? message = null) /// /// The required UUID version /// Optional error message - public UUIDRule(UuidVersion version, string? message = null) + /// Optional error code override + public UUIDRule(UuidVersion version, string? message = null, string? code = null) { if (version == UuidVersion.None) throw new ArgumentOutOfRangeException(nameof(version), version, "UUID version must be between V1 and V8."); _version = version; _message = message.OrNull(); + _code = code.Or(ErrorCode); } /// @@ -85,16 +93,16 @@ public bool IsValid(ReadOnlySpan value) /// 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) + { + if (_message is not null) + return RuleMessage.Format(_message, value, _version); - string GetErrorMessageCore(string value) => - _message - ?? ( - _version is UuidVersion version - ? RuleMessage.Format(VersionedMessageFormat, (int)version, value) - : RuleMessage.Format(MessageFormat, value) - ); + // If a specific version is required, include it in the message; otherwise, use the generic message. + return _version is UuidVersion version + ? RuleMessage.Format(VersionedMessageFormat, (int)version, value) + : RuleMessage.Format(MessageFormat, value); + } static bool IsValidVersionless(ReadOnlySpan value) { @@ -134,7 +142,7 @@ static bool HasValidStructure(ReadOnlySpan value) /// Gets the Zod-compatible error code reported when the rule fails. public string Code => ErrorCode; - string? Core.IZodRule.Code => ErrorCode; + string? Core.IZodRule.Code => _code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Schemas/ZodBigInt.cs b/src/src/ZodSharp/Schemas/ZodBigInt.cs index 624c91a..38adca4 100644 --- a/src/src/ZodSharp/Schemas/ZodBigInt.cs +++ b/src/src/ZodSharp/Schemas/ZodBigInt.cs @@ -21,10 +21,12 @@ public class ZodBigInt : ZodType /// Equivalent to Zod's z.bigint().min(value). /// /// The minimum value (inclusive) + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Min(long minValue) + public ZodBigInt Min(long minValue, string? message = null, string? code = null) { - AddRule(new MinValueRule(minValue)); + AddRule(new MinValueRule(minValue, message, code)); return this; } @@ -33,10 +35,12 @@ public ZodBigInt Min(long minValue) /// Equivalent to Zod's z.bigint().max(value). /// /// The maximum value (inclusive) + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Max(long maxValue) + public ZodBigInt Max(long maxValue, string? message = null, string? code = null) { - AddRule(new MaxValueRule(maxValue)); + AddRule(new MaxValueRule(maxValue, message, code)); return this; } @@ -45,10 +49,12 @@ public ZodBigInt Max(long maxValue) /// Equivalent to Zod's z.bigint().gt(value). /// /// The exclusive lower bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Gt(long value) + public ZodBigInt Gt(long value, string? message = null, string? code = null) { - AddRule(new GreaterThanRule(value)); + AddRule(new GreaterThanRule(value, message, code)); return this; } @@ -57,10 +63,12 @@ public ZodBigInt Gt(long value) /// Equivalent to Zod's z.bigint().gte(value). /// /// The inclusive lower bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Gte(long value) + public ZodBigInt Gte(long value, string? message = null, string? code = null) { - AddRule(new MinValueRule(value)); + AddRule(new MinValueRule(value, message, code)); return this; } @@ -69,10 +77,12 @@ public ZodBigInt Gte(long value) /// Equivalent to Zod's z.bigint().lt(value). /// /// The exclusive upper bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Lt(long value) + public ZodBigInt Lt(long value, string? message = null, string? code = null) { - AddRule(new LessThanRule(value)); + AddRule(new LessThanRule(value, message, code)); return this; } @@ -81,10 +91,12 @@ public ZodBigInt Lt(long value) /// Equivalent to Zod's z.bigint().lte(value). /// /// The inclusive upper bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Lte(long value) + public ZodBigInt Lte(long value, string? message = null, string? code = null) { - AddRule(new MaxValueRule(value)); + AddRule(new MaxValueRule(value, message, code)); return this; } @@ -92,10 +104,12 @@ public ZodBigInt Lte(long value) /// Adds a strictly positive validation (value must be greater than zero). /// Equivalent to Zod's z.bigint().positive(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Positive() + public ZodBigInt Positive(string? message = null, string? code = null) { - AddRule(new GreaterThanRule(0)); + AddRule(new GreaterThanRule(0, message, code)); return this; } @@ -103,10 +117,12 @@ public ZodBigInt Positive() /// Adds a strictly negative validation (value must be less than zero). /// Equivalent to Zod's z.bigint().negative(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt Negative() + public ZodBigInt Negative(string? message = null, string? code = null) { - AddRule(new LessThanRule(0)); + AddRule(new LessThanRule(0, message, code)); return this; } @@ -114,10 +130,12 @@ public ZodBigInt Negative() /// Adds a non-negative validation (value must be greater than or equal to zero). /// Equivalent to Zod's z.bigint().nonnegative(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt NonNegative() + public ZodBigInt NonNegative(string? message = null, string? code = null) { - AddRule(new MinValueRule(0)); + AddRule(new MinValueRule(0, message, code)); return this; } @@ -125,10 +143,12 @@ public ZodBigInt NonNegative() /// Adds a non-positive validation (value must be less than or equal to zero). /// Equivalent to Zod's z.bigint().nonpositive(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodBigInt NonPositive() + public ZodBigInt NonPositive(string? message = null, string? code = null) { - AddRule(new MaxValueRule(0)); + AddRule(new MaxValueRule(0, message, code)); return this; } } diff --git a/src/src/ZodSharp/Schemas/ZodDate.cs b/src/src/ZodSharp/Schemas/ZodDate.cs index 027245e..920e72d 100644 --- a/src/src/ZodSharp/Schemas/ZodDate.cs +++ b/src/src/ZodSharp/Schemas/ZodDate.cs @@ -22,10 +22,12 @@ protected override ValidationResult ParseInternal(DateTime value) => /// Equivalent to Zod's z.date().min(value). /// /// The earliest allowed date (inclusive) + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodDate Min(DateTime minValue) + public ZodDate Min(DateTime minValue, string? message = null, string? code = null) { - AddRule(new MinValueRule(minValue)); + AddRule(new MinValueRule(minValue, message, code)); return this; } @@ -34,10 +36,12 @@ public ZodDate Min(DateTime minValue) /// Equivalent to Zod's z.date().max(value). /// /// The latest allowed date (inclusive) + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodDate Max(DateTime maxValue) + public ZodDate Max(DateTime maxValue, string? message = null, string? code = null) { - AddRule(new MaxValueRule(maxValue)); + AddRule(new MaxValueRule(maxValue, message, code)); return this; } @@ -46,10 +50,11 @@ public ZodDate Max(DateTime maxValue) /// . /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public override ZodDate NonSentinel(string? message = null) + public override ZodDate NonSentinel(string? message = null, string? code = null) { - AddRule(new NonSentinelRule(message)); + AddRule(new NonSentinelRule(message, code)); return this; } } diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index 50e95bd..ff60836 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -27,10 +27,12 @@ protected override ValidationResult ParseInternal(double value) => /// Adds a minimum value validation. /// /// The minimum value + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Min(double minValue) + public ZodNumber Min(double minValue, string? message = null, string? code = null) { - AddRule(new MinValueRule(minValue)); + AddRule(new MinValueRule(minValue, message, code)); return this; } @@ -38,10 +40,12 @@ public ZodNumber Min(double minValue) /// Adds a maximum value validation. /// /// The maximum value + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Max(double maxValue) + public ZodNumber Max(double maxValue, string? message = null, string? code = null) { - AddRule(new MaxValueRule(maxValue)); + AddRule(new MaxValueRule(maxValue, message, code)); return this; } @@ -50,10 +54,12 @@ public ZodNumber Max(double maxValue) /// Equivalent to Zod's z.number().gt(value). /// /// The exclusive lower bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Gt(double value) + public ZodNumber Gt(double value, string? message = null, string? code = null) { - AddRule(new GreaterThanRule(value)); + AddRule(new GreaterThanRule(value, message, code)); return this; } @@ -62,10 +68,12 @@ public ZodNumber Gt(double value) /// Equivalent to Zod's z.number().gte(value). /// /// The inclusive lower bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Gte(double value) + public ZodNumber Gte(double value, string? message = null, string? code = null) { - AddRule(new GreaterThanOrEqualRule(value)); + AddRule(new GreaterThanOrEqualRule(value, message, code)); return this; } @@ -74,10 +82,12 @@ public ZodNumber Gte(double value) /// Equivalent to Zod's z.number().lt(value). /// /// The exclusive upper bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Lt(double value) + public ZodNumber Lt(double value, string? message = null, string? code = null) { - AddRule(new LessThanRule(value)); + AddRule(new LessThanRule(value, message, code)); return this; } @@ -86,21 +96,25 @@ public ZodNumber Lt(double value) /// Equivalent to Zod's z.number().lte(value). /// /// The inclusive upper bound + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Lte(double value) + public ZodNumber Lte(double value, string? message = null, string? code = null) { - AddRule(new LessThanOrEqualRule(value)); + AddRule(new LessThanOrEqualRule(value, message, code)); return this; } /// /// Adds an integer validation (must be a whole number). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1720:Identifier contains type name")] - public ZodNumber Int() + public ZodNumber Int(string? message = null, string? code = null) { - AddRule(new IntRule()); + AddRule(new IntRule(message, code)); return this; } @@ -108,10 +122,12 @@ public ZodNumber Int() /// Adds a strictly positive number validation (value must be greater than zero). /// Equivalent to Zod's z.number().positive(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Positive() + public ZodNumber Positive(string? message = null, string? code = null) { - AddRule(new GreaterThanRule(0.0)); + AddRule(new GreaterThanRule(0.0, message, code)); return this; } @@ -119,30 +135,36 @@ public ZodNumber Positive() /// Adds a strictly negative number validation (value must be less than zero). /// Equivalent to Zod's z.number().negative(). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Negative() + public ZodNumber Negative(string? message = null, string? code = null) { - AddRule(new LessThanRule(0.0)); + AddRule(new LessThanRule(0.0, message, code)); return this; } /// /// Adds a non-negative number validation (value must be greater than or equal to zero). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber NonNegative() + public ZodNumber NonNegative(string? message = null, string? code = null) { - AddRule(new MinValueRule(0.0)); + AddRule(new MinValueRule(0.0, message, code)); return this; } /// /// Adds a non-positive number validation (value must be less than or equal to zero). /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber NonPositive() + public ZodNumber NonPositive(string? message = null, string? code = null) { - AddRule(new MaxValueRule(0.0)); + AddRule(new MaxValueRule(0.0, message, code)); return this; } @@ -151,10 +173,11 @@ public ZodNumber NonPositive() /// /// The divisor /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber MultipleOf(double divisor, string? message = null) + public ZodNumber MultipleOf(double divisor, string? message = null, string? code = null) { - AddRule(new MultipleOfRule(divisor, message)); + AddRule(new MultipleOfRule(divisor, message, code)); return this; } @@ -162,10 +185,11 @@ public ZodNumber MultipleOf(double divisor, string? message = null) /// Adds a finite number validation. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Finite(string? message = null) + public ZodNumber Finite(string? message = null, string? code = null) { - AddRule(new FiniteRule(message)); + AddRule(new FiniteRule(message, code)); return this; } @@ -173,10 +197,11 @@ public ZodNumber Finite(string? message = null) /// Adds a safe integer validation (within int.MinValue and int.MaxValue). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Safe(string? message = null) + public ZodNumber Safe(string? message = null, string? code = null) { - AddRule(new SafeIntegerRule(message)); + AddRule(new SafeIntegerRule(message, code)); return this; } @@ -184,10 +209,11 @@ public ZodNumber Safe(string? message = null) /// Adds an even-number validation (the value must be a multiple of two). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Even(string? message = null) + public ZodNumber Even(string? message = null, string? code = null) { - AddRule(new EvenRule(message)); + AddRule(new EvenRule(message, code)); return this; } @@ -195,10 +221,11 @@ public ZodNumber Even(string? message = null) /// Adds an odd-number validation (the value must not be a multiple of two). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodNumber Odd(string? message = null) + public ZodNumber Odd(string? message = null, string? code = null) { - AddRule(new OddRule(message)); + AddRule(new OddRule(message, code)); return this; } } diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 861f33d..3f40b9f 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -105,7 +105,7 @@ public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray(); - builder.Add(new ValidationError(rule.Code, rule.GetErrorMessage(value), EmptyPath)); + builder.Add(new ValidationError(ResolveCode(rule), rule.GetErrorMessage(value), EmptyPath)); } if (builder is null) @@ -115,16 +115,26 @@ public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray + /// Resolves the error code to report for a failed span rule: a rule that implements + /// and supplies a code wins over the rule's intrinsic + /// . + /// + static string ResolveCode(IStringValidationRule rule) => + rule is IZodRule zodRule && zodRule.Code is { } code ? code : rule.Code; + bool SupportsSpanRules => _spanRulesSupported && SupportsSpanValidation && _spanRules.Length == RuleCount; /// /// Adds a minimum length validation. /// /// The minimum length + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Min(int minLength) + public ZodString Min(int minLength, string? message = null, string? code = null) { - AddRule(new MinLengthRule(minLength)); + AddRule(new MinLengthRule(minLength, message, code)); return this; } @@ -132,20 +142,24 @@ public ZodString Min(int minLength) /// Adds a maximum length validation. /// /// The maximum length + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Max(int maxLength) + public ZodString Max(int maxLength, string? message = null, string? code = null) { - AddRule(new MaxLengthRule(maxLength)); + AddRule(new MaxLengthRule(maxLength, message, code)); return this; } /// /// Adds an email format validation. /// + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Email() + public ZodString Email(string? message = null, string? code = null) { - AddRule(new EmailRule()); + AddRule(new EmailRule(message, code)); return this; } @@ -154,10 +168,11 @@ public ZodString Email() /// /// The regex pattern /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Regex(Regex pattern, string? message = null) + public ZodString Regex(Regex pattern, string? message = null, string? code = null) { - AddRule(new RegexRule(pattern, message)); + AddRule(new RegexRule(pattern, message, code)); return this; } @@ -166,22 +181,25 @@ public ZodString Regex(Regex pattern, string? message = null) /// /// The regex pattern string /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Regex(string pattern, string? message = null) + public ZodString Regex(string pattern, string? message = null, string? code = null) { Regex regex = new(pattern, RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); - return Regex(regex, message); + return Regex(regex, message, code); } /// /// Sets the exact string length. /// /// The exact length + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Length(int length) + public ZodString Length(int length, string? message = null, string? code = null) { - AddRule(new MinLengthRule(length)); - AddRule(new MaxLengthRule(length)); + AddRule(new MinLengthRule(length, message, code)); + AddRule(new MaxLengthRule(length, message, code)); return this; } @@ -189,15 +207,16 @@ public ZodString Length(int length) /// Adds a URL format validation. /// /// Optional error message + /// Optional error code override /// This schema for method chaining [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", "PDS0004:Use correct acronym capitalization", Justification = "Name is real" )] - public ZodString Url(string? message = null) + public ZodString Url(string? message = null, string? code = null) { - AddRule(new UrlRule(message)); + AddRule(new UrlRule(message, code)); return this; } @@ -205,18 +224,20 @@ public ZodString Url(string? message = null) /// Adds a URI format validation that accepts a relative or absolute URI. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Uri(string? message = null) => Uri(UriKind.RelativeOrAbsolute, message); + public ZodString Uri(string? message = null, string? code = null) => Uri(UriKind.RelativeOrAbsolute, message, code); /// /// Adds a URI format validation requiring the specified . /// /// The kind of URI the value must be /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Uri(UriKind uriKind, string? message = null) + public ZodString Uri(UriKind uriKind, string? message = null, string? code = null) { - AddRule(new UriRule(uriKind, message)); + AddRule(new UriRule(uriKind, message, code)); return this; } @@ -224,10 +245,11 @@ public ZodString Uri(UriKind uriKind, string? message = null) /// Adds a phone number format validation. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Phone(string? message = null) + public ZodString Phone(string? message = null, string? code = null) { - AddRule(new PhoneRule(message)); + AddRule(new PhoneRule(message, code)); return this; } @@ -235,10 +257,11 @@ public ZodString Phone(string? message = null) /// Adds a credit card number format validation. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString CreditCard(string? message = null) + public ZodString CreditCard(string? message = null, string? code = null) { - AddRule(new CreditCardRule(message)); + AddRule(new CreditCardRule(message, code)); return this; } @@ -246,10 +269,11 @@ public ZodString CreditCard(string? message = null) /// Adds a Base64 string format validation. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Base64String(string? message = null) + public ZodString Base64String(string? message = null, string? code = null) { - AddRule(new Base64StringRule(message)); + AddRule(new Base64StringRule(message, code)); return this; } @@ -258,10 +282,11 @@ public ZodString Base64String(string? message = null) /// variant nibble, plus the nil and max UUIDs. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString UUID(string? message = null) + public ZodString UUID(string? message = null, string? code = null) { - AddRule(new UUIDRule(message)); + AddRule(new UUIDRule(message, code)); return this; } @@ -270,10 +295,11 @@ public ZodString UUID(string? message = null) /// /// The required UUID version /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString UUID(UuidVersion version, string? message = null) + public ZodString UUID(UuidVersion version, string? message = null, string? code = null) { - AddRule(new UUIDRule(version, message)); + AddRule(new UUIDRule(version, message, code)); return this; } @@ -281,11 +307,18 @@ public ZodString UUID(UuidVersion version, string? message = null) /// Adds a validation that the string must start with the specified prefix. /// /// The required prefix + /// The string comparison type /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString StartsWith(string prefix, string? message = null) - { - AddRule(new StartsWithRule(prefix, message)); + public ZodString StartsWith( + string prefix, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) + { + AddRule(new StartsWithRule(prefix, comparison, message, code)); return this; } @@ -293,11 +326,18 @@ public ZodString StartsWith(string prefix, string? message = null) /// Adds a validation that the string must end with the specified suffix. /// /// The required suffix + /// The string comparison type /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString EndsWith(string suffix, string? message = null) - { - AddRule(new EndsWithRule(suffix, message)); + public ZodString EndsWith( + string suffix, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) + { + AddRule(new EndsWithRule(suffix, comparison, message, code)); return this; } @@ -307,10 +347,11 @@ public ZodString EndsWith(string suffix, string? message = null) /// /// The required substring /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Includes(string substring, string? message = null) + public ZodString Includes(string substring, string? message = null, string? code = null) { - AddRule(new IncludesRule(substring, message)); + AddRule(new IncludesRule(substring, message, code)); return this; } @@ -319,10 +360,25 @@ public ZodString Includes(string substring, string? message = null) /// Equivalent to Zod's z.string().ip(). /// /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString IP(string? message = null, string? code = null) + { + AddRule(new IPAddressRule(message, code)); + return this; + } + + /// + /// Adds an IPv4 or IPv6 address format validation requiring the specified + /// . + /// + /// The IP address family the value must be + /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString IP(string? message = null) + public ZodString IP(IPAddressRuleType ruleType, string? message = null, string? code = null) { - AddRule(new IPAddressRule(message)); + AddRule(new IPAddressRule(ruleType, message, code)); return this; } @@ -331,10 +387,11 @@ public ZodString IP(string? message = null) /// Equivalent to Zod's z.string().jwt(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString JWT(string? message = null) + public ZodString JWT(string? message = null, string? code = null) { - AddRule(new JWTRule(message)); + AddRule(new JWTRule(message, code)); return this; } @@ -343,10 +400,11 @@ public ZodString JWT(string? message = null) /// Equivalent to Zod's z.string().hex(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Hex(string? message = null) + public ZodString Hex(string? message = null, string? code = null) { - AddRule(new HexRule(message)); + AddRule(new HexRule(message, code)); return this; } @@ -355,15 +413,16 @@ public ZodString Hex(string? message = null) /// Equivalent to Zod's z.string().base64url(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", "PDS0004:Use correct acronym capitalization", Justification = "Name matches Zod's base64url() method." )] - public ZodString Base64Url(string? message = null) + public ZodString Base64Url(string? message = null, string? code = null) { - AddRule(new Base64UrlRule(message)); + AddRule(new Base64UrlRule(message, code)); return this; } @@ -372,10 +431,11 @@ public ZodString Base64Url(string? message = null) /// Equivalent to Zod's z.string().ulid(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString ULID(string? message = null) + public ZodString ULID(string? message = null, string? code = null) { - AddRule(new ULIDRule(message)); + AddRule(new ULIDRule(message, code)); return this; } @@ -384,10 +444,11 @@ public ZodString ULID(string? message = null) /// Equivalent to Zod's z.string().datetime(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Datetime(string? message = null) + public ZodString Datetime(string? message = null, string? code = null) { - AddRule(new DatetimeStringRule(message)); + AddRule(new DatetimeStringRule(message, code)); return this; } @@ -396,10 +457,11 @@ public ZodString Datetime(string? message = null) /// Equivalent to Zod's z.string().date(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Date(string? message = null) + public ZodString Date(string? message = null, string? code = null) { - AddRule(new DateStringRule(message)); + AddRule(new DateStringRule(message, code)); return this; } @@ -408,10 +470,11 @@ public ZodString Date(string? message = null) /// Equivalent to Zod's z.string().time(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Time(string? message = null) + public ZodString Time(string? message = null, string? code = null) { - AddRule(new TimeStringRule(message)); + AddRule(new TimeStringRule(message, code)); return this; } @@ -420,10 +483,11 @@ public ZodString Time(string? message = null) /// Equivalent to Zod's z.string().nanoid(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Nanoid(string? message = null) + public ZodString Nanoid(string? message = null, string? code = null) { - AddRule(new NanoidRule(message)); + AddRule(new NanoidRule(message, code)); return this; } @@ -432,10 +496,11 @@ public ZodString Nanoid(string? message = null) /// Equivalent to Zod's z.string().cuid2(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString Cuid2(string? message = null) + public ZodString Cuid2(string? message = null, string? code = null) { - AddRule(new Cuid2Rule(message)); + AddRule(new Cuid2Rule(message, code)); return this; } @@ -444,10 +509,11 @@ public ZodString Cuid2(string? message = null) /// Equivalent to Zod's z.string().e164(). /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public ZodString E164(string? message = null) + public ZodString E164(string? message = null, string? code = null) { - AddRule(new E164Rule(message)); + AddRule(new E164Rule(message, code)); return this; } @@ -455,10 +521,11 @@ public ZodString E164(string? message = null) /// Adds a non-sentinel validation that rejects null, empty, and whitespace strings. /// /// Optional error message + /// Optional error code override /// This schema for method chaining - public override ZodString NonSentinel(string? message = null) + public override ZodString NonSentinel(string? message = null, string? code = null) { - AddRule(new NonSentinelRule(message)); + AddRule(new NonSentinelRule(message, code)); return this; } @@ -493,7 +560,7 @@ public ZodString Trim() return new ZodStringWrapper(transform); } - class ZodStringWrapper(ZodTransform transform) : ZodString + sealed class ZodStringWrapper(ZodTransform transform) : ZodString { protected override bool SupportsSpanValidation => false; diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 670ee77..337f4db 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -132,7 +132,7 @@ public class User } ``` -Marking the rule itself with the parameterless `[ZodRule]` makes the generator emit a matching `NoWhitespaceAttribute` whose properties mirror the rule's constructor parameters. +Marking the rule itself with the parameterless `[ZodRule]` makes the generator emit a matching `NoWhitespaceAttribute`: its properties mirror the rule's constructor parameters, and its value parameters also become a constructor — a parameter the rule declares without a default is required at the call site, while one with a default keeps that default. 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: diff --git a/src/src/ZodSharp/UuidVersion.cs b/src/src/ZodSharp/UuidVersion.cs index 84fc62b..ba1c189 100644 --- a/src/src/ZodSharp/UuidVersion.cs +++ b/src/src/ZodSharp/UuidVersion.cs @@ -1,7 +1,7 @@ namespace ZodSharp; /// -/// RFC 9562 UUID versions supported by . +/// RFC 9562 UUID versions supported by . /// public enum UuidVersion { diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index 7a32200..69d4177 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -73,10 +73,117 @@ public readonly record struct ThresholdRule(T threshold) : IValidationRule // Assert await Assert.That(generated).ContainsGeneratedCode("public double Threshold { get; set; } = default!;"); await Assert.That(generated).ContainsGeneratedCode("typeof(global::Testing.Rules.ThresholdRule<>)"); + // The required bound becomes a constructor parameter, so the attribute cannot be applied without it. + await Assert.That(generated).ContainsGeneratedCode("public ThresholdAttribute(double threshold)"); + await Assert.That(generated).ContainsGeneratedCode("Threshold = threshold;"); await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN043"); await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN032"); } + [Test] + public async Task RuleAttributeGeneration_GivenRequiredValue_GeneratesAConstructorParameter( + CancellationToken cancellationToken + ) + { + // Arrange - a rule whose value parameter has no default: the attribute must demand it. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct UpperBoundRule(T maxValue, string? message = null) + : IValidationRule + where T : IComparable + { + public bool IsValid(in T value) => value.CompareTo(maxValue) <= 0; + + public string GetErrorMessage(in T value) => message ?? "Too big."; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("UpperBoundAttribute"); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var attributeType = assembly.GetType("Testing.Rules.UpperBoundAttribute")!; + + // Assert - the constructor takes the required value and the message stays a named property. + await Assert.That(generated).ContainsGeneratedCode("public UpperBoundAttribute(double maxValue)"); + await Assert.That(generated).ContainsGeneratedCode("MaxValue = maxValue;"); + await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); + await Assert.That(attributeType.GetConstructor([typeof(double)])).IsNotNull(); + await Assert.That(attributeType.GetConstructor([])).IsNull(); + driverResult.AssertNoCompilationErrors(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenMissingRequiredValue_FailsToCompile( + CancellationToken cancellationToken + ) + { + // Arrange - the same attribute applied without its required value. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Sample + { + [LessThanOrEqual] + public int Value { get; set; } + } + } + """; + + // Act - the harness normally rejects a run whose compilation has errors, so validation is disabled here + // to assert the error explicitly. + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + + // Assert - a missing required value is a compile error at the attribute's usage site (CS7036), not a + // generator diagnostic that only surfaces when a schema is generated. + var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); + await Assert.That(diagnostics).Contains(d => d.Id == "CS7036"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenOptionalValueOnly_KeepsTheParameterlessConstructor( + CancellationToken cancellationToken + ) + { + // Arrange - a rule whose only constructor parameter is the message: nothing is required. + const string source = """ + using ZodSharp.Core; + + namespace Testing.Rules + { + [ZodRule] + public readonly record struct WholeRule(string? message = null) : IValidationRule + where T : System.Numerics.INumber + { + public bool IsValid(in T value) => value % T.One == T.Zero; + + public string GetErrorMessage(in T value) => message ?? "Expected a whole number."; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WholeAttribute"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); + await Assert.That(generated).DoesNotContain("public WholeAttribute("); + } + [Test] public async Task RuleAttributeGeneration_GivenBuiltInRuleWithUnrepresentableParameter_ReportsZodsgen043( CancellationToken cancellationToken @@ -157,7 +264,7 @@ public partial class Contact [E164] public string Phone { get; set; } = string.Empty; - [Regex(Pattern = "^[a-z]+$")] + [Regex("^[a-z]+$")] public string Code { get; set; } = string.Empty; [NonSentinel(Message = "Value must not be the default.")] @@ -176,14 +283,17 @@ CancellationToken cancellationToken var generated = driverResult.GetSource("ContactSchema"); // Assert - each shipped attribute resolves to its built-in rule. The regex attribute mirrors the - // string overload (not the Regex one) and the open generic is closed with the member type. - await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EmailRule()"); - await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.E164Rule(null!)"); - await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.RegexRule(\"^[a-z]+$\", null!)"); + // string overload (not the Regex one) and the open generic is closed with the member type. Every + // rule now takes (message, code), so an unset message/code is emitted as null!. + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EmailRule(null!, null!)"); + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.E164Rule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.RegexRule(\"^[a-z]+$\", null!, null!)"); await Assert .That(generated) .ContainsGeneratedCode( - "new global::ZodSharp.Rules.NonSentinelRule(\"Value must not be the default.\")" + "new global::ZodSharp.Rules.NonSentinelRule(\"Value must not be the default.\", null!)" ); // The built-in rules own their identity, so the generated validation reads it from the rule. await Assert.That(generated).Contains("((global::ZodSharp.Core.IZodRule)"); @@ -338,16 +448,19 @@ namespace Testing [ZodSchema] public partial class Metrics { - [MinValue(MinValue = 3)] + [MinValue(3)] public int Count { get; set; } [Even] public int EvenCount { get; set; } - [GreaterThanOrEqual(MinValue = 1.5)] + [GreaterThanOrEqual(1.5)] public double Ratio { get; set; } - [MinLengthZod(MinLength = 3, Code = "too_short", Message = "Too short.")] + [LessThanOrEqual(10)] + public int Bounded { get; set; } + + [MinLengthZod(3, Code = "too_short", Message = "Too short.")] public string Code { get; set; } = string.Empty; } } @@ -362,13 +475,20 @@ CancellationToken cancellationToken var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); var generated = driverResult.GetSource("MetricsSchema"); - // Assert - the generic bound rule is closed with the member type (its double bound converted to int), - // the generic even rule is closed with int, and the suffixed length attribute carries code and message. - await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.MinValueRule(3)"); - await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EvenRule(null!)"); + // Assert - the generic bound rules are closed with the member type (their double bound converted to + // int), the generic even rule is closed with int, and the suffixed length attribute carries code and + // message. The inclusive bounds close with the member type too, so an int member works. Every rule now + // takes (message, code), so an unset message/code is emitted as null!. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.MinValueRule(3, null!, null!)"); + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.EvenRule(null!, null!)"); await Assert .That(generated) - .ContainsGeneratedCode("new global::ZodSharp.Rules.GreaterThanOrEqualRule(1.5D, null!)"); + .ContainsGeneratedCode("new global::ZodSharp.Rules.GreaterThanOrEqualRule(1.5D, null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.LessThanOrEqualRule(10, null!, null!)"); await Assert .That(generated) .ContainsGeneratedCode("new global::ZodSharp.Rules.MinLengthRule(3, \"Too short.\", \"too_short\")"); @@ -389,9 +509,10 @@ CancellationToken cancellationToken var modelType = assembly.GetType("Testing.Metrics")!; var validate = assembly.GetType("Testing.MetricsSchema")!.GetMethod("Validate")!; - // Act - the default metrics are all invalid; the even member is set odd. + // Act - the default metrics are all invalid; the even member is set odd and the upper bound is exceeded. var instance = Activator.CreateInstance(modelType)!; modelType.GetProperty("EvenCount")!.SetValue(instance, 3); + modelType.GetProperty("Bounded")!.SetValue(instance, 11); var result = validate.Invoke(null, [instance])!; // Assert @@ -400,7 +521,69 @@ CancellationToken cancellationToken result.GetType().GetProperty("Errors")!.GetValue(result)!; await Assert.That(errors.Any(error => error.Code == Rules.MinValueRule.ErrorCode)).IsTrue(); await Assert.That(errors.Any(error => error.Code == Rules.EvenRule.ErrorCode)).IsTrue(); - await Assert.That(errors.Any(error => error.Code == Rules.GreaterThanOrEqualRule.ErrorCode)).IsTrue(); + await Assert.That(errors.Any(error => error.Code == Rules.GreaterThanOrEqualRule.ErrorCode)).IsTrue(); + await Assert.That(errors.Any(error => error.Code == Rules.LessThanOrEqualRule.ErrorCode)).IsTrue(); await Assert.That(errors.Any(error => error.Code == "too_short")).IsTrue(); } + + /// + /// A [ZodSchema] class that uses the shipped attributes' optional value parameters (string + /// comparison, IP family) alongside message/code overrides. + /// + const string OptionalValueAttributeSource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Preferences + { + [StartsWith("https://", StringComparison.OrdinalIgnoreCase, Code = "bad_scheme")] + public string Endpoint { get; set; } = string.Empty; + + [IPAddress(IPAddressRuleType.IPv4)] + public string Address { get; set; } = string.Empty; + + [IPAddress] + public string AnyAddress { get; set; } = string.Empty; + + [Email(Message = "Not an email.", Code = "bad_email")] + public string Contact { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenOptionalValueAttributes_ResolvesTheRules( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(OptionalValueAttributeSource, cancellationToken); + var generated = driverResult.GetSource("PreferencesSchema"); + + // Assert - the string comparison and IP family flow into the rule (rendered as their enum value) and + // the message/code overrides are carried through. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.StartsWithRule(\"https://\", (global::System.StringComparison)5, null!, \"bad_scheme\")" + ); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.IPAddressRule((global::ZodSharp.Rules.IPAddressRuleType)0, null!, null!)" + ); + // A bare [IPAddress] keeps the rule's Any default. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.IPAddressRule((global::ZodSharp.Rules.IPAddressRuleType)2, null!, null!)" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.EmailRule(\"Not an email.\", \"bad_email\")"); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs index d9204cc..0b451b4 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs @@ -11,7 +11,7 @@ namespace ZodSharp.Rules; /// public class GeneratedRuleAttributesTests { - static Assembly RulesAssembly => typeof(IntRule).Assembly; + static Assembly RulesAssembly => typeof(IntRule<>).Assembly; /// The attribute name the rule derives: a trailing Rule becomes Attribute. static string AttributeName(string ruleName) => @@ -130,6 +130,8 @@ public async Task Rule_GivenDataAnnotationsNameCollision_ExposesCodeAndMessage(T [Arguments(typeof(MaxValueRule<>), "MaxValue")] [Arguments(typeof(GreaterThanRule<>), "ExclusiveMinimum")] [Arguments(typeof(LessThanRule<>), "ExclusiveMaximum")] + [Arguments(typeof(GreaterThanOrEqualRule<>), "MinValue")] + [Arguments(typeof(LessThanOrEqualRule<>), "MaxValue")] public async Task Attribute_GivenGenericBoundRule_ExposesDoubleBound(Type ruleType, string propertyName) { // Act - the type-parameter bound cannot be mirrored directly, so it is surfaced as a double. @@ -144,8 +146,8 @@ public async Task Attribute_GivenGenericBoundRule_ExposesDoubleBound(Type ruleTy [Arguments(typeof(RegexRule))] [Arguments(typeof(E164Rule))] [Arguments(typeof(NonSentinelRule<>))] - [Arguments(typeof(GreaterThanOrEqualRule))] - [Arguments(typeof(LessThanOrEqualRule))] + [Arguments(typeof(GreaterThanOrEqualRule<>))] + [Arguments(typeof(LessThanOrEqualRule<>))] [Arguments(typeof(EvenRule<>))] [Arguments(typeof(OddRule<>))] public async Task Attribute_GivenRuleWithMessageParameter_ExposesMessageAlias(Type ruleType) @@ -159,13 +161,31 @@ public async Task Attribute_GivenRuleWithMessageParameter_ExposesMessageAlias(Ty } [Test] - public async Task Attribute_GivenRuleWithoutMessageParameter_HasNoMessageAlias() + public async Task Attribute_GivenRuleWithoutMessageParameter_HasDefaultMessage() { + // Arrange - the generated Message property mirrors the rule's own default message format. + var attributeType = FindAttribute(typeof(EmailRule))!; + var instance = Activator.CreateInstance(attributeType)!; + + // Act + var message = (string?)attributeType.GetProperty("Message")!.GetValue(instance); + + // Assert + await Assert.That(message).IsEqualTo(EmailRule.MessageFormat); + } + + [Test] + public async Task Attribute_GivenRuleWithoutCodeParameter_HasDefaultCode() + { + // Arrange - the generated Code property mirrors the rule's own default error code. + var attributeType = FindAttribute(typeof(EmailRule))!; + var instance = Activator.CreateInstance(attributeType)!; + // Act - var message = FindAttribute(typeof(EmailRule))!.GetProperty("Message"); + var code = (string?)attributeType.GetProperty("Code")!.GetValue(instance); // Assert - await Assert.That(message).IsNull(); + await Assert.That(code).IsEqualTo(EmailRule.ErrorCode); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs index c92673f..26f089e 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs @@ -16,27 +16,43 @@ public class NumericRulesTests public async Task GreaterThanOrEqualRule_GivenValue_ComparesInclusively(double value, bool expected) { // Arrange - GreaterThanOrEqualRule rule = new(3.0); + GreaterThanOrEqualRule rule = new(3.0); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); + } + + [Test] + [Arguments(3, true)] + [Arguments(4, true)] + [Arguments(2, false)] + public async Task GreaterThanOrEqualRule_GivenInteger_ComparesInclusively(int value, bool expected) + { + // Arrange - the rule is generic over any IComparable, so it closes with int as well as double. + GreaterThanOrEqualRule rule = new(3); // Act var valid = rule.IsValid(value); // Assert await Assert.That(valid).IsEqualTo(expected); - await Assert.That(rule.Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); } [Test] public async Task GreaterThanOrEqualRule_GivenFailure_FormatsTheBoundAndValue() { // Arrange - GreaterThanOrEqualRule rule = new(3.0); + GreaterThanOrEqualRule rule = new(3.0); // Act var message = rule.GetErrorMessage(1.0); var expected = string.Format( CultureInfo.CurrentCulture, - CompositeFormat.Parse(GreaterThanOrEqualRule.MessageFormat), + CompositeFormat.Parse(GreaterThanOrEqualRule.MessageFormat), 3.0, 1.0 ); @@ -52,14 +68,30 @@ public async Task GreaterThanOrEqualRule_GivenFailure_FormatsTheBoundAndValue() public async Task LessThanOrEqualRule_GivenValue_ComparesInclusively(double value, bool expected) { // Arrange - LessThanOrEqualRule rule = new(3.0); + LessThanOrEqualRule rule = new(3.0); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + await Assert.That(rule.Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); + } + + [Test] + [Arguments(3, true)] + [Arguments(2, true)] + [Arguments(4, false)] + public async Task LessThanOrEqualRule_GivenInteger_ComparesInclusively(int value, bool expected) + { + // Arrange - the inclusive bound closes with the member's own numeric type. + LessThanOrEqualRule rule = new(3); // Act var valid = rule.IsValid(value); // Assert await Assert.That(valid).IsEqualTo(expected); - await Assert.That(rule.Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); } [Test] @@ -132,6 +164,111 @@ public async Task OddRule_GivenInteger_ChecksParity(int value, bool expected) await Assert.That(valid).IsEqualTo(expected); } + [Test] + [Arguments(5)] + [Arguments(0)] + [Arguments(-12)] + public async Task IntRule_GivenInteger_AlwaysHolds(int value) + { + // Arrange - the rule is generic over any INumber, so an int member closes it directly. + IntRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsTrue(); + } + + [Test] + [Arguments(25.0, true)] + [Arguments(25.5, false)] + [Arguments(double.PositiveInfinity, false)] + public async Task IntRule_GivenDouble_ChecksWholeness(double value, bool expected) + { + // Arrange + IntRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + [Arguments(5)] + [Arguments(-1)] + public async Task FiniteRule_GivenInteger_AlwaysHolds(int value) + { + // Arrange + FiniteRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsTrue(); + } + + [Test] + [Arguments(1.0, true)] + [Arguments(double.PositiveInfinity, false)] + [Arguments(double.NegativeInfinity, false)] + public async Task FiniteRule_GivenDouble_ChecksFiniteness(double value, bool expected) + { + // Arrange + FiniteRule rule = new(); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + [Arguments(30, true)] + [Arguments(25, false)] + [Arguments(0, true)] + public async Task MultipleOfRule_GivenInteger_ChecksExactMultiples(int value, bool expected) + { + // Arrange - integer types cannot represent the relative tolerance, so the comparison is exact. + MultipleOfRule rule = new(10); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + [Arguments(0.3, true)] + [Arguments(0.3000000001, false)] + [Arguments(-0.3, true)] + public async Task MultipleOfRule_GivenDouble_ToleratesFloatingPointRounding(double value, bool expected) + { + // Arrange + MultipleOfRule rule = new(0.1); + + // Act + var valid = rule.IsValid(value); + + // Assert + await Assert.That(valid).IsEqualTo(expected); + } + + [Test] + public async Task MultipleOfRule_GivenZeroDivisor_Throws() + { + // Act + var exception = Assert.Throws(static () => _ = new MultipleOfRule(0)); + + // Assert + await Assert.That(exception).IsNotNull(); + } + [Test] public async Task EvenRule_GivenMessage_OverridesTheFormattedMessage() { @@ -179,7 +316,7 @@ public async Task ZodNumber_GivenGteAndLte_ReportsTheRuleCodes() var lte = Z.Number().Lte(3.0).Validate(4.0); // Assert - await Assert.That(gte.Errors[0].Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); - await Assert.That(lte.Errors[0].Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); + await Assert.That(gte.Errors[0].Code).IsEqualTo(GreaterThanOrEqualRule.ErrorCode); + await Assert.That(lte.Errors[0].Code).IsEqualTo(LessThanOrEqualRule.ErrorCode); } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs index 9e61730..49c69e4 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleConstantsTests.cs @@ -13,7 +13,7 @@ public class RuleConstantsTests /// . /// public static IEnumerable RuleTypes() => - typeof(IntRule) + typeof(IntRule<>) .Assembly.GetTypes() .Where(type => type is { IsPublic: true, IsValueType: true } && type.Namespace == "ZodSharp.Rules") .Where(type => diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs index adef4bc..6478e71 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodNumberTests.cs @@ -14,6 +14,16 @@ public async Task NumberMin_GivenValue_ReturnsExpectedResult(double value, bool await Assert.That(result.IsSuccess).IsEqualTo(expected); } + [Test] + public async Task NumberMin_GivenCustomMessageAndCode_ReportsThem() + { + var result = Z.Number().Min(5, "Too small.", "custom_too_small").Validate(3); + + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo("custom_too_small"); + await Assert.That(result.Errors[0].Message).IsEqualTo("Too small."); + } + [Test] [Arguments(25.0, true)] [Arguments(121.0, false)] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs index c1720d6..f5673e1 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs @@ -143,6 +143,36 @@ public async Task StringEndsWith_GivenNonMatchingSuffix_ReturnsFailure() await Assert.That(result.IsSuccess).IsFalse(); } + [Test] + public async Task StringStartsWith_GivenOrdinalIgnoreCaseComparison_IgnoresCase() + { + var result = Z.String() + .StartsWith("HTTPS://", StringComparison.OrdinalIgnoreCase) + .Validate("https://example.com"); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task StringEndsWith_GivenOrdinalIgnoreCaseComparison_IgnoresCase() + { + var result = Z.String().EndsWith(".COM", StringComparison.OrdinalIgnoreCase).Validate("example.com"); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task StringStartsWith_GivenCustomMessageAndCode_ReportsThem() + { + var result = Z.String() + .StartsWith("https://", message: "Must start with https.", code: "bad_prefix") + .Validate("http://example.com"); + + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo("bad_prefix"); + await Assert.That(result.Errors[0].Message).IsEqualTo("Must start with https."); + } + [Test] [Arguments(" hello ", "hello")] [Arguments("\t hello \t", "hello")] @@ -232,6 +262,23 @@ public async Task StringIP_GivenValue_ReturnsExpectedResult(string value, bool e await Assert.That(result.IsSuccess).IsEqualTo(expected); } + [Test] + [Arguments(IPAddressRuleType.IPv4, "192.168.1.1", true)] + [Arguments(IPAddressRuleType.IPv4, "::1", false)] + [Arguments(IPAddressRuleType.IPv6, "::1", true)] + [Arguments(IPAddressRuleType.IPv6, "192.168.1.1", false)] + [Arguments(IPAddressRuleType.Any, "2001:db8::8a2e:370:7334", true)] + public async Task StringIP_GivenRuleType_ReturnsExpectedResult( + IPAddressRuleType ruleType, + string value, + bool expected + ) + { + var result = Z.String().IP(ruleType).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + [Test] [Arguments("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", true)] [Arguments("a.b.c", true)] From e7dcc04396b7ff33caa7d4d466787a5b5a21dadb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 00:11:55 +0100 Subject: [PATCH 14/26] fix: uuid attribute rule gen required both ctors --- docs/wiki/Custom-Rules.md | 4 +- docs/wiki/Source-Generator-DataAnnotations.md | 2 +- docs/wiki/Validation-Rules-Reference.md | 2 +- .../Helpers/CustomRuleResolver.cs | 164 ++++++++-- .../Models/RuleAttributeGenerationModel.cs | 21 +- .../ZodSchemaGenerator.RuleAttributes.cs | 287 ++++++++++++------ ...emaGeneratorTests.BuiltInRuleAttributes.cs | 99 ++++++ 7 files changed, 455 insertions(+), 124 deletions(-) diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 1b717cc..11b01fa 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -432,7 +432,7 @@ public sealed class NoWhitespaceAttribute 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. The rule's **value** parameters are additionally emitted as a constructor: a parameter the rule declares without a default becomes a required constructor argument, so an attribute like `[MinValue]` cannot be applied without its bound, while a parameter with a default keeps that default (so `[NoWhitespace]` still works). A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument) and defaults to the rule's `MessageFormat`; a parameter named `code`/`origin` becomes a `Code`/`Origin` property and `Code` defaults to the rule's `ErrorCode`; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. +- Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. The rule's **value** parameters are additionally emitted as a constructor: a parameter the rule declares without a default becomes a required constructor argument, so an attribute like `[MinValue]` cannot be applied without its bound, while a parameter with a default keeps that default (so `[NoWhitespace]` still works). A rule that overloads its constructor surfaces one attribute constructor per attribute-addressable overload, and the applied constructor selects the matching rule overload — `UUIDRule` yields both `[UUID]` (the versionless overload) and `[UUID(UuidVersion.V4)]` (the versioned one). A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument) and defaults to the rule's `MessageFormat`; a parameter named `code`/`origin` becomes a `Code`/`Origin` property and `Code` defaults to the rule's `ErrorCode`; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. - If the derived name collides with a `System.ComponentModel.DataAnnotations` attribute, the generated attribute is emitted under a `Zod` suffix (`MinLengthAttribute` → `MinLengthZodAttribute`, used as `[MinLengthZod]`). - The attribute is always decorated with `AttributeTargets.Class | Struct | Property | Field | Parameter`, so it can annotate a member or a scalar value object. (`Class`/`Struct` are what make the [type-level form](#type-level-rules) possible.) - The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). @@ -493,7 +493,7 @@ public partial class Contact } ``` -Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). +Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). A rule with overloaded constructors mirrors each overload, so `[UUID]` uses the versionless overload and `[UUID(UuidVersion.V4)]` (or `[UUID(Version = UuidVersion.V4)]`) the versioned one. `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). Two adjustments keep every rule addressable: diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 1d38ff8..8231021 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -90,4 +90,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full 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). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 03ed3e4..3c2ec50 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -187,7 +187,7 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[Email]` | `EmailRule` | | | `[E164]` | `E164Rule` | | | `[Regex("…")]` | `RegexRule` | mirrors the `(string pattern, string? message)` overload | -| `[UUID(UuidVersion.…)]` | `UUIDRule` | the version is required | +| `[UUID]` / `[UUID(UuidVersion.…)]` | `UUIDRule` | the version is optional; omit it for the versionless rule | | `[ULID]` | `ULIDRule` | | | `[JWT]` | `JWTRule` | | | `[IPAddress]` / `[IPAddress(IPAddressRuleType.…)]` | `IPAddressRule` | defaults to `Any`; pass a type to require IPv4/IPv6 | diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 89eeabc..1f4b693 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -710,25 +710,41 @@ static ImmutableArray GetIdentityPropertyNames(INamedTypeSymbol? attribu } /// - /// Gets the public constructor parameters used to build the rule's arguments: the constructor the generated - /// attribute mirrors, matching the rule instantiation contract. + /// Gets every public constructor parameter used to build the rule's arguments across all attribute-addressable + /// constructors, matching the rule instantiation contract. Used to decide whether an applied named argument + /// is consumed by any overload. /// /// The resolved rule type. - /// The mapped constructor parameters. - static ImmutableArray MappedParameters(INamedTypeSymbol ruleType) => - SelectAttributeConstructor(ruleType)?.Parameters ?? []; + /// The mapped constructor parameters, de-duplicated by name. + static ImmutableArray MappedParameters(INamedTypeSymbol ruleType) + { + HashSet seen = [with(StringComparer.OrdinalIgnoreCase)]; + var parameters = ImmutableArray.CreateBuilder(); + + foreach (var constructor in SelectAttributeConstructors(ruleType)) + { + foreach (var parameter in constructor.Parameters) + { + if (seen.Add(parameter.Name)) + parameters.Add(parameter); + } + } + + return parameters.ToImmutable(); + } /// - /// Selects the public constructor a rule attribute mirrors and the generated validation instantiates: the - /// one whose parameters are all representable as attribute properties (a message or - /// CancellationToken parameter is consumed rather than mirrored), preferring the one with the most - /// parameters. Rules that declare a second overload only to accept a non-attribute-argument type (for - /// example ) are still attribute-addressable through the - /// compatible overload. + /// Selects the public constructors a rule attribute mirrors and the generated validation instantiates: every + /// overload whose parameters are all representable as attribute properties (a message or + /// CancellationToken parameter is consumed rather than mirrored), widest first. A rule that overloads + /// its constructor for a non-attribute-argument type (for example ) + /// only surfaces the compatible overload; a rule whose overloads are all addressable (for example the + /// versioned and versionless UUID rules) surfaces one attribute constructor per overload so each stays + /// reachable. /// /// The rule type to inspect. - /// The selected constructor, or when the rule declares none. - internal static IMethodSymbol? SelectAttributeConstructor(INamedTypeSymbol ruleType) + /// The selected constructors, widest first, or an empty array when the rule declares none. + internal static ImmutableArray SelectAttributeConstructors(INamedTypeSymbol ruleType) { var candidates = ruleType .InstanceConstructors.Where(static c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) @@ -741,11 +757,16 @@ static ImmutableArray MappedParameters(INamedTypeSymbol ruleTy ? [.. candidates.Where(static c => !c.IsImplicitlyDeclared)] : candidates; - return considered - .Where(static c => c.Parameters.All(IsAttributeMappableParameter)) - .OrderByDescending(static c => c.Parameters.Length) - .FirstOrDefault() - ?? considered.OrderByDescending(static c => c.Parameters.Length).FirstOrDefault(); + var mappable = considered + .Where(static c => c.Parameters.All(IsAttributeMappableParameter)) + .OrderByDescending(static c => c.Parameters.Length) + .ToImmutableArray(); + + if (!mappable.IsEmpty) + return mappable; + + // No overload is addressable; keep the widest so the caller can still report the offending parameter. + return [.. considered.OrderByDescending(static c => c.Parameters.Length).Take(1)]; } static bool IsAttributeMappableParameter(IParameterSymbol parameter) => @@ -880,6 +901,19 @@ static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyTyp return false; } + /// + /// Builds the rule constructor arguments for an applied attribute, choosing the rule overload the attribute + /// addresses. A rule that overloads its constructor (for example the versioned and versionless UUID rules) + /// exposes one attribute constructor per overload; the applied attribute's constructor selects the matching + /// rule overload, so a bare [Uuid] does not try to satisfy the versioned overload's required version. + /// + /// The applied attribute. + /// The resolved rule type. + /// The argument expressions, in the selected constructor's parameter order. + /// + /// The parameter that could not be mapped when no overload could be satisfied. + /// + /// when an overload was satisfied. static bool TryBuildArguments( AttributeData attribute, INamedTypeSymbol ruleType, @@ -890,10 +924,100 @@ out string? unmappedParameterName arguments = new([]); unmappedParameterName = null; - var constructor = SelectAttributeConstructor(ruleType); + var candidates = SelectAttributeConstructors(ruleType); + if (candidates.IsDefaultOrEmpty) + return false; + + // Prefer an overload that consumes every value the attribute supplies, so a named/positional value that + // only the wider overload declares (for example `Version`) is not silently dropped by a narrower one. + foreach (var candidate in candidates) + { + if (!ConsumesSuppliedArguments(attribute, candidate)) + continue; + + if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + return true; + } + + // Fall back to any overload that can be built: hand-authored attributes map positionally and may name + // their parameters differently from the rule's. + foreach (var candidate in candidates) + { + if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + return true; + } + + // Report the failure against the widest overload for a useful message. + TryBuildArgumentsFor(attribute, candidates[0], out _, out unmappedParameterName); + return false; + } + + /// + /// Determines whether has a parameter for every value the applied attribute + /// supplies: each named argument is either a constructor parameter or part of the error identity, and each + /// positional argument maps by the applied constructor's parameter name or by position. + /// + static bool ConsumesSuppliedArguments(AttributeData attribute, IMethodSymbol candidate) + { + HashSet parameterNames = [with(StringComparer.OrdinalIgnoreCase)]; + foreach (var parameter in candidate.Parameters) + parameterNames.Add(parameter.Name); + + foreach (var pair in attribute.NamedArguments) + { + if (IsIdentityOrValidationName(pair.Key)) + continue; + + if (!parameterNames.Contains(pair.Key)) + return false; + } + + var positional = attribute.ConstructorArguments; + if (positional.IsDefaultOrEmpty) + return true; + + var appliedParameters = attribute.AttributeConstructor is { } appliedConstructor + ? appliedConstructor.Parameters + : default; + + for (var i = 0; i < positional.Length; i++) + { + var appliedName = + !appliedParameters.IsDefaultOrEmpty && i < appliedParameters.Length ? appliedParameters[i].Name : null; + + if (appliedName is not null && parameterNames.Contains(appliedName)) + continue; + + if (i < candidate.Parameters.Length) + continue; - if (constructor is null) return false; + } + + return true; + } + + /// + /// Determines whether is consumed by every rule overload: the error identity + /// (Code/Origin) and the inherited ValidationAttribute message members are read from the + /// applied attribute rather than passed to the rule, so no overload needs a matching parameter. + /// + static bool IsIdentityOrValidationName(string name) => + name.Equals("Code", StringComparison.OrdinalIgnoreCase) + || name.Equals("Origin", StringComparison.OrdinalIgnoreCase) + || name.Equals("ErrorMessage", StringComparison.OrdinalIgnoreCase) + || name.Equals("ErrorMessageResourceName", StringComparison.OrdinalIgnoreCase) + || name.Equals("ErrorMessageResourceType", StringComparison.OrdinalIgnoreCase); + + static bool TryBuildArgumentsFor( + AttributeData attribute, + IMethodSymbol constructor, + out EquatableArray arguments, + out string? unmappedParameterName + ) + { + arguments = new([]); + unmappedParameterName = null; var validation = ValidationAttributeData.FromAttributeData(attribute); var positional = attribute.ConstructorArguments; diff --git a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs index c4a8b11..334c3cc 100644 --- a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs +++ b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs @@ -14,10 +14,12 @@ namespace ZodSharp.SourceGenerators.Models; /// [ZodRule(AllowMultiple = ...)]. /// /// The generated attribute properties, mirroring the rule constructor parameters. -/// -/// The attribute constructor parameters carrying the rule's constructor values, in the rule's declaration +/// +/// The attribute constructors, one per attribute-addressable rule constructor, in the rule's declaration /// order. A parameter without a is required, so the -/// attribute cannot be applied without it. +/// attribute cannot be applied without it. A rule with overloaded constructors (for example the versioned and +/// versionless UUID rules) surfaces one attribute constructor per overload so each rule overload stays +/// reachable. /// readonly record struct RuleAttributeGenerationModel( TypeIdentity RuleType, @@ -27,9 +29,20 @@ readonly record struct RuleAttributeGenerationModel( string? Origin, bool AllowMultiple, EquatableArray Properties, - EquatableArray ConstructorParameters = default + EquatableArray Constructors = default ); +/// +/// Describes one constructor of a generated validation attribute, mirroring a single attribute-addressable +/// constructor of the rule it maps to. +/// +/// +/// The constructor parameters carrying the rule's constructor values. Empty when the rule overload has no +/// attribute-addressable value parameters (its message/identity parameters are surfaced as properties), +/// which produces an explicit parameterless constructor when another constructor is also emitted. +/// +readonly record struct GeneratedAttributeConstructor(EquatableArray Parameters); + /// /// Describes a settable property on a generated validation attribute. /// diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index cf60d86..f442a43 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -152,93 +152,49 @@ is not null ]; } + var ruleConstructors = CustomRuleResolver.SelectAttributeConstructors(ruleType); var properties = ImmutableArray.CreateBuilder(); - var constructorParameters = ImmutableArray.CreateBuilder(); - var constructor = CustomRuleResolver.SelectAttributeConstructor(ruleType); - - if (constructor is not null) + HashSet propertyNames = [with(StringComparer.OrdinalIgnoreCase)]; + var constructors = ImmutableArray.CreateBuilder(); + var messageParameter = ruleConstructors + .SelectMany(static constructor => constructor.Parameters) + .FirstOrDefault(CustomRuleResolver.IsMessageParameter); + + // Every attribute-addressable rule overload surfaces its own attribute constructor. Overloads that + // differ only in the message/identity parameters collapse to the same parameterless attribute + // constructor, so identical signatures are emitted once. + foreach (var ruleConstructor in ruleConstructors) { - IParameterSymbol? messageParameter = null; - - foreach (var parameter in constructor.Parameters) + if ( + !TryBuildAttributeConstructorModel( + ruleConstructor, + ruleType, + context.SemanticModel.Compilation, + identityWarnings, + properties, + propertyNames, + out var generatedConstructor, + out var failureDiagnostics + ) + ) { - if (CustomRuleResolver.IsMessageParameter(parameter)) - { - // The rule's message flows through the inherited ValidationAttribute.ErrorMessage, which the - // resolver maps onto this parameter. The generated attribute additionally exposes a Message - // alias, so the rule's own parameter name stays usable at the call site. - messageParameter = parameter; - continue; - } - - if (CustomRuleResolver.IsCancellationToken(parameter.Type)) - continue; - - var isIdentityParameter = CustomRuleResolver.IsIdentityParameter(parameter); - - // A type-parameter constructor argument (for example `MinValueRule(T minValue)`) cannot be - // mirrored directly; it is surfaced as a double, the numeric type the schema pipeline uses and - // the type a `[MinValue]`-style attribute can carry. - var propertyType = - parameter.Type.TypeKind == TypeKind.TypeParameter - ? context.SemanticModel.Compilation.GetSpecialType(SpecialType.System_Double) - : parameter.Type; - - if (!CustomRuleResolver.IsSupportedAttributePropertyType(propertyType)) - { - ImmutableArray propertyDiagnostics = - [ - ReportUnsupportedRuleAttributeGeneration( - ruleType, - $"constructor parameter '{parameter.Name}' has type '{parameter.Type.ToDisplayString()}', which cannot be represented as an attribute property" - ), - .. identityWarnings, - ]; - - return GeneratorResult.Create(default, propertyDiagnostics); - } - - TypeIdentity propertyTypeIdentity = new(TypeHelpers.StripNullableAnnotations(propertyType)); - var propertyName = ToPascalCase(parameter.Name); - var initializer = BuildPropertyInitializer(parameter, propertyType, ruleType, isIdentityParameter); - var isNullable = TypeHelpers.CanBeNull(propertyType); - - properties.Add( - new GeneratedAttributeProperty(propertyTypeIdentity, propertyName, initializer, isNullable) - ); - - // The attribute constructor mirrors the rule's value parameters so a required value (for - // example the `maxValue` of `LessThanOrEqualRule`) has to be supplied at the usage site - // instead of silently falling back to the type's default. A defaulted parameter keeps its - // default, so `[Even]`-style attributes stay applicable without arguments. - // - // Identity parameters stay properties only: the resolver reads the reported error identity from - // the applied attribute's named arguments, so a constructor parameter would never feed it. - if (isIdentityParameter) - continue; - - constructorParameters.Add( - new GeneratedAttributeParameter( - propertyTypeIdentity, - parameter.Name, - propertyName, - parameter.HasExplicitDefaultValue ? initializer : null, - isNullable - ) - ); + return GeneratorResult.Create(default, failureDiagnostics); } - if (messageParameter is not null) - { - properties.Add( - new GeneratedAttributeProperty( - new TypeIdentity(TypeHelpers.StripNullableAnnotations(messageParameter.Type)), - "Message", - BuildMessageInitializer(messageParameter, ruleType), - TypeHelpers.CanBeNull(messageParameter.Type) - ) - ); - } + if (!constructors.Any(existing => HaveSameParameters(existing.Parameters, generatedConstructor.Parameters))) + constructors.Add(generatedConstructor); + } + + if (messageParameter is not null && propertyNames.Add("Message")) + { + properties.Add( + new GeneratedAttributeProperty( + new TypeIdentity(TypeHelpers.StripNullableAnnotations(messageParameter.Type)), + "Message", + BuildMessageInitializer(messageParameter, ruleType), + TypeHelpers.CanBeNull(messageParameter.Type) + ) + ); } return GeneratorResult.Create( @@ -252,12 +208,140 @@ is not null GetNamedString(attribute, "Origin"), GetNamedBool(attribute, "AllowMultiple"), new(properties.ToImmutable()), - new(constructorParameters.ToImmutable()) + new(constructors.ToImmutable()) ), identityWarnings ); } + /// + /// Builds the generated attribute constructor that mirrors one attribute-addressable rule overload, adding + /// the overload's properties to the shared property set. A parameter the rule declares without a default has + /// no default here either, so the attribute cannot be applied without it; the rule's message and + /// identity (code/origin) parameters stay properties. + /// + /// The rule overload to mirror. + /// The rule the attribute maps to. + /// The compilation, used to resolve the surfaced type of a generic parameter. + /// Diagnostics to carry alongside an unsupported-parameter failure. + /// The shared generated attribute property set. + /// The property names already added, so an overload pair does not duplicate one. + /// The generated constructor when the overload can be surfaced. + /// The diagnostics to report when a parameter cannot be surfaced. + /// when the overload was surfaced. + static bool TryBuildAttributeConstructorModel( + IMethodSymbol ruleConstructor, + INamedTypeSymbol ruleType, + Compilation compilation, + ImmutableArray identityWarnings, + ImmutableArray.Builder properties, + HashSet propertyNames, + out GeneratedAttributeConstructor constructor, + out ImmutableArray failureDiagnostics + ) + { + constructor = default; + failureDiagnostics = []; + var constructorParameters = ImmutableArray.CreateBuilder(); + + foreach (var parameter in ruleConstructor.Parameters) + { + // The rule's message flows through the inherited ValidationAttribute.ErrorMessage, which the + // resolver maps onto this parameter. The generated attribute additionally exposes a Message alias, + // so the rule's own parameter name stays usable at the call site. + if (CustomRuleResolver.IsMessageParameter(parameter)) + continue; + + if (CustomRuleResolver.IsCancellationToken(parameter.Type)) + continue; + + var isIdentityParameter = CustomRuleResolver.IsIdentityParameter(parameter); + + // A type-parameter constructor argument (for example `MinValueRule(T minValue)`) cannot be + // mirrored directly; it is surfaced as a double, the numeric type the schema pipeline uses and the + // type a `[MinValue]`-style attribute can carry. + var propertyType = + parameter.Type.TypeKind == TypeKind.TypeParameter + ? compilation.GetSpecialType(SpecialType.System_Double) + : parameter.Type; + + if (!CustomRuleResolver.IsSupportedAttributePropertyType(propertyType)) + { + failureDiagnostics = + [ + ReportUnsupportedRuleAttributeGeneration( + ruleType, + $"constructor parameter '{parameter.Name}' has type '{parameter.Type.ToDisplayString()}', which cannot be represented as an attribute property" + ), + .. identityWarnings, + ]; + + return false; + } + + TypeIdentity propertyTypeIdentity = new(TypeHelpers.StripNullableAnnotations(propertyType)); + var propertyName = ToPascalCase(parameter.Name); + var initializer = BuildPropertyInitializer(parameter, propertyType, ruleType, isIdentityParameter); + var isNullable = TypeHelpers.CanBeNull(propertyType); + + // The widest overload is visited first, so its property type and initializer win when an overload + // pair shares a parameter. + if (propertyNames.Add(propertyName)) + { + properties.Add( + new GeneratedAttributeProperty(propertyTypeIdentity, propertyName, initializer, isNullable) + ); + } + + // The attribute constructor mirrors the rule's value parameters so a required value (for example + // the `maxValue` of `LessThanOrEqualRule`) has to be supplied at the usage site instead of + // silently falling back to the type's default. A defaulted parameter keeps its default, so + // `[Even]`-style attributes stay applicable without arguments. + // + // Identity parameters stay properties only: the resolver reads the reported error identity from the + // applied attribute's named arguments, so a constructor parameter would never feed it. + if (isIdentityParameter) + continue; + + constructorParameters.Add( + new GeneratedAttributeParameter( + propertyTypeIdentity, + parameter.Name, + propertyName, + parameter.HasExplicitDefaultValue ? initializer : null, + isNullable + ) + ); + } + + constructor = new GeneratedAttributeConstructor(constructorParameters.ToImmutable()); + return true; + } + + /// + /// Determines whether two generated attribute constructor signatures are identical, so an overload pair that + /// differs only in message/identity parameters is emitted once. + /// + /// The first constructor's parameters. + /// The second constructor's parameters. + /// when the signatures match. + static bool HaveSameParameters( + EquatableArray left, + EquatableArray right + ) + { + if (left.Count != right.Count) + return false; + + for (var i = 0; i < left.Count; i++) + { + if (left[i] != right[i]) + return false; + } + + return true; + } + static bool IsValidationRule(INamedTypeSymbol ruleType) => ruleType.AllInterfaces.Any(static iface => iface.OriginalDefinition is { Name: "IValidationRule", Arity: 1 } definition @@ -356,6 +440,7 @@ bool isIdentityParameter return errorCode.StringLiteral(); } + // The rule's own default is used for every other property, so the generated attribute is self-describing and return BuildInitializer(parameter, propertyType); } @@ -633,7 +718,17 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m ) ) { - BuildAttributeConstructor(writer, model); + // A single parameterless constructor is left implicit; once any constructor is emitted the + // parameterless overload must be emitted explicitly so the attribute stays applicable without + // arguments (for example `[Uuid]` alongside `[Uuid(UuidVersion.V4)]`). + if ( + model.Constructors.Count > 1 + || (model.Constructors.Count == 1 && model.Constructors[0].Parameters.Count > 0) + ) + { + foreach (var constructor in model.Constructors) + BuildAttributeConstructor(writer, model, constructor); + } foreach (var property in model.Properties) { @@ -654,27 +749,27 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m } /// - /// Emits the attribute constructor that carries the rule's value parameters. A parameter the rule declares - /// without a default has no default here either, so the attribute cannot be applied without it; the rule's - /// message and identity (code/origin) parameters stay properties. Rules whose only - /// constructor parameters are message/identity (or none) emit no constructor and keep the implicit - /// parameterless one. + /// Emits one attribute constructor that carries a rule overload's value parameters. A parameter the rule + /// declares without a default has no default here either, so the attribute cannot be applied without it; the + /// rule's message and identity (code/origin) parameters stay properties. /// /// The writer positioned inside the attribute's class scope. /// The generation model describing the attribute. - static void BuildAttributeConstructor(CodeWriter writer, RuleAttributeGenerationModel model) + /// The constructor signature to emit. + static void BuildAttributeConstructor( + CodeWriter writer, + RuleAttributeGenerationModel model, + GeneratedAttributeConstructor constructor + ) { - if (model.ConstructorParameters.IsEmpty) - return; - writer.XmlSummary( $"Initializes the attribute with the values required by {CodeWriter.XmlSee(model.RuleType)}.", "A required rule constructor value cannot be omitted; optional values keep their rule default." ); - var parameters = ImmutableArray.CreateBuilder(model.ConstructorParameters.Count); + var parameters = ImmutableArray.CreateBuilder(constructor.Parameters.Count); - foreach (var constructorParameter in model.ConstructorParameters) + foreach (var constructorParameter in constructor.Parameters) { var parameterType = constructorParameter.IsNullable ? constructorParameter.Type.AsTypeReference().Nullable(writer) @@ -695,7 +790,7 @@ static void BuildAttributeConstructor(CodeWriter writer, RuleAttributeGeneration }, constructorBody => { - foreach (var constructorParameter in model.ConstructorParameters) + foreach (var constructorParameter in constructor.Parameters) constructorBody.Assignment(constructorParameter.PropertyName, constructorParameter.Name); } ); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index 69d4177..1cc0aeb 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -586,4 +586,103 @@ await Assert .That(generated) .ContainsGeneratedCode("new global::ZodSharp.Rules.EmailRule(\"Not an email.\", \"bad_email\")"); } + + /// + /// A [ZodSchema] class that applies the shipped [UUID] attribute both without a version and + /// with an explicit RFC 9562 version. The rule overloads its constructor, so the attribute overloads too. + /// + const string UuidAttributeSource = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Identifiers + { + [UUID] + public string AnyUuid { get; set; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string V4Uuid { get; set; } = string.Empty; + + [UUID(Version = UuidVersion.V7)] + public string V7Uuid { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task RuleAttributeGeneration_GivenVersionlessAndVersionedUuid_SelectsTheMatchingRuleOverload( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync(UuidAttributeSource, cancellationToken); + var generated = driverResult.GetSource("IdentifiersSchema"); + + // Assert - the bare [UUID] maps to the versionless overload and [UUID(UuidVersion.V4)] to the versioned + // one, rather than both collapsing to the widest constructor (which would demand a version for [UUID]). + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.UUIDRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.UUIDRule((global::ZodSharp.UuidVersion)4, null!, null!)" + ); + // A version supplied through the property rather than positionally selects the versioned overload too. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.UUIDRule((global::ZodSharp.UuidVersion)7, null!, null!)" + ); + } + + [Test] + public async Task RuleAttributeGeneration_GivenVersionlessAndVersionedUuid_ValidatesWithTheMatchingOverload( + CancellationToken cancellationToken + ) + { + // Arrange + var driverResult = await GenerateAsync( + UuidAttributeSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Identifiers")!; + var validate = assembly.GetType("Testing.IdentifiersSchema")!.GetMethod("Validate")!; + + // Act - each member receives a UUID of the version its attribute requires; the versionless member accepts + // either. A V7 UUID supplied to the V4 member fails only that rule. + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("AnyUuid")!.SetValue(validInstance, "550e8400-e29b-41d4-a716-446655440000"); + modelType.GetProperty("V4Uuid")!.SetValue(validInstance, "550e8400-e29b-41d4-a716-446655440000"); + modelType.GetProperty("V7Uuid")!.SetValue(validInstance, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + var validResult = validate.Invoke(null, [validInstance])!; + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("AnyUuid")!.SetValue(invalidInstance, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + modelType.GetProperty("V4Uuid")!.SetValue(invalidInstance, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + modelType.GetProperty("V7Uuid")!.SetValue(invalidInstance, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + var invalidResult = validate.Invoke(null, [invalidInstance])!; + + // Assert + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenUuidRule_GeneratedAttributeExposesBothRuleOverloads( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange - the shipped attribute is generated when the runtime assembly builds, so reflect over it here. + var attributeType = typeof(Rules.UUIDAttribute); + + // Assert - the versionless and versioned rule overloads are both reachable through the attribute. + await Assert.That(attributeType.GetConstructor([])).IsNotNull(); + await Assert.That(attributeType.GetConstructor([typeof(UuidVersion)])).IsNotNull(); + } } From 81c302608583d66b63d60197b3343ead3ce063b6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 00:34:35 +0100 Subject: [PATCH 15/26] chore: made RuleMessage public --- src/src/ZodSharp/Rules/RuleMessage.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/src/ZodSharp/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs index ae10ec4..9d315a3 100644 --- a/src/src/ZodSharp/Rules/RuleMessage.cs +++ b/src/src/ZodSharp/Rules/RuleMessage.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; /// Centralising formatting keeps the culture provider in one place (satisfying CA1305) and lets every rule /// expose its message as a public const string without re-parsing that constant on each failure. /// -static class RuleMessage +public static class RuleMessage { static readonly ConcurrentDictionary Formats = new(StringComparer.Ordinal); From 28424ea13b50b0e6e87cf4fb481d5384e6b7dc39 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 08:01:25 +0100 Subject: [PATCH 16/26] feat: added enum rule + auto usage on schemas --- docs/wiki/Custom-Rules.md | 2 + docs/wiki/Source-Generator-DataAnnotations.md | 8 +- docs/wiki/Source-Generator.md | 43 +- docs/wiki/Validation-Rules-Reference.md | 41 ++ .../Helpers/AttributeGenHelper.cs | 19 + .../Helpers/CustomRuleResolver.cs | 23 + .../Helpers/SourceGenLibrary.cs | 59 +- .../SourceGenerators/Helpers/TypeLibrary.cs | 6 + .../Models/SchemaGenerationModel.cs | 28 + .../Models/ZodSchemaAttributeData.cs | 3 +- .../ZodSchemaGenerator.BuildSchema.cs | 24 +- .../ZodSchemaGenerator.CustomRules.cs | 6 +- .../ZodSchemaGenerator.EnumValidators.cs | 138 ++++ .../ZodSchemaGenerator.ValueValidators.cs | 11 +- src/src/ZodSharp/Rules/EnumRule.cs | 83 +++ src/src/ZodSharp/Schemas/ZodNativeEnum.cs | 19 +- src/src/ZodSharp/Sdk/README.md | 2 + src/src/ZodSharp/ZodIgnoreAttribute.cs | 36 ++ .../ZodSchemaGeneratorTests.EnumValidation.cs | 607 ++++++++++++++++++ .../ZodSharp.UnitTests/Rules/EnumRuleTests.cs | 169 +++++ 20 files changed, 1312 insertions(+), 15 deletions(-) create mode 100644 src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs create mode 100644 src/src/ZodSharp/Rules/EnumRule.cs create mode 100644 src/src/ZodSharp/ZodIgnoreAttribute.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 11b01fa..74d5996 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -502,6 +502,8 @@ Two adjustments keep every rule addressable: The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(…)]`, `[LessThanOrEqual(…)]`, `[Even]`, and `[Odd]`. +`[Enum]` mirrors `EnumRule` and closes the open generic with the annotated enum member type. The `[ZodSchema]` generator applies the rule automatically to enum properties; `[ZodIgnore]` on an enum member excludes that member from the rule everywhere the enum is validated, and a property's `[DeniedValues]` excludes values for that property only. See [Enum rules](Validation-Rules-Reference.md#enum-rules). + ## 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: diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 8231021..5504d18 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -14,7 +14,7 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib | `[Range(...)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | | `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | | `[AllowedValues(...)]` | typed equality checks against the allowed set | `invalid_value` | -| `[DeniedValues(...)]` | typed equality checks against the denied set | `invalid_value` | +| `[DeniedValues(...)]` | typed equality checks against the denied set; for an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | | `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | | `[Url]` | reuses `UrlRule` | `invalid_string` | | `[Phone]` | reuses `PhoneRule` | `invalid_string` | @@ -25,6 +25,10 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. +## Enum properties + +Enum properties are validated automatically — the generator rejects a value that is not a defined member of the enum type with `invalid_enum_value`. The check is emitted as `ZodSharp.Rules.EnumRule`. Members can be excluded with `[ZodIgnore]` (on the enum member, for every property of that type) or `[DeniedValues]` (on the property only). `[Flags]` enums and properties with an explicit `[AllowedValues]` allow-list are not auto-validated, and the whole feature is disabled with `[ZodSchema(ValidateEnumValues = false)]`. See [Source Generator](Source-Generator.md#automatic-enum-validation) for the full rules and examples. + ## Size validators and structured issues Size attributes generate direct `Length` or `Count` access when possible: @@ -90,4 +94,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full 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). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, and `[Odd]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, and `[Enum]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index d0ea03c..042aa11 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -55,6 +55,7 @@ All options are optional. | `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. Mutually exclusive with the synchronous `OnZodValidate` refinement hook. | | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | +| `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. | > [!NOTE] > `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. @@ -147,10 +148,50 @@ MSBuild switches: | `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) | | `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list | +## Automatic enum validation + +Every non-flags enum property is validated automatically: the generated validator rejects a value that is not a defined member of the enum type. The check is emitted as an `EnumRule` (see [Validation Rules Reference](Validation-Rules-Reference.md#enum-rules)) and reports `invalid_enum_value`. + +A member that is defined but never a valid value can be excluded globally by marking it `[ZodIgnore]`, and excluded for a single property with `[DeniedValues]`: + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +public enum ExampleEnum +{ + [ZodIgnore] + Unspecified, + + AValidValue, + + AnotherValidValue, +} + +[ZodSchema] +public class Model +{ + // Rejects anything that is not AValidValue or AnotherValidValue. + public ExampleEnum Status { get; set; } + + // Also rejects AnotherValidValue for this property only. + [DeniedValues(ExampleEnum.AnotherValidValue)] + public ExampleEnum SecondaryStatus { get; set; } +} +``` + +The automatic validation is skipped when: + +- the enum is declared `[Flags]` — a combination is a valid value without being a defined member; +- the property declares an explicit `[AllowedValues]` allow-list, which governs the property instead; +- the schema opts out with `[ZodSchema(ValidateEnumValues = false)]`. + +A nullable enum property is validated only when it is not `null`. + ## What is validated - Properties must be public, non-static, non-indexer. -- A property is included when it carries any DataAnnotations attribute or its type is a source-defined complex type with a nested schema. +- Validation is emitted for a property when it carries any DataAnnotations attribute, its type is a source-defined complex type with a nested schema, or its type is an enum (see [Automatic enum validation](#automatic-enum-validation)). - Classes, structs, and records are supported; structs do not receive `IValidateOptions` (ZODSGEN028 if requested). - 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`. diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 3c2ec50..feab72b 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -63,6 +63,7 @@ The built-in rules report one of the following Zod-compatible codes: | `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | | `not_finite` | A number was `NaN` or infinite | `FiniteRule` | | `invalid_value` | A value was a rejected sentinel | `NonSentinelRule` | +| `invalid_enum_value` | A value was not a defined member of the enum type, or was an excluded member | `EnumRule` | | `validation_failed` | Fallback for rules that do not declare an identity | any rule without `ErrorCode`/`IZodRule` | ## String rules @@ -178,6 +179,45 @@ var chained = Z.String().NonSentinel().Min(3); The same rule can be attached directly with `AddRule`/`Rule`, or through the shipped `[NonSentinel]` attribute, which closes the open generic with the annotated member/scalar type — see [Custom Rules](Custom-Rules.md#non-sentinel-values-ef-friendly). +## Enum rules + +| Rule | Constructor | Fluent method | Code | Message format | +|---|---|---|---|---| +| `EnumRule` | `()` or `(TEnum[]? disallowed, string? message, string? code)` | `Enum()` | `invalid_enum_value` | `'{0}' is not a defined member of {1}` | + +`EnumRule` is generic over `TEnum : struct, Enum` and rejects a value that is not a defined member of the enum type, or that resolves to a member the rule excludes. It is equivalent to Zod's `z.nativeEnum(Enum)` semantics. The defined members are resolved once per closed generic type, so the validation path performs no reflection. + +The `[ZodSchema]` generator emits this rule automatically for every non-flags enum property — see [Source Generator](Source-Generator.md#automatic-enum-validation). The rule is closed with the property's enum type and receives the excluded members as its disallowed set: + +- every enum member marked `[ZodIgnore]` (a member that is defined but never a valid value, such as `Unspecified`), and +- the values the property's `[DeniedValues]` attribute lists. + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +public enum ExampleEnum +{ + [ZodIgnore] + Unspecified, + + AValidValue, + + AnotherValidValue, +} + +[ZodSchema] +public class Model +{ + public ExampleEnum Status { get; set; } + + [DeniedValues(ExampleEnum.AnotherValidValue)] + public ExampleEnum SecondaryStatus { get; set; } +} +``` + +The rule is skipped for a `[Flags]` enum (a combination is valid without being a defined member), for a property that declares an explicit `[AllowedValues]` allow-list (the allow-list governs), and when the schema opts out with `[ZodSchema(ValidateEnumValues = false)]`. A hand-written `ZodNativeEnum` schema already rejects undefined members; its `Enum()` method adds this rule to customise the message/code, and the rule can be attached to any enum-output schema with `AddRule`/`Rule`. + ## Generated attributes Each built-in rule that can be expressed as an attribute ships a generated `ValidationAttribute` in the `ZodSharp.Rules` namespace (inside the `Purview.ZodSharp` assembly), so a member or a scalar can be annotated directly: @@ -207,6 +247,7 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[Int]` | `IntRule` | closes the open generic with the member type | | `[Uri(UriKind.…)]` | `UriRule` | the `UriKind` is required | | `[NonSentinel]` | `NonSentinelRule` | closes the open generic with the member/scalar type | +| `[Enum]` | `EnumRule` | closes the open generic with the enum member type; excludes only the members the rule receives | | `[MinValue(…)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | | `[MaxValue(…)]` | `MaxValueRule` | as above | | `[GreaterThan(…)]` | `GreaterThanRule` | as above | diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index 3ae4d32..4d81e3b 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -142,6 +142,25 @@ static SourceText ZodSchemaAttribute() Initializer = "false", } ); + + body.XmlSummary( + "Whether enum properties are validated automatically: the generated validator rejects a", + "value that is not a defined member of the enum type, or that resolves to a member", + "excluded from the allowed set (an enum member marked [ZodIgnore], or a value the", + "property's [DeniedValues] attribute lists). [Flags] enums are never validated.", + "Default is true." + ) + .Property( + new( + nameof(ZodSchemaAttributeData.ValidateEnumValues), + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + Initializer = "true", + } + ); } ); diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 1f4b693..05208ec 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -447,6 +447,11 @@ static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) if (IsOrImplements(type, substituted)) continue; + // A class-type constraint (for example `where T : struct, Enum`) is satisfied by a type the + // interface check above does not cover: an enum implements no interface named Enum. + if (SatisfiesBaseTypeConstraint(type, substituted)) + continue; + // A constraint can be satisfied by a declaration this generator cannot see: a type declared // partial in this compilation may receive the interface implementation from another generator // (a scalar value object gets IScalarValueObject from the value object @@ -462,6 +467,24 @@ static bool SatisfiesConstraints(INamedTypeSymbol definition, ITypeSymbol type) return true; } + /// + /// Determines whether satisfies a base-class constraint the interface check does not + /// cover. Only the constrained base types a rule can declare are recognised. + /// + /// The candidate type the rule is closed with. + /// The constraint type. + /// when the type satisfies the constraint. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static bool SatisfiesBaseTypeConstraint(ITypeSymbol type, ITypeSymbol constraint) => + constraint.SpecialType switch + { + SpecialType.System_Enum => type.TypeKind == TypeKind.Enum, + SpecialType.System_ValueType => type.IsValueType, + SpecialType.System_Delegate => type.TypeKind == TypeKind.Delegate, + SpecialType.System_Object => true, + _ => false, + }; + /// /// Determines whether can be completed by another source generator, which means /// it can end up satisfying a constraint this compilation does not yet show. diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 1ba3055..a52e13d 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -201,6 +201,7 @@ CancellationToken cancellationToken // attribute is actually present; ZodSchemaAttributeData.Empty carries default(bool)). var generateValidateMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateValidateMethod; var generateParseMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateParseMethod; + var validateEnumValues = !zodSchemaAttribute.Exists || zodSchemaAttribute.ValidateEnumValues; schemas.Add( new( @@ -218,6 +219,7 @@ CancellationToken cancellationToken generateValidateMethod, generateParseMethod, typeRules, + validateEnumValues, isPrimary ) ); @@ -328,6 +330,7 @@ internal static GeneratorResult GetValidatablePropertyDes var propertyType = CreateTypeIdentity(property.Type); var originalPropertyType = property.Type; var propertyCanBeNull = TypeHelpers.CanBeNull(originalPropertyType); + var isNullableValueType = false; if ( originalPropertyType is INamedTypeSymbol { @@ -337,6 +340,7 @@ originalPropertyType is INamedTypeSymbol { propertyType = new(nullableType.TypeArguments[0]); originalPropertyType = nullableType.TypeArguments[0]; + isNullableValueType = true; } var diagnostics = ImmutableArray.CreateBuilder(); @@ -443,8 +447,10 @@ rangeAttribute with if (rangeAttribute.Exists) diagnostics.AddRange(rangeAttributeResult.Diagnostics); - var isEnum = - TypeHelpers.UnwrapNullableType(originalPropertyType) is INamedTypeSymbol { TypeKind: TypeKind.Enum }; + var enumType = TypeHelpers.UnwrapNullableType(originalPropertyType) as INamedTypeSymbol; + var isEnum = enumType is { TypeKind: TypeKind.Enum }; + var isFlagsEnum = isEnum && HasFlagsAttribute(enumType!); + var ignoredEnumMembers = isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default; var customRules = CustomRuleResolver.Resolve(property, originalPropertyType, diagnostics); @@ -459,7 +465,10 @@ rangeAttribute with property.Name, displayName, propertyCanBeNull, + isNullableValueType, isEnum, + isFlagsEnum, + ignoredEnumMembers, validationKind, elementType, elementTypeCanBeNull, @@ -532,10 +541,54 @@ originalType is IArrayTypeSymbol if (originalType is INamedTypeSymbol comparableType && IsComparableRangeType(comparableType)) return PropertyValidationKind.Comparable; - // If the original type is an enum, we can validate it against allowed/denied values. + if (originalType is INamedTypeSymbol { TypeKind: TypeKind.Enum }) + return PropertyValidationKind.Enum; + + // The property type is not a string, numeric, comparable, collection, complex, or enum type that can be validated by the generator. return PropertyValidationKind.Unsupported; } + /// + /// Determines whether the enum type is declared with the [Flags] attribute. A flags combination is a + /// valid value without being a defined member, so the automatic enum validation does not apply. + /// + static bool HasFlagsAttribute(INamedTypeSymbol enumType) => + enumType.GetAttributes().Any(static attribute => attribute.AttributeClass?.MetadataName == "FlagsAttribute"); + + /// + /// Collects the enum members marked with [ZodIgnore], rendered as member expressions the generated + /// validation passes to the enum rule as its disallowed set. + /// + /// The enum type to inspect. + /// The ignored member expressions; empty when the enum declares none. + /// + /// Every field of an enum type is a member, so the constant-value check is what selects them: the + /// compiler-generated backing field has no constant value. + /// + static EquatableArray GetIgnoredEnumMembers(INamedTypeSymbol enumType) + { + ImmutableArray.Builder? ignored = null; + var enumTypeReference = new TypeIdentity(enumType).RenderFullName; + + foreach (var member in enumType.GetMembers()) + { + if (member is not IFieldSymbol { HasConstantValue: true } enumMember) + continue; + + if ( + !enumMember + .GetAttributes() + .Any(attribute => TypeLibrary.ZodSharp.ZodIgnoreAttribute.Equals(attribute.AttributeClass)) + ) + continue; + + ignored ??= ImmutableArray.CreateBuilder(); + ignored.Add($"{enumTypeReference}.{enumMember.Name}"); + } + + return ignored is null ? new(ImmutableArray.Empty) : new(ignored.ToImmutable()); + } + static TypeIdentity? GetCollectionElementTypeIdentity(ITypeSymbol propertyType) { if (propertyType is IArrayTypeSymbol arrayType) diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index 2e1f5f7..a91f79f 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -47,6 +47,9 @@ static partial class TypeLibraryGenerator [TypeRef(ZodSharpNamespace)] static readonly TypeIdentity ZodSchemaAttribute = default; + [TypeRef(ZodSharpNamespace)] + static readonly TypeIdentity ZodIgnoreAttribute = default; + [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity ZodSchemaGeneratedAttribute = default; @@ -66,6 +69,9 @@ static partial class TypeLibraryGenerator [TypeRef("System")] static readonly TypeIdentity AttributeUsageAttribute = default; + [TypeRef(ZodSharpRulesNamespace)] + static readonly TypeIdentity EnumRule = default; + [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity ValidationResult = default; diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 1f3e904..be5e145 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -24,6 +24,7 @@ enum PropertyValidationKind Comparable, Collection, Complex, + Enum, Unsupported, } @@ -46,6 +47,10 @@ enum PropertyValidationKind /// Rules bound to the target type itself (type-level [ZodRule]-mapped attributes). They are /// evaluated against the whole value with an empty path. /// +/// +/// Whether the automatic enum validation is emitted for the schema's enum properties. On by default; the +/// [ZodSchema] attribute opts out. +/// /// True if this is the primary schema for the target type, false if it is a secondary schema. readonly record struct ZodSchemaDescriptor( TypeIdentity TargetType, @@ -62,15 +67,38 @@ readonly record struct ZodSchemaDescriptor( bool GenerateValidateMethod, bool GenerateParseMethod, EquatableArray TypeRules, + bool ValidateEnumValues, bool IsPrimary ); +/// The declared type of the property, with a nullable wrapper removed. +/// The name of the property. +/// The name reported in error messages, honouring a [Display] attribute. +/// Indicates whether the property can be null. +/// Indicates whether the property type is a Nullable<T>. +/// Indicates whether the property type is an enum. +/// Indicates whether the enum type is declared with the [Flags] attribute. +/// +/// The enum members marked with [ZodIgnore], rendered as member expressions. Empty for a non-enum or +/// flags property. +/// +/// The kind of validation the property supports. +/// The collection element type, when the property is a collection. +/// Indicates whether a collection element can be null. +/// The generated schema type of a nested complex type, when there is one. +/// The length expression and origin, when the property's length is countable. +/// Indicates whether ranges compare through IComparable. +/// The DataAnnotations attributes declared on the property. +/// The rules bound to the property through [ZodRule]-mapped attributes. readonly record struct ZodPropertyDescriptor( TypeIdentity PropertyType, string Name, string DisplayName, bool CanBeNull, + bool IsNullableValueType, bool IsEnum, + bool IsFlagsEnum, + EquatableArray IgnoredEnumMembers, PropertyValidationKind ValidationKind, TypeIdentity? ElementType, bool ElementTypeCanBeNull, diff --git a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs index 9736dfa..709b437 100644 --- a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs +++ b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs @@ -8,5 +8,6 @@ readonly partial record struct ZodSchemaAttributeData( [Property(DefaultValue = true)] bool EnableComposition, string? CustomValidationMethodName, [Property(DefaultValue = false)] bool GenerateIValidateOptions, - [Property(DefaultValue = false)] bool SuppressIValidateOptions + [Property(DefaultValue = false)] bool SuppressIValidateOptions, + [Property(DefaultValue = true)] bool ValidateEnumValues ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 4551571..81a1ce6 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -448,7 +448,12 @@ CancellationToken cancellationToken foreach (var property in schema.Properties) { if (property.ShouldProcess) - GeneratePropertyValidation(writer, property.Value, cancellationToken); + GeneratePropertyValidation( + writer, + property.Value, + schema.ValidateEnumValues, + cancellationToken + ); } GenerateTypeRuleValidations(writer, schema); @@ -654,6 +659,7 @@ void WriteFailure(CodeWriter body, string fallbackMessage) => static void GeneratePropertyValidation( CodeWriter writer, ZodPropertyDescriptor property, + bool validateEnumValues, CancellationToken cancellationToken ) { @@ -664,6 +670,10 @@ CancellationToken cancellationToken var attributes = property.ValidationAttributes; CodeWriter.BlockScope? block = null; + // The enum rule owns the property's value set: the values a [DeniedValues] attribute lists are + // absorbed into the rule's disallowed set, so the standalone denied-values validation is skipped. + var emitEnumRule = IsEnumValidationEmitted(property, validateEnumValues); + if (property.CanBeNull && attributes.Required.ShouldProcess && attributes.Required.Value.Exists) { var required = attributes.Required.Value; @@ -683,23 +693,23 @@ CancellationToken cancellationToken writer.Rule(propertyName, comparison, "missing_field", errorMessage); block = writer.ElseScope(); - GenerateValueSetValidations(writer, property); + GenerateValueSetValidations(writer, property, emitEnumRule); } else { - GenerateValueSetValidations(writer, property); + GenerateValueSetValidations(writer, property, emitEnumRule); if (property.CanBeNull) block = writer.IfBlockScope($"value.{propertyName} != null"); } - GenerateTypeSpecificValidations(writer, property); + GenerateTypeSpecificValidations(writer, property, emitEnumRule); block?.Dispose(); GenerateCompareValidation(writer, property); writer.NewLine(); } - static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescriptor property) + static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescriptor property, bool emitEnumRule) { switch (property.ValidationKind) { @@ -716,6 +726,10 @@ static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescri case PropertyValidationKind.Complex: GenerateComplexTypeValidation(writer, property); break; + case PropertyValidationKind.Enum: + if (emitEnumRule) + GenerateEnumValidation(writer, property); + break; case PropertyValidationKind.Unsupported: break; default: diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 4122c5a..a4e6ba6 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -15,11 +15,15 @@ static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescript if (property.CustomRules.Count == 0) return; + // The resolver closes a generic rule over the property's underlying type, so a nullable value type is + // unwrapped to match. The validation runs inside the non-null guard, so the unwrap cannot throw. + var valueExpression = property.IsNullableValueType ? $"value.{property.Name}!.Value" : $"value.{property.Name}"; + GenerateRuleValidations( writer, property.CustomRules, property.Name, - $"value.{property.Name}", + valueExpression, CodeGenHelpers.GetPathFieldName(property.Name), property.DisplayName, declareValueLocal: true diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs new file mode 100644 index 0000000..e4d5b5c --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs @@ -0,0 +1,138 @@ +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + /// + /// Determines whether the automatic enum validation is emitted for the property. A flags combination is a + /// valid value without being a defined member, and an explicit allow-list governs the property itself, so + /// neither is validated by the enum rule. + /// + /// The property to validate. + /// Whether the schema asked for the automatic enum validation. + /// when the enum rule is emitted. + static bool IsEnumValidationEmitted(ZodPropertyDescriptor property, bool validateEnumValues) + { + if (!validateEnumValues || !property.IsEnum || property.IsFlagsEnum) + return false; + + var allowedValues = property.ValidationAttributes.AllowedValues; + return !(allowedValues.ShouldProcess && allowedValues.Value.Exists); + } + + /// + /// Emits the automatic enum validation: an EnumRule<TEnum> instance the property value must + /// satisfy. The rule owns the reported error identity, so a failure surfaces as the rule's + /// invalid_enum_value code and message. + /// + /// The writer positioned inside the generated Validate method. + /// The enum property the rule validates. + static void GenerateEnumValidation(CodeWriter writer, ZodPropertyDescriptor property) + { + var propertyName = property.Name; + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(propertyName, "EnumRule"); + var valueVariable = CodeGenHelpers.GetLocalIdentifier(propertyName, "EnumRuleValue"); + var pathFieldName = CodeGenHelpers.GetPathFieldName(propertyName); + var ruleType = GetEnumRuleType(property); + var zodRuleInterface = TypeLibrary.ZodSharp.Core.IZodRule.AsTypeReference().RenderFullName; + + // The rule is closed with the underlying enum type, so a nullable property is unwrapped. The + // validation always runs inside the non-null guard, so the unwrap cannot throw. + var valueExpression = property.IsNullableValueType ? $"value.{propertyName}!.Value" : $"value.{propertyName}"; + writer.Assignment("var", valueVariable, valueExpression); + writer.Assignment("var", ruleVariable, $"new {ruleType}({GetDisallowedEnumValuesExpression(property)})"); + + // The rule implements IZodRule, so it owns its error identity; the rule's constant is only the + // fallback when the interface reports none. The cast is required because the interface may be + // implemented explicitly. + var codeExpression = $"(({zodRuleInterface}){ruleVariable}).Code ?? {ruleType}.ErrorCode"; + var originExpression = $"(({zodRuleInterface}){ruleVariable}).Origin"; + var messageExpression = GetEnumValidationMessageExpression(property, ruleVariable, 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}, {messageExpression}, {pathFieldName}, origin: {originExpression})" + ); + } + ); + + writer.NewLine(); + } + + static string GetEnumRuleType(ZodPropertyDescriptor property) => + $"{TypeLibrary.ZodSharp.Rules.EnumRule.MakeGeneric(property.PropertyType)}"; + + /// + /// Builds the disallowed-member argument: the enum members marked [ZodIgnore] plus the values the + /// property's [DeniedValues] attribute lists. Empty when every defined member is allowed. + /// + /// The enum property the rule validates. + /// The array-creation expression, or for none. + static string GetDisallowedEnumValuesExpression(ZodPropertyDescriptor property) + { + var deniedValues = property.ValidationAttributes.DeniedValues; + if (property.IgnoredEnumMembers.Count == 0 && !(deniedValues.ShouldProcess && deniedValues.Value.Exists)) + return string.Empty; + + List expressions = [.. property.IgnoredEnumMembers]; + var values = deniedValues.Value.Values; + for (var i = 0; i < values.Count; i++) + if (TryBuildTypedConstantExpression(property, values[i], out var expression)) + expressions.Add(expression); + + return $"new {property.PropertyType}[] {{{string.Join(", ", expressions)}}}"; + } + + /// + /// Builds the message expression: a [DeniedValues] attribute that configures its own message is + /// honoured, and otherwise the rule formats its own. + /// + /// The enum property the rule validates. + /// The local the rule instance is assigned to. + /// The local holding the property value. + /// The message expression. + static string GetEnumValidationMessageExpression( + ZodPropertyDescriptor property, + string ruleVariable, + string valueVariable + ) + { + var deniedValues = property.ValidationAttributes.DeniedValues; + var validationAttribute = deniedValues.Value.ValidationAttribute; + + if (deniedValues.ShouldProcess && deniedValues.Value.Exists && HasConfiguredMessage(validationAttribute)) + { + return BuildErrorMessageExpression( + validationAttribute, + "Field '{0}' is invalid.", + property.DisplayName.StringLiteral() + ); + } + + // The rule formats its own message, so the property name is not needed. + return $"{ruleVariable}.GetErrorMessage({valueVariable})"; + } + + static bool HasConfiguredMessage(ValidationAttributeData validationAttribute) => + !string.IsNullOrEmpty(validationAttribute.ErrorMessage) + || ( + !string.IsNullOrEmpty(validationAttribute.ErrorMessageResourceName) + && validationAttribute.ErrorMessageResourceType is not null + ); +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs index 3dff02e..c22a089 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs @@ -8,8 +8,17 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { - static void GenerateValueSetValidations(CodeWriter writer, ZodPropertyDescriptor property) + static void GenerateValueSetValidations( + CodeWriter writer, + ZodPropertyDescriptor property, + bool emitEnumRule = false + ) { + // The enum rule owns the value set: the values a [DeniedValues] attribute lists are absorbed into the + // rule's disallowed set, so the standalone validation is skipped to avoid reporting the value twice. + if (emitEnumRule) + return; + var allowedValues = property.ValidationAttributes.AllowedValues; var deniedValues = property.ValidationAttributes.DeniedValues; if ( diff --git a/src/src/ZodSharp/Rules/EnumRule.cs b/src/src/ZodSharp/Rules/EnumRule.cs new file mode 100644 index 0000000..928e4a6 --- /dev/null +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -0,0 +1,83 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that rejects values that are not a defined member of the enum type, or that resolve to a +/// member excluded from the allowed set. Equivalent to Zod's z.nativeEnum(Enum) semantics. +/// +/// The enum type the rule validates. +/// +/// +/// The source generator emits this rule automatically for every non-flags enum property of a +/// [ZodSchema] type, supplying the members excluded from the allowed set: enum members marked with +/// and the values a property's [DeniedValues] attribute lists. Apply +/// [ZodSchema(ValidateEnumValues = false)] to opt out. +/// +/// +/// The defined members are resolved once per closed generic type, so the validation path performs no +/// reflection. +/// +/// +[Core.ZodRule] +public readonly record struct EnumRule : Core.IValidationRule, Core.IZodRule + where TEnum : struct, Enum +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_enum_value"; + + /// Gets the message format; {0} is the offending value and {1} the enum name. + public const string MessageFormat = "'{0}' is not a defined member of {1}"; + + // Resolving the defined members once per closed generic type avoids the per-validation + // Enum.IsDefined reflection path. + static readonly HashSet DefinedValues = [.. Enum.GetValues()]; + + readonly TEnum[]? _disallowed; + readonly string _message; + readonly string _code; + + /// + /// Initializes a new instance of the struct that accepts every defined + /// member of . + /// + /// Optional error message + /// Optional error code override + public EnumRule(string? message = null, string? code = null) + : this(disallowed: null, message, code) { } + + /// + /// Initializes a new instance of the struct that accepts every defined + /// member of except the disallowed values. + /// + /// The defined members the rule rejects; or empty accepts every member. + /// Optional error message + /// Optional error code override + public EnumRule(TEnum[]? disallowed, string? message = null, string? code = null) + { + _disallowed = disallowed is { Length: > 0 } ? disallowed : null; + _message = message.Or(MessageFormat); + _code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is a defined member of the enum type and is not disallowed. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in TEnum value) => + DefinedValues.Contains(value) && (_disallowed is null || Array.IndexOf(_disallowed, value) < 0); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in TEnum value) => + RuleMessage.Format(_message ?? MessageFormat, value, typeof(TEnum).Name); + + /// Gets the error code reported when the rule fails. + public string Code => ErrorCode; + + string? Core.IZodRule.Code => _code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs index b568b87..5573e8f 100644 --- a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs @@ -20,7 +20,7 @@ public class ZodNativeEnum() : ZodType // Resolving the defined members once per closed generic type avoids the per-validation // Enum.IsDefined reflection path. - static readonly HashSet DefinedValues = [.. Enum.GetValues()]; + static readonly HashSet DefinedValues = [.. System.Enum.GetValues()]; /// /// Validates that the value is a defined enum member. @@ -37,4 +37,21 @@ protected override ValidationResult ParseInternal(TEnum value) => EmptyPath ) ); + + /// + /// Adds an that validates the value is a defined member of + /// , rejecting any member the rule excludes. + /// + /// Optional error message. + /// Optional error code override. + /// This schema for method chaining. + /// + /// The schema already rejects undefined members; this method mirrors the [Enum] attribute the + /// source generator emits for enum properties and lets the error message and code be customised. + /// + public ZodNativeEnum Enum(string? message = null, string? code = null) + { + AddRule(new Rules.EnumRule(message, code)); + return this; + } } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 337f4db..1c8ccd8 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -91,6 +91,8 @@ 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). +Enum properties are validated automatically: a value that is not a defined member of the enum type is rejected with `invalid_enum_value`. Mark an enum member `[ZodIgnore]` to exclude it everywhere the enum is validated, or list it in a property's `[DeniedValues]` to exclude it for that property only. `[Flags]` enums are skipped, and `[ZodSchema(ValidateEnumValues = false)]` opts the type out. + ## 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. diff --git a/src/src/ZodSharp/ZodIgnoreAttribute.cs b/src/src/ZodSharp/ZodIgnoreAttribute.cs new file mode 100644 index 0000000..e4e1b73 --- /dev/null +++ b/src/src/ZodSharp/ZodIgnoreAttribute.cs @@ -0,0 +1,36 @@ +namespace ZodSharp; + +/// +/// Marks an enum member as not a valid value, so the source generator's automatic enum validation rejects it. +/// +/// +/// +/// Apply to an enum member to exclude it from the allowed set everywhere that enum is validated by a +/// generated schema. A common case is an Unspecified-style member that is defined for wire +/// compatibility but is never a valid field value: +/// +/// +/// enum ExampleEnum +/// { +/// [ZodIgnore] +/// Unspecified, +/// +/// AValidValue, +/// +/// AnotherValidValue +/// } +/// +/// +/// The exclusion applies to the automatic enum validation the [ZodSchema] generator emits +/// ([ZodSchema(ValidateEnumValues = false)] opts out) and composes with a property's +/// [DeniedValues] attribute, whose values are excluded for that property only. +/// +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class ZodIgnoreAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public ZodIgnoreAttribute() { } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs new file mode 100644 index 0000000..859d136 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs @@ -0,0 +1,607 @@ +using System.Collections.Immutable; +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string EnumModel = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + public ExampleEnum Status { get; set; } + } + } + """; + + [Test] + public async Task EnumProperty_GivenNoAttributes_GeneratesEnumRuleValidation(CancellationToken cancellationToken) + { + // Arrange + const string source = EnumModel; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "var statusEnumRule = new global::ZodSharp.Rules.EnumRule()" + ); + await Assert.That(generated).ContainsGeneratedCode("!statusEnumRule.IsValid(statusEnumRuleValue)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "((global::ZodSharp.Core.IZodRule)statusEnumRule).Code ?? global::ZodSharp.Rules.EnumRule.ErrorCode" + ); + } + + [Test] + public async Task EnumProperty_GivenUndefinedValue_ReportsInvalidEnumValue(CancellationToken cancellationToken) + { + // Arrange + const string source = EnumModel; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var instance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(instance, Enum.ToObject(enumType, 999)); + + var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; + var errors = (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_enum_value"); + await Assert.That(errors[0].Path[0]).IsEqualTo("Status"); + } + + [Test] + public async Task EnumProperty_GivenDefinedValue_ReturnsSuccess(CancellationToken cancellationToken) + { + // Arrange + const string source = EnumModel; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var instance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(instance, Enum.ToObject(enumType, 1)); + + var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsTrue(); + } + + [Test] + public async Task EnumProperty_GivenValidateEnumValuesFalse_DoesNotGenerateEnumRuleValidation( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue + } + + [ZodSchema(ValidateEnumValues = false)] + public class Model + { + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert.That(generated).DoesNotContain("EnumRule", StringComparison.Ordinal); + await Assert.That(generated).DoesNotContain("invalid_enum_value", StringComparison.Ordinal); + } + + [Test] + public async Task EnumProperty_GivenFlagsEnum_DoesNotGenerateEnumRuleValidation(CancellationToken cancellationToken) + { + // Arrange - a flags combination is valid without being a defined member. + const string source = """ + using ZodSharp; + + namespace Testing + { + [System.Flags] + public enum ExampleFlags + { + None = 0, + + First = 1, + + Second = 2 + } + + [ZodSchema] + public class Model + { + public ExampleFlags Flags { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert.That(generated).DoesNotContain("EnumRule", StringComparison.Ordinal); + } + + [Test] + public async Task EnumProperty_GivenAllowedValues_DoesNotGenerateEnumRuleValidation( + CancellationToken cancellationToken + ) + { + // Arrange - the explicit allow-list governs the property. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + [AllowedValues(ExampleEnum.AValidValue, ExampleEnum.AnotherValidValue)] + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert.That(generated).DoesNotContain("EnumRule", StringComparison.Ordinal); + await Assert.That(generated).ContainsGeneratedCode("invalid_value"); + } + + [Test] + public async Task EnumProperty_GivenDeniedValues_AbsorbsThemIntoTheEnumRule(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + [DeniedValues(ExampleEnum.Unspecified)] + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert - the denied value is the rule's disallowed set, and no standalone check duplicates it. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.EnumRule(new global::Testing.ExampleEnum[] { (global::Testing.ExampleEnum)0 })" + ); + await Assert.That(generated).DoesNotContain("contains a denied value", StringComparison.Ordinal); + } + + [Test] + public async Task EnumProperty_GivenDeniedValue_ReportsASingleInvalidEnumValue(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + [DeniedValues(ExampleEnum.Unspecified)] + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var instance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(instance, Enum.ToObject(enumType, 0)); + + var result = schemaType.GetMethod("Validate")!.Invoke(null, [instance])!; + var errors = (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_enum_value"); + } + + [Test] + public async Task EnumProperty_GivenZodIgnoreMember_AbsorbsItIntoTheEnumRule(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + [ZodIgnore] + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.EnumRule(new global::Testing.ExampleEnum[] { global::Testing.ExampleEnum.Unspecified })" + ); + } + + [Test] + public async Task EnumProperty_GivenZodIgnoreMember_RejectsIt(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + [ZodIgnore] + Unspecified, + + AValidValue, + + AnotherValidValue + } + + [ZodSchema] + public class Model + { + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(validInstance, Enum.ToObject(enumType, 1)); + + var ignoredInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(ignoredInstance, Enum.ToObject(enumType, 0)); + + var validResult = schemaType.GetMethod("Validate")!.Invoke(null, [validInstance])!; + var ignoredResult = schemaType.GetMethod("Validate")!.Invoke(null, [ignoredInstance])!; + + // Assert - the member is defined, so only the ignored marker rejects it. + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + await Assert.That((bool)ignoredResult.GetType().GetProperty("IsSuccess")!.GetValue(ignoredResult)!).IsFalse(); + + var errors = + (ImmutableArray)ignoredResult.GetType().GetProperty("Errors")!.GetValue(ignoredResult)!; + await Assert.That(errors[0].Code).IsEqualTo("invalid_enum_value"); + } + + [Test] + public async Task EnumProperty_GivenNullableEnum_ValidatesOnlyWhenNotNull(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue + } + + [ZodSchema] + public class Model + { + public ExampleEnum? Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var nullInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(nullInstance, null); + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(invalidInstance, Enum.ToObject(enumType, 999)); + + var nullResult = schemaType.GetMethod("Validate")!.Invoke(null, [nullInstance])!; + var invalidResult = schemaType.GetMethod("Validate")!.Invoke(null, [invalidInstance])!; + + // Assert + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsTrue(); + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + } + + [Test] + public async Task EnumProperty_GivenZodIgnoreMemberInTheEnum_ReportsTheEnumRuleForEveryPropertyOfThatType( + CancellationToken cancellationToken + ) + { + // Arrange - the marker is on the enum, so every property of the type enforces it. + const string source = """ + using ZodSharp; + + namespace Testing + { + public enum ExampleEnum + { + [ZodIgnore] + Unspecified, + + AValidValue + } + + [ZodSchema] + public class Model + { + public ExampleEnum First { get; set; } + + public ExampleEnum Second { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ModelSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("var firstEnumRule"); + await Assert.That(generated).ContainsGeneratedCode("var secondEnumRule"); + } + + [Test] + public async Task EnumProperty_GivenExplicitEnumAttribute_ResolvesTheRuleAndValidates( + CancellationToken cancellationToken + ) + { + // Arrange - the automatic rule is off, so the explicit [Enum] attribute is what validates. The rule + // closes over the enum member type, which is what the constraint resolution has to accept. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue + } + + [ZodSchema(ValidateEnumValues = false)] + public class Model + { + [Enum(Message = "Not a valid option.")] + public ExampleEnum Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("ModelSchema"); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(invalidInstance, Enum.ToObject(enumType, 999)); + + var result = schemaType.GetMethod("Validate")!.Invoke(null, [invalidInstance])!; + var errors = (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.EnumRule("); + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_enum_value"); + await Assert.That(errors[0].Message).IsEqualTo("Not a valid option."); + } + + [Test] + public async Task EnumProperty_GivenExplicitEnumAttributeOnNullableEnum_ResolvesTheRule( + CancellationToken cancellationToken + ) + { + // Arrange - the explicit attribute must close the rule over the underlying enum type, matching the + // automatic path, so the nullable property is validated only when it is not null. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + public enum ExampleEnum + { + Unspecified, + + AValidValue + } + + [ZodSchema(ValidateEnumValues = false)] + public class Model + { + [Enum] + public ExampleEnum? Status { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.Model")!; + var schemaType = assembly.GetType("Testing.ModelSchema")!; + var enumType = assembly.GetType("Testing.ExampleEnum")!; + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Status")!.SetValue(invalidInstance, Enum.ToObject(enumType, 999)); + + var result = schemaType.GetMethod("Validate")!.Invoke(null, [invalidInstance])!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs new file mode 100644 index 0000000..af9886b --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using System.Text; +using ZodSharp.Schemas; + +namespace ZodSharp.Rules; + +public class EnumRuleTests +{ + enum Color + { + Red, + + Green, + + Blue, + } + + [Test] + public async Task IsValid_GivenDefinedMember_ReturnsTrue() + { + // Arrange + EnumRule rule = new(); + + // Act + var isValid = rule.IsValid(Color.Green); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenUndefinedMember_ReturnsFalse() + { + // Arrange + EnumRule rule = new(); + + // Act + var isValid = rule.IsValid((Color)999); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenDisallowedMember_ReturnsFalse() + { + // Arrange + EnumRule rule = new([Color.Blue]); + + // Act + var isValid = rule.IsValid(Color.Blue); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenOtherMemberWithDisallowedSet_ReturnsTrue() + { + // Arrange + EnumRule rule = new([Color.Blue]); + + // Act + var isValid = rule.IsValid(Color.Red); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenEmptyDisallowedSet_AcceptsEveryDefinedMember() + { + // Arrange + EnumRule rule = new([]); + + // Act + var isValid = rule.IsValid(Color.Blue); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenDisallowedUndefinedMember_RejectsIt() + { + // Arrange - a disallowed value is rejected whether or not it is a defined member. + EnumRule rule = new([(Color)999]); + + // Act + var isValid = rule.IsValid((Color)999); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task GetErrorMessage_GivenValue_FormatsTheValueAndEnumName() + { + // Arrange + EnumRule rule = new(); + + // Act + var message = rule.GetErrorMessage((Color)999); + var expected = string.Format( + CultureInfo.CurrentCulture, + CompositeFormat.Parse(EnumRule.MessageFormat), + (Color)999, + "Color" + ); + + // Assert + await Assert.That(message).IsEqualTo(expected); + } + + [Test] + public async Task Code_Always_ReportsInvalidEnumValue() + { + // Arrange + EnumRule rule = new(); + + // Act + var code = rule.Code; + + // Assert + await Assert.That(code).IsEqualTo(EnumRule.ErrorCode); + await Assert.That(EnumRule.ErrorCode).IsEqualTo("invalid_enum_value"); + } + + [Test] + public async Task GetErrorMessage_GivenCustomMessage_UsesIt() + { + // Arrange + EnumRule rule = new(message: "Custom message."); + + // Act + var message = rule.GetErrorMessage((Color)999); + + // Assert + await Assert.That(message).IsEqualTo("Custom message."); + } + + [Test] + public async Task Code_GivenOverride_ReportsIt() + { + // Arrange + EnumRule rule = new(code: "custom_code"); + + // Act + var code = ((Core.IZodRule)rule).Code; + + // Assert + await Assert.That(code).IsEqualTo("custom_code"); + } + + [Test] + public async Task Enum_GivenFluentNativeEnumSchema_KeepsTheConcreteSchema() + { + // Arrange + var schema = Z.Enum(); + + // Act + var chained = schema.Enum(); + var result = chained.Validate(Color.Red); + + // Assert - the extension returns the schema, so the fluent chain keeps its concrete type. + await Assert.That(chained).IsTypeOf>(); + await Assert.That(result.IsSuccess).IsTrue(); + } +} From 34060a02e7a65a63e0e94354a1a900c27cf055bd Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 10:23:59 +0100 Subject: [PATCH 17/26] refactor: made the rules ErrorCode vs. Code consistent --- docs/wiki/Custom-Rules.md | 2 +- docs/wiki/Validation-Rules-Reference.md | 2 +- src/src/ZodSharp/Core/IStringValidationRule.cs | 5 +++-- src/src/ZodSharp/Core/IValidationRule.cs | 5 +++-- src/src/ZodSharp/Core/IZodRule.cs | 6 ++++-- src/src/ZodSharp/Rules/Base64StringRule.cs | 11 +++++------ src/src/ZodSharp/Rules/Base64UrlRule.cs | 11 +++++------ src/src/ZodSharp/Rules/CreditCardRule.cs | 11 +++++------ src/src/ZodSharp/Rules/Cuid2Rule.cs | 11 +++++------ src/src/ZodSharp/Rules/DateStringRule.cs | 11 +++++------ src/src/ZodSharp/Rules/DatetimeStringRule.cs | 11 +++++------ src/src/ZodSharp/Rules/E164Rule.cs | 11 +++++------ src/src/ZodSharp/Rules/EmailRule.cs | 7 +++---- src/src/ZodSharp/Rules/EndsWithRule.cs | 11 +++++------ src/src/ZodSharp/Rules/EnumRule.cs | 15 +++++++-------- src/src/ZodSharp/Rules/EvenRule.cs | 11 +++++------ src/src/ZodSharp/Rules/FiniteRule.cs | 11 +++++------ .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 11 +++++------ src/src/ZodSharp/Rules/GreaterThanRule.cs | 11 +++++------ src/src/ZodSharp/Rules/HexRule.cs | 11 +++++------ src/src/ZodSharp/Rules/IPAddressRule.cs | 15 +++++++-------- src/src/ZodSharp/Rules/IncludesRule.cs | 11 +++++------ src/src/ZodSharp/Rules/IntRule.cs | 7 +++---- src/src/ZodSharp/Rules/JWTRule.cs | 11 +++++------ src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 11 +++++------ src/src/ZodSharp/Rules/LessThanRule.cs | 11 +++++------ src/src/ZodSharp/Rules/MaxLengthRule.cs | 11 +++++------ src/src/ZodSharp/Rules/MaxValueRule.cs | 11 +++++------ src/src/ZodSharp/Rules/MinLengthRule.cs | 11 +++++------ src/src/ZodSharp/Rules/MinValueRule.cs | 11 +++++------ src/src/ZodSharp/Rules/MultipleOfRule.cs | 11 +++++------ src/src/ZodSharp/Rules/NanoidRule.cs | 11 +++++------ src/src/ZodSharp/Rules/NonSentinelRule.cs | 11 +++++------ src/src/ZodSharp/Rules/OddRule.cs | 11 +++++------ src/src/ZodSharp/Rules/PhoneRule.cs | 11 +++++------ src/src/ZodSharp/Rules/RegexRule.cs | 15 +++++++-------- src/src/ZodSharp/Rules/SafeIntegerRule.cs | 11 +++++------ src/src/ZodSharp/Rules/StartsWithRule.cs | 11 +++++------ src/src/ZodSharp/Rules/TimeStringRule.cs | 11 +++++------ src/src/ZodSharp/Rules/ULIDRule.cs | 11 +++++------ src/src/ZodSharp/Rules/UriRule.cs | 11 +++++------ src/src/ZodSharp/Rules/UrlRule.cs | 11 +++++------ src/src/ZodSharp/Rules/UuidRule.cs | 17 ++++++++--------- .../ZodSharp.UnitTests/Rules/EnumRuleTests.cs | 4 +++- .../Rules/NumericRulesTests.cs | 10 ++++++---- 45 files changed, 216 insertions(+), 246 deletions(-) diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 74d5996..8676742 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -39,7 +39,7 @@ public readonly record struct EmailRule : IValidationRule } ``` -- **`ErrorCode`** is the rule's canonical code — the value a test compares against (`error.Code`). The interface member `IValidationRule.Code` defaults to `"validation_failed"`; built-in rules override it as `public string Code => ErrorCode;`. +- **`ErrorCode`** is the rule's canonical code — the value a test compares against when no per-usage override is supplied. The interface member `IValidationRule.Code` defaults to `"validation_failed"`; a rule that accepts a per-usage `code` override returns it (falling back to `ErrorCode`) from both `Code` and `IZodRule.Code`, so every route reports the same effective value. - **`MessageFormat`** is a `string.Format` template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments; format it with `string.Format(System.Globalization.CultureInfo.CurrentCulture, MessageFormat, …)`. - The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index feab72b..567c5aa 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -18,7 +18,7 @@ public interface IValidationRule ``` - String rules additionally implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so `ZodString.ValidateSpan`/`IsValidSpan` can validate without materialising the input. -- Every rule exposes its error identity as public constants: `public const string ErrorCode` and `public const string MessageFormat` (a `{0}`-style `string.Format` template). `Code` returns `ErrorCode`, and `GetErrorMessage` formats `MessageFormat` with `RuleMessage.Format` (which caches the parsed `CompositeFormat`). +- Every rule exposes its error identity as public constants: `public const string ErrorCode` and `public const string MessageFormat` (a `{0}`-style `string.Format` template). `Code` returns the rule's effective code — a per-usage `code` override when supplied, otherwise `ErrorCode` — and `GetErrorMessage` formats `MessageFormat` with `RuleMessage.Format` (which caches the parsed `CompositeFormat`). - A constructor `message` parameter, when supplied, overrides the formatted `MessageFormat`. `MessageFormat` is the fallback, not the only message. - The analyzer reports `ZODSGEN042` when a source-declared rule omits `ErrorCode`/`MessageFormat`. See [Source Generator Diagnostics](Source-Generator-Diagnostics.md). diff --git a/src/src/ZodSharp/Core/IStringValidationRule.cs b/src/src/ZodSharp/Core/IStringValidationRule.cs index 0d46d97..be29249 100644 --- a/src/src/ZodSharp/Core/IStringValidationRule.cs +++ b/src/src/ZodSharp/Core/IStringValidationRule.cs @@ -21,8 +21,9 @@ public interface IStringValidationRule string GetErrorMessage(ReadOnlySpan value); /// - /// Gets the Zod-compatible error code reported when the rule fails. Defaults to - /// "validation_failed". + /// Gets the Zod-compatible error code reported when the rule fails. A rule that accepts a per-usage + /// override returns it; otherwise the rule's canonical default is returned. Defaults to + /// "validation_failed" when the rule does not declare a code. /// string Code => "validation_failed"; } diff --git a/src/src/ZodSharp/Core/IValidationRule.cs b/src/src/ZodSharp/Core/IValidationRule.cs index b24aeb7..7fd73f7 100644 --- a/src/src/ZodSharp/Core/IValidationRule.cs +++ b/src/src/ZodSharp/Core/IValidationRule.cs @@ -22,8 +22,9 @@ public interface IValidationRule string GetErrorMessage(in T value); /// - /// Gets the Zod-compatible error code reported when the rule fails. Defaults to - /// "validation_failed". + /// Gets the Zod-compatible error code reported when the rule fails. A rule that accepts a per-usage + /// override returns it; otherwise the rule's canonical default is returned. Defaults to + /// "validation_failed" when the rule does not declare a code. /// string Code => "validation_failed"; } diff --git a/src/src/ZodSharp/Core/IZodRule.cs b/src/src/ZodSharp/Core/IZodRule.cs index e5d38d1..d98b963 100644 --- a/src/src/ZodSharp/Core/IZodRule.cs +++ b/src/src/ZodSharp/Core/IZodRule.cs @@ -22,8 +22,10 @@ namespace ZodSharp.Core; 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"). + /// Gets the effective error code for the constructed rule, or to defer to the + /// attribute-mapped code (and then to "validation_failed"). A rule that accepts a code + /// override typically returns it here and from its / + /// implementation, so every route reports the same value. /// string? Code { get; } diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 2ef9862..0dbec1c 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -15,17 +15,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid Base64 string format: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the Base64StringRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public Base64StringRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -57,9 +56,9 @@ public bool IsValid(in string value) public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index d62d7df..29cf908 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid base64url string: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the Base64UrlRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public Base64UrlRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -71,9 +70,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index b69b446..de5ca86 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -15,17 +15,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid credit card number format: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the CreditCardRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public CreditCardRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -95,9 +94,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs index 183bd03..8c8daad 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid CUID2: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the Cuid2Rule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public Cuid2Rule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -69,9 +68,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index 0565ace..2bd9024 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -16,17 +16,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid date: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the DateStringRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public DateStringRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -66,9 +65,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index 5868160..fdc1d2a 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -20,17 +20,16 @@ public readonly record struct DatetimeStringRule public const string MessageFormat = "Invalid date-time: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the DatetimeStringRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public DatetimeStringRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -123,9 +122,9 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 47f7a60..13bc568 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid E.164 phone number: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the E164Rule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public E164Rule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -73,9 +72,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 1ab0dd1..3b5bdfb 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -25,12 +25,11 @@ namespace ZodSharp.Rules; ); readonly string _message; - readonly string _code; public EmailRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -63,9 +62,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index 3d4709b..d62d791 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -16,15 +16,14 @@ namespace ZodSharp.Rules; readonly string _suffix; readonly StringComparison _comparison; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the EndsWithRule struct. /// /// The required suffix /// The string comparison type - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public EndsWithRule( string suffix, StringComparison comparison = StringComparison.Ordinal, @@ -35,7 +34,7 @@ public EndsWithRule( _suffix = suffix.OrNull() ?? throw new ArgumentNullException(nameof(suffix)); _comparison = comparison; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -68,9 +67,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _suffix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EnumRule.cs b/src/src/ZodSharp/Rules/EnumRule.cs index 928e4a6..633b166 100644 --- a/src/src/ZodSharp/Rules/EnumRule.cs +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -33,14 +33,13 @@ namespace ZodSharp.Rules; readonly TEnum[]? _disallowed; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the struct that accepts every defined /// member of . /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public EnumRule(string? message = null, string? code = null) : this(disallowed: null, message, code) { } @@ -49,13 +48,13 @@ public EnumRule(string? message = null, string? code = null) /// member of except the disallowed values. /// /// The defined members the rule rejects; or empty accepts every member. - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public EnumRule(TEnum[]? disallowed, string? message = null, string? code = null) { _disallowed = disallowed is { Length: > 0 } ? disallowed : null; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -75,9 +74,9 @@ public string GetErrorMessage(in TEnum value) => RuleMessage.Format(_message ?? MessageFormat, value, typeof(TEnum).Name); /// Gets the error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs index 2dfa856..0f7d264 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -18,17 +18,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Number must be even, but got {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the EvenRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public EvenRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -46,9 +45,9 @@ public EvenRule(string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index f2cb948..f1a2246 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -18,17 +18,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Number must be finite, but got {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the FiniteRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public FiniteRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -46,9 +45,9 @@ public FiniteRule(string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index 1b8a616..3e7a9f1 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -17,19 +17,18 @@ namespace ZodSharp.Rules; readonly T _minValue; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the GreaterThanOrEqualRule struct. /// /// The inclusive lower bound - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = null) { _minValue = minValue; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -47,9 +46,9 @@ public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index d0e73a8..537e115 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -18,19 +18,18 @@ namespace ZodSharp.Rules; readonly T _exclusiveMinimum; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the GreaterThanRule struct. /// /// The value the input must be strictly greater than - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public GreaterThanRule(T exclusiveMinimum, string? message = null, string? code = null) { _exclusiveMinimum = exclusiveMinimum; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -49,9 +48,9 @@ public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMinimum, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs index a0f3d3f..8973909 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid hexadecimal string: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the HexRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public HexRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -66,9 +65,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 4f633ab..4767a87 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -39,13 +39,12 @@ public enum IPAddressRuleType readonly IPAddressRuleType _ruleType; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the IPAddressRule struct with default rule type (Any). /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public IPAddressRule(string? message = null, string? code = null) : this(IPAddressRuleType.Any, message, code) { } @@ -53,8 +52,8 @@ public IPAddressRule(string? message = null, string? code = null) /// Initializes a new instance of the IPAddressRule struct. /// /// The type of IP address to validate against (defaults to ) - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public IPAddressRule( IPAddressRuleType ruleType = IPAddressRuleType.Any, string? message = null, @@ -63,7 +62,7 @@ public IPAddressRule( { _ruleType = ruleType; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -118,9 +117,9 @@ public string GetErrorMessage(ReadOnlySpan value) => ); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index 259ae76..aceb05b 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -15,19 +15,18 @@ namespace ZodSharp.Rules; readonly string _substring; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the IncludesRule struct. /// /// The required substring - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public IncludesRule(string substring, string? message = null, string? code = null) { _substring = substring ?? throw new ArgumentNullException(nameof(substring)); _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -60,9 +59,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _substring, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index f83c6a9..a31816c 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -18,12 +18,11 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Expected integer, but got {0}"; readonly string _message; - readonly string _code; public IntRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -45,9 +44,9 @@ public IntRule(string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index a8f0fa0..96066b6 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid JWT: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the JWTRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public JWTRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -89,9 +88,9 @@ static bool IsBase64Url(ReadOnlySpan segment) } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 166b644..99f096a 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -17,19 +17,18 @@ namespace ZodSharp.Rules; readonly T _maxValue; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the LessThanOrEqualRule struct. /// /// The inclusive upper bound - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public LessThanOrEqualRule(T maxValue, string? message = null, string? code = null) { _maxValue = maxValue; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -47,9 +46,9 @@ public LessThanOrEqualRule(T maxValue, string? message = null, string? code = nu public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index a33b205..6f03e0c 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -18,19 +18,18 @@ namespace ZodSharp.Rules; readonly T _exclusiveMaximum; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the LessThanRule struct. /// /// The value the input must be strictly less than - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public LessThanRule(T exclusiveMaximum, string? message = null, string? code = null) { _exclusiveMaximum = exclusiveMaximum; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -49,9 +48,9 @@ public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMaximum, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index c94c9ea..ff760ad 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -15,19 +15,18 @@ namespace ZodSharp.Rules; readonly int _maxLength; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the MaxLengthRule struct. /// /// The maximum length - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public MaxLengthRule(int maxLength, string? message = null, string? code = null) { _maxLength = maxLength; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -61,9 +60,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _maxLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index c2d451b..2b3541a 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -17,19 +17,18 @@ namespace ZodSharp.Rules; readonly T _maxValue; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the MaxValueRule struct. /// /// The maximum value - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public MaxValueRule(T maxValue, string? message = null, string? code = null) { _maxValue = maxValue; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -47,9 +46,9 @@ public MaxValueRule(T maxValue, string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 4311e03..df55147 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -15,19 +15,18 @@ namespace ZodSharp.Rules; readonly int _minLength; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the MinLengthRule struct. /// /// The minimum length - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public MinLengthRule(int minLength, string? message = null, string? code = null) { _minLength = minLength; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -61,9 +60,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _minLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 6ed166b..39efe6b 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -17,19 +17,18 @@ namespace ZodSharp.Rules; readonly T _minValue; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the MinValueRule struct. /// /// The minimum value - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public MinValueRule(T minValue, string? message = null, string? code = null) { _minValue = minValue; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -47,9 +46,9 @@ public MinValueRule(T minValue, string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index b883932..0c84dea 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -24,14 +24,13 @@ namespace ZodSharp.Rules; readonly T _divisor; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the MultipleOfRule struct. /// /// The divisor - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public MultipleOfRule(T divisor, string? message = null, string? code = null) { if (divisor == T.Zero) @@ -39,7 +38,7 @@ public MultipleOfRule(T divisor, string? message = null, string? code = null) _divisor = divisor; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -72,9 +71,9 @@ public bool IsValid(in T value) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _divisor, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs index 9c84866..e6fa8b7 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid nanoid: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the NanoidRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public NanoidRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -69,9 +68,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 57c2424..0440c74 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -36,17 +36,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value is a sentinel value, but got {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public NonSentinelRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -64,9 +63,9 @@ public NonSentinelRule(string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs index f22e350..a5073bf 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -18,17 +18,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Number must be odd, but got {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the OddRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public OddRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -46,9 +45,9 @@ public OddRule(string? message = null, string? code = null) public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index 4541dd2..de28329 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -16,17 +16,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid phone number format: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the PhoneRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public PhoneRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -82,9 +81,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 6a1660d..828c461 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -17,27 +17,26 @@ namespace ZodSharp.Rules; readonly Regex _pattern; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the RegexRule struct. /// /// The regex pattern - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public RegexRule(Regex pattern, string? message = null, string? code = null) { _pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// /// Initializes a new instance of the RegexRule struct. /// /// The regex pattern - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public RegexRule(string pattern, string? message = null, string? code = null) : this(new Regex(pattern, RegexOptions.Compiled), message, code) { } @@ -70,9 +69,9 @@ public RegexRule(string pattern, string? message = null, string? code = null) public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessage(value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index f4797b2..ef9a879 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Number must be a safe integer, but got {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the SafeIntegerRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public SafeIntegerRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -43,9 +42,9 @@ public bool IsValid(in double value) => public string GetErrorMessage(in double value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 92464e5..1be1fef 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -16,15 +16,14 @@ namespace ZodSharp.Rules; readonly string _prefix; readonly StringComparison _comparison; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the StartsWithRule struct. /// /// The required prefix /// The string comparison type - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public StartsWithRule( string prefix, StringComparison comparison = StringComparison.Ordinal, @@ -35,7 +34,7 @@ public StartsWithRule( _prefix = prefix.OrNull() ?? throw new ArgumentNullException(nameof(prefix)); _comparison = comparison; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -68,9 +67,9 @@ public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _prefix, value.ToString()); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 636df93..6d1e8e6 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -15,17 +15,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid time: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the TimeStringRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public TimeStringRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -105,9 +104,9 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi } /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index f821352..8116a69 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -14,17 +14,16 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Invalid ULID: {0}"; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the ULIDRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public ULIDRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -90,9 +89,9 @@ static bool IsCrockfordBase32(char c) => }; /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index ac13bdc..e1a661d 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -15,19 +15,18 @@ namespace ZodSharp.Rules; readonly UriKind _uriKind; readonly string _message; - readonly string _code; /// /// Initializes a new instance of the UriRule struct. /// /// The uri kind to validate against - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public UriRule(UriKind uriKind, string? message = null, string? code = null) { _uriKind = uriKind; _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -46,9 +45,9 @@ public bool IsValid(in string value) => public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _uriKind, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index ce1674a..7faa2d6 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -27,17 +27,16 @@ namespace ZodSharp.Rules; ); readonly string _message; - readonly string _code; /// /// Initializes a new instance of the UrlRule struct. /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public UrlRule(string? message = null, string? code = null) { _message = message.Or(MessageFormat); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -63,9 +62,9 @@ public bool IsValid(in string value) => public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index d739c8a..f901334 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -21,27 +21,26 @@ namespace ZodSharp.Rules; readonly UuidVersion? _version; readonly string? _message; - readonly string _code; /// /// Initializes a new instance of the UuidRule struct with Zod-parity semantics /// (version 1-8, variant 8-9/a-b, plus the nil and max UUIDs). /// - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public UUIDRule(string? message = null, string? code = null) { _version = null; _message = message.OrNull(); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// /// Initializes a new instance of the UuidRule struct that requires a specific RFC 9562 version. /// /// The required UUID version - /// Optional error message - /// Optional error code override + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. public UUIDRule(UuidVersion version, string? message = null, string? code = null) { if (version == UuidVersion.None) @@ -49,7 +48,7 @@ public UUIDRule(UuidVersion version, string? message = null, string? code = null _version = version; _message = message.OrNull(); - _code = code.Or(ErrorCode); + Code = code.Or(ErrorCode); } /// @@ -140,9 +139,9 @@ static bool HasValidStructure(ReadOnlySpan value) static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; /// Gets the Zod-compatible error code reported when the rule fails. - public string Code => ErrorCode; + public string Code => field.Or(ErrorCode); - string? Core.IZodRule.Code => _code; + string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; } diff --git a/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs index af9886b..19864c4 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/EnumRuleTests.cs @@ -146,10 +146,12 @@ public async Task Code_GivenOverride_ReportsIt() EnumRule rule = new(code: "custom_code"); // Act - var code = ((Core.IZodRule)rule).Code; + var code = rule.Code; + var interfaceCode = ((Core.IZodRule)rule).Code; // Assert await Assert.That(code).IsEqualTo("custom_code"); + await Assert.That(interfaceCode).IsEqualTo("custom_code"); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs index 26f089e..7fb5dfe 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NumericRulesTests.cs @@ -283,17 +283,19 @@ public async Task EvenRule_GivenMessage_OverridesTheFormattedMessage() } [Test] - public async Task Rule_GivenCodeOverride_ReportsItThroughIZodRule() + public async Task Rule_GivenCodeOverride_ReportsItThroughCodeAndIZodRule() { // Arrange MinLengthRule rule = new(3, code: "too_short"); // Act - var code = ((IZodRule)rule).Code; + var code = rule.Code; + var interfaceCode = ((IZodRule)rule).Code; - // Assert - the canonical constant is unchanged, the per-usage override is reported at runtime. - await Assert.That(rule.Code).IsEqualTo(MinLengthRule.ErrorCode); + // Assert - the per-usage override is the effective code on every route; the canonical constant is unchanged. await Assert.That(code).IsEqualTo("too_short"); + await Assert.That(interfaceCode).IsEqualTo("too_short"); + await Assert.That(MinLengthRule.ErrorCode).IsEqualTo("too_small"); } [Test] From 118b0fcfc4cd6ce9b6ffd805fa47563d6b68877c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 14:32:53 +0100 Subject: [PATCH 18/26] feat: added additional/ extra explicit rules --- docs/wiki/Custom-Rules.md | 4 +- docs/wiki/Source-Generator-DataAnnotations.md | 2 +- docs/wiki/Validation-Rules-Reference.md | 52 +- .../Helpers/CustomRuleResolver.cs | 56 +- .../Models/RuleAttributeGenerationModel.cs | 20 +- .../ZodSchemaGenerator.RuleAttributes.cs | 62 +- src/src/ZodSharp/Core/ZodType.cs | 27 + .../Extensions/ZodSharp/ZExtensions.cs | 49 + src/src/ZodSharp/Rules/AllowedValuesRule.cs | 68 + src/src/ZodSharp/Rules/CidrRule.cs | 144 ++ src/src/ZodSharp/Rules/CompareRule.cs | 80 ++ src/src/ZodSharp/Rules/DeniedValuesRule.cs | 68 + src/src/ZodSharp/Rules/DurationRule.cs | 76 ++ src/src/ZodSharp/Rules/EmojiRule.cs | 1199 +++++++++++++++++ src/src/ZodSharp/Rules/GuidRule.cs | 87 ++ src/src/ZodSharp/Rules/IPAddressRule.cs | 15 +- src/src/ZodSharp/Rules/KsuidRule.cs | 78 ++ src/src/ZodSharp/Rules/LengthRule.cs | 79 ++ src/src/ZodSharp/Rules/RangeRule.cs | 87 ++ src/src/ZodSharp/Rules/RequiredRule.cs | 95 ++ src/src/ZodSharp/Rules/RuleMessage.cs | 12 + src/src/ZodSharp/Rules/StringLengthRule.cs | 79 ++ src/src/ZodSharp/Rules/XidRule.cs | 78 ++ src/src/ZodSharp/Schemas/ZodBigInt.cs | 23 + src/src/ZodSharp/Schemas/ZodDate.cs | 23 + src/src/ZodSharp/Schemas/ZodEnum.cs | 2 +- src/src/ZodSharp/Schemas/ZodNumber.cs | 23 + src/src/ZodSharp/Schemas/ZodString.cs | 155 +++ ...emaGeneratorTests.BuiltInRuleAttributes.cs | 141 ++ .../Rules/DataAnnotationRulesTests.cs | 196 +++ .../Rules/GeneratedRuleAttributesTests.cs | 44 + .../Rules/RequiredRuleTests.cs | 172 +++ .../Rules/StringFormatRulesTests.cs | 203 +++ 33 files changed, 3471 insertions(+), 28 deletions(-) create mode 100644 src/src/ZodSharp/Extensions/ZodSharp/ZExtensions.cs create mode 100644 src/src/ZodSharp/Rules/AllowedValuesRule.cs create mode 100644 src/src/ZodSharp/Rules/CidrRule.cs create mode 100644 src/src/ZodSharp/Rules/CompareRule.cs create mode 100644 src/src/ZodSharp/Rules/DeniedValuesRule.cs create mode 100644 src/src/ZodSharp/Rules/DurationRule.cs create mode 100644 src/src/ZodSharp/Rules/EmojiRule.cs create mode 100644 src/src/ZodSharp/Rules/GuidRule.cs create mode 100644 src/src/ZodSharp/Rules/KsuidRule.cs create mode 100644 src/src/ZodSharp/Rules/LengthRule.cs create mode 100644 src/src/ZodSharp/Rules/RangeRule.cs create mode 100644 src/src/ZodSharp/Rules/RequiredRule.cs create mode 100644 src/src/ZodSharp/Rules/StringLengthRule.cs create mode 100644 src/src/ZodSharp/Rules/XidRule.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/DataAnnotationRulesTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/StringFormatRulesTests.cs diff --git a/docs/wiki/Custom-Rules.md b/docs/wiki/Custom-Rules.md index 8676742..464d2f6 100644 --- a/docs/wiki/Custom-Rules.md +++ b/docs/wiki/Custom-Rules.md @@ -493,11 +493,11 @@ public partial class Contact } ``` -Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). A rule with overloaded constructors mirrors each overload, so `[UUID]` uses the versionless overload and `[UUID(UuidVersion.V4)]` (or `[UUID(Version = UuidVersion.V4)]`) the versioned one. `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). +Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). A rule with overloaded constructors mirrors each overload, so `[UUID]` uses the versionless overload and `[UUID(UuidVersion.V4)]` (or `[UUID(Version = UuidVersion.V4)]`) the versioned one. `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). A type-parameter value is surfaced as a `double`; an array of type parameters (for example the allowed values of `AllowedValuesRule`) is surfaced as a `params object[]`, with each element converted back to the member type at the usage site, so `[AllowedValuesZod("a", "b", "c")]` works on a `string` member and `[DeniedValuesZod(1, 2, 3)]` on an `int` member. Two adjustments keep every rule addressable: -- **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, and `Base64StringRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute. +- **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule`, `RequiredRule`, `RangeRule`, `LengthRule`, `StringLengthRule`, `CompareRule`, `AllowedValuesRule`, and `DeniedValuesRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute. - **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(…)]`, `[LessThanOrEqual(…)]`, `[Even]`, and `[Odd]`. diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 5504d18..e4c2975 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -94,4 +94,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full 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). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, and `[Enum]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 567c5aa..403b269 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -62,8 +62,12 @@ The built-in rules report one of the following Zod-compatible codes: | `too_big` | A length or value exceeded an upper bound | `MaxLengthRule`, `MaxValueRule`, `LessThanRule`, `SafeIntegerRule` | | `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | | `not_finite` | A number was `NaN` or infinite | `FiniteRule` | -| `invalid_value` | A value was a rejected sentinel | `NonSentinelRule` | +| `invalid_value` | A value was a rejected sentinel or was not in the allowed set | `NonSentinelRule`, `AllowedValuesRule`, `DeniedValuesRule` | | `invalid_enum_value` | A value was not a defined member of the enum type, or was an excluded member | `EnumRule` | +| `missing_field` | A required value was absent (or empty/whitespace-only) | `RequiredRule` | +| `invalid_length` | A string length fell outside the required range | `LengthRule`, `StringLengthRule` | +| `invalid_range` | A comparable value fell outside the required range | `RangeRule` | +| `mismatch` | A value did not equal the expected value | `CompareRule` | | `validation_failed` | Fallback for rules that do not declare an identity | any rule without `ErrorCode`/`IZodRule` | ## String rules @@ -105,6 +109,12 @@ Fluent methods live on `ZodString` (`ZodSharp.Schemas`). The `Span` column marks | `StartsWithRule` | `(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWith(prefix, comparison)` | `invalid_string` | `String must start with '{0}', but got '{1}'` | yes | | `EndsWithRule` | `(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWith(suffix, comparison)` | `invalid_string` | `String must end with '{0}', but got '{1}'` | yes | | `IncludesRule` | `(string substring, string? message, string? code)` | `Includes(substring)` | `invalid_string` | `String must contain '{0}', but got '{1}'` | yes | +| `EmojiRule` | `(string? message)` | `Emoji()` | `invalid_string` | `Invalid emoji: {0}` | yes | +| `XidRule` | `(string? message)` | `Xid()` | `invalid_string` | `Invalid XID: {0}` | yes | +| `KsuidRule` | `(string? message)` | `Ksuid()` | `invalid_string` | `Invalid KSUID: {0}` | yes | +| `DurationRule` | `(string? message)` | `Duration()` | `invalid_string` | `Invalid duration: {0}` | yes | +| `GuidRule` | `(string? message)` | `Guid()` | `invalid_string` | `Invalid GUID: {0}` | yes | +| `CidrRule` | `(CidrRuleType ruleType, string? message, string? code)` | `Cidr()` / `Cidr(ruleType)` | `invalid_string` | `Invalid {1} CIDR: {0}` | yes | Every rule constructor also accepts an optional `string? code = null` after `message`; when set it overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). @@ -127,6 +137,11 @@ Behaviour notes: - **`DateStringRule`** parses `yyyy-MM-dd` (invariant culture); **`TimeStringRule`** accepts `HH:mm`, optionally `:ss` and fractional seconds; **`DatetimeStringRule`** requires `yyyy-MM-dd` + `T` + `HH:mm:ss[.fff]` + `Z`. - **`NanoidRule`** requires exactly 21 URL-safe characters; **`Cuid2Rule`** requires non-empty lowercase alphanumerics. - **`StartsWithRule`** and **`EndsWithRule`** use the supplied `StringComparison` (default `Ordinal`); **`IncludesRule`** uses ordinal comparison. +- **`EmojiRule`** mirrors Zod's `emoji()`: every rune must be an `Extended_Pictographic` or `Emoji_Component` code point, and at least one must be an anchor (a pictograph, a regional indicator, or a keycap). The Unicode tables are generated from Unicode 16.0 `emoji-data.txt`, because .NET's regex engine does not expose the `Extended_Pictographic` property. +- **`XidRule`** requires exactly 20 base32hex characters (`0-9`, `a-v`/`A-V`); **`KsuidRule`** requires exactly 27 base62 characters. +- **`DurationRule`** accepts ISO 8601-1 durations; the 8601-2 extensions (negative durations and fractional/negative components) are rejected, matching Zod. +- **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. +- **`CidrRule`** accepts an IPv4 or IPv6 block; pass a `CidrRuleType` to require `IPv4` or `IPv6` (`Any` is the default). ## Number rules @@ -179,6 +194,24 @@ var chained = Z.String().NonSentinel().Min(3); The same rule can be attached directly with `AddRule`/`Rule`, or through the shipped `[NonSentinel]` attribute, which closes the open generic with the annotated member/scalar type — see [Custom Rules](Custom-Rules.md#non-sentinel-values-ef-friendly). +## DataAnnotations-mirroring rules + +These rules mirror `System.ComponentModel.DataAnnotations` attributes as first-class rules, so the same behaviour is available standalone, through the fluent API, or through a generated attribute: + +| Rule | Constructor | Fluent method | Code | Message format | +|---|---|---|---|---| +| `RequiredRule` | `(bool allowEmptyString = false, bool trimWhitespace = false, string? message, string? code)` | `ZodString.Required(...)` | `missing_field` | `Field is required` | +| `RangeRule` | `(T minimum, T maximum, bool minimumIsExclusive = false, bool maximumIsExclusive = false, string? message, string? code)` | `ZodNumber.Range(...)`, `ZodBigInt.Range(...)`, `ZodDate.Range(...)` | `invalid_range` | `Value must be between {0} and {1}, but got {2}` | +| `LengthRule` | `(int minimum, int maximum, string? message, string? code)` | `ZodString.Length(min, max)` | `invalid_length` | `String length must be between {0} and {1} characters, but got {2}` | +| `StringLengthRule` | `(int maximumLength, int minimumLength = 0, string? message, string? code)` | `ZodString.StringLength(max, min)` | `invalid_length` | `String must be at most {0} characters long and at least {1}, but got {2}` | +| `CompareRule` | `(string other, StringComparison comparison = Ordinal, string? message, string? code)` | `ZodString.Compare(...)` | `mismatch` | `Value must match '{0}', but got '{1}'` | +| `AllowedValuesRule` | `(T[] values, string? message, string? code)` | `AllowedValues(values)` | `invalid_value` | `Value '{0}' is not one of the allowed values` | +| `DeniedValuesRule` | `(T[] values, string? message, string? code)` | `DeniedValues(values)` | `invalid_value` | `Value '{0}' is one of the denied values` | + +`RequiredRule` always rejects `null`. When `allowEmptyString` is `false` an empty string is rejected as well; `trimWhitespace` additionally treats a whitespace-only string as empty (leading/trailing whitespace on a non-empty value is preserved). The `ZodString.Required` fluent method is a covariant convenience over the generic rule. + +`AllowedValuesRule` and `DeniedValuesRule` accept both a single value (`new AllowedValuesRule(5)`) and an array. The generated `[AllowedValuesZod]` / `[DeniedValuesZod]` attributes surface the array as a `params object[]` (plus a single-value overload), so `[AllowedValuesZod("a", "b", "c")]` works on a `string` member and `[DeniedValuesZod(1, 2, 3)]` on an `int` member; the resolver converts each element to the member type. + ## Enum rules | Rule | Constructor | Fluent method | Code | Message format | @@ -262,12 +295,25 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[PhoneZod]` | `PhoneRule` | as above | | `[CreditCardZod]` | `CreditCardRule` | as above | | `[Base64StringZod]` | `Base64StringRule` | as above | +| `[Emoji]` | `EmojiRule` | | +| `[Xid]` | `XidRule` | | +| `[Ksuid]` | `KsuidRule` | | +| `[Duration]` | `DurationRule` | | +| `[Guid]` | `GuidRule` | accepts any 8-4-4-4-12 hex identifier | +| `[Cidr]` / `[Cidr(CidrRuleType.…)]` | `CidrRule` | defaults to `Any`; pass a type to require IPv4/IPv6 | +| `[RequiredZod(…)]` | `RequiredRule` | suffixed; exposes `AllowEmptyString`/`TrimWhitespace` | +| `[RangeZod(…)]` | `RangeRule` | the type-parameter bounds are doubles; the rule closes with the member type | +| `[LengthZod(…)]` | `LengthRule` | suffixed to avoid the DataAnnotations name | +| `[StringLengthZod(…)]` | `StringLengthRule` | as above | +| `[CompareZod(…)]` | `CompareRule` | as above | +| `[AllowedValuesZod(…)]` | `AllowedValuesRule` | a `params object[]` array (and a single-value overload); the rule closes with the member type | +| `[DeniedValuesZod(…)]` | `DeniedValuesRule` | a `params object[]` array (and a single-value overload); the rule closes with the member type | A generated attribute's constructor mirrors the rule's value parameters: a parameter the rule declares without a default becomes a required constructor argument, and a parameter with a default keeps that default (so an optional value such as `[StartsWith("…", StringComparison.OrdinalIgnoreCase)]` still has a usable default). The value parameters are also exposed as settable properties. The rule's `message` and identity (`code`/`origin`) parameters stay properties only — `Message` and, where present, `Code` — and default to the rule's own `MessageFormat`/`ErrorCode`, so the attribute is self-describing. Each generated attribute reports the rule's own `ErrorCode`/`Origin` because every built-in rule implements `IZodRule`. -Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). +Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule`, `RequiredRule`, `RangeRule`, `LengthRule`, `StringLengthRule`, `CompareRule`, `AllowedValuesRule`, `DeniedValuesRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`, `[RequiredZod]`, `[RangeZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). ## Exposed as DataAnnotations attributes @@ -282,7 +328,7 @@ The `[ZodSchema]` generator maps several built-in rules to their `System.Compone | `[Base64String]` | `Base64StringRule` | `invalid_string` | | `[RegularExpression]` | compiled `Regex`, not `RegexRule` | `invalid_string` | -Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen rather than as rule structs. The rule-identity variants of the colliding names are the suffixed [generated attributes](#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex("…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. +Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen; the equivalent rule structs (`LengthRule`, `StringLengthRule`, `MinLengthRule`, `MaxLengthRule`, `RangeRule`) are also available directly and through their generated attributes. The rule-identity variants of the colliding names are the suffixed [generated attributes](#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex("…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](Source-Generator-DataAnnotations.md) for the full attribute table and the generated metadata, and [Custom Rules](Custom-Rules.md) for mapping your own rules to attributes. ## Related diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 05208ec..7d6dc66 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -806,13 +806,24 @@ internal static bool IsCancellationToken(ITypeSymbol type) => internal static bool IsSupportedAttributePropertyType(ITypeSymbol type) { var unwrapped = TypeHelpers.UnwrapNullableType(type); + + // A type parameter is surfaced as a double on the generated attribute (see the attribute builder), so + // it is addressable even though the parameter itself is not an attribute-argument type. + if (unwrapped.TypeKind == TypeKind.TypeParameter) + return true; + + // A one-dimensional array of a supported type is a legal attribute argument. An array of a type + // parameter is surfaced as object[] by the attribute builder, so this also covers AllowedValuesRule. + if (unwrapped is IArrayTypeSymbol { Rank: 1 } array) + return IsSupportedAttributePropertyType(array.ElementType); + if (unwrapped is INamedTypeSymbol { TypeKind: TypeKind.Enum }) return true; if (unwrapped.ToDisplayString() == "System.Type") return true; - // Only primitive types and string are supported as attribute properties. + // Only primitive types, object and string are supported as attribute properties. return unwrapped.SpecialType is SpecialType.System_Boolean or SpecialType.System_Byte @@ -826,6 +837,7 @@ or SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_Single or SpecialType.System_Double + or SpecialType.System_Object or SpecialType.System_String; } @@ -1155,6 +1167,9 @@ static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, o return CanPassNull(targetType); } + if (constant.Kind == TypedConstantKind.Array) + return TryConvertArrayConstant(constant, targetType, out expression); + if (constant.Kind == TypedConstantKind.Type) { if (constant.Value is ITypeSymbol typeSymbol) @@ -1170,6 +1185,45 @@ static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, o return TryConvertValue(constant.Value, targetType, out expression); } + /// + /// Converts an array attribute argument (for example the allowed/denied values of + /// AllowedValuesRule<T>) into a typed array expression, converting each element to the target + /// array's element type so an object[] attribute argument can be closed with the member type. + /// + /// The array constant supplied by the applied attribute. + /// The rule constructor parameter type the array is passed to. + /// The generated array expression when the conversion succeeds. + /// when every element converts to the target element type. + static bool TryConvertArrayConstant(TypedConstant constant, ITypeSymbol targetType, out string expression) + { + expression = string.Empty; + + if (TypeHelpers.UnwrapNullableType(targetType) is not IArrayTypeSymbol { Rank: 1 } arrayType) + return false; + + if (!TypeReference.TryCreate(arrayType.ElementType, out var elementReference)) + return false; + + var elements = constant.Values; + if (elements.IsDefaultOrEmpty) + { + expression = $"new {elementReference.RenderFullName}[0]"; + return true; + } + + var converted = ImmutableArray.CreateBuilder(elements.Length); + foreach (var element in elements) + { + if (!TryConvertConstant(element, arrayType.ElementType, out var elementExpression)) + return false; + + converted.Add(elementExpression); + } + + expression = $"new {elementReference.RenderFullName}[] {{ {string.Join(", ", converted)} }}"; + return true; + } + internal static bool TryConvertValue(object? value, ITypeSymbol targetType, out string expression) { if (value is null) diff --git a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs index 334c3cc..50ff3c8 100644 --- a/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs +++ b/src/src/SourceGenerators/Models/RuleAttributeGenerationModel.cs @@ -46,17 +46,22 @@ readonly record struct RuleAttributeGenerationModel( /// /// Describes a settable property on a generated validation attribute. /// -/// The property type. +/// The property type. A TypeReference so an array parameter can be surfaced. /// 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); +readonly record struct GeneratedAttributeProperty( + TypeReference Type, + string Name, + string? Initializer, + bool IsNullable +); /// /// Describes a constructor parameter on a generated validation attribute that carries a rule's constructor /// value. /// -/// The parameter type. +/// The parameter type. A TypeReference so an array parameter can be surfaced. /// The parameter name, taken from the rule's constructor parameter. /// The generated property the parameter assigns. /// @@ -64,10 +69,15 @@ readonly record struct RuleAttributeGenerationModel( /// required and must be supplied at the attribute's usage site. /// /// Whether the parameter type is nullable. +/// +/// Whether the parameter may be emitted as a params array. Only ever applied to the final parameter, +/// so an array-valued rule parameter stays ergonomic at the attribute's usage site. +/// readonly record struct GeneratedAttributeParameter( - TypeIdentity Type, + TypeReference Type, string Name, string PropertyName, string? DefaultValue, - bool IsNullable + bool IsNullable, + bool IsParams = false ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs index f442a43..014cd3f 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.RuleAttributes.cs @@ -187,9 +187,16 @@ out var failureDiagnostics if (messageParameter is not null && propertyNames.Add("Message")) { + var messageType = TypeReference.TryCreate( + TypeHelpers.StripNullableAnnotations(messageParameter.Type), + out var messageReference + ) + ? messageReference + : new TypeIdentity(SpecialType.System_String).AsTypeReference(); + properties.Add( new GeneratedAttributeProperty( - new TypeIdentity(TypeHelpers.StripNullableAnnotations(messageParameter.Type)), + messageType, "Message", BuildMessageInitializer(messageParameter, ruleType), TypeHelpers.CanBeNull(messageParameter.Type) @@ -259,11 +266,16 @@ out ImmutableArray failureDiagnostics // A type-parameter constructor argument (for example `MinValueRule(T minValue)`) cannot be // mirrored directly; it is surfaced as a double, the numeric type the schema pipeline uses and the - // type a `[MinValue]`-style attribute can carry. + // type a `[MinValue]`-style attribute can carry. An array of type parameters (for example the + // allowed values of `AllowedValuesRule`) is surfaced as `object[]`, so one attribute serves any + // member type and the resolver converts each element back to that type. var propertyType = parameter.Type.TypeKind == TypeKind.TypeParameter ? compilation.GetSpecialType(SpecialType.System_Double) - : parameter.Type; + : parameter.Type is IArrayTypeSymbol { Rank: 1 } array + && array.ElementType.TypeKind == TypeKind.TypeParameter + ? compilation.CreateArrayTypeSymbol(compilation.GetSpecialType(SpecialType.System_Object)) + : parameter.Type; if (!CustomRuleResolver.IsSupportedAttributePropertyType(propertyType)) { @@ -279,17 +291,38 @@ out ImmutableArray failureDiagnostics return false; } - TypeIdentity propertyTypeIdentity = new(TypeHelpers.StripNullableAnnotations(propertyType)); + if ( + !TypeReference.TryCreate( + TypeHelpers.StripNullableAnnotations(propertyType), + out var propertyTypeReference + ) + ) + { + failureDiagnostics = + [ + ReportUnsupportedRuleAttributeGeneration( + ruleType, + $"constructor parameter '{parameter.Name}' has type '{parameter.Type.ToDisplayString()}', which cannot be represented as an attribute property" + ), + .. identityWarnings, + ]; + + return false; + } + + // An array parameter is emitted as a params array and is never nullable, so the generated attribute + // stays ergonomic and cannot be handed a null array. + var isArray = propertyType is IArrayTypeSymbol; var propertyName = ToPascalCase(parameter.Name); var initializer = BuildPropertyInitializer(parameter, propertyType, ruleType, isIdentityParameter); - var isNullable = TypeHelpers.CanBeNull(propertyType); + var isNullable = !isArray && TypeHelpers.CanBeNull(propertyType); // The widest overload is visited first, so its property type and initializer win when an overload // pair shares a parameter. if (propertyNames.Add(propertyName)) { properties.Add( - new GeneratedAttributeProperty(propertyTypeIdentity, propertyName, initializer, isNullable) + new GeneratedAttributeProperty(propertyTypeReference, propertyName, initializer, isNullable) ); } @@ -305,11 +338,12 @@ out ImmutableArray failureDiagnostics constructorParameters.Add( new GeneratedAttributeParameter( - propertyTypeIdentity, + propertyTypeReference, parameter.Name, propertyName, parameter.HasExplicitDefaultValue ? initializer : null, - isNullable + isNullable, + isArray ) ); } @@ -732,9 +766,7 @@ static void BuildRuleAttribute(CodeWriter writer, RuleAttributeGenerationModel m foreach (var property in model.Properties) { - var propertyType = property.IsNullable - ? property.Type.AsTypeReference().Nullable(writer) - : property.Type.AsTypeReference(); + var propertyType = property.IsNullable ? property.Type.Nullable(writer) : property.Type; writer.Property( new PropertyDeclarationOptions(property.Name, propertyType, TypeDeclarationAccessibility.Public) @@ -769,16 +801,18 @@ GeneratedAttributeConstructor constructor var parameters = ImmutableArray.CreateBuilder(constructor.Parameters.Count); - foreach (var constructorParameter in constructor.Parameters) + for (var i = 0; i < constructor.Parameters.Count; i++) { + var constructorParameter = constructor.Parameters[i]; var parameterType = constructorParameter.IsNullable - ? constructorParameter.Type.AsTypeReference().Nullable(writer) - : constructorParameter.Type.AsTypeReference(); + ? constructorParameter.Type.Nullable(writer) + : constructorParameter.Type; parameters.Add( new ParameterDeclarationOptions(constructorParameter.Name, parameterType) { DefaultValue = constructorParameter.DefaultValue, + IsParams = constructorParameter.IsParams && i == constructor.Parameters.Count - 1, } ); } diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index 01a33dc..0ae95e5 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -139,6 +139,33 @@ public virtual ZodType NonSentinel(string? message = null, stri return this; } + /// + /// Adds an that requires the value to be one of the supplied + /// values. + /// + /// The allowed values. + /// Optional error message. + /// Optional error code override. + /// This schema for method chaining. + public virtual ZodType AllowedValues(TOutput[] values, string? message = null, string? code = null) + { + AddRule(new AllowedValuesRule(values, message, code)); + return this; + } + + /// + /// Adds a that rejects the supplied values. + /// + /// The denied values. + /// Optional error message. + /// Optional error code override. + /// This schema for method chaining. + public virtual ZodType DeniedValues(TOutput[] values, string? message = null, string? code = null) + { + AddRule(new DeniedValuesRule(values, message, code)); + return this; + } + /// /// Resolves the error code to report for a failed rule: a rule that implements /// and supplies a code (typically from its constructor) wins over the rule's intrinsic diff --git a/src/src/ZodSharp/Extensions/ZodSharp/ZExtensions.cs b/src/src/ZodSharp/Extensions/ZodSharp/ZExtensions.cs new file mode 100644 index 0000000..26b9d96 --- /dev/null +++ b/src/src/ZodSharp/Extensions/ZodSharp/ZExtensions.cs @@ -0,0 +1,49 @@ +using ZodSharp.Core; + +namespace ZodSharp; + +public static class ZExtensions +{ + /// + /// Validates the specified value against the schema and throws an exception if validation fails. + /// + /// The type of the value to validate. + /// The schema to validate against. + /// The value to validate. + /// A cancellation token. + /// A task that represents the asynchronous operation. + /// Thrown if the schema is null. + public static async ValueTask ThrowOnErrorAsync( + this IZodSchemaValidator schema, + T value, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(schema); + + var result = await schema.ValidateAsync(value, cancellationToken); + result.ThrowOnError(); + } + + /// + /// Validates the specified value against the schema and returns the validated value or throws an exception if + /// validation fails. + /// + /// The type of the value to validate. + /// The schema to validate against. + /// The value to validate. + /// A cancellation token. + /// A task that represents the asynchronous operation. + /// Thrown if the schema is null. + public static async ValueTask GetOrThrowOnErrorAsync( + this IZodSchemaValidator schema, + T value, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(schema); + + var result = await schema.ValidateAsync(value, cancellationToken); + return result.GetValueOrThrow(); + } +} diff --git a/src/src/ZodSharp/Rules/AllowedValuesRule.cs b/src/src/ZodSharp/Rules/AllowedValuesRule.cs new file mode 100644 index 0000000..a8397c6 --- /dev/null +++ b/src/src/ZodSharp/Rules/AllowedValuesRule.cs @@ -0,0 +1,68 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a value to be one of an allowed set, mirroring +/// System.ComponentModel.DataAnnotations.AllowedValuesAttribute. +/// +/// The value type the rule validates. +/// +/// The generated [AllowedValuesZod] attribute exposes both a single value and a params object[] +/// array; the array elements are converted back to the member type by the resolver. The name is suffixed with +/// Zod because AllowedValuesAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct AllowedValuesRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Value '{0}' is not one of the allowed values"; + + readonly T[]? _values; + readonly string _message; + + /// + /// Initializes a new instance of the struct that accepts a single value. + /// + /// The single allowed value. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public AllowedValuesRule(T value, string? message = null, string? code = null) + : this([value], message, code) { } + + /// + /// Initializes a new instance of the struct. + /// + /// The allowed values; or empty accepts every value. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public AllowedValuesRule(T[]? values, string? message = null, string? code = null) + { + _values = values is { Length: > 0 } ? values : null; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is one of the allowed values. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => _values is null || Array.IndexOf(_values, value) >= 0; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/CidrRule.cs b/src/src/ZodSharp/Rules/CidrRule.cs new file mode 100644 index 0000000..ae314a9 --- /dev/null +++ b/src/src/ZodSharp/Rules/CidrRule.cs @@ -0,0 +1,144 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace ZodSharp.Rules; + +/// +/// Specifies the type of CIDR block to validate against. +/// +public enum CidrRuleType +{ + /// + /// Validates that the value is an IPv4 CIDR block. + /// + IPv4, + + /// + /// Validates that the value is an IPv6 CIDR block. + /// + IPv6, + + /// + /// Validates that the value is either an IPv4 or IPv6 CIDR block. + /// + Any, +} + +/// +/// Validation rule for CIDR notation (an IP address with a prefix length), equivalent to Zod's +/// z.string().cidr(). Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct CidrRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value and {1} the required type. + public const string MessageFormat = "Invalid {1} CIDR: {0}"; + + readonly CidrRuleType _ruleType; + readonly string _message; + + /// + /// Initializes a new instance of the struct with the default rule type (Any). + /// + /// + /// A struct always has a parameterless constructor; declaring it explicitly stops new CidrRule() + /// from defaulting to the first enum member (). + /// + public CidrRule() + { + _ruleType = CidrRuleType.Any; + _message = MessageFormat; + } + + /// + /// Initializes a new instance of the struct with the default rule type (Any). + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public CidrRule(string? message = null, string? code = null) + : this(CidrRuleType.Any, message, code) { } + + /// + /// Initializes a new instance of the struct. + /// + /// The type of CIDR block to validate against (defaults to ). + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public CidrRule(CidrRuleType ruleType = CidrRuleType.Any, string? message = null, string? code = null) + { + _ruleType = ruleType; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is an IPv4 or IPv6 CIDR block. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an IPv4 or IPv6 CIDR block without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + var separator = value.LastIndexOf('/'); + if (separator <= 0 || separator == value.Length - 1) + return false; + + if (!int.TryParse(value[(separator + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var prefix)) + return false; + + if (!IPAddress.TryParse(value[..separator], out var address)) + return false; + +#pragma warning disable IDE0072 // Add missing cases + return address.AddressFamily switch + { + AddressFamily.InterNetwork => (_ruleType is CidrRuleType.Any or CidrRuleType.IPv4) + && prefix is >= 0 and <= 32, + AddressFamily.InterNetworkV6 => (_ruleType is CidrRuleType.Any or CidrRuleType.IPv6) + && prefix is >= 0 and <= 128, + _ => false, + }; +#pragma warning restore IDE0072 // Add missing cases + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => + RuleMessage.Format( + _message ?? MessageFormat, + value, + _ruleType == CidrRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() + ); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format( + _message ?? MessageFormat, + value.ToString(), + _ruleType == CidrRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() + ); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/CompareRule.cs b/src/src/ZodSharp/Rules/CompareRule.cs new file mode 100644 index 0000000..97dee78 --- /dev/null +++ b/src/src/ZodSharp/Rules/CompareRule.cs @@ -0,0 +1,80 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a string to equal a fixed value, mirroring the common use of +/// System.ComponentModel.DataAnnotations.CompareAttribute for a confirmation field. +/// +/// +/// A rule cannot reference another property, so the expected value is supplied directly. The generated +/// attribute is named [CompareZod] because CompareAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct CompareRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "mismatch"; + + /// Gets the message format; {0} is the expected value and {1} the actual value. + public const string MessageFormat = "Value must match '{0}', but got '{1}'"; + + readonly string _other; + readonly StringComparison _comparison; + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The value the validated string must equal. + /// The string comparison to use. Defaults to . + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public CompareRule( + string other, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) + { + _other = other.OrNull() ?? throw new ArgumentNullException(nameof(other)); + _comparison = comparison; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value equals the expected value. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && value.Equals(_other, _comparison); + + /// + /// Validates that the span equals the expected value without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Equals(_other.AsSpan(), _comparison); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _other, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, _other, value.ToString()); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/DeniedValuesRule.cs b/src/src/ZodSharp/Rules/DeniedValuesRule.cs new file mode 100644 index 0000000..83b11c4 --- /dev/null +++ b/src/src/ZodSharp/Rules/DeniedValuesRule.cs @@ -0,0 +1,68 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that rejects a value that is one of a denied set, mirroring +/// System.ComponentModel.DataAnnotations.DeniedValuesAttribute. +/// +/// The value type the rule validates. +/// +/// The generated [DeniedValuesZod] attribute exposes both a single value and a params object[] +/// array; the array elements are converted back to the member type by the resolver. The name is suffixed with +/// Zod because DeniedValuesAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct DeniedValuesRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Value '{0}' is one of the denied values"; + + readonly T[]? _values; + readonly string _message; + + /// + /// Initializes a new instance of the struct that rejects a single value. + /// + /// The single denied value. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public DeniedValuesRule(T value, string? message = null, string? code = null) + : this([value], message, code) { } + + /// + /// Initializes a new instance of the struct. + /// + /// The denied values; or empty denies nothing. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public DeniedValuesRule(T[]? values, string? message = null, string? code = null) + { + _values = values is { Length: > 0 } ? values : null; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is not one of the denied values. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) => _values is null || Array.IndexOf(_values, value) < 0; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/DurationRule.cs b/src/src/ZodSharp/Rules/DurationRule.cs new file mode 100644 index 0000000..5ad7632 --- /dev/null +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; + +namespace ZodSharp.Rules; + +/// +/// Validation rule for ISO 8601 duration format, equivalent to Zod's z.string().duration(). +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct DurationRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid duration: {0}"; + + /// + /// Regular expression for ISO 8601-1 durations. Does not accept the 8601-2 extensions (negative + /// durations or fractional/negative components), matching Zod. + /// + public static readonly Regex DurationRegex = new( + @"^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$", + RegexOptions.Compiled, + TimeSpan.FromMilliseconds(100) + ); + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public DurationRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is an ISO 8601 duration. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && DurationRegex.IsMatch(value); + + /// + /// Validates that the span is an ISO 8601 duration without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => DurationRegex.IsMatch(value); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/EmojiRule.cs b/src/src/ZodSharp/Rules/EmojiRule.cs new file mode 100644 index 0000000..a3e09a6 --- /dev/null +++ b/src/src/ZodSharp/Rules/EmojiRule.cs @@ -0,0 +1,1199 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a string to consist only of emoji, mirroring Zod's +/// z.string().emoji() check. +/// +/// +/// The check mirrors the Unicode Extended_Pictographic / Emoji_Component properties: +/// every rune must be a pictograph or an emoji component, and at least one rune must be an actual +/// anchor (a pictograph, a regional indicator, or a keycap). The tables are generated from the +/// Unicode 16.0 emoji-data.txt file. +/// +[Core.ZodRule] +public readonly record struct EmojiRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid emoji: {0}"; + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public EmojiRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value consists only of emoji. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span consists only of emoji without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.IsEmpty) + return false; + + var hasAnchor = false; + var enumerator = value.EnumerateRunes(); + while (enumerator.MoveNext()) + { + var codePoint = enumerator.Current.Value; + if (EmojiRanges.IsExtendedPictographic(codePoint)) + { + hasAnchor = true; + continue; + } + + if (!EmojiRanges.IsEmojiComponent(codePoint)) + return false; + + if (EmojiRanges.IsRegionalIndicator(codePoint) || codePoint == 0x20E3) + hasAnchor = true; + } + + return hasAnchor; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; + + /// The Unicode Extended_Pictographic and Emoji_Component tables. + static class EmojiRanges + { + /// The Extended_Pictographic code-point ranges, as start/end pairs. + static readonly int[] ExtendedPictographic = + [ + 0x00A9, + 0x00A9, + 0x00AE, + 0x00AE, + 0x203C, + 0x203C, + 0x2049, + 0x2049, + 0x2122, + 0x2122, + 0x2139, + 0x2139, + 0x2194, + 0x2199, + 0x21A9, + 0x21AA, + 0x231A, + 0x231B, + 0x2328, + 0x2328, + 0x2388, + 0x2388, + 0x23CF, + 0x23CF, + 0x23E9, + 0x23EC, + 0x23ED, + 0x23EE, + 0x23EF, + 0x23EF, + 0x23F0, + 0x23F0, + 0x23F1, + 0x23F2, + 0x23F3, + 0x23F3, + 0x23F8, + 0x23FA, + 0x24C2, + 0x24C2, + 0x25AA, + 0x25AB, + 0x25B6, + 0x25B6, + 0x25C0, + 0x25C0, + 0x25FB, + 0x25FE, + 0x2600, + 0x2601, + 0x2602, + 0x2603, + 0x2604, + 0x2604, + 0x2605, + 0x2605, + 0x2607, + 0x260D, + 0x260E, + 0x260E, + 0x260F, + 0x2610, + 0x2611, + 0x2611, + 0x2612, + 0x2612, + 0x2614, + 0x2615, + 0x2616, + 0x2617, + 0x2618, + 0x2618, + 0x2619, + 0x261C, + 0x261D, + 0x261D, + 0x261E, + 0x261F, + 0x2620, + 0x2620, + 0x2621, + 0x2621, + 0x2622, + 0x2623, + 0x2624, + 0x2625, + 0x2626, + 0x2626, + 0x2627, + 0x2629, + 0x262A, + 0x262A, + 0x262B, + 0x262D, + 0x262E, + 0x262E, + 0x262F, + 0x262F, + 0x2630, + 0x2637, + 0x2638, + 0x2639, + 0x263A, + 0x263A, + 0x263B, + 0x263F, + 0x2640, + 0x2640, + 0x2641, + 0x2641, + 0x2642, + 0x2642, + 0x2643, + 0x2647, + 0x2648, + 0x2653, + 0x2654, + 0x265E, + 0x265F, + 0x265F, + 0x2660, + 0x2660, + 0x2661, + 0x2662, + 0x2663, + 0x2663, + 0x2664, + 0x2664, + 0x2665, + 0x2666, + 0x2667, + 0x2667, + 0x2668, + 0x2668, + 0x2669, + 0x267A, + 0x267B, + 0x267B, + 0x267C, + 0x267D, + 0x267E, + 0x267E, + 0x267F, + 0x267F, + 0x2680, + 0x2685, + 0x2690, + 0x2691, + 0x2692, + 0x2692, + 0x2693, + 0x2693, + 0x2694, + 0x2694, + 0x2695, + 0x2695, + 0x2696, + 0x2697, + 0x2698, + 0x2698, + 0x2699, + 0x2699, + 0x269A, + 0x269A, + 0x269B, + 0x269C, + 0x269D, + 0x269F, + 0x26A0, + 0x26A1, + 0x26A2, + 0x26A6, + 0x26A7, + 0x26A7, + 0x26A8, + 0x26A9, + 0x26AA, + 0x26AB, + 0x26AC, + 0x26AF, + 0x26B0, + 0x26B1, + 0x26B2, + 0x26BC, + 0x26BD, + 0x26BE, + 0x26BF, + 0x26C3, + 0x26C4, + 0x26C5, + 0x26C6, + 0x26C7, + 0x26C8, + 0x26C8, + 0x26C9, + 0x26CD, + 0x26CE, + 0x26CE, + 0x26CF, + 0x26CF, + 0x26D0, + 0x26D0, + 0x26D1, + 0x26D1, + 0x26D2, + 0x26D2, + 0x26D3, + 0x26D3, + 0x26D4, + 0x26D4, + 0x26D5, + 0x26E8, + 0x26E9, + 0x26E9, + 0x26EA, + 0x26EA, + 0x26EB, + 0x26EF, + 0x26F0, + 0x26F1, + 0x26F2, + 0x26F3, + 0x26F4, + 0x26F4, + 0x26F5, + 0x26F5, + 0x26F6, + 0x26F6, + 0x26F7, + 0x26F9, + 0x26FA, + 0x26FA, + 0x26FB, + 0x26FC, + 0x26FD, + 0x26FD, + 0x26FE, + 0x2701, + 0x2702, + 0x2702, + 0x2703, + 0x2704, + 0x2705, + 0x2705, + 0x2708, + 0x270C, + 0x270D, + 0x270D, + 0x270E, + 0x270E, + 0x270F, + 0x270F, + 0x2710, + 0x2711, + 0x2712, + 0x2712, + 0x2714, + 0x2714, + 0x2716, + 0x2716, + 0x271D, + 0x271D, + 0x2721, + 0x2721, + 0x2728, + 0x2728, + 0x2733, + 0x2734, + 0x2744, + 0x2744, + 0x2747, + 0x2747, + 0x274C, + 0x274C, + 0x274E, + 0x274E, + 0x2753, + 0x2755, + 0x2757, + 0x2757, + 0x2763, + 0x2763, + 0x2764, + 0x2764, + 0x2765, + 0x2767, + 0x2795, + 0x2797, + 0x27A1, + 0x27A1, + 0x27B0, + 0x27B0, + 0x27BF, + 0x27BF, + 0x2934, + 0x2935, + 0x2B05, + 0x2B07, + 0x2B1B, + 0x2B1C, + 0x2B50, + 0x2B50, + 0x2B55, + 0x2B55, + 0x3030, + 0x3030, + 0x303D, + 0x303D, + 0x3297, + 0x3297, + 0x3299, + 0x3299, + 0x1F000, + 0x1F003, + 0x1F004, + 0x1F004, + 0x1F005, + 0x1F0CE, + 0x1F0CF, + 0x1F0CF, + 0x1F0D0, + 0x1F0FF, + 0x1F10D, + 0x1F10F, + 0x1F12F, + 0x1F12F, + 0x1F16C, + 0x1F16F, + 0x1F170, + 0x1F171, + 0x1F17E, + 0x1F17F, + 0x1F18E, + 0x1F18E, + 0x1F191, + 0x1F19A, + 0x1F1AD, + 0x1F1E5, + 0x1F201, + 0x1F202, + 0x1F203, + 0x1F20F, + 0x1F21A, + 0x1F21A, + 0x1F22F, + 0x1F22F, + 0x1F232, + 0x1F23A, + 0x1F23C, + 0x1F23F, + 0x1F249, + 0x1F24F, + 0x1F250, + 0x1F251, + 0x1F252, + 0x1F2FF, + 0x1F300, + 0x1F30C, + 0x1F30D, + 0x1F30E, + 0x1F30F, + 0x1F30F, + 0x1F310, + 0x1F310, + 0x1F311, + 0x1F311, + 0x1F312, + 0x1F312, + 0x1F313, + 0x1F315, + 0x1F316, + 0x1F318, + 0x1F319, + 0x1F319, + 0x1F31A, + 0x1F31A, + 0x1F31B, + 0x1F31B, + 0x1F31C, + 0x1F31C, + 0x1F31D, + 0x1F31E, + 0x1F31F, + 0x1F320, + 0x1F321, + 0x1F321, + 0x1F322, + 0x1F323, + 0x1F324, + 0x1F32C, + 0x1F32D, + 0x1F32F, + 0x1F330, + 0x1F331, + 0x1F332, + 0x1F333, + 0x1F334, + 0x1F335, + 0x1F336, + 0x1F336, + 0x1F337, + 0x1F34A, + 0x1F34B, + 0x1F34B, + 0x1F34C, + 0x1F34F, + 0x1F350, + 0x1F350, + 0x1F351, + 0x1F37B, + 0x1F37C, + 0x1F37C, + 0x1F37D, + 0x1F37D, + 0x1F37E, + 0x1F37F, + 0x1F380, + 0x1F393, + 0x1F394, + 0x1F395, + 0x1F396, + 0x1F397, + 0x1F398, + 0x1F398, + 0x1F399, + 0x1F39B, + 0x1F39C, + 0x1F39D, + 0x1F39E, + 0x1F39F, + 0x1F3A0, + 0x1F3C4, + 0x1F3C5, + 0x1F3C5, + 0x1F3C6, + 0x1F3C6, + 0x1F3C7, + 0x1F3C7, + 0x1F3C8, + 0x1F3C8, + 0x1F3C9, + 0x1F3C9, + 0x1F3CA, + 0x1F3CA, + 0x1F3CB, + 0x1F3CE, + 0x1F3CF, + 0x1F3D3, + 0x1F3D4, + 0x1F3DF, + 0x1F3E0, + 0x1F3E3, + 0x1F3E4, + 0x1F3E4, + 0x1F3E5, + 0x1F3F0, + 0x1F3F1, + 0x1F3F2, + 0x1F3F3, + 0x1F3F3, + 0x1F3F4, + 0x1F3F4, + 0x1F3F5, + 0x1F3F5, + 0x1F3F6, + 0x1F3F6, + 0x1F3F7, + 0x1F3F7, + 0x1F3F8, + 0x1F3FA, + 0x1F400, + 0x1F407, + 0x1F408, + 0x1F408, + 0x1F409, + 0x1F40B, + 0x1F40C, + 0x1F40E, + 0x1F40F, + 0x1F410, + 0x1F411, + 0x1F412, + 0x1F413, + 0x1F413, + 0x1F414, + 0x1F414, + 0x1F415, + 0x1F415, + 0x1F416, + 0x1F416, + 0x1F417, + 0x1F429, + 0x1F42A, + 0x1F42A, + 0x1F42B, + 0x1F43E, + 0x1F43F, + 0x1F43F, + 0x1F440, + 0x1F440, + 0x1F441, + 0x1F441, + 0x1F442, + 0x1F464, + 0x1F465, + 0x1F465, + 0x1F466, + 0x1F46B, + 0x1F46C, + 0x1F46D, + 0x1F46E, + 0x1F4AC, + 0x1F4AD, + 0x1F4AD, + 0x1F4AE, + 0x1F4B5, + 0x1F4B6, + 0x1F4B7, + 0x1F4B8, + 0x1F4EB, + 0x1F4EC, + 0x1F4ED, + 0x1F4EE, + 0x1F4EE, + 0x1F4EF, + 0x1F4EF, + 0x1F4F0, + 0x1F4F4, + 0x1F4F5, + 0x1F4F5, + 0x1F4F6, + 0x1F4F7, + 0x1F4F8, + 0x1F4F8, + 0x1F4F9, + 0x1F4FC, + 0x1F4FD, + 0x1F4FD, + 0x1F4FE, + 0x1F4FE, + 0x1F4FF, + 0x1F502, + 0x1F503, + 0x1F503, + 0x1F504, + 0x1F507, + 0x1F508, + 0x1F508, + 0x1F509, + 0x1F509, + 0x1F50A, + 0x1F514, + 0x1F515, + 0x1F515, + 0x1F516, + 0x1F52B, + 0x1F52C, + 0x1F52D, + 0x1F52E, + 0x1F53D, + 0x1F546, + 0x1F548, + 0x1F549, + 0x1F54A, + 0x1F54B, + 0x1F54E, + 0x1F54F, + 0x1F54F, + 0x1F550, + 0x1F55B, + 0x1F55C, + 0x1F567, + 0x1F568, + 0x1F56E, + 0x1F56F, + 0x1F570, + 0x1F571, + 0x1F572, + 0x1F573, + 0x1F579, + 0x1F57A, + 0x1F57A, + 0x1F57B, + 0x1F586, + 0x1F587, + 0x1F587, + 0x1F588, + 0x1F589, + 0x1F58A, + 0x1F58D, + 0x1F58E, + 0x1F58F, + 0x1F590, + 0x1F590, + 0x1F591, + 0x1F594, + 0x1F595, + 0x1F596, + 0x1F597, + 0x1F5A3, + 0x1F5A4, + 0x1F5A4, + 0x1F5A5, + 0x1F5A5, + 0x1F5A6, + 0x1F5A7, + 0x1F5A8, + 0x1F5A8, + 0x1F5A9, + 0x1F5B0, + 0x1F5B1, + 0x1F5B2, + 0x1F5B3, + 0x1F5BB, + 0x1F5BC, + 0x1F5BC, + 0x1F5BD, + 0x1F5C1, + 0x1F5C2, + 0x1F5C4, + 0x1F5C5, + 0x1F5D0, + 0x1F5D1, + 0x1F5D3, + 0x1F5D4, + 0x1F5DB, + 0x1F5DC, + 0x1F5DE, + 0x1F5DF, + 0x1F5E0, + 0x1F5E1, + 0x1F5E1, + 0x1F5E2, + 0x1F5E2, + 0x1F5E3, + 0x1F5E3, + 0x1F5E4, + 0x1F5E7, + 0x1F5E8, + 0x1F5E8, + 0x1F5E9, + 0x1F5EE, + 0x1F5EF, + 0x1F5EF, + 0x1F5F0, + 0x1F5F2, + 0x1F5F3, + 0x1F5F3, + 0x1F5F4, + 0x1F5F9, + 0x1F5FA, + 0x1F5FA, + 0x1F5FB, + 0x1F5FF, + 0x1F600, + 0x1F600, + 0x1F601, + 0x1F606, + 0x1F607, + 0x1F608, + 0x1F609, + 0x1F60D, + 0x1F60E, + 0x1F60E, + 0x1F60F, + 0x1F60F, + 0x1F610, + 0x1F610, + 0x1F611, + 0x1F611, + 0x1F612, + 0x1F614, + 0x1F615, + 0x1F615, + 0x1F616, + 0x1F616, + 0x1F617, + 0x1F617, + 0x1F618, + 0x1F618, + 0x1F619, + 0x1F619, + 0x1F61A, + 0x1F61A, + 0x1F61B, + 0x1F61B, + 0x1F61C, + 0x1F61E, + 0x1F61F, + 0x1F61F, + 0x1F620, + 0x1F625, + 0x1F626, + 0x1F627, + 0x1F628, + 0x1F62B, + 0x1F62C, + 0x1F62C, + 0x1F62D, + 0x1F62D, + 0x1F62E, + 0x1F62F, + 0x1F630, + 0x1F633, + 0x1F634, + 0x1F634, + 0x1F635, + 0x1F635, + 0x1F636, + 0x1F636, + 0x1F637, + 0x1F640, + 0x1F641, + 0x1F644, + 0x1F645, + 0x1F64F, + 0x1F680, + 0x1F680, + 0x1F681, + 0x1F682, + 0x1F683, + 0x1F685, + 0x1F686, + 0x1F686, + 0x1F687, + 0x1F687, + 0x1F688, + 0x1F688, + 0x1F689, + 0x1F689, + 0x1F68A, + 0x1F68B, + 0x1F68C, + 0x1F68C, + 0x1F68D, + 0x1F68D, + 0x1F68E, + 0x1F68E, + 0x1F68F, + 0x1F68F, + 0x1F690, + 0x1F690, + 0x1F691, + 0x1F693, + 0x1F694, + 0x1F694, + 0x1F695, + 0x1F695, + 0x1F696, + 0x1F696, + 0x1F697, + 0x1F697, + 0x1F698, + 0x1F698, + 0x1F699, + 0x1F69A, + 0x1F69B, + 0x1F6A1, + 0x1F6A2, + 0x1F6A2, + 0x1F6A3, + 0x1F6A3, + 0x1F6A4, + 0x1F6A5, + 0x1F6A6, + 0x1F6A6, + 0x1F6A7, + 0x1F6AD, + 0x1F6AE, + 0x1F6B1, + 0x1F6B2, + 0x1F6B2, + 0x1F6B3, + 0x1F6B5, + 0x1F6B6, + 0x1F6B6, + 0x1F6B7, + 0x1F6B8, + 0x1F6B9, + 0x1F6BE, + 0x1F6BF, + 0x1F6BF, + 0x1F6C0, + 0x1F6C0, + 0x1F6C1, + 0x1F6C5, + 0x1F6C6, + 0x1F6CA, + 0x1F6CB, + 0x1F6CB, + 0x1F6CC, + 0x1F6CC, + 0x1F6CD, + 0x1F6CF, + 0x1F6D0, + 0x1F6D0, + 0x1F6D1, + 0x1F6D2, + 0x1F6D3, + 0x1F6D4, + 0x1F6D5, + 0x1F6D5, + 0x1F6D6, + 0x1F6D7, + 0x1F6D8, + 0x1F6DB, + 0x1F6DC, + 0x1F6DC, + 0x1F6DD, + 0x1F6DF, + 0x1F6E0, + 0x1F6E5, + 0x1F6E6, + 0x1F6E8, + 0x1F6E9, + 0x1F6E9, + 0x1F6EA, + 0x1F6EA, + 0x1F6EB, + 0x1F6EC, + 0x1F6ED, + 0x1F6EF, + 0x1F6F0, + 0x1F6F0, + 0x1F6F1, + 0x1F6F2, + 0x1F6F3, + 0x1F6F3, + 0x1F6F4, + 0x1F6F6, + 0x1F6F7, + 0x1F6F8, + 0x1F6F9, + 0x1F6F9, + 0x1F6FA, + 0x1F6FA, + 0x1F6FB, + 0x1F6FC, + 0x1F6FD, + 0x1F6FF, + 0x1F774, + 0x1F77F, + 0x1F7D5, + 0x1F7DF, + 0x1F7E0, + 0x1F7EB, + 0x1F7EC, + 0x1F7EF, + 0x1F7F0, + 0x1F7F0, + 0x1F7F1, + 0x1F7FF, + 0x1F80C, + 0x1F80F, + 0x1F848, + 0x1F84F, + 0x1F85A, + 0x1F85F, + 0x1F888, + 0x1F88F, + 0x1F8AE, + 0x1F8FF, + 0x1F90C, + 0x1F90C, + 0x1F90D, + 0x1F90F, + 0x1F910, + 0x1F918, + 0x1F919, + 0x1F91E, + 0x1F91F, + 0x1F91F, + 0x1F920, + 0x1F927, + 0x1F928, + 0x1F92F, + 0x1F930, + 0x1F930, + 0x1F931, + 0x1F932, + 0x1F933, + 0x1F93A, + 0x1F93C, + 0x1F93E, + 0x1F93F, + 0x1F93F, + 0x1F940, + 0x1F945, + 0x1F947, + 0x1F94B, + 0x1F94C, + 0x1F94C, + 0x1F94D, + 0x1F94F, + 0x1F950, + 0x1F95E, + 0x1F95F, + 0x1F96B, + 0x1F96C, + 0x1F970, + 0x1F971, + 0x1F971, + 0x1F972, + 0x1F972, + 0x1F973, + 0x1F976, + 0x1F977, + 0x1F978, + 0x1F979, + 0x1F979, + 0x1F97A, + 0x1F97A, + 0x1F97B, + 0x1F97B, + 0x1F97C, + 0x1F97F, + 0x1F980, + 0x1F984, + 0x1F985, + 0x1F991, + 0x1F992, + 0x1F997, + 0x1F998, + 0x1F9A2, + 0x1F9A3, + 0x1F9A4, + 0x1F9A5, + 0x1F9AA, + 0x1F9AB, + 0x1F9AD, + 0x1F9AE, + 0x1F9AF, + 0x1F9B0, + 0x1F9B9, + 0x1F9BA, + 0x1F9BF, + 0x1F9C0, + 0x1F9C0, + 0x1F9C1, + 0x1F9C2, + 0x1F9C3, + 0x1F9CA, + 0x1F9CB, + 0x1F9CB, + 0x1F9CC, + 0x1F9CC, + 0x1F9CD, + 0x1F9CF, + 0x1F9D0, + 0x1F9E6, + 0x1F9E7, + 0x1F9FF, + 0x1FA00, + 0x1FA6F, + 0x1FA70, + 0x1FA73, + 0x1FA74, + 0x1FA74, + 0x1FA75, + 0x1FA77, + 0x1FA78, + 0x1FA7A, + 0x1FA7B, + 0x1FA7C, + 0x1FA7D, + 0x1FA7F, + 0x1FA80, + 0x1FA82, + 0x1FA83, + 0x1FA86, + 0x1FA87, + 0x1FA88, + 0x1FA89, + 0x1FA89, + 0x1FA8A, + 0x1FA8E, + 0x1FA8F, + 0x1FA8F, + 0x1FA90, + 0x1FA95, + 0x1FA96, + 0x1FAA8, + 0x1FAA9, + 0x1FAAC, + 0x1FAAD, + 0x1FAAF, + 0x1FAB0, + 0x1FAB6, + 0x1FAB7, + 0x1FABA, + 0x1FABB, + 0x1FABD, + 0x1FABE, + 0x1FABE, + 0x1FABF, + 0x1FABF, + 0x1FAC0, + 0x1FAC2, + 0x1FAC3, + 0x1FAC5, + 0x1FAC6, + 0x1FAC6, + 0x1FAC7, + 0x1FACD, + 0x1FACE, + 0x1FACF, + 0x1FAD0, + 0x1FAD6, + 0x1FAD7, + 0x1FAD9, + 0x1FADA, + 0x1FADB, + 0x1FADC, + 0x1FADC, + 0x1FADD, + 0x1FADE, + 0x1FADF, + 0x1FADF, + 0x1FAE0, + 0x1FAE7, + 0x1FAE8, + 0x1FAE8, + 0x1FAE9, + 0x1FAE9, + 0x1FAEA, + 0x1FAEF, + 0x1FAF0, + 0x1FAF6, + 0x1FAF7, + 0x1FAF8, + 0x1FAF9, + 0x1FAFF, + 0x1FC00, + 0x1FFFD, + ]; + + /// The Emoji_Component code-point ranges, as start/end pairs. + static readonly int[] EmojiComponent = + [ + 0x0023, + 0x0023, + 0x002A, + 0x002A, + 0x0030, + 0x0039, + 0x200D, + 0x200D, + 0x20E3, + 0x20E3, + 0xFE0F, + 0xFE0F, + 0x1F1E6, + 0x1F1FF, + 0x1F3FB, + 0x1F3FF, + 0x1F9B0, + 0x1F9B3, + 0xE0020, + 0xE007F, + ]; + + /// Determines whether a code point is in Extended_Pictographic. + /// The code point to inspect. + /// when the code point is a pictograph. + public static bool IsExtendedPictographic(int codePoint) => IsInRanges(ExtendedPictographic, codePoint); + + /// Determines whether a code point is in Emoji_Component. + /// The code point to inspect. + /// when the code point is an emoji component. + public static bool IsEmojiComponent(int codePoint) => IsInRanges(EmojiComponent, codePoint); + + /// Determines whether a code point is a regional indicator (U+1F1E6..U+1F1FF). + /// The code point to inspect. + /// when the code point is a regional indicator. + public static bool IsRegionalIndicator(int codePoint) => codePoint is >= 0x1F1E6 and <= 0x1F1FF; + + static bool IsInRanges(int[] ranges, int codePoint) + { + var low = 0; + var high = (ranges.Length / 2) - 1; + + while (low <= high) + { + var middle = (low + high) / 2; + var start = ranges[middle * 2]; + var end = ranges[(middle * 2) + 1]; + + if (codePoint < start) + high = middle - 1; + else if (codePoint > end) + low = middle + 1; + else + return true; + } + + return false; + } + } +} diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs new file mode 100644 index 0000000..a3743fc --- /dev/null +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -0,0 +1,87 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for GUID format (any 8-4-4-4-12 hexadecimal identifier), equivalent to Zod's +/// z.string().guid(). Unlike it accepts every version nibble. +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct GuidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid GUID: {0}"; + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public GuidRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is an 8-4-4-4-12 hexadecimal identifier. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is an 8-4-4-4-12 hexadecimal identifier without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length != 36) + return false; + + for (var index = 0; index < value.Length; index++) + { + if (index is 8 or 13 or 18 or 23) + { + if (value[index] != '-') + return false; + + continue; + } + + if (!IsHex(value[index])) + return false; + } + + return true; + } + + static bool IsHex(char character) => character is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 4767a87..64ebeea 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -41,7 +41,20 @@ public enum IPAddressRuleType readonly string _message; /// - /// Initializes a new instance of the IPAddressRule struct with default rule type (Any). + /// Initializes a new instance of the struct with the default rule type (Any). + /// + /// + /// A struct always has a parameterless constructor; declaring it explicitly stops new IPAddressRule() + /// from defaulting to the first enum member (). + /// + public IPAddressRule() + { + _ruleType = IPAddressRuleType.Any; + _message = MessageFormat; + } + + /// + /// Initializes a new instance of the struct with default rule type (Any). /// /// Optional error message/ message format. /// Optional error code override. If one is not specified then the is used. diff --git a/src/src/ZodSharp/Rules/KsuidRule.cs b/src/src/ZodSharp/Rules/KsuidRule.cs new file mode 100644 index 0000000..1bf682e --- /dev/null +++ b/src/src/ZodSharp/Rules/KsuidRule.cs @@ -0,0 +1,78 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for KSUID format (27 base62 characters), equivalent to Zod's z.string().ksuid(). +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct KsuidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid KSUID: {0}"; + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public KsuidRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is a 27-character base62 KSUID. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a 27-character base62 KSUID without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length != 27) + return false; + + foreach (var character in value) + { + if (character is (>= '0' and <= '9') or (>= 'a' and <= 'z') or (>= 'A' and <= 'Z')) + continue; + + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/LengthRule.cs b/src/src/ZodSharp/Rules/LengthRule.cs new file mode 100644 index 0000000..f0bdb91 --- /dev/null +++ b/src/src/ZodSharp/Rules/LengthRule.cs @@ -0,0 +1,79 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a string to have a length within an inclusive minimum/maximum range, +/// mirroring System.ComponentModel.DataAnnotations.LengthAttribute. +/// +/// +/// The generated attribute is named [LengthZod] because LengthAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct LengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_length"; + + /// Gets the message format; {0} is the minimum, {1} the maximum and {2} the actual length. + public const string MessageFormat = "String length must be between {0} and {1} characters, but got {2}"; + + readonly int _minimum; + readonly int _maximum; + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The inclusive minimum length. + /// The inclusive maximum length. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public LengthRule(int minimum, int maximum, string? message = null, string? code = null) + { + _minimum = minimum; + _maximum = maximum; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value's length is within the configured range. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) + { + var length = value.LengthOrDefault(); + return length >= _minimum && length <= _maximum; + } + + /// + /// Validates that the span's length is within the configured range without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Length >= _minimum && value.Length <= _maximum; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => + RuleMessage.Format(_message ?? MessageFormat, _minimum, _maximum, value.LengthOrDefault()); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, _minimum, _maximum, value.Length); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/RangeRule.cs b/src/src/ZodSharp/Rules/RangeRule.cs new file mode 100644 index 0000000..49bdf69 --- /dev/null +++ b/src/src/ZodSharp/Rules/RangeRule.cs @@ -0,0 +1,87 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a comparable value to fall within a minimum/maximum range, mirroring +/// System.ComponentModel.DataAnnotations.RangeAttribute with both bounds expressed in one rule. +/// +/// The comparable type the rule validates. +/// +/// Each bound is inclusive unless the matching minimumIsExclusive/maximumIsExclusive flag is +/// set. The generated [RangeZod] attribute surfaces the type-parameter bounds as double values, +/// so the same attribute applies to an int or a double member; the name is suffixed with +/// Zod because RangeAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct RangeRule : Core.IValidationRule, Core.IZodRule + where T : IComparable +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_range"; + + /// Gets the message format; {0} is the minimum, {1} the maximum and {2} the value. + public const string MessageFormat = "Value must be between {0} and {1}, but got {2}"; + + readonly T _minimum; + readonly T _maximum; + readonly bool _minimumIsExclusive; + readonly bool _maximumIsExclusive; + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The lower bound. + /// The upper bound. + /// Whether the lower bound is exclusive. Defaults to . + /// Whether the upper bound is exclusive. Defaults to . + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public RangeRule( + T minimum, + T maximum, + bool minimumIsExclusive = false, + bool maximumIsExclusive = false, + string? message = null, + string? code = null + ) + { + _minimum = minimum; + _maximum = maximum; + _minimumIsExclusive = minimumIsExclusive; + _maximumIsExclusive = maximumIsExclusive; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value falls within the configured range. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in T value) + { + var aboveMinimum = _minimumIsExclusive ? value.CompareTo(_minimum) > 0 : value.CompareTo(_minimum) >= 0; + + if (!aboveMinimum) + return false; + + // If the value is above the minimum, check if it is below the maximum. + return _maximumIsExclusive ? value.CompareTo(_maximum) < 0 : value.CompareTo(_maximum) <= 0; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => + RuleMessage.Format(_message ?? MessageFormat, _minimum, _maximum, value); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs new file mode 100644 index 0000000..e01bff8 --- /dev/null +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -0,0 +1,95 @@ +using System.Runtime.CompilerServices; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a value to be present, mirroring the semantics of +/// System.ComponentModel.DataAnnotations.RequiredAttribute. +/// +/// The value type. Only strings are inspected for emptiness; every other type is present +/// unless it is (a nullable value type or a reference type). +/// +/// +/// For a string value the rule always rejects . When +/// allowEmptyString is +/// an empty string is rejected as well; when it is an empty string is accepted. The +/// trimWhitespace option additionally treats a whitespace-only string as empty, so " " fails +/// unless empty strings are allowed. Leading/trailing whitespace on a non-empty value is preserved, matching a +/// trim-then-check reading of the option. +/// +/// +/// The generated [RequiredZod] attribute surfaces both options as constructor/property arguments; the +/// name is suffixed with Zod because RequiredAttribute is already declared by +/// System.ComponentModel.DataAnnotations. +/// +/// +[Core.ZodRule] +public readonly record struct RequiredRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "missing_field"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Field is required"; + + readonly bool _allowEmptyString; + readonly bool _trimWhitespace; + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Whether an empty string satisfies the rule. Defaults to . + /// Whether a whitespace-only string counts as empty. Defaults to . + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public RequiredRule( + bool allowEmptyString = false, + bool trimWhitespace = false, + string? message = null, + string? code = null + ) + { + _allowEmptyString = allowEmptyString; + _trimWhitespace = trimWhitespace; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is present. + /// + /// The value to validate + /// True if the value is present, false otherwise + public bool IsValid(in T value) + { + if (typeof(T) == typeof(string)) + { + var text = Unsafe.As(ref Unsafe.AsRef(in value)); + if (text is null) + return false; + + if (_allowEmptyString) + return true; + + // If we get here, the string is not null and empty strings are not allowed. Check for whitespace if needed. + return _trimWhitespace ? !string.IsNullOrWhiteSpace(text) : text.Length != 0; + } + + return value is not null; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs index 9d315a3..2e8a076 100644 --- a/src/src/ZodSharp/Rules/RuleMessage.cs +++ b/src/src/ZodSharp/Rules/RuleMessage.cs @@ -34,5 +34,17 @@ public static string Format(string format, TArg0 arg0) => public static string Format(string format, TArg0 arg0, TArg1 arg1) => string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1); + /// Formats a rule message with three arguments. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The message format (a rule's MessageFormat constant). + /// The first format argument. + /// The second format argument. + /// The third format argument. + /// The formatted message using . + public static string Format(string format, TArg0 arg0, TArg1 arg1, TArg2 arg2) => + string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1, arg2); + static CompositeFormat GetFormat(string format) => Formats.GetOrAdd(format, CompositeFormat.Parse); } diff --git a/src/src/ZodSharp/Rules/StringLengthRule.cs b/src/src/ZodSharp/Rules/StringLengthRule.cs new file mode 100644 index 0000000..352768f --- /dev/null +++ b/src/src/ZodSharp/Rules/StringLengthRule.cs @@ -0,0 +1,79 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a string to have a maximum length and an optional minimum length, +/// mirroring System.ComponentModel.DataAnnotations.StringLengthAttribute. +/// +/// +/// The generated attribute is named [StringLengthZod] because StringLengthAttribute is already +/// declared by System.ComponentModel.DataAnnotations. +/// +[Core.ZodRule] +public readonly record struct StringLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_length"; + + /// Gets the message format; {0} is the maximum, {1} the minimum and {2} the actual length. + public const string MessageFormat = "String must be at most {0} characters long and at least {1}, but got {2}"; + + readonly int _maximumLength; + readonly int _minimumLength; + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The inclusive maximum length. + /// The inclusive minimum length. Defaults to 0. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public StringLengthRule(int maximumLength, int minimumLength = 0, string? message = null, string? code = null) + { + _maximumLength = maximumLength; + _minimumLength = minimumLength; + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value's length is within the configured range. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) + { + var length = value.LengthOrDefault(); + return length >= _minimumLength && length <= _maximumLength; + } + + /// + /// Validates that the span's length is within the configured range without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) => value.Length >= _minimumLength && value.Length <= _maximumLength; + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => + RuleMessage.Format(_message ?? MessageFormat, _maximumLength, _minimumLength, value.LengthOrDefault()); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, _maximumLength, _minimumLength, value.Length); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/XidRule.cs b/src/src/ZodSharp/Rules/XidRule.cs new file mode 100644 index 0000000..8142708 --- /dev/null +++ b/src/src/ZodSharp/Rules/XidRule.cs @@ -0,0 +1,78 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule for XID format (20 base32hex characters), equivalent to Zod's z.string().xid(). +/// Uses struct to avoid allocations. +/// +[Core.ZodRule] +public readonly record struct XidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Invalid XID: {0}"; + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public XidRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is a 20-character base32hex XID. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string value) => value is not null && IsValid(value.AsSpan()); + + /// + /// Validates that the span is a 20-character base32hex XID without materialising a string. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(ReadOnlySpan value) + { + if (value.Length != 20) + return false; + + foreach (var character in value) + { + if (character is (>= '0' and <= '9') or (>= 'a' and <= 'v') or (>= 'A' and <= 'V')) + continue; + + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// + /// Gets the error message for a failed span validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(ReadOnlySpan value) => + RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Schemas/ZodBigInt.cs b/src/src/ZodSharp/Schemas/ZodBigInt.cs index 38adca4..c03f9dc 100644 --- a/src/src/ZodSharp/Schemas/ZodBigInt.cs +++ b/src/src/ZodSharp/Schemas/ZodBigInt.cs @@ -44,6 +44,29 @@ public ZodBigInt Max(long maxValue, string? message = null, string? code = null) return this; } + /// + /// Adds a range validation requiring the value to fall between a minimum and maximum. + /// + /// The lower bound + /// The upper bound + /// Whether the lower bound is exclusive + /// Whether the upper bound is exclusive + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodBigInt Range( + long minimum, + long maximum, + bool minimumIsExclusive = false, + bool maximumIsExclusive = false, + string? message = null, + string? code = null + ) + { + AddRule(new RangeRule(minimum, maximum, minimumIsExclusive, maximumIsExclusive, message, code)); + return this; + } + /// /// Adds a strictly-greater-than validation. /// Equivalent to Zod's z.bigint().gt(value). diff --git a/src/src/ZodSharp/Schemas/ZodDate.cs b/src/src/ZodSharp/Schemas/ZodDate.cs index 920e72d..f043185 100644 --- a/src/src/ZodSharp/Schemas/ZodDate.cs +++ b/src/src/ZodSharp/Schemas/ZodDate.cs @@ -45,6 +45,29 @@ public ZodDate Max(DateTime maxValue, string? message = null, string? code = nul return this; } + /// + /// Adds a range validation requiring the date to fall between a minimum and maximum. + /// + /// The lower bound + /// The upper bound + /// Whether the lower bound is exclusive + /// Whether the upper bound is exclusive + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodDate Range( + DateTime minimum, + DateTime maximum, + bool minimumIsExclusive = false, + bool maximumIsExclusive = false, + string? message = null, + string? code = null + ) + { + AddRule(new RangeRule(minimum, maximum, minimumIsExclusive, maximumIsExclusive, message, code)); + return this; + } + /// /// Adds a non-sentinel validation that rejects and /// . diff --git a/src/src/ZodSharp/Schemas/ZodEnum.cs b/src/src/ZodSharp/Schemas/ZodEnum.cs index a89eb7e..8d1c408 100644 --- a/src/src/ZodSharp/Schemas/ZodEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodEnum.cs @@ -19,7 +19,7 @@ public class ZodEnum(HashSet allowedValues) : ZodType /// /// Gets the set of allowed values. /// - public IReadOnlyCollection AllowedValues => allowedValues; + public new IReadOnlyCollection AllowedValues => allowedValues; /// /// Validates that the string is one of the allowed values. diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index ff60836..5a0bb65 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -49,6 +49,29 @@ public ZodNumber Max(double maxValue, string? message = null, string? code = nul return this; } + /// + /// Adds a range validation requiring the value to fall between a minimum and maximum. + /// + /// The lower bound + /// The upper bound + /// Whether the lower bound is exclusive + /// Whether the upper bound is exclusive + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodNumber Range( + double minimum, + double maximum, + bool minimumIsExclusive = false, + bool maximumIsExclusive = false, + string? message = null, + string? code = null + ) + { + AddRule(new RangeRule(minimum, maximum, minimumIsExclusive, maximumIsExclusive, message, code)); + return this; + } + /// /// Adds a strictly-greater-than validation. /// Equivalent to Zod's z.number().gt(value). diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 3f40b9f..d8cdd85 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -529,6 +529,161 @@ public override ZodString NonSentinel(string? message = null, string? code = nul return this; } + /// + /// Adds a required validation that rejects (and, unless allowed, empty or + /// whitespace-only) strings. + /// + /// Whether an empty string satisfies the rule. + /// Whether a whitespace-only string counts as empty. + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Required( + bool allowEmptyString = false, + bool trimWhitespace = false, + string? message = null, + string? code = null + ) + { + AddRule(new RequiredRule(allowEmptyString, trimWhitespace, message, code)); + return this; + } + + /// + /// Adds an emoji-only validation. + /// Equivalent to Zod's z.string().emoji(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Emoji(string? message = null, string? code = null) + { + AddRule(new EmojiRule(message, code)); + return this; + } + + /// + /// Adds an XID format validation (20 base32hex characters). + /// Equivalent to Zod's z.string().xid(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Xid(string? message = null, string? code = null) + { + AddRule(new XidRule(message, code)); + return this; + } + + /// + /// Adds a KSUID format validation (27 base62 characters). + /// Equivalent to Zod's z.string().ksuid(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Ksuid(string? message = null, string? code = null) + { + AddRule(new KsuidRule(message, code)); + return this; + } + + /// + /// Adds an ISO 8601 duration format validation. + /// Equivalent to Zod's z.string().duration(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Duration(string? message = null, string? code = null) + { + AddRule(new DurationRule(message, code)); + return this; + } + + /// + /// Adds a GUID format validation (any 8-4-4-4-12 hexadecimal identifier). + /// Equivalent to Zod's z.string().guid(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1720:Identifier contains type name")] + public ZodString Guid(string? message = null, string? code = null) + { + AddRule(new GuidRule(message, code)); + return this; + } + + /// + /// Adds a CIDR validation (either an IPv4 or IPv6 block). + /// Equivalent to Zod's z.string().cidr(). + /// + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Cidr(string? message = null, string? code = null) => Cidr(CidrRuleType.Any, message, code); + + /// + /// Adds a CIDR validation for a specific IP family. + /// + /// The IP family the CIDR block must belong to + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Cidr(CidrRuleType ruleType, string? message = null, string? code = null) + { + AddRule(new CidrRule(ruleType, message, code)); + return this; + } + + /// + /// Adds an equality validation against a fixed value. + /// + /// The value the string must equal + /// The string comparison to use + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Compare( + string other, + StringComparison comparison = StringComparison.Ordinal, + string? message = null, + string? code = null + ) + { + AddRule(new CompareRule(other, comparison, message, code)); + return this; + } + + /// + /// Adds a string-length validation with an inclusive minimum and maximum. + /// + /// The inclusive minimum length + /// The inclusive maximum length + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString Length(int minimum, int maximum, string? message = null, string? code = null) + { + AddRule(new LengthRule(minimum, maximum, message, code)); + return this; + } + + /// + /// Adds a string-length validation with a maximum length and an optional minimum. + /// + /// The inclusive maximum length + /// The inclusive minimum length + /// Optional error message + /// Optional error code override + /// This schema for method chaining + public ZodString StringLength(int maximumLength, int minimumLength = 0, string? message = null, string? code = null) + { + AddRule(new StringLengthRule(maximumLength, minimumLength, message, code)); + return this; + } + /// /// Transforms the string to lowercase. /// diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index 1cc0aeb..3722ff2 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -685,4 +685,145 @@ CancellationToken cancellationToken await Assert.That(attributeType.GetConstructor([])).IsNotNull(); await Assert.That(attributeType.GetConstructor([typeof(UuidVersion)])).IsNotNull(); } + + [Test] + public async Task RuleAttributeGeneration_GivenRequiredZodAttribute_ResolvesTheBuiltInRequiredRule( + CancellationToken cancellationToken + ) + { + // Arrange - the shipped RequiredZod attribute is the Zod-suffixed form of RequiredRule. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Account + { + [RequiredZod(AllowEmptyString = false, TrimWhitespace = true)] + public string Name { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AccountSchema"); + + // Assert - the open generic is closed with the member type and carries both options. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.RequiredRule(false, true, null!, null!)"); + } + + [Test] + public async Task RuleAttributeGeneration_GivenAllowedValuesZodAttribute_ClosesTheArrayWithTheMemberType( + CancellationToken cancellationToken + ) + { + // Arrange - the params object[] attribute form supplies every allowed value. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Choice + { + [AllowedValuesZod("a", "b", "c")] + public string Code { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("ChoiceSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.AllowedValuesRule(new string[] { \"a\", \"b\", \"c\" }, null!, null!)" + ); + } + + [Test] + public async Task RuleAttributeGeneration_GivenAllowedValuesZodAttribute_ValidatesTheAllowedSet( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Choice + { + [AllowedValuesZod("a", "b", "c")] + public string Code { get; set; } = string.Empty; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Choice")!; + var validate = assembly.GetType("Testing.ChoiceSchema")!.GetMethod("Validate")!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Code")!.SetValue(validInstance, "b"); + var validResult = validate.Invoke(null, [validInstance])!; + + var invalidInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Code")!.SetValue(invalidInstance, "z"); + var invalidResult = validate.Invoke(null, [invalidInstance])!; + + // Assert + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + await Assert.That((bool)invalidResult.GetType().GetProperty("IsSuccess")!.GetValue(invalidResult)!).IsFalse(); + } + + [Test] + public async Task RuleAttributeGeneration_GivenDeniedValuesZodAttribute_ConvertsEachElementToTheMemberType( + CancellationToken cancellationToken + ) + { + // Arrange - the elements are object-typed at the attribute, so the resolver converts each to int. + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Rating + { + [DeniedValuesZod(1, 2, 3)] + public int Score { get; set; } + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("RatingSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.DeniedValuesRule(new int[] { 1, 2, 3 }, null!, null!)" + ); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/DataAnnotationRulesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/DataAnnotationRulesTests.cs new file mode 100644 index 0000000..dc583e1 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/DataAnnotationRulesTests.cs @@ -0,0 +1,196 @@ +namespace ZodSharp.Rules; + +public class DataAnnotationRulesTests +{ + [Test] + [Arguments(1, true)] + [Arguments(10, true)] + [Arguments(5, true)] + [Arguments(0, false)] + [Arguments(11, false)] + public async Task Range_GivenInclusiveBounds_ValidatesTheValue( + int value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RangeRule rule = new(1, 10); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments(1, false)] + [Arguments(10, false)] + [Arguments(5, true)] + public async Task Range_GivenExclusiveBounds_ExcludesTheEndpoints( + int value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RangeRule rule = new(1, 10, minimumIsExclusive: true, maximumIsExclusive: true); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("ab", true)] + [Arguments("abcde", true)] + [Arguments("a", false)] + [Arguments("abcdef", false)] + public async Task Length_GivenInclusiveBounds_ValidatesTheStringLength( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + LengthRule rule = new(2, 5); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("ab", true)] + [Arguments("abcde", true)] + [Arguments("a", false)] + [Arguments("abcdef", false)] + public async Task StringLength_GivenBounds_ValidatesTheStringLength( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + StringLengthRule rule = new(5, 2); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("secret", true)] + [Arguments("other", false)] + public async Task Compare_GivenValue_ComparesAgainstTheExpectedValue( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + CompareRule rule = new("secret"); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments(2, true)] + [Arguments(4, false)] + public async Task AllowedValues_GivenArray_AcceptsOnlyTheAllowedSet( + int value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + AllowedValuesRule rule = new([1, 2, 3]); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task AllowedValues_GivenSingleValue_AcceptsIt(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + AllowedValuesRule rule = new(5); + + // Act + var isValid = rule.IsValid(5); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + [Arguments(4, true)] + [Arguments(2, false)] + public async Task DeniedValues_GivenArray_RejectsTheDeniedSet( + int value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + DeniedValuesRule rule = new([1, 2, 3]); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task DataAnnotationRules_GivenFluentApi_ValidateThroughTheSchema(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var range = Z.Number().Range(1, 10).Validate(11); + var length = Z.String().Length(2, 5).Validate("abcdef"); + var stringLength = Z.String().StringLength(5, 2).Validate("a"); + var compare = Z.String().Compare("secret").Validate("other"); + var allowed = Z.String().AllowedValues(["a", "b"]).Validate("c"); + var denied = Z.String().DeniedValues(["a", "b"]).Validate("a"); + + // Assert + await Assert.That(range.Errors[0].Code).IsEqualTo(RangeRule.ErrorCode); + await Assert.That(length.Errors[0].Code).IsEqualTo(LengthRule.ErrorCode); + await Assert.That(stringLength.Errors[0].Code).IsEqualTo(StringLengthRule.ErrorCode); + await Assert.That(compare.Errors[0].Code).IsEqualTo(CompareRule.ErrorCode); + await Assert.That(allowed.Errors[0].Code).IsEqualTo(AllowedValuesRule.ErrorCode); + await Assert.That(denied.Errors[0].Code).IsEqualTo(DeniedValuesRule.ErrorCode); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs index 0b451b4..66a3b4c 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs @@ -31,6 +31,13 @@ static string AttributeName(string ruleName) => typeof(PhoneRule), typeof(CreditCardRule), typeof(Base64StringRule), + typeof(RequiredRule<>), + typeof(RangeRule<>), + typeof(LengthRule), + typeof(StringLengthRule), + typeof(CompareRule), + typeof(AllowedValuesRule<>), + typeof(DeniedValuesRule<>), ]; static string GeneratedAttributeName(Type ruleType) @@ -198,4 +205,41 @@ public async Task Attribute_GivenRegexRule_MirrorsTheStringPatternOverload() await Assert.That(pattern).IsNotNull(); await Assert.That(pattern!.PropertyType).IsEqualTo(typeof(string)); } + + [Test] + public async Task Attribute_GivenRequiredRule_GeneratesTheZodSuffixedAttribute(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act - RequiredAttribute already exists in System.ComponentModel.DataAnnotations, so the rule's + // attribute is emitted under the Zod suffix. + var attributeType = FindAttribute(typeof(RequiredRule<>)); + + // Assert + await Assert.That(attributeType).IsNotNull(); + await Assert.That(attributeType!.Name).IsEqualTo("RequiredZodAttribute"); + await Assert.That(attributeType.GetProperty("AllowEmptyString")).IsNotNull(); + await Assert.That(attributeType.GetProperty("TrimWhitespace")).IsNotNull(); + await Assert.That(attributeType.GetProperty("Message")).IsNotNull(); + await Assert.That(attributeType.GetProperty("Code")).IsNotNull(); + } + + [Test] + public async Task Attribute_GivenRangeRule_GeneratesTheZodSuffixedAttributeWithDoubleBounds( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var attributeType = FindAttribute(typeof(RangeRule<>)); + + // Assert - the type-parameter bounds surface as doubles so one attribute serves any numeric member. + await Assert.That(attributeType).IsNotNull(); + await Assert.That(attributeType!.Name).IsEqualTo("RangeZodAttribute"); + await Assert.That(attributeType.GetProperty("Minimum")!.PropertyType).IsEqualTo(typeof(double)); + await Assert.That(attributeType.GetProperty("Maximum")!.PropertyType).IsEqualTo(typeof(double)); + await Assert.That(attributeType.GetProperty("MinimumIsExclusive")).IsNotNull(); + await Assert.That(attributeType.GetProperty("MaximumIsExclusive")).IsNotNull(); + } } diff --git a/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs new file mode 100644 index 0000000..dc73c96 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs @@ -0,0 +1,172 @@ +namespace ZodSharp.Rules; + +public class RequiredRuleTests +{ + [Test] + public async Task IsValid_GivenNullString_ReturnsFalse(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(); + + // Act + var isValid = rule.IsValid(null!); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenEmptyStringAndEmptyNotAllowed_ReturnsFalse(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(); + + // Act + var isValid = rule.IsValid(string.Empty); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenEmptyStringAndEmptyAllowed_ReturnsTrue(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(allowEmptyString: true); + + // Act + var isValid = rule.IsValid(string.Empty); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenWhitespaceAndTrimDisabled_ReturnsTrue(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange - an empty string is rejected, but " " has a non-zero length so it passes. + RequiredRule rule = new(); + + // Act + var isValid = rule.IsValid(" "); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenWhitespaceAndTrimEnabled_ReturnsFalse(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(trimWhitespace: true); + + // Act + var isValid = rule.IsValid(" "); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task IsValid_GivenPaddedValueAndTrimEnabled_ReturnsTrue(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange - trimming only affects the empty check; a padded non-empty value stays valid. + RequiredRule rule = new(trimWhitespace: true); + + // Act + var isValid = rule.IsValid(" value "); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenNullableValueTypeWithValue_ReturnsTrue(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(); + + // Act + var isValid = rule.IsValid(5); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task IsValid_GivenNullableValueTypeWithoutValue_ReturnsFalse(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(); + + // Act + var isValid = rule.IsValid(null); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task Code_GivenOverride_ReportsIt(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + RequiredRule rule = new(code: "custom_code"); + + // Act + var code = rule.Code; + + // Assert + await Assert.That(code).IsEqualTo("custom_code"); + } + + [Test] + public async Task Required_GivenEmptyString_FailsThroughTheFluentAPI(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var schema = Z.String().Required(); + + // Act + var result = schema.Validate(string.Empty); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo(RequiredRule.ErrorCode); + } + + [Test] + public async Task Required_GivenWhitespaceAndTrimEnabled_FailsThroughTheFluentAPI( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var schema = Z.String().Required(trimWhitespace: true); + + // Act + var result = schema.Validate(" "); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/StringFormatRulesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/StringFormatRulesTests.cs new file mode 100644 index 0000000..a6b9557 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/StringFormatRulesTests.cs @@ -0,0 +1,203 @@ +namespace ZodSharp.Rules; + +public class StringFormatRulesTests +{ + [Test] + [Arguments("9m4e2mr0ui3e8a215n4g", true)] + [Arguments("9M4E2MR0UI3E8A215N4G", true)] + [Arguments("9m4e2mr0ui3e8a215n4", false)] + [Arguments("9m4e2mr0ui3e8a215n4z", false)] + [Arguments("", false)] + public async Task Xid_GivenValue_ValidatesTheTwentyCharacterBase32HexFormat( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + XidRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("0o5Fs0EELR0fUjHjbCnEtdUwQe3", true)] + [Arguments("0o5Fs0EELR0fUjHjbCnEtdUwQe", false)] + [Arguments("0o5Fs0EELR0fUjHjbCnEtdUwQe-", false)] + public async Task Ksuid_GivenValue_ValidatesTheTwentySevenCharacterBase62Format( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + KsuidRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("550e8400-e29b-41d4-a716-446655440000", true)] + [Arguments("00000000-0000-0000-0000-000000000000", true)] + [Arguments("550e8400-e29b-41d4-a716-44665544000g", false)] + [Arguments("550e8400e29b41d4a716446655440000", false)] + public async Task Guid_GivenValue_ValidatesTheEightFourFourFourTwelveFormat( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + GuidRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("P3Y6M4DT12H30M5S", true)] + [Arguments("P1W", true)] + [Arguments("PT1H", true)] + [Arguments("P1Y2M3DT4H5M6.7S", true)] + [Arguments("P", false)] + [Arguments("3Y", false)] + [Arguments("P1Y2W", false)] + public async Task Duration_GivenValue_ValidatesIso8601Durations( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + DurationRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments("192.168.0.0/24", true)] + [Arguments("10.0.0.0/8", true)] + [Arguments("192.168.0.0/33", false)] + [Arguments("192.168.0.0", false)] + [Arguments("not-an-address/24", false)] + public async Task Cidr_GivenIPv4Value_ValidatesTheBlock( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + CidrRule rule = new(CidrRuleType.IPv4); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task Cidr_GivenIPv6Value_RejectsItForTheIPv4Rule(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + CidrRule rule = new(CidrRuleType.IPv4); + + // Act + var isValid = rule.IsValid("2001:db8::/32"); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task Cidr_GivenAnyRule_AcceptsBothFamilies(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + CidrRule rule = new(); + + // Act + var ipv4 = rule.IsValid("192.168.0.0/24"); + var ipv6 = rule.IsValid("2001:db8::/32"); + + // Assert + await Assert.That(ipv4).IsTrue(); + await Assert.That(ipv6).IsTrue(); + } + + [Test] + [Arguments("😀", true)] + [Arguments("👍🏽", true)] + [Arguments("🇬🇧", true)] + [Arguments("1️⃣", true)] + [Arguments("abc", false)] + [Arguments("", false)] + [Arguments("hello 😀", false)] + public async Task Emoji_GivenValue_ValidatesEmojiOnlyStrings( + string value, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + EmojiRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task FormatRules_GivenFluentApi_ReportTheRuleErrorCode(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var xid = Z.String().Xid().Validate("not-an-xid"); + var ksuid = Z.String().Ksuid().Validate("not-a-ksuid"); + var guid = Z.String().Guid().Validate("not-a-guid"); + var duration = Z.String().Duration().Validate("not-a-duration"); + var cidr = Z.String().Cidr().Validate("not-a-cidr"); + var emoji = Z.String().Emoji().Validate("not-emoji"); + + // Assert + await Assert.That(xid.Errors[0].Code).IsEqualTo(XidRule.ErrorCode); + await Assert.That(ksuid.Errors[0].Code).IsEqualTo(KsuidRule.ErrorCode); + await Assert.That(guid.Errors[0].Code).IsEqualTo(GuidRule.ErrorCode); + await Assert.That(duration.Errors[0].Code).IsEqualTo(DurationRule.ErrorCode); + await Assert.That(cidr.Errors[0].Code).IsEqualTo(CidrRule.ErrorCode); + await Assert.That(emoji.Errors[0].Code).IsEqualTo(EmojiRule.ErrorCode); + } +} From 98e0ed8d203d10ce5e07777ce6346b8d9a05f3f9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 18:34:33 +0100 Subject: [PATCH 19/26] fix: correct json schema export, bound regex execution, and remove runtime codegen JSON Schema export was silently dropping most of the schema. The converter reached into the library's own types by reflecting on type and field names, and several of those names matched nothing; GetField returns null rather than throwing, so every miss failed quietly: - object properties were always empty (looked for _shape; ZodObject.Shape is public) - property types were empty even once the shape was found (the builders wrap each field schema) - array Min/Max never exported (MinItemsRule/_minItems do not exist anywhere) - array element schemas never exported (_elementSchema does not exist) - optional properties exported as an empty "any" schema (_innerSchema does not exist) - unions exported with an empty anyOf (_options does not exist, and the cast expected an array) - optional properties were still listed as required (the check matched the wrapper's type name) Replaced with type-checked internal seams, which also removed all 17 trim warnings. Both were symptoms of the same root cause. Also fixed: - ZodArray built corrupt error paths for nested element failures: it used the two-argument ImmutableArray.CopyTo, which takes a destination index, so the field name was destroyed and a null segment introduced. ProblemDetails reported "[0]" instead of "[0].email". - ZodArray now enforces length constraints before validating elements. A million-element array against .Max(10) previously ran a million validations and allocations first. - CompiledValidator no longer emits a DynamicMethod per call. The expression tree compiled to a single Validate call, so it inlined nothing, and the emitted method is never reclaimed. - ZodTransform no longer puts the exception message into the validation error, which flowed into ProblemDetails and out to the client. Cancellation now propagates instead of being swallowed. Security: - Regex match timeouts on every pattern the library compiles. RegexRule(string) and the generated [RegularExpression] support had none, and the generated path dropped the 2s default that RegularExpressionAttribute provides. JSON Schema import was worse: pattern and input are both external. The shared budget is 2s; the previous 100ms on Email/Url/Duration threw under load. Trimming and Native AOT: - Purview.ZodSharp, .SystemTextJson and .AspNetCore are verified clean and marked IsAotCompatible. - RegisterFromAssembly is now genuinely trim-safe rather than annotated: the generator records the validator type as a typeof in the attribute, so no type is resolved from a string. - System.Text.Json gained JsonTypeInfo overloads, with the options overloads annotated, and the converter resolves a JsonTypeInfo instead of calling reflection-based overloads. - Purview.ZodSharp.NewtonsoftJson cannot be AOT-compatible and is documented as such. Packaging and tooling: - Microsoft.Extensions.* versions now track the target framework; net8.0 was being dragged onto .NET 10 assemblies. - New packed-generator smoke test: the in-repo tests run against the unmerged generator, and pack validation only checks the merged assembly is present. That merge has regressed twice. - The vitest cross-platform suite now runs in CI, and no longer passes when the C# fixtures are absent. Its flat directory read never matched a file, so the handshake had never been asserted. Docs: - Removed "10x faster than reflection-based validation" (no comparative benchmark exists) and the ArrayPool claim (ZeroAllocationHelpers is internal and has no callers). - Fixed a thread-safety contradiction: the caching page claimed schemas are immutable while the guarantees page correctly documents that fluent rule methods mutate the receiver. ZODSGEN037-043 moved into AnalyzerReleases.Shipped.md under Release 2.1.0. BREAKING CHANGE: ZodSchemaGeneratedAttribute now takes (Type targetType, Type validatorType). It is generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. --- .github/workflows/pr.yml | 49 +++ CHANGELOG.md | 201 ++++++++++ Directory.Packages.props | 28 +- Justfile | 10 + README.md | 19 +- bun.lock | 3 - docs/wiki/Compiled-Validators-and-Caching.md | 44 ++- docs/wiki/Performance.md | 4 + scripts/test-packed-generator.ps1 | 158 ++++++++ src/src/AspNetCore/AspNetCore.csproj | 6 + .../ServiceCollectionExtensions.cs | 20 + src/src/AspNetCore/ProblemDetailsMapper.cs | 7 + src/src/AspNetCore/ZodExceptionHandler.cs | 27 +- .../NewtonsoftJson/FromJsonSchemaParser.cs | 6 +- .../AnalyzerReleases.Shipped.md | 14 + .../AnalyzerReleases.Unshipped.md | 17 +- .../SourceGenerators/SourceGenerators.csproj | 8 +- .../ZodSchemaGenerator.BuildSchema.cs | 17 +- .../SystemTextJson/Json/ZodJsonConverter.cs | 52 ++- .../SystemTextJson/FromJsonSchemaParser.cs | 10 +- .../SystemTextJson/JsonSchemaJsonContext.cs | 27 ++ .../JsonSchemaSerializerOptions.cs | 43 ++- src/src/SystemTextJson/SystemTextJson.csproj | 15 + .../SystemTextJsonExtensions.cs | 185 +++++++++ src/src/ZodSharp/Core/ErrorTypeParameters.cs | 8 + .../Core/ZodSchemaFactoryExtensions.cs | 33 +- .../Core/ZodSchemaGeneratedAttribute.cs | 36 +- src/src/ZodSharp/Core/ZodType.cs | 4 + .../ZodSharp/Expressions/CompiledValidator.cs | 91 +++-- .../JsonSchema/IJsonSchemaArrayInfo.cs | 40 ++ .../JsonSchema/IJsonSchemaInnerSchema.cs | 25 ++ .../JsonSchema/JsonSchemaDefinition.cs | 15 + .../JsonSchema/JsonSchemaIntrospection.cs | 33 ++ .../JsonSchema/ToJsonSchemaConverter.cs | 352 ++++++------------ src/src/ZodSharp/Rules/DurationRule.cs | 31 +- src/src/ZodSharp/Rules/EmailRule.cs | 2 +- .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 3 + src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 3 + src/src/ZodSharp/Rules/MaxLengthRule.cs | 3 + src/src/ZodSharp/Rules/MaxValueRule.cs | 3 + src/src/ZodSharp/Rules/MinLengthRule.cs | 3 + src/src/ZodSharp/Rules/MinValueRule.cs | 3 + src/src/ZodSharp/Rules/MultipleOfRule.cs | 3 + src/src/ZodSharp/Rules/RegexRule.cs | 58 ++- src/src/ZodSharp/Rules/UrlRule.cs | 2 +- .../ZodSharp/Schemas/FieldSchemaWrapper.cs | 10 +- src/src/ZodSharp/Schemas/ZodArray.cs | 88 +++-- .../ZodSharp/Schemas/ZodDiscriminatedUnion.cs | 19 + src/src/ZodSharp/Schemas/ZodLiteral.cs | 6 +- src/src/ZodSharp/Schemas/ZodNullable.cs | 10 +- src/src/ZodSharp/Schemas/ZodObjectBuilder.cs | 9 +- src/src/ZodSharp/Schemas/ZodOptional.cs | 11 +- src/src/ZodSharp/Schemas/ZodTransform.cs | 24 +- src/src/ZodSharp/Schemas/ZodUnion.cs | 5 + src/src/ZodSharp/ZodSharp.csproj | 17 + .../Properties/AssemblyInfo.cs | 2 +- .../AnalyzerReleaseTrackingTests.cs | 39 +- ...ZodSchemaGeneratorTests.DataAnnotations.cs | 4 +- .../Json/Schema/FromJsonSchemaParserTests.cs | 29 +- .../Core/ZodSchemaGeneratedAttributeTests.cs | 38 +- .../Expressions/CompiledValidatorTests.cs | 92 ++++- .../ToJsonSchemaArrayConstraintTests.cs | 99 +++++ .../JsonSchema/ToJsonSchemaSurfaceTests.cs | 133 +++++++ .../Properties/AssemblyInfo.cs | 2 +- .../Rules/RegexRuleTimeoutTests.cs | 89 +++++ .../Schemas/ZodArrayTests.cs | 105 ++++++ .../Schemas/ZodTransformTests.cs | 34 ++ tests/ts/cross-platform.test.ts | 41 +- 68 files changed, 2173 insertions(+), 454 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/test-packed-generator.ps1 create mode 100644 src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaJsonContext.cs create mode 100644 src/src/ZodSharp/JsonSchema/IJsonSchemaArrayInfo.cs create mode 100644 src/src/ZodSharp/JsonSchema/IJsonSchemaInnerSchema.cs create mode 100644 src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaArrayConstraintTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaSurfaceTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/RegexRuleTimeoutTests.cs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f3632c2..33d5b74 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,3 +16,52 @@ jobs: run-pack: true validate-pack: true secrets: inherit + + # The shared pipeline runs the C# suite only, so the TypeScript/Zod side of the cross-platform + # parity guarantee (docs/wiki/Guarantees-and-Limitations.md) would otherwise never run in CI. + # The vitest suite validates the JSON the C# cross-platform tests write, so the C# tests must run + # first, in this same workspace — hence running them here rather than relying on the `build` job. + cross-platform: + name: Cross-platform (C# to Zod) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # global.json pins no sdk.version, so the SDK feed quality has to be named here. + # Keep in step with the target frameworks in src/Directory.Build.props. + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "11.0.x" + dotnet-quality: "preview" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - run: bun install --frozen-lockfile + + - name: Run the C# cross-platform tests (writes the fixtures vitest reads) + run: >- + dotnet test --solution src/ZodSharp.slnx --configuration Debug + --treenode-filter '/*/ZodSharp.Json/*CrossPlatformTests/*' + + - name: Run the TypeScript/Zod cross-platform tests + run: bun run test + + # Pack validation checks the IL-merged generator is present in the .nupkg; it cannot check that the + # assembly loads and generates. That merge has regressed twice (#36, #38), both times breaking consumers + # while the in-repo tests stayed green, because those run against the unmerged generator. + packed-generator: + name: Packed generator smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "11.0.x" + dotnet-quality: "preview" + + - name: Build and run a consumer against the packed generator + shell: pwsh + run: ./scripts/test-packed-generator.ps1 -Configuration Release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f283c58 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,201 @@ +# Changelog + +All notable changes to this repository are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see +[Release Flow](docs/wiki/Release-Flow.md). + +Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not +been published to NuGet. + +## Unreleased + +> **This release contains breaking changes.** See [Breaking changes](#breaking-changes) below before +> upgrading from `2.0.0`. The analyzer catalogue records them under `2.1.0`; `package.json` still carries the +> prerelease version and is set at release time. +> +> Note the changes below are consumer-visible: a rule's public `Code` constant was renamed to `ErrorCode`, +> and rule constructor signatures changed, so attribute usages and code referencing those constants may not +> compile against this version. Read the migration notes before upgrading. + +### Breaking changes + +- **Rule error-identity constants renamed from `Code` to `ErrorCode`.** Every validation rule now exposes its + error identity as public `const string ErrorCode` and `const string MessageFormat`. Code that referenced a + rule's `Code` constant must use `ErrorCode`. The new `ZODSGEN042` analyzer enforces the convention on + source-declared rules. +- **Rule constructors are standardised.** Every rule now has a consistent constructor, extension method and + generated attribute. Generated rule attributes carry the rule's *value* parameters as constructor arguments: + a parameter declared without a default becomes a required positional argument, a parameter with a default + keeps it, and `message`/`code`/`origin` remain properties. Attribute usages for required values become + positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. Usages that previously set a required value by property + name no longer compile. + +### Added + +- **Native C# 15 union support.** `Z.NativeUnion` and `ZodTypedNativeUnion` return a native union on `net11.0`, + giving allocation-free, exhaustively pattern-matchable options. `ZODSGEN041` suggests it where a typed union's + option types are all reference types. +- `net11.0` added to the target frameworks (now `net8.0`, `net9.0`, `net10.0`, `net11.0`). +- **Per-framework `Microsoft.Extensions.*` dependencies.** All target frameworks previously resolved + `Microsoft.Extensions.Options` and `…DependencyInjection.Abstractions` 10.0.12, so referencing this package + dragged a **net8.0 (LTS)** application onto .NET 10 assemblies. Each framework now gets its matching line: + net8.0 → 8.0.x, net9.0 → 9.0.20, net10.0 → 10.0.12. On net11.0 the SDK prunes them entirely because the + targeted framework supplies them, which is why the net11.0 dependency group in the `.nuspec` is empty — + that is expected, and a net11.0 consumer resolves them from the framework (verified). +- **Auto-generated attributes for all rule types**, including non-generic and open-generic rules under a single + attribute, and expanded downstream rule generation for `Purview.ValueObjects` consumers. +- A new `enum` rule, applied automatically on generated schemas, plus additional explicit rules. +- `IZodRule` organisation with a supporting analyzer, and `RuleMessage` is now public. +- Seven new diagnostics: `ZODSGEN037`–`ZODSGEN043`, recorded in `AnalyzerReleases.Shipped.md` under + `## Release 2.1.0`. See [Source Generator Diagnostics](docs/wiki/Source-Generator-Diagnostics.md). + `AnalyzerReleases.Unshipped.md` is now empty; the next diagnostic added goes there and moves across when + it ships. + +### Changed — trimming and Native AOT + +- `Purview.ZodSharp` is now marked `IsAotCompatible`, enabling `IsTrimmable` and both analyzers. Getting + there meant replacing the JSON Schema exporter's name-based reflection with type-checked internal seams + (`IJsonSchemaArrayInfo`, `IJsonSchemaInnerSchema`, `IJsonSchemaNullableInfo`, `IJsonSchemaLiteralInfo`, + plus internal accessors on the rules and `ZodType`). That removed all 17 trim warnings *and* the six + silent export defects above — both were symptoms of the same root cause. +- **`RegisterFromAssembly` is now genuinely trim- and AOT-safe, not merely annotated.** It used to rebuild + the validator's type name as a string and ask `Assembly.GetType(string)` for it — untrimmable, so the + validators were removed from a published application and registration threw at runtime. The generator now + records the validator in the attribute as a `typeof`, which roots it, and + `ZodSchemaGeneratedAttribute.ValidatorType` is annotated + `[DynamicallyAccessedMembers(PublicParameterlessConstructor)]` so its constructor survives. No string + resolution remains, and the `[RequiresUnreferencedCode]` annotation has been removed entirely. Discovery + is also now proportional to the number of generated schemas rather than the size of the assembly. + - **Breaking:** `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The + attribute is generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. +- **`Purview.ZodSharp.SystemTextJson` is AOT-clean and marked.** + - The validating `JsonConverter` resolves a `JsonTypeInfo` from the serializer options instead of + calling the reflection-based `JsonSerializer` overloads, so it defers to whatever resolver the host + configured — a source-generated `JsonSerializerContext` in a trimmed or AOT application. It also caches + the converter-excluding options copy per instance; it previously rebuilt them on every read and write, + which meant a cold metadata cache on each call, not just an allocation. + - Every extension method now comes in a pair: a `JsonTypeInfo` overload that is safe everywhere, and + the existing `JsonSerializerOptions` overload annotated `[RequiresUnreferencedCode]`/ + `[RequiresDynamicCode]`. This is the same shape `JsonSerializer` itself uses. + - JSON Schema import and export use this package's own source-generated `JsonSchemaJsonContext`, so they + need nothing from the consumer. To make that possible the custom `JsonSchemaNamingPolicy` was removed — + a naming policy is a runtime object the source generator cannot reproduce — and the four `$`-prefixed + keyword names are now declared with `[JsonPropertyName]` on `JsonSchemaDefinition`. **The wire format is + unchanged**, and the test for it now asserts the emitted JSON rather than the naming mechanism. +- `Purview.ZodSharp.AspNetCore` is AOT-clean and marked. Graph-based assembly scanning + (`ScanAssemblyGraphs`) stays reflective by nature and is documented as the path to avoid in a trimmed or + AOT host, in favour of naming assemblies explicitly or registering validators directly. +- The discriminated-union discriminator accessor falls back to plain reflection when + `RuntimeFeature.IsDynamicCodeSupported` is false, instead of relying on expression compilation that Native + AOT does not provide. +- `Purview.ZodSharp.NewtonsoftJson` is **not** marked AOT-compatible and cannot be: Newtonsoft.Json is + reflection-based throughout. Use `Purview.ZodSharp.SystemTextJson` in a trimmed or Native AOT + application. + +### Changed + +- Clarified in `SourceGenerators.csproj` that `EnforceExtendedAnalyzerRules` and `TreatWarningsAsErrors` are + supplied and enforced by `Purview.BuildSdk` for every `IsRoslynComponent` project, rather than being optional + project settings. Both were already `true`; the commented-out block implied otherwise. + +### Security + +- **Regex match timeouts on every pattern the library compiles.** `RegexRule(string)` and the generated + `[RegularExpression]`/`[Regex]` support previously compiled patterns with **no** timeout, so a + catastrophically backtracking pattern could hang the calling thread — and the generated path, which is how + ASP.NET Core request DTOs are validated, dropped the 2-second default that + `System.ComponentModel.DataAnnotations.RegularExpressionAttribute` provides. JSON Schema import was worse: + both the pattern and the input come from outside the application. All of these now carry + `RegexRule.DefaultMatchTimeout`, and exceeding it is reported as a validation failure rather than an + exception escaping into the host. +- The shared budget is 2 seconds, matching `RegularExpressionAttribute`. The built-in `Email`, `Url` and + `Duration` rules previously used 100 ms, which proved too tight: under full-suite parallel load their + matches exceeded it and threw `RegexMatchTimeoutException`, rejecting valid input. Bounding the work is + what defeats ReDoS; an aggressive bound only adds false negatives. + +### Fixed + +- **`ZodArray` built corrupt error paths for nested element failures.** It used the two-argument + `ImmutableArray.CopyTo(destination, destinationIndex)` — which copies *into* that index — and then + overwrote the copied element with the index segment. For an element error at path `["email"]` the result + was `[null, "[0]"]` instead of `["[0]", "email"]`: the field name was destroyed and a null segment + introduced. `ProblemDetails` therefore reported the key as `[0]`, so an API client could not tell which + field of which array element failed. Only arrays whose element schema produces a path (arrays of objects, + nested arrays) were affected, which is why the existing containment-based assertion did not catch it. +- **`ZodArray` enforces its length constraints before validating any element.** They depend only on the + array's length, but were checked last — so an array of a million elements against `.Max(10)` ran a + million element validations and allocated a million `ValidationError`s, each with an interpolated index + string, before reporting that the array was simply too long. A single request could pin a core and a + large heap allocation on input the schema had already declared out of range. Note this changes which + error is reported when an array is both the wrong length *and* has invalid elements: the length failure + now wins, which matches the container constraint being the outer one. +- **`CompiledValidator` no longer leaks a `DynamicMethod` per call.** It built an expression tree on every + invocation that compiled to a single `IZodSchema.Validate` call — no inlining, no devirtualisation, just a + `LambdaExpression.Compile` whose emitted method is never reclaimed in a non-collectible load context. The + documented inline usage therefore leaked managed and native memory for the process lifetime. The delegate + now binds `Validate` directly and is cached per schema instance with weak keys, which is behaviourally + identical, strictly cheaper, and removes the `RequiresDynamicCode` dependency. +- **JSON Schema export was silently dropping most of the schema.** `ToJsonSchemaConverter` reached into the + library's own types by reflecting on type and field *names*, and several of those names matched nothing. + `GetField` returns null rather than throwing, so each lookup failed quietly. Fixed, with tests: + - **Object properties were always empty.** It looked for a field `_shape` on `ZodObject`; the shape is a + primary-constructor parameter and `ZodObject.Shape` has been public all along. + - **Object property types were empty even once the shape was found**, because the builders wrap each field + schema to present it untyped and the wrapper fell through to the generic fallback. + - **Array `Min`/`Max` never appeared.** It looked for rules named `MinItemsRule`/`MaxItemsRule` reading + fields `_minItems`/`_maxItems`. None of those four names exists anywhere in the library. + - **Array element schemas never appeared**, from a `_elementSchema` field that does not exist. + - **Optional properties exported as an empty "any" schema**, from a `_innerSchema` field that does not exist. + - **Unions exported with an empty `anyOf`**, from a `_options` field that does not exist — and the cast + expected an array where the member is an `IReadOnlyList`. + - **Optional properties were still listed as required**, because the required check matched the wrapper's + type name rather than asking `IOptionalSchema`. +- Scalar schema generation, which had regressed. +- The `uuid` attribute rule required both constructors to be present to generate. +- Nullability annotations on `IZodRule` properties. + +### Added — packed generator smoke test + +- `just smoke-packed-generator` (and a `packed-generator` CI job) packs the package, then builds and runs a + throwaway consumer against it with an isolated package cache. Every in-repo generator test runs against + the **unmerged** generator by design, and pack validation only checks the IL-merged assembly is *present* + in the `.nupkg` — nothing checked that it loads and generates. That merge has regressed twice (`#36`, + `#38`), both times silently breaking consumers while this repository's suite stayed green. Verified to + have teeth: with generation disabled the consumer fails to compile with + `CS0103: The name 'PersonSchema' does not exist`. + +### Documentation + +- **Removed two unsubstantiated performance claims.** "10x faster than reflection-based validation" appeared + twice; the benchmark suite measures this library against itself across scenarios and contains no + comparison with another validation library, so there was nothing behind it. The README now says so + explicitly and points you at measuring your own schemas. Also removed "Array pooling via `ArrayPool` + for zero-allocation helpers": `ArrayPool` appears only inside `ZeroAllocationHelpers`, which is + `internal` and has no callers anywhere — the claim described dead code. The `Span` bullet now names + where that work actually is (`IStringValidationRule`, `ZodString.ValidateSpan`/`IsValidSpan`, + `EmojiRule`). The measured sub-microsecond timings are unchanged; those are benchmark-backed. +- **Fixed a thread-safety contradiction.** `Compiled-Validators-and-Caching.md` claimed "Schemas are + immutable and shareable", while `Guarantees-and-Limitations.md` correctly documents that the fluent rule + methods mutate the receiver in place. A reader who believed the first would cache a schema and then + mutate it from a request path. That page now states the build-then-share rule, cross-references the + limitation, and documents three properties of `SchemaCache` that matter because it is process-global: a + shared key space, no type check on retrieval, and no eviction. +- `Compiled-Validators-and-Caching.md` also described the old expression-tree implementation and claimed it + removed interface dispatch. Neither was true: the tree was a single `Validate` call. The page now + describes what the type actually does — and says plainly that it does not make validation faster. +- `Performance.md` records that its figures were produced with BenchmarkDotNet 0.15.8 while the repository + now pins 0.16.0-preview.2, so the numbers are from a different version than the suite builds against. +- Corrected the target-framework list in `README.md` and linked `LICENSE.md` from the licence section. +- The vitest cross-platform suite now runs in CI, and it **fails** rather than silently passing when the C# + cross-platform fixtures are absent. It also now reads the C# output recursively — the previous flat directory + read never matched a file, so the C#-to-Zod handshake had never actually been asserted. + +## 2.0.0 + +First stable release. See the +[`v2.0.0`](https://github.com/purview-dev/zodsharp/releases/tag/v2.0.0) release notes, and +[What's new in v2](README.md#whats-new-in-v2) for the summary of the `Purview.*` package IDs, the JSON +integrations, JSON Schema interoperability, the ASP.NET Core `ProblemDetails` integration and the expanded +DataAnnotations support. diff --git a/Directory.Packages.props b/Directory.Packages.props index 924a50f..042a7b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,15 +13,37 @@ 1.68.17 1.0.0-prerelease.54 10.0.12 + + $(DotnetRuntimeVersion) + 8.0.2 + 9.0.20 + $(MicrosoftExtensionsVersion) + 8.0.1 - + - + diff --git a/Justfile b/Justfile index 2d0e21c..2c51f6e 100644 --- a/Justfile +++ b/Justfile @@ -49,6 +49,16 @@ pipeline-pack-validate *args: echo "Running pack + validate pipeline..." "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} +# Build and run a throwaway consumer against the packed generator. +# +# The in-repo generator tests run against the unmerged generator, and pack validation only checks the +# IL-merged assembly is present in the .nupkg - not that it loads and generates. That merge has regressed +# twice (#36, #38), both times silently breaking consumers while this repository's tests stayed green. +[group('Pipeline')] +smoke-packed-generator *args: + echo "Running the packed generator smoke test..." + pwsh -NoProfile -File scripts/test-packed-generator.ps1 {{ args }} + # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. # Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. diff --git a/README.md b/README.md index 81737b1..cb27479 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The library is developed and maintained at [github.com/purview-dev/zodsharp](htt - **Struct-based rules** - Validation rules implemented as structs to avoid GC - **Fluent API** - Fluent and extensible API similar to original Zod - **Type-safe** - Strong typing with advanced C# generics -- **High performance** - Sub-microsecond validation times, 10x faster than reflection-based validation +- **High performance** - Sub-microsecond validation times; see [the measurements](#performance) - **Cross-platform** - Works on .NET 8.0, .NET 9.0, .NET 10.0 and .NET 11.0 - **Source Generators** - Compile-time validator generation with `[ZodSchema]` attribute - **DataAnnotations Support** - Automatic validation from `[Required]`, `[StringLength]`, `[Length]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, etc. @@ -69,7 +69,7 @@ The fixture generator script is intentionally run via Bun rather than `npx tsx` ## What's new in v2 - **Packages are published under the `Purview.*` IDs.** The core `Purview.ZodSharp` package ships the validator and the source generator, with optional `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` integration packages. -- **Targets `net8.0`, `net9.0`, and `net10.0`.** The source generator remains on `netstandard2.0` so it can run in any compiler host. +- **Targets `net8.0`, `net9.0`, `net10.0`, and `net11.0`.** The source generator remains on `netstandard2.0` so it can run in any compiler host. - **System.Text.Json integration.** JSON deserialize-and-validate is available for both major JSON libraries, including validating `JsonConverter` instances. - **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. The import API lives in the JSON integration package's namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`); export stays in the core package. - **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails()`. @@ -240,8 +240,8 @@ Purview.ZodSharp implements several optimizations for maximum performance: #### 1. Zero-allocation Validation - Validation rules implemented as `struct` to avoid allocations -- Use of `Span` and `ReadOnlySpan` when appropriate -- Array pooling via `ArrayPool` for zero-allocation helpers +- Use of `Span` and `ReadOnlySpan` when appropriate — specifically `IStringValidationRule` with + `ZodString.ValidateSpan`/`IsValidSpan`, and `EmojiRule` #### 2. Struct-based Rules @@ -296,12 +296,15 @@ dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter **Key performance highlights**: -- **10x faster** than reflection-based validation libraries - **Zero allocations** for primitive validations - **Sub-microsecond** validation for simple types - **Minimal GC pressure** with struct-based architecture - **Scalable** performance even with complex nested schemas +> The benchmark suite measures this library against itself across scenarios; it does not benchmark against +> other validation libraries, so no comparative claim is made here. If a comparison matters to your +> decision, measure it against your own schemas and payloads. + See the [performance README](src/src/Benchmarks/README.md) for detailed benchmark results and optimization tips. ## Architecture @@ -640,7 +643,11 @@ Package versions are declared centrally in `Directory.Packages.props`. No `packa ## License -MIT — the license is declared in the NuGet package metadata (`PackageLicenseExpression`) and in `package.json`. +MIT — see [`LICENSE.md`](LICENSE.md). The license is also declared in the NuGet package metadata +(`PackageLicenseExpression`) and in `package.json`. + +This repository is a fork of [ZodSharp](https://github.com/guinhx/ZodSharp); see +[Acknowledgments](#acknowledgments). ## Contributing diff --git a/bun.lock b/bun.lock index f41d0d4..0b62f9f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "zodsharp", "devDependencies": { - "tsx": "^4.23.13", "vitest": "^5.0.0", "zod": "^4.6.2", }, @@ -176,8 +175,6 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], - "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "vitest": ["vitest@5.0.0", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.0", "@vitest/browser-preview": "5.0.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.0", "@vitest/coverage-v8": "5.0.0", "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q=="], diff --git a/docs/wiki/Compiled-Validators-and-Caching.md b/docs/wiki/Compiled-Validators-and-Caching.md index d2ec690..9311605 100644 --- a/docs/wiki/Compiled-Validators-and-Caching.md +++ b/docs/wiki/Compiled-Validators-and-Caching.md @@ -2,7 +2,7 @@ ## CompiledValidator -`CompiledValidator` (namespace `ZodSharp.Expressions`) compiles a schema into an expression tree and a delegate. +`CompiledValidator` (namespace `ZodSharp.Expressions`) returns a cached delegate for a schema. ```csharp using ZodSharp; @@ -17,14 +17,27 @@ var value = parser(input); // T, throws ZodException on failure | Member | Signature | Returns | |---|---|---| -| `Compile` | `Func> Compile(IZodSchema schema)` | compiled validation delegate | +| `Compile` | `Func> Compile(IZodSchema schema)` | validation delegate | | `CompileParser` | `Func CompileParser(IZodSchema schema)` | returns the value or throws `ZodException` | -The expression tree calls `IZodSchema.Validate` on the schema bound as a constant, removing interface dispatch overhead. +The delegate binds `IZodSchema.Validate` directly, and the result is cached per schema instance with +weak keys — so repeated calls for the same schema return the same delegate, and a discarded schema is still +collectable. + +> **It does not make validation faster.** Despite the name, this type adds no optimisation over calling +> `schema.Validate(value)` yourself: the delegate performs the same single interface call. It exists to give +> you a `Func>` where one is wanted — a cached field, a dictionary of validators, +> something that takes a delegate. If you want genuinely faster validation, use the source generator. +> +> Earlier versions built an expression tree and called `LambdaExpression.Compile` on every invocation. That +> removed no dispatch (the tree was a single `Validate` call) and emitted a `DynamicMethod` that is never +> reclaimed in a non-collectible load context, so calling it per request leaked managed and native memory. +> It also made the package incompatible with Native AOT. Both are fixed; nothing is compiled at runtime now. ## SchemaCache -`SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for expensive schema construction. +`SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for +expensive schema construction. ```csharp using ZodSharp.Core; @@ -41,8 +54,27 @@ var schema = SchemaCache.GetOrCreate("user", () => | `Count` | number of cached entries | | `Clear()` | empties the cache | -Schemas are immutable and shareable, so caching identical definitions avoids repeated construction cost across request boundaries. +### What to know before using it + +A **fully built** schema is safe to cache and share across threads: validation only reads the rule set and +the description. But building a schema is **not** immutable — the fluent rule methods mutate the receiver +in place (see [Guarantees and Limitations](Guarantees-and-Limitations.md#fluent-rule-methods-mutate-the-receiver)). +So finish configuring a schema *before* putting it in the cache, and never apply a fluent rule method to a +schema you retrieved from it: that mutates the instance every other caller is sharing. + +Three further properties of `SchemaCache` worth knowing, because it is process-global: + +- **The key space is shared.** It is a single static dictionary keyed by an arbitrary string, so two + libraries in the same process that both use `"user"` collide. Prefix your keys. +- **`GetOrCreate` does not verify the cached type.** The same key used with a different `T` throws + `InvalidCastException` at the point of retrieval. +- **There is no eviction or size bound.** A key derived from user input grows the dictionary for the + lifetime of the process. + +A static field, or your container's singleton lifetime, is usually a better fit than this cache. Prefer it +when you genuinely need lookup by name. ## The source generator alternative -For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static validator with no runtime compilation or dispatch overhead. See [Source Generator](Source-Generator.md). \ No newline at end of file +For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static +validator with no runtime compilation or dispatch overhead. See [Source Generator](Source-Generator.md). diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index f01515d..da5e304 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -19,6 +19,10 @@ Results are written to `BenchmarkDotNet.Artifacts/` (HTML, Markdown, logs) in th ## Measurement environment - BenchmarkDotNet 0.15.8, .NET 10.0.12, Windows 11 (10.0.28020.2991). +- **These figures are stale in one respect:** the repository now pins BenchmarkDotNet + 0.16.0-preview.2 (`Directory.Packages.props`), so the numbers below were produced by a different version + than the suite currently builds against. They are also from one machine. Re-run the suite before relying + on absolute values; use them for relative comparison between scenarios. - 13th Gen Intel Core i9-13900KF 3.00 GHz (24 physical / 32 logical cores), X64 RyuJIT x86-64-v3. Numbers are indicative; re-run on your own hardware for local planning. diff --git a/scripts/test-packed-generator.ps1 b/scripts/test-packed-generator.ps1 new file mode 100644 index 0000000..da44211 --- /dev/null +++ b/scripts/test-packed-generator.ps1 @@ -0,0 +1,158 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves the packed source generator actually works when consumed from the .nupkg. + +.DESCRIPTION + Every in-repo generator test runs against the *unmerged* generator: ZodSharp.csproj deliberately keeps + the loose build output so the tests are fast and debuggable. What ships is different — the + IL-merged, framework-internalized assembly produced by Purview.SourceGeneratorFramework's + GetPurviewMergedAnalyzerFile target, packed under analyzers/dotnet/cs. + + Pack validation checks that assembly is *present*. Nothing checked that it *loads and generates*, and + that merge has regressed twice before (#36 "fixed source gen il-merge", #38 "sg leaking types"). Both + times the symptom was a consumer whose schemas silently stopped generating — invisible to this + repository's own test suite. + + This script closes that gap: it packs, then builds and runs a throwaway consumer against the resulting + package, with an isolated package cache so a previously extracted copy of the same version cannot be + reused. + +.PARAMETER Configuration + Build configuration to pack. Defaults to Debug to match the other local recipes. + +.PARAMETER KeepArtifacts + Leave the temporary directory in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Debug', + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "zodsharp-packed-smoke-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$consumer = Join-Path $workspace 'consumer' + +New-Item -ItemType Directory -Path $feed, $packages, $consumer -Force | Out-Null + +Write-Host "Packing Purview.ZodSharp $version ($Configuration) to an isolated feed..." -ForegroundColor Cyan + +& dotnet pack (Join-Path $repositoryRoot 'src/src/ZodSharp/ZodSharp.csproj') ` + --configuration $Configuration ` + --output $feed +if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed with exit code $LASTEXITCODE." } + +# Only the freshly packed feed, so the consumer cannot silently resolve a published package instead. +@" + + + + + + + + +"@ | Set-Content (Join-Path $consumer 'nuget.config') -Encoding utf8 + +@" + + + Exe + net10.0 + enable + + true + + + + + +"@ | Set-Content (Join-Path $consumer 'consumer.csproj') -Encoding utf8 + +'{ "sdk": { "allowPrerelease": true } }' | Set-Content (Join-Path $consumer 'global.json') -Encoding utf8 + +# Exercises the generator end to end: the [ZodSchema] attribute comes from the package, the generated +# PersonSchema.Validate is what the merged generator has to produce, and the DataAnnotations attributes make +# it emit rules rather than an empty schema. +@' +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace PackedSmoke; + +[ZodSchema] +public sealed class Person +{ + [Required] + [StringLength(50, MinimumLength = 2)] + public string Name { get; set; } = string.Empty; + + [Range(0, 130)] + public int Age { get; set; } + + [EmailAddress] + public string Email { get; set; } = string.Empty; +} + +public static class Program +{ + public static int Main() + { + // The generated validator. If the merged generator did not run, this does not compile. + var valid = PersonSchema.Validate(new Person { Name = "Ada", Age = 36, Email = "ada@example.com" }); + var invalid = PersonSchema.Validate(new Person { Name = "A", Age = 500, Email = "nope" }); + + if (!valid.IsSuccess) + { + System.Console.Error.WriteLine("FAIL: a valid person was rejected."); + + return 1; + } + + if (invalid.IsSuccess) + { + System.Console.Error.WriteLine("FAIL: an invalid person was accepted, so no rules were generated."); + + return 1; + } + + System.Console.WriteLine($"PASS: generated validator reported {invalid.Errors.Length} error(s) for the invalid person."); + + return 0; + } +} +'@ | Set-Content (Join-Path $consumer 'Program.cs') -Encoding utf8 + +Write-Host 'Building and running the consumer against the packed generator...' -ForegroundColor Cyan + +# An isolated extraction directory: NuGet reuses an already-extracted package for the same id and version, +# so without this a stale copy from a previous run would be used instead of what was just packed. +$env:NUGET_PACKAGES = $packages + +try { + & dotnet run --project (Join-Path $consumer 'consumer.csproj') --configuration Release + $exitCode = $LASTEXITCODE +} +finally { + Remove-Item Env:\NUGET_PACKAGES -ErrorAction SilentlyContinue + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { + throw "The packed generator smoke test failed with exit code $exitCode. The IL-merged generator in the .nupkg did not produce a working validator." +} + +Write-Host 'Packed generator smoke test passed.' -ForegroundColor Green diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 8fc0ed9..1751d85 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -2,6 +2,12 @@ $(ZodSharpNetTargetFrameworks) true + + true true Purview.ZodSharp.AspNetCore Purview.ZodSharp.AspNetCore diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index d2f44fe..7f2e783 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -142,6 +142,13 @@ public IServiceCollection AddZodSharpProblemDetails( } } + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Assembly scanning for generated validators cannot be followed by a trimmer. The " + + "requirement is surfaced on the public AddZodSharp* registration methods, which is where a " + + "host opts into scanning; a trimmed or Native AOT host registers validators explicitly." + )] static ZodSchemaFactory CreateFactory(IServiceProvider serviceProvider) { ZodSchemaFactory factory = new(); @@ -196,6 +203,19 @@ var assembly in GetAssembliesFromGraphs( yield return assembly; } + /// + /// Walking an assembly's reference graph cannot be followed by a trimmer, which may also have removed + /// the references entirely. Prefer ScanAssemblies with an explicit assembly, or register + /// validators directly, in a trimmed or Native AOT host — the graph walk also loads the whole + /// transitive closure at container-build time, which is a measurable startup cost in a large solution. + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Only reached when a host opts into graph scanning via ScanAssemblyGraphs. The " + + "alternative — naming assemblies explicitly, or registering validators directly — is " + + "documented on the option and is what a trimmed or Native AOT host should use." + )] static IEnumerable GetAssembliesFromGraphs( IEnumerable rootAssemblies, HashSet seenAssemblyNames diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs index c1f758a..0d4c331 100644 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ b/src/src/AspNetCore/ProblemDetailsMapper.cs @@ -115,6 +115,13 @@ static string ToProblemDetailsKey(ImmutableArray path) return builder.ToString(); } + /// + /// Every declared error parameter is copied into the response. That is deliberate — the parameters exist + /// to be substituted into a client-facing message, so the values are already destined for the caller — + /// but it does mean a rule that stores internal context in a parameter discloses it. The constraint is + /// documented on , where an author populates them. issues and + /// traceId are reserved by this mapper and are never overwritten from parameters. + /// static void MergeParameters(HttpValidationProblemDetails details, ImmutableArray errors) { foreach (var error in errors.IsDefault ? [] : errors) diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs index 9dbc184..f26d3d6 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -51,13 +51,28 @@ CancellationToken cancellationToken problem.Extensions["traceId"] = httpContext.TraceIdentifier; httpContext.Response.StatusCode = problem.Status!.Value; - await httpContext.Response.WriteAsJsonAsync( - problem, - JsonOptions, - "application/problem+json", - cancellationToken - ); + await WriteProblemAsync(httpContext, problem, cancellationToken); return true; } + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and is " + + "the same type ASP.NET Core's own problem-details writer emits, so a trimmed or Native AOT host " + + "already roots it. WriteAsJsonAsync carries the requirement unconditionally regardless of T." + )] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and is " + + "the same type ASP.NET Core's own problem-details writer emits, so a trimmed or Native AOT host " + + "already roots it. WriteAsJsonAsync carries the requirement unconditionally regardless of T." + )] + static Task WriteProblemAsync( + HttpContext httpContext, + HttpValidationProblemDetails problem, + CancellationToken cancellationToken + ) => httpContext.Response.WriteAsJsonAsync(problem, JsonOptions, "application/problem+json", cancellationToken); } diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index f4dfeaa..1b368a2 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -157,7 +157,11 @@ static IZodSchema ConvertStringSchema(JsonSchemaDefinition schem if (schema.Pattern != null) { - stringSchema = stringSchema.Regex(new Regex(schema.Pattern)); + // Both the pattern and the values later validated against it come from outside the + // application, so this is the most exposed regex path in the library. Bound it. + stringSchema = stringSchema.Regex( + new Regex(schema.Pattern, RegexOptions.None, Rules.RegexRule.DefaultMatchTimeout) + ); } // Apply format diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index 0c5d06d..6d05900 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -46,3 +46,17 @@ | ZODSASP003 | ZodSharp.SourceGenerator | Warning | ErrorType field must be static readonly | ZODSASP100 | ZodSharp.SourceGenerator | Error | Unhandled exception in the ErrorType source generator | ZODSASP101 | ZodSharp.SourceGenerator | Error | ErrorType Parameters could not be extracted + +## Release 2.1.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSGEN037 | ZodSharp.SourceGenerator | Warning | A rule's derived validation attribute name is already declared by a hand-authored type, so the generated attribute is suppressed in favour of that declaration's own [ZodRule] mapping | +| ZODSGEN038 | ZodSharp.SourceGenerator | Warning | A hand-authored rule attribute's [ZodRule] mapping does not address every rule declared under the name the attribute derives from | +| ZODSGEN039 | ZodSharp.SourceGenerator | Warning | A rule accepts a code/origin constructor parameter without implementing IZodRule, so the value never reaches the reported error identity | +| ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | +| ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | +| ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | +| ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 534cf0f..d50fc1a 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,14 +1,7 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - -### New Rules - -| Rule ID | Category | Severity | Notes | -|---|---|---|---| -| ZODSGEN037 | ZodSharp.SourceGenerator | Warning | A rule's derived validation attribute name is already declared by a hand-authored type, so the generated attribute is suppressed in favour of that declaration's own [ZodRule] mapping | -| ZODSGEN038 | ZodSharp.SourceGenerator | Warning | A hand-authored rule attribute's [ZodRule] mapping does not address every rule declared under the name the attribute derives from | -| ZODSGEN039 | ZodSharp.SourceGenerator | Warning | A rule accepts a code/origin constructor parameter without implementing IZodRule, so the value never reaches the reported error identity | -| ZODSGEN040 | ZodSharp.SourceGenerator | Warning | An attribute argument is not consumed by the resolved rule: no matching constructor parameter and not part of the error identity | -| ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | -| ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | -| ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | +; +; Every rule is recorded in AnalyzerReleases.Shipped.md. Add a new or changed diagnostic here, and move it +; across when the next version ships. +; +; ZODSGEN002 and ZODSGEN022-ZODSGEN026 were retired during development and are never reused. diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index 9bed6b8..9f0b6ee 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -2,8 +2,12 @@ true Purview.ZodSharp.SourceGenerators - + diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 81a1ce6..5057bd5 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -31,10 +31,19 @@ bool isPrimary if (!outputContext.ZodSchema.GenerateValidateMethod) return; + // The validator type is named here as a typeof rather than left for the consumer to rebuild from a + // string at runtime. That is what makes RegisterFromAssembly trim- and AOT-safe: the typeof in + // attribute metadata roots the validator, and the attribute's DynamicallyAccessedMembers annotation + // keeps its parameterless constructor. The adapter name must match GenerateValidatorAdapter. + var validatorTypeName = + $"{outputContext.ZodSchema.SchemaType.Namespace}.{outputContext.ZodSchema.SchemaType.Name}Validator"; + var registrationWriter = outputContext.Context.CreateCodeWriter(); registrationWriter.AutoGeneratedHeader(); registrationWriter.Line( - $"[assembly: global::{TypeLibrary.ZodSharp.Core.ZodSchemaGeneratedAttribute.MetadataFullName}(typeof({outputContext.ZodSchema.TargetType.AsTypeReference()}))]" + $"[assembly: global::{TypeLibrary.ZodSharp.Core.ZodSchemaGeneratedAttribute.MetadataFullName}(" + + $"typeof({outputContext.ZodSchema.TargetType.AsTypeReference()}), " + + $"typeof(global::{validatorTypeName}))]" ); context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}Registration.g.cs", registrationWriter); @@ -904,8 +913,12 @@ CancellationToken cancellationToken { IsStatic = true, IsReadOnly = true, + // The pattern is author-supplied but runs against untrusted request input, so it + // carries the same match timeout RegexRule applies to string patterns. Without + // it a catastrophically backtracking pattern hangs the request thread; note + // RegularExpressionAttribute, which this mirrors, defaults to a 2s timeout. Initializer = - $"new({pattern.StringLiteral()}, global::System.Text.RegularExpressions.RegexOptions.CultureInvariant)", + $"new({pattern.StringLiteral()}, global::System.Text.RegularExpressions.RegexOptions.CultureInvariant, global::ZodSharp.Rules.RegexRule.DefaultMatchTimeout)", } ); } diff --git a/src/src/SystemTextJson/Json/ZodJsonConverter.cs b/src/src/SystemTextJson/Json/ZodJsonConverter.cs index 0c8948f..700a916 100644 --- a/src/src/SystemTextJson/Json/ZodJsonConverter.cs +++ b/src/src/SystemTextJson/Json/ZodJsonConverter.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using ZodSharp.Core; namespace ZodSharp.Json; @@ -7,14 +8,34 @@ namespace ZodSharp.Json; /// /// System.Text.Json converter that validates using a Zod schema. /// +/// +/// +/// Reading and writing go through a resolved from the serializer options +/// rather than the reflection-based overloads. Those overloads are annotated +/// RequiresUnreferencedCode/RequiresDynamicCode whatever the options contain, so using them +/// here would make every consumer of this package unsafe under trimming and Native AOT. Resolving the type +/// info instead defers to whatever resolver the host configured: a source-generated +/// in a trimmed or AOT application, or the default reflection resolver +/// elsewhere. The converter itself adds no reflection. +/// +/// +/// The options copy that excludes this converter is built once per converter instance. It used to be +/// rebuilt on every read and write, which is not just allocation: a fresh +/// has a cold metadata cache, so each call re-resolved the contract for +/// . +/// +/// sealed class ZodJsonConverter(IZodSchema schema) : JsonConverter { + JsonSerializerOptions? _withoutThisConverter; + JsonTypeInfo? _typeInfo; + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Buffer the current token so we can re-read it after extracting the value. using var document = JsonDocument.ParseValue(ref reader); var deserialized = - document.Deserialize(WithoutThisConverter(options)) + JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) ?? throw new JsonException("Failed to deserialize JSON"); var result = schema.Validate(deserialized); if (!result.IsSuccess) @@ -41,7 +62,34 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions throw new JsonException($"Validation failed: {errorMessages}"); } - JsonSerializer.Serialize(writer, result.Value, WithoutThisConverter(options)); + JsonSerializer.Serialize(writer, result.Value, TypeInfoFor(options)); + } + + /// + /// Resolves the contract for from options that exclude this converter. + /// + /// + /// Cached per converter instance. A converter is registered against one options instance in practice, so + /// the first resolution wins; a racing caller simply resolves the same contract again and the result is + /// equivalent. + /// + JsonTypeInfo TypeInfoFor(JsonSerializerOptions options) + { + if (_typeInfo is { } cached) + return cached; + + var effective = _withoutThisConverter ??= WithoutThisConverter(options); + var resolved = + effective.GetTypeInfo(typeof(T)) as JsonTypeInfo + ?? throw new JsonException( + $"No JsonTypeInfo is available for '{typeof(T)}'. In a trimmed or Native AOT application, " + + "register the type with a source-generated JsonSerializerContext and set it as the " + + "options' TypeInfoResolver." + ); + + _typeInfo = resolved; + + return resolved; } /// diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 7426147..7c234ae 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -36,7 +36,9 @@ public static IZodSchema Parse(JsonSchemaDefinition schema) /// A ZodSharp schema that validates according to the JSON Schema public static IZodSchema Parse(string jsonSchema) { - var schema = JsonSerializer.Deserialize(jsonSchema, JsonSchemaSerializerOptions.Reading); + // Uses the source-generated contract rather than the reflection-based overload, so JSON Schema + // import works under trimming and Native AOT without the consumer registering our type. + var schema = JsonSerializer.Deserialize(jsonSchema, JsonSchemaJsonContext.Default.JsonSchemaDefinition); return schema == null ? throw new ArgumentException("Invalid JSON Schema: could not parse JSON", nameof(jsonSchema)) @@ -153,7 +155,11 @@ static IZodSchema ConvertStringSchema(JsonSchemaDefinition schem if (schema.Pattern != null) { - stringSchema = stringSchema.Regex(new Regex(schema.Pattern)); + // Both the pattern and the values later validated against it come from outside the + // application, so this is the most exposed regex path in the library. Bound it. + stringSchema = stringSchema.Regex( + new Regex(schema.Pattern, RegexOptions.None, Rules.RegexRule.DefaultMatchTimeout) + ); } // Apply format diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaJsonContext.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaJsonContext.cs new file mode 100644 index 0000000..6b3666a --- /dev/null +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaJsonContext.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace ZodSharp.JsonSchema.SystemTextJson; + +/// +/// Source-generated serialization contracts for . +/// +/// +/// +/// JSON Schema import and export serialize a type this library owns, so the contract can be generated at +/// compile time instead of resolved reflectively. That makes both directions safe under trimming and Native +/// AOT with nothing required from the consumer — they do not have to register our type in their own +/// . +/// +/// +/// The camelCase policy here matches what previously applied at +/// runtime. The four $-prefixed keyword names are not expressible as a policy, so they are declared +/// with on itself and are +/// honoured identically by the generated contract and by the reflection resolver. +/// +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] +[JsonSerializable(typeof(JsonSchemaDefinition))] +partial class JsonSchemaJsonContext : JsonSerializerContext { } diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs index a4decb5..e4b84ee 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs @@ -2,23 +2,11 @@ namespace ZodSharp.JsonSchema.SystemTextJson; -/// -/// Names the JSON Schema keyword properties with their specification names ($schema, $id, -/// $ref and $defs) and camelCase for every other member. -/// -sealed class JsonSchemaNamingPolicy : JsonNamingPolicy -{ - static readonly Dictionary KeywordNames = new(StringComparer.Ordinal) - { - ["Schema"] = "$schema", - ["Id"] = "$id", - ["Ref"] = "$ref", - ["Defs"] = "$defs", - }; - - public override string ConvertName(string name) => - KeywordNames.TryGetValue(name, out var keywordName) ? keywordName : CamelCase.ConvertName(name); -} +// The custom JsonSchemaNamingPolicy that used to live here has been removed. A naming policy is a runtime +// object, so the System.Text.Json source generator cannot reproduce it, and JSON Schema (de)serialization +// could not use a source-generated — trim- and AOT-safe — contract while it existed. The four $-prefixed +// keyword names are now declared with [JsonPropertyName] on JsonSchemaDefinition, which both the generated +// and reflection contracts honour identically, and the rest is the built-in camelCase policy. /// /// Options for JSON serialization/deserialization of JSON Schema using System.Text.Json. @@ -30,9 +18,20 @@ public static class JsonSchemaSerializerOptions /// Writes the JSON Schema keyword names ($schema, $id, $ref, $defs) and /// camelCase for every other member, and ignores null values. /// + /// + /// Resolves contracts through the source-generated , so JSON Schema + /// export is safe under trimming and Native AOT. The $-prefixed keyword names now come from + /// on rather than from a + /// runtime naming policy, which the source generator cannot reproduce; the wire format is unchanged. + /// public static readonly JsonSerializerOptions Default = new() { - PropertyNamingPolicy = new JsonSchemaNamingPolicy(), + TypeInfoResolver = JsonSchemaJsonContext.Default, + // The built-in camelCase policy, not a custom one: a JsonNamingPolicy set in + // JsonSourceGenerationOptions is not carried onto a separate JsonSerializerOptions instance that + // merely uses the context as its resolver, so it has to be stated here too. [JsonPropertyName] on + // JsonSchemaDefinition still wins, which is what keeps the $-prefixed keyword names. + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, WriteIndented = true, }; @@ -40,9 +39,15 @@ public static class JsonSchemaSerializerOptions /// /// Settings for reading JSON Schema with flexible property matching. /// + /// See . public static readonly JsonSerializerOptions Reading = new() { - PropertyNamingPolicy = new JsonSchemaNamingPolicy(), + TypeInfoResolver = JsonSchemaJsonContext.Default, + // The built-in camelCase policy, not a custom one: a JsonNamingPolicy set in + // JsonSourceGenerationOptions is not carried onto a separate JsonSerializerOptions instance that + // merely uses the context as its resolver, so it has to be stated here too. [JsonPropertyName] on + // JsonSchemaDefinition still wins, which is what keeps the $-prefixed keyword names. + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }; } diff --git a/src/src/SystemTextJson/SystemTextJson.csproj b/src/src/SystemTextJson/SystemTextJson.csproj index 6bc87d9..cb6cf35 100644 --- a/src/src/SystemTextJson/SystemTextJson.csproj +++ b/src/src/SystemTextJson/SystemTextJson.csproj @@ -13,6 +13,21 @@ zod;validation;schema;json + + + true + + diff --git a/src/src/SystemTextJson/SystemTextJsonExtensions.cs b/src/src/SystemTextJson/SystemTextJsonExtensions.cs index daf546c..61b509c 100644 --- a/src/src/SystemTextJson/SystemTextJsonExtensions.cs +++ b/src/src/SystemTextJson/SystemTextJsonExtensions.cs @@ -1,5 +1,7 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using ZodSharp.Core; using ZodSharp.Json; #if NETSTANDARD2_1_OR_GREATER @@ -16,11 +18,179 @@ namespace ZodSharp; #endif public static class SystemTextJsonExtensions { + const string JsonSerializerRequirement = + "Resolves the contract for T reflectively. Use the JsonTypeInfo overload in a trimmed or Native " + + "AOT application, passing the type info from a source-generated JsonSerializerContext."; + static readonly string[] EmptyPath = []; + /// + /// Deserializes JSON and validates it using a Zod schema, using a supplied contract. + /// + /// The validated type. + /// The schema to validate with. + /// The JSON to deserialize. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// The validated value, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static ValidationResult DeserializeAndValidate( + this IZodSchema schema, + string json, + JsonTypeInfo jsonTypeInfo + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(json); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + try + { + var deserialized = JsonSerializer.Deserialize(json, jsonTypeInfo); + + return deserialized == null + ? ValidationResult.Failure( + new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) + ) + : schema.Validate(deserialized); + } + catch (JsonException ex) + { + return ValidationResult.Failure( + new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) + ); + } + } + + /// + /// Deserializes JSON from a stream and validates it using a Zod schema, using a supplied contract (async). + /// + /// The validated type. + /// The schema to validate with. + /// The stream to read JSON from. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// Cancels the read and the validation. + /// The validated value, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static async ValueTask> DeserializeAndValidateAsync( + this IZodSchema schema, + Stream jsonStream, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(jsonStream); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + try + { + var deserialized = await JsonSerializer + .DeserializeAsync(jsonStream, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); + + return deserialized == null + ? ValidationResult.Failure( + new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) + ) + : await schema.ValidateAsync(deserialized, cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + return ValidationResult.Failure( + new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) + ); + } + } + + /// + /// Validates a value and serializes it to a JSON string, using a supplied contract. + /// + /// The validated type. + /// The schema to validate with. + /// The value to validate and serialize. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// The serialized JSON, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static ValidationResult ValidateAndSerialize( + this IZodSchema schema, + T value, + JsonTypeInfo jsonTypeInfo + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var result = schema.Validate(value); + + return result.IsSuccess + ? ValidationResult.Success(JsonSerializer.Serialize(result.Value, jsonTypeInfo)) + : ValidationResult.Failure(result.Errors); + } + + /// + /// Validates a value and serializes it to a stream, using a supplied contract (async). + /// + /// The validated type. + /// The schema to validate with. + /// The value to validate and serialize. + /// The stream to write to. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// Cancels the write. + /// An empty success, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static async ValueTask> ValidateAndSerializeAsync( + this IZodSchema schema, + T value, + Stream output, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var result = await schema.ValidateAsync(value, cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess) + return ValidationResult.Failure(result.Errors); + + await JsonSerializer + .SerializeAsync(output, result.Value, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); + + return ValidationResult.Success(string.Empty); + } + /// /// Deserializes JSON and validates it using a Zod schema. /// + /// + /// Not supported under trimming or Native AOT, because the contract for is + /// resolved reflectively. Use the overload there, passing the type info + /// from a source-generated . + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] public static ValidationResult DeserializeAndValidate( this IZodSchema schema, string json, @@ -52,6 +222,11 @@ public static ValidationResult DeserializeAndValidate( /// /// Deserializes JSON from a stream and validates it using a Zod schema (async). /// + /// + /// Not supported under trimming or Native AOT. Use the overload there. + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] public static async ValueTask> DeserializeAndValidateAsync( this IZodSchema schema, Stream jsonStream, @@ -92,6 +267,11 @@ public static async ValueTask> DeserializeAndValidateAsync /// Validates a value and serializes it to a JSON string. /// + /// + /// Not supported under trimming or Native AOT. Use the overload there. + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] public static ValidationResult ValidateAndSerialize( this IZodSchema schema, T value, @@ -112,6 +292,11 @@ public static ValidationResult ValidateAndSerialize( /// /// Validates a value and serializes it to a stream (async). /// + /// + /// Not supported under trimming or Native AOT. Use the overload there. + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] public static async ValueTask> ValidateAndSerializeAsync( this IZodSchema schema, T value, diff --git a/src/src/ZodSharp/Core/ErrorTypeParameters.cs b/src/src/ZodSharp/Core/ErrorTypeParameters.cs index 5413569..d670026 100644 --- a/src/src/ZodSharp/Core/ErrorTypeParameters.cs +++ b/src/src/ZodSharp/Core/ErrorTypeParameters.cs @@ -8,6 +8,14 @@ namespace ZodSharp.Core; /// through the strongly typed accessor and format messages with knowledge of /// each parameter's type. Implements so existing /// dictionary-based consumers keep working unchanged. +/// +/// These values leave the process. They exist to be substituted into a client-facing message, and +/// the ASP.NET Core integration also copies every one of them into the ProblemDetails extensions of +/// the response — so whatever you put here is returned to whoever made the request. That is the intended +/// behaviour for a value the message names, such as an identifier the caller supplied. Do not put internal +/// context in a parameter: connection details, configuration values, another user's data, or a whole +/// record fetched while validating. Only issues and traceId are reserved and withheld. +/// /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", diff --git a/src/src/ZodSharp/Core/ZodSchemaFactoryExtensions.cs b/src/src/ZodSharp/Core/ZodSchemaFactoryExtensions.cs index 366ac05..23be25b 100644 --- a/src/src/ZodSharp/Core/ZodSchemaFactoryExtensions.cs +++ b/src/src/ZodSharp/Core/ZodSchemaFactoryExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace ZodSharp.Core; @@ -13,6 +14,19 @@ public static class ZodSchemaFactoryExtensions /// Generated validators are expected to live in the same namespace as the target type and be named /// {TypeName}SchemaValidator. /// + /// + /// + /// Safe under trimming and Native AOT. The generator records the validator type in the attribute as a + /// typeof, which roots it for the trimmer, and + /// is annotated + /// so its constructor is + /// preserved as well. Nothing here resolves a type from a string. + /// + /// + /// This reads attribute metadata rather than scanning types, so its cost is proportional to the number + /// of generated schemas, not to the size of the assembly. + /// + /// public static IZodSchemaFactory RegisterFromAssembly(this IZodSchemaFactory factory, Assembly assembly) { if (factory is null) @@ -22,19 +36,15 @@ public static IZodSchemaFactory RegisterFromAssembly(this IZodSchemaFactory fact foreach (var attr in assembly.GetCustomAttributes()) { - var targetType = attr.TargetType; - var validatorTypeName = $"{targetType.Name}SchemaValidator"; - var validatorType = - targetType.Assembly.GetType($"{targetType.Namespace}.{validatorTypeName}") - ?? throw new InvalidOperationException( - $"No generated validator '{validatorTypeName}' found for type '{targetType.FullName}' in assembly '{assembly.GetName().Name}'." - ); - if (Activator.CreateInstance(validatorType) is not IZodSchemaValidator validator) + // Activator.CreateInstance over a type the attribute names directly is trim- and AOT-safe: the + // type is rooted by the typeof in metadata, and its parameterless constructor is kept by the + // DynamicallyAccessedMembers annotation on ValidatorType. + if (Activator.CreateInstance(attr.ValidatorType) is not IZodSchemaValidator validator) throw new InvalidOperationException( - $"Generated validator '{validatorType.FullName}' does not implement IZodSchemaValidator." + $"Generated validator '{attr.ValidatorType.FullName}' does not implement IZodSchemaValidator." ); - factory.Register(targetType, validator); + factory.Register(attr.TargetType, validator); } return factory; @@ -46,6 +56,9 @@ public static IZodSchemaFactory RegisterFromAssembly(this IZodSchemaFactory fact /// Generated validators are expected to live in the same namespace as the target type and be named /// {TypeName}SchemaValidator. /// + /// + /// Safe under trimming and Native AOT. See the other overload. + /// public static IZodSchemaFactory RegisterFromAssembly(this IZodSchemaFactory factory) => RegisterFromAssembly(factory, typeof(T).Assembly); } diff --git a/src/src/ZodSharp/Core/ZodSchemaGeneratedAttribute.cs b/src/src/ZodSharp/Core/ZodSchemaGeneratedAttribute.cs index e234eaf..c842b8a 100644 --- a/src/src/ZodSharp/Core/ZodSchemaGeneratedAttribute.cs +++ b/src/src/ZodSharp/Core/ZodSchemaGeneratedAttribute.cs @@ -1,17 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + namespace ZodSharp.Core; /// /// Marks a type as having a source-generated Zod schema validator, enabling auto-discovery by . /// Applied at module level by the ZodSchemaGenerator. /// -/// Initializes a new instance. +/// +/// +/// The attribute names the validator type directly rather than leaving the caller to reconstruct it. That +/// is what makes discovery safe under trimming and Native AOT: a typeof in attribute metadata roots +/// the validator so the trimmer keeps it, and is annotated +/// so its constructor survives +/// too. Discovery previously rebuilt the type's name as a string and asked +/// Assembly.GetType(string) for it, which a trimmer cannot follow — so the validators were removed +/// from a trimmed application and registration threw at runtime. +/// +/// Initializes a new instance. +/// +/// The type that has a generated validator. +/// +/// The generated validator type. It must implement and expose a public +/// parameterless constructor. +/// [AttributeUsage( AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true, Inherited = false )] -public sealed class ZodSchemaGeneratedAttribute(Type targetType) : Attribute +public sealed class ZodSchemaGeneratedAttribute( + Type targetType, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type validatorType +) : Attribute { /// The type that has a generated validator. public Type TargetType { get; } = targetType ?? throw new ArgumentNullException(nameof(targetType)); + + /// + /// The generated validator type for . + /// + /// + /// Annotated so the trimmer preserves the parameterless constructor that + /// + /// calls. + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + public Type ValidatorType { get; } = validatorType ?? throw new ArgumentNullException(nameof(validatorType)); } diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index 0ae95e5..04604a0 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -16,6 +16,10 @@ public abstract class ZodType : IZodSchema, IO ImmutableArray> _rules = []; + // Read by the JSON Schema exporter, which previously reached this by walking BaseType.BaseType and + // reflecting on the field name — fragile, and invisible to the trimmer. + internal ImmutableArray> AppliedRules => _rules; + /// public virtual bool IsOptional => false; diff --git a/src/src/ZodSharp/Expressions/CompiledValidator.cs b/src/src/ZodSharp/Expressions/CompiledValidator.cs index 0430013..9d91931 100644 --- a/src/src/ZodSharp/Expressions/CompiledValidator.cs +++ b/src/src/ZodSharp/Expressions/CompiledValidator.cs @@ -1,49 +1,86 @@ -using System.Linq.Expressions; +using System.Runtime.CompilerServices; using ZodSharp.Core; namespace ZodSharp.Expressions; /// -/// Provides compiled validators using Expression Trees for maximum performance. -/// Inlines validation rules to avoid virtual calls and allocations. +/// Provides cached validator delegates for a schema. /// +/// +/// +/// This type previously built its delegate with System.Linq.Expressions. That was removed: the +/// expression tree it produced was a single call to , +/// so it performed no inlining and removed no virtual dispatch, while costing a +/// LambdaExpression.Compile per call. Each compile emitted a DynamicMethod that is never +/// reclaimed in a non-collectible load context, so calling it per request — which the documented usage +/// invited — leaked both managed and native memory for the lifetime of the process. It also made the +/// package incompatible with Native AOT, because Compile is annotated +/// . +/// +/// +/// The delegate returned now binds directly, which is +/// behaviourally identical and strictly cheaper. Results are cached per schema instance with weak keys, +/// so repeated calls for the same schema return the same delegate and a discarded schema is still +/// collectable. +/// +/// public static class CompiledValidator { /// - /// Compiles a validator function from a schema using Expression Trees. - /// This creates a highly optimized delegate that can be cached and reused. + /// Returns a validator delegate for . /// - public static Func> Compile(IZodSchema schema) => CompileStandard(schema); + /// The validated type. + /// The schema to validate with. + /// + /// A delegate equivalent to . The same delegate is + /// returned for repeated calls with the same schema instance. + /// + /// is null. + public static Func> Compile(IZodSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + + return ValidatorCache.Validators.GetValue(schema, static key => key.Validate); + } /// - /// Standard compilation that calls Validate method. + /// Returns a validator delegate that returns the validated value and throws on failure. /// - static Func> CompileStandard(IZodSchema schema) + /// The validated type. + /// The schema to validate with. + /// + /// A delegate equivalent to Parse. The same delegate is returned for repeated calls with the + /// same schema instance. + /// + /// is null. + /// Thrown by the returned delegate when validation fails. + public static Func CompileParser(IZodSchema schema) { - var inputParam = Expression.Parameter(typeof(T), "input"); - var validateMethod = - typeof(IZodSchema).GetMethod(nameof(IZodSchema<,>.Validate)) - ?? throw new InvalidOperationException( - $"Method '{nameof(IZodSchema<,>.Validate)}' not found on type '{typeof(IZodSchema)}'." - ); - var validateCall = Expression.Call(Expression.Constant(schema), validateMethod, inputParam); + ArgumentNullException.ThrowIfNull(schema); - var lambda = Expression.Lambda>>(validateCall, inputParam); + return ValidatorCache.Parsers.GetValue( + schema, + static key => + { + var validate = Compile(key); - return lambda.Compile(); + return input => + { + var result = validate(input); + + return result.IsSuccess ? result.Value : throw new ZodException(result.Errors); + }; + } + ); } - /// - /// Compiles a validator that throws on failure. - /// - public static Func CompileParser(IZodSchema schema) + // One table per T. ConditionalWeakTable is thread-safe and holds its keys weakly, so caching here + // cannot keep a schema (or its closure over user state) alive. + static class ValidatorCache { - var compiledValidator = Compile(schema); + internal static readonly ConditionalWeakTable, Func>> Validators = + new(); - return input => - { - var result = compiledValidator(input); - return result.IsSuccess ? result.Value : throw new ZodException(result.Errors); - }; + internal static readonly ConditionalWeakTable, Func> Parsers = new(); } } diff --git a/src/src/ZodSharp/JsonSchema/IJsonSchemaArrayInfo.cs b/src/src/ZodSharp/JsonSchema/IJsonSchemaArrayInfo.cs new file mode 100644 index 0000000..a140bbc --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/IJsonSchemaArrayInfo.cs @@ -0,0 +1,40 @@ +using ZodSharp.Core; + +namespace ZodSharp.JsonSchema; + +/// +/// A non-generic view of an array schema, for the JSON Schema exporter. +/// +/// +/// +/// walks schemas through a non-generic , so it cannot +/// pattern-match ZodArray<T> without knowing T. It used to bridge that gap by matching on +/// the type's name and then reading private fields by name — and both names were wrong: +/// it looked for _elementSchema (the element schema is a primary-constructor parameter, so no such +/// field exists) and for _minItems/_maxItems on rules called MinItemsRule/ +/// MaxItemsRule that have never existed. Every lookup returned null, so array element schemas and +/// bounds were silently missing from every exported schema. +/// +/// +/// This interface is the replacement: a type-safe, non-generic seam that the compiler checks, costs no +/// reflection, and is safe under trimming and Native AOT. It is deliberately internal — it is a +/// cross-type detail of this assembly, not public API. +/// +/// +interface IJsonSchemaArrayInfo +{ + /// + /// Gets the minimum element count, or null when unbounded. + /// + int? MinItems { get; } + + /// + /// Gets the maximum element count, or null when unbounded. + /// + int? MaxItems { get; } + + /// + /// Gets the element schema, boxed so the member is reachable without naming the element type. + /// + object ElementSchema { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/IJsonSchemaInnerSchema.cs b/src/src/ZodSharp/JsonSchema/IJsonSchemaInnerSchema.cs new file mode 100644 index 0000000..7fa1bbb --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/IJsonSchemaInnerSchema.cs @@ -0,0 +1,25 @@ +namespace ZodSharp.JsonSchema; + +/// +/// A non-generic view of a schema that wraps another schema, for the JSON Schema exporter. +/// +/// +/// +/// dispatches on a non-generic , so a generic wrapper +/// such as FieldSchemaWrapper<T> cannot be pattern-matched without naming T. Before this +/// seam existed, a wrapper fell through to the generic fallback and produced a definition with no type at +/// all — which is why every property of an exported object schema came back empty: the object builder wraps +/// each field schema to present it untyped. +/// +/// +/// Implement this on anything that merely adapts or decorates another schema without changing the exported +/// JSON Schema shape, so the exporter can unwrap to the schema that does determine it. +/// +/// +interface IJsonSchemaInnerSchema +{ + /// + /// Gets the wrapped schema, boxed so the member is reachable without naming its type. + /// + object InnerSchema { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index bdfa81c..a88d5c0 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -1,20 +1,34 @@ +using System.Text.Json.Serialization; + namespace ZodSharp.JsonSchema; /// /// Represents a JSON Schema definition. /// Based on Draft 2020-12 specification. /// +/// +/// The four JSON Schema keyword members carry an explicit rather +/// than relying on a custom JsonNamingPolicy. A naming policy is a runtime object, so the +/// System.Text.Json source generator cannot reproduce it, and a context-based (trim- and AOT-safe) +/// contract would have emitted schema/id/ref/defs instead of the specification's +/// $-prefixed names. Declaring the names here keeps the wire format identical on both the +/// reflection and source-generated paths. Newtonsoft.Json ignores these attributes and keeps using its own +/// resolver. +/// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only")] public class JsonSchemaDefinition { /// $schema - The JSON Schema version URI + [JsonPropertyName("$schema")] public string? Schema { get; set; } /// $id - Schema identifier + [JsonPropertyName("$id")] public string? Id { get; set; } /// $ref - Reference to another schema + [JsonPropertyName("$ref")] public string? Ref { get; set; } /// type - The data type (string, number, integer, boolean, object, array, null) @@ -107,6 +121,7 @@ public class JsonSchemaDefinition // ========== Definitions ========== /// $defs - Schema definitions (Draft 2020-12) + [JsonPropertyName("$defs")] public Dictionary? Defs { get; set; } /// definitions - Schema definitions (Draft 07 and earlier) diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs new file mode 100644 index 0000000..2e6cab9 --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs @@ -0,0 +1,33 @@ +namespace ZodSharp.JsonSchema; + +/// +/// A non-generic view of a nullable schema, for the JSON Schema exporter. +/// +/// +/// Kept separate from because the two mean different things to the +/// exporter. An optional schema exports as its inner schema — optionality lives in the parent's +/// required list — so it is simply unwrapped. A nullable schema changes the exported shape to +/// anyOf: [inner, {"type": "null"}], so it must be recognised rather than unwrapped. +/// +interface IJsonSchemaNullableInfo +{ + /// + /// Gets the schema the null is permitted alongside, boxed so it is reachable without naming its type. + /// + object NullableInnerSchema { get; } +} + +/// +/// A non-generic view of a literal schema, for the JSON Schema exporter. +/// +/// +/// ZodLiteral<T> is generic, so the exporter cannot pattern-match it without naming T. +/// This replaces matching the type's name and reading its value by private field name. +/// +interface IJsonSchemaLiteralInfo +{ + /// + /// Gets the literal value, boxed so it is reachable without naming its type. + /// + object? LiteralValue { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 60cde18..605a3c3 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -55,6 +55,12 @@ sealed class ConversionContext static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) { + // Unwrap adapters that do not affect the exported shape, so the switch below sees the schema that + // determines it. The object builder wraps every field schema to present it untyped, and before this + // the wrapper fell through to the generic fallback and exported with no type. + while (schema is IJsonSchemaInnerSchema wrapper) + schema = wrapper.InnerSchema; + // Handle circular references if (ctx.Seen.Contains(schema)) { @@ -70,7 +76,8 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) ZodBoolean => new JsonSchemaDefinition { Type = "boolean" }, ZodNull => new JsonSchemaDefinition { Type = "null" }, ZodObject zodObject => ConvertObject(zodObject, ctx), - ZodOptional zodOptional => ConvertOptional(zodOptional, ctx), + // ZodOptional is handled by the unwrap loop above: in JSON Schema optionality lives in the + // parent's "required" list, not in the property's own type. ZodUnion zodUnion => ConvertUnion(zodUnion, ctx), _ => ConvertGeneric(schema, ctx), }; @@ -85,67 +92,44 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) return result; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0010:Add missing cases")] static JsonSchemaDefinition ConvertString(ZodString schema) { JsonSchemaDefinition result = new() { Type = "string" }; - // Extract rules from the schema using reflection (since rules are private) - // We'll use the built-in rule inspection if available - var schemaType = schema.GetType(); - var rulesField = schemaType.BaseType?.BaseType?.GetField( - "_rules", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (rulesField?.GetValue(schema) is System.Collections.IEnumerable rules) + // Rules are matched by type and read through internal members. This used to walk + // BaseType.BaseType to find a field called "_rules" and then read each rule's value by field name — + // name-based reflection into the library's own private state, which the trimmer cannot see and which + // fails silently the moment a field is renamed. + foreach (var rule in schema.AppliedRules) { - foreach (var rule in rules) + switch (rule) { - var ruleType = rule.GetType(); - var ruleName = ruleType.Name; + case MinLengthRule minLength: + result.MinLength = minLength.MinLength; + break; - switch (ruleName) - { - case nameof(MinLengthRule): - var minLengthField = ruleType.GetField( - "_minLength", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (minLengthField?.GetValue(rule) is int minLength) - result.MinLength = minLength; - break; - - case nameof(MaxLengthRule): - var maxLengthField = ruleType.GetField( - "_maxLength", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (maxLengthField?.GetValue(rule) is int maxLength) - result.MaxLength = maxLength; - break; - - case nameof(EmailRule): - result.Format = "email"; - break; - - case nameof(UrlRule): - result.Format = "uri"; - break; - - case nameof(UUIDRule): - result.Format = "uuid"; - break; - - case nameof(RegexRule): - var patternField = ruleType.GetField( - "_pattern", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (patternField?.GetValue(rule) is System.Text.RegularExpressions.Regex regex) - result.Pattern = regex.ToString(); - break; - } + case MaxLengthRule maxLength: + result.MaxLength = maxLength.MaxLength; + break; + + case EmailRule: + result.Format = "email"; + break; + + case UrlRule: + result.Format = "uri"; + break; + + case UUIDRule: + result.Format = "uuid"; + break; + + case RegexRule regex: + result.Pattern = regex.Pattern.ToString(); + break; + + default: + break; } } @@ -162,69 +146,39 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) { JsonSchemaDefinition result = new() { Type = "number" }; - // Extract rules from the schema - var schemaType = schema.GetType(); - var rulesField = schemaType.BaseType?.BaseType?.GetField( - "_rules", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (rulesField?.GetValue(schema) is System.Collections.IEnumerable rules) + // ZodNumber closes the generic numeric rules with double, so they can be matched as closed types and + // read through internal members. This previously matched rule type names with StartsWith (to cope + // with the `1 arity suffix) and read each value by field name. + foreach (var rule in schema.AppliedRules) { - foreach (var rule in rules) + switch (rule) { - var ruleType = rule.GetType(); - var ruleName = ruleType.Name; + case MinValueRule minValue: + result.Minimum = minValue.MinValue; + break; - if (ruleName.StartsWith("MinValueRule", StringComparison.Ordinal)) - { - var minValueField = ruleType.GetField( - "_minValue", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (minValueField?.GetValue(rule) is double minValue) - result.Minimum = minValue; - } - else if (ruleName.StartsWith("MaxValueRule", StringComparison.Ordinal)) - { - var maxValueField = ruleType.GetField( - "_maxValue", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (maxValueField?.GetValue(rule) is double maxValue) - result.Maximum = maxValue; - } - else if (ruleName.StartsWith("GreaterThanOrEqualRule", StringComparison.Ordinal)) - { - var minValueField = ruleType.GetField( - "_minValue", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (minValueField?.GetValue(rule) is double minValue) - result.Minimum = minValue; - } - else if (ruleName.StartsWith("LessThanOrEqualRule", StringComparison.Ordinal)) - { - var maxValueField = ruleType.GetField( - "_maxValue", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (maxValueField?.GetValue(rule) is double maxValue) - result.Maximum = maxValue; - } - else if (ruleName.StartsWith("IntRule", StringComparison.Ordinal)) - { + case MaxValueRule maxValue: + result.Maximum = maxValue.MaxValue; + break; + + case GreaterThanOrEqualRule greaterThanOrEqual: + result.Minimum = greaterThanOrEqual.MinValue; + break; + + case LessThanOrEqualRule lessThanOrEqual: + result.Maximum = lessThanOrEqual.MaxValue; + break; + + case IntRule: result.Type = "integer"; - } - else if (ruleName.StartsWith("MultipleOfRule", StringComparison.Ordinal)) - { - var divisorField = ruleType.GetField( - "_divisor", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (divisorField?.GetValue(rule) is double divisor) - result.MultipleOf = divisor; - } + break; + + case MultipleOfRule multipleOf: + result.MultipleOf = multipleOf.Divisor; + break; + + default: + break; } } @@ -246,28 +200,19 @@ static JsonSchemaDefinition ConvertObject(ZodObject schema, ConversionContext ct AdditionalProperties = false, }; - // Get the shape from ZodObject using reflection - var shapeField = typeof(ZodObject).GetField( - "_shape", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if ( - shapeField?.GetValue(schema) - is System.Collections.Immutable.ImmutableDictionary> shape - ) + // ZodObject.Shape is public, so read it directly. This previously reflected a private field named + // "_shape", which does not exist — the shape is a primary-constructor parameter — so GetField + // returned null and every exported object schema came back with no properties at all. + foreach (var (key, propSchema) in schema.Shape) { - foreach (var (key, propSchema) in shape) - { - result.Properties[key] = ConvertSchema(propSchema, ctx); - - // Check if the property is optional - var propSchemaType = propSchema.GetType(); - if (!propSchemaType.Name.StartsWith("ZodOptional", StringComparison.Ordinal)) - { - result.Required.Add(key); - } - } + result.Properties[key] = ConvertSchema(propSchema, ctx); + + // An optional property is not required. Asked through IOptionalSchema rather than by matching the + // type name: the builders store each field in a wrapper to present it untyped, so a name check + // sees the wrapper instead of ZodOptional and marks every optional field required. The wrappers + // forward IsOptional to the schema they wrap. + if (propSchema is not IOptionalSchema { IsOptional: true }) + result.Required.Add(key); } // Remove required array if empty @@ -279,37 +224,15 @@ is System.Collections.Immutable.ImmutableDictionary(ZodOptional schema, ConversionContext ctx) - where T : class - { - // Get inner schema - var innerField = typeof(ZodOptional).GetField( - "_innerSchema", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (innerField?.GetValue(schema) is object innerSchema) - { - return ConvertSchema(innerSchema, ctx); - } - - // Fallback: return an empty schema (any) - return new JsonSchemaDefinition(); - } - static JsonSchemaDefinition ConvertUnion(ZodUnion schema, ConversionContext ctx) { JsonSchemaDefinition result = new() { AnyOf = [] }; - // Get options from ZodUnion - var optionsField = typeof(ZodUnion).GetField( - "_options", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (optionsField?.GetValue(schema) is IZodSchema[] options) + // ZodUnion.Options is read directly. This previously reflected a field named "_options" and cast it + // to an array; the options are a primary-constructor parameter typed IReadOnlyList, so neither the + // name nor the cast matched and every union exported with an empty "anyOf". { - foreach (var option in options) + foreach (var option in schema.Options) { result.AnyOf.Add(ConvertSchema(option, ctx)); } @@ -323,98 +246,43 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) var schemaType = schema.GetType(); var typeName = schemaType.Name; - // Handle ZodArray - if (typeName.StartsWith("ZodArray", StringComparison.Ordinal)) + // Handle ZodArray. Matched through the internal IJsonSchemaArrayInfo seam rather than by type + // name plus private-field reflection: the old lookups named fields and rule types that do not + // exist, so element schemas and bounds never reached the exported schema. See IJsonSchemaArrayInfo. + if (schema is IJsonSchemaArrayInfo arrayInfo) { - JsonSchemaDefinition result = new() { Type = "array" }; - - // Get element schema - var elementField = schemaType.GetField( - "_elementSchema", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (elementField?.GetValue(schema) is object elementSchema) - { - result.Items = ConvertSchema(elementSchema, ctx); - } - - // Get min/max from rules - var rulesField = schemaType.BaseType?.BaseType?.GetField( - "_rules", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (rulesField?.GetValue(schema) is System.Collections.IEnumerable rules) + return new JsonSchemaDefinition { - foreach (var rule in rules) - { - var ruleType = rule.GetType(); - var ruleName = ruleType.Name; - - if (ruleName == "MinItemsRule") - { - var minField = ruleType.GetField( - "_minItems", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (minField?.GetValue(rule) is int min) - result.MinItems = min; - } - else if (ruleName == "MaxItemsRule") - { - var maxField = ruleType.GetField( - "_maxItems", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - if (maxField?.GetValue(rule) is int max) - result.MaxItems = max; - } - } - } - - return result; + Type = "array", + Items = ConvertSchema(arrayInfo.ElementSchema, ctx), + MinItems = arrayInfo.MinItems, + MaxItems = arrayInfo.MaxItems, + }; } - // Handle ZodLiteral - if (typeName.StartsWith("ZodLiteral", StringComparison.Ordinal)) + // Handle ZodLiteral through its introspection seam rather than by type name and field name. + if (schema is IJsonSchemaLiteralInfo literalInfo && literalInfo.LiteralValue is { } value) { - var valueField = schemaType.GetField( - "_value", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - - if (valueField?.GetValue(schema) is object value) + return new JsonSchemaDefinition { - JsonSchemaDefinition result = new() + Const = value, + Type = value switch { - Const = value, // Set type based on value type - Type = value switch - { - string => "string", - int or long or double or float => "number", - bool => "boolean", - _ => null, - }, - }; - - return result; - } + string => "string", + int or long or double or float => "number", + bool => "boolean", + _ => null, + }, + }; } - // Handle ZodNullable - if (typeName.StartsWith("ZodNullable", StringComparison.Ordinal)) + // Handle ZodNullable. Unlike an optional schema this is not unwrapped: null permission changes + // the exported shape rather than living in the parent's "required" list. + if (schema is IJsonSchemaNullableInfo nullableInfo) { - var innerField = schemaType.GetField( - "_innerSchema", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); + var inner = ConvertSchema(nullableInfo.NullableInnerSchema, ctx); - if (innerField?.GetValue(schema) is object innerSchema) - { - var inner = ConvertSchema(innerSchema, ctx); - return new JsonSchemaDefinition { AnyOf = [inner, new JsonSchemaDefinition { Type = "null" }] }; - } + return new JsonSchemaDefinition { AnyOf = [inner, new JsonSchemaDefinition { Type = "null" }] }; } // Handle ZodLazy diff --git a/src/src/ZodSharp/Rules/DurationRule.cs b/src/src/ZodSharp/Rules/DurationRule.cs index 5ad7632..ebd51d0 100644 --- a/src/src/ZodSharp/Rules/DurationRule.cs +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -22,7 +22,7 @@ namespace ZodSharp.Rules; public static readonly Regex DurationRegex = new( @"^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$", RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100) + RegexRule.DefaultMatchTimeout ); readonly string _message; @@ -43,14 +43,39 @@ public DurationRule(string? message = null, string? code = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => value is not null && DurationRegex.IsMatch(value); + public bool IsValid(in string value) + { + if (value is null) + return false; + + try + { + return DurationRegex.IsMatch(value); + } + catch (RegexMatchTimeoutException) + { + // Report as invalid rather than letting the exception escape into the host. See + // RegexRule.DefaultMatchTimeout. + return false; + } + } /// /// Validates that the span is an ISO 8601 duration without materialising a string. /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(ReadOnlySpan value) => DurationRegex.IsMatch(value); + public bool IsValid(ReadOnlySpan value) + { + try + { + return DurationRegex.IsMatch(value); + } + catch (RegexMatchTimeoutException) + { + return false; + } + } /// /// Gets the error message for a failed validation. diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index 3b5bdfb..bab31b8 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -21,7 +21,7 @@ namespace ZodSharp.Rules; public static readonly Regex EmailRegex = new( @"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled | RegexOptions.IgnoreCase, - TimeSpan.FromMilliseconds(100) + RegexRule.DefaultMatchTimeout ); readonly string _message; diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index 3e7a9f1..ae6e757 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -16,6 +16,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be greater than or equal to {0}, but got {1}"; readonly T _minValue; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal T MinValue => _minValue; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 99f096a..4564832 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -16,6 +16,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be less than or equal to {0}, but got {1}"; readonly T _maxValue; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal T MaxValue => _maxValue; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index ff760ad..fcd7f95 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -14,6 +14,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; readonly int _maxLength; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal int MaxLength => _maxLength; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index 2b3541a..fa61c35 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -16,6 +16,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be at most {0}, but got {1}"; readonly T _maxValue; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal T MaxValue => _maxValue; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index df55147..ed0d041 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -14,6 +14,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; readonly int _minLength; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal int MinLength => _minLength; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 39efe6b..9cd405d 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -16,6 +16,9 @@ namespace ZodSharp.Rules; public const string MessageFormat = "Value must be at least {0}, but got {1}"; readonly T _minValue; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal T MinValue => _minValue; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index 0c84dea..1a1bd33 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -23,6 +23,9 @@ namespace ZodSharp.Rules; const double RelativeTolerance = 1e-12; readonly T _divisor; + + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal T Divisor => _divisor; readonly string _message; /// diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index 828c461..bbad56f 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -15,9 +15,34 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the required pattern. public const string MessageFormat = "String does not match the required pattern: {0}"; + /// + /// The match timeout applied to every pattern this library compiles itself. + /// + /// + /// + /// A pattern is attacker-adjacent: it reaches the library from a [Regex]/ + /// [RegularExpression] attribute, a fluent call, or an imported JSON Schema, and is then run + /// against untrusted input. Without a timeout a catastrophically backtracking pattern hangs the + /// calling thread, so every pattern the library builds carries this budget, and exceeding it is + /// reported as a validation failure rather than an escaping exception. + /// + /// + /// Two seconds matches the default of System.ComponentModel.DataAnnotations' + /// RegularExpressionAttribute, which the generated attribute support mirrors. A much tighter + /// budget is counter-productive for a validation library: on a loaded machine a thread-starved match + /// can exceed it and reject input that is actually valid, which is a correctness bug rather than a + /// security win. Bounding the work is what defeats ReDoS; the exact bound is not the control. + /// Supply a yourself if you need a different budget. + /// + /// + public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(2); + readonly Regex _pattern; readonly string _message; + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. + internal Regex Pattern => _pattern; + /// /// Initializes a new instance of the RegexRule struct. /// @@ -38,21 +63,44 @@ public RegexRule(Regex pattern, string? message = null, string? code = null) /// Optional error message/ message format. /// Optional error code override. If one is not specified then the is used. public RegexRule(string pattern, string? message = null, string? code = null) - : this(new Regex(pattern, RegexOptions.Compiled), message, code) { } + : this(new Regex(pattern, RegexOptions.Compiled, DefaultMatchTimeout), message, code) { } /// /// Validates that the value matches the regex pattern. /// /// The value to validate - /// True if valid, false otherwise - public bool IsValid(in string value) => _pattern.IsMatch(value); + /// True if valid, false otherwise. A pattern that exceeds its match timeout is invalid. + public bool IsValid(in string value) + { + try + { + return _pattern.IsMatch(value); + } + catch (RegexMatchTimeoutException) + { + // Input the pattern cannot decide within its budget is treated as not matching. Letting + // this escape would turn a slow input into an unhandled exception (a 500 under ASP.NET + // Core) rather than the validation failure the caller is equipped to handle. + return false; + } + } /// /// 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); + /// True if valid, false otherwise. A pattern that exceeds its match timeout is invalid. + public bool IsValid(ReadOnlySpan value) + { + try + { + return _pattern.IsMatch(value); + } + catch (RegexMatchTimeoutException) + { + return false; + } + } /// /// Gets the error message for a failed validation. diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 7faa2d6..4ca3a13 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -23,7 +23,7 @@ namespace ZodSharp.Rules; static readonly Regex UrlRegex = new( @"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase, - TimeSpan.FromMilliseconds(100) + RegexRule.DefaultMatchTimeout ); readonly string _message; diff --git a/src/src/ZodSharp/Schemas/FieldSchemaWrapper.cs b/src/src/ZodSharp/Schemas/FieldSchemaWrapper.cs index 1642830..10c1636 100644 --- a/src/src/ZodSharp/Schemas/FieldSchemaWrapper.cs +++ b/src/src/ZodSharp/Schemas/FieldSchemaWrapper.cs @@ -9,8 +9,16 @@ namespace ZodSharp.Schemas; /// Used by and the object/union builders. /// /// The inner schema's type. -public sealed class FieldSchemaWrapper(IZodSchema inner) : IZodSchema, IOptionalSchema +public sealed class FieldSchemaWrapper(IZodSchema inner) + : IZodSchema, + IOptionalSchema, + JsonSchema.IJsonSchemaInnerSchema { + // The wrapper only adapts the input type; it does not change the exported JSON Schema shape. Exposing + // the inner schema lets the exporter unwrap to the schema that does. Without this, every wrapped field + // hit the exporter's generic fallback and exported with no type. + object JsonSchema.IJsonSchemaInnerSchema.InnerSchema => inner; + /// public bool IsOptional => inner is IOptionalSchema o && o.IsOptional; diff --git a/src/src/ZodSharp/Schemas/ZodArray.cs b/src/src/ZodSharp/Schemas/ZodArray.cs index 63fc373..80c88f9 100644 --- a/src/src/ZodSharp/Schemas/ZodArray.cs +++ b/src/src/ZodSharp/Schemas/ZodArray.cs @@ -10,7 +10,7 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the ZodArray class. /// /// The schema for array elements -public class ZodArray(IZodSchema elementSchema) : ZodType +public class ZodArray(IZodSchema elementSchema) : ZodType, JsonSchema.IJsonSchemaArrayInfo { static readonly string[] EmptyPath = []; @@ -19,6 +19,15 @@ public class ZodArray(IZodSchema elementSchema) : ZodType string? _errorMessage; + // IJsonSchemaArrayInfo: the JSON Schema exporter's non-generic seam. It previously reflected on private + // field names that do not exist, so bounds and the element schema were silently dropped from every + // exported schema. See IJsonSchemaArrayInfo. + int? JsonSchema.IJsonSchemaArrayInfo.MinItems => _minLength; + + int? JsonSchema.IJsonSchemaArrayInfo.MaxItems => _maxLength; + + object JsonSchema.IJsonSchemaArrayInfo.ElementSchema => elementSchema; + /// /// Parses and validates an array value. /// @@ -33,6 +42,18 @@ protected override ValidationResult ParseInternal(T[] value) ); } + // The length constraints depend only on the array's length, so they are checked before any element + // is validated. Checking them afterwards meant an array of a million elements against .Max(10) ran a + // million element validations and allocated a million ValidationErrors — each with an interpolated + // index string — before reporting that the array was simply too long. A single request could pin a + // core and a large heap allocation on input the schema had already declared out of range. + // + // This does change which error is reported when an array is both the wrong length and has invalid + // elements: the length failure now wins. That is the more useful answer, and it matches the + // container constraint being the outer one. + if (LengthFailure(value.Length) is { } lengthFailure) + return ValidationResult.Failure(lengthFailure); + List? errors = null; List? rebuilt = null; @@ -44,9 +65,14 @@ protected override ValidationResult ParseInternal(T[] value) errors ??= []; foreach (var error in itemResult.Errors) { + // The index is the prefix, and the element's own path follows it, so a nested + // failure reads "[0].email". Use the 4-argument CopyTo: the 2-argument overload + // takes a *destination* index, which would copy the element path to the end and + // then have it overwritten by the index segment below. var path = new string[error.Path.Length + 1]; - error.Path.CopyTo(path, error.Path.Length); - path[error.Path.Length] = $"[{i}]"; + path[0] = $"[{i}]"; + + error.Path.CopyTo(0, path, 1, error.Path.Length); errors.Add(new(error.Code, error.Message, path, error.Parameters)); } } @@ -67,42 +93,46 @@ protected override ValidationResult ParseInternal(T[] value) if (errors is { Count: > 0 }) return ValidationResult.Failure(errors); - var count = value.Length; + // When every element passed through unchanged, the input array is already + // the validated result, so reuse it instead of copying. + return rebuilt is not null ? ValidationResult.Success([.. rebuilt]) : ValidationResult.Success(value); + } + + /// + /// The length-constraint failure for , or when the length + /// is acceptable. + /// + ValidationError? LengthFailure(int count) + { if (_minLength.HasValue && count < _minLength.Value) { - return ValidationResult.Failure( - new ValidationError( - "too_small", - _errorMessage ?? $"Array must have at least {_minLength.Value} elements, but got {count}", - EmptyPath, - parameters: null, - origin: "array", - minimum: _minLength.Value, - maximum: _maxLength, - inclusive: true - ) + return new ValidationError( + "too_small", + _errorMessage ?? $"Array must have at least {_minLength.Value} elements, but got {count}", + EmptyPath, + parameters: null, + origin: "array", + minimum: _minLength.Value, + maximum: _maxLength, + inclusive: true ); } if (_maxLength.HasValue && count > _maxLength.Value) { - return ValidationResult.Failure( - new ValidationError( - "too_big", - _errorMessage ?? $"Array must have at most {_maxLength.Value} elements, but got {count}", - EmptyPath, - parameters: null, - origin: "array", - minimum: _minLength, - maximum: _maxLength.Value, - inclusive: true - ) + return new ValidationError( + "too_big", + _errorMessage ?? $"Array must have at most {_maxLength.Value} elements, but got {count}", + EmptyPath, + parameters: null, + origin: "array", + minimum: _minLength, + maximum: _maxLength.Value, + inclusive: true ); } - // When every element passed through unchanged, the input array is already - // the validated result, so reuse it instead of copying. - return rebuilt is not null ? ValidationResult.Success([.. rebuilt]) : ValidationResult.Success(value); + return null; } /// diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs index 51a5689..5889f26 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; @@ -104,6 +105,20 @@ protected override ValidationResult ParseInternal(object value) /// The runtime type of the value being validated. /// The discriminator property name. /// The compiled accessor, or when no readable property exists. + /// + /// The expression-compiled fast path needs runtime code generation, which Native AOT does not provide. + /// When it is unavailable this falls back to plain reflection: slower per call, but correct, and still + /// built once per type because the result is cached by the caller. The discriminator property belongs to + /// the consumer's own type, so a trimmed application has to preserve it — which it already does if that + /// type is serialized. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:UnrecognizedReflectionPattern", + Justification = "The discriminator is a property of the consumer's own validated type, resolved from " + + "its runtime type. A trimmed application must preserve that type's properties; it is the " + + "consumer's model, not library-internal state." + )] static Func? BuildDiscriminatorAccessor(Type type, string discriminator) { var property = type.GetProperty( @@ -114,6 +129,10 @@ protected override ValidationResult ParseInternal(object value) if (property is null || !property.CanRead || property.GetMethod is not { IsStatic: false }) return null; + // Native AOT has no runtime code generation, so do not build an expression tree there. + if (!RuntimeFeature.IsDynamicCodeSupported) + return value => property.GetValue(value)?.ToString(); + var parameter = Expression.Parameter(typeof(object), "value"); Expression access = Expression.Property(Expression.Convert(parameter, type), property); Expression boxed = Expression.Convert(access, typeof(object)); diff --git a/src/src/ZodSharp/Schemas/ZodLiteral.cs b/src/src/ZodSharp/Schemas/ZodLiteral.cs index a989826..29cf061 100644 --- a/src/src/ZodSharp/Schemas/ZodLiteral.cs +++ b/src/src/ZodSharp/Schemas/ZodLiteral.cs @@ -10,11 +10,15 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the ZodLiteral class. /// /// The literal value -public class ZodLiteral(T value) : ZodType +public class ZodLiteral(T value) : ZodType, JsonSchema.IJsonSchemaLiteralInfo where T : IEquatable { readonly T _value = value; + // Read by the JSON Schema exporter, which previously matched this type by name and read the value by + // private field name. See IJsonSchemaLiteralInfo. + object? JsonSchema.IJsonSchemaLiteralInfo.LiteralValue => _value; + /// /// Parses and validates the value against the literal. /// diff --git a/src/src/ZodSharp/Schemas/ZodNullable.cs b/src/src/ZodSharp/Schemas/ZodNullable.cs index d3c6cc4..f0d1339 100644 --- a/src/src/ZodSharp/Schemas/ZodNullable.cs +++ b/src/src/ZodSharp/Schemas/ZodNullable.cs @@ -10,9 +10,17 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the ZodNullable class. /// /// The inner schema -public class ZodNullable(IZodSchema innerSchema) : ZodType, IAcceptsNull +public class ZodNullable(IZodSchema innerSchema) + : ZodType, + IAcceptsNull, + JsonSchema.IJsonSchemaNullableInfo where T : struct { + // The exporter previously reached the inner schema by reflecting a field named "_innerSchema", which does + // not exist (it is a primary-constructor parameter), so a nullable schema exported with no inner type at + // all. See IJsonSchemaNullableInfo. + object JsonSchema.IJsonSchemaNullableInfo.NullableInnerSchema => innerSchema; + /// public ValidationResult ValidateNull() => ValidationResult.Success(null!); diff --git a/src/src/ZodSharp/Schemas/ZodObjectBuilder.cs b/src/src/ZodSharp/Schemas/ZodObjectBuilder.cs index e4cd820..b6bd074 100644 --- a/src/src/ZodSharp/Schemas/ZodObjectBuilder.cs +++ b/src/src/ZodSharp/Schemas/ZodObjectBuilder.cs @@ -35,8 +35,15 @@ public ZodObjectBuilder Field(string name, IZodSchema schema) public ZodObject Build() => new(_shape.ToImmutableDictionary()); /// The inner type - sealed class SchemaWrapper(IZodSchema inner) : IZodSchema, IOptionalSchema + sealed class SchemaWrapper(IZodSchema inner) + : IZodSchema, + IOptionalSchema, + JsonSchema.IJsonSchemaInnerSchema { + // Lets the JSON Schema exporter unwrap to the schema that determines the exported shape. Without it + // every field of an exported object schema came back with no type. See IJsonSchemaInnerSchema. + object JsonSchema.IJsonSchemaInnerSchema.InnerSchema => inner; + public bool IsOptional => inner is IOptionalSchema o && o.IsOptional; public bool ProvidesValueOnMissing => inner is IOptionalSchema o && o.ProvidesValueOnMissing; diff --git a/src/src/ZodSharp/Schemas/ZodOptional.cs b/src/src/ZodSharp/Schemas/ZodOptional.cs index 6edb7ce..2e17eef 100644 --- a/src/src/ZodSharp/Schemas/ZodOptional.cs +++ b/src/src/ZodSharp/Schemas/ZodOptional.cs @@ -10,9 +10,18 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the ZodOptional class. /// /// The inner schema -public class ZodOptional(IZodSchema innerSchema) : ZodType, IAcceptsNull +public class ZodOptional(IZodSchema innerSchema) + : ZodType, + IAcceptsNull, + JsonSchema.IJsonSchemaInnerSchema where T : class { + // In JSON Schema, optionality is expressed by omitting the property from "required", not by changing its + // type — so the exporter unwraps to the inner schema. It previously tried to reach it by reflecting a + // field named "_innerSchema", which does not exist (it is a primary-constructor parameter), so every + // optional property exported as an empty "any" schema. See IJsonSchemaInnerSchema. + object JsonSchema.IJsonSchemaInnerSchema.InnerSchema => innerSchema; + /// public override bool IsOptional => true; diff --git a/src/src/ZodSharp/Schemas/ZodTransform.cs b/src/src/ZodSharp/Schemas/ZodTransform.cs index 446e7ea..e83fd61 100644 --- a/src/src/ZodSharp/Schemas/ZodTransform.cs +++ b/src/src/ZodSharp/Schemas/ZodTransform.cs @@ -32,11 +32,27 @@ protected override ValidationResult ParseInternal(TInput value) var transformedValue = transform(validationResult.Value); return ValidationResult.Success(transformedValue); } - catch (Exception ex) + catch (Exception ex) when (IsTransformFailure(ex)) { - return ValidationResult.Failure( - new ValidationError("transform_error", $"Transform failed: {ex.Message}", []) - ); + // The exception message is deliberately NOT part of the validation error. A transform is + // arbitrary caller code — it may reach a database or a service — and a validation error flows + // into ProblemDetails and out to the HTTP client, so putting the message there discloses + // internal detail to whoever made the request. Only the fixed code and message are reported. + _ = ex; + + return ValidationResult.Failure(new ValidationError("transform_error", "Transform failed.", [])); } } + + /// + /// Whether is a transform failure rather than something that must not be + /// turned into a validation error. + /// + /// + /// The previous blanket catch (Exception) also swallowed cancellation — breaking a caller's + /// contract — and conditions the process cannot continue + /// through, such as . Those propagate. + /// + static bool IsTransformFailure(Exception exception) => + exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException); } diff --git a/src/src/ZodSharp/Schemas/ZodUnion.cs b/src/src/ZodSharp/Schemas/ZodUnion.cs index 259e9ac..b44d396 100644 --- a/src/src/ZodSharp/Schemas/ZodUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodUnion.cs @@ -11,6 +11,11 @@ namespace ZodSharp.Schemas; /// The union options public class ZodUnion(IReadOnlyList> options) : ZodType { + // Exposed to the JSON Schema exporter, which previously reflected a field named "_options" and cast it + // to an array. Neither matched: the options are a primary-constructor parameter typed IReadOnlyList, so + // the lookup always failed and every union exported with an empty "anyOf". + internal IReadOnlyList> Options => options; + /// /// Parses and validates the value against union options. /// diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index 7b5a29d..b2377d8 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -10,6 +10,23 @@ true zod;validation;schema + + true $(NoWarn);NU5118 + All preview - $(ZodSharpNetTargetFrameworks) ZodSharp false @@ -16,14 +17,11 @@ - - - - - + - $(ZodSharpNetTargetFrameworks) + $(PurviewTargetFrameworksAll) diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 1751d85..640de6c 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -1,6 +1,6 @@  - $(ZodSharpNetTargetFrameworks) + $(PurviewTargetFrameworksAll) true