diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d51c9f03..9edd0c00 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.7.0", "commands": [ "purview-build" ], diff --git a/.config/purview-build.json b/.config/purview-build.json index 4d10b720..332e7fa1 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -3,13 +3,15 @@ "Solution": "src/ZodSharp.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*[Category=Unit]" + "TestFilter": "/*/*/*/*" }, "PackValidation": { // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier // versions forced this check on and could not be configured off, so these packages have // always been validated against it; declaring it keeps that behaviour. - "RequiredCompilerFlags": ["optimization=release"], + "RequiredCompilerFlags": [ + "optimization=release" + ], "RequireSymbolPackage": true, "RequireSymbolFiles": true, "RequiredContent": { @@ -50,4 +52,4 @@ "Release": { "Mode": "None" } -} +} \ No newline at end of file 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/.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/.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/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/Directory.Packages.props b/Directory.Packages.props index ea8b4032..d7c56a01 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,17 +1,9 @@ - - true - true - 5.9.0 5.9.0 1.75.0 - 1.1.1 + 1.2.0 10.0.13 true - 2.0.2 + 2.2.0 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/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/SourceGenerators/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs index 56fbf37e..eeac98b4 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs @@ -47,10 +47,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) SchemaGenerationOutputContext outputContext = new(model.Context, schema.Value); BuildSchema(outputContext, spc, schema.Value.IsPrimary); } - catch (CodeWriterScopeValidationException) - { - throw; - } + // CodeWriterScopeValidationException was caught here and rethrown, to fail loudly on a + // generator bug. It reached the consumer as CS8785 instead, which discards every file this + // generator produced for the compilation and buries the real cause under the 'type or + // namespace not found' errors that follow (PSGFR44). 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. catch (Exception ex) { var diagnostic = ReportableDiagnostic.Create( 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..976a3e23 --- /dev/null +++ b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs @@ -0,0 +1,41 @@ +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. +/// +/// +/// +/// 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(DateTimeOffsetSerialization serialization) : Attribute +{ + /// + /// Gets the DateTimeOffset wire form assumed for JSON Schema generation. + /// + public DateTimeOffsetSerialization Serialization { get; } = serialization; +} 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, + ); + }); + } + } +});