From ac9c92fc574ea8d82c6001924c6d6f34bba0f31f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 20 Oct 2026 23:59:44 +0100 Subject: [PATCH 1/7] feat: supporting utc/ local dto handling --- .github/workflows/pr.yml | 14 +- AGENTS.md | 5 +- CHANGELOG.md | 48 +++ docs/wiki/Cross-Platform-Interop.md | 6 + docs/wiki/Fluent-Schema-API.md | 1 + docs/wiki/Guarantees-and-Limitations.md | 18 + docs/wiki/JsonSchema-Export.md | 78 ++++ docs/wiki/Release-Flow.md | 2 +- docs/wiki/Source-Generator.md | 40 ++ docs/wiki/Validation-Rules-Reference.md | 7 +- package.json | 2 +- skills/purview-zodsharp-custom-rules/SKILL.md | 4 +- skills/purview-zodsharp-json/SKILL.md | 8 +- .../SKILL.md | 6 + src/Directory.Build.targets | 8 +- .../NewtonsoftJsonSerializationExtensions.cs | 42 ++ .../PreserveOffsetDateTimeOffsetConverter.cs | 61 +++ .../UtcDateTimeOffsetConverter.cs | 88 +++++ src/src/NewtonsoftJson/Sdk/README.md | 20 + .../Helpers/AttributeGenHelper.cs | 6 + .../Helpers/GenerationSettingsResolver.cs | 124 +++++- .../Helpers/PropertyLibrary.cs | 2 + .../Helpers/SourceGenLibrary.cs | 82 +++- .../SourceGenerators/Helpers/TypeLibrary.cs | 3 + .../Models/SchemaGenerationModel.cs | 29 +- .../SourceGenerators/SourceGenerators.csproj | 1 + .../ZodSchemaGenerator.JsonSchema.cs | 55 ++- .../PreserveOffsetDateTimeOffsetConverter.cs | 29 ++ .../UtcDateTimeOffsetConverter.cs | 61 +++ src/src/SystemTextJson/Sdk/README.md | 18 + .../DateTimeOffsetBehaviorAttribute.cs | 43 +++ .../ZodSharp/DateTimeOffsetSerialization.cs | 33 ++ .../ZodSharp/JsonSchema/JsonSchemaRules.cs | 45 +++ .../JsonSchema/ToJsonSchemaConverter.cs | 59 ++- .../JsonSchema/ToJsonSchemaOptions.cs | 11 + src/src/ZodSharp/Rules/NonSentinelRule.cs | 172 ++++++++- src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs | 35 ++ src/src/ZodSharp/Sdk/README.md | 9 + .../buildTransitive/Purview.ZodSharp.props | 3 + src/src/ZodSharp/Z.cs | 9 + .../ZodSharpSchemaDefaultsAttribute.cs | 20 + .../Json/Schema/FromJsonSchemaParserTests.cs | 66 ++++ .../DateTimeOffsetConverterTests.cs | 140 +++++++ .../CrossPlatformVitestTests.cs | 1 + ...SchemaGeneratorTests.JsonSchemaTemporal.cs | 360 ++++++++++++++++++ ...GeneratorTests.JsonSchemaTemporalParity.cs | 170 +++++++++ .../Json/Schema/FromJsonSchemaParserTests.cs | 66 ++++ .../DateTimeOffsetConverterTests.cs | 81 ++++ .../ToJsonSchemaTemporalSentinelTests.cs | 303 +++++++++++++++ tests/ts/temporal-parity.test.ts | 65 ++++ 50 files changed, 2507 insertions(+), 52 deletions(-) create mode 100644 src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs create mode 100644 src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs create mode 100644 src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs create mode 100644 src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs create mode 100644 src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs create mode 100644 src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs create mode 100644 src/src/ZodSharp/DateTimeOffsetSerialization.cs create mode 100644 src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs create mode 100644 src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs create mode 100644 tests/ts/temporal-parity.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c37a5651..db498c65 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -48,7 +48,7 @@ jobs: - run: bun install --frozen-lockfile - # There are TWO producers of the fixtures vitest reads, in different assemblies, namespaces and + # There are THREE producers of the fixtures vitest reads, in different assemblies, namespaces and # classes, so they cannot be selected by one treenode filter: # # tests/ts/cross-platform.test.ts <- src/tests/cross-platform/output/ @@ -56,9 +56,11 @@ jobs: # NewtonsoftJson unit tests) # tests/ts/guid-uuid-parity.test.ts <- src/tests/cross-platform/guid-parity/ # ZodSharp.SourceGenerators/ZodSchemaGeneratorTests + # tests/ts/temporal-parity.test.ts <- src/tests/cross-platform/temporal-parity/ + # ZodSharp.SourceGenerators/ZodSchemaGeneratorTests # - # Both run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level - # alternation by the treenode filter — it silently selects a different, broader set and skips the + # They run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level + # alternation by the treenode filter — it silently selects a different, broader set and skips a # parity producer, which is how the parity artifacts came to be missing in CI while this job stayed # green. Keep a step per producer, and keep each filter anchored to its class. - name: Run the C# cross-platform tests (writes cross-platform/output) @@ -72,6 +74,12 @@ jobs: --treenode-filter '/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts' + - name: Run the C# temporal parity test (writes cross-platform/temporal-parity) + run: >- + dotnet test --solution src/ZodSharp.slnx --configuration Debug + --treenode-filter + '/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts' + - name: Run the TypeScript/Zod cross-platform tests run: bun run test diff --git a/AGENTS.md b/AGENTS.md index eb31747e..75dc4e29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,14 +121,15 @@ Keep `purview-build.json`'s `PackValidation` requirements in sync with any packa Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live under `src/ts/`. The fixture generator (`bun run generate-fixtures`) writes JSON fixtures and a `manifest.json` that the C# tests consume. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync. -`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **two** producer/consumer pairs, in different assemblies, namespaces and classes: +`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **three** producer/consumer pairs, in different assemblies, namespaces and classes: | Vitest consumer | Reads | C# producer | | --- | --- | --- | | `tests/ts/cross-platform.test.ts` | `src/tests/cross-platform/output/` | `ZodSharp.Json/*CrossPlatformTests` (the SystemTextJson and NewtonsoftJson unit tests) | | `tests/ts/guid-uuid-parity.test.ts` | `src/tests/cross-platform/guid-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts` | +| `tests/ts/temporal-parity.test.ts` | `src/tests/cross-platform/temporal-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts` | -`just test` runs the whole suite, so it covers both producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping the parity producer. Adding a producer means adding a step. Both vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. +`just test` runs the whole suite, so it covers all three producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping a parity producer. Adding a producer means adding a step. All vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. `src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs` additionally drives the vitest suites **from the .NET test run**, as child processes through CliWrap, so `just test` alone proves both halves of the guarantee and a TypeScript-side regression fails a local run. It is a deliberate duplicate of the CI job, not a replacement — the job stays the authoritative gate because it controls producer ordering and installs bun from scratch. Three rules keep the duplication honest: diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c7debe..2cd5ba13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## 2.3.0 + +### Added + +- **Temporal `[NonSentinel]` JSON Schema mappings.** `NonSentinelRule`, ``, `` + and `` now export exact `pattern` constraints that reject the System.Text.Json wire forms of + their bounds, so no temporal rule appears under `x-zodsharp-unsupported`. The `DateTime` mapping covers the + deterministic `Unspecified`, `Utc` and zero-offset forms. The `DateTimeOffset` pattern is offset-aware: it + correlates the local time with the offset and rejects every RFC 3339 representation of both instant bounds. + `Z.Date().NonSentinel()` also exports `type: "string"`, `format: "date-time"` (previously it fell back to + the empty schema). See + [JSON Schema Export](docs/wiki/JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). +- **Optional exact UTC `DateTimeOffset` contract.** `DateTimeOffsetSerialization.Utc` narrows + `[NonSentinel] DateTimeOffset` to a zero-offset wire form. Configure it per project with + `Utc` (for example in + `Directory.Build.props`), per assembly with + `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]`, or per type/property with + `[DateTimeOffsetBehavior(...)]`. Precedence is property, type, assembly, MSBuild, then `PreserveOffset`. +- **`UtcDateTimeOffsetConverter` and `PreserveOffsetDateTimeOffsetConverter`** in both JSON integration + packages (`ZodSharp.Json.Serialization.SystemTextJson` and `...NewtonsoftJson`). The UTC converter writes + UTC and rejects a value without an explicit `Z`/zero offset (or with a non-zero offset) on read, which is what + keeps it in agreement with the UTC schema; the + preserve-offset converter supports overriding a project-wide UTC converter. The Newtonsoft converters + require `DateParseHandling.None` so they can inspect the original offset; + `UseZodSharpUtcDateTimeOffset()` / `UseZodSharpPreserveOffsetDateTimeOffset()` configure both together. +- `NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern` and a + `JsonSchemaRules.Apply(schema, rule, DateTimeOffsetSerialization)` overload for manual schemas. +- **`Z.DateTimeOffset()` and runtime manual-schema options.** `ZodDateTimeOffset` is the fluent counterpart to + a generated `DateTimeOffset` property, with the same `NonSentinel()` override. + `ToJsonSchemaOptions.DateTimeOffsetSerialization` lets a hand-built schema select the default offset-aware + mapping or the narrower UTC contract. `JsonSchemaRules.TimeOnlyPattern` is the local-time base pattern used + for a generated `TimeOnly` property. + +### Changed + +- `NonSentinelRule` no longer reports `x-zodsharp-unsupported` for a type whose sentinel it does not + inspect. Such a rule always passes, so an unconstrained schema is complete. +- A generated `TimeOnly` property now exports a local-time `pattern` instead of `format: time`. JSON Schema's + `format: time` requires an RFC 3339 offset, which System.Text.Json never writes for `TimeOnly`; strict + consumers previously rejected every value. + +### Known limitations + +- A `DateTimeKind.Local` sentinel can carry a machine-specific offset; use `Unspecified` or `Utc` for a + portable `DateTime` contract. +- The optional UTC contract deliberately rejects non-UTC input, including otherwise valid timestamps, because + normalizing it on read would reintroduce the offset-equivalence gap. + ## 2.2.0 ### Added diff --git a/docs/wiki/Cross-Platform-Interop.md b/docs/wiki/Cross-Platform-Interop.md index 53781768..b0a846fe 100644 --- a/docs/wiki/Cross-Platform-Interop.md +++ b/docs/wiki/Cross-Platform-Interop.md @@ -45,6 +45,12 @@ The same interop goal is available without fixtures via JSON Schema: A generated collection maps to Zod's array element type: `T[]` / `List` ↔ `z.array(T)`, and a nullable element (`T?[]`, `List`, or a property that allows nulls via `[CollectionBehavior(AllowNulls = true)]`) ↔ `z.array(T.nullable())`. A collection whose elements may be null exports `items.anyOf: [ …, { "type": "null" } ]`, and both `z.fromJSONSchema` and `Z.FromJsonSchema` read that shape back. Null elements are rejected by default, so a generated `T[]` matches `z.array(T)` exactly — see [Collection null items](Source-Generator.md#collection-null-items). +The temporal parity suite (`tests/ts/temporal-parity.test.ts`) proves the same loop for `[NonSentinel]` on +`DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` under the default preserve-offset contract, including +the offset-equivalent bounds. The C# producer +(`JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts`) writes to +`src/tests/cross-platform/temporal-parity/`; the UTC contract has its own generator tests. + See [JSON Schema Export](JsonSchema-Export.md) and [JSON Schema Import](JsonSchema-Import.md). ## Directory layout diff --git a/docs/wiki/Fluent-Schema-API.md b/docs/wiki/Fluent-Schema-API.md index 7988ae30..02cb6a7d 100644 --- a/docs/wiki/Fluent-Schema-API.md +++ b/docs/wiki/Fluent-Schema-API.md @@ -11,6 +11,7 @@ | `Boolean()` | `Z.Boolean()` | `ZodBoolean` | | `Null()` | `Z.Null()` | `ZodNull` | | `Date()` | `Z.Date()` | `ZodDate` | +| `DateTimeOffset()` | `Z.DateTimeOffset()` | `ZodDateTimeOffset` | | `BigInt()` | `Z.BigInt()` | `ZodBigInt` | | `Array` | `Z.Array(IZodSchema elementSchema)` | `ZodArray` | | `Optional` | `Z.Optional(IZodSchema schema)` — `T : class` | `ZodOptional` | diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 9446db5e..3dc67c35 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -47,6 +47,24 @@ Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_ `ZodEnum` (string values) and `ZodNativeEnum` validate against defined members; they do not parse or convert values. +### Temporal sentinel export + +`[NonSentinel]` exports exact `pattern` constraints for `DateOnly`, `TimeOnly`, the deterministic `DateTime` +wire forms (`Unspecified`, `Utc`, or a zero offset), and `DateTimeOffset`. The `DateTimeOffset` pattern is +offset-aware: it correlates the local time with the offset and rejects every RFC 3339 representation of the +instant bounds, including offset-equivalent forms, without an `x-zodsharp-unsupported` marker. + +A `DateTimeKind.Local` value can carry an arbitrary machine offset, and System.Text.Json converts an +offset-bearing `DateTime` into the receiver's time zone, so that form is not portable; use `Unspecified`, +`Utc`, or `DateTimeOffset` (whose sentinel pattern is exact) for a cross-platform contract. +`DateTimeOffsetSerialization.Utc` is an optional narrower contract that requires a zero offset and a +converter that rejects non-UTC input on read; see +[JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). + +`TimeOnly` is exported as a local-time `pattern` instead of `format: time`. JSON Schema's `format: time` is an +RFC 3339 full-time and requires an offset, while System.Text.Json writes `TimeOnly` as a local +`HH:mm:ss[.fffffff]`; a strict `format: time` consumer would reject every value. + ### JSON Schema import scope `Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The integration packages' `JsonSchemaSerializerOptions` read and write the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), so exported definitions round-trip; the core `JsonSchemaDefinition` type itself stays free of serializer annotations. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index 604477b2..3cd8dd89 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -146,6 +146,10 @@ the same, so a rule maps to JSON Schema identically on both paths: | `StartsWithRule` / `EndsWithRule` / `IncludesRule` | anchored / escaped `pattern` | | `NonSentinelRule` / `NullOrNonWhiteSpaceRule` | `minLength: 1` + `pattern: "\\S"` | | `NonSentinelRule` | a `pattern` that rejects the all-zero UUID | +| `NonSentinelRule` | a `pattern` that rejects `0001-01-01` and `9999-12-31` | +| `NonSentinelRule` | a `pattern` that rejects `00:00:00` and `23:59:59.9999999` | +| `NonSentinelRule` | a `pattern` that rejects the deterministic min/max timestamp forms (no offset, `Z`, or zero offset) | +| `NonSentinelRule` | an offset-aware `pattern` that rejects every RFC 3339 representation of both instant bounds; the `Utc` contract narrows it to zero-offset form | | `AllowedValuesRule` / `DeniedValuesRule` / `EnumRule` | `enum` / `not: { enum: … }` | | `FiniteRule` / `RequiredRule` | no keyword (implied by `number` / the object's `required` list) | @@ -167,6 +171,80 @@ interface; for example a custom null-or-non-whitespace rule would add `IJsonSche `minLength`/`pattern` on the schema passed to `ApplyTo`. The built-in `NullOrNonWhiteSpaceRule` already does this, so a domain that needs that behaviour should use `[NullOrNonWhiteSpace]` rather than a bespoke rule. +## Temporal sentinels and the DateTimeOffset wire form + +`DateOnly`, `TimeOnly` and `DateTime` export a `pattern` that rejects the System.Text.Json wire forms of their +`MinValue`/`MaxValue`. The temporal mappings are exact for the deterministic forms: a `DateOnly` has one +`yyyy-MM-dd` form, `TimeOnly` is a local `HH:mm:ss[.fffffff]` value, and a `DateTime` is deterministic when it +is `Unspecified` or `Utc` (or written with a zero offset). A `DateTimeKind.Local` boundary value can carry an +arbitrary machine offset, and JSON Schema has no way to know the receiver's time zone; use +`Unspecified`/`Utc`, or `DateTimeOffset`/`Z.DateTimeOffset()` whose pattern is exact, for a portable contract. + +`TimeOnly` is exported as a `pattern` rather than `format: time`. JSON Schema's `format: time` is an RFC 3339 +full-time and requires an offset, while System.Text.Json never writes one for `TimeOnly`; a strict +`format: time` consumer would reject every value. + +`DateTimeOffset` compares instants, and RFC 3339 allows the same instant at different offsets +(`0001-01-01T00:00:00+00:00` and `0001-01-01T05:30:00+05:30` are both `DateTimeOffset.MinValue`). The default +`DateTimeOffsetSerialization.PreserveOffset` pattern is offset-aware: it correlates the local time with the +offset and rejects every representation of both bounds. It is exact, so no `x-zodsharp-unsupported` marker is +emitted and no serializer change is required. The pattern is intentionally detailed because it encodes that +relation for both bounds; it stays a standard ECMA-262 `pattern`. + +The `Utc` setting is a narrower contract. It requires a `Z` or zero offset and pairs with a converter that +rejects non-UTC input, which is useful when the application wants to standardise on UTC: + +```csharp +using ZodSharp; + +// Project-wide default (or set ZodSharpDateTimeOffsetSerialization=Utc in Directory.Build.props). +[assembly: ZodSharpSchemaDefaults( + DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] +``` + +```csharp +// System.Text.Json +using ZodSharp.Json.Serialization.SystemTextJson; +options.Converters.Add(new UtcDateTimeOffsetConverter()); + +// Newtonsoft.Json — the helper also sets DateParseHandling.None so the converter sees the raw offset. +using ZodSharp.Json.Serialization.NewtonsoftJson; +var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset(); +``` + +Under the UTC contract the exported `pattern` requires a `Z` or zero offset and rejects both bounds. The +converter must reject a non-UTC offset on read; normalizing it silently would reintroduce the gap. A value such +as `2024-01-02T03:04:05+05:30` is therefore rejected by both sides when the contract is UTC, even though it is +a valid timestamp. Under the default `PreserveOffset`, that value is accepted and the exact offset-aware +pattern still rejects the bounds. + +The setting can be overridden at several levels; the most specific statement wins: + +| Level | Declaration | +|---|---| +| Property (or positional record parameter) | `[DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]` / `...[PreserveOffset]` | +| Containing type | `[DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]` | +| Assembly | `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]` | +| MSBuild | `ZodSharpDateTimeOffsetSerialization` (`Utc` or `PreserveOffset`, for example in `Directory.Build.props`) | +| Built-in default | `PreserveOffset` | + +The attribute controls schema generation only. Pair a `Utc` override with `UtcDateTimeOffsetConverter`, and a +`PreserveOffset` override under a project-wide UTC converter with `PreserveOffsetDateTimeOffsetConverter` +(available in both integration packages). A `[Scalar]` owns its schema, so apply the attribute +to the scalar type rather than to a property whose type is that scalar. + +A hand-built fluent schema has no compile-time metadata to read, so set the runtime equivalent on +`ToJsonSchemaOptions` when converting a manual schema: + +```csharp +var jsonSchema = Z.ToJsonSchema( + Z.DateTimeOffset().NonSentinel(), + new ToJsonSchemaOptions { DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc }); +``` + +The default remains `PreserveOffset`, which is also exact. The `rule.ApplyTo(schema, ...)` and +`JsonSchemaRules.Apply(schema, rule, ...)` overloads are available for code that builds a definition directly. + ## Serialize the definition Pick the JSON serializer that matches the integration package you referenced: diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 7cdcfa19..4fe8a3e7 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.1.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.2.0`; 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 311aa520..3b2a8d76 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -157,6 +157,7 @@ MSBuild switches: | `ZodSharpEnumSerialization` | `Number` | `String` makes enums export as their member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collection properties may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; the matching converter must also reject non-UTC input. Both modes export an exact sentinel pattern | ## Collection null items @@ -171,6 +172,45 @@ A collection property rejects `null` elements by default: the generated validato Precedence, highest first: the property attribute, the element's `T?` annotation, the containing type's `[ZodSchema]` option, the assembly attribute, the `ZodSharpAllowNulls` MSBuild property, then the built-in default (reject). +## DateTimeOffset wire form + +The JSON Schema generated for `[NonSentinel] DateTimeOffset` depends on the wire form assumed for the value. +By default offsets are preserved and the pattern is offset-aware: it rejects every offset-equivalent +representation of the instant-based bounds, so no unsupported marker is emitted. An optional narrower UTC +contract is available: + +```csharp +// Directory.Build.props +Utc +``` + +```csharp +using ZodSharp; + +[assembly: ZodSharpSchemaDefaults( + DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] + +[ZodSchema] +[DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)] +public sealed class Observation +{ + public DateTimeOffset ObservedAt { get; set; } + + // Override the project/type default for one property. + [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] + [JsonConverter(typeof(PreserveOffsetDateTimeOffsetConverter))] + public DateTimeOffset LegacySeenAt { get; set; } +} +``` + +Precedence, highest first: `[DateTimeOffsetBehavior]` on the property (or positional record parameter), the +containing type's attribute, the assembly attribute, the `ZodSharpDateTimeOffsetSerialization` MSBuild +property, then `PreserveOffset`. The attribute shapes the schema only; pair `Utc` with the integration +package's `UtcDateTimeOffsetConverter` (and `PreserveOffset` under a global UTC converter with +`PreserveOffsetDateTimeOffsetConverter`). Both modes export an exact sentinel pattern; `Utc` additionally +narrows the accepted wire form to a zero offset. See +[JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). + ```csharp using ZodSharp; diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index a094d32c..abb7a1ae 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -180,17 +180,20 @@ Behaviour notes: | `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value has a sentinel value, but got {0}` | | `NullOrNonWhiteSpaceRule` | `(string? message, string? code)` | — | `invalid_string` | `Value must be null or non-whitespace, but got '{0}'` | -`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. The `{0}` argument is a **description** of the sentinel (`an empty GUID`, `DateTime.MinValue`, `an empty string`, …) rather than the raw value, and a type-level `[NonSentinel]` on a `[Scalar]` type replaces the message's `Value` subject with the scalar's name — see [Value Objects Integration](Value-Objects-Integration.md#message-overrides). +`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. The `{0}` argument is a **description** of the sentinel (`an empty GUID`, `DateTime.MinValue`, `an empty string`, …) rather than the raw value, and a type-level `[NonSentinel]` on a `[Scalar]` type replaces the message's `Value` subject with the scalar's name — see [Value Objects Integration](Value-Objects-Integration.md#message-overrides). Its JSON Schema mapping is exact for `DateOnly`, `TimeOnly`, the deterministic `DateTime` wire forms, and `DateTimeOffset` (the default pattern is offset-aware); the optional UTC contract narrows the accepted offset — see [JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). `NullOrNonWhiteSpaceRule` is the null-tolerant counterpart: it accepts `null` but rejects an empty or whitespace-only string. It exists for nullable scalar value objects, which round-trip JSON `null`, so their schema must accept `null` while still rejecting whitespace — `[RequiredZod]` always rejects `null` and so cannot express it. Apply it to a nullable scalar type with `[NullOrNonWhiteSpace]`; a rule written against the underlying `string?` value is adapted to the value object automatically. -The fluent `NonSentinel()` method is declared on `ZodType`, so it is available on every schema and closes the rule with the schema's output type. `ZodString` and `ZodDate` override it with a covariant return type so the fluent chain keeps the concrete schema: +The fluent `NonSentinel()` method is declared on `ZodType`, so it is available on every schema and closes the rule with the schema's output type. `ZodString`, `ZodDate`, and `ZodDateTimeOffset` override it with a covariant return type so the fluent chain keeps the concrete schema: ```csharp var schema = Z.Date().NonSentinel(); var result = schema.Validate(DateTime.MinValue); // result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") +var offsetSchema = Z.DateTimeOffset().NonSentinel(); +var offsetResult = offsetSchema.Validate(DateTimeOffset.MinValue); + // Covariant overrides keep the concrete schema, so later fluent calls still compile. var chained = Z.String().NonSentinel().Min(3); ``` diff --git a/package.json b/package.json index c33b5a0e..744a8971 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.2.0", + "version": "2.3.0", "private": true, "license": "MIT", "author": { diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md index 4167b524..06215ccb 100644 --- a/skills/purview-zodsharp-custom-rules/SKILL.md +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -197,7 +197,9 @@ A generic rule closed with the property type lets one attribute serve every memb default/boundary values an ORM stores to mean "no value" — `Guid.Empty`, `DateTime.MinValue`/`MaxValue`, the `DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and null/empty/whitespace strings. A type with no known sentinel always passes, so the rule never rejects -something it does not understand. +something it does not understand. Its JSON Schema mapping is exact for `DateOnly`, `TimeOnly`, deterministic +`DateTime` forms, and `DateTimeOffset`; the optional UTC opt-in only narrows the accepted offset (see the JSON +skill). ```csharp var schema = Z.Date().AddRule(new NonSentinelRule()); diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index f65bce52..2109cf5d 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -110,7 +110,13 @@ behaviours: when serialized as strings. A `[Flags]` enum emits `type: integer` instead, because a combination is valid without being a defined member; - lazy/recursive schemas emit `$ref` entries under `$defs` (`#/$defs/__lazyN`); -- rules that describe their own constraint implement `IJsonSchemaRule` and are applied during export. +- rules that describe their own constraint implement `IJsonSchemaRule` and are applied during export; +- `[NonSentinel]` exports exact patterns for `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset`. The + default `DateTimeOffset` pattern is offset-aware and needs no converter. An optional narrower UTC contract + uses `ZodSharpDateTimeOffsetSerialization=Utc` or + `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`, paired + with the integration package's `UtcDateTimeOffsetConverter` (which rejects non-UTC input on read). Override + per type/property with `[DateTimeOffsetBehavior(...)]`. ### Exporting a generated `[ZodSchema]` diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index ad4c9654..228a53dd 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -196,6 +196,12 @@ and the type name ends with a configured suffix — `Options` or `Settings` by d | `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collections may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; pair with the integration package's `UtcDateTimeOffsetConverter`. Both modes export an exact sentinel pattern | + +`DateTimeOffset` precedence: `[DateTimeOffsetBehavior]` on the property/parameter, then on the type, then +`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]`, then the MSBuild property, then +`PreserveOffset`. The attribute shapes the schema only; `Utc` requires a converter that also rejects non-UTC +input on read. ## Value objects diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index fdf37cf4..99f8beac 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -7,14 +7,14 @@ version ships. This lives in the repo because the baseline is package-specific; Purview.BuildSdk has no default for it. - 2.1.0 validates clean against 2.0.2 with no suppressions: the rule and attribute changes in the CHANGELOG's - upgrade notes landed in the 2.0.x releases, so they are already part of the baseline. An intended change - from here needs a CompatibilitySuppressions.xml next to the project that owns it (generate one with + 2.3.0 validates against 2.2.0 with no suppressions: the temporal rule and DateTimeOffset wire-form + additions are additive public API. An intended change from here needs a CompatibilitySuppressions.xml next + to the project that owns it (generate one with `dotnet pack /p:GenerateCompatibilitySuppressionFile=true`), and that file is then the evidence behind the matching upgrade note — a change that is not listed in both is a defect. --> true - 2.0.2 + 2.2.0 diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs new file mode 100644 index 00000000..255526cf --- /dev/null +++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; + +namespace ZodSharp.Json.Serialization.NewtonsoftJson; + +/// +/// Configuration helpers for the DateTimeOffset converters. +/// +/// +/// Newtonsoft.Json parses date-like strings into by default, which discards the +/// offset before a converter sees it. These helpers set +/// to together with the converter, so the converter can inspect and +/// enforce the original offset. +/// +public static class NewtonsoftJsonSerializationExtensions +{ + /// + /// Configures the serializer for the UTC DateTimeOffset contract. + /// + /// The settings to update. + /// The same settings instance, for chaining. + public static JsonSerializerSettings UseZodSharpUtcDateTimeOffset(this JsonSerializerSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + settings.DateParseHandling = DateParseHandling.None; + settings.Converters.Add(new UtcDateTimeOffsetConverter()); + return settings; + } + + /// + /// Configures the serializer to preserve DateTimeOffset offsets, including as a per-type/property + /// override of a global UTC converter. + /// + /// The settings to update. + /// The same settings instance, for chaining. + public static JsonSerializerSettings UseZodSharpPreserveOffsetDateTimeOffset(this JsonSerializerSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + settings.DateParseHandling = DateParseHandling.None; + settings.Converters.Add(new PreserveOffsetDateTimeOffsetConverter()); + return settings; + } +} diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs new file mode 100644 index 00000000..4ce8c990 --- /dev/null +++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using Newtonsoft.Json; + +namespace ZodSharp.Json.Serialization.NewtonsoftJson; + +/// +/// Serializes with its original offset. +/// +/// +/// +/// Newtonsoft.Json already preserves the offset by default. This converter exists so a type or property +/// that overrides a project-wide with +/// [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] can also override the +/// serializer converter explicitly, for example with [JsonConverter]. +/// +/// +/// Like the UTC converter, this one needs DateParseHandling = DateParseHandling.None so it can read +/// the original string and preserve its offset. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1062:Validate arguments of public methods", + Justification = "Called by Newtonsoft.Json with non-null arguments." +)] +public sealed class PreserveOffsetDateTimeOffsetConverter : JsonConverter +{ + /// + public override bool CanConvert(Type objectType) => + objectType == typeof(DateTimeOffset) || objectType == typeof(DateTimeOffset?); + + /// + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer + ) + { + if (reader.TokenType == JsonToken.Null) + return null; + + return DateTimeOffset.Parse( + UtcDateTimeOffsetConverter.ReadRawString(reader), + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind + ); + } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + return; + } + + writer.WriteValue(((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture)); + } +} diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs new file mode 100644 index 00000000..10f929f1 --- /dev/null +++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using Newtonsoft.Json; + +namespace ZodSharp.Json.Serialization.NewtonsoftJson; + +/// +/// Serializes as UTC and rejects a non-UTC offset on read. +/// +/// +/// +/// Pair this converter with a UTC JSON Schema contract, for example +/// [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] +/// or the ZodSharpDateTimeOffsetSerialization MSBuild property. The schema requires a Z or +/// zero offset, so rejecting a non-UTC value here keeps the serializer and the schema in agreement. +/// +/// +/// Newtonsoft.Json parses date-like strings into by default, which discards the +/// offset before a converter sees it. Configure the serializer with +/// DateParseHandling = DateParseHandling.None so this converter can inspect the original string; the +/// converter reports a clear exception when that setting is missing. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1062:Validate arguments of public methods", + Justification = "Called by Newtonsoft.Json with non-null arguments." +)] +public sealed class UtcDateTimeOffsetConverter : JsonConverter +{ + /// + public override bool CanConvert(Type objectType) => + objectType == typeof(DateTimeOffset) || objectType == typeof(DateTimeOffset?); + + /// + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer + ) + { + if (reader.TokenType == JsonToken.Null) + return null; + + var text = ReadRawString(reader); + if (!HasUtcSuffix(text)) + { + throw new JsonSerializationException( + "DateTimeOffset values must carry an explicit UTC designator ('Z' or a zero offset) under the " + + "ZodSharp UTC contract." + ); + } + + var value = DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + return value.Offset == TimeSpan.Zero + ? value + : throw new JsonSerializationException( + $"DateTimeOffset values must be UTC under the ZodSharp UTC contract, but got offset " + + $"'{value.Offset}'." + ); + } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + return; + } + + writer.WriteValue(((DateTimeOffset)value).ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + } + + static bool HasUtcSuffix(string text) => + text.EndsWith('Z') + || text.EndsWith("+00:00", StringComparison.Ordinal) + || text.EndsWith("-00:00", StringComparison.Ordinal); + + internal static string ReadRawString(JsonReader reader) => + reader.Value as string + ?? throw new JsonSerializationException( + "DateTimeOffset values must be read with JsonSerializerSettings.DateParseHandling set to " + + "DateParseHandling.None so the offset can be inspected; Newtonsoft.Json parsed the value " + + "before the converter could see it." + ); +} diff --git a/src/src/NewtonsoftJson/Sdk/README.md b/src/src/NewtonsoftJson/Sdk/README.md index 71d6909e..92c3cc35 100644 --- a/src/src/NewtonsoftJson/Sdk/README.md +++ b/src/src/NewtonsoftJson/Sdk/README.md @@ -45,6 +45,26 @@ var converter = userSchema.CreateValidatingConverter(); var value = JsonConvert.DeserializeObject(json, converter); ``` +## UTC DateTimeOffset contract + +The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses +`DateTimeOffsetSerialization.Utc`, add the matching converter so the serializer writes UTC and rejects a value +without an explicit `Z`/zero offset (or with a non-zero offset) on read: + +```csharp +using Newtonsoft.Json; +using ZodSharp.Json.Serialization.NewtonsoftJson; + +var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset(); +``` + +The helper sets `DateParseHandling = DateParseHandling.None`, which is required so the converter can inspect +the original offset, and adds the UTC converter. `UseZodSharpPreserveOffsetDateTimeOffset()` is the +per-type/property opt-out equivalent. + +`PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC +converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). + ## Import from JSON Schema ```csharp diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index 04bc8092..30cd6c9f 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -18,6 +18,12 @@ static class AttributeGenHelper /// internal const string AllowNullsPropertyName = "AllowNulls"; + /// + /// The name of the DateTimeOffsetSerialization property on + /// [assembly: ZodSharpSchemaDefaults(...)]. + /// + internal const string DateTimeOffsetSerializationPropertyName = "DateTimeOffsetSerialization"; + public static IEnumerable<(string HintName, SourceText SourceText)> GenerateMarkers() { yield return (nameof(ZodSchemaAttribute), ZodSchemaAttribute()); diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs index 2c70e659..2d67488a 100644 --- a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs +++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs @@ -18,14 +18,18 @@ internal readonly record struct AutoDetectSettings( bool GenerateJsonSchemas, bool EnumAsString, EnumJsonNamingPolicy EnumNamingPolicy, - bool AllowNulls + bool AllowNulls, + DateTimeOffsetSerialization DateTimeOffsetSerialization ); /// /// The assembly-level schema defaults declared by [assembly: ZodSharpSchemaDefaults(...)]. A null /// value defers to the corresponding MSBuild property. /// - internal readonly record struct AssemblyDefaults(bool? AllowNulls); + internal readonly record struct AssemblyDefaults( + bool? AllowNulls, + DateTimeOffsetSerialization? DateTimeOffsetSerialization + ); public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider) { @@ -71,7 +75,18 @@ out var allowNullsValue && bool.TryParse(allowNullsValue, out var allowNullsEnabled) && allowNullsEnabled; - return new(autoGenerate, suffixes, generateJsonSchemas, enumAsString, enumNamingPolicy, allowNulls); + // Offsets are preserved by default. Utc is the only opt-in value; anything else keeps the default. + var dateTimeOffsetSerialization = ReadDateTimeOffsetSerialization(provider); + + return new( + autoGenerate, + suffixes, + generateJsonSchemas, + enumAsString, + enumNamingPolicy, + allowNulls, + dateTimeOffsetSerialization + ); } /// @@ -88,12 +103,43 @@ public static AssemblyDefaults ReadAssemblyDefaults(Compilation compilation) if (!TypeLibrary.ZodSharp.ZodSharpSchemaDefaultsAttribute.Equals(attribute.AttributeClass)) continue; - return attribute.TryGetNamedArgument(AttributeGenHelper.AllowNullsPropertyName, out var allowNulls) - ? new(allowNulls) - : new(null); + var allowNulls = attribute.TryGetNamedArgument( + AttributeGenHelper.AllowNullsPropertyName, + out var allow + ) + ? allow + : (bool?)null; + + DateTimeOffsetSerialization? dateTimeOffsetSerialization = null; + foreach (var argument in attribute.NamedArguments) + { + if ( + argument.Key == AttributeGenHelper.DateTimeOffsetSerializationPropertyName + && argument.Value.Value is int serialization + ) + { + dateTimeOffsetSerialization = (DateTimeOffsetSerialization)serialization; + } + } + + return new(allowNulls, dateTimeOffsetSerialization); } - return new(null); + return new(null, null); + } + + static DateTimeOffsetSerialization ReadDateTimeOffsetSerialization(AnalyzerConfigOptionsProvider provider) + { + var value = provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.DateTimeOffsetSerializationProperty}", + out var configured + ) + ? configured?.Trim() + : null; + + return string.Equals(value, nameof(DateTimeOffsetSerialization.Utc), StringComparison.OrdinalIgnoreCase) + ? DateTimeOffsetSerialization.Utc + : DateTimeOffsetSerialization.PreserveOffset; } static bool ReadEnumSerializationAsString(AnalyzerConfigOptionsProvider provider) => @@ -159,6 +205,19 @@ AssemblyDefaults assemblyDefaults changed = true; } + // Precedence, highest first: the type-level [DateTimeOffsetBehavior], the assembly-level + // ZodSharpSchemaDefaults, the MSBuild property, then PreserveOffset. + var dateTimeOffsetSerialization = + schema.DateTimeOffsetSerialization + ?? assemblyDefaults.DateTimeOffsetSerialization + ?? settings.DateTimeOffsetSerialization; + + if (schema.DateTimeOffsetSerialization != dateTimeOffsetSerialization) + { + schema = schema with { DateTimeOffsetSerialization = dateTimeOffsetSerialization }; + changed = true; + } + if ( settings.AutoGenerate && settings.Suffixes.Count > 0 @@ -179,6 +238,16 @@ AssemblyDefaults assemblyDefaults changed = true; } + var resolvedDateTimeOffset = ResolveDateTimeOffsetSerialization( + schema.Properties, + dateTimeOffsetSerialization + ); + if (resolvedDateTimeOffset is { } dateTimeOffsetProperties) + { + schema = schema with { Properties = dateTimeOffsetProperties }; + changed = true; + } + builder.Add(changed ? GeneratorResult.Create(schema) : result); } @@ -229,4 +298,45 @@ AssemblyDefaults assemblyDefaults return changed ? new(builder.ToImmutable()) : null; } + + /// + /// Resolves each property's DateTimeOffset wire form against the containing type's resolved setting. + /// + /// The schema's properties. + /// The type/assembly/MSBuild setting already resolved for the schema. + /// The rewritten properties, or when no property changed. + static EquatableArray>? ResolveDateTimeOffsetSerialization( + EquatableArray> properties, + DateTimeOffsetSerialization schemaSerialization + ) + { + if (properties.Count == 0) + return null; + + var changed = false; + var builder = ImmutableArray.CreateBuilder>(properties.Count); + foreach (var propertyResult in properties) + { + if (!propertyResult.ShouldProcess) + { + builder.Add(propertyResult); + continue; + } + + var property = propertyResult.Value; + var resolved = property.DateTimeOffsetSerialization ?? schemaSerialization; + if (property.DateTimeOffsetSerialization == resolved) + { + builder.Add(propertyResult); + continue; + } + + builder.Add( + GeneratorResult.Create(property with { DateTimeOffsetSerialization = resolved }) + ); + changed = true; + } + + return changed ? new(builder.ToImmutable()) : null; + } } diff --git a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs index bfef5d91..22224de0 100644 --- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs @@ -17,4 +17,6 @@ static class PropertyLibrary public const string EnumNamingPolicyProperty = "ZodSharpEnumNamingPolicy"; public const string AllowNullsProperty = "ZodSharpAllowNulls"; + + public const string DateTimeOffsetSerializationProperty = "ZodSharpDateTimeOffsetSerialization"; } diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index fdaa51d6..42c2015d 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -102,8 +102,20 @@ CancellationToken cancellationToken var schema = target with { Name = schemaName }; var targetCanBeNull = TypeHelpers.CanBeNull(symbol); var typeAllowNulls = ReadAllowNulls(attribute); - var properties = GetZodProperties(symbol, externalSchemas, compilation, typeAllowNulls); - properties = AddAutomaticScalarProperty(symbol, properties, externalSchemas); + var typeDateTimeOffsetSerialization = ReadDateTimeOffsetSerialization(symbol.GetAttributes()); + var properties = GetZodProperties( + symbol, + externalSchemas, + compilation, + typeAllowNulls, + typeDateTimeOffsetSerialization + ); + properties = AddAutomaticScalarProperty( + symbol, + properties, + externalSchemas, + typeDateTimeOffsetSerialization + ); // Type-level rules ([ZodRule]-mapped attributes on the target itself) validate the whole value // rather than a property, which is what makes a scalar value object validatable as a unit. The @@ -170,7 +182,8 @@ CancellationToken cancellationToken scalarUnderlyingCanBeNull, generateJsonSchema, false, - default + default, + typeDateTimeOffsetSerialization ) ); @@ -281,7 +294,8 @@ static EquatableArray> GetZodProperties( INamedTypeSymbol symbol, ExternalSchemaResolver externalSchemas, Compilation compilation, - bool? typeAllowNulls + bool? typeAllowNulls, + DateTimeOffsetSerialization? typeDateTimeOffsetSerialization ) { var properties = symbol @@ -291,7 +305,13 @@ static EquatableArray> GetZodProperties( property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer ) .Select(property => - GetValidatablePropertyDescriptor(property, externalSchemas, compilation, typeAllowNulls) + GetValidatablePropertyDescriptor( + property, + externalSchemas, + compilation, + typeAllowNulls, + typeDateTimeOffsetSerialization + ) ) .ToImmutableArray(); @@ -318,11 +338,13 @@ Compilation compilation /// The schema target type. /// The properties discovered from source. /// The resolver that decides schema ownership. + /// 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 + ExternalSchemaResolver externalSchemas, + DateTimeOffsetSerialization? typeDateTimeOffsetSerialization ) { if (!CustomRuleResolver.TryGetAutomaticScalar(symbol, out var propertyName, out var valueType)) @@ -386,7 +408,8 @@ originalPropertyType is INamedTypeSymbol null, true, false, - new([]) + new([]), + typeDateTimeOffsetSerialization ); return new([GeneratorResult.Create(descriptor), .. properties]); @@ -419,7 +442,8 @@ internal static GeneratorResult GetValidatablePropertyDes IPropertySymbol property, ExternalSchemaResolver? externalSchemas, Compilation compilation, - bool? typeAllowNulls + bool? typeAllowNulls, + DateTimeOffsetSerialization? typeDateTimeOffsetSerialization = null ) { var declaredType = CreateTypeIdentity(property.Type); @@ -471,6 +495,8 @@ originalPropertyType is INamedTypeSymbol var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas); var lengthAccessor = ClassifyLengthAccessor(originalPropertyType); var validationAttributes = GetValidationAttributes(property, compilation); + var dateTimeOffsetSerialization = + ReadDateTimeOffsetSerialization(validationAttributes) ?? typeDateTimeOffsetSerialization; var allowNulls = validationKind == PropertyValidationKind.Collection ? ResolveAllowNulls(originalPropertyType, validationAttributes, typeAllowNulls) @@ -629,7 +655,8 @@ rangeAttribute with GetJsonPropertyName(property), NestedSchemaExportsJsonSchema(property, validationKind, externalSchemas), enumSerializedAsString, - enumJsonNames + enumJsonNames, + dateTimeOffsetSerialization ), diagnostics.ToImmutable() ); @@ -884,6 +911,43 @@ attribute is not null ? allow : null; + /// + /// Reads the [DateTimeOffsetBehavior] override from a type's or property's attributes, or + /// when absent. + /// + /// The effective attributes to inspect. + /// The declared serialization, or to defer to the next level. + static DateTimeOffsetSerialization? ReadDateTimeOffsetSerialization(ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if (!TypeLibrary.ZodSharp.DateTimeOffsetBehaviorAttribute.Equals(attribute.AttributeClass)) + continue; + + foreach (var constructorArgument in attribute.ConstructorArguments) + { + if (constructorArgument.Value is int serialization) + return (DateTimeOffsetSerialization)serialization; + + if (constructorArgument.Value is { } boxed) + return (DateTimeOffsetSerialization) + Convert.ToInt32(boxed, System.Globalization.CultureInfo.InvariantCulture); + } + + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "Serialization" && argument.Value.Value is int namedSerialization) + { + return (DateTimeOffsetSerialization)namedSerialization; + } + } + + break; + } + + return null; + } + /// /// Resolves the property/type-level override for whether a collection may contain null elements. /// diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index 3e864c1a..f4f66e3d 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -57,6 +57,9 @@ static partial class TypeLibraryGenerator [TypeRef(ZodSharpNamespace)] static readonly TypeIdentity CollectionBehaviorAttribute = default; + [TypeRef(ZodSharpNamespace)] + static readonly TypeIdentity DateTimeOffsetBehaviorAttribute = default; + [TypeRef(ZodSharpNamespace)] static readonly TypeIdentity ZodSharpSchemaDefaultsAttribute = default; diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index aa57bac8..032f679f 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -41,6 +41,20 @@ enum EnumJsonNamingPolicy CamelCase, } +/// +/// The DateTimeOffset wire form the generator assumes, mirroring the public +/// ZodSharp.DateTimeOffsetSerialization enum. The generator cannot reference the core assembly, so the +/// values are mapped by their numeric identity. +/// +enum DateTimeOffsetSerialization +{ + /// The wire form keeps the value's offset. + PreserveOffset = 0, + + /// The wire form is UTC: a Z or zero offset. + Utc = 1, +} + readonly record struct LengthAccessor(string LengthExpression, string Origin, bool IsSupported); /// This is the target of the attribute, the type where the attribute was defined. @@ -88,6 +102,11 @@ enum EnumJsonNamingPolicy /// How a string-serialized enum's member names are transformed, set by the assembly-level /// ZodSharpEnumNamingPolicy switch. /// +/// +/// The type-level [DateTimeOffsetBehavior] override for the wire form assumed by JSON Schema +/// generation, or when unset. The generation-settings pass resolves it against the +/// assembly/MSBuild default. +/// readonly record struct ZodSchemaDescriptor( TypeIdentity TargetType, TypeIdentity SchemaType, @@ -109,7 +128,8 @@ readonly record struct ZodSchemaDescriptor( bool ScalarUnderlyingCanBeNull, bool GenerateJsonSchema, bool EnumAsString, - EnumJsonNamingPolicy EnumNamingPolicy + EnumJsonNamingPolicy EnumNamingPolicy, + DateTimeOffsetSerialization? DateTimeOffsetSerialization = null ); /// The declared type of the property, with a nullable wrapper removed. @@ -158,6 +178,10 @@ EnumJsonNamingPolicy EnumNamingPolicy /// For a non-flags enum, the JSON names of the allowed members, in declaration order: the member name, or its /// [JsonStringEnumMemberName] value when declared. Used when the enum is serialized as a string. /// +/// +/// The property-level [DateTimeOffsetBehavior] override, or when unset. The +/// generation-settings pass resolves it against the type and assembly/MSBuild defaults. +/// readonly record struct ZodPropertyDescriptor( TypeIdentity PropertyType, string Name, @@ -180,7 +204,8 @@ readonly record struct ZodPropertyDescriptor( string? JsonPropertyName, bool NestedSchemaExportsJsonSchema, bool EnumSerializedAsString, - EquatableArray EnumJsonNames + EquatableArray EnumJsonNames, + DateTimeOffsetSerialization? DateTimeOffsetSerialization = null ); [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index 34190656..5f7544a1 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -20,6 +20,7 @@ + diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index dc35bd1d..11cccba6 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -89,7 +89,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty body.Assignment("var", "inner", BuildBaseSchemaExpression(underlying)); foreach (var rule in schema.TypeRules) - body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); + EmitRuleJsonSchema(body, rule, "inner", schema.DateTimeOffsetSerialization); foreach (var property in schema.Properties) { @@ -98,7 +98,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty EmitAttributeConstraints(body, property.Value, "inner"); foreach (var rule in property.Value.CustomRules) - body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); + EmitRuleJsonSchema(body, rule, "inner", property.Value.DateTimeOffsetSerialization); } body.Return( @@ -125,7 +125,7 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper EmitAttributeConstraints(body, property, localName); foreach (var rule in property.CustomRules) - body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule)); + EmitRuleJsonSchema(body, rule, localName, property.DateTimeOffsetSerialization); if (isNullable) { @@ -227,14 +227,23 @@ static string BuildBaseSchemaExpression(TypeIdentity type) if (IsWellKnown(type, "System", "Guid")) return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"string\", Pattern = global::ZodSharp.Rules.GuidRule.JsonSchemaPattern }}"; - if (type.SpecialType == SpecialType.System_DateTime || IsWellKnown(type, "System", "DateTimeOffset")) + if ( + type.SpecialType == SpecialType.System_DateTime + || IsWellKnown(type, "System", "DateTime") + || IsWellKnown(type, "System", "DateTimeOffset") + ) + { return Schema("string", "date-time"); + } if (IsWellKnown(type, "System", "DateOnly")) return Schema("string", "date"); + // format: time is an RFC 3339 full-time with an offset, while System.Text.Json writes TimeOnly as a + // local HH:mm:ss[.fffffff] value. Express the local lexical space as a pattern so strict consumers do + // not reject every TimeOnly payload. if (IsWellKnown(type, "System", "TimeOnly")) - return Schema("string", "time"); + return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"string\", Pattern = {JsonSchemaRulesTypeName}.TimeOnlyPattern }}"; if (IsWellKnown(type, "System", "TimeSpan")) return Schema("string", "duration"); @@ -284,6 +293,42 @@ or SpecialType.System_Int64 static bool IsFloatingType(SpecialType specialType) => specialType is SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal; + /// + /// Emits the JSON Schema application of a custom rule. NonSentinelRule<T> receives the + /// resolved DateTimeOffset wire form so the rule can choose between the exact UTC mapping and the + /// offset-preserving mapping that retains the unsupported marker; every other rule uses the normal + /// two-argument overload. + /// + static void EmitRuleJsonSchema( + CodeWriter body, + CustomRuleDescriptor rule, + string localName, + DateTimeOffsetSerialization? dateTimeOffsetSerialization + ) + { + if (IsNonSentinelRule(rule)) + { + body.MethodCallOn( + JsonSchemaRulesTypeName, + "Apply", + localName, + BuildRuleInstanceExpression(rule), + RenderDateTimeOffsetSerialization(dateTimeOffsetSerialization) + ); + return; + } + + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule)); + } + + static bool IsNonSentinelRule(CustomRuleDescriptor rule) => + (rule.AdaptedFrom ?? rule.RuleType) is { Name: "NonSentinelRule", Namespace: "ZodSharp.Rules" }; + + static string RenderDateTimeOffsetSerialization(DateTimeOffsetSerialization? serialization) => + serialization == DateTimeOffsetSerialization.Utc + ? "global::ZodSharp.DateTimeOffsetSerialization.Utc" + : "global::ZodSharp.DateTimeOffsetSerialization.PreserveOffset"; + static string BuildRuleInstanceExpression(CustomRuleDescriptor rule) { // A scalar rule adapter wraps a rule written against the scalar's underlying value; the wrapped rule diff --git a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs new file mode 100644 index 00000000..42f570b8 --- /dev/null +++ b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZodSharp.Json.Serialization.SystemTextJson; + +/// +/// Serializes with its original offset. +/// +/// +/// System.Text.Json already preserves the offset by default. This converter exists so a type or property +/// that overrides a project-wide with +/// [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] can also override the +/// serializer converter explicitly, for example with [JsonConverter]. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1062:Validate arguments of public methods", + Justification = "Called by System.Text.Json with non-null arguments." +)] +public sealed class PreserveOffsetDateTimeOffsetConverter : JsonConverter +{ + /// + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetDateTimeOffset(); + + /// + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => + writer.WriteStringValue(value); +} diff --git a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs new file mode 100644 index 00000000..4364a12a --- /dev/null +++ b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZodSharp.Json.Serialization.SystemTextJson; + +/// +/// Serializes as UTC and rejects a non-UTC offset on read. +/// +/// +/// +/// Pair this converter with a UTC JSON Schema contract, for example +/// [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] +/// or the ZodSharpDateTimeOffsetSerialization MSBuild property. The schema requires a Z or +/// zero offset, so rejecting a non-UTC value here keeps the serializer and the schema in agreement. +/// +/// +/// Writing normalizes the instant to UTC and emits +00:00. The converter adds no reflection and is +/// safe under trimming and Native AOT. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1062:Validate arguments of public methods", + Justification = "Called by System.Text.Json with non-null arguments." +)] +public sealed class UtcDateTimeOffsetConverter : JsonConverter +{ + /// + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var text = + reader.GetString() + ?? throw new JsonException("DateTimeOffset values must be a JSON string under the ZodSharp UTC contract."); + + if (!HasUtcSuffix(text)) + { + throw new JsonException( + "DateTimeOffset values must carry an explicit UTC designator ('Z' or a zero offset) under the " + + "ZodSharp UTC contract." + ); + } + + var value = DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + return value.Offset == TimeSpan.Zero + ? value + : throw new JsonException( + $"DateTimeOffset values must be UTC under the ZodSharp UTC contract, but got offset " + + $"'{value.Offset}'." + ); + } + + /// + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToUniversalTime()); + + static bool HasUtcSuffix(string text) => + text.EndsWith('Z') + || text.EndsWith("+00:00", StringComparison.Ordinal) + || text.EndsWith("-00:00", StringComparison.Ordinal); +} diff --git a/src/src/SystemTextJson/Sdk/README.md b/src/src/SystemTextJson/Sdk/README.md index 37528d15..d179c8e7 100644 --- a/src/src/SystemTextJson/Sdk/README.md +++ b/src/src/SystemTextJson/Sdk/README.md @@ -43,6 +43,24 @@ var options = new JsonSerializerOptions { Converters = { converter } }; var value = JsonSerializer.Deserialize(json, options); ``` +## UTC DateTimeOffset contract + +The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses +`DateTimeOffsetSerialization.Utc`, add the matching converter so the serializer writes UTC and rejects a value +without an explicit `Z`/zero offset (or with a non-zero offset) on read: + +```csharp +using ZodSharp.Json.Serialization.SystemTextJson; + +var options = new JsonSerializerOptions +{ + Converters = { new UtcDateTimeOffsetConverter() }, +}; +``` + +`PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC +converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). + ## Import from JSON Schema ```csharp diff --git a/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs new file mode 100644 index 00000000..13e11b67 --- /dev/null +++ b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs @@ -0,0 +1,43 @@ +namespace ZodSharp; + +/// +/// Overrides the assumed for a type or a single +/// property (or positional record parameter). +/// +/// +/// +/// The setting only shapes the JSON Schema the [ZodSchema] generator exports; it does not change how +/// values are serialized. matches the serializer's +/// normal behavior and exports an exact offset-aware sentinel pattern. +/// is a narrower contract; pair it with the integration +/// package's UTC converter, which writes UTC and rejects a non-UTC offset on read. +/// +/// +/// Precedence, highest first: this attribute on the property (or positional parameter), this attribute on +/// the containing type, [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)], the +/// ZodSharpDateTimeOffsetSerialization MSBuild property (for example in Directory.Build.props), +/// and finally . +/// +/// +/// A [Scalar<DateTimeOffset>] owns its schema, so apply the attribute to the scalar type rather +/// than to a property whose type is that scalar. +/// +/// +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property | AttributeTargets.Parameter, + AllowMultiple = false, + Inherited = false +)] +public sealed class DateTimeOffsetBehaviorAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The DateTimeOffset wire form assumed for JSON Schema generation. + public DateTimeOffsetBehaviorAttribute(DateTimeOffsetSerialization serialization) => Serialization = serialization; + + /// + /// Gets the DateTimeOffset wire form assumed for JSON Schema generation. + /// + public DateTimeOffsetSerialization Serialization { get; } +} diff --git a/src/src/ZodSharp/DateTimeOffsetSerialization.cs b/src/src/ZodSharp/DateTimeOffsetSerialization.cs new file mode 100644 index 00000000..47b1547d --- /dev/null +++ b/src/src/ZodSharp/DateTimeOffsetSerialization.cs @@ -0,0 +1,33 @@ +namespace ZodSharp; + +/// +/// Controls the JSON wire form assumed for values when the +/// [ZodSchema] source generator exports JSON Schema. +/// +/// +/// +/// This setting affects schema generation only; it does not serialize anything by itself. A +/// contract must be paired with a converter that writes UTC and rejects non-UTC input, such as the +/// integration package's UtcDateTimeOffsetConverter, so the schema and the serializer agree. +/// +/// +/// The default, , leaves the offset in the wire form. The exported +/// [NonSentinel] pattern is offset-aware, so it rejects every RFC 3339 representation of the +/// instant-based bounds. is a narrower contract: the schema requires a zero offset and is +/// paired with a converter that rejects non-UTC input. +/// +/// +public enum DateTimeOffsetSerialization +{ + /// + /// The wire form keeps the value's offset. This is the default and works with the serializer's normal + /// DateTimeOffset behavior. + /// + PreserveOffset = 0, + + /// + /// The wire form is UTC: a Z or zero offset. The schema rejects every other offset and is paired + /// with a converter that also rejects non-UTC input. + /// + Utc = 1, +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs index 7d669bfc..b7f1e64a 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs @@ -1,4 +1,5 @@ using System.Globalization; +using ZodSharp.Rules; namespace ZodSharp.JsonSchema; @@ -38,6 +39,18 @@ public interface IJsonSchemaRule /// public static class JsonSchemaRules { + /// + /// The JSON Schema pattern for the System.Text.Json wire form + /// (HH:mm:ss[.fffffff], with no offset). + /// + /// + /// JSON Schema's format: time is an RFC 3339 full-time and therefore requires an offset. A + /// System.Text.Json is a local time and never carries one, so a strict + /// format: time consumer rejects every value. The local lexical space is expressed as this pattern + /// instead, which a standards-based consumer can evaluate without a format assertion. + /// + public const string TimeOnlyPattern = "^(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d{1,7})?$"; + /// /// Applies to when it implements /// ; otherwise records the rule as unsupported so the exported schema is @@ -59,6 +72,38 @@ public static void Apply(JsonSchemaDefinition schema, object rule) MarkUnsupported(schema, rule.GetType().Name); } + /// + /// Applies with the wire-form assumption a DateTimeOffset sentinel mapping + /// needs; every other rule ignores . + /// + /// The schema the rule constrains. + /// The rule instance. + /// + /// The wire form assumed for a value. + /// + /// + /// The generated BuildJsonSchema calls this overload for so the + /// rule itself owns both the default and the UTC mapping. A rule that does not describe its own JSON + /// Schema constraint is still reported as unsupported. + /// + public static void Apply( + JsonSchemaDefinition schema, + object rule, + DateTimeOffsetSerialization dateTimeOffsetSerialization + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(rule); + + if (rule is NonSentinelRule dateTimeOffsetRule) + { + dateTimeOffsetRule.ApplyTo(schema, dateTimeOffsetSerialization); + return; + } + + Apply(schema, rule); + } + /// /// Records in . /// diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 6b280ac4..3477ff02 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -21,7 +21,7 @@ public static class ToJsonSchemaConverter public static JsonSchemaDefinition Convert(IZodSchema schema, ToJsonSchemaOptions? options = null) { options ??= new ToJsonSchemaOptions(); - ConversionContext context = new(); + ConversionContext context = new() { DateTimeOffsetSerialization = options.DateTimeOffsetSerialization }; var result = ConvertSchema(schema, context); @@ -51,6 +51,8 @@ sealed class ConversionContext public Dictionary Defs { get; } = []; public int Counter { get; set; } + + public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; init; } } static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) @@ -77,8 +79,13 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) var result = schema switch { - ZodString zodString => ConvertString(zodString), - ZodNumber zodNumber => ConvertNumber(zodNumber), + ZodString zodString => ConvertString(zodString, ctx), + ZodNumber zodNumber => ConvertNumber(zodNumber, ctx), + // A DateTime is the RFC 3339 date-time string System.Text.Json writes. The schema is expressed + // here rather than in ConvertGeneric so a rule that contributes a pattern (for example + // NonSentinelRule) has the typed schema to apply it to. + ZodDate zodDate => ConvertDate(zodDate, ctx), + ZodDateTimeOffset zodDateTimeOffset => ConvertDateTimeOffset(zodDateTimeOffset, ctx), ZodBoolean => new JsonSchemaDefinition { Type = "boolean" }, ZodNull => new JsonSchemaDefinition { Type = "null" }, ZodObject zodObject => ConvertObject(zodObject, ctx), @@ -98,7 +105,7 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) return result; } - static JsonSchemaDefinition ConvertString(ZodString schema) + static JsonSchemaDefinition ConvertString(ZodString schema, ConversionContext ctx) { JsonSchemaDefinition result = new() { Type = "string" }; @@ -139,7 +146,7 @@ static JsonSchemaDefinition ConvertString(ZodString schema) break; default: - ApplyUnmappedRule(result, rule); + ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization); break; } } @@ -153,7 +160,33 @@ static JsonSchemaDefinition ConvertString(ZodString schema) return result; } - static JsonSchemaDefinition ConvertNumber(ZodNumber schema) + static JsonSchemaDefinition ConvertDate(ZodDate schema, ConversionContext ctx) + { + JsonSchemaDefinition result = new() { Type = "string", Format = "date-time" }; + + foreach (var rule in schema.AppliedRules) + ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization); + + if (schema.Description != null) + result.Description = schema.Description; + + return result; + } + + static JsonSchemaDefinition ConvertDateTimeOffset(ZodDateTimeOffset schema, ConversionContext ctx) + { + JsonSchemaDefinition result = new() { Type = "string", Format = "date-time" }; + + foreach (var rule in schema.AppliedRules) + ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization); + + if (schema.Description != null) + result.Description = schema.Description; + + return result; + } + + static JsonSchemaDefinition ConvertNumber(ZodNumber schema, ConversionContext ctx) { JsonSchemaDefinition result = new() { Type = "number" }; @@ -189,7 +222,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) break; default: - ApplyUnmappedRule(result, rule); + ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization); break; } } @@ -322,8 +355,18 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) /// contributes it; anything else is recorded as unsupported so the omission is visible rather than /// silently weakening the exported schema. /// - static void ApplyUnmappedRule(JsonSchemaDefinition schema, object rule) + static void ApplyUnmappedRule( + JsonSchemaDefinition schema, + object rule, + DateTimeOffsetSerialization dateTimeOffsetSerialization + ) { + if (rule is NonSentinelRule dateTimeOffsetRule) + { + dateTimeOffsetRule.ApplyTo(schema, dateTimeOffsetSerialization); + return; + } + if (rule is IJsonSchemaRule mapped) { mapped.ApplyTo(schema); diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs index 89dbac6b..36129e43 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs @@ -20,4 +20,15 @@ public sealed class ToJsonSchemaOptions /// Custom title for the schema. /// public string? Title { get; set; } + + /// + /// Gets or sets the wire form assumed for a sentinel mapping. + /// + /// + /// The generated BuildJsonSchema path resolves this from + /// and the assembly/MSBuild defaults. This option is the + /// runtime equivalent for a hand-built fluent schema, which has no compile-time metadata to read. + /// + public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; set; } = + DateTimeOffsetSerialization.PreserveOffset; } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 3e923ea1..df75c3ca 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Runtime.CompilerServices; +using System.Text; namespace ZodSharp.Rules; @@ -25,6 +27,12 @@ namespace ZodSharp.Rules; /// without rejecting that type's entire value range. Detection uses a typeof(T) dispatch and /// reinterprets the value in place, so no boxing occurs on the validation path. /// +/// +/// The JSON Schema mapping applies to the same types. uses an offset-aware +/// pattern that rejects every RFC 3339 representation of the instant-based bounds; a UTC-only contract is +/// available as a narrower alternative. See and the overload +/// of . +/// /// [Core.ZodRule] public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule @@ -41,6 +49,103 @@ namespace ZodSharp.Rules; public const string GuidJsonSchemaPattern = "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + /// + /// The JSON Schema pattern that rejects and + /// in their System.Text.Json wire form (yyyy-MM-dd). + /// + public const string DateOnlyJsonSchemaPattern = "^(?!0001-01-01$)(?!9999-12-31$)"; + + /// + /// The JSON Schema pattern that rejects and + /// in their System.Text.Json wire form (HH:mm:ss[.fffffff]). + /// + public const string TimeOnlyJsonSchemaPattern = "^(?!00:00(?::00(?:\\.0+)?)?$)(?!23:59:59\\.9999999\\d*$)"; + + /// + /// The JSON Schema pattern that rejects the deterministic System.Text.Json wire forms of + /// and : no offset, Z, or a zero + /// offset. A non-zero offset is intentionally not rejected — System.Text.Json converts such a value into + /// the receiver's local time zone, so it no longer represents the sentinel ticks. + /// + public const string DateTimeJsonSchemaPattern = + "^(?!0001-01-01T00:00:00(?:\\.0+)?(?:Z|[+-]00:00)?$)" + + "(?!9999-12-31T23:59:59\\.9999999\\d*(?:Z|[+-]00:00)?$)"; + + /// + /// The JSON Schema pattern that rejects every RFC 3339 representation of + /// and , whatever offset the + /// wire form carries. + /// + /// + /// + /// DateTimeOffset equality compares instants, and RFC 3339 permits the same instant at different offsets. + /// For the minimum, the offset must be non-negative and the local wall clock equals the offset + /// (0001-01-01T05:30:00+05:30); for the maximum, the local wall clock is + /// 23:59:59.9999999 minus the offset (9999-12-31T18:29:59.9999999-05:30). + /// + /// + /// The pattern correlates the local time with the offset using a backreference for the minimum and two + /// lookaheads for the maximum (the hour and minute relations are independent). It is therefore exact for + /// every offset System.Text.Json can write, not just the zero-offset form. + /// + /// + public static readonly string DateTimeOffsetJsonSchemaPattern = BuildDateTimeOffsetJsonSchemaPattern(); + + /// + /// The JSON Schema pattern for a UTC-only contract: it requires a + /// Z or zero offset and rejects and + /// . + /// + /// + /// Because every accepted string has a zero offset, it denotes exactly one instant, so this pattern is + /// an exact representation of the instant-based sentinel check. Pair it with a converter that also + /// rejects non-UTC input; see . + /// + public const string DateTimeOffsetUtcJsonSchemaPattern = + "^(?=.*(?:Z|[+-]00:00)$)" + + "(?!0001-01-01T00:00:00(?:\\.0+)?(?:Z|[+-]00:00)$)" + + "(?!9999-12-31T23:59:59\\.9999999\\d*(?:Z|[+-]00:00)$)"; + + static string BuildDateTimeOffsetJsonSchemaPattern() + { + // Maximum: local time = 23:59:59.9999999 minus the offset. The hour relation + // (local 23-offsetHour) and the minute relation (local 59-offsetMinute) are independent, so two + // lookaheads correlate them without enumerating every offset pair. + StringBuilder maxHours = new("9999-12-31T23:[0-5]\\d:59\\.9999999\\d*(?:Z|[+-]00:00|-00:[0-5]\\d)"); + for (var offsetHour = 1; offsetHour <= 14; offsetHour++) + { + maxHours.Append( + CultureInfo.InvariantCulture, + $"|9999-12-31T{TwoDigits(23 - offsetHour)}:[0-5]\\d:59\\.9999999\\d*-{TwoDigits(offsetHour)}:[0-5]\\d" + ); + } + + StringBuilder maxMinutes = new("9999-12-31T[0-2]\\d:59:59\\.9999999\\d*(?:Z|[+-]00:00|-[0-2]\\d:00)"); + for (var offsetMinute = 1; offsetMinute <= 59; offsetMinute++) + { + maxMinutes.Append( + CultureInfo.InvariantCulture, + $"|9999-12-31T[0-2]\\d:{TwoDigits(59 - offsetMinute)}:59\\.9999999\\d*-[0-2]\\d:{TwoDigits(offsetMinute)}" + ); + } + + // Minimum: local time = the offset, so numbered backreferences require both components to match. The + // offset cannot be negative (it would fall below DateTimeOffset.MinValue); zero-offset forms may be + // written as Z or -00:00 as well as +00:00 (which the backreferences cover). Numbered rather than named + // groups keep the pattern usable by JSON Schema validators built on regex engines that do not support + // named backreferences. + return "^(?!" + + "(?=(?:" + + maxHours + + ")$)(?=(?:" + + maxMinutes + + ")$))" + + "(?!0001-01-01T(0\\d|1[0-4]):([0-5]\\d):00(?:\\.0+)?\\+\\1:\\2" + + "|0001-01-01T00:00:00(?:\\.0+)?(?:Z|-00:00))"; + } + + static string TwoDigits(int value) => value.ToString("00", CultureInfo.InvariantCulture); + readonly string _message; /// @@ -61,18 +166,43 @@ public NonSentinelRule(string? message = null, string? code = null) /// True if valid, false otherwise public bool IsValid(in T value) => !SentinelValues.IsSentinel(value); + /// + /// Applies the JSON Schema constraint that rejects the value's sentinel, assuming the default + /// wire form. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) => + ApplyTo(schema, DateTimeOffsetSerialization.PreserveOffset); + /// /// Applies the JSON Schema constraint that rejects the value's sentinel, where the wire form is known. /// /// The schema for the value being validated. + /// + /// The wire form assumed for a value. It is ignored for every other type. + /// /// + /// /// A string sentinel (null/empty/whitespace) becomes minLength: 1 plus a non-whitespace pattern; a - /// GUID sentinel () becomes a not: { const: ... } against the all-zero - /// UUID. A date/time sentinel depends on the exact serialized form, which the exporter cannot know here, - /// so it is reported through instead of - /// being guessed or dropped. + /// GUID sentinel () becomes a canonical-Guid pattern that rejects the all-zero + /// UUID. , and become patterns that + /// reject the System.Text.Json wire forms of their bounds; those mappings are exact for the deterministic + /// wire forms. + /// + /// + /// compares instants, and RFC 3339 permits offset-equivalent + /// representations. uses an offset-aware + /// pattern that correlates the local time with the offset and rejects every such representation of + /// and . + /// is the narrower alternative: the schema requires a zero + /// offset, so the same bounds reduce to a shorter pattern. + /// + /// + /// A type with no known sentinel always passes, so it contributes no constraint and is not reported as + /// unsupported. + /// /// - public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema, DateTimeOffsetSerialization dateTimeOffsetSerialization) { ArgumentNullException.ThrowIfNull(schema); @@ -93,7 +223,37 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) return; } - JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(NonSentinelRule<>)); + if (typeof(T) == typeof(DateOnly)) + { + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateOnlyJsonSchemaPattern); + return; + } + + if (typeof(T) == typeof(TimeOnly)) + { + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, TimeOnlyJsonSchemaPattern); + return; + } + + if (typeof(T) == typeof(DateTime)) + { + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeJsonSchemaPattern); + return; + } + + if (typeof(T) == typeof(DateTimeOffset)) + { + if (dateTimeOffsetSerialization == DateTimeOffsetSerialization.Utc) + { + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeOffsetUtcJsonSchemaPattern); + return; + } + + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeOffsetJsonSchemaPattern); + return; + } + + // No built-in sentinel is known for T, so IsValid always passes and the schema needs no constraint. } /// diff --git a/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs b/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs new file mode 100644 index 00000000..7380a8ec --- /dev/null +++ b/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs @@ -0,0 +1,35 @@ +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Schemas; + +/// +/// Schema for validation. +/// +/// +/// This is the fluent counterpart to a generated [ZodSchema] property. Its JSON Schema mapping is an +/// RFC 3339 date-time string, and exports the offset-aware sentinel pattern. +/// +public class ZodDateTimeOffset : ZodType +{ + /// + /// Parses and validates a value. + /// + /// The value to validate. + /// A successful validation result. + protected override ValidationResult ParseInternal(DateTimeOffset value) => + ValidationResult.Success(value); + + /// + /// Adds a non-sentinel validation that rejects and + /// . + /// + /// Optional error message. + /// Optional error code override. + /// This schema for method chaining. + public override ZodDateTimeOffset NonSentinel(string? message = null, string? code = null) + { + AddRule(new NonSentinelRule(message, code)); + return this; + } +} diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 999b285f..a4c6c195 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -351,6 +351,15 @@ The `Purview.ZodSharp.AspNetCore` package offers `AddZodSharp` with assembly aut var jsonSchema = Z.ToJsonSchema(userSchema, new ToJsonSchemaOptions { Title = "User" }); ``` +Generated schemas export `[NonSentinel]` for `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` as exact +patterns. The `DateTimeOffset` pattern is offset-aware, so the default preserves offsets without an +`x-zodsharp-unsupported` marker. An optional narrower UTC contract is available with +`Utc` (or +`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`) and the +JSON integration package's `UtcDateTimeOffsetConverter`. See the +[JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form) +documentation. + ## Documentation - [Homepage](https://purview.dev/projects/zodsharp/) diff --git a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props index 2ec4a361..5d32d293 100644 --- a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props +++ b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props @@ -21,5 +21,8 @@ Assembly-wide default for whether collection properties may contain null elements; false (the default) rejects them. + + Assembly-wide DateTimeOffset wire form assumed by JSON Schema generation; Utc requires a zero offset and makes the non-sentinel mapping exact, PreserveOffset (the default) keeps the value's offset. + diff --git a/src/src/ZodSharp/Z.cs b/src/src/ZodSharp/Z.cs index f2be543f..ab0d8a7a 100644 --- a/src/src/ZodSharp/Z.cs +++ b/src/src/ZodSharp/Z.cs @@ -37,6 +37,15 @@ public static class Z /// public static ZodDate Date() => new(); + /// + /// Creates a schema. + /// + /// + /// The wire form is an RFC 3339 date-time string. Use NonSentinel() to reject + /// and . + /// + public static ZodDateTimeOffset DateTimeOffset() => new(); + /// /// Creates a 64-bit integer (bigint) schema. /// Equivalent to Zod's z.bigint(). diff --git a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs index 7ad458ad..fef5674a 100644 --- a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs +++ b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs @@ -30,4 +30,24 @@ public sealed class ZodSharpSchemaDefaultsAttribute : Attribute /// parameter type, so the unset state is the absence of the named argument rather than a null value. /// public bool AllowNulls { get; set; } + + /// + /// Gets or sets the assembly-wide default for the JSON wire form assumed for + /// values. + /// + /// + /// + /// This is the assembly-level default for schema generation. It overrides the + /// ZodSharpDateTimeOffsetSerialization MSBuild property, and a type or property can override it + /// with . When neither this property nor the MSBuild + /// property is set, applies. + /// + /// + /// The setting only shapes the exported JSON Schema. + /// exports an offset-aware sentinel pattern that matches the serializer's normal behavior. + /// is narrower and must be paired with the integration + /// package's UTC converter so the serializer and the schema agree. + /// + /// + public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; set; } } diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index 634896f9..2b69a1d7 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -162,4 +162,70 @@ public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(Cancellati await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenTemporalPattern_WritesPatternAndUnsupportedKeyword() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Format = "date-time", + Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern, + UnsupportedRules = [nameof(NonSentinelRule<>)], + }; + + // Act + var json = Newtonsoft.Json.JsonConvert.SerializeObject(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"pattern\""); + await Assert.That(json).Contains("\"x-zodsharp-unsupported\""); + await Assert.That(json).Contains(nameof(NonSentinelRule<>)); + } + + [Test] + public async Task Parse_GivenDefaultDateTimeOffsetPattern_RejectsOffsetEquivalentBounds( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern, + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("9999-12-31T18:29:59.9999999-05:30").IsSuccess).IsFalse(); + } + + [Test] + public async Task Parse_GivenUtcTemporalPattern_RejectsBoundsAndNonUtcOffsets(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Pattern = NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern, + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("2024-01-02T03:04:05Z").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("0001-01-01T00:00:00Z").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsFalse(); + } } diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs new file mode 100644 index 00000000..6bf71978 --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs @@ -0,0 +1,140 @@ +using Newtonsoft.Json; + +namespace ZodSharp.Json.Serialization.NewtonsoftJson; + +public class DateTimeOffsetConverterTests +{ + static readonly DateTimeOffset OffsetValue = new(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5)); + + static JsonSerializerSettings UtcSettings() => + new() { DateParseHandling = DateParseHandling.None, Converters = { new UtcDateTimeOffsetConverter() } }; + + static JsonSerializerSettings PreserveSettings() => + new() + { + DateParseHandling = DateParseHandling.None, + Converters = { new PreserveOffsetDateTimeOffsetConverter() }, + }; + + [Test] + public async Task UtcConverter_GivenNonZeroOffset_WritesUtc() + { + // Act + var json = JsonConvert.SerializeObject(OffsetValue, UtcSettings()); + + // Assert + await Assert.That(json).IsEqualTo("\"2024-01-01T21:34:05.0000000+00:00\""); + } + + [Test] + public async Task UtcConverter_GivenUtcInput_ReadsWithZeroOffset() + { + // Act + var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", UtcSettings()); + + // Assert + await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(value).IsEqualTo(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero)); + } + + [Test] + public async Task UtcConverter_GivenNonUtcInput_Throws() + { + // Act + var exception = Assert.Throws(() => + JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", UtcSettings()) + ); + + // Assert + await Assert.That(exception.Message).Contains("UTC"); + } + + [Test] + public async Task UtcConverter_GivenNoOffsetDesignator_Throws() + { + // Act — an offset of zero is not enough: the JSON Schema requires an explicit Z/zero-offset suffix. + var exception = Assert.Throws(() => + JsonConvert.DeserializeObject("\"2024-01-02T03:04:05\"", UtcSettings()) + ); + + // Assert + await Assert.That(exception.Message).Contains("UTC designator"); + } + + [Test] + public async Task UtcConverter_GivenDateParseHandlingNotDisabled_ReportsTheRequiredSetting() + { + // Arrange — without DateParseHandling.None, Newtonsoft parses the offset away before the converter. + JsonSerializerSettings settings = new() { Converters = { new UtcDateTimeOffsetConverter() } }; + + // Act + var exception = Assert.Throws(() => + JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", settings) + ); + + // Assert + await Assert.That(exception.Message).Contains("DateParseHandling"); + } + + [Test] + public async Task UtcConverter_GivenNullableNull_ReturnsNull() + { + // Act + var value = JsonConvert.DeserializeObject("null", UtcSettings()); + + // Assert + await Assert.That(value).IsNull(); + } + + [Test] + public async Task SettingsHelper_GivenUtcContract_ConfiguresDateParseHandlingAndConverter() + { + // Act + var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset(); + var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", settings); + + // Assert + await Assert.That(settings.DateParseHandling).IsEqualTo(DateParseHandling.None); + await Assert.That(settings.Converters.Any(converter => converter is UtcDateTimeOffsetConverter)).IsTrue(); + await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero); + } + + [Test] + public async Task SettingsHelper_GivenUtcContract_LeavesDateTimeDeserializationWorking() + { + // Act — DateParseHandling.None must not break a normal DateTime property. + var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset(); + var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", settings); + + // Assert + await Assert.That(value).IsEqualTo(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + } + + [Test] + public async Task SettingsHelper_GivenPreserveOffsetContract_ConfiguresDateParseHandlingAndConverter() + { + // Act + var settings = new JsonSerializerSettings().UseZodSharpPreserveOffsetDateTimeOffset(); + var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", settings); + + // Assert + await Assert.That(settings.DateParseHandling).IsEqualTo(DateParseHandling.None); + await Assert + .That(settings.Converters.Any(converter => converter is PreserveOffsetDateTimeOffsetConverter)) + .IsTrue(); + await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5)); + } + + [Test] + public async Task PreserveOffsetConverter_GivenNonZeroOffset_RoundTripsTheOffset() + { + // Act + var json = JsonConvert.SerializeObject(OffsetValue, PreserveSettings()); + var value = JsonConvert.DeserializeObject(json, PreserveSettings()); + + // Assert + await Assert.That(json).IsEqualTo("\"2024-01-02T03:04:05.0000000+05:30\""); + await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5)); + await Assert.That(value).IsEqualTo(OffsetValue); + } +} diff --git a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs index abdc9ec6..aaf0be2d 100644 --- a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs +++ b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs @@ -73,6 +73,7 @@ public static IEnumerable> Suites() => [ () => new VitestSuite("tests/ts/cross-platform.test.ts", "src/tests/cross-platform/output"), () => new VitestSuite("tests/ts/guid-uuid-parity.test.ts", "src/tests/cross-platform/guid-parity"), + () => new VitestSuite("tests/ts/temporal-parity.test.ts", "src/tests/cross-platform/temporal-parity"), ]; [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs new file mode 100644 index 00000000..f0de9a77 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs @@ -0,0 +1,360 @@ +using System.Collections.Immutable; +using ZodSharp.JsonSchema; +using ZodSharp.Rules; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaGeneratorTests +{ + const string TemporalHolderSource = """ + using System; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed class TemporalHolder + { + [NonSentinel] + public DateTimeOffset ObservedAt { get; set; } + + [NonSentinel] + public DateTime Timestamp { get; set; } + + [NonSentinel] + public DateOnly Day { get; set; } + + [NonSentinel] + public TimeOnly Time { get; set; } + + [NonSentinel] + public DateTimeOffset? Optional { get; set; } + } + } + """; + + [Test] + public async Task JsonSchema_GivenNonSentinelTemporalProperties_ExportsExactPatterns( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var definition = await BuildTemporalDefinitionAsync( + TemporalHolderSource, + "TemporalHolderSchema", + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert — the deterministic types are exact and carry no marker. + var timestamp = definition.Properties!["timestamp"]; + await Assert.That(timestamp.Format).IsEqualTo("date-time"); + await Assert.That(timestamp.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern); + await Assert.That(timestamp.UnsupportedRules).IsNull(); + + var day = definition.Properties!["day"]; + await Assert.That(day.Format).IsEqualTo("date"); + await Assert.That(day.Pattern).IsEqualTo(NonSentinelRule.DateOnlyJsonSchemaPattern); + await Assert.That(day.UnsupportedRules).IsNull(); + + var time = definition.Properties!["time"]; + await Assert.That(time.Format).IsNull(); + await Assert.That(time.Pattern).IsNull(); + await Assert.That(time.AllOf!).Count().IsEqualTo(2); + await Assert.That(time.AllOf!.Select(branch => branch.Pattern)).Contains(JsonSchemaRules.TimeOnlyPattern); + await Assert + .That(time.AllOf!.Select(branch => branch.Pattern)) + .Contains(NonSentinelRule.TimeOnlyJsonSchemaPattern); + await Assert.That(time.UnsupportedRules).IsNull(); + + // Assert — the default PreserveOffset mapping is offset-aware and exact. + var observedAt = definition.Properties!["observedAt"]; + await Assert.That(observedAt.Format).IsEqualTo("date-time"); + await Assert + .That(observedAt.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(observedAt.UnsupportedRules).IsNull(); + + // Assert — the nullable DateTimeOffset keeps the null branch. + await Assert.That(definition.Required!).DoesNotContain("optional"); + var optional = definition.Properties!["optional"].AnyOf!; + await Assert.That(optional.Any(candidate => candidate.Type == "null")).IsTrue(); + var optionalText = optional.Single(candidate => candidate.Type == "string"); + await Assert.That(optionalText.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenMsBuildUtc_ExportsTheExactDateTimeOffsetPatternWithoutAMarker( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the Directory.Build.props shape. + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpDateTimeOffsetSerialization", + "Utc" + ), + }; + + // Act + var definition = await BuildTemporalDefinitionAsync( + TemporalHolderSource, + "TemporalHolderSchema", + options, + cancellationToken + ); + + // Assert + var observedAt = definition.Properties!["observedAt"]; + await Assert + .That(observedAt.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(observedAt.UnsupportedRules).IsNull(); + + var optional = definition.Properties!["optional"].AnyOf!.Single(candidate => candidate.Type == "string"); + await Assert + .That(optional.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(optional.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenAssemblyUtc_OverridesTheMsBuildDefault(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the MSBuild default is PreserveOffset; the assembly opts this compilation into Utc. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + [assembly: ZodSharpSchemaDefaults( + DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] + + namespace Testing + { + [ZodSchema] + public sealed class Stamp + { + [NonSentinel] + public DateTimeOffset At { get; set; } + } + } + """; + + // Act + var definition = await BuildTemporalDefinitionAsync( + source, + "StampSchema", + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var at = definition.Properties!["at"]; + await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(at.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenAssemblyPreserveOffset_OverridesTheMsBuildUtcDefault( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + [assembly: ZodSharpSchemaDefaults( + DateTimeOffsetSerialization = DateTimeOffsetSerialization.PreserveOffset)] + + namespace Testing + { + [ZodSchema] + public sealed class Stamp + { + [NonSentinel] + public DateTimeOffset At { get; set; } + } + } + """; + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpDateTimeOffsetSerialization", + "Utc" + ), + }; + + // Act + var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken); + + // Assert + var at = definition.Properties!["at"]; + await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(at.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenTypeAndPropertyOverrides_UsesTheMostSpecificSetting( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the project is Utc; the type keeps Utc for ObservedAt but opts Optional back out. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + [DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)] + public sealed class Stamp + { + [NonSentinel] + public DateTimeOffset ObservedAt { get; set; } + + [NonSentinel] + [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] + public DateTimeOffset? Optional { get; set; } + } + } + """; + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpDateTimeOffsetSerialization", + "Utc" + ), + }; + + // Act + var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken); + + // Assert — the type-level Utc wins for ObservedAt even though the MSBuild default is also Utc. + var observedAt = definition.Properties!["observedAt"]; + await Assert + .That(observedAt.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(observedAt.UnsupportedRules).IsNull(); + + // Assert — the property-level PreserveOffset wins for Optional. + var optionalText = definition.Properties!["optional"].AnyOf!.Single(candidate => candidate.Type == "string"); + await Assert + .That(optionalText.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(optionalText.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenPositionalParameterBehavior_MergesTheOverrideOntoTheProperty( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the project is Utc; the positional parameter opts this property back out. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Stamp( + [NonSentinel] + [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] + DateTimeOffset At); + } + """; + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpDateTimeOffsetSerialization", + "Utc" + ), + }; + + // Act + var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken); + + // Assert + var at = definition.Properties!["at"]; + await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(at.UnsupportedRules).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenUtcScalarWithNonSentinel_ExportsTheExactPatternWithoutAMarker( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)] + [NonSentinel] + public partial record struct ObservedAt + : Purview.ValueObjects.IScalarValueObject + { + public DateTimeOffset Value { get; init; } + } + } + """; + + // Act + var definition = await BuildTemporalDefinitionAsync( + source, + "ObservedAtSchema", + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + await Assert.That(definition.Type).IsEqualTo("string"); + await Assert.That(definition.Format).IsEqualTo("date-time"); + await Assert + .That(definition.Pattern) + .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(definition.UnsupportedRules).IsNull(); + } + + async Task BuildTemporalDefinitionAsync( + string source, + string schemaTypeName, + ZodSourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + using var driverResult = await GenerateAsync(source, options, cancellationToken); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + return (JsonSchemaDefinition) + assembly.GetType($"Testing.{schemaTypeName}")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs new file mode 100644 index 00000000..5bb7bde8 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs @@ -0,0 +1,170 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaGeneratorTests +{ + const string TemporalParityOutputRelativePath = "src/tests/cross-platform/temporal-parity"; + + /// + /// The default PreserveOffset model. The exported DateTimeOffset pattern is offset-aware, so the schema + /// is exact and carries no x-zodsharp-unsupported marker. + /// + const string TemporalParitySource = """ + using System; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed class TemporalHolder + { + [NonSentinel] + public DateTimeOffset ObservedAt { get; set; } + + [NonSentinel] + public DateTime Timestamp { get; set; } + + [NonSentinel] + public DateOnly Day { get; set; } + + [NonSentinel] + public TimeOnly Time { get; set; } + + [NonSentinel] + public DateTimeOffset? Optional { get; set; } + } + } + """; + + [Test] + public async Task JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the default PreserveOffset cases. The offset-aware pattern is exact, so the + // offset-equivalent bounds are rejected without a UTC converter or an unsupported marker. + using var driverResult = await GenerateAsync( + TemporalParitySource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var outputDir = Path.Combine("..", "..", "..", "..", "..", "..", TemporalParityOutputRelativePath); + + // Act & Assert + await WriteParityArtifactAsync( + assembly, + "TemporalHolder", + [ + ( + "valid-utc", + TemporalPayload( + "2024-01-02T03:04:05Z", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + "2024-01-03T00:00:00+00:00" + ), + true + ), + ( + "valid-offset", + TemporalPayload( + "2024-01-02T03:04:05+05:30", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56.1234567", + null + ), + true + ), + ( + "min-utc", + TemporalPayload( + "0001-01-01T00:00:00+00:00", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + null + ), + false + ), + ( + "max-utc", + TemporalPayload( + "9999-12-31T23:59:59.9999999Z", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + null + ), + false + ), + ( + "min-offset-equivalent", + TemporalPayload( + "0001-01-01T05:30:00+05:30", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + null + ), + false + ), + ( + "max-offset-equivalent", + TemporalPayload( + "9999-12-31T18:29:59.9999999-05:30", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + null + ), + false + ), + ( + "nullable-min", + TemporalPayload( + "2024-01-02T03:04:05Z", + "2024-01-02T03:04:05Z", + "2024-01-02", + "12:34:56", + "0001-01-01T00:00:00Z" + ), + false + ), + ( + "dateonly-min", + TemporalPayload("2024-01-02T03:04:05Z", "2024-01-02T03:04:05Z", "0001-01-01", "12:34:56", null), + false + ), + ( + "datetime-min", + TemporalPayload("2024-01-02T03:04:05Z", "0001-01-01T00:00:00Z", "2024-01-02", "12:34:56", null), + false + ), + ( + "timeonly-min", + TemporalPayload("2024-01-02T03:04:05Z", "2024-01-02T03:04:05Z", "2024-01-02", "00:00:00", null), + false + ), + ], + outputDir, + cancellationToken + ); + } + + static string TemporalPayload(string observedAt, string timestamp, string day, string time, string? optional) => + $$""" + { + "observedAt": "{{observedAt}}", + "timestamp": "{{timestamp}}", + "day": "{{day}}", + "time": "{{time}}", + "optional": {{(optional is null ? "null" : $"\"{optional}\"")}} + } + """; +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index 622184d8..7b2ef5e8 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -175,4 +175,70 @@ public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(Cancellati await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenTemporalPattern_WritesPatternAndUnsupportedKeyword() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Format = "date-time", + Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern, + UnsupportedRules = [nameof(NonSentinelRule<>)], + }; + + // Act + var json = System.Text.Json.JsonSerializer.Serialize(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"pattern\""); + await Assert.That(json).Contains("\"x-zodsharp-unsupported\""); + await Assert.That(json).Contains(nameof(NonSentinelRule<>)); + } + + [Test] + public async Task Parse_GivenDefaultDateTimeOffsetPattern_RejectsOffsetEquivalentBounds( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern, + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("9999-12-31T18:29:59.9999999-05:30").IsSuccess).IsFalse(); + } + + [Test] + public async Task Parse_GivenUtcTemporalPattern_RejectsBoundsAndNonUtcOffsets(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "string", + Pattern = NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern, + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("2024-01-02T03:04:05Z").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("0001-01-01T00:00:00Z").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsFalse(); + } } diff --git a/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs new file mode 100644 index 00000000..ddeabea3 --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +namespace ZodSharp.Json.Serialization.SystemTextJson; + +public class DateTimeOffsetConverterTests +{ + static readonly DateTimeOffset OffsetValue = new(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5)); + + static JsonSerializerOptions UtcOptions() => new() { Converters = { new UtcDateTimeOffsetConverter() } }; + + static JsonSerializerOptions PreserveOptions() => + new() { Converters = { new PreserveOffsetDateTimeOffsetConverter() } }; + + [Test] + public async Task UtcConverter_GivenNonZeroOffset_WritesUtc() + { + // Act + var json = JsonSerializer.Serialize(OffsetValue, UtcOptions()); + + // Assert + await Assert.That(json).IsEqualTo("\"2024-01-01T21:34:05+00:00\""); + } + + [Test] + public async Task UtcConverter_GivenUtcInput_ReadsWithZeroOffset() + { + // Act + var value = JsonSerializer.Deserialize("\"2024-01-02T03:04:05Z\"", UtcOptions()); + + // Assert + await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(value).IsEqualTo(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero)); + } + + [Test] + public async Task UtcConverter_GivenNonUtcInput_Throws() + { + // Act + var exception = Assert.Throws(() => + JsonSerializer.Deserialize("\"2024-01-02T03:04:05+05:30\"", UtcOptions()) + ); + + // Assert + await Assert.That(exception.Message).Contains("UTC"); + } + + [Test] + public async Task UtcConverter_GivenNoOffsetDesignator_Throws() + { + // Act — an offset of zero is not enough: the JSON Schema requires an explicit Z/zero-offset suffix. + var exception = Assert.Throws(() => + JsonSerializer.Deserialize("\"2024-01-02T03:04:05\"", UtcOptions()) + ); + + // Assert + await Assert.That(exception.Message).Contains("UTC designator"); + } + + [Test] + public async Task UtcConverter_GivenNullableNull_ReturnsNull() + { + // Act + var value = JsonSerializer.Deserialize("null", UtcOptions()); + + // Assert + await Assert.That(value).IsNull(); + } + + [Test] + public async Task PreserveOffsetConverter_GivenNonZeroOffset_RoundTripsTheOffset() + { + // Act + var json = JsonSerializer.Serialize(OffsetValue, PreserveOptions()); + var value = JsonSerializer.Deserialize(json, PreserveOptions()); + + // Assert + await Assert.That(json).IsEqualTo("\"2024-01-02T03:04:05+05:30\""); + await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5)); + await Assert.That(value).IsEqualTo(OffsetValue); + } +} diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs new file mode 100644 index 00000000..00c65e24 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs @@ -0,0 +1,303 @@ +using System.Text.RegularExpressions; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// Coverage for the temporal JSON Schema mappings contributed by . +/// +/// +/// The DateTimeOffset case is deliberately split in two: preserving offsets cannot express the instant-based +/// bounds, so the rule reports itself as unsupported and only rejects the canonical zero-offset forms; a UTC +/// contract is exact because every accepted string denotes one instant. +/// +public class ToJsonSchemaTemporalSentinelTests +{ + [Test] + public async Task ApplyTo_GivenDateOnly_ExportsPatternThatRejectsBothBounds(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string", Format = "date" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateOnlyJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-02")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "9999-12-30")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02")).IsTrue(); + } + + [Test] + public async Task ApplyTo_GivenTimeOnly_ExportsPatternThatRejectsBothBounds(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string", Format = "time" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert — System.Text.Json writes "00:00:00" and "23:59:59.9999999". + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.TimeOnlyJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + await Assert.That(Matches(schema.Pattern!, "00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "00:00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "00:00:00.000")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "23:59:59.9999999")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "00:00:00.0000001")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "23:59:59.999999")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "12:34:56")).IsTrue(); + } + + [Test] + public async Task TimeOnlyPattern_GivenLocalWireForm_AcceptsValuesAndRejectsOffsets( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act & Assert — System.Text.Json never writes an offset for TimeOnly. + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "00:00:00")).IsTrue(); + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:34:56")).IsTrue(); + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "23:59:59.9999999")).IsTrue(); + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:34:56Z")).IsFalse(); + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "24:00:00")).IsFalse(); + await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:60:00")).IsFalse(); + } + + [Test] + public async Task ApplyTo_GivenDateTime_ExportsPatternThatRejectsDeterministicBounds( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert — the no-offset (Unspecified) and Z/zero-offset (Utc) wire forms are the deterministic ones. + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00Z")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00.000+00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00.0000001Z")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05Z")).IsTrue(); + } + + [Test] + public async Task DateTimePattern_GivenNonZeroOffset_LeavesTheReceiverShiftedValueUnconstrained( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — System.Text.Json converts an offset-bearing DateTime into the receiver's local time + // zone, so the string no longer denotes the sentinel ticks. + var pattern = NonSentinelRule.DateTimeJsonSchemaPattern; + + // Assert + await Assert.That(Matches(pattern, "0001-01-01T00:00:00-05:30")).IsTrue(); + await Assert.That(Matches(pattern, "9999-12-31T23:59:59.9999999+05:30")).IsTrue(); + await Assert.That(Matches(pattern, "0001-01-01T00:00:00Z")).IsFalse(); + } + + [Test] + public async Task ApplyTo_GivenDateTimeOffsetWithPreservedOffsets_RejectsEveryOffsetEquivalentBound( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert — the default mapping is exact and carries no unsupported marker. + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00+00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00+05:30")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T14:00:00+14:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999999-05:30")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T09:59:59.9999999-14:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00.0000001+05:30")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999998-05:30")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05+05:30")).IsTrue(); + } + + [Test] + public async Task DateTimeOffsetPattern_GivenEveryOffset_RejectsEachBoundAndAcceptsTheAdjacentTick( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern; + var rejectedBounds = 0; + var acceptedAdjacentValues = 0; + + // Act + for (var offsetHour = 0; offsetHour <= 14; offsetHour++) + { + for (var offsetMinute = 0; offsetMinute <= 59; offsetMinute++) + { + var minOffset = $"+{offsetHour:00}:{offsetMinute:00}"; + var minBound = $"0001-01-01T{offsetHour:00}:{offsetMinute:00}:00{minOffset}"; + var minAdjacent = $"0001-01-01T{offsetHour:00}:{offsetMinute:00}:00.0000001{minOffset}"; + if (!Matches(pattern, minBound)) + rejectedBounds++; + if (Matches(pattern, minAdjacent)) + acceptedAdjacentValues++; + + var localHour = 23 - offsetHour; + var localMinute = 59 - offsetMinute; + var maxOffset = offsetHour == 0 && offsetMinute == 0 ? "+00:00" : $"-{offsetHour:00}:{offsetMinute:00}"; + var maxBound = $"9999-12-31T{localHour:00}:{localMinute:00}:59.9999999{maxOffset}"; + var maxAdjacent = $"9999-12-31T{localHour:00}:{localMinute:00}:59.9999998{maxOffset}"; + if (!Matches(pattern, maxBound)) + rejectedBounds++; + if (Matches(pattern, maxAdjacent)) + acceptedAdjacentValues++; + } + } + + // Assert + await Assert.That(rejectedBounds).IsEqualTo(1800); + await Assert.That(acceptedAdjacentValues).IsEqualTo(1800); + } + + [Test] + public async Task ApplyTo_GivenDateTimeOffsetWithUtcContract_IsExactAndHasNoMarker( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule(), DateTimeOffsetSerialization.Utc); + + // Assert — every accepted string has a zero offset, so both offset-equivalent bounds are rejected. + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00Z")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00+00:00")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00+05:30")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999999-05:30")).IsFalse(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05Z")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05.123+00:00")).IsTrue(); + await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05+05:30")).IsFalse(); + } + + [Test] + public async Task ApplyTo_GivenTypeWithoutKnownSentinel_AddsNoConstraintAndNoMarker( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "integer" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert — IsValid always passes for an unknown type, so an unconstrained schema is complete. + await Assert.That(schema.Pattern).IsNull(); + await Assert.That(schema.UnsupportedRules).IsNull(); + } + + [Test] + public async Task DateTimeOffsetSchema_GivenSentinelAndOrdinaryValues_ValidatesByInstant( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var schema = Z.DateTimeOffset().NonSentinel(); + + // Act + var min = schema.Validate(DateTimeOffset.MinValue); + var offsetEquivalent = schema.Validate(new DateTimeOffset(1, 1, 1, 5, 30, 0, TimeSpan.FromHours(5.5))); + var ordinary = schema.Validate(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5))); + + // Assert + await Assert.That(min.IsSuccess).IsFalse(); + await Assert.That(offsetEquivalent.IsSuccess).IsFalse(); + await Assert.That(ordinary.IsSuccess).IsTrue(); + } + + [Test] + public async Task ToJsonSchema_GivenManualDateTimeOffsetWithUtcOption_UsesTheExactUtcPattern( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var schema = Z.ToJsonSchema( + Z.DateTimeOffset().NonSentinel(), + new ToJsonSchemaOptions { DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc } + ); + + // Assert + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + } + + [Test] + public async Task ToJsonSchema_GivenManualDateTimeOffsetWithDefaultOption_UsesTheExactOffsetAwarePattern( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var schema = Z.ToJsonSchema(Z.DateTimeOffset().NonSentinel()); + + // Assert + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + } + + [Test] + public async Task ToJsonSchema_GivenFluentDateWithNonSentinel_ExportsDateTimeFormatAndPattern( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var schema = Z.ToJsonSchema(Z.Date().NonSentinel()); + + // Assert + await Assert.That(schema.Type).IsEqualTo("string"); + await Assert.That(schema.Format).IsEqualTo("date-time"); + await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern); + await Assert.That(schema.UnsupportedRules).IsNull(); + } + + static bool Matches(string pattern, string value) => Regex.IsMatch(value, pattern); +} diff --git a/tests/ts/temporal-parity.test.ts b/tests/ts/temporal-parity.test.ts new file mode 100644 index 00000000..3d73f7cd --- /dev/null +++ b/tests/ts/temporal-parity.test.ts @@ -0,0 +1,65 @@ +/** + * Temporal cross-platform parity: C# (Purview.ZodSharp) -> Zod. + * + * The C# producer compiles a `[ZodSchema]` model with the default PreserveOffset contract and writes each + * exported schema with the outcome of the C# validator pipeline. This suite imports the schema with Zod's + * `z.fromJSONSchema()` and checks that both sides agree, including the offset-equivalent bounds + * (`0001-01-01T05:30:00+05:30` and `9999-12-31T18:29:59.9999999-05:30`) and the local `TimeOnly` wire form. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { z } from "zod"; + +const parityDir = resolve("src", "tests", "cross-platform", "temporal-parity"); + +interface ParityCase { + name: string; + value: string; + valid: boolean; +} + +interface ParityArtifact { + name: string; + schema: unknown; + cases: ParityCase[]; +} + +let artifacts: ParityArtifact[] = []; +try { + artifacts = readdirSync(parityDir) + .filter((file) => file.endsWith(".json")) + .map((file) => JSON.parse(readFileSync(resolve(parityDir, file), "utf-8")) as ParityArtifact); +} catch { + // The directory does not exist until the C# tests run. Handled below. +} + +describe("Temporal export matches C# validation under z.fromJSONSchema", () => { + if (artifacts.length === 0) { + // Do NOT pass silently: an empty directory means this suite verified nothing. + it("has C# parity artifacts to validate", () => { + expect.fail( + `No parity artifacts found in ${parityDir}. ` + + "Run the C# tests first (`just test`) so they write their output, then re-run `bun run test`.", + ); + }); + } + + for (const artifact of artifacts) { + // The UTC contract is exact, so the schema must not carry the incomplete-rule marker. + it(`${artifact.name} has no x-zodsharp-unsupported marker`, () => { + expect(JSON.stringify(artifact.schema)).not.toContain("x-zodsharp-unsupported"); + }); + + const zodSchema = z.fromJSONSchema(artifact.schema as never); + + for (const testCase of artifact.cases) { + it(`${artifact.name} / ${testCase.name} — matches C#`, () => { + const result = zodSchema.safeParse(JSON.parse(testCase.value)); + expect(result.success, `${artifact.name}/${testCase.name} (${testCase.value})`).toBe( + testCase.valid, + ); + }); + } + } +}); From d1457f098faa1ac9b72358fb680492f5c2b24dab Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 21 Oct 2026 14:03:39 +0100 Subject: [PATCH 2/7] chore: bumped build version --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d51c9f03..88817112 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.5.4", + "version": "0.6.1", "commands": [ "purview-build" ], From 5e5bb2306646fa98fe924b339b0aee7d3d79ee0a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 21 Oct 2026 16:34:40 +0100 Subject: [PATCH 3/7] fix: report a scope-validation failure instead of rethrowing it from the generator The generator caught CodeWriterScopeValidationException and rethrew it, to fail loudly on a generator bug. It reached the consumer as CS8785 instead, which discards every file the generator produced for that compilation and buries the real cause under the "type or namespace not found" errors that follow. It now takes the same path as any other failure: the exception message lists every open scope, so the diagnostic is no less loud, and the rest of the generated output still arrives. Flagged by PSGFR44 in Purview.SourceGeneratorFramework 1.2.0. Takes Purview.BuildSdk 1.1.0, Purview.Build 0.7.0, Purview.SourceGeneratorFramework 1.2.0 and Purview.Skills.Testing 1.1.0. --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 4 ++-- global.json | 2 +- package.json | 2 +- src/src/SourceGenerators/ZodSchemaGenerator.cs | 10 ++++++---- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 88817112..9edd0c00 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.6.1", + "version": "0.7.0", "commands": [ "purview-build" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index ea8b4032..6ba50a2a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,7 @@ 5.9.0 5.9.0 1.75.0 - 1.1.1 + 1.2.0 10.0.13 - true - true - 5.9.0 5.9.0 1.75.0 diff --git a/package.json b/package.json index 675e4b0a..b5ed0cc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.3.1", + "version": "2.3.0", "private": true, "license": "MIT", "author": { @@ -28,4 +28,4 @@ "bun": ">=1.4.2" }, "packageManager": "bun@1.4.2" -} +} \ No newline at end of file diff --git a/src/src/Benchmarks/Directory.Build.props b/src/src/Benchmarks/Directory.Build.props index 8bcb67aa..e30aea60 100644 --- a/src/src/Benchmarks/Directory.Build.props +++ b/src/src/Benchmarks/Directory.Build.props @@ -16,4 +16,9 @@ BenchmarkDotNet's generated build output. The Benchmarks.csproj is fully self-contained and does not need the SDK. --> + + + true + true + diff --git a/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs index 13e11b67..976a3e23 100644 --- a/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs +++ b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs @@ -23,21 +23,19 @@ namespace ZodSharp; /// than to a property whose type is that scalar. /// /// +/// +/// Initializes a new instance of the class. +/// +/// The DateTimeOffset wire form assumed for JSON Schema generation. [AttributeUsage( AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false )] -public sealed class DateTimeOffsetBehaviorAttribute : Attribute +public sealed class DateTimeOffsetBehaviorAttribute(DateTimeOffsetSerialization serialization) : Attribute { - /// - /// Initializes a new instance of the class. - /// - /// The DateTimeOffset wire form assumed for JSON Schema generation. - public DateTimeOffsetBehaviorAttribute(DateTimeOffsetSerialization serialization) => Serialization = serialization; - /// /// Gets the DateTimeOffset wire form assumed for JSON Schema generation. /// - public DateTimeOffsetSerialization Serialization { get; } + public DateTimeOffsetSerialization Serialization { get; } = serialization; } From 74d8a227f42c14e2f284c00d57556edb567d3d0a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 08:23:35 +0100 Subject: [PATCH 5/7] chore: updatetd editorconfig --- .editorconfig | 26 +++++++++++++++++++++----- global.json | 4 +--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.editorconfig b/.editorconfig index 120d5094..b5117897 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,23 @@ +# Purview.BuildSdk 1.1.0 - managed file, do not edit +# +# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version +# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is +# yours, and is never touched. +# +# To add your own rules, create a second .editorconfig in a subdirectory - `src/.editorconfig` is the +# usual choice - without `root = true`. EditorConfig applies the nearest file last, so your settings win +# over these, and nothing you write is lost when this file is updated. +# +# To stop the SDK updating this file, set one of: +# false keep this file as it is +# Never opt out of all bootstrapping +# false +# +# Deleting the first line also works: without the marker the SDK treats the file as yours. You then own +# every rule in it, including the ones a future SDK version would have fixed. +# +# See https://github.com/purview-dev/build-sdk#editorconfig for the full description. + root = true # Base Configuration - General Settings, File Formatting, and Naming Conventions @@ -360,12 +380,8 @@ dotnet_style_require_accessibility_modifiers = omit_if_default:warning # Code-block preferences csharp_prefer_braces = when_multiline:suggestion csharp_prefer_simple_using_statement = false:silent -csharp_prefer_system_threading_lock = true dotnet_style_prefer_collection_expression = true:suggestion -# Enable foreach explicit cast preference -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning - # Prefer System.Threading.Lock csharp_prefer_system_threading_lock = true:warning @@ -497,7 +513,7 @@ dotnet_style_prefer_auto_properties = true:warning dotnet_style_prefer_compound_assignment = true:warning dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_return = true:suggestion -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning dotnet_style_prefer_inferred_tuple_names = true:warning dotnet_style_prefer_simplified_boolean_expressions = false:silent diff --git a/global.json b/global.json index d704407b..780fad77 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,5 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", - "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { @@ -10,4 +8,4 @@ "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file From 72e54f078e7c9abd081971462f3103dda4ddeeec Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 12:12:43 +0100 Subject: [PATCH 6/7] build: updated workflow for packaging --- .github/workflows/release.yml | 7 +++++++ global.json | 3 --- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f7d2163..59b20ce6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: push: branches: [main] +permissions: + contents: write + # The shared release workflow pushes the same packages to nuget.org and, when + # PURVIEW_PUBLISH_GITHUB_PACKAGES=true, to GitHub Packages. A reusable workflow cannot grant + # more than its caller, so the permission is declared here. + packages: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/global.json b/global.json index 780fad77..025f0be0 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,4 @@ { - "sdk": { - "allowPrerelease": true - }, "msbuild-sdks": { "Purview.BuildSdk": "1.1.0" }, From 46e0831a50120c917782e13fd13b85fa28fd54b0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 12:20:25 +0100 Subject: [PATCH 7/7] fix: restore the SDK pin removed by the PR global.json lost its sdk block, so actions/setup-dotnet with global-json-file installed nothing and the standalone jobs used the runner's 10.0.402 SDK against net11 targets. Restore the pinned .NET 11 rc while keeping the BuildSdk 1.1.0 bump. --- global.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/global.json b/global.json index 025f0be0..d704407b 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,13 @@ { + "sdk": { + "version": "11.0.100-rc.2.26504.105", + "rollForward": "latestMajor", + "allowPrerelease": true + }, "msbuild-sdks": { "Purview.BuildSdk": "1.1.0" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +}