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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
Expand Down Expand Up @@ -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<TSelf, TValue, TRule>` 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<T, string, NonSentinelRule<string?>>`, whose `TValue` satisfied neither the adapter's
`IValidationRule<TValue>` constraint nor the value object's `IScalarValueObject<TSelf, TValue>` — 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

Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/Source-Generator-DataAnnotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<TEnum>`. 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.
Expand Down
6 changes: 3 additions & 3 deletions docs/wiki/Validation-Rules-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,15 +200,15 @@ These rules mirror `System.ComponentModel.DataAnnotations` attributes as first-c

| Rule | Constructor | Fluent method | Code | Message format |
|---|---|---|---|---|
| `RequiredRule<T>` | `(bool allowEmptyString = false, bool trimWhitespace = false, string? message, string? code)` | `ZodString.Required(...)` | `missing_field` | `Field is required` |
| `RequiredRule<T>` | `(bool allowEmptyStrings = false, bool trimWhitespace = false, string? message, string? code)` | `ZodString.Required(...)` | `missing_field` | `Field is required` |
| `RangeRule<T>` | `(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>` | `(T[] values, string? message, string? code)` | `AllowedValues(values)` | `invalid_value` | `Value '{0}' is not one of the allowed values` |
| `DeniedValuesRule<T>` | `(T[] values, string? message, string? code)` | `DeniedValues(values)` | `invalid_value` | `Value '{0}' is one of the denied values` |

`RequiredRule<T>` 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<T>` 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<T>` and `DeniedValuesRule<T>` accept both a single value (`new AllowedValuesRule<int>(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.

Expand Down Expand Up @@ -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<T>` | suffixed; exposes `AllowEmptyString`/`TrimWhitespace` |
| `[RequiredZod(…)]` | `RequiredRule<T>` | suffixed; exposes `AllowEmptyStrings`/`TrimWhitespace` and rejects an absent value exactly like `[Required]` |
| `[RangeZod(…)]` | `RangeRule<T>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.0.1",
"version": "2.0.1.1",
"private": true,
"license": "MIT",
"author": {
Expand Down
17 changes: 15 additions & 2 deletions src/src/SourceGenerators/Helpers/CustomRuleResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
);

Expand Down Expand Up @@ -320,6 +321,14 @@ static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueT
return null;
}

/// <summary>
/// Builds the <c>Purview.ValueObjects.ScalarRuleAdapter&lt;TSelf, TValue, TRule&gt;</c> identity the
/// adapted rule is emitted as. The value argument is carried as a <c>TypeReference</c> rather than a
/// <c>TypeIdentity</c> because an identity drops nullable reference annotations: a
/// scalar backed by <c>string?</c> would otherwise be closed over <c>NonSentinelRule&lt;string?&gt;</c>
/// but constrained by <c>IValidationRule&lt;string&gt;</c>, which the invariant contract rejects
/// (CS8631).
/// </summary>
static TypeIdentity BuildScalarAdapterIdentity(
INamedTypeSymbol scalar,
ITypeSymbol valueType,
Expand All @@ -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()
);

/// <summary>
/// Resolves the rule type to instantiate: a plain type is used as-is, an unbound generic
Expand Down
3 changes: 3 additions & 0 deletions src/src/SourceGenerators/Helpers/TypeLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 7 additions & 1 deletion src/src/SourceGenerators/Models/CustomRuleDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ namespace ZodSharp.SourceGenerators.Models;
/// Whether the rule implements <c>IZodRule</c> 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.
/// </param>
/// <param name="IsRequired">
/// Whether the rule implements <c>IRequiredRule</c> 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
/// <c>[Required]</c> fails.
/// </param>
readonly record struct CustomRuleDescriptor(
TypeIdentity RuleType,
TypeIdentity? AdaptedFrom,
string? Code,
string? Origin,
ValidationAttributeData Message,
EquatableArray<string> Arguments,
bool RuleOwnsIdentity
bool RuleOwnsIdentity,
bool IsRequired
);
47 changes: 44 additions & 3 deletions src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -704,21 +708,58 @@ 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);
if (property.CanBeNull)
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)
/// <summary>
/// Finds the rule that rejects an absent value (<c>IRequiredRule</c>) among the property's custom rules,
/// when the property can be null.
/// </summary>
/// <param name="property">The property to inspect.</param>
/// <param name="requiredRule">The required rule, when one is bound to the property.</param>
/// <returns><see langword="true"/> when a required rule is bound to a nullable property.</returns>
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)
{
Expand All @@ -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)
Expand Down
Loading
Loading