From 63c6eb53828eee837702dce01a86dee586dc4733 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 23 Oct 2026 12:41:27 +0100 Subject: [PATCH] fix: nullable value types were failing schema gen --- CHANGELOG.md | 26 ++ docs/wiki/Release-Flow.md | 2 +- docs/wiki/Source-Generator.md | 2 +- docs/wiki/Value-Objects-Integration.md | 6 +- package.json | 2 +- src/Directory.Build.targets | 2 +- .../PreserveOffsetDateTimeOffsetConverter.cs | 1 + .../Helpers/CustomRuleResolver.cs | 143 +++++-- .../Helpers/SourceGenLibrary.cs | 22 +- .../Models/CustomRuleDescriptor.cs | 9 +- src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 2 +- .../ZodSchemaGenerator.BuildSchema.cs | 16 + .../ZodSchemaGenerator.CustomRules.cs | 95 +++++ .../ZodSchemaGenerator.JsonSchema.cs | 6 +- .../ZodSchemaAnalyzerTests.CustomRules.cs | 63 +++ ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 396 ++++++++++++++++++ 16 files changed, 748 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b17ce7e..a5e04c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## 2.4.1 + +### Fixed + +- **Nullable value-type scalars.** `Purview.ValueObjects` 1.2.1 applies `Nullable = true` to a value type + by wrapping it in `Nullable`. ZodSharp only applied the option to reference types, so + `[Scalar(Nullable = true)]` closed its rules over `DateTimeOffset` while the value object + implemented `IScalarValueObject`, which failed the generated adapter's constraint + (`CS0315`). The automatic scalar forms now mirror the value-object generator for both reference and value + types. +- **Rules on a nullable value scalar are null-guarded and unwrapped.** A type-level or property-level rule + written against the underlying value is resolved against the non-nullable value type and run only when the + value is present, mirroring how a `Nullable` property is validated. This also lets constrained rules + (`MinValueRule`, numeric rules, comparison rules, `EnumRule`, ...) resolve, because + `Nullable` does not implement their constraints. `[RequiredZod]` still rejects an absent value, and the + exported JSON Schema no longer advertises a `null` branch when a required rule is present. +- **Nullable scalar properties validate their unwrapped value.** A property declared as `T?` where `T` is a + scalar value object passed the `T?` value to the nested `TSchema.Validate(T)` call (`CS1503`). The nested + schema is now invoked with `T` inside the null guard. + +### Upgrade notes + +- A nullable value scalar now accepts `null` unless it carries `[RequiredZod]`; a rule written against its + underlying value is skipped for `null` and receives the non-nullable value otherwise. This matches the + behaviour of a nullable `Nullable` property. + ## 2.4.0 ### Added diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 7cf2a997..ce22765a 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,7 +4,7 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g ## Versioning -The package version comes from `package.json` (`version` field). The current stable line is `2.4.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). +The package version comes from `package.json` (`version` field). The current stable line is `2.4.1`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 0d3bc5f4..e7133472 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -299,7 +299,7 @@ A nullable enum property is validated only when it is not `null`. - 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). +- 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, or resolved against the unwrapped value and null-guarded when the underlying value is a `Nullable` — 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/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index 57f12d74..1b10f8b0 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -81,7 +81,9 @@ so `AssetId` is `IScalarValueObject`. [Scalar("Id")] [Scalar(typeof(Guid), "Id")] ``` -A nullable reference type cannot be a generic attribute argument (`[Scalar]` reports `CS8970`) or a `typeof` operand (`typeof(string?)` reports `CS8639`), so a nullable reference scalar is written with `Nullable = true`. Nullable value types are written directly (`[Scalar]` / `[Scalar(typeof(int?))]`). A nullable scalar round-trips JSON `null`, so its schema accepts `null`. +A nullable reference type cannot be a generic attribute argument (`[Scalar]` reports `CS8970`) or a `typeof` operand (`typeof(string?)` reports `CS8639`), so a nullable reference scalar is written with `Nullable = true`. For a value type, `Nullable = true` wraps the underlying type in `Nullable`, so `[Scalar(Nullable = true)]` emits `public DateTimeOffset? Value`. Nullable value types can also be written directly (`[Scalar]` / `[Scalar(typeof(int?))]`). A nullable scalar round-trips JSON `null`, so its schema accepts `null`. + +When the underlying value is nullable, a type-level rule written against that value is applied only when a value is present. The rule is closed over the non-nullable value type (so constrained rules such as `MinValueRule` work), and `null` is treated as absent. Use `[RequiredZod]` when `null` must itself fail, exactly as for a nullable member. The property is emitted by the value-object generator, which ZodSharp cannot observe, so the automatic form has no source member for ZodSharp to read. ZodSharp resolves the underlying type from the attribute instead, so the schema is generated exactly as for the manual form: @@ -94,7 +96,7 @@ public readonly partial record struct InstallationId { } Because there is no property to carry DataAnnotations, the automatic form supports **type-level** rules only: -- `[RequiredZod]`, `[NullOrNonWhiteSpace]`, `[NonSentinel]`, and any custom `[ZodRule(typeof(...))]`-mapped attribute applied to the type are read from the type and adapted to the scalar's underlying value, reporting an **empty path** exactly as for the manual form. +- `[RequiredZod]`, `[NullOrNonWhiteSpace]`, `[NonSentinel]`, and any custom `[ZodRule(typeof(...))]`-mapped attribute applied to the type are read from the type and applied to the scalar's underlying value, reporting an **empty path** exactly as for the manual form. For a nullable value scalar the rule runs only when the value is present; for a nullable reference scalar the rule receives the nullable value so it can decide (hence the null-tolerant `[NullOrNonWhiteSpace]`). - A **property-level DataAnnotation has no host** on the generated property, so it is never observed. Use a Zod-native type-level rule, or switch to the manual `[Scalar]` form when property-level DataAnnotations are required. - `[RequiredZod]` always rejects `null`; use `[NullOrNonWhiteSpace]` (or a custom null-tolerant rule) when a nullable scalar must accept `null` while rejecting whitespace. diff --git a/package.json b/package.json index cb55986c..4af5ac7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.4.0", + "version": "2.4.1", "private": true, "license": "MIT", "author": { diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 5cde60df..ea8040de 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -15,6 +15,6 @@ --> true - 2.3.0 + 2.4.0 diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs index 4ce8c990..a2303b28 100644 --- a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs +++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs @@ -40,6 +40,7 @@ JsonSerializer serializer if (reader.TokenType == JsonToken.Null) return null; + // Read the raw string and parse it with DateTimeOffset.Parse to preserve the offset. return DateTimeOffset.Parse( UtcDateTimeOffsetConverter.ReadRawString(reader), CultureInfo.InvariantCulture, diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index bbb9b0fa..dcf3d0f9 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -32,6 +32,7 @@ static class CustomRuleResolver /// /// The symbol whose rule attributes are resolved. /// The type the rules are applied to. + /// The compilation, used to construct a value type. /// /// Whether the rules are applied to itself rather than to a member. A /// type-level rule on a scalar reports an empty path, so its default message names the scalar. @@ -45,15 +46,18 @@ static class CustomRuleResolver public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, + Compilation compilation, bool isTypeLevel = false - ) => Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder(), isTypeLevel); + ) => + Resolve(symbol, ruleTargetType, compilation, ImmutableArray.CreateBuilder(), isTypeLevel); public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, + Compilation compilation, ImmutableArray.Builder diagnostics, bool isTypeLevel = false - ) => Resolve(symbol.GetAttributes(), ruleTargetType, diagnostics, isTypeLevel); + ) => Resolve(symbol.GetAttributes(), ruleTargetType, compilation, diagnostics, isTypeLevel); /// /// Resolves the custom rules declared by . The attributes are supplied by the @@ -63,6 +67,7 @@ public static EquatableArray Resolve( /// /// The validation attributes to resolve. /// The type the rules are applied to. + /// The compilation, used to construct a value type. /// The diagnostics a failed resolution reports into. /// /// Whether the rules are applied to itself rather than to a member. A @@ -72,6 +77,7 @@ public static EquatableArray Resolve( public static EquatableArray Resolve( ImmutableArray attributes, ITypeSymbol ruleTargetType, + Compilation compilation, ImmutableArray.Builder diagnostics, bool isTypeLevel = false ) @@ -102,9 +108,11 @@ public static EquatableArray Resolve( !TryResolveRule( mapping.RuleType, ruleTargetType, + compilation, out var ruleType, out var scalarTarget, - out var scalarValue + out var scalarValue, + out var guardedScalarPropertyName ) ) { @@ -169,19 +177,26 @@ out var unmappedParameterName continue; } + // A guarded rule is resolved against the scalar's unwrapped value type, so it is instantiated + // directly and receives that value only when it is present. An adapted rule is wrapped in the + // ScalarRuleAdapter so it can validate the value object as a unit. + var isGuardedScalarValue = guardedScalarPropertyName is not null; + var isAdapted = scalarTarget is not null && !isGuardedScalarValue; + builder ??= ImmutableArray.CreateBuilder(); builder.Add( new CustomRuleDescriptor( - scalarTarget is null - ? new TypeIdentity(ruleType) - : BuildScalarAdapterIdentity(scalarTarget, scalarValue!, ruleType), - scalarTarget is null ? null : new TypeIdentity(ruleType), + isAdapted + ? BuildScalarAdapterIdentity(scalarTarget!, scalarValue!, ruleType) + : new TypeIdentity(ruleType), + isAdapted ? new TypeIdentity(ruleType) : null, GetAttributeString(attribute, "Code") ?? mapping.Code, GetAttributeString(attribute, "Origin") ?? mapping.Origin, ValidationAttributeData.FromAttributeData(attribute), arguments, IsZodRule(ruleType), - TypeHelpers.IsOrImplements(ruleType, TypeLibrary.ZodSharp.Core.IRequiredRule) + TypeHelpers.IsOrImplements(ruleType, TypeLibrary.ZodSharp.Core.IRequiredRule), + guardedScalarPropertyName ) ); @@ -250,30 +265,59 @@ attribute.AttributeClass is not null /// /// The rule type declared by the mapping. /// The type the rule is applied to. + /// The compilation, used to construct a value type. /// 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 the underlying value is a , the name of the scalar property holding it. + /// The rule is then instantiated directly against the unwrapped value type and emitted under a non-null + /// guard, rather than adapted to the value object. + /// /// when a usable rule was resolved. + /// + /// A nullable value scalar is validated the same way a Nullable<T> property is: the rule is + /// closed over the non-nullable value type and skipped when the value is absent. This is what lets rules + /// with value-type constraints (for example MinValueRule<T>, where + /// T : IComparable<T>) resolve, because Nullable<T> does not implement them. + /// A rule written explicitly against the nullable type still takes the adapted path. + /// static bool TryResolveRule( INamedTypeSymbol ruleType, ITypeSymbol targetType, + Compilation compilation, out INamedTypeSymbol instantiated, out INamedTypeSymbol? scalarTarget, - out ITypeSymbol? scalarValue + out ITypeSymbol? scalarValue, + out string? guardedScalarPropertyName ) { scalarTarget = null; scalarValue = null; + guardedScalarPropertyName = null; - if ( - TryGetScalarUnderlyingValue(targetType, out var valueType) - && TryResolveUnderlyingRule(ruleType, valueType, out var underlyingRule) - ) + if (TryGetScalarUnderlyingValue(targetType, compilation, out var valueType, out var scalarPropertyName)) { - instantiated = underlyingRule; - scalarTarget = (INamedTypeSymbol)targetType; - scalarValue = valueType; - return true; + var unwrappedValueType = TypeHelpers.UnwrapNullableType(valueType); + if ( + valueType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + && TryResolveUnderlyingRule(ruleType, unwrappedValueType, out var guardedRule) + ) + { + instantiated = guardedRule; + scalarTarget = (INamedTypeSymbol)targetType; + scalarValue = unwrappedValueType; + guardedScalarPropertyName = scalarPropertyName; + return true; + } + + if (TryResolveUnderlyingRule(ruleType, valueType, out var underlyingRule)) + { + instantiated = underlyingRule; + scalarTarget = (INamedTypeSymbol)targetType; + scalarValue = valueType; + return true; + } } return TryResolveRuleType(ruleType, targetType, out instantiated); @@ -322,7 +366,10 @@ out INamedTypeSymbol resolved /// The name of the property holding the underlying value (default Value). /// Whether the value-object generator owns the underlying property. /// The declared underlying type for an automatic form; otherwise . - /// Whether the automatic form declares the property as a nullable reference type. + /// + /// Whether the automatic form declares the property as nullable: a nullable reference annotation for a + /// reference type, or a wrapper for a value type. + /// internal readonly record struct ScalarAttributeInfo( string PropertyName, bool IsAutomatic, @@ -422,15 +469,25 @@ internal static bool TryGetScalarAttribute(INamedTypeSymbol type, out ScalarAttr /// owns the property, so the type is read from the attribute instead. /// /// The type to inspect. + /// The compilation, used to construct a value type. /// The scalar's underlying value type, when it is a scalar. + /// The name of the property holding the scalar's underlying value. /// when is a scalar value object. - internal static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) + internal static bool TryGetScalarUnderlyingValue( + ITypeSymbol type, + Compilation compilation, + out ITypeSymbol valueType, + out string propertyName + ) { valueType = null!; + propertyName = string.Empty; if (type is not INamedTypeSymbol named || !TryGetScalarAttribute(named, out var scalar)) return false; + propertyName = scalar.PropertyName; + foreach (var member in named.GetMembers(scalar.PropertyName)) { if (member is IPropertySymbol { IsStatic: false, DeclaredAccessibility: Accessibility.Public } property) @@ -443,7 +500,7 @@ internal static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymb if (scalar.ValueType is null) return false; - valueType = AnnotateScalarValueType(scalar); + valueType = AnnotateScalarValueType(scalar, compilation); return true; } @@ -453,10 +510,16 @@ internal static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymb /// declares the property. Used to synthesize the schema's property without the member being visible. /// /// The type to inspect. + /// The compilation, used to construct a value type. /// The property the value-object generator declares. - /// The declared underlying type, with a nullable reference annotation applied. + /// The declared underlying type, with the nullable option applied. /// when uses an automatic scalar form. - internal static bool TryGetAutomaticScalar(ITypeSymbol type, out string propertyName, out ITypeSymbol valueType) + internal static bool TryGetAutomaticScalar( + ITypeSymbol type, + Compilation compilation, + out string propertyName, + out ITypeSymbol valueType + ) { propertyName = string.Empty; valueType = null!; @@ -471,19 +534,39 @@ type is not INamedTypeSymbol named } propertyName = scalar.PropertyName; - valueType = AnnotateScalarValueType(scalar); + valueType = AnnotateScalarValueType(scalar, compilation); return true; } /// - /// Applies the automatic form's Nullable option as a nullable reference annotation, so the - /// adapted rule and the synthesized schema property match the value-object generator's - /// IScalarValueObject<TSelf, string?> construction. + /// Applies the automatic form's Nullable option, so the resolved rule and the synthesized schema + /// property match the value-object generator's IScalarValueObject<TSelf, TValue> construction. + /// A reference type gains the nullable annotation; a non-nullable value type is wrapped in + /// . An already-nullable value type is left alone so + /// [Scalar<int?>(Nullable = true)] does not double-wrap, and a ref-like struct is left alone + /// because cannot be constructed over it. /// - static ITypeSymbol AnnotateScalarValueType(ScalarAttributeInfo scalar) => - scalar.Nullable && scalar.ValueType is { IsReferenceType: true } referenceType - ? referenceType.WithNullableAnnotation(NullableAnnotation.Annotated) - : scalar.ValueType!; + static ITypeSymbol AnnotateScalarValueType(ScalarAttributeInfo scalar, Compilation compilation) + { + if (!scalar.Nullable) + return scalar.ValueType!; + + if (scalar.ValueType is { IsReferenceType: true } referenceType) + return referenceType.WithNullableAnnotation(NullableAnnotation.Annotated); + + if ( + scalar.ValueType is not { IsValueType: true } valueType + || valueType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || valueType is INamedTypeSymbol { IsRefLikeType: true } + ) + { + return scalar.ValueType!; + } + + // The value type is non-nullable and not ref-like, so wrap it in Nullable to match the value-object + // generator's construction of IScalarValueObject for a nullable scalar. + return compilation.GetSpecialType(SpecialType.System_Nullable_T).Construct(valueType); + } /// /// Builds the Purview.ValueObjects.ScalarRuleAdapter<TSelf, TValue, TRule> identity the diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 2367af3d..85143787 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -114,6 +114,7 @@ CancellationToken cancellationToken symbol, properties, externalSchemas, + compilation, typeDateTimeOffsetSerialization ); @@ -121,7 +122,7 @@ CancellationToken cancellationToken // 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, isTypeLevel: true); + var typeRules = CustomRuleResolver.Resolve(symbol, symbol, compilation, isTypeLevel: true); var accessibility = symbol.ContainingType is null ? symbol.DeclaredAccessibility == Accessibility.Public ? TypeDeclarationAccessibility.Public @@ -142,7 +143,7 @@ CancellationToken cancellationToken // RFC UUID format), not an object wrapping a Value property. TypeIdentity? scalarUnderlyingType = null; var scalarUnderlyingCanBeNull = false; - if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, out var scalarValueType)) + if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, compilation, out var scalarValueType, out _)) { scalarUnderlyingType = CreateTypeIdentity(TypeHelpers.UnwrapNullableType(scalarValueType)); scalarUnderlyingCanBeNull = IsNullableProperty(scalarValueType); @@ -328,17 +329,21 @@ Compilation compilation /// The schema target type. /// The properties discovered from source. /// The resolver that decides schema ownership. + /// The compilation, used to construct a value type. /// The containing type's DateTimeOffset wire-form override. /// The properties with the synthesized scalar property prepended, when applicable. static EquatableArray> AddAutomaticScalarProperty( INamedTypeSymbol symbol, EquatableArray> properties, ExternalSchemaResolver externalSchemas, + Compilation compilation, DateTimeOffsetSerialization? typeDateTimeOffsetSerialization ) { - if (!CustomRuleResolver.TryGetAutomaticScalar(symbol, out var propertyName, out var valueType)) + if (!CustomRuleResolver.TryGetAutomaticScalar(symbol, compilation, out var propertyName, out var valueType)) + { return properties; + } // A declared member means the value-object generator does not own the property (the manual form, or // a consumer that declared it by mistake); the discovered descriptor already covers it. @@ -598,7 +603,12 @@ rangeAttribute with var enumJsonNames = isEnum && !isFlagsEnum ? EnumJsonResolver.GetJsonNames(enumType!, validationAttributes) : new([]); - var customRules = CustomRuleResolver.Resolve(validationAttributes, originalPropertyType, diagnostics); + var customRules = CustomRuleResolver.Resolve( + validationAttributes, + originalPropertyType, + compilation, + diagnostics + ); var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable @@ -921,6 +931,7 @@ candidate is null if (TypeHelpers.TryGetElementType(type, out var elementType)) return elementType; + // The helper does not resolve a property declared as the enumerable interface itself, so check for that. return type is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } enumerable ? enumerable.TypeArguments[0] : null; @@ -996,8 +1007,7 @@ attribute is not null return (DateTimeOffsetSerialization)serialization; if (constructorArgument.Value is { } boxed) - return (DateTimeOffsetSerialization) - Convert.ToInt32(boxed, System.Globalization.CultureInfo.InvariantCulture); + return (DateTimeOffsetSerialization)Convert.ToInt32(boxed, CultureInfo.InvariantCulture); } foreach (var argument in attribute.NamedArguments) diff --git a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs index b41c29c3..534c11c8 100644 --- a/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs +++ b/src/src/SourceGenerators/Models/CustomRuleDescriptor.cs @@ -25,6 +25,12 @@ namespace ZodSharp.SourceGenerators.Models; /// emitted outside the generator's non-null guard so a missing value fails exactly as /// [Required] fails. /// +/// +/// When the rule targets a nullable value scalar's underlying value, the name of the scalar property that +/// holds it. The rule is then instantiated directly against the unwrapped value type and emitted under a +/// non-null guard (or as a required check), rather than wrapped in a scalar rule adapter. Null for every +/// other rule. +/// readonly record struct CustomRuleDescriptor( TypeIdentity RuleType, TypeIdentity? AdaptedFrom, @@ -33,5 +39,6 @@ readonly record struct CustomRuleDescriptor( ValidationAttributeData Message, EquatableArray Arguments, bool RuleOwnsIdentity, - bool IsRequired + bool IsRequired, + string? GuardedScalarPropertyName ); diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 463a4990..ede59f1a 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -234,7 +234,7 @@ ImmutableHashSet schemaReachableTypes static void ReportTypeRuleDiagnostics(SymbolAnalysisContext context, INamedTypeSymbol type, Location typeLocation) { var diagnostics = ImmutableArray.CreateBuilder(); - _ = CustomRuleResolver.Resolve(type, type, diagnostics, isTypeLevel: true); + _ = CustomRuleResolver.Resolve(type, type, context.Compilation, diagnostics, isTypeLevel: true); foreach (var diagnosticInfo in diagnostics) { diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index d05641b7..985a31c9 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -1039,6 +1039,22 @@ static void GenerateComplexTypeValidation(CodeWriter writer, ZodPropertyDescript var nestedResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Result"); var schemaType = property.NestedSchemaType.Value; + if (property.IsNullableValueType) + { + // A Nullable property is validated through its unwrapped value; the nested schema is declared + // for T, not Nullable. + using (writer.IfBlockScope($"value.{propertyName} is {{ }} {propertyValueName}")) + { + writer.Assignment("var", nestedResultName, $"{schemaType}.Validate({propertyValueName})"); + using (writer.IfBlockScope($"!{nestedResultName}.IsSuccess")) + { + writer.MethodCall("AddNestedErrors", "ref errors", nestedResultName, propertyName.Surround()); + } + } + + return; + } + writer.Assignment("var", propertyValueName, $"value.{propertyName}"); if (property.IsDeclaredNullable) { diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 6747ea8b..78252f5b 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -73,6 +73,27 @@ CustomRuleDescriptor requiredRule arguments ); + if (requiredRule.GuardedScalarPropertyName is { Length: > 0 } scalarPropertyName) + { + // The rule is closed over the scalar's unwrapped underlying value, so both an absent property and a + // present scalar with an absent underlying value are missing. + var scalarExpression = property.IsNullableValueType + ? $"value.{propertyName}.Value" + : $"value.{propertyName}"; + + WriteRuleFailure( + writer, + requiredRule, + ruleVariable, + identityVariable, + "default!", + CodeGenHelpers.GetPathFieldName(propertyName), + property.DisplayName, + invalidCondition: $"value.{propertyName} == null || {scalarExpression}.{scalarPropertyName} == null" + ); + return; + } + if (property.IsNullableValueType) { // The rule is closed over the underlying value, so it cannot be handed null. An absent value is @@ -157,6 +178,21 @@ static void EmitCustomRule( bool declareValueLocal ) { + if (rule.GuardedScalarPropertyName is { Length: > 0 } scalarPropertyName) + { + EmitGuardedScalarRule( + writer, + rule, + localPrefix, + index, + valueExpression, + scalarPropertyName, + pathExpression, + displayName + ); + return; + } + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{index}"); var valueVariable = declareValueLocal ? CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleValue{index}") @@ -179,6 +215,65 @@ bool declareValueLocal writer.NewLine(); } + /// + /// Emits a rule that targets a nullable value scalar's underlying value. The rule is closed over the + /// non-nullable value type, so it is only invoked when the value is present; a required rule instead + /// reports the absent value itself. This mirrors how a Nullable<T> property is validated. + /// + /// The writer positioned inside the validation method. + /// The resolved guarded rule descriptor. + /// The prefix for generated local variable names. + /// The rule's index within its owner's rule list. + /// The expression holding the scalar value object. + /// The scalar property that holds the nullable underlying value. + /// The error path expression. + /// The display name used for a custom error message. + static void EmitGuardedScalarRule( + CodeWriter writer, + CustomRuleDescriptor rule, + string localPrefix, + int index, + string scalarExpression, + string scalarPropertyName, + string pathExpression, + string displayName + ) + { + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{index}"); + var identityVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRuleInner{index}"); + var arguments = $"({string.Join(", ", rule.Arguments)})"; + var underlyingExpression = $"{scalarExpression}.{scalarPropertyName}"; + + if (rule.IsRequired) + { + // A required rule rejects an absent underlying value; it does not need to run over a present one. + var requiredIdentity = EmitRuleInstance(writer, rule, identityVariable, ruleVariable, arguments); + WriteRuleFailure( + writer, + rule, + ruleVariable, + requiredIdentity, + "default!", + pathExpression, + displayName, + invalidCondition: $"{underlyingExpression} == null" + ); + writer.NewLine(); + return; + } + + writer.IfBlock( + $"{underlyingExpression} is {{ }} scalarValue", + body => + { + var identity = EmitRuleInstance(body, rule, identityVariable, ruleVariable, arguments); + WriteRuleFailure(body, rule, ruleVariable, identity, "scalarValue", 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 diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index 8fc3aabf..ddb636ee 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -101,8 +101,12 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty EmitRuleJsonSchema(body, rule, "inner", property.Value.DateTimeOffsetSerialization); } + // A rule that rejects an absent value ([RequiredZod]) makes the scalar effectively non-null even + // when its underlying type is nullable, matching the generated validator. + var canBeNull = schema.ScalarUnderlyingCanBeNull && !schema.TypeRules.Any(static rule => rule.IsRequired); + body.Return( - schema.ScalarUnderlyingCanBeNull + canBeNull ? $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ inner, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}" : "inner" ); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs index 9dada9e8..11f35291 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomRules.cs @@ -623,4 +623,67 @@ public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject< var result = await AnalyzeAsync(source, cancellationToken); await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); } + + /// + /// A nullable value scalar's underlying value is Nullable<T>, which does not satisfy a rule's + /// IEquatable<T> constraint. The analyzer resolves the rule against the unwrapped value, so the + /// rule is not reported as an unsupported target. + /// + [Test] + public async Task TypeRule_GivenNullableValueTypeScalar_ResolvesAgainstUnwrappedValue( + 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 OptionalTimestamp + : Purview.ValueObjects.IScalarValueObject + { + public DateTimeOffset? Value { get; init; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 4d4a405b..5c53c575 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -598,4 +598,400 @@ await Assert .That(generated) .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); } + + [Test] + public async Task AutomaticScalar_GivenNullableValueTypeOption_EmitsNullablePropertyAndSchema( + CancellationToken cancellationToken + ) + { + // Arrange — both automatic spellings. The value-object generator wraps the value type in + // Nullable, so ZodSharp must synthesize the same nullable property and JSON\Schema null branch. + const string genericSource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [ZodSchema] + public readonly partial record struct OptionalTimestamp { } + } + """; + const string typeofSource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(DateTimeOffset), Nullable = true)] + [ZodSchema] + public readonly partial record struct OptionalTimestamp { } + } + """; + + // Act + using var genericResult = await GenerateAsync( + genericSource, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + using var typeofResult = await GenerateAsync( + typeofSource, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generic = genericResult.GetSource("OptionalTimestampSchema"); + var typeofGenerated = typeofResult.GetSource("OptionalTimestampSchema"); + + // Assert — the synthesized property is DateTimeOffset?, so null is accepted and exported. + foreach (var generated in new[] { generic, typeofGenerated }) + { + await Assert.That(generated).ContainsGeneratedCode("value.Value != null"); + await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + await Assert.That(generated).ContainsGeneratedCode("AnyOf = new()"); + await Assert.That(generated).ContainsGeneratedCode("Type = \"null\""); + } + } + + [Test] + public async Task AutomaticScalar_GivenNullableValueTypeOptionAndTypeRule_GuardsUnwrappedRule( + CancellationToken cancellationToken + ) + { + // Arrange — [NonSentinel] is written against the underlying value. The scalar's underlying value is + // DateTimeOffset?, so the rule must be closed over DateTimeOffset and run only when a value is present; + // closing it over Nullable would not compile against the adapter's TSelf contract. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [NonSentinel] + [ZodSchema] + public readonly partial record struct OptionalDateTimeOffset { } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generated = driverResult.GetSource("OptionalDateTimeOffsetSchema"); + + // Assert — the rule is direct (not adapted), guarded by the nullable underlying value. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule("); + await Assert.That(generated).ContainsGeneratedCode("value.Value is { } scalarValue"); + await Assert.That(generated).ContainsGeneratedCode("IsValid(scalarValue)"); + await Assert.That(generated).DoesNotContain("ScalarRuleAdapter"); + await Assert.That(generated).ContainsGeneratedCode("AnyOf = new()"); + } + + [Test] + public async Task AutomaticScalar_GivenNullableValueTypeOptionAndNonSentinel_ValidatesAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange — the declared property stands in for the one the value-object generator would emit, so the + // generated schema compiles and can be exercised. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [NonSentinel(Message = "OptionalDateTimeOffset has a sentinel value.")] + [ZodSchema] + public readonly partial record struct OptionalDateTimeOffset + : Purview.ValueObjects.IScalarValueObject + { + public DateTimeOffset? Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.OptionalDateTimeOffset")!; + var validate = assembly.GetType("Testing.OptionalDateTimeOffsetSchema")!.GetMethod("Validate")!; + + var nullInstance = Activator.CreateInstance(modelType)!; + var nullResult = validate.Invoke(null, [nullInstance])!; + + var sentinelInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(sentinelInstance, DateTimeOffset.MinValue); + var sentinelResult = validate.Invoke(null, [sentinelInstance])!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, DateTimeOffset.UtcNow); + var validResult = validate.Invoke(null, [validInstance])!; + + // Assert — null is absent (accepted), a sentinel present value fails, and a normal value passes. + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsTrue(); + await Assert.That((bool)sentinelResult.GetType().GetProperty("IsSuccess")!.GetValue(sentinelResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + sentinelResult.GetType().GetProperty("Errors")!.GetValue(sentinelResult)!; + 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((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task AutomaticScalar_GivenNullableValueTypeOptionAndMinValue_ValidatesAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange — a constrained generic rule (MinValueRule where T : IComparable) cannot close over + // Nullable; it must be resolved against int and guarded. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [MinValue(3)] + [ZodSchema] + public readonly partial record struct Count + : Purview.ValueObjects.IScalarValueObject + { + public int? Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.Count")!; + var validate = assembly.GetType("Testing.CountSchema")!.GetMethod("Validate")!; + + var nullInstance = Activator.CreateInstance(modelType)!; + var nullResult = validate.Invoke(null, [nullInstance])!; + + var belowInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(belowInstance, 2); + var belowResult = validate.Invoke(null, [belowInstance])!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, 5); + var validResult = validate.Invoke(null, [validInstance])!; + + // Assert + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsTrue(); + await Assert.That((bool)belowResult.GetType().GetProperty("IsSuccess")!.GetValue(belowResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + belowResult.GetType().GetProperty("Errors")!.GetValue(belowResult)!; + await Assert.That(errors.Any(error => error.Code == Rules.MinValueRule.ErrorCode)).IsTrue(); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task AutomaticScalar_GivenNullableValueTypeOptionAndRequiredZod_RejectsNull( + CancellationToken cancellationToken + ) + { + // Arrange — [RequiredZod] rejects an absent value even though the underlying type is nullable, and the + // exported JSON Schema must not advertise a null branch. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [RequiredZod] + [ZodSchema] + public readonly partial record struct RequiredCount + : Purview.ValueObjects.IScalarValueObject + { + public int? Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.RequiredCount")!; + var validate = assembly.GetType("Testing.RequiredCountSchema")!.GetMethod("Validate")!; + + var nullInstance = Activator.CreateInstance(modelType)!; + var nullResult = validate.Invoke(null, [nullInstance])!; + + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, 5); + var validResult = validate.Invoke(null, [validInstance])!; + + var generated = driverResult.GetSource("RequiredCountSchema"); + + // Assert + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + nullResult.GetType().GetProperty("Errors")!.GetValue(nullResult)!; + await Assert.That(errors.Any(error => error.Code == Rules.RequiredRule.ErrorCode)).IsTrue(); + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + + await Assert.That(generated).ContainsGeneratedCode("value.Value == null"); + await Assert.That(generated).DoesNotContain("Type = \"null\""); + } + + [Test] + public async Task PropertyRule_GivenScalarWithNullableValue_GuardsUnwrappedRule(CancellationToken cancellationToken) + { + // Arrange — a rule on a scalar-typed property also targets the scalar's underlying value, so it must + // use the same guard-and-unwrap path. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [ZodSchema] + public readonly partial record struct Score + : Purview.ValueObjects.IScalarValueObject + { + public int? Value { get; init; } + } + + [ZodSchema] + public partial class ScoreHolder + { + [MinValue(3)] + public Score Score { get; set; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var holderType = assembly.GetType("Testing.ScoreHolder")!; + var scoreType = assembly.GetType("Testing.Score")!; + var scoreProperty = holderType.GetProperty("Score")!; + var scoreValueProperty = scoreType.GetProperty("Value")!; + var validate = assembly.GetType("Testing.ScoreHolderSchema")!.GetMethod("Validate")!; + + var nullHolder = Activator.CreateInstance(holderType)!; + var nullResult = validate.Invoke(null, [nullHolder])!; + + var belowHolder = Activator.CreateInstance(holderType)!; + var belowScore = Activator.CreateInstance(scoreType)!; + scoreValueProperty.SetValue(belowScore, 2); + scoreProperty.SetValue(belowHolder, belowScore); + var belowResult = validate.Invoke(null, [belowHolder])!; + + var validHolder = Activator.CreateInstance(holderType)!; + var validScore = Activator.CreateInstance(scoreType)!; + scoreValueProperty.SetValue(validScore, 5); + scoreProperty.SetValue(validHolder, validScore); + var validResult = validate.Invoke(null, [validHolder])!; + + // Assert + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsTrue(); + await Assert.That((bool)belowResult.GetType().GetProperty("IsSuccess")!.GetValue(belowResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + belowResult.GetType().GetProperty("Errors")!.GetValue(belowResult)!; + await Assert.That(errors.Any(error => error.Code == Rules.MinValueRule.ErrorCode)).IsTrue(); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } + + [Test] + public async Task PropertyRule_GivenNullableScalarAndRequiredZod_RejectsNullPropertyAndNullValue( + CancellationToken cancellationToken + ) + { + // Arrange — [RequiredZod] on a nullable scalar property must reject both an absent scalar and a + // present scalar whose underlying value is null. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [ZodSchema] + public readonly partial record struct Score + : Purview.ValueObjects.IScalarValueObject + { + public int? Value { get; init; } + } + + [ZodSchema] + public partial class ScoreHolder + { + [RequiredZod] + public Score? Score { get; set; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var holderType = assembly.GetType("Testing.ScoreHolder")!; + var scoreType = assembly.GetType("Testing.Score")!; + var scoreProperty = holderType.GetProperty("Score")!; + var scoreValueProperty = scoreType.GetProperty("Value")!; + var validate = assembly.GetType("Testing.ScoreHolderSchema")!.GetMethod("Validate")!; + + var nullHolder = Activator.CreateInstance(holderType)!; + var nullResult = validate.Invoke(null, [nullHolder])!; + + var nullValueScore = Activator.CreateInstance(scoreType)!; + var nullValueHolder = Activator.CreateInstance(holderType)!; + scoreProperty.SetValue(nullValueHolder, nullValueScore); + var nullValueResult = validate.Invoke(null, [nullValueHolder])!; + + var validScore = Activator.CreateInstance(scoreType)!; + scoreValueProperty.SetValue(validScore, 5); + var validHolder = Activator.CreateInstance(holderType)!; + scoreProperty.SetValue(validHolder, validScore); + var validResult = validate.Invoke(null, [validHolder])!; + + // Assert + await Assert.That((bool)nullResult.GetType().GetProperty("IsSuccess")!.GetValue(nullResult)!).IsFalse(); + var nullErrors = (System.Collections.Immutable.ImmutableArray) + nullResult.GetType().GetProperty("Errors")!.GetValue(nullResult)!; + await Assert.That(nullErrors.Any(error => error.Code == Rules.RequiredRule.ErrorCode)).IsTrue(); + + await Assert + .That((bool)nullValueResult.GetType().GetProperty("IsSuccess")!.GetValue(nullValueResult)!) + .IsFalse(); + var nullValueErrors = (System.Collections.Immutable.ImmutableArray) + nullValueResult.GetType().GetProperty("Errors")!.GetValue(nullValueResult)!; + await Assert.That(nullValueErrors.Any(error => error.Code == Rules.RequiredRule.ErrorCode)).IsTrue(); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + + var generated = driverResult.GetSource("ScoreHolderSchema"); + await Assert.That(generated).ContainsGeneratedCode("value.Score == null || value.Score.Value.Value == null"); + } }