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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

Released versions correspond to `v<version>` 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<T>`. ZodSharp only applied the option to reference types, so
`[Scalar<DateTimeOffset>(Nullable = true)]` closed its rules over `DateTimeOffset` while the value object
implemented `IScalarValueObject<TSelf, DateTimeOffset?>`, 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<T>` property is validated. This also lets constrained rules
(`MinValueRule<T>`, numeric rules, comparison rules, `EnumRule<T>`, ...) resolve, because
`Nullable<T>` 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<T>` property.

## 2.4.0

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Release-Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Source-Generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` — 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.
6 changes: 4 additions & 2 deletions docs/wiki/Value-Objects-Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ so `AssetId` is `IScalarValueObject<AssetId, Guid>`.
[Scalar<Guid>("Id")] [Scalar(typeof(Guid), "Id")]
```

A nullable reference type cannot be a generic attribute argument (`[Scalar<string?>]` 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<int?>]` / `[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<string?>]` 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<T>`, so `[Scalar<DateTimeOffset>(Nullable = true)]` emits `public DateTimeOffset? Value`. Nullable value types can also be written directly (`[Scalar<int?>]` / `[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<T>` 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:

Expand All @@ -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.

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.4.0",
"version": "2.4.1",
"private": true,
"license": "MIT",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
-->
<PropertyGroup Label="NuGet package validation" Condition="'$(IsPackable)' == 'true'">
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>2.3.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>2.4.0</PackageValidationBaselineVersion>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading