From 094b2d514686ecd54963f3105d6038c77ad59c98 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 19:57:14 +0100 Subject: [PATCH] fix: nullable scalar support --- CHANGELOG.md | 14 + docs/wiki/Source-Generator-DataAnnotations.md | 2 + docs/wiki/Validation-Rules-Reference.md | 6 +- package.json | 2 +- .../Helpers/CustomRuleResolver.cs | 17 +- .../SourceGenerators/Helpers/TypeLibrary.cs | 3 + .../Models/CustomRuleDescriptor.cs | 8 +- .../ZodSchemaGenerator.BuildSchema.cs | 47 ++- .../ZodSchemaGenerator.CustomRules.cs | 303 +++++++++++++----- src/src/ZodSharp/Core/IRequiredRule.cs | 18 ++ src/src/ZodSharp/Rules/RequiredRule.cs | 20 +- src/src/ZodSharp/Schemas/ZodString.cs | 6 +- ...emaGeneratorTests.BuiltInRuleAttributes.cs | 55 +++- ...odSchemaGeneratorTests.ScalarAdaptation.cs | 102 ++++++ .../Rules/GeneratedRuleAttributesTests.cs | 2 +- .../Rules/RequiredRuleTests.cs | 2 +- 16 files changed, 501 insertions(+), 106 deletions(-) create mode 100644 src/src/ZodSharp/Core/IRequiredRule.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f283c58..966e644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,13 @@ been published to NuGet. ### Fixed +- **`[RequiredZod]` now rejects an absent value exactly like `[Required]`.** A custom rule bound to a + reference-type member was emitted inside the generator's non-null guard, so a missing value never reached + `RequiredRule` and options/schema validation passed. The rule now implements the new `IRequiredRule` + marker and the generator emits it before the guard, reporting `missing_field` for `null` (and, unless + allowed, empty or whitespace-only strings). The attribute's `AllowEmptyString` property was renamed to + `AllowEmptyStrings` to match `RequiredAttribute.AllowEmptyStrings`, so `[RequiredZod]` is a drop-in + replacement for `[Required]`. - **`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 @@ -155,6 +162,13 @@ been published to NuGet. - Scalar schema generation, which had regressed. - The `uuid` attribute rule required both constructors to be present to generate. - Nullability annotations on `IZodRule` properties. +- **A scalar rule adapted over a nullable reference value no longer emits a nullability-mismatched adapter.** + `CustomRuleResolver` built the `ScalarRuleAdapter` identity from a `TypeIdentity`, + which drops nullable reference annotations, while the wrapped rule was closed over a `TypeReference` that + keeps them. A scalar backed by `string?` therefore emitted + `ScalarRuleAdapter>`, whose `TValue` satisfied neither the adapter's + `IValidationRule` constraint nor the value object's `IScalarValueObject` — reporting + CS8631 at every consumer. The value argument is now carried as a `TypeReference`, so both sides stay in step. ### Added — packed generator smoke test diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index e4c2975..84cca0d 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -25,6 +25,8 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. +`[RequiredZod]` (the generated `RequiredRule` attribute) mirrors `[Required]`: it is emitted outside the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` property has the same name and meaning as `RequiredAttribute.AllowEmptyStrings`, and it additionally exposes `TrimWhitespace`, so it is a drop-in replacement for `[Required]`. + ## 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. diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 403b269..6b2acc0 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -200,7 +200,7 @@ These rules mirror `System.ComponentModel.DataAnnotations` attributes as first-c | 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` | +| `RequiredRule` | `(bool allowEmptyStrings = 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}` | @@ -208,7 +208,7 @@ These rules mirror `System.ComponentModel.DataAnnotations` attributes as first-c | `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. +`RequiredRule` always rejects `null`. When `allowEmptyStrings` 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. @@ -301,7 +301,7 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[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` | +| `[RequiredZod(…)]` | `RequiredRule` | suffixed; exposes `AllowEmptyStrings`/`TrimWhitespace` and rejects an absent value exactly like `[Required]` | | `[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 | diff --git a/package.json b/package.json index e6ca1dc..c5954ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.1", + "version": "2.0.1.1", "private": true, "license": "MIT", "author": { diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 7d6dc66..b4e9c02 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -128,7 +128,8 @@ scalarTarget is null GetAttributeString(attribute, "Origin") ?? mapping.Origin, ValidationAttributeData.FromAttributeData(attribute), arguments, - IsZodRule(ruleType) + IsZodRule(ruleType), + TypeHelpers.IsOrImplements(ruleType, TypeLibrary.ZodSharp.Core.IRequiredRule) ) ); @@ -320,6 +321,14 @@ static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueT return null; } + /// + /// Builds the Purview.ValueObjects.ScalarRuleAdapter<TSelf, TValue, TRule> identity the + /// adapted rule is emitted as. The value argument is carried as a TypeReference rather than a + /// TypeIdentity because an identity drops nullable reference annotations: a + /// scalar backed by string? would otherwise be closed over NonSentinelRule<string?> + /// but constrained by IValidationRule<string>, which the invariant contract rejects + /// (CS8631). + /// static TypeIdentity BuildScalarAdapterIdentity( INamedTypeSymbol scalar, ITypeSymbol valueType, @@ -329,7 +338,11 @@ INamedTypeSymbol underlyingRule TypeLibraryGenerator.ScalarRuleAdapterName, TypeLibraryGenerator.ValueObjectsNamespace, 3 - ).MakeGeneric(new TypeIdentity(scalar), new TypeIdentity(valueType), new TypeIdentity(underlyingRule)); + ).MakeGeneric( + new TypeIdentity(scalar).AsTypeReference(), + TypeReference.Create(valueType), + new TypeIdentity(underlyingRule).AsTypeReference() + ); /// /// Resolves the rule type to instantiate: a plain type is used as-is, an unbound generic diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index a91f79f..bae32a2 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -63,6 +63,9 @@ static partial class TypeLibraryGenerator [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity IZodRule = default; + [TypeRef(ZodSharpCoreNamespace)] + static readonly TypeIdentity IRequiredRule = default; + [TypeRef(ZodSharpCoreNamespace)] static readonly TypeIdentity ZodRuleAttribute = default; diff --git a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs index 05b8754..b41c29c 100644 --- a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs +++ b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs @@ -20,6 +20,11 @@ namespace ZodSharp.SourceGenerators.Models; /// 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. /// +/// +/// Whether the rule implements IRequiredRule and therefore rejects an absent value. Such a rule is +/// emitted outside the generator's non-null guard so a missing value fails exactly as +/// [Required] fails. +/// readonly record struct CustomRuleDescriptor( TypeIdentity RuleType, TypeIdentity? AdaptedFrom, @@ -27,5 +32,6 @@ readonly record struct CustomRuleDescriptor( string? Origin, ValidationAttributeData Message, EquatableArray Arguments, - bool RuleOwnsIdentity + bool RuleOwnsIdentity, + bool IsRequired ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 5057bd5..0793279 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -683,6 +683,10 @@ CancellationToken cancellationToken // absorbed into the rule's disallowed set, so the standalone denied-values validation is skipped. var emitEnumRule = IsEnumValidationEmitted(property, validateEnumValues); + // A rule that rejects an absent value (IRequiredRule) stands in for [Required]: it is emitted outside + // the non-null guard, so a missing value fails instead of being skipped. + var hasRequiredRule = TryGetRequiredRule(property, out var requiredRule); + if (property.CanBeNull && attributes.Required.ShouldProcess && attributes.Required.Value.Exists) { var required = attributes.Required.Value; @@ -704,6 +708,12 @@ CancellationToken cancellationToken block = writer.ElseScope(); GenerateValueSetValidations(writer, property, emitEnumRule); } + else if (hasRequiredRule) + { + GenerateRequiredRuleValidation(writer, property, requiredRule); + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } else { GenerateValueSetValidations(writer, property, emitEnumRule); @@ -711,14 +721,45 @@ CancellationToken cancellationToken block = writer.IfBlockScope($"value.{propertyName} != null"); } - GenerateTypeSpecificValidations(writer, property, emitEnumRule); + GenerateTypeSpecificValidations(writer, property, emitEnumRule, skipRequiredRule: hasRequiredRule); block?.Dispose(); GenerateCompareValidation(writer, property); writer.NewLine(); } - static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescriptor property, bool emitEnumRule) + /// + /// Finds the rule that rejects an absent value (IRequiredRule) among the property's custom rules, + /// when the property can be null. + /// + /// The property to inspect. + /// The required rule, when one is bound to the property. + /// when a required rule is bound to a nullable property. + static bool TryGetRequiredRule(ZodPropertyDescriptor property, out CustomRuleDescriptor requiredRule) + { + requiredRule = default; + + if (!property.CanBeNull) + return false; + + foreach (var rule in property.CustomRules) + { + if (!rule.IsRequired) + continue; + + requiredRule = rule; + return true; + } + + return false; + } + + static void GenerateTypeSpecificValidations( + CodeWriter writer, + ZodPropertyDescriptor property, + bool emitEnumRule, + bool skipRequiredRule + ) { switch (property.ValidationKind) { @@ -745,7 +786,7 @@ static void GenerateTypeSpecificValidations(CodeWriter writer, ZodPropertyDescri break; } - GenerateCustomRuleValidations(writer, property); + GenerateCustomRuleValidations(writer, property, skipRequiredRule); } static void GenerateCompareValidation(CodeWriter writer, ZodPropertyDescriptor property) diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index a4e6ba6..6747ea8 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -10,7 +10,13 @@ partial class ZodSchemaGenerator /// carry [ZodRule(typeof(...))]. The emitted call reuses the rule's own /// IsValid/GetErrorMessage contract, so a custom rule behaves exactly like a built-in one. /// - static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescriptor property) + /// The writer positioned inside the validation method. + /// The property being validated. + /// + /// Whether a rule that rejects an absent value (IRequiredRule) has already been emitted outside the + /// non-null guard and must not be emitted again here. + /// + static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescriptor property, bool skipRequiredRule) { if (property.CustomRules.Count == 0) return; @@ -19,14 +25,79 @@ static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescript // 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( + for (var i = 0; i < property.CustomRules.Count; i++) + { + var rule = property.CustomRules[i]; + + // A required rule is emitted outside the guard by GenerateRequiredRuleValidation; emitting it here + // would duplicate it and hide a missing value behind the guard. + if (skipRequiredRule && rule.IsRequired) + continue; + + EmitCustomRule( + writer, + rule, + property.Name, + i, + valueExpression, + CodeGenHelpers.GetPathFieldName(property.Name), + property.DisplayName, + declareValueLocal: true + ); + } + } + + /// + /// Emits a rule that rejects an absent value (IRequiredRule) before the non-null guard, so a + /// missing value fails exactly as [Required] fails instead of being skipped. The remaining + /// validations run only when this rule passes. + /// + /// The writer positioned inside the validation method. + /// The property being validated. + /// The resolved required rule. + static void GenerateRequiredRuleValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + CustomRuleDescriptor requiredRule + ) + { + var propertyName = property.Name; + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(propertyName, "RequiredRule"); + var arguments = $"({string.Join(", ", requiredRule.Arguments)})"; + + var identityVariable = EmitRuleInstance( + writer, + requiredRule, + CodeGenHelpers.GetLocalIdentifier(propertyName, "RequiredRuleInner"), + ruleVariable, + arguments + ); + + if (property.IsNullableValueType) + { + // The rule is closed over the underlying value, so it cannot be handed null. An absent value is + // reported directly, matching how [Required] handles a Nullable member. + WriteRuleFailure( + writer, + requiredRule, + ruleVariable, + identityVariable, + "default!", + CodeGenHelpers.GetPathFieldName(propertyName), + property.DisplayName, + invalidCondition: $"value.{propertyName} == null" + ); + return; + } + + WriteRuleFailure( writer, - property.CustomRules, - property.Name, - valueExpression, - CodeGenHelpers.GetPathFieldName(property.Name), - property.DisplayName, - declareValueLocal: true + requiredRule, + ruleVariable, + identityVariable, + $"value.{propertyName}", + CodeGenHelpers.GetPathFieldName(propertyName), + property.DisplayName ); } @@ -62,82 +133,152 @@ bool declareValueLocal { for (var i = 0; i < rules.Count; i++) { - var rule = rules[i]; - var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{i}"); - var valueVariable = declareValueLocal - ? CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleValue{i}") - : valueExpression; - var arguments = $"({string.Join(", ", rule.Arguments)})"; - - if (declareValueLocal) - writer.Assignment("var", valueVariable, valueExpression); - - // 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) + EmitCustomRule( + writer, + rules[i], + localPrefix, + i, + valueExpression, + pathExpression, + displayName, + declareValueLocal + ); + } + } + + static void EmitCustomRule( + CodeWriter writer, + CustomRuleDescriptor rule, + string localPrefix, + int index, + string valueExpression, + string pathExpression, + string displayName, + bool declareValueLocal + ) + { + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{index}"); + var valueVariable = declareValueLocal + ? CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleValue{index}") + : valueExpression; + var arguments = $"({string.Join(", ", rule.Arguments)})"; + + if (declareValueLocal) + writer.Assignment("var", valueVariable, valueExpression); + + var identityVariable = EmitRuleInstance( + writer, + rule, + CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleInner{index}"), + ruleVariable, + arguments + ); + + WriteRuleFailure(writer, rule, ruleVariable, identityVariable, valueVariable, pathExpression, displayName); + + writer.NewLine(); + } + + /// + /// Emits the rule instance. 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). + /// + /// The writer positioned inside the validation method. + /// The resolved rule descriptor. + /// The local name for the wrapped rule when the rule is adapted. + /// The local name for the rule instance. + /// The rendered rule constructor arguments. + /// The local name holding the rule that owns the error identity. + static string EmitRuleInstance( + CodeWriter writer, + CustomRuleDescriptor rule, + string innerVariable, + string ruleVariable, + string arguments + ) + { + if (rule.AdaptedFrom is { } adaptedRuleType) + { + writer.Assignment( + "var", + innerVariable, + $"new {adaptedRuleType.AsTypeReference().RenderFullName}{arguments}" + ); + writer.Assignment( + "var", + ruleVariable, + $"new {rule.RuleType.AsTypeReference().RenderFullName}({innerVariable})" + ); + return innerVariable; + } + + writer.Assignment("var", ruleVariable, $"new {rule.RuleType.AsTypeReference().RenderFullName}{arguments}"); + return ruleVariable; + } + + /// + /// Emits the failure branch that records a rule's error when it does not validate the value. + /// + /// The writer positioned inside the validation method. + /// The resolved rule descriptor. + /// The local name holding the rule instance. + /// The local name holding the rule that owns the error identity. + /// The expression passed to the rule for the message. + /// The error path expression. + /// The display name used for a custom error message. + /// + /// The condition that reports the failure. Defaults to the rule's own !IsValid(...) contract; a + /// required rule closed over a value type supplies an explicit null comparison instead. + /// + static void WriteRuleFailure( + CodeWriter writer, + CustomRuleDescriptor rule, + string ruleVariable, + string identityVariable, + string valueVariable, + string pathExpression, + string displayName, + string? invalidCondition = null + ) + { + var codeFallback = rule.Code is { Length: > 0 } customCode ? customCode : "validation_failed"; + var zodRuleInterface = TypeLibrary.ZodSharp.Core.IZodRule.AsTypeReference().RenderFullName; + + // A rule that implements IZodRule owns its error identity; the attribute-mapped value is only a + // fallback. The cast is required because the interface may be implemented explicitly. + var codeExpression = rule.RuleOwnsIdentity + ? $"(({zodRuleInterface}){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}){identityVariable}).Origin" + : $"(({zodRuleInterface}){identityVariable}).Origin ?? {originFallback}" + : originFallback ?? "null"; + + var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) + ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) + : $"{ruleVariable}.GetErrorMessage({valueVariable})"; + + writer.IfBlock( + invalidCondition ?? $"!{ruleVariable}.IsValid({valueVariable})", + ifBody => { - identityVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleInner{i}"); - writer.Assignment( - "var", - identityVariable, - $"new {adaptedRuleType.AsTypeReference().RenderFullName}{arguments}" + ifBody.IfBlock( + "errors is null", + errorsBody => + errorsBody.Assignment( + "errors", + "new global::System.Collections.Generic.List()" + ) ); - writer.Assignment( - "var", - ruleVariable, - $"new {rule.RuleType.AsTypeReference().RenderFullName}({identityVariable})" + ifBody.MethodCallOn( + "errors", + "Add", + $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({codeExpression}, {message}, {pathExpression}, origin: {originExpression})" ); } - 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; - - // 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}){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}){identityVariable}).Origin" - : $"(({zodRuleInterface}){identityVariable}).Origin ?? {originFallback}" - : originFallback ?? "null"; - - var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) - ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) - : $"{ruleVariable}.GetErrorMessage({valueVariable})"; - - writer.IfBlock( - $"!{ruleVariable}.IsValid({valueVariable})", - ifBody => - { - ifBody.IfBlock( - "errors is null", - errorsBody => - errorsBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - ifBody.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({codeExpression}, {message}, {pathExpression}, origin: {originExpression})" - ); - } - ); - - writer.NewLine(); - } + ); } } diff --git a/src/src/ZodSharp/Core/IRequiredRule.cs b/src/src/ZodSharp/Core/IRequiredRule.cs new file mode 100644 index 0000000..1a1a618 --- /dev/null +++ b/src/src/ZodSharp/Core/IRequiredRule.cs @@ -0,0 +1,18 @@ +namespace ZodSharp.Core; + +/// +/// Identifies validation rules that reject an absent () value, such as +/// . +/// +/// +/// A rule bound to a nullable member is normally emitted inside the non-null guard the schema generator +/// applies, so a missing value never reaches it. A rule that implements this contract is instead emitted +/// outside that guard, so an absent value is reported exactly as +/// System.ComponentModel.DataAnnotations.RequiredAttribute reports it. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1040:Avoid empty interfaces", + Justification = "Marker interface consumed by the schema generator." +)] +public interface IRequiredRule { } diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs index e01bff8..d8911d8 100644 --- a/src/src/ZodSharp/Rules/RequiredRule.cs +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -11,20 +11,22 @@ namespace ZodSharp.Rules; /// /// /// For a string value the rule always rejects . When -/// allowEmptyString is +/// allowEmptyStrings 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 +/// The generated [RequiredZod] attribute surfaces both options as constructor/property arguments and +/// mirrors System.ComponentModel.DataAnnotations.RequiredAttribute: it is emitted outside the +/// generator's non-null guard, so an absent value fails exactly as [Required] fails. 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 +public readonly record struct RequiredRule : Core.IValidationRule, Core.IZodRule, Core.IRequiredRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "missing_field"; @@ -32,25 +34,25 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Field is required"; - readonly bool _allowEmptyString; + readonly bool _allowEmptyStrings; readonly bool _trimWhitespace; readonly string _message; /// /// Initializes a new instance of the struct. /// - /// Whether an empty string satisfies the rule. Defaults to . + /// 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 allowEmptyStrings = false, bool trimWhitespace = false, string? message = null, string? code = null ) { - _allowEmptyString = allowEmptyString; + _allowEmptyStrings = allowEmptyStrings; _trimWhitespace = trimWhitespace; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); @@ -69,7 +71,7 @@ public bool IsValid(in T value) if (text is null) return false; - if (_allowEmptyString) + if (_allowEmptyStrings) return true; // If we get here, the string is not null and empty strings are not allowed. Check for whitespace if needed. diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index d8cdd85..0cebcd4 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -533,19 +533,19 @@ public override ZodString NonSentinel(string? message = null, string? code = nul /// Adds a required validation that rejects (and, unless allowed, empty or /// whitespace-only) strings. /// - /// Whether an empty string satisfies the rule. + /// 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 allowEmptyStrings = false, bool trimWhitespace = false, string? message = null, string? code = null ) { - AddRule(new RequiredRule(allowEmptyString, trimWhitespace, message, code)); + AddRule(new RequiredRule(allowEmptyStrings, trimWhitespace, message, code)); return this; } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index 3722ff2..db24042 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -701,7 +701,7 @@ namespace Testing [ZodSchema] public partial class Account { - [RequiredZod(AllowEmptyString = false, TrimWhitespace = true)] + [RequiredZod(AllowEmptyStrings = false, TrimWhitespace = true)] public string Name { get; set; } = string.Empty; } } @@ -717,6 +717,59 @@ await Assert .ContainsGeneratedCode("new global::ZodSharp.Rules.RequiredRule(false, true, null!, null!)"); } + [Test] + public async Task GeneratedValidate_GivenRequiredZodOnMissingValue_ReportsMissingFieldLikeRequired( + CancellationToken cancellationToken + ) + { + // Arrange - [RequiredZod] must reject an absent value exactly as [Required] does, so the rule is + // emitted outside the generator's non-null guard instead of being skipped. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed class RequiredParityModel + { + [RequiredZod(AllowEmptyStrings = false, TrimWhitespace = true)] + public string ZodName { get; set; } + + [Required(AllowEmptyStrings = false)] + public string DataAnnotationName { get; set; } + } + } + """; + + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var modelType = assembly.GetType("Testing.RequiredParityModel")!; + var model = Activator.CreateInstance(modelType)!; + + // Act + var result = InvokeValidate(assembly, model, "Testing.RequiredParityModelSchema"); + var errors = (System.Collections.Immutable.ImmutableArray) + result.GetType().GetProperty("Errors")!.GetValue(result)!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + await Assert + .That(errors.Any(static error => error.Path.Contains("ZodName") && error.Code == "missing_field")) + .IsTrue(); + await Assert + .That( + errors.Any(static error => error.Path.Contains("DataAnnotationName") && error.Code == "missing_field") + ) + .IsTrue(); + } + [Test] public async Task RuleAttributeGeneration_GivenAllowedValuesZodAttribute_ClosesTheArrayWithTheMemberType( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 00f0131..9e5b727 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -78,6 +78,108 @@ public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject< } """; + /// + /// A scalar whose underlying value is a nullable reference. The adapter's TValue has to keep the + /// annotation, because the wrapped rule is closed over string? and the value object implements + /// IScalarValueObject<TSelf, string?>: an adapter closed over string satisfies + /// neither constraint and the generated schema reports CS8631. + /// + const string NullableScalarAdaptationSource = """ + 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 NotNullRule(string? Message = null) + : IValidationRule, IZodRule + { + public bool IsValid(in T value) => value is not null; + + public string GetErrorMessage(in T value) => Message ?? "Value must not be null."; + + string? IZodRule.Code => "null_value"; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NotNullRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NotNullAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [Purview.ValueObjects.Serialization.Scalar] + [NotNull(Message = "TenantKey must not be null.")] + [ZodSchema] + public partial record struct TenantKey : Purview.ValueObjects.IScalarValueObject + { + public string? Value { get; init; } + } + } + """; + + [Test] + public async Task ScalarAdaptation_GivenNullableReferenceScalarValue_KeepsTheNullableAnnotation( + CancellationToken cancellationToken + ) + { + // Act + var driverResult = await GenerateAsync( + NullableScalarAdaptationSource, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generated = driverResult.GetSource("TenantKeySchema"); + + // Assert — the wrapped rule and the adapter's TValue are both closed over string?, matching the + // rule's IValidationRule and the scalar's IScalarValueObject. + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NotNullRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + + // The generated schema is in a #nullable enable context, so an adapter closed over the wrong + // nullability would surface here as CS8631 rather than at a consumer's build. + var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); + await Assert.That(diagnostics).DoesNotContain(d => d.Id == "CS8631"); + } + [Test] public async Task ScalarAdaptation_GivenRuleWrittenAgainstTheUnderlyingValue_EmitsAdapter( CancellationToken cancellationToken diff --git a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs index 66a3b4c..c9684b2 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/GeneratedRuleAttributesTests.cs @@ -218,7 +218,7 @@ public async Task Attribute_GivenRequiredRule_GeneratesTheZodSuffixedAttribute(C // 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("AllowEmptyStrings")).IsNotNull(); await Assert.That(attributeType.GetProperty("TrimWhitespace")).IsNotNull(); await Assert.That(attributeType.GetProperty("Message")).IsNotNull(); await Assert.That(attributeType.GetProperty("Code")).IsNotNull(); diff --git a/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs index dc73c96..89d3d84 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/RequiredRuleTests.cs @@ -38,7 +38,7 @@ public async Task IsValid_GivenEmptyStringAndEmptyAllowed_ReturnsTrue(Cancellati cancellationToken.ThrowIfCancellationRequested(); // Arrange - RequiredRule rule = new(allowEmptyString: true); + RequiredRule rule = new(allowEmptyStrings: true); // Act var isValid = rule.IsValid(string.Empty);