diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index fb05763..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,11 +10,18 @@ "rollForward": false }, "dotnet-inspect": { - "version": "0.25.0", + "version": "0.26.0", "commands": [ "dotnet-inspect" ], "rollForward": false + }, + "purview.build": { + "version": "0.5.4", + "commands": [ + "purview-build" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.config/purview-build.json b/.config/purview-build.json index f8d356c..4d10b72 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -6,6 +6,10 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "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"], "RequireSymbolPackage": true, "RequireSymbolFiles": true, "RequiredContent": { @@ -46,4 +50,4 @@ "Release": { "Mode": "None" } -} \ No newline at end of file +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5b0c0d9..c37a565 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: # install`. Before global.json pinned a version the same gap failed later instead, with # NETSDK1045 on the net11.0 target, because the 10.0.x SDK cannot build it. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" run-pack: true validate-pack: true secrets: inherit @@ -48,11 +48,30 @@ jobs: - run: bun install --frozen-lockfile - - name: Run the C# cross-platform tests (writes the fixtures vitest reads) + # There are TWO 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/ + # ZodSharp.Json/*CrossPlatformTests (SystemTextJson and + # NewtonsoftJson unit tests) + # tests/ts/guid-uuid-parity.test.ts <- src/tests/cross-platform/guid-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 + # 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) run: >- dotnet test --solution src/ZodSharp.slnx --configuration Debug --treenode-filter '/*/ZodSharp.Json/*CrossPlatformTests/*' + - name: Run the C# Guid/UUID parity test (writes cross-platform/guid-parity) + run: >- + dotnet test --solution src/ZodSharp.slnx --configuration Debug + --treenode-filter + '/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_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 655aa10..7f7d216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: # The shared workflow defaults to the 10.0.x SDK, but this repository targets net11.0 and pins # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" release-mode: NuGet release-branch: main secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 0b49120..eb31747 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ src/ cross-platform/ Output directory shared between the C# and TS cross-platform tests src/ts/ TypeScript (Zod) schema + fixture generation; consumed by C# and vitest tests tests/ts/ Vitest cross-platform tests -.agents/ Skills/prompts bundled from Purview.BuildSdk and Purview.SourceGeneratorFramework +skills/ Agent skills this repository authors and publishes +.agents/skills/ Skills installed from other repositories by the skills CLI (gitignored) ``` ## Build system @@ -46,7 +47,7 @@ The build is driven by `Purview.BuildSdk`, imported via `src/Directory.Build.pro - `global.json` pins the `Purview.BuildSdk` MSBuild SDK version. - Pack layout for all packages is validated by `purview-build.json` (`PackValidation.RequiredContent`). -Consult `.agents/skills/sdk-configuration-reference/SKILL.md`, `.agents/skills/sdk-project-behavior-and-detection/SKILL.md`, and `.agents/skills/project-placement-defaults/SKILL.md` before changing build/SDK configuration or adding/moving projects. +Before changing build/SDK configuration or adding/moving projects, install and consult the SDK's own skills: `npx skills add purview-dev/build-sdk` (`purview-buildsdk-configuration`, `purview-buildsdk-project-detection`, `purview-buildsdk-project-layout`). ## Commands @@ -71,6 +72,8 @@ bun run test # vitest run (cross-platform TS tests) bun run generate-fixtures # regenerates src/ts/fixtures/*.json from Zod ``` +`just test` also runs the vitest suites through CliWrap (see [Cross-platform fixtures](#cross-platform-fixtures)), so `bun run test` is for iterating on the TypeScript side rather than the only way to exercise it. It needs `bun install` to have been run. + Formatting is enforced with CSharpier; `.editorconfig` at the repo root defines style (tabs for code, 2-space for XML/JSON/YAML/markdown). ## Testing @@ -81,7 +84,7 @@ Formatting is enforced with CSharpier; `.editorconfig` at the repo root defines - `// Arrange`, `// Act`, `// Assert` comments. - Meaningful, descriptive method names (`Action_GivenCondition_ExpectedResult`). - Treat work as incomplete until the relevant tests pass. -- For source generators, diagnostic analyzers, code fixes and refactorings, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, `TUnitRefactoringTestBase`) and assert with `CodeQuery`. Load `.agents/skills/source-generator-testing/SKILL.md` and `.agents/skills/tunit-test-authoring/SKILL.md` before writing or changing these tests. +- For source generators, diagnostic analyzers, code fixes and refactorings, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, `TUnitRefactoringTestBase`) and assert with `CodeQuery`. Install the framework's skills with `npx skills add purview-dev/sourcegenerator-framework` and load `purview-sourcegenerator-testing` and `purview-sourcegenerator-tunit-tests` before writing or changing these tests. ## Source generators @@ -89,7 +92,7 @@ The `[ZodSchema]` attribute (generated into the `ZodSharp` namespace) marks a cl The generator and analyzer are built with `Purview.SourceGeneratorFramework`: -- Use `CodeWriter` emission and incremental pipelines; load `.agents/skills/source-generator-codewriter-modernization/SKILL.md` before implementing, reviewing, or refactoring generator/analyzer code. +- Use `CodeWriter` emission and incremental pipelines; load `purview-sourcegenerator-codewriter` (`npx skills add purview-dev/sourcegenerator-framework`) before implementing, reviewing, or refactoring generator/analyzer code. - Keep pipeline values immutable and value-equatable; never retain `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, or `Location` in pipeline models. - Use `ForAttributeWithMetadataName` for attribute-driven discovery. - Test incrementally, not just generated text (see the skills above). @@ -116,7 +119,28 @@ Keep `purview-build.json`'s `PackValidation` requirements in sync with any packa ## Cross-platform fixtures -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; the C# cross-platform tests write output to `src/tests/cross-platform/output/`, which the vitest tests (`tests/ts/cross-platform.test.ts`) read. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync. +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: + +| 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` | + +`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. + +`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: + +- **One test per suite**, so a failure names the `.test.ts` file, and the child process's stdout/stderr becomes the assertion message. +- **`VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered`** fails when a `tests/ts/*.test.ts` file has no entry in `Suites()`, so a new suite cannot be driven by the CI job alone. +- **These tests never skip.** A skipped test proves nothing, and the parity guarantee is not optional. The runner is resolved per run: **bun** when it is on PATH, otherwise **node**. If neither is available, or the install or the run fails, the test fails and names the step. + +The node fallback invokes vitest's entry point (`node_modules/vitest/vitest.mjs`, from vitest's own `package.json` `bin` field) directly, rather than `npx` or `node_modules/.bin`. Both are unreliable: the `.bin` shims are written by whichever package manager installed the tree — a bun install leaves `vitest.exe`/`vitest.bunx` and no node shim — and on Windows `npx` resolves to a `.cmd`/`.ps1` shim, which cannot be started directly as a child process. For the same reason the PATH probe only accepts a real executable image (`.exe`/`.com`), not a shim. + +`node_modules/` is gitignored, so when vitest is missing the tests install it first — with `bun install --frozen-lockfile`, or `npm install` when only node is present. There is no npm lockfile (`bun.lock` is the only one), so npm resolves from `package.json`; that is a second reason bun is preferred for both the install and the run. + +Because `src/tests/cross-platform/` is gitignored and the producers live in other assemblies that may still be running in parallel, these tests wait for their fixture directory to fill before skipping, rather than nesting a second `dotnet test` inside the run. ## Commit conventions @@ -129,3 +153,44 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - `just lint-check` (CSharpier) reports no formatting changes. - Generated code is deterministic and reviewable; no scope leaks in `CodeWriter` output. - Packed packages match `purview-build.json` `PackValidation` (including per-package `README.md` and `purview-logo-light.png`). + +## Agent skills + +Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet +package** - `Purview.BuildSdk` 1.0.6 removed skill packaging outright. The skills CLI clones the *git* +repository and only discovers a root `skills/` folder, so that is the one delivery route. + +`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is +gitignored. This repository's own skills are never installed into it - that would leave two copies to +drift apart. Read them at their authored path, `skills//SKILL.md`. + +```bash +npx skills add purview-dev/zodsharp +``` + +| Skill | Answers | +| --- | --- | +| `skills/purview-zodsharp-core/` | the runtime API: packages, the `Z` factory, `Validate`/`Parse`, `ValidationResult`, composition, coercion | +| `skills/purview-zodsharp-source-generator/` | `[ZodSchema]`: what it emits, its options, DataAnnotations, `OnZodValidate`, enum validation, `IValidateOptions` | +| `skills/purview-zodsharp-custom-rules/` | writing a rule, `ErrorCode`/`MessageFormat`, `IZodRule`, `[ZodRule]` attributes, scalar value objects | +| `skills/purview-zodsharp-json/` | System.Text.Json and Newtonsoft integrations, JSON Schema export and import, TypeScript Zod interop | +| `skills/purview-zodsharp-aspnetcore/` | ProblemDetails, minimal-API validation, `IZodSchemaFactory` registration, options validation, `ErrorType` | +| `skills/purview-zodsharp-diagnostics/` | what a `ZODSGEN` or `ZODSASP` diagnostic means and how to fix it | + +Every skill carries the `purview-zodsharp` prefix. Skills install into one flat directory per agent, +shared with every skill a developer has installed from anywhere, so an unprefixed name collides and +the loser is whichever installed second. + +To add one: create `skills//SKILL.md` with spec-legal frontmatter. There is no per-project +registration. + +`src/tests/Repository.UnitTests/SkillTests.cs` validates every skill on every test run, through +`Purview.Skills.Testing`'s rule set (the Agent Skills specification: frontmatter fields, name and +description constraints, bundled links, body size). The `[InheritsTests]` attribute on that class is +load-bearing - TUnit does not walk base types during discovery, so without it the run is green and +nothing is checked. That project is single-targeted at `net10.0`, because it asserts something about +the repository rather than about the runtime's reach. + +A relative link from one `SKILL.md` into another skill's folder is a rule violation: only a skill's +own folder is installed with it, so such a link works here and nowhere else. Name the other skill in +prose instead, and keep deep detail in the skill's own `references/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c646a4..0a5826c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,33 +6,59 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see [Release Flow](docs/wiki/Release-Flow.md). -Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not -been published to NuGet. - -## Unreleased - -> **This release contains breaking changes.** See [Breaking changes](#breaking-changes) below before -> upgrading from `2.0.0`. The analyzer catalogue records them under `2.0.2`. -> -> Note the changes below are consumer-visible: a rule's public `Code` constant was renamed to `ErrorCode`, -> and rule constructor signatures changed, so attribute usages and code referencing those constants may not -> compile against this version. Read the migration notes before upgrading. - -### Breaking changes - -- **Rule error-identity constants renamed from `Code` to `ErrorCode`.** Every validation rule now exposes its - error identity as public `const string ErrorCode` and `const string MessageFormat`. Code that referenced a - rule's `Code` constant must use `ErrorCode`. The new `ZODSGEN042` analyzer enforces the convention on - source-declared rules. -- **Rule constructors are standardised.** Every rule now has a consistent constructor, extension method and - generated attribute. Generated rule attributes carry the rule's *value* parameters as constructor arguments: - a parameter declared without a default becomes a required positional argument, a parameter with a default - keeps it, and `message`/`code`/`origin` remain properties. Attribute usages for required values become - positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. Usages that previously set a required value by property - name no longer compile. +Released versions correspond to `v` GitHub releases. + +## 2.1.0 + +Consolidates the `2.0.1`, `2.0.1.1` and `2.0.2` incremental releases: upgrading from `2.0.0` goes straight to +`2.1.0`, and the notes below cover that whole arc. The analyzer catalogue records each diagnostic under the +release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037`–`ZODSGEN044` under +`## Release 2.0.2` and `ZODSGEN045` under `## Release 2.1.0`. + +> **Upgrading from `2.0.0`?** Start with [Upgrade notes](#upgrade-notes). A few rule and attribute APIs were +> renamed and standardised across these releases, so some usages need a mechanical edit before they compile. +> Each note names the old form, the new form, and the edit to make. + +### Upgrade notes + +- **A rule's error-identity constants are now `ErrorCode` and `MessageFormat`.** Every validation rule exposes + its error identity as public `const string ErrorCode` and `const string MessageFormat`. + - *Previously:* a rule's identity constant was named `Code`. + - *Now:* it is named `ErrorCode`. + - *To upgrade:* rename references from `SomeRule.Code` to `SomeRule.ErrorCode`. `ZODSGEN042` reports + source-declared rules that do not follow the convention, so the compiler points at each one. +- **Rule constructors, extension methods and generated attributes now follow one shape.** A generated rule + attribute carries the rule's *value* parameters as constructor arguments: a parameter declared without a + default becomes a required positional argument, a parameter with a default keeps it, and + `message`/`code`/`origin` remain properties. + - *Previously:* a required value could be supplied by property name — the property derived from the rule's + parameter, so `MinValueRule(T minValue, …)` was settable as `[MinValue(MinValue = 3)]`. + - *Now:* required values are positional — `[MinValue(3)]`, `[Regex("^[a-z]+$")]`. + - *To upgrade:* move a required value from a property assignment to a positional argument. Defaulted values + may still be set by property name. +- **`NonSentinelRule.MessageFormat` is reworded, and `{0}` now describes the sentinel.** + - *Previously:* `{0}` interpolated the raw value. + - *Now:* the format is `Value has a sentinel value, but got {0}`, and `{0}` is a description — `an empty + GUID`, `DateTime.MinValue`, `an empty string`, and so on. + - *To upgrade:* update any code or test that asserts against the old message text. Asserting against + `NonSentinelRule.ErrorCode` instead keeps the assertion stable across future wording changes. +- **`ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`.** See + [`RegisterFromAssembly` is now genuinely trim- and AOT-safe](#changed--trimming-and-native-aot) for why. + - *To upgrade:* nothing, in the normal case — the attribute is generator-emitted and regenerates on rebuild. + Only a hand-written usage needs the second argument. ### Added +- **`UUIDRule` now validates a `Guid` as well as a `string`.** A `Guid` is a UUID, so its bytes are checked + directly; `[UUID]` / `[UUID(UuidVersion.Vx)]` can therefore constrain a `Guid` member or a `[Scalar]` + value object, and the constraint is exported as `format: "uuid"` plus a version-pinning `pattern`. + +- **A type-level rule on a `[Scalar]` type now names the scalar in its default message.** Because a scalar's + error reports an empty path, the generator replaces the leading subject word `Value` in the resolved rule's + `MessageFormat` with the scalar's name, so `[NonSentinel]` on `[Scalar] TenantId` reports + `TenantId has a sentinel value, but got an empty GUID`. A rule whose format does not contain `Value`, and any + explicit `message`/`ErrorMessage`, are left unchanged. + - **Native C# 15 union support.** `Z.NativeUnion` and `ZodTypedNativeUnion` return a native union on `net11.0`, giving allocation-free, exhaustively pattern-matchable options. `ZODSGEN041` suggests it where a typed union's option types are all reference types. @@ -61,7 +87,27 @@ been published to NuGet. (which always rejects `null`) cannot express the check. - **`ZODSGEN044`.** A `[ZodSchema]` type using an automatic `[Scalar]` form that cannot produce a usable schema (a suppressed `Validate` method, or an unrepresentable underlying type) is reported instead of - leaving a dangling `{Type}Schema` reference. Recorded in `AnalyzerReleases.Unshipped.md`. + leaving a dangling `{Type}Schema` reference. Recorded under `## Release 2.0.2`. +- **A `[ZodSchema]` schema now exports a complete JSON Schema.** The generated `{Type}Schema` class emits a + static `BuildJsonSchema()` method and the generated `{Type}SchemaValidator` implements the new + `ZodSharp.JsonSchema.IJsonSchemaProvider`, so `Z.ToJsonSchema(new {Type}SchemaValidator())` returns a real + definition instead of an empty one. The definition is built from the same metadata as `Validate`, so nested + objects, native enums, nullable/required fields, scalar value objects (`[Scalar]` export as their underlying + value), `[JsonPropertyName]`/camelCase property names, DataAnnotations and `[ZodRule]`-mapped rules are all + reflected. Previously only hand-built runtime schemas could be exported. +- **Rules describe their own JSON Schema constraint.** The new `ZodSharp.JsonSchema.IJsonSchemaRule` is + implemented by the built-in rules (length, bounds, `multipleOf`, integer/safe-integer, string formats, + `pattern`, sentinel, allowed/denied values, enum), and `ToJsonSchemaConverter` and the generated + `BuildJsonSchema()` apply them identically. A rule that cannot be represented — or a consumer rule that has + not opted in — is listed under the `x-zodsharp-unsupported` extension on the exported schema instead of + being dropped silently. +- `ZodNativeEnum` and `ZodEnum` now export as JSON Schema `enum` (via the new `IJsonSchemaEnumInfo` + seam), and `JsonSchemaDefinition` gained `not`, `contentEncoding` and `x-zodsharp-unsupported`. +- **JSON Schema generation can be opted out, per type or per assembly.** `[ZodSchema(GenerateJsonSchema = + false)]` omits the export members for one type, and the `ZodSharpGenerateJsonSchemas=false` MSBuild property + does so for the whole assembly — keeping validation-only types out of the cross-platform contract surface. + An exporting type that references a type whose export is disabled falls back to an empty schema and is + reported as the new **ZODSGEN045** warning instead of failing to compile or exporting silently. ### Changed — trimming and Native AOT @@ -78,8 +124,9 @@ been published to NuGet. `[DynamicallyAccessedMembers(PublicParameterlessConstructor)]` so its constructor survives. No string resolution remains, and the `[RequiresUnreferencedCode]` annotation has been removed entirely. Discovery is also now proportional to the number of generated schemas rather than the size of the assembly. - - **Breaking:** `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The - attribute is generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. + - `ZodSchemaGeneratedAttribute` now takes `(Type targetType, Type validatorType)`. The attribute is + generator-emitted, so it regenerates on rebuild; only hand-written usages need updating. Listed under + [Upgrade notes](#upgrade-notes). - **`Purview.ZodSharp.SystemTextJson` is AOT-clean and marked.** - The validating `JsonConverter` resolves a `JsonTypeInfo` from the serializer options instead of calling the reflection-based `JsonSerializer` overloads, so it defers to whatever resolver the host @@ -106,6 +153,11 @@ been published to NuGet. ### Changed +- **NuGet package validation.** Every packable project now enables `EnablePackageValidation` against the + previous stable release (`2.0.2`), so an unintended change to the public API fails `dotnet pack` rather than + shipping. All four packages validate clean with no suppressions — the rule and attribute changes under + [Upgrade notes](#upgrade-notes) landed in the `2.0.x` releases, so they are already part of the baseline, and + `2.1.0` is additive over it. This is the guard that was missing when those changes went out. - Clarified in `SourceGenerators.csproj` that `EnforceExtendedAnalyzerRules` and `TreatWarningsAsErrors` are supplied and enforced by `Purview.BuildSdk` for every `IsRoslynComponent` project, rather than being optional project settings. Both were already `true`; the commented-out block implied otherwise. @@ -127,6 +179,41 @@ been published to NuGet. ### Fixed +- **Guid and UUID export semantics now match the runtime validator.** A C# `Guid` was exported as + `format: "uuid"`, so a consumer applied its RFC UUID validator (Zod's `z.uuid()`, which `z.fromJSONSchema` + also uses for `format: "guid"`) and rejected valid values that C# accepts — including every + `FastGuid.NewSqlServerGuid()`/`NewPostgreSqlGuid()` time-bound identifier, which is deliberately not an RFC + UUID. A `Guid` (and a `[Scalar]` value object) now exports as a canonical hexadecimal `pattern`; a + versioned `UUIDRule`/`[UUID(UuidVersion.Vx)]` keeps its version as an additional `pattern`; and two rules that + both constrain `pattern` (for example a Guid plus `[NonSentinel]`) are composed under `allOf` instead of one + silently overwriting the other. The JSON Schema importer now intersects every `allOf` branch (previously it + took only the first) so an exported schema round-trips. +- **Exported enum schemas now match the System.Text.Json wire form.** The generated JSON Schema always + enumerated an enum's numeric values, so an enum serialized as a string (a per-type or per-property + `[JsonConverter(typeof(JsonStringEnumConverter))]`, or a global converter) exported a schema that rejected + the actual JSON. The generator now detects the string-enum converter, honours `[JsonStringEnumMemberName]`, + and supports the assembly-level `ZodSharpEnumSerialization=String` / `ZodSharpEnumNamingPolicy=CamelCase` + switches for a global converter and naming policy. +- **A `[Flags]` enum exported an `enum` list, rejecting valid combinations.** The generated validator never + validates a flags enum against its defined members (a combination is a valid value without being a defined + member), but the generated JSON Schema enumerated them, so a value such as `Read | Write` failed the + exported schema. A flags enum now exports as `type: integer`. +- **Exported requiredness and null acceptance now follow the nullable reference-type annotation.** The + generator treated every reference type as nullable, so a non-nullable `string` with `[NonSentinel]` was + wrapped in `anyOf: [ …, { "type": "null" } ]`, omitted from `required`, and its rule was skipped for null + (null passed validation). A non-nullable reference type is now required, its rules run unconditionally and + reject null, and the generated validator reports a null value rather than skipping it — matching the + exported schema. A `string?` keeps its null branch, and a rule that rejects an absent value still makes the + property non-null; a rule that accepts null (such as the built-in `NullOrNonWhiteSpaceRule`) keeps the + property nullable. +- **A `[ZodSchema]` type referenced as a nested property could lose its generated DI adapter.** A type is + reached twice by the generator — once from its own `[ZodSchema]` attribute (the primary schema) and once as + a nested property of another schema (a secondary copy). Deduplication kept whichever the attribute contexts + happened to produce first, so a secondary copy could displace the primary, dropping the primary-only members: + the `{Type}SchemaValidator` adapter and its `[assembly: ZodSchemaGenerated]` registration. Value-object + structs that were referenced by an earlier-declared type (for example a `[Scalar]` id nested in a record) + silently vanished from `IZodSchemaFactory` assembly scanning. Deduplication now always prefers the primary + schema. - **`[RequiredZod]` now rejects an absent value exactly like `[Required]`.** A custom rule bound to a reference-type member was emitted inside the generator's non-null guard, so a missing value never reached `RequiredRule` and options/schema validation passed. The rule now implements the new `IRequiredRule` @@ -180,6 +267,38 @@ been published to NuGet. `IValidationRule` constraint nor the value object's `IScalarValueObject` — reporting CS8631 at every consumer. The value argument is now carried as a `TypeReference`, so both sides stay in step. +### Fixed — analyzer release tracking guard + +- **`AnalyzerReleaseTrackingTests` was passing vacuously, and `ZODSGEN045` was never recorded as shipped.** The + guard that asserts the unshipped file declares no rules matched a rule row only in the pipe-delimited form + (`| ZODSGEN045 |`), but `AnalyzerReleases.Unshipped.md` is written without the leading pipe — so a rule parked + there parsed as *no rules at all* and the assertion compared two empty sets. `ZODSGEN045` sat unshipped with a + green suite. The row pattern now accepts both table styles, `ZODSGEN045` is recorded under a new + `## Release 2.1.0` block, and the test asserts that block exists. The guard now fails when a diagnostic is left + unshipped, which is the only thing it was ever meant to do. + +### Added — the vitest suites run from the .NET test run + +- **`just test` now proves both halves of the cross-platform guarantee.** The TypeScript/Zod suites were driven + only by the dedicated `cross-platform` CI job, so a Zod-side regression could not fail a local run — and a gap + in that job's own wiring went unnoticed for a whole release. `Repository.UnitTests` now runs each + `tests/ts/*.test.ts` suite as a child process through **CliWrap** (one test per suite, so a failure names the + file and the child's stdout/stderr becomes the assertion message). The CI job remains the authoritative gate, + because it controls producer ordering and installs bun from scratch. +- `VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered` fails when a vitest suite has no entry in the C# list, so + a new suite cannot end up driven by the CI job alone — the same class of gap as the parity producer above. +- **These tests never skip, and they run on bun or node.** A skipped test proves nothing, so the runner is + resolved per run — bun when it is on PATH, otherwise node against vitest's own entry point — and the test + **fails** when neither is available, or when the install or the run fails. `node_modules/` is gitignored, so + vitest is installed first when it is missing (`bun install --frozen-lockfile`, or `npm install` when only + node is present). The node path deliberately avoids `npx` and `node_modules/.bin`: the `.bin` shims belong to + whichever package manager installed the tree, and on Windows `npx` is a `.cmd`/`.ps1` shim that cannot be + started as a child process. +- Every path is verified, not just the green one: an injected parity violation fails the .NET test with the + vitest output attached and names the runner that produced it; an unlisted `.test.ts` file fails the coverage + guard; the node fallback is confirmed to execute with bun removed from PATH; and with both removed the suite + tests fail rather than skipping. + ### Added — packed generator smoke test - `just smoke-packed-generator` (and a `packed-generator` CI job) packs the package, then builds and runs a @@ -215,6 +334,17 @@ been published to NuGet. - The vitest cross-platform suite now runs in CI, and it **fails** rather than silently passing when the C# cross-platform fixtures are absent. It also now reads the C# output recursively — the previous flat directory read never matched a file, so the C#-to-Zod handshake had never actually been asserted. +- **The CI cross-platform job never produced the Guid/UUID parity artifacts, so `bun run test` failed there.** + The job selects the C# producers with a treenode filter, and carried only one — + `/*/ZodSharp.Json/*CrossPlatformTests/*`, which writes `cross-platform/output/`. The parity artifacts + `tests/ts/guid-uuid-parity.test.ts` reads come from a *second* producer added with the Guid/UUID export work, + in a different assembly, namespace and class + (`ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts`), + which that filter does not match. `cross-platform/guid-parity/` was therefore always empty in CI and the + parity suite failed every run. The job now runs a step per producer. They cannot be combined into one + OR-ed filter: `a|b` is not honoured as a top-level alternation — it silently selects a broader set and still + skips the parity producer, which was verified when fixing this. `just test` was unaffected, because it runs + the whole suite. ## 2.0.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 93b3457..ea8b403 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,9 +10,9 @@ --> 5.9.0 5.9.0 - 1.68.17 - 1.0.0 - 10.0.12 + 1.75.0 + 1.1.1 + 10.0.13 + + - + + diff --git a/Justfile b/Justfile index 2c51f6e..12096af 100644 --- a/Justfile +++ b/Justfile @@ -7,47 +7,44 @@ build_configuration := "Debug" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*" -pipeline_feed := "https://api.nuget.org/v3/index.json" -pipeline_tool := ".tools/purview-build/purview-build" + if os() == "windows" { ".exe" } else { "" } +pipeline_tool := "dotnet purview-build" [private] default: just --list -# Install the shared Purview.Build tool (authenticated to the Purview-Dev feed) if not present +# Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - if [ ! -x "{{ pipeline_tool }}" ]; then \ - dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}"; \ - fi + dotnet tool restore # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] pipeline-pr *args: just ensure-pipeline-tool echo "Running PR pipeline..." - "{{ pipeline_tool }}" {{ args }} + {{ pipeline_tool }} {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] pipeline-build *args: just ensure-pipeline-tool echo "Running build pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=false --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] pipeline-release *args: just ensure-pipeline-tool echo "Running release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=NuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} # Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing [group('Pipeline')] pipeline-pack-validate *args: just ensure-pipeline-tool echo "Running pack + validate pipeline..." - "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} # Build and run a throwaway consumer against the packed generator. # @@ -68,14 +65,14 @@ pipeline-local-release *args: just ensure-pipeline-tool just lint-fix echo "Running local release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: just ensure-pipeline-tool echo "Running tests pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} # Build and test with the specified configuration, defaulting to "Debug" [group('Build and Test')] diff --git a/README.md b/README.md index cb27479..deb46f4 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ src/ └── tests/ # TUnit test projects ``` -The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net8.0`, `net9.0` and `net10.0`. +The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net8.0`, `net9.0`, `net10.0` and `net11.0`. ## Advanced Features diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index 4c62ee8..45be0e9 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -43,12 +43,127 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ ## Supported schema types -`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, and `ZodLazy`. +`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`. - Objects emit `additionalProperties: false`. - Literals emit `const` (and `type`). +- Native enums emit `enum` with the defined members in their System.Text.Json wire form: the numeric values by default, or the member names when the enum is serialized as a string. A `[Flags]` enum emits `type: integer` instead, because a combination is valid without being a defined member. - Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`). +Rules that describe their own JSON Schema constraint implement `IJsonSchemaRule` and are applied during +export (see [Validation rules and JSON Schema](#validation-rules-and-json-schema) below). + +## Export a generated `[ZodSchema]` + +A source-generated `[ZodSchema]` schema can be exported directly — there is no need to rebuild its rules by +hand with `Z.Object()`/`Z.String()`. The generated `{Type}Schema` class exposes a static `BuildJsonSchema()` +method, and the generated `{Type}SchemaValidator` (the DI adapter) implements `IJsonSchemaProvider`, so it can +be handed to `Z.ToJsonSchema`: + +```csharp +using ZodSharp; + +// Either call the generated schema directly… +var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); + +// …or export the generated validator (the DI adapter) through Z.ToJsonSchema. +var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); +``` + +The definition is produced from the same metadata the generated validator uses, so the two cannot drift +apart: property names, nested objects, native enums, nullable fields, required fields, `[NonSentinel]`, +`[NullOrNonWhiteSpace]`, DataAnnotations, and `[ZodRule]`-mapped rules are all reflected. + +- **Property names** use the System.Text.Json wire form: an explicit `[JsonPropertyName]` wins, otherwise the + name is camelCased to match the ASP.NET Core / `JsonSerializerDefaults.Web` default. A non-camelCase global + naming policy configured at runtime is not visible to the generator, so a consumer using one must adjust the + exported names (or declare `[JsonPropertyName]`). +- **Required fields** follow the nullable reference-type annotation: a non-nullable reference type (or + `Nullable`) is required, and a nullable one (`string?`) is wrapped in `anyOf: [ …, { "type": "null" } ]` + and is not required. `[Required]` also makes a field required. The generated validator agrees: a + non-nullable reference type rejects null and its rules run unconditionally, while a nullable one skips them + for null. A rule that rejects an absent value (for example `[RequiredZod]`) makes the field non-null even + when its annotation is nullable. +- **Scalar value objects** (`[Scalar]`) export as their underlying value's schema — a `Guid` scalar is + a canonical 8-4-4-4-12 hexadecimal string (a `pattern`, not Zod's RFC `uuid` format, because C# `Guid` + accepts any 128-bit value) — not as an object wrapping a `Value` property. +- **Enums** follow System.Text.Json. They export as numbers by default; a per-type or per-property + `[JsonConverter(typeof(JsonStringEnumConverter))]` (or the assembly-level `ZodSharpEnumSerialization=String` + switch, matching a global converter) makes them strings, and `ZodSharpEnumNamingPolicy=CamelCase` matches a + `JsonNamingPolicy.CamelCase`. A member's `[JsonStringEnumMemberName]` name is used verbatim. A `[Flags]` + enum exports as `type: integer`. +- **Unsupported rules** are not dropped silently: a rule the exporter cannot represent is listed under the + `x-zodsharp-unsupported` extension so the exported schema is visibly incomplete. + +> `additionalProperties: false` is emitted for objects (matching the runtime exporter). System.Text.Json +> ignores unknown members by default, so a request with extra keys is accepted by the C# validator but +> rejected by the exported schema. + +### Opting a type out of schema generation + +Not every `[ZodSchema]` type belongs to the cross-platform contract surface — a validation-only domain or +options type does not need to be exported to TypeScript/JavaScript. Two switches disable the export members +(the static `BuildJsonSchema` and the `IJsonSchemaProvider` implementation) while keeping validation intact: + +```csharp +[ZodSchema(GenerateJsonSchema = false)] // this type only +public sealed record InternalThing; +``` + +```xml + + + false + +``` + +A type that exports JSON Schema must not reference a type whose export is disabled, because the nested +`BuildJsonSchema` does not exist. The generator falls back to an empty (any) schema for that property and the +analyzer reports **ZODSGEN045** naming the property and the disabled type, so the gap is surfaced at build +time rather than shipped silently. + +## Validation rules and JSON Schema + +Every rule that can describe its own JSON Schema constraint implements `ZodSharp.JsonSchema.IJsonSchemaRule`. +The exporter applies these rules through `JsonSchemaRules.Apply`, and the generated `BuildJsonSchema()` does +the same, so a rule maps to JSON Schema identically on both paths: + +| Rule | JSON Schema | +|---|---| +| `MinLengthRule` / `MaxLengthRule` | `minLength` / `maxLength` | +| `MinValueRule` / `MaxValueRule` | `minimum` / `maximum` | +| `GreaterThanRule` / `LessThanRule` | `exclusiveMinimum` / `exclusiveMaximum` | +| `MultipleOfRule` / `EvenRule` / `OddRule` | `multipleOf` (odd uses `not: { multipleOf: 2 }`) | +| `IntRule` / `SafeIntegerRule` | `type: integer` (safe integer also bounds to the `int` range) | +| `EmailRule` / `UrlRule` / `UriRule` | `format: email` / `uri` | +| `UUIDRule` (versionless) | `format: uuid` | +| `UUIDRule(UuidVersion.Vx)` | `format: uuid` plus a `pattern` that pins the version and variant | +| `GuidRule` | a `pattern` for a canonical 8-4-4-4-12 hexadecimal identifier (any version/variant) | +| `RegexRule` | `pattern` | +| `StartsWithRule` / `EndsWithRule` / `IncludesRule` | anchored / escaped `pattern` | +| `NonSentinelRule` / `NullOrNonWhiteSpaceRule` | `minLength: 1` + `pattern: "\\S"` | +| `NonSentinelRule` | a `pattern` that rejects the all-zero UUID | +| `AllowedValuesRule` / `DeniedValuesRule` / `EnumRule` | `enum` / `not: { enum: … }` | +| `FiniteRule` / `RequiredRule` | no keyword (implied by `number` / the object's `required` list) | + +A C# `Guid` is exported as a `pattern`, not `format: uuid`, because `Guid` accepts any 128-bit value while an +RFC UUID validator (Zod's `z.uuid()`, which `z.fromJSONSchema` applies to **both** `uuid` and `guid`) checks the +version and variant nibbles. A time-bound identifier generated by `FastGuid.NewSqlServerGuid()` / +`NewPostgreSqlGuid()` is deliberately not an RFC UUID and would be rejected by that validator. A Guid that must +be a specific RFC version opts in with `[UUID(UuidVersion.Vx)]` (which also validates a `Guid` or a +`[Scalar]`), and exports `format: uuid` plus a version-pinning `pattern`. + +JSON Schema has a single `pattern` keyword, so when more than one rule constrains the pattern (for example a +Guid plus `[NonSentinel]`, or a regex plus `[NonSentinel]`) the constraints are composed under `allOf` rather +than one overwriting the other. Each `allOf` branch repeats the schema's `type`, because Zod's `fromJSONSchema` +discards a branch without one. + +A rule that does not implement `IJsonSchemaRule` is reported through +`JsonSchemaDefinition.UnsupportedRules` (`x-zodsharp-unsupported`). Consumer rules opt in by implementing the +interface; for example a custom null-or-non-whitespace rule would add `IJsonSchemaRule` and set +`minLength`/`pattern` on the schema passed to `ApplyTo`. The built-in `NullOrNonWhiteSpaceRule` already does +this, so a domain that needs that behaviour should use `[NullOrNonWhiteSpace]` rather than a bespoke rule. + ## Serialize the definition Pick the JSON serializer that matches the integration package you referenced: diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index 3bd59a6..e33932d 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -37,8 +37,9 @@ var result = userSchema.Validate(userData); - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. -- `anyOf` / `oneOf` → `ZodUnion`; `allOf` → first schema. -- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`. +- `anyOf` / `oneOf` → `ZodUnion`; `allOf` → the intersection of every branch (and the base schema when the + schema also declares a `type`). A type-less branch is `any`, matching Zod's `fromJSONSchema`. +- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves that pattern on import. Multiple `pattern` constraints are composed under `allOf`, and every branch is applied. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index b9bcf6b..7cdcfa1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,10 +4,19 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g ## Versioning -The package version comes from `package.json` (`version` field). The current stable line is `2.0.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). +The package version comes from `package.json` (`version` field). The current stable line is `2.1.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. +## Releasing a version + +A push to `main` publishes, so everything below has to be true *before* the merge. + +1. **Rename the changelog heading to the version being released.** `CHANGELOG.md`'s newest `## ` heading is the one that ships; there is no `Unreleased` heading, precisely so a merge cannot publish an unnamed release. Consumer-visible renames and signature changes go under that version's `### Upgrade notes`, each naming the old form, the new form and the edit to make. +2. **Move new diagnostics into a shipped block.** Anything in `AnalyzerReleases.Unshipped.md` moves to a `## Release ` block in `AnalyzerReleases.Shipped.md`. A rule stays recorded under the release that *introduced* it, so earlier blocks are never rewritten. +3. **Bump the package-validation baseline.** `src/Directory.Build.targets` sets `PackageValidationBaselineVersion` to the previous stable release; it must name the version currently on NuGet, not the one being released. The build then fails `dotnet pack` on an unintended public-API change. An intended one needs a `CompatibilitySuppressions.xml` beside the owning project (`dotnet pack /p:GenerateCompatibilitySuppressionFile=true`) *and* a matching upgrade note — the two are each other's evidence. +4. **Run `just pipeline-pr`**, which covers restore, build, lint, tests, pack and pack validation in one pass. + ## Workflows | Workflow | Trigger | Pipeline mode | diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 18908c8..c53a9c5 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -27,6 +27,22 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib `[RequiredZod]` (the generated `RequiredRule` attribute) mirrors `[Required]`: it is emitted outside the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` property has the same name and meaning as `RequiredAttribute.AllowEmptyStrings`, and it additionally exposes `TrimWhitespace`, so it is a drop-in replacement for `[Required]`. +## Positional records + +A positional record declares its properties from the primary-constructor parameter list, and the C# compiler applies an attribute that is valid on both a parameter and a property to the parameter only. The generator therefore reads the attributes on the synthesized property *and* on the primary-constructor parameter, so a validation attribute written directly on a positional parameter is honoured: + +```csharp +[ZodSchema] +public sealed record RepositoryInventorySummary( + [NonSentinel] string Name, + [NonSentinel] string CanonicalReference, + [NullOrNonWhiteSpace] string? DefaultBranch, + [NonSentinel] DateTimeOffset LastObservedAt +); +``` + +`[property: NonSentinel]` (the explicit property target) is equally honoured; when both targets carry the same attribute type the property target wins and the rule runs once. Declared properties, classes, and record structs are unaffected. + ## Enum properties Enum properties are validated automatically — the generator rejects a value that is not a defined member of the enum type with `invalid_enum_value`. The check is emitted as `ZodSharp.Rules.EnumRule`. Members can be excluded with `[ZodIgnore]` (on the enum member, for every property of that type) or `[DeniedValues]` (on the property only). `[Flags]` enums and properties with an explicit `[AllowedValues]` allow-list are not auto-validated, and the whole feature is disabled with `[ZodSchema(ValidateEnumValues = false)]`. See [Source Generator](Source-Generator.md#automatic-enum-validation) for the full rules and examples. @@ -96,4 +112,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type (a `Guid` is a UUID, so the rule validates its bytes), which is how a time-bound identifier that must be an RFC version is constrained. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 042aa11..eaaa8ef 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -30,11 +30,15 @@ For a `[ZodSchema]` target type `{TypeName}`, the generator emits: | Artifact | Shape | |---|---| -| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | -| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | +| `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `Parse`, `BuildJsonSchema`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | +| `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — DI-friendly adapter with `Validate` / `ValidateAsync` / `BuildJsonSchema`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | | `{TypeName}Validator` | `sealed partial class {TypeName}Validator : IValidateOptions<{TypeName}>` — emitted only when `IValidateOptions` support is enabled (and the target is a class) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | +`BuildJsonSchema` returns a `ZodSharp.JsonSchema.JsonSchemaDefinition` built from the same metadata as +`Validate`, so a generated `[ZodSchema]` schema can be exported through `Z.ToJsonSchema` without rebuilding its +rules by hand. See [JSON Schema Export](JsonSchema-Export.md#export-a-generated-zodschema). + ```csharp // Value-first composition methods (EnableComposition, default true): var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); @@ -56,6 +60,7 @@ All options are optional. | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | | `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. | +| `GenerateJsonSchema` | `true` | Set to `false` to omit the JSON Schema export members (`BuildJsonSchema` and the `IJsonSchemaProvider` on the adapter), keeping the type out of the cross-platform contract surface. A type referenced by an exported type is reported as `ZODSGEN045`. | > [!NOTE] > `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together. @@ -147,6 +152,9 @@ MSBuild switches: | `DisableZodSharpSourceGenerator` | unset | disables the generator entirely when truthy | | `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) | | `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list | +| `ZodSharpGenerateJsonSchemas` | `true` | assembly-wide JSON Schema export switch; explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` for every type | +| `ZodSharpEnumSerialization` | `Number` | `String` makes enums export as their member names, matching a global `JsonStringEnumConverter` | +| `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | ## Automatic enum validation diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 849fbad..a094d32 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -129,7 +129,7 @@ Behaviour notes: - **`CreditCardRule`** mirrors `[CreditCard]`: Luhn check, ignoring spaces and hyphens. - **`Base64StringRule`** mirrors `[Base64String]`: `Convert.FromBase64String` must succeed. - **`Base64UrlRule`** accepts URL-safe base64 (`A-Z a-z 0-9 - _`) with no padding; a length of `4n+1` is rejected. -- **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. +- **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. It validates both a `string` and a `Guid` — a `Guid` is a UUID, so its bytes are inspected directly — which lets `[UUID(UuidVersion.Vx)]` constrain a `Guid` member or a `[Scalar]` value object. - **`ULIDRule`** requires 26 Crockford base32 characters, with the first character `0`-`7`. - **`JWTRule`** requires three non-empty base64url-encoded segments separated by periods. - **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`); pass an `IPAddressRuleType` to require `IPv4`, `IPv6`, or `Any` (the default). @@ -140,7 +140,7 @@ Behaviour notes: - **`EmojiRule`** mirrors Zod's `emoji()`: every rune must be an `Extended_Pictographic` or `Emoji_Component` code point, and at least one must be an anchor (a pictograph, a regional indicator, or a keycap). The Unicode tables are generated from Unicode 16.0 `emoji-data.txt`, because .NET's regex engine does not expose the `Extended_Pictographic` property. - **`XidRule`** requires exactly 20 base32hex characters (`0-9`, `a-v`/`A-V`); **`KsuidRule`** requires exactly 27 base62 characters. - **`DurationRule`** accepts ISO 8601-1 durations; the 8601-2 extensions (negative durations and fractional/negative components) are rejected, matching Zod. -- **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. +- **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. A C# `Guid` is exported to JSON Schema as a canonical hexadecimal `pattern` (not `format: uuid`), because `Guid` accepts any 128-bit value; a `Guid` that must be an RFC version opts in with `[UUID(UuidVersion.Vx)]`. - **`CidrRule`** accepts an IPv4 or IPv6 block; pass a `CidrRuleType` to require `IPv4` or `IPv6` (`Any` is the default). ## Number rules @@ -177,10 +177,10 @@ Behaviour notes: | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| -| `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value is a sentinel value, but got {0}` | +| `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value has a sentinel value, but got {0}` | | `NullOrNonWhiteSpaceRule` | `(string? message, string? code)` | — | `invalid_string` | `Value must be null or non-whitespace, but got '{0}'` | -`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. +`NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. The `{0}` argument is a **description** of the sentinel (`an empty GUID`, `DateTime.MinValue`, `an empty string`, …) rather than the raw value, and a type-level `[NonSentinel]` on a `[Scalar]` type replaces the message's `Value` subject with the scalar's name — see [Value Objects Integration](Value-Objects-Integration.md#message-overrides). `NullOrNonWhiteSpaceRule` is the null-tolerant counterpart: it accepts `null` but rejects an empty or whitespace-only string. It exists for nullable scalar value objects, which round-trip JSON `null`, so their schema must accept `null` while still rejecting whitespace — `[RequiredZod]` always rejects `null` and so cannot express it. Apply it to a nullable scalar type with `[NullOrNonWhiteSpace]`; a rule written against the underlying `string?` value is adapted to the value object automatically. diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index b02dd3b..57f12d7 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -177,6 +177,8 @@ The reported message is resolved in this order: `MessageFormat` is therefore the fallback, not the only message. +When the fallback is used by a **type-level** rule on a scalar, the generator rewords it: the leading subject word `Value` in `MessageFormat` is replaced with the scalar's name, so the error still identifies its owner even though it reports an empty path. A rule whose `MessageFormat` does not contain `Value` (for example `"Number must be even, but got {0}"`) is left unchanged, and an explicit `message`/`ErrorMessage` is never rewritten. This is why `NonSentinelRule` reports `TenantId has a sentinel value, but got an empty GUID` for `[NonSentinel]` on a `[Scalar] TenantId`, but `Value has a sentinel value, but got an empty GUID` for a member or the fluent API. + ### Rule against the value object Define one generic rule per underlying primitive, close it over the scalar, and implement `IZodRule` so each scalar keeps its own code: @@ -290,6 +292,23 @@ The reported error keeps the **empty path**, and the error identity (`Code`/`Ori > [!NOTE] > If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](Custom-Rules.md#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](Custom-Rules.md#generic-rules). +> [!NOTE] +> A `Guid`-backed scalar is validated as a canonical Guid, and exports to JSON Schema as a `pattern`, not +> `format: uuid` — a `Guid` accepts any 128-bit value, and a time-bound identifier generated by +> `FastGuid.NewSqlServerGuid()`/`NewPostgreSqlGuid()` is not an RFC UUID. When an identifier *must* be a +> specific RFC version (for example a `Guid.CreateVersion7()` key), declare it with the UUID rule: +> +> ```csharp +> [Scalar] +> [ZodSchema] +> [UUID(UuidVersion.V7)] +> public readonly partial record struct AssetId { } +> ``` +> +> `UUIDRule` validates a `Guid`'s bytes directly, so the rule adapts onto the scalar; the export is +> `format: "uuid"` plus a version-pinning `pattern`. Generation strategy alone never implies a version +> constraint — it is only applied when the runtime validator applies it. + ## Testing Assert against the rule's constants instead of duplicating literals, and remember that an adapted scalar rule reads its identity from the wrapped rule: @@ -309,10 +328,16 @@ var adaptedError = TenantIdSchema.Validate(default).Errors[0]; await Assert.That(adaptedError.Code).IsEqualTo(NonSentinelRule.ErrorCode); // "invalid_value" await Assert.That(adaptedError.Message).IsEqualTo("TenantId must not be empty."); // attribute override -// With no Message override, the rule's MessageFormat is the message: +// With no Message override, the rule's MessageFormat is the message. When the rule runs at the type +// level of a scalar, the subject ("Value") is replaced with the scalar's name and the sentinel is +// described rather than printed raw: +await Assert.That(TenantIdSchema.Validate(default).Errors[0].Message) + .IsEqualTo("TenantId has a sentinel value, but got an empty GUID"); + +// Anywhere else (a member, or the fluent API), the generic subject is kept: await Assert.That( - string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, Guid.Empty)) - .IsEqualTo("Value is a sentinel value, but got 00000000-0000-0000-0000-000000000000"); + string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, "an empty GUID")) + .IsEqualTo("Value has a sentinel value, but got an empty GUID"); ``` ## Diagnostics and troubleshooting diff --git a/global.json b/global.json index bb960c5..5820a3b 100644 --- a/global.json +++ b/global.json @@ -1,13 +1,13 @@ { "sdk": { - "version": "11.0.100-rc.1.26425.128", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.4" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index b95f01f..8ed2867 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.2", + "version": "2.1.0", "private": true, "license": "MIT", "author": { diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md new file mode 100644 index 0000000..a11c258 --- /dev/null +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -0,0 +1,242 @@ +--- +name: purview-zodsharp-aspnetcore +description: >- + Use when wiring Purview.ZodSharp into ASP.NET Core or any DI host - ProblemDetails from a + ValidationResult or a thrown ZodException, minimal-API endpoint validation, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects with + IValidateOptions, and mapping error codes to HTTP statuses with ErrorType. +license: MIT +metadata: + product: Purview.ZodSharp.AspNetCore + repository: purview-dev/zodsharp +--- + +# ASP.NET Core and dependency injection + +`Purview.ZodSharp.AspNetCore` turns failed validation into standard `ProblemDetails` / +`HttpValidationProblemDetails` payloads **without losing the structured issues**, and registers +schema resolution into DI. + +```bash +dotnet add package Purview.ZodSharp.AspNetCore +``` + +## ProblemDetails from a result + +```csharp +using ZodSharp.AspNetCore; + +var result = BasketSchema.Validate(basket); + +if (!result.IsSuccess) +{ + var problem = result.ToHttpValidationProblemDetails(); + + return Results.ValidationProblem( + problem.Errors, + extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }); +} +``` + +| Member | Produces | +| --- | --- | +| `ToHttpValidationProblemDetails(result, statusCode = 400)` | `HttpValidationProblemDetails`; paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | +| `ToValidationProblemDetails(result, statusCode = 400)` | `ValidationProblemDetails` | + +Both **throw `InvalidOperationException` when the result is successful** — they are failure +converters, not general mappers. The structured metadata survives in the `issues` extension as a +`ValidationIssue[]`: + +```csharp +public sealed class ValidationIssue +{ + public required string Code { get; init; } + public string? Category { get; init; } + public string? Origin { get; init; } + public int? Minimum { get; init; } + public int? Maximum { get; init; } + public bool? Inclusive { get; init; } + public required string[] Path { get; init; } + public required string Message { get; init; } + public IReadOnlyDictionary? Parameters { get; init; } +} +``` + +## Minimal APIs + +```csharp +// Validate the bound DTO automatically - needs AddZodSharp to have registered the validator. +app.MapPost("/users", (UserDto dto) => dto) + .WithZodSharpValidation(); + +// Or validate in the handler and return the IResult directly. +app.MapPost("/users", (UserDto dto) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); +}); +``` + +`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type +`T` against the `IZodSchemaFactory` registered by `AddZodSharp`/`AddZodSharpFactory`. On failure it +returns the same payload as `ToHttpValidationProblemDetails()`; on success it passes through. + +## Thrown `ZodException` + +A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under +strict deserialization maps the same way: + +```csharp +catch (ZodException ex) +{ + return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); +} +``` + +Or handle it globally: + +```csharp +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +`AddZodSharpProblemDetails(configure?, problemDetails?)` registers `ZodExceptionHandler`, calls +`services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: + +- `ErrorTypeRegistry Registry` — resolves codes to `ErrorType`s; defaults to `ErrorTypeRegistry.Default`. +- `bool FormatMessages` — format messages from the matched `ErrorType.MessageFormat`. +- `Func, int>? StatusCodeSelector` — total control of the status code. + +> It does **not** register `IZodSchemaFactory`. Pair it with `AddZodSharp` (or the core +> `AddZodSharpFactory`) when you also want DI validator resolution. + +## Error codes to HTTP statuses + +`ErrorType`, the `[ErrorType]` attribute, the generator that emits `Create`/`Throw` helpers and the +`ZODSASP001`–`ZODSASP003` analyzers all ship in the **core** package and need no ASP.NET Core +dependency. The AspNetCore package adds `ErrorTypeRegistry` and the ProblemDetails mapping on top. + +```csharp +public static partial class ConcurrentErrorType +{ + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Category: "invalid_value", + Description: "The aggregate could not be saved.", + HttpStatus: 409, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new("AggregateId", typeof(string)), + ErrorType.Param("AggregateType"), + ]); +} + +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +``` + +`HttpStatus` lives on `ErrorType` purely for convenience — the core library never reads it; only the +ASP.NET Core mapping does. `Category` is a broad grouping that can span many specific codes (code +`invalid_tenant_id`, category `invalid_value`); the generated helpers copy it onto each +`ValidationError` and it surfaces on the serialized `ValidationIssue`. + +Marking the field `[ErrorType]` and the containing class `partial` gets you strongly typed helpers, +one parameter per declared entry: + +```csharp +var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); +ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); + +var detailed = ConcurrentErrorType.CreateSaveFailed( + "agg-123", "Invoice", + path: ["order", "items", "[0]"], + origin: "collection", minimum: 1, maximum: 10, inclusive: true); +``` + +`Create` builds a typed `ErrorTypeParameters` (validated against the declared types, exposed through +`ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so +`error.Message` already reads the formatted text. + +Mapping rules: + +- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `400`. + `StatusCodeSelector` overrides everything. +- **Title / Type / Detail** — from the highest-status matched `ErrorType`, otherwise defaulted. +- **Message** — named placeholders are substituted from `ValidationError.Parameters`. A placeholder + with no matching value is **left as-is**, so a templating gap stays visible rather than silently + disappearing. `ZODSASP001` warns at compile time when a placeholder is not declared in `Parameters`. +- **Parameters** — surfaced per-issue in `issues`, and merged (camel-cased) into the top-level + extensions for client correlation. + +`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` also accept an `ErrorTypeRegistry` or +a `Func` for on-demand mapping. + +## Registering the factory + +`IZodSchemaFactory` (`ZodSharp.Core`) resolves validators by type: + +| Member | Behaviour | +| --- | --- | +| `Resolve()` | the validator, or `null` | +| `ResolveRequired()` | the validator, or `InvalidOperationException` | +| `Validate(value)` | validate through the registered validator | +| `Register(…)` / `Register(Type, …)` / `TryRegister(…)` / `IsRegistered()` | registration | + +```csharp +builder.Services.AddZodSharp(options => +{ + options.ScanAssemblies.Add(typeof(UserDto).Assembly); + options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); + options.ScanLoadedAssemblies = true; +}); + +// Additive, for modular apps: +builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); +builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); +builder.Services.AddZodSharpLoadedAssemblies(); +``` + +Scanning finds the generator's `[assembly: ZodSchemaGenerated(typeof(…))]` markers and registers each +`{Type}SchemaValidator`. A hand-built schema registers by wrapping: +`factory.Register(new ZodSchemaValidator>(mySchema))`. + +> **`TryAdd` semantics.** The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls — +> including their options and `ConfigureFactory` callbacks — are ignored. State is never overwritten, +> so calling it more than once is safe, and the additive helpers still contribute. + +| Method | Package | Registers | Use when | +| --- | --- | --- | --- | +| `AddZodSharp(options)` | AspNetCore | factory + auto-registered generated validators | an ASP.NET Core app | +| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | modular apps where deeper layers contribute their own schemas | +| `AddZodSharpFactory(configure)` | core | the factory only | any host; you call `RegisterFromAssembly` yourself | +| `AddZodSharpProblemDetails(…)` | AspNetCore | exception handler + ProblemDetails, **not** the factory | mapping thrown `ZodException`s | +| `AddZodSchemaOptionsValidator(…)` | core | `IValidateOptions` | options validation; needs a factory first | + +## Options validation + +```csharp +builder.Services.AddZodSharpFactory(f => f.RegisterFromAssembly(typeof(UserOptions).Assembly)); +builder.Services.AddZodSchemaOptionsValidator(); + +// or chained: +builder.Services + .AddOptions() + .Bind(configuration.GetSection("User")) + .AddZodSchemaValidator() + .ValidateOnStart(); // fail at startup, not first access +``` + +`MissingValidatorBehavior` decides what happens with no validator registered for `T`: +`Throw` (default — `ResolveRequired()`, so a missing schema is loud rather than a silent skip) or +`Ignore` (passes through as success). + +The generator also auto-generates `IValidateOptions` for types whose names end in configurable +suffixes — see the `purview-zodsharp-source-generator` skill. + +## Related skills + +`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time +validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md new file mode 100644 index 0000000..98280dd --- /dev/null +++ b/skills/purview-zodsharp-core/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-zodsharp-core +description: >- + Use when validating data with Purview.ZodSharp at runtime - picking the right package, building + schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError, + composing with Transform/Refine/Pipe/Catch/Default, and how null handling and type coercion work. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp core + +A C# port of [Zod](https://github.com/colinhacks/zod): schemas you build fluently, validated without +exceptions, reporting machine-readable issues. + +## Packages + +```bash +dotnet add package Purview.ZodSharp # core, source generator, JSON Schema export +dotnet add package Purview.ZodSharp.SystemTextJson # STJ deserialize-and-validate + schema import +dotnet add package Purview.ZodSharp.NewtonsoftJson # Newtonsoft equivalent +dotnet add package Purview.ZodSharp.AspNetCore # ProblemDetails + DI +``` + +JSON Schema **export** (`Z.ToJsonSchema`) is in the core package; **import** (`Z.FromJsonSchema`) comes +from whichever JSON integration you reference, so import its namespace +(`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). + +## First schema + +```csharp +using ZodSharp; + +var nameSchema = Z.String().Min(3).Max(50); +var result = nameSchema.Validate("John"); + +if (result.IsSuccess) + Console.WriteLine(result.Value); +``` + +## Validate, SafeParse, Parse, ValidateAsync + +| Member | Behaviour | +| --- | --- | +| `Validate(value)` | returns `ValidationResult`; **never throws** | +| `SafeParse(value)` | alias of `Validate` | +| `Parse(value)` | returns the validated value, or throws `ZodException` (via `GetValueOrThrow()`) | +| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in the source generator's custom async validation. | + +Reach for `Validate` by default and `Parse` only where a throw is the right control flow. Exceptions +for expected invalid input are expensive and lose the structured issue list unless you catch. + +## `ValidationResult` + +A `readonly record struct` in `ZodSharp.Core`: + +- `bool IsSuccess` — annotated `[MemberNotNullWhen(true, nameof(Value))]`, so the compiler knows + `Value` is non-null inside the success branch. +- `T? Value` — valid only when `IsSuccess`. +- `ImmutableArray Errors`. + +Factories: `Success(value)`, `Failure(ValidationError)`, `Failure(ImmutableArray)`, +`Failure(IEnumerable)`, and `Merge(lhs, rhs)` — succeeds only if both succeed, and +concatenates the errors otherwise. + +## `ValidationError` + +A `readonly record struct` carrying machine-readable metadata, not just a message: + +| Member | Meaning | +| --- | --- | +| `Code` | `invalid_type`, `too_small`, `too_big`, `invalid_union`, `missing_field`, `unrecognized_key`, `validation_failed`, `refinement_failed`, `transform_error`, … | +| `Message` | human-readable | +| `Path` | `ImmutableArray` — `["user", "email"]`; array indexes are string segments, `"0"` | +| `Origin` | category for size issues: `"string"`, `"array"`, `"collection"` | +| `Minimum` / `Maximum` / `Inclusive` | the bound, for size issues | +| `Parameters` | `IReadOnlyDictionary`; a union failure carries every option error under `"errors"` | + +Build one with +`ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. + +`ZodException` carries `ImmutableArray Errors`; its `ToString()` renders one line per +error as `"{joinedPath}: {Message} ({Code})"`. + +## The `Z` factory + +| Method | Returns | +| --- | --- | +| `Z.String()` / `Z.Number()` / `Z.Boolean()` / `Z.Null()` / `Z.Date()` / `Z.BigInt()` | the matching `Zod*` schema | +| `Z.Array(elementSchema)` | `ZodArray` | +| `Z.Optional(schema)` (`T : class`) / `Z.Nullable(schema)` (`T : struct`) | `ZodOptional` / `ZodNullable` | +| `Z.Object()` | `ZodObjectBuilder` | +| `Z.Union(params ...)` / `Z.Union(...)` | `ZodUnion` / `ZodTypedUnion` | +| `Z.DiscriminatedUnion(discriminator)` | `ZodDiscriminatedUnionBuilder` | +| `Z.Intersection(left, right)` | `ZodIntersection` | +| `Z.Literal(value)` (`T : IEquatable`) | `ZodLiteral` | +| `Z.Lazy(schemaGetter)` | `ZodLazy` — for recursive schemas | +| `Z.Enum(params string[])` / `Z.Enum()` | `ZodEnum` / `ZodNativeEnum` | +| `Z.Record(valueSchema)` | `ZodRecord` | +| `Z.Tuple(…)` / `Z.Tuple(…)` | `ZodTuple<…>` | +| `Z.ToJsonSchema(schema, options?)` | `JsonSchemaDefinition` | + +`Enum` and `Union` are overloaded — `Enum(params string[])` versus `Enum()`, and +`Union(params …)` versus `Union(…)`. + +```csharp +var userSchema = Z.Object() + .Field("name", Z.String().Min(1)) + .Field("age", Z.Number().Min(0).Max(120).Int()) + .Field("email", Z.String().Email()) + .Build(); +``` + +## The validation pipeline + +Every schema derives from `ZodType` (`ZodSharp.Core`). Two phases: + +1. **`ParseInternal`** — the schema's own type check and traversal: rejecting `null` where not + allowed, coercing, walking objects/arrays/tuples/unions, producing structured failures. +2. **Rules** — on success, the accumulated `IValidationRule` structs run. A failing rule emits + a `ValidationError` with its Zod-compatible code. + +Rules are `readonly record struct`s implementing `IValidationRule` (`bool IsValid(in T value)`, +`string GetErrorMessage(in T value)`) — zero allocation. Each exposes `public const string ErrorCode` +and `MessageFormat`, so a test asserts against the rule rather than a duplicated literal. (A rule +without them is `ZODSGEN042`.) + +`ZodString` also exposes `ValidateSpan(ReadOnlySpan)` and `IsValidSpan(value, out errors)` for +allocation-free string validation; string rules implementing `IStringValidationRule` participate. + +## Composition + +`ZodType` composes by **wrapping**, never mutating. Each of these returns a new schema: + +| Call | Produces | +| --- | --- | +| `.Transform(func)` | `ZodTransform` | +| `.Refine(predicate, message?)` | `ZodRefinement` | +| `.SuperRefine(Action>)` | `ZodSuperRefinement` | +| `.Pipe(schema)` | `ZodPipe` | +| `.Catch(value \| func)` | `ZodCatch` | +| `.Prefault(value)` / `.Default(value)` | `ZodPrefault` / `ZodDefault` | +| `.And(schema)` / `.Or(schema)` | `ZodIntersection` / `ZodTypedUnion` | +| `.Describe("…")` | sets `Description` | + +Because composition lives on the base type, every schema chains all of them. + +## Null and optionality + +Schemas report optionality through the internal `IOptionalSchema`: + +- `IsOptional` — true for `ZodOptional`, `ZodNullable`, `ZodDefault`, `ZodPrefault`. +- `ProvidesValueOnMissing` — true for `ZodDefault` and `ZodPrefault`. Object fields route a missing + value through `Validate(null!)` so the produced value is injected. +- `IAcceptsNull.ValidateNull()` is implemented by the nullable/optional/default/prefault schemas and + `ZodNull`; object-field and union wrappers route `null` to it rather than failing coercion. + +## Type coercion + +Object fields and union options wrap typed schemas, so boxed values out of a +`Dictionary` validate: + +- `null` routes to `IAcceptsNull.ValidateNull()` where supported. +- An exactly-typed value reuses the original box. +- Numeric coercion goes through `IConvertible` in the **invariant** culture, so a boxed `long` + satisfies a `Z.Number()` field. +- A non-nullable value type rejects `null`; anything else falls through to failure. + +## Related skills + +`purview-zodsharp-source-generator` for compile-time `[ZodSchema]` validators (which is what you want +for a DTO), `purview-zodsharp-custom-rules` for writing rules and rule attributes, +`purview-zodsharp-aspnetcore` for ProblemDetails and DI, and `purview-zodsharp-diagnostics` for the +`ZODSGEN`/`ZODSASP` catalogue. diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md new file mode 100644 index 0000000..4167b52 --- /dev/null +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -0,0 +1,239 @@ +--- +name: purview-zodsharp-custom-rules +description: >- + Use when writing a custom validation rule for Purview.ZodSharp - implementing IValidationRule and + IStringValidationRule, the ErrorCode/MessageFormat constants convention, IZodRule runtime identity, + attaching a rule with AddRule or a fluent extension, exposing it as a DataAnnotations-style + attribute with [ZodRule], generic rules, and type-level rules on scalar value objects. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Custom rules + +A **rule** is a `readonly record struct` (or class) implementing `ZodSharp.Core.IValidationRule`. +Rules run after a schema's type and structural check succeeds; each failing rule adds a +`ValidationError`. + +Rules are first class in two directions: + +- attach one to a schema with the public `AddRule`/`Rule` API, or +- surface it as a `System.ComponentModel.DataAnnotations`-style attribute that the `[ZodSchema]` + generator honours exactly like `[EmailAddress]` or `[Range]`. + +## The contract + +```csharp +namespace ZodSharp.Core; + +public interface IValidationRule +{ + bool IsValid(in T value); + string GetErrorMessage(in T value); +} +``` + +Implement it as a **struct** so validation does not allocate. `IsValid` is called only when the +surrounding schema succeeded and — for a nullable property — the value is not `null`. + +For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` +(`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so the rule +participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. A rule that +implements only `IValidationRule` still works; it falls back to the string pipeline. + +## `ErrorCode` and `MessageFormat` — not optional in practice + +Every rule **should** expose its error identity as public constants, so code and tests assert against +the rule rather than re-typing literals: + +```csharp +public readonly record struct EmailRule : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Invalid email format: {0}"; + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +- **`ErrorCode`** is the canonical code. `IValidationRule.Code` defaults to `"validation_failed"`; + a rule accepting a per-usage `code` override returns it (falling back to `ErrorCode`) from both + `Code` and `IZodRule.Code`, so every route reports the same effective value. +- **`MessageFormat`** is a `string.Format` template. `{0}` is the offending value; `{1}`, … are + rule-specific arguments. Format with `CultureInfo.CurrentCulture`. +- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a + shared base can host them for its concrete derivations. + +A source-declared rule missing either constant is reported as **`ZODSGEN042`**. The payoff: + +```csharp +await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); +await Assert.That(error.Message).IsEqualTo( + string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); +``` + +### Runtime identity: `IZodRule` + +The constants are the rule's *static* default. When one attribute must report a different code per +annotated member, implement `IZodRule` and supply code/origin at runtime: + +```csharp +public interface IZodRule +{ + string? Code { get; } // null falls back to the attribute-mapped code + string? Origin { get; } // null falls back to the attribute-mapped origin +} +``` + +The generated code casts to `IZodRule` when reading them, so identity can depend on constructor +arguments. A rule that **accepts** a `code`/`origin` constructor parameter without implementing +`IZodRule` never reaches the reported identity — that is `ZODSGEN039`. + +Code and origin resolve first-match-wins: + +1. the rule, when it implements `IZodRule`; +2. `Code`/`Origin` named arguments on the applied attribute; +3. the `[ZodRule(typeof(X), Code = "…", Origin = "…")]` mapping on the attribute type; +4. default — `validation_failed`, no origin. + +### Message precedence + +1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. +2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped + to a constructor parameter named `message`. +3. `GetErrorMessage(value)`, formatting `MessageFormat`. + +`MessageFormat` is the fallback, not the only message. + +## Writing one + +```csharp +using System.Globalization; +using ZodSharp.Core; + +namespace MyRules; + +/// Rejects strings that contain whitespace. +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + + public bool IsValid(in string value) + { + if (value is null) + return false; + + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + return false; + } + + return true; + } + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +It works standalone, and attaches to any schema: + +```csharp +var rule = new NoWhitespaceRule(); +if (!rule.IsValid("John Doe")) + Console.WriteLine(rule.GetErrorMessage("John Doe")); + +var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed.")); +``` + +`ZodType.AddRule(IValidationRule)` and the generic `Rule(TRule)` +helper are both public. Give the rule a first-class fluent method with an ordinary extension method on +its schema type, the way the built-in rules are surfaced. + +## Exposing it as an attribute + +Two routes. + +**Map a hand-authored attribute to the rule.** The open generic form serves both a primitive member +and a scalar value object: + +```csharp +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +**Or let the generator write it.** Mark the rule with the parameterless `[ZodRule]` and a +`{RuleName}Attribute` is emitted in the rule's namespace — a trailing `Rule` becomes `Attribute`, and +a name colliding with a DataAnnotations attribute takes a `Zod` suffix: + +```csharp +[ZodRule] // emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule; +``` + +The generator runs over the compilation that **declares** the rule, so the marker form suits a rule +in the same project, or in a rules library referencing `Purview.ZodSharp`. + +## Generic rules and rule families + +A generic rule closed with the property type lets one attribute serve every member of that shape — +`[ZodRule(typeof(NonSentinelRule<>))]` on a `NonSentinelAttribute` covers `Guid`, `DateTime`, +`string` and the rest. + +`ZodSharp.Rules.NonSentinelRule` is the canonical example: it rejects the framework +default/boundary values an ORM stores to mean "no value" — `Guid.Empty`, +`DateTime.MinValue`/`MaxValue`, the `DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and +null/empty/whitespace strings. A type with no known sentinel always passes, so the rule never rejects +something it does not understand. + +```csharp +var schema = Z.Date().AddRule(new NonSentinelRule()); +var result = schema.Validate(DateTime.MinValue); +// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") +``` + +## Type-level rules and scalar value objects + +An attribute applied to a **type** validates the value as a unit, reporting an **empty path**. That is +what makes a rule usable on a `Purview.ValueObjects` `[Scalar]`: + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId +{ + public Guid Value { get; init; } +} +``` + +A rule written against the *underlying* value — `NonSentinelRule` — is adapted automatically: +the generator closes it with the underlying type and wraps it, so it runs against the value object +with an empty path, and the wrapped rule owns the reported `Code`. Closing such a rule with the +scalar type instead (`NonSentinelRule`) compiles but **always passes**, because the check only +knows the primitive. + +## Diagnostics you will meet + +`ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), +`ZODSGEN031` (a constructor parameter could not be mapped from the attribute), `ZODSGEN032` (no +attribute could be generated), `ZODSGEN033` (a rule-mapped attribute on a type that gets no generated +schema, so the rule never runs), `ZODSGEN037`/`ZODSGEN038` (attribute-name collisions and incomplete +mappings), `ZODSGEN039` (`code`/`origin` without `IZodRule`), `ZODSGEN040` (an attribute argument with +no effect), `ZODSGEN042` (missing `ErrorCode`/`MessageFormat`) and `ZODSGEN043` (a built-in rule whose +constructor parameter cannot be an attribute property). + +The `purview-zodsharp-diagnostics` skill carries the full catalogue. diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md new file mode 100644 index 0000000..c8fc134 --- /dev/null +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -0,0 +1,119 @@ +--- +name: purview-zodsharp-diagnostics +description: >- + Use when a Purview.ZodSharp build reports a ZODSGEN or ZODSASP diagnostic - the [ZodSchema] + generator's configuration and usage rules, the custom-rule mapping rules, and the ErrorType + analyzers - to find what triggered it and how to fix it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp diagnostics + +Two families, both enabled by default: + +- **`ZODSGEN*`** — the `[ZodSchema]` generator's analyzer (category `ZodSharp.SourceGenerator`). +- **`ZODSASP*`** — the `ErrorType` factory's analyzer and generator, which ship in the **core** + package and need no ASP.NET Core dependency. + +`ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` and `ZODSGEN042` are warnings; `ZODSGEN041` and +`ZODSGEN043` are informational; the rest are errors. + +IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never +renumbered or reused, so a sighting of one means stale tooling or a stale suppression. + +## Generator failures and DataAnnotations usage + +| ID | Meaning | +| --- | --- | +| `ZODSGEN001` | Unhandled generator exception. | +| `ZODSGEN003` | `[Length]` with `min > max`. | +| `ZODSGEN004` | `[Length]` on an unsupported target. | +| `ZODSGEN005` | Error-message resource name without type, or type without name. | +| `ZODSGEN006` | Unsupported DataAnnotations usage: a string-only attribute on a non-string, `[AllowedValues]`/`[DeniedValues]` on an unsupported type, `[RegularExpression]` on a non-string, `[Range]` on an unsupported type. | +| `ZODSGEN020` | `[Compare]` references an unknown property. | +| `ZODSGEN021` | `System.ComponentModel.DataAnnotations` reference is missing. | + +## Custom async validation method + +All of these mean the method the generator was told to call cannot be called: + +| ID | Meaning | +| --- | --- | +| `ZODSGEN007` | Configured but not found. | +| `ZODSGEN008` | Return type is not `ValueTask>`. | +| `ZODSGEN009` | Parameter count is not 2. | +| `ZODSGEN010` | First parameter is not the model type. | +| `ZODSGEN011` | Second parameter is not `CancellationToken`. | +| `ZODSGEN012` | The method is generic. | +| `ZODSGEN013` | Declared on the model type but not `static`. | +| `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | +| `ZODSGEN015` | Ambiguous overloads — only when at least two valid candidates exist. | +| `ZODSGEN016` | The configured name is not a valid C# identifier. | +| `ZODSGEN017` | The method is abstract. | +| `ZODSGEN018` | An unimplemented partial method. | +| `ZODSGEN019` | Uses `ref`/`in`/`out`/`params`/`scoped` parameters. | + +## `IValidateOptions` + +| ID | Meaning | +| --- | --- | +| `ZODSGEN027` | Requested, but the `Microsoft.Extensions.Options` reference is missing. | +| `ZODSGEN028` | Requested on a struct — it requires a class. | + +## The refinement hook + +| ID | Meaning | +| --- | --- | +| `ZODSGEN029` | The model declares **both** `OnZodValidate` and an async custom validation method. Exactly one is allowed. | +| `ZODSGEN034` | `OnZodValidate` is on a type that is not `partial`, or whose containing types are not all `partial`, so the generated declaration cannot be emitted. | +| `ZODSGEN035` | Not declared as `partial void OnZodValidate(RefineCtx context)` — wrong modifiers, return type or parameters. | +| `ZODSGEN036` | A member still uses the retired `IEnumerable Validate()` contract, which the generator no longer binds. Implement `OnZodValidate`. | + +## Custom rules and rule attributes + +| ID | Meaning | +| --- | --- | +| `ZODSGEN030` | A rule mapped through `[ZodRule(typeof(…))]` does not implement `IValidationRule` for the property type — including an unbound generic rule that cannot be closed with it. | +| `ZODSGEN031` | A rule constructor parameter could not be mapped from the attribute. | +| `ZODSGEN032` | No validation attribute could be generated for a rule marked `[ZodRule]`. | +| `ZODSGEN033` | *(warning)* A rule-mapped attribute is applied to a type that gets no generated schema — no `[ZodSchema]`, and not reachable as a complex property — so the rule never runs. | +| `ZODSGEN037` | *(warning)* A `[ZodRule]`-derived attribute name (`XRule` → `XAttribute`) is already declared by hand, so no attribute is generated and that declaration's own mapping governs every usage. | +| `ZODSGEN038` | *(warning)* A hand-authored attribute's `[ZodRule(typeof(…))]` mapping does not address every rule declared under the name it encodes (`XAttribute` → `XRule`), so some usages resolve to no rule. | +| `ZODSGEN039` | *(warning)* A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity. | +| `ZODSGEN040` | *(warning)* An attribute argument has no effect: the resolved rule has no matching constructor parameter, and the value is not part of the reported identity. | +| `ZODSGEN042` | *(warning)* A rule does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals. | +| `ZODSGEN043` | *(info)* A built-in rule marked `[ZodRule]` generates no attribute, because a constructor parameter cannot be represented as an attribute property. The rule is still reachable through the runtime/fluent API. A name colliding with DataAnnotations is **not** skipped — it is emitted with a `Zod` suffix. | + +## Unions, value objects, JSON Schema + +| ID | Meaning | +| --- | --- | +| `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | +| `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | +| `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | + +## `ErrorType` — `ZODSASP*` + +These ship with the **core** package, alongside the `ErrorType` factory, the `[ErrorType]` attribute +and the generator that emits `Create`/`Throw` helpers: + +| ID | Severity | Meaning | +| --- | --- | --- | +| `ZODSASP001` | Warning | A `MessageFormat` placeholder is not declared in `ErrorType.Parameters`. | +| `ZODSASP002` | Warning | The containing type of an `[ErrorType]` field is not `partial`. | +| `ZODSASP003` | Warning | An `[ErrorType]` field is not `static readonly`. | +| `ZODSASP100` | Error | Unhandled exception in the `ErrorType` source generator. | +| `ZODSASP101` | Error | The `Parameters` of an `[ErrorType]` field could not be extracted. | + +`ZODSASP001`–`ZODSASP003` explain why `Create`/`Throw` helpers were **not** generated — a silent +absence otherwise. `ZODSASP100`/`ZODSASP101` are fatal generator failures and name the field. + +## Suppressing + +Standard mechanisms: `#pragma warning disable ZODSGEN006`, a `NoWarn` entry, or an `.editorconfig` +severity override. The canonical catalogue is the analyzer's `AnalyzerReleases.Shipped.md` / +`AnalyzerReleases.Unshipped.md` in the generator project — a new or changed rule must be recorded +there in the same change. diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md new file mode 100644 index 0000000..0b325fb --- /dev/null +++ b/skills/purview-zodsharp-json/SKILL.md @@ -0,0 +1,206 @@ +--- +name: purview-zodsharp-json +description: >- + Use when combining Purview.ZodSharp with JSON - deserialize-and-validate and validating converters + for System.Text.Json or Newtonsoft.Json, choosing between the two packages, exporting a schema to + JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, and sharing a contract with + TypeScript Zod. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# JSON and JSON Schema + +Two integration packages, and they are **mutually exclusive in practice** — pick the one matching +your JSON library: + +```bash +dotnet add package Purview.ZodSharp.SystemTextJson +dotnet add package Purview.ZodSharp.NewtonsoftJson +``` + +Each adds deserialize-and-validate, a validating converter, and JSON Schema **import**. JSON Schema +**export** is in the core package. + +Both can be referenced from one project without `extern alias` — the JSON Schema types live in +`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` — but the +deserialize/serialize extension overloads share names, so **import exactly one package namespace per +file**. + +## Deserialize and validate + +```csharp +using ZodSharp; + +var userSchema = Z.Object() + .Field("name", Z.String().Min(3)) + .Field("age", Z.Number().Min(0).Int()) + .Build(); + +var result = userSchema.DeserializeAndValidate("""{ "name": "John", "age": 30 }"""); + +await using var stream = File.OpenRead("user.json"); +var streamed = await userSchema.DeserializeAndValidateAsync(stream); +``` + +Going the other way: + +```csharp +var json = userSchema.ValidateAndSerialize(user); // ValidationResult +await userSchema.ValidateAndSerializeAsync(user, outputStream); +``` + +Failures add `deserialization_failed` and `json_error` codes alongside the schema's own. + +## Validating converters + +```csharp +// System.Text.Json +var options = new JsonSerializerOptions { Converters = { userSchema.CreateValidatingConverter() } }; +var value = JsonSerializer.Deserialize(json, options); + +// Newtonsoft.Json +var value2 = JsonConvert.DeserializeObject(json, userSchema.CreateValidatingConverter()); +``` + +Invalid JSON **throws** — `JsonException` (STJ) or `JsonSerializationException` (Newtonsoft), with a +`"Validation failed: …"` message. Each converter removes itself from the options/serializer it uses +internally, to avoid recursing into itself. + +## Which package differs how + +| Aspect | SystemTextJson | NewtonsoftJson | +| --- | --- | --- | +| Async result | `ValueTask<…>` | `Task<…>` | +| Options parameter | `JsonSerializerOptions` | `JsonSerializerSettings` | +| Converter return | generic `JsonConverter` | non-generic `JsonConverter` | +| `JToken` overload | no | yes | +| Formatting | via options | explicit `Formatting` argument | +| Invalid-data exception | `JsonException` | `JsonSerializationException` | +| Plumbing type | `JsonElement` | `JToken`/`JObject`/`JArray` | + +## JSON Schema export + +`Z.ToJsonSchema` (core package) produces a Draft 2020-12 `JsonSchemaDefinition`: + +```csharp +var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJsonSchemaOptions +{ + Title = "User", + Id = "https://example.com/schemas/user.json", +}); +``` + +`ToJsonSchemaOptions`: `IncludeSchema` (default `true`, emits `$schema`), `Id`, `Title`. + +`JsonSchemaDefinition` (`ZodSharp.JsonSchema`) is a mutable POCO mirroring the keywords — identity +(`$schema`/`$id`/`$ref`), type and title, string/number/array/object constraints, composition +(`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata +keywords including OpenAPI 3.0 `nullable`. + +Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, +`ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodLazy`. Notable +behaviours: + +- objects emit `additionalProperties: false`; +- literals emit `const` plus `type`; +- native enums emit `enum` in their System.Text.Json wire form — numbers by default, member names + when serialized as strings. A `[Flags]` enum emits `type: integer` instead, because a combination is + valid without being a defined member; +- lazy/recursive schemas emit `$ref` entries under `$defs` (`#/$defs/__lazyN`); +- rules that describe their own constraint implement `IJsonSchemaRule` and are applied during export. + +### Exporting a generated `[ZodSchema]` + +No need to rebuild the rules by hand with `Z.Object()`: + +```csharp +var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); +var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); +``` + +The definition comes from the same metadata the generated validator uses, so the two cannot drift. +Things worth knowing: + +- **Property names** use the System.Text.Json wire form: `[JsonPropertyName]` wins, otherwise the name + is camelCased to match `JsonSerializerDefaults.Web`. A non-camelCase global naming policy configured + at runtime is invisible to the generator, so adjust the exported names or declare + `[JsonPropertyName]`. +- **Required** follows the nullable reference-type annotation: a non-nullable reference type (or + `Nullable`) is required; a nullable one is wrapped in `anyOf: [ …, { "type": "null" } ]` and is + not required. `[Required]` also makes a field required, and a rule that rejects an absent value — + `[RequiredZod]` — makes the field non-null even with a nullable annotation. The generated validator + agrees with all of this. +- **Scalar value objects** (`[Scalar]`) export as the **underlying** value's schema, not as an + object wrapping a `Value` property. A `Guid` scalar exports as a canonical 8-4-4-4-12 hexadecimal + `pattern`, not Zod's RFC `uuid` format, because a C# `Guid` accepts any 128-bit value. + +## JSON Schema import + +```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; // or …NewtonsoftJson + +var userSchema = Z.FromJsonSchema(jsonSchemaString); +var result = userSchema.Validate(userData); +``` + +Overloads take a `string` or an already-deserialized `JsonSchemaDefinition`, and both return +`IZodSchema`. + +`Z.FromJsonSchema` is a **C# 14 extension member on `Z`**, so it only exists when a JSON integration +package is referenced. `Z.ToJsonSchema` is a real static member in the core package. + +Mapped keywords: + +- `type` — `string`, `number`, `integer`, `boolean`, `null`, `object`, `array`. +- `enum` → a union of literals (a single member becomes a literal); `const` → a literal. +- `anyOf`/`oneOf` → union; `allOf` → the intersection of every branch, plus the base schema when the + schema also declares a `type`. A type-less branch is `any`, matching Zod's `fromJSONSchema`. +- Strings — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The + `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema + exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves it + on import. Multiple `pattern` constraints compose under `allOf`, and every branch is applied. +- Numbers — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. +- Objects — `required` and optional fields. Arrays — `items`, `minItems`, `maxItems`. + +### Limits + +`$ref` resolves **local** references only (`#/…`, for example `#/$defs/Name`). An external target +throws `NotSupportedException` naming it — inline the referenced schema, or move it under the root +`$defs`, before importing. Local references **may be cyclic**: one still being resolved becomes a lazy +schema. + +The reader binds `$schema`, `$id`, `$ref`, `$defs` and the draft-07 `definitions` through the +integration package's `JsonSchemaSerializerOptions`, and the same options write them back, so exported +definitions round-trip through either package. `JsonSchemaDefinition` itself carries no serializer +annotations — serialize it with `JsonSchemaSerializerOptions` to get keyword-compliant output. + +## Sharing a contract with TypeScript Zod + +```typescript +import { z } from "zod"; +const UserSchema = z.object({ username: z.string().min(3), email: z.string().email() }); +const jsonSchema = z.toJSONSchema(UserSchema); // Zod v4+ +``` + +```csharp +var userSchema = Z.FromJsonSchema(jsonSchemaString); +``` + +This repository also proves agreement by fixture rather than by assertion. `bun run generate-fixtures` +runs `src/ts/generate-fixtures.ts`, writing `src/ts/fixtures/*.json` plus a `manifest.json` in which +**Zod is the authority** on each fixture's validity. `just test` then reads both, asserts the C# +outcome matches, and writes validated C# output to +`src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. Finally `bun run test` (vitest) +re-checks the fixtures under Zod and parses the C# output — closing the loop in both directions. + +If the C# output directory is empty, the vitest suite tells you to run the C# cross-platform tests +first. + +## Related skills + +`purview-zodsharp-core` for the schema API, and `purview-zodsharp-source-generator` for the generated +validators that export through `BuildJsonSchema`. diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md new file mode 100644 index 0000000..53e8322 --- /dev/null +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -0,0 +1,202 @@ +--- +name: purview-zodsharp-source-generator +description: >- + Use when generating a compile-time validator with Purview.ZodSharp's [ZodSchema] attribute - which + types it emits, the attribute options, DataAnnotations coverage, the OnZodValidate refinement hook, + async custom validation, automatic enum validation, IValidateOptions support, and the MSBuild + switches that control it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# The `[ZodSchema]` source generator + +Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted +at compile time — no reflection at runtime. The attribute itself is generated into the `ZodSharp` +namespace by `Purview.ZodSharp.SourceGenerators`, so nothing beyond `Purview.ZodSharp` is needed. + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +[ZodSchema] +public class User +{ + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + + [Range(0, 120)] + public int Age { get; set; } + + [EmailAddress] + public string? Email { get; set; } +} + +var result = UserSchema.Validate(user); +var validated = UserSchema.Parse(user); // throws ZodException on failure +``` + +## What gets emitted + +| Artifact | Shape | +| --- | --- | +| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | +| `{TypeName}SchemaValidator` | `partial class : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — the DI adapter, with `Validate`/`ValidateAsync`/`BuildJsonSchema`. Primary schema only; `{SchemaName}Validator` when renamed. | +| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` — only when `IValidateOptions` support is on, and the target is a class. | +| `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | the marker `IZodSchemaFactory` assembly scanning consumes. Primary, non-nested schemas only. | + +`BuildJsonSchema` is built from the same metadata as `Validate`, so a generated schema exports through +`Z.ToJsonSchema` without rebuilding its rules by hand. + +```csharp +var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); +var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); +var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult, or a minor with consent"); +``` + +## Attribute options + +| Property | Default | Purpose | +| --- | --- | --- | +| `SchemaName` | `null` | Renames `{TypeName}Schema`; the DI adapter becomes `{SchemaName}Validator`. | +| `GenerateValidateMethod` | `true` | `false` omits `Validate` **and everything that depends on it**. | +| `GenerateParseMethod` | `true` | `false` omits `Parse`. | +| `EnableComposition` | `true` | Emits `ApplyAnd`/`ApplyOr`/`ApplyRefine`. | +| `CustomValidationMethodName` | `null` | Names the async custom validation method (default lookup `CustomValidationAsync`). Mutually exclusive with `OnZodValidate`. | +| `GenerateIValidateOptions` | `false` | Forces `IValidateOptions` generation. | +| `SuppressIValidateOptions` | `false` | Opts out even when auto-detection would enable it. | +| `ValidateEnumValues` | `true` | `false` skips automatic enum validation. | +| `GenerateJsonSchema` | `true` | `false` omits `BuildJsonSchema` and `IJsonSchemaProvider`. A type referenced by an exported type is `ZODSGEN045`. | + +`Parse`, the composition methods, the `IZodSchemaValidator` adapter and the `IValidateOptions` +validator all depend on `Validate` — `GenerateValidateMethod = false` removes them together. + +## What is validated + +- Properties must be public, non-static, non-indexer. +- A property gets validation when it carries **any** DataAnnotations attribute, when its type is a + source-defined complex type with a nested schema, or when its type is an enum. +- Nested complex types are discovered recursively and get their own `{TypeName}Schema`, **even + without** their own `[ZodSchema]`. +- Nullable properties are null-guarded before value-set/type validation; a nullable target rejects + `null` with `invalid_type`. +- Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if + requested). + +DataAnnotations coverage, positional-record attribute targets, structured size issues and the +`[Range]` shapes are in [references/dataannotations.md](references/dataannotations.md). + +## The `OnZodValidate` refinement hook + +Rules DataAnnotations cannot express are written as a **generator-declared partial method**. The +generator emits the declaration, so the IDE offers the implementation with the right signature and +nothing is resolved by naming convention: + +```csharp +[ZodSchema] +public partial class Order +{ + public decimal Total { get; set; } + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Total < 0) + context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); + } +} +``` + +- The type **and every containing type** must be `partial` (`ZODSGEN034`). That is the only shape + requirement the hook adds. +- The signature must be `partial void OnZodValidate(RefineCtx context)`, `T` being the model + (`ZODSGEN035`). The parameter is a plain by-value `RefineCtx`. +- The hook runs for **every** entry point — `Validate`, `Parse`, the `IZodSchemaValidator` adapter, + `IValidateOptions`, any factory that validates through the schema — so a rule written here behaves + exactly like an attribute rule. +- A type that does not implement it allocates nothing: the generated `Validate` only builds a + `RefineCtx` and calls the hook when a body exists. +- The retired `IEnumerable Validate()` contract is no longer bound, and is reported + as `ZODSGEN036`. + +Alongside the hook the generator emits one `internal static` +`InvokeZodRefinementHook(T value, RefineCtx context)` bridge on the target type. A classic partial +method is private and `{Type}Schema` is a different type, so the bridge is what lets `Validate` reach +the hook while keeping the hook optional. **It is not part of your API; never implement it by hand.** + +## Async custom validation + +Mutually exclusive with `OnZodValidate` — declaring both is `ZODSGEN029`. + +```csharp +public partial class UserSchemaValidator +{ + public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) + { + await Task.Delay(1, ct); + return ValidationResult.Success(value); + } +} +``` + +Signature `ValueTask> Name(T value, CancellationToken ct)`. The default lookup +name is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the +**model type** must be `static`; one on the generated `{TypeName}SchemaValidator` partial may be an +instance method. The generated `ValidateAsync` runs the synchronous `Validate`, awaits the custom +method, and merges the error sets. + +## Automatic enum validation + +Every non-`[Flags]` enum property is validated automatically: a value that is not a defined member is +rejected with `invalid_enum_value`, emitted as an `EnumRule`. + +```csharp +public enum ExampleEnum +{ + [ZodIgnore] // never a valid value, for every property of this type + Unspecified, + AValidValue, + AnotherValidValue, +} + +[ZodSchema] +public class Model +{ + public ExampleEnum Status { get; set; } + + [DeniedValues(ExampleEnum.AnotherValidValue)] // excluded for this property only + public ExampleEnum SecondaryStatus { get; set; } +} +``` + +It is skipped when the enum is `[Flags]` (a combination is valid without being a defined member), +when the property declares an explicit `[AllowedValues]` allow-list (which governs instead), or with +`[ZodSchema(ValidateEnumValues = false)]`. A nullable enum is validated only when not `null`. + +## `IValidateOptions` support + +Enabled by `GenerateIValidateOptions = true`, by auto-detection (unset, target is not a value type, +and the type name ends with a configured suffix — `Options` or `Settings` by default), or by MSBuild. + +| MSBuild property | Default | Behaviour | +| --- | --- | --- | +| `DisableZodSharpSourceGenerator` | unset | truthy disables the generator entirely | +| `ZodSharpAutoGenerateOptionsValidators` | `true` | only an explicit `false` disables auto-detection | +| `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated | +| `ZodSharpGenerateJsonSchemas` | `true` | explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` assembly-wide | +| `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | +| `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | + +## Value objects + +A `Purview.ValueObjects` scalar marked `[Scalar]` can carry `[ZodSchema]` on the same type. The +generated validator validates the scalar **as a unit** and reports an empty path, and a rule written +against the underlying value is adapted automatically. + +## Related skills + +`purview-zodsharp-custom-rules` for `[ZodRule]` and the built-in rule attributes, +`purview-zodsharp-diagnostics` for the `ZODSGEN` catalogue, and `purview-zodsharp-core` for the +runtime API the generated code reports through. diff --git a/skills/purview-zodsharp-source-generator/references/dataannotations.md b/skills/purview-zodsharp-source-generator/references/dataannotations.md new file mode 100644 index 0000000..30e92ec --- /dev/null +++ b/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -0,0 +1,137 @@ +# DataAnnotations coverage + +The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attributes and emits +direct, typed codegen. Nothing is reflected at runtime. + +## Supported attributes + +| Attribute | Generated behaviour | Failure code | +| --- | --- | --- | +| `[Required]` | nullable property must not be null; a string with `AllowEmptyStrings=false` must be non-empty | `missing_field` | +| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `too_small` / `too_big` | +| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `too_small` / `too_big` | +| `[MinLength(n)]` | checked only when `n > 0` | `too_small` | +| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` | +| `[Range(…)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | +| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | +| `[AllowedValues(…)]` | typed equality against the allowed set | `invalid_value` | +| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | +| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | +| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` | +| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | +| `[Display(Name=…)]` | not validated; `Name` is the display name in messages and `{0}` placeholders | — | + +`[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also +present. + +`[RequiredZod]` — the generated `RequiredRule` attribute — mirrors `[Required]`: it is emitted +*outside* the generator's non-null guard, so an absent value is reported as `missing_field` even +though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` has the +same meaning as `RequiredAttribute.AllowEmptyStrings`, and it adds `TrimWhitespace`, so it is a +drop-in replacement. + +## Positional records + +A positional record declares its properties from the primary-constructor parameter list, and the C# +compiler applies an attribute valid on both a parameter and a property **to the parameter only**. The +generator therefore reads attributes on the synthesized property *and* on the primary-constructor +parameter: + +```csharp +[ZodSchema] +public sealed record RepositoryInventorySummary( + [NonSentinel] string Name, + [NonSentinel] string CanonicalReference, + [NullOrNonWhiteSpace] string? DefaultBranch, + [NonSentinel] DateTimeOffset LastObservedAt +); +``` + +`[property: NonSentinel]` is equally honoured. When both targets carry the same attribute type the +property target wins and the rule runs **once**. Declared properties, classes and record structs are +unaffected. + +## Size validators and structured issues + +Size attributes generate direct `Length`/`Count` access where possible: + +- `string` → `.Length` +- arrays, including rectangular → `.Length` +- jagged arrays → outer-array `.Length` +- countable collections → `.Count` +- `IEnumerable` / `IEnumerable` → one counted pass via `CollectionCountHelper.GetCount`, with fast + paths for `ICollection`, `IReadOnlyCollection` and non-generic `ICollection` + +A structured size failure carries the same metadata as the runtime API: `Code` (`too_small` / +`too_big`), `Origin` (`string` for strings, `array` for arrays, `collection` for countable and +`IEnumerable` collections), `Minimum`/`Maximum` (the inclusive bound), `Inclusive` (`true`) and +`Path`. + +```csharp +[ZodSchema] +public sealed class Basket +{ + [Required] + [Length(2, 5)] + public List? Items { get; set; } +} + +var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); +// Code == "too_small", Minimum == 2, Origin == "array", Inclusive == true +``` + +## `[Range]` + +Three constructor shapes, plus `MinimumIsExclusive`, `MaximumIsExclusive`, +`ConvertValueInInvariantCulture` and `ParseLimitsInInvariantCulture`: + +- `[Range(int, int)]` and `[Range(double, double)]` — literal numeric bounds. +- `[Range(typeof(T), "min", "max")]` — parsed bounds for numeric and comparable types. + +Comparable targets include `TimeSpan`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and +`Version` (which uses `CompareTo`), plus any `IComparable` with user-defined comparison operators. +Bounds are emitted as static typed fields and compared without running the attribute at runtime. + +## Error messages + +`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, are honoured, with `{0}` +(display name), `{1}` and `{2}` (bound) formatted through +`string.Format(CultureInfo.CurrentCulture, …)`. Supplying only one half of the resource name/type +pair is `ZODSGEN005`. + +## Applicability is a compile error, not a silent skip + +- `[Length]` with `min > max` → `ZODSGEN003`. +- `[Length]` on an unsupported target, such as `decimal` → `ZODSGEN004`. +- String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, + `[CreditCard]`, `[Base64String]`) on a non-string, `[AllowedValues]`/`[DeniedValues]` on an + unsupported type, or `[Range]` on an unsupported type → `ZODSGEN006`. +- `[Compare]` naming an unknown property → `ZODSGEN020`. + +## Built-in rule attributes + +Every built-in rule ships a generated attribute in the `ZodSharp.Rules` namespace, usable alongside +the DataAnnotations above: + +`[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, +`[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, +`[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, +`[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, +`[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, +`[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, +`[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`. + +A value the rule declares **without a default** becomes a required constructor argument — +`[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while +defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors +mirrors each overload, so `[Uuid]` is the versionless rule and `[UUID(UuidVersion.V4)]` the versioned +one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type, because a `Guid` *is* a +UUID — which is how a time-ordered identifier is constrained to an RFC version. + +Names colliding with a DataAnnotations attribute take a `Zod` suffix: `[MinLengthZod]`, +`[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, +`[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, +`[DeniedValuesZod]`. + +A custom rule reaches the same pipeline through `[ZodRule(typeof(MyRule))]` on an attribute, or +`[ZodRule]` on the rule to have the attribute generated. diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..fdf37cf 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,20 @@ + + + + true + 2.0.2 + diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index e5db138..35f048c 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -19,6 +19,7 @@ + diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index 1b368a2..c041bba 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -68,6 +68,42 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con ); } + // Convert the base schema (type and its keywords, enum, const, $ref) first, then layer the + // composition keywords on top. An explicit type makes the composition an intersection with the base; + // without one the composition stands alone. This mirrors Zod's fromJSONSchema, and is what lets an + // `allOf` of pattern branches (for example a Guid plus a UUID-version constraint) keep every + // constraint instead of taking only the first branch. + var baseSchema = ConvertBaseSchema(schema, ctx); + var hasExplicitType = schema.Type != null || schema.Enum is { Count: > 0 } || schema.Const != null; + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + ZodUnion union = new(anyOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.OneOf is { Count: > 0 } oneOf) + { + // oneOf is semantically different (exactly one must match) but we approximate with union + ZodUnion union = new(oneOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + var result = hasExplicitType ? baseSchema : ConvertSchema(allOf[0], ctx); + var start = hasExplicitType ? 0 : 1; + for (var i = start; i < allOf.Count; i++) + result = Z.Intersection(result, ConvertSchema(allOf[i], ctx)); + + baseSchema = result; + } + + return baseSchema; + } + + static IZodSchema ConvertBaseSchema(JsonSchemaDefinition schema, ConversionContext ctx) + { // Handle $ref if (schema.Ref != null) { @@ -93,27 +129,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return CreateLiteralSchema(schema.Const); } - // Handle composition keywords - if (schema.AnyOf != null && schema.AnyOf.Count > 0) - { - var options = schema.AnyOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.OneOf != null && schema.OneOf.Count > 0) - { - // oneOf is semantically different (exactly one must match) but we approximate with union - var options = schema.OneOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.AllOf != null && schema.AllOf.Count > 0) - { - // allOf requires all to match - we can only fully support this for objects - // For now, just use the first schema - return ConvertSchema(schema.AllOf[0], ctx); - } - // Handle type if (schema.Type == null) { @@ -121,7 +136,7 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return new AnySchema(); } - var result = schema.Type switch + return schema.Type switch { "string" => ConvertStringSchema(schema), "number" => ConvertNumberSchema(schema, isInteger: false), @@ -132,8 +147,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con "array" => ConvertArraySchema(schema, ctx), _ => new AnySchema(), }; - - return result; } [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs index 261dcce..0791df4 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs @@ -15,6 +15,7 @@ sealed class JsonSchemaNamingStrategy : CamelCaseNamingStrategy ["Id"] = "$id", ["Ref"] = "$ref", ["Defs"] = "$defs", + ["UnsupportedRules"] = "x-zodsharp-unsupported", }; public override string GetPropertyName(string name, bool hasSpecifiedName) => diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index 8f41bac..88b2aeb 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -61,3 +61,11 @@ | ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | | ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | | ZODSGEN044 | ZodSharp.SourceGenerator | Error | A [ZodSchema] type that uses an automatic [Scalar] form ([Scalar] or [Scalar(typeof(TValue))]) cannot produce a usable schema, because the generated Validate method is suppressed or the underlying type cannot be represented | + +## Release 2.1.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSGEN045 | ZodSharp.SourceGenerator | Warning | A type that exports JSON Schema references a nested [ZodSchema] type whose export is disabled ([ZodSchema(GenerateJsonSchema = false)] or the assembly-level ZodSharpGenerateJsonSchemas=false switch), so the parent's exported schema falls back to an empty schema for that property | diff --git a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs index 24ead81..cb5c286 100644 --- a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs +++ b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs @@ -1,4 +1,3 @@ -#pragma warning disable CS1591 using System.ComponentModel; namespace Purview.SourceGeneratorFramework; diff --git a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs index df2bbaa..3b98a46 100644 --- a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs +++ b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/Helpers/TypeHelpersExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.ComponentModel; using System.Text; using Microsoft.CodeAnalysis; @@ -84,18 +85,35 @@ public static bool HasZodSchemaAttribute(ITypeSymbol typeSymbol) return namedType.GetAttributes().Any(a => TypeLibrary.ZodSharp.ZodSchemaAttribute.Equals(a.AttributeClass)); } - public static bool HasDataAnnotationAttribute(IPropertySymbol propertySymbol) + public static bool HasDataAnnotationAttribute(IPropertySymbol propertySymbol) => + HasDataAnnotationAttribute(propertySymbol.GetAttributes()); + + /// + /// Determines whether any of derives from the DataAnnotations + /// ValidationAttribute. The attributes are supplied by the caller rather than read from a symbol + /// so a positional record property can contribute the attributes of the primary-constructor parameter it + /// is synthesized from, which the compiler does not copy onto the property. + /// + /// The attributes to inspect. + /// when an attribute derives from ValidationAttribute. + public static bool HasDataAnnotationAttribute(ImmutableArray attributes) { - // Check if the property has the DataAnnotation attribute - return propertySymbol - .GetAttributes() - .Any(a => - a.AttributeClass is not null + // Check if any attribute derives from the DataAnnotation attribute + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is not null && TypeHelpers.InheritsFrom( - a.AttributeClass, + attribute.AttributeClass, TypeLibrary.System.ComponentModel.DataAnnotations.ValidationAttribute ) - ); + ) + { + return true; + } + } + + return false; } #pragma warning disable format diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index 4d81e3b..67c7703 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -161,6 +161,26 @@ static SourceText ZodSchemaAttribute() Initializer = "true", } ); + + body.XmlSummary( + "Whether to generate the JSON Schema export members for this type: the static", + "BuildJsonSchema method and the IJsonSchemaProvider implementation on the", + "generated validator adapter. Set to false to keep a validation-only type out of the", + "cross-platform (TypeScript/JavaScript) contract surface. Default is true.", + "A type whose export is disabled cannot be referenced by an exported type; the generator", + "falls back to an empty schema and the analyzer reports ZODSGEN045." + ) + .Property( + new( + nameof(ZodSchemaAttributeData.GenerateJsonSchema), + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + Initializer = "true", + } + ); } ); diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index 32d0340..bbb9b0f 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -32,24 +32,61 @@ static class CustomRuleResolver /// /// The symbol whose rule attributes are resolved. /// The type the rules are applied to. + /// + /// Whether the rules are applied to itself rather than to a member. A + /// type-level rule on a scalar reports an empty path, so its default message names the scalar. + /// /// The resolved rule descriptors. /// /// Used by the generator, which only needs the descriptors: the same resolution runs in /// ZodSchemaAnalyzer, which reports the diagnostics, so a rule that resolves to nothing is still /// visible in the build. /// - public static EquatableArray Resolve(ISymbol symbol, ITypeSymbol ruleTargetType) => - Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder()); + public static EquatableArray Resolve( + ISymbol symbol, + ITypeSymbol ruleTargetType, + bool isTypeLevel = false + ) => Resolve(symbol, ruleTargetType, ImmutableArray.CreateBuilder(), isTypeLevel); public static EquatableArray Resolve( ISymbol symbol, ITypeSymbol ruleTargetType, - ImmutableArray.Builder diagnostics + ImmutableArray.Builder diagnostics, + bool isTypeLevel = false + ) => Resolve(symbol.GetAttributes(), ruleTargetType, diagnostics, isTypeLevel); + + /// + /// Resolves the custom rules declared by . The attributes are supplied by the + /// caller rather than read from a symbol so a positional record property can contribute the attributes of + /// the primary-constructor parameter it is synthesized from, which the compiler does not copy onto the + /// property when the attribute is valid on both targets. + /// + /// The validation attributes to resolve. + /// The type the rules are applied to. + /// The diagnostics a failed resolution reports into. + /// + /// Whether the rules are applied to itself rather than to a member. A + /// type-level rule on a scalar reports an empty path, so its default message names the scalar. + /// + /// The resolved rule descriptors. + public static EquatableArray Resolve( + ImmutableArray attributes, + ITypeSymbol ruleTargetType, + ImmutableArray.Builder diagnostics, + bool isTypeLevel = false ) { ImmutableArray.Builder? builder = null; - foreach (var attribute in symbol.GetAttributes()) + // A rule applied at the type level of a scalar value object reports an empty path, so the generic + // subject word in its message ("Value") is replaced with the scalar's name. The substitution only + // applies when the attribute supplies no message of its own. + var scalarSubject = + isTypeLevel && ruleTargetType is INamedTypeSymbol owner && TryGetScalarAttribute(owner, out _) + ? owner.Name + : null; + + foreach (var attribute in attributes) { if (attribute.AttributeClass is not INamedTypeSymbol attributeClass) continue; @@ -102,7 +139,22 @@ out var scalarValue continue; } - if (!TryBuildArguments(attribute, ruleType, out var arguments, out var unmappedParameterName)) + // The subject-substituted default message, used only when the attribute supplies no message of its + // own. It is derived from the resolved rule's own MessageFormat so the wording stays the rule's. + var subjectMessage = + scalarSubject is not null && GetRuleMessageFormat(ruleType) is { } messageFormat + ? SubstituteSubject(messageFormat, scalarSubject) + : null; + + if ( + !TryBuildArguments( + attribute, + ruleType, + subjectMessage, + out var arguments, + out var unmappedParameterName + ) + ) { diagnostics.Add( ReportableDiagnostic.Create( @@ -372,7 +424,7 @@ internal static bool TryGetScalarAttribute(INamedTypeSymbol type, out ScalarAttr /// The type to inspect. /// The scalar's underlying value type, when it is a scalar. /// when is a scalar value object. - static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) + internal static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueType) { valueType = null!; @@ -989,6 +1041,59 @@ or SpecialType.System_Object return null; } + /// + /// Reads a rule's public const string MessageFormat, walking base types so a rule that inherits + /// its error identity (for example from a shared abstract base) is still described. + /// + /// The resolved rule type. + /// The constant's value, or when no such constant exists. + static string? GetRuleMessageFormat(INamedTypeSymbol ruleType) + { + for (var current = (INamedTypeSymbol?)ruleType; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers("MessageFormat")) + { + if (member is IFieldSymbol { IsConst: true, ConstantValue: string value }) + return value; + } + } + + return null; + } + + /// + /// Replaces the first whole-word subject token (Value) in a rule's message format with + /// , so a type-level rule on a scalar names the scalar instead of the generic + /// subject. A format that does not reference the subject token is returned unchanged. + /// + /// The rule's message format. + /// The name to substitute for the subject token. + /// The message format with the subject token replaced. + static string SubstituteSubject(string messageFormat, string subject) + { + const string token = "Value"; + + for (var index = 0; index <= messageFormat.Length - token.Length; index++) + { + if (string.CompareOrdinal(messageFormat, index, token, 0, token.Length) != 0) + continue; + + var afterIndex = index + token.Length; + var before = index == 0 ? '\0' : messageFormat[index - 1]; + var after = afterIndex >= messageFormat.Length ? '\0' : messageFormat[afterIndex]; + + // Only replace a whole word, so a format that mentions the subject inside another word is left alone. + if (IsWordCharacter(before) || IsWordCharacter(after)) + continue; + + return messageFormat.Substring(0, index) + subject + messageFormat.Substring(afterIndex); + } + + return messageFormat; + } + + static bool IsWordCharacter(char value) => char.IsLetterOrDigit(value) || value == '_'; + static bool TryGetRuleMapping(INamedTypeSymbol attributeClass, out RuleMapping mapping) { for (var current = (INamedTypeSymbol?)attributeClass; current is not null; current = current.BaseType) @@ -1069,6 +1174,10 @@ static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyTyp /// /// The applied attribute. /// The resolved rule type. + /// + /// The subject-substituted default message for a type-level rule on a scalar, or . + /// It is used only when the attribute supplies no message of its own. + /// /// The argument expressions, in the selected constructor's parameter order. /// /// The parameter that could not be mapped when no overload could be satisfied. @@ -1077,6 +1186,7 @@ static bool ImplementsRuleFor(INamedTypeSymbol ruleType, ITypeSymbol propertyTyp static bool TryBuildArguments( AttributeData attribute, INamedTypeSymbol ruleType, + string? subjectMessage, out EquatableArray arguments, out string? unmappedParameterName ) @@ -1095,7 +1205,7 @@ out string? unmappedParameterName if (!ConsumesSuppliedArguments(attribute, candidate)) continue; - if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + if (TryBuildArgumentsFor(attribute, candidate, subjectMessage, out arguments, out _)) return true; } @@ -1103,12 +1213,12 @@ out string? unmappedParameterName // their parameters differently from the rule's. foreach (var candidate in candidates) { - if (TryBuildArgumentsFor(attribute, candidate, out arguments, out _)) + if (TryBuildArgumentsFor(attribute, candidate, subjectMessage, out arguments, out _)) return true; } // Report the failure against the widest overload for a useful message. - TryBuildArgumentsFor(attribute, candidates[0], out _, out unmappedParameterName); + TryBuildArgumentsFor(attribute, candidates[0], subjectMessage, out _, out unmappedParameterName); return false; } @@ -1172,6 +1282,7 @@ static bool IsIdentityOrValidationName(string name) => static bool TryBuildArgumentsFor( AttributeData attribute, IMethodSymbol constructor, + string? subjectMessage, out EquatableArray arguments, out string? unmappedParameterName ) @@ -1248,6 +1359,15 @@ out string? unmappedParameterName continue; } + // A type-level rule on a scalar reports an empty path, so its default message names the scalar + // instead of the generic subject. An explicit message (named/positional/ErrorMessage) has already + // been consumed above, so this only replaces the rule's own default. + if (subjectMessage is not null && IsMessageParameter(parameter)) + { + expressions.Add(subjectMessage.StringLiteral()); + continue; + } + if (parameter.HasExplicitDefaultValue) { if (!TryConvertValue(parameter.ExplicitDefaultValue, parameter.Type, out var defaultExpression)) diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index a93d3d0..d383645 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -451,4 +451,19 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// + /// ZODSGEN045: a type that exports JSON Schema references a nested [ZodSchema] type whose export + /// is disabled ([ZodSchema(GenerateJsonSchema = false)], or the assembly-level + /// ZodSharpGenerateJsonSchemas=false switch). The nested type's schema cannot be referenced, so + /// the parent's exported schema falls back to an empty schema for that property. + /// + public static readonly DiagnosticDescriptor NestedSchemaExportDisabled = new( + id: "ZODSGEN045", + title: "Nested type has JSON Schema export disabled", + messageFormat: "Property '{0}' references type '{1}', which disables JSON Schema generation; the exported schema cannot describe it", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs new file mode 100644 index 0000000..235833c --- /dev/null +++ b/src/src/SourceGenerators/Helpers/EnumJsonResolver.cs @@ -0,0 +1,169 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves how an enum is serialized (numbers or strings) and, for strings, the JSON name of each member. +/// The generator uses this to make the exported JSON Schema match the System.Text.Json wire form instead of +/// always assuming the numeric default. +/// +static class EnumJsonResolver +{ + /// + /// Determines whether a property's enum is serialized as a string, from a per-type or per-property + /// [JsonConverter(typeof(JsonStringEnumConverter))] (or its generic form). The assembly-level + /// convention is applied separately, because a global converter is not visible to the generator. + /// + /// The enum type. + /// The property that references it. + /// when the enum is serialized as a string. + public static bool IsSerializedAsString(INamedTypeSymbol enumType, IPropertySymbol property) => + HasStringEnumConverter(enumType) || HasStringEnumConverter(property); + + static bool HasStringEnumConverter(ISymbol symbol) + { + foreach (var attribute in symbol.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonConverterAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not ITypeSymbol converterType + ) + { + continue; + } + + // Both JsonStringEnumConverter and JsonStringEnumConverter share this name (the generic + // form carries an arity but the same metadata name). + if ( + converterType.Name == "JsonStringEnumConverter" + && converterType.ContainingNamespace.ToDisplayString() == "System.Text.Json.Serialization" + ) + { + return true; + } + } + + return false; + } + + /// + /// Gets the JSON names of a non-flags enum's allowed members, in declaration order. A member marked + /// [ZodIgnore] or listed by the property's [DeniedValues] is excluded; the name is the + /// member's [JsonStringEnumMemberName] value when declared, otherwise the member name. + /// + /// The enum type. + /// The property's DataAnnotations. + /// The allowed members' JSON names. + public static EquatableArray GetJsonNames( + INamedTypeSymbol enumType, + ImmutableArray validationAttributes + ) + { + var denied = DeniedValuesAttributeData.FromAttributeData(validationAttributes); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var member in enumType.GetMembers()) + { + if (member is not IFieldSymbol { HasConstantValue: true } field) + continue; + + if ( + field + .GetAttributes() + .Any(attribute => TypeLibrary.ZodSharp.ZodIgnoreAttribute.Equals(attribute.AttributeClass)) + ) + { + continue; + } + + if (denied.Exists && IsDenied(field, denied.Values)) + continue; + + builder.Add(GetJsonStringEnumMemberName(field) ?? field.Name); + } + + return new(builder.ToImmutable()); + } + + static bool IsDenied(IFieldSymbol field, EquatableArray deniedValues) + { + for (var i = 0; i < deniedValues.Count; i++) + { + var value = deniedValues[i]; + if (!value.IsNull && Equals(value.Value, field.ConstantValue)) + return true; + } + + return false; + } + + static string? GetJsonStringEnumMemberName(IFieldSymbol field) + { + foreach (var attribute in field.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonStringEnumMemberNameAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + ) + { + continue; + } + + if (attribute.ConstructorArguments[0].Value is string { Length: > 0 } name) + return name; + } + + return null; + } + + /// + /// Applies the assembly-level enum naming policy to a member name, matching + /// System.Text.Json's JsonNamingPolicy.CamelCase. + /// + /// The member name. + /// The naming policy. + /// The transformed name. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public static string ApplyNamingPolicy(string name, EnumJsonNamingPolicy policy) => + policy switch + { + EnumJsonNamingPolicy.CamelCase => ToCamelCase(name), + _ => name, + }; + + /// + /// Mirrors System.Text.Json.JsonNamingPolicy.CamelCase: the leading run of uppercase characters is + /// lowercased, except the last one when it is followed by a lowercase character (so IOStream + /// becomes ioStream). + /// + static string ToCamelCase(string name) + { + if (name.Length == 0 || !char.IsUpper(name[0])) + return name; + + var chars = name.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (i == 1 && !char.IsUpper(chars[i])) + break; + + var hasNext = i + 1 < chars.Length; + if (i > 0 && hasNext && !char.IsUpper(chars[i + 1])) + { + if (chars[i + 1] == ' ') + chars[i] = char.ToLowerInvariant(chars[i]); + break; + } + + chars[i] = char.ToLowerInvariant(chars[i]); + } + + return new string(chars); + } +} diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs new file mode 100644 index 0000000..2f9047e --- /dev/null +++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Reads the assembly-level MSBuild switches that shape generation (ZodSharpAutoGenerateOptionsValidators, +/// ZodSharpGenerateJsonSchemas, ZodSharpEnumSerialization, ZodSharpEnumNamingPolicy) and +/// applies them to the collected schema descriptors. +/// +static class GenerationSettingsResolver +{ + internal readonly record struct AutoDetectSettings( + bool AutoGenerate, + EquatableArray Suffixes, + bool GenerateJsonSchemas, + bool EnumAsString, + EnumJsonNamingPolicy EnumNamingPolicy + ); + + public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider) + { + // Auto-detection is on by default; only an explicit "false" disables it. + var autoGenerate = + !provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsProperty}", + out var autoGenerateValue + ) + || !bool.TryParse(autoGenerateValue, out var autoGenerateEnabled) + || autoGenerateEnabled; + + var suffixes = ParseAutoGenerateIValidateOptionsSuffixes( + provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty}", + out var suffixValue + ) + ? suffixValue + : null + ); + + // JSON Schema export is on by default; the assembly-level switch disables it for every type. + var generateJsonSchemas = + !provider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + out var generateJsonSchemasValue + ) + || !bool.TryParse(generateJsonSchemasValue, out var generateJsonSchemasEnabled) + || generateJsonSchemasEnabled; + + // Enums serialise as numbers by default; the assembly-level switch matches a global + // JsonStringEnumConverter, and the naming policy matches its JsonNamingPolicy. + var enumAsString = ReadEnumSerializationAsString(provider); + var enumNamingPolicy = ReadEnumNamingPolicy(provider); + + return new(autoGenerate, suffixes, generateJsonSchemas, enumAsString, enumNamingPolicy); + } + + static bool ReadEnumSerializationAsString(AnalyzerConfigOptionsProvider provider) => + provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumSerializationProperty}", out var value) + && string.Equals(value?.Trim(), "String", StringComparison.OrdinalIgnoreCase); + + static EnumJsonNamingPolicy ReadEnumNamingPolicy(AnalyzerConfigOptionsProvider provider) => + provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumNamingPolicyProperty}", out var value) + && string.Equals(value?.Trim(), "CamelCase", StringComparison.OrdinalIgnoreCase) + ? EnumJsonNamingPolicy.CamelCase + : EnumJsonNamingPolicy.Unchanged; + + static EquatableArray ParseAutoGenerateIValidateOptionsSuffixes(string? value) + { + var raw = string.IsNullOrWhiteSpace(value) ? "Options;Settings" : value!; + var builder = ImmutableArray.CreateBuilder(); + foreach (var segment in raw.Split(';', ',')) + { + var suffix = segment.Trim(); + if (suffix.Length > 0) + builder.Add(suffix); + } + + return new(builder.ToImmutable()); + } + + public static SchemaGenerationModel Apply((SchemaGenerationModel Model, AutoDetectSettings Settings) input) + { + var (model, settings) = input; + + var builder = ImmutableArray.CreateBuilder>(model.ZodSchemas.Count); + foreach (var result in model.ZodSchemas) + { + if (!result.ShouldProcess) + { + builder.Add(result); + continue; + } + + var schema = result.Value; + var changed = false; + + // The assembly-level switch disables JSON Schema export for every type in the compilation. + if (!settings.GenerateJsonSchemas && schema.GenerateJsonSchema) + { + schema = schema with { GenerateJsonSchema = false }; + changed = true; + } + + // The assembly-level enum convention applies to every enum in the compilation (a global + // JsonStringEnumConverter / naming policy is not visible to the generator). + if (settings.EnumAsString != schema.EnumAsString) + { + schema = schema with { EnumAsString = settings.EnumAsString }; + changed = true; + } + + if (settings.EnumNamingPolicy != schema.EnumNamingPolicy) + { + schema = schema with { EnumNamingPolicy = settings.EnumNamingPolicy }; + changed = true; + } + + if ( + settings.AutoGenerate + && settings.Suffixes.Count > 0 + && schema.GenerateIValidateOptions is null + && schema.IsPrimary + && !schema.IsValueType + && settings.Suffixes.Any(suffix => schema.TargetType.Name.EndsWith(suffix, StringComparison.Ordinal)) + ) + { + schema = schema with { GenerateIValidateOptions = true }; + changed = true; + } + + builder.Add(changed ? GeneratorResult.Create(schema) : result); + } + + return model with + { + ZodSchemas = new(builder.ToImmutable()), + }; + } +} diff --git a/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs b/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs new file mode 100644 index 0000000..a8993e9 --- /dev/null +++ b/src/src/SourceGenerators/Helpers/PropertyAttributeResolver.cs @@ -0,0 +1,94 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves the validation attributes that apply to a property: the attributes on the property itself plus, +/// when the property is synthesized from a positional record parameter, the attributes on that parameter. +/// +/// +/// +/// The C# compiler applies an attribute that is valid on both a parameter and a property to the parameter +/// only, so an attribute written on a positional record parameter without a property: target is not +/// present on the synthesized property. Reading the property alone would silently drop the rule, so the +/// parameter's attributes are merged in. +/// +/// +/// A property attribute of the same type wins: it is the more specific target for property validation, and +/// honoring both would emit the rule twice. +/// +/// +static class PropertyAttributeResolver +{ + /// + /// Gets the validation attributes that apply to , in declaration order. + /// + /// The property the schema validates. + /// The compilation being generated, used to bind the declaring parameter. + /// The effective validation attributes. + public static ImmutableArray Resolve(IPropertySymbol property, Compilation compilation) + { + var attributes = property.GetAttributes(); + var parameter = GetPositionalRecordParameter(property, compilation); + if (parameter is null) + return attributes; + + var parameterAttributes = parameter.GetAttributes(); + if (parameterAttributes.IsEmpty) + return attributes; + + var merged = ImmutableArray.CreateBuilder(attributes.Length + parameterAttributes.Length); + merged.AddRange(attributes); + + foreach (var parameterAttribute in parameterAttributes) + { + var alreadyPresent = false; + foreach (var attribute in attributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, parameterAttribute.AttributeClass)) + { + alreadyPresent = true; + break; + } + } + + if (!alreadyPresent) + merged.Add(parameterAttribute); + } + + return merged.ToImmutable(); + } + + /// + /// Gets the primary-constructor parameter a positional record property is synthesized from. + /// + /// The property to inspect. + /// The compilation being generated, used to bind the declaring parameter. + /// The declaring parameter, or for a declared property. + /// + /// A positional record property's declaring syntax is the parameter itself; a declared property's is its + /// own declaration, so this returns for every non-positional property. + /// + static IParameterSymbol? GetPositionalRecordParameter(IPropertySymbol property, Compilation compilation) + { + foreach (var reference in property.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is not ParameterSyntax parameterSyntax) + continue; + + if ( + compilation.GetSemanticModel(parameterSyntax.SyntaxTree).GetDeclaredSymbol(parameterSyntax) + is not IParameterSymbol parameter + ) + { + continue; + } + + return parameter; + } + + return null; + } +} diff --git a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs index 05391cb..e5ca96b 100644 --- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs @@ -9,4 +9,10 @@ static class PropertyLibrary public const string AutoGenerateIValidateOptionsProperty = "ZodSharpAutoGenerateOptionsValidators"; public const string AutoGenerateIValidateOptionsSuffixesProperty = "ZodSharpAutoGenerateOptionsValidatorSuffixes"; + + public const string GenerateJsonSchemasProperty = "ZodSharpGenerateJsonSchemas"; + + public const string EnumSerializationProperty = "ZodSharpEnumSerialization"; + + public const string EnumNamingPolicyProperty = "ZodSharpEnumNamingPolicy"; } diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 36e873d..7abb0dc 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -2,12 +2,16 @@ using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; using ZodSharp.SourceGenerators.Models; using ZodSharp.SourceGenerators.Models.DataAttributes; namespace ZodSharp.SourceGenerators.Helpers; +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "Cohesive helper library for the ZodSchema generator: the schema model, the DataAnnotations descriptors and the rule resolver are all in scope by design. The incremental stages that only configure generation live in GenerationSettingsResolver, and the enum/JSON wire metadata in EnumJsonResolver." +)] static partial class SourceGenLibrary { const string IValidateOptionsMetadataName = "Microsoft.Extensions.Options.IValidateOptions`1"; @@ -43,7 +47,7 @@ IncrementalGeneratorInitializationContext context GetSchemasForGeneration(attributeContext, cancellationToken) ); var autoDetectSettings = context.AnalyzerConfigOptionsProvider.Select( - static (provider, _) => ReadAutoDetectSettings(provider) + static (provider, _) => GenerationSettingsResolver.Read(provider) ); return generationContext @@ -54,87 +58,7 @@ IncrementalGeneratorInitializationContext context "CollectZodSchemas" ) .Combine(autoDetectSettings) - .Select(static (pair, _) => ApplyIValidateOptionsAutoDetection(pair)); - } - - readonly record struct AutoDetectSettings(bool AutoGenerate, EquatableArray Suffixes); - - static AutoDetectSettings ReadAutoDetectSettings(AnalyzerConfigOptionsProvider provider) - { - // Auto-detection is on by default; only an explicit "false" disables it. - var autoGenerate = - !provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsProperty}", - out var autoGenerateValue - ) - || !bool.TryParse(autoGenerateValue, out var autoGenerateEnabled) - || autoGenerateEnabled; - - var suffixes = ParseAutoGenerateIValidateOptionsSuffixes( - provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty}", - out var suffixValue - ) - ? suffixValue - : null - ); - - return new(autoGenerate, suffixes); - } - - static EquatableArray ParseAutoGenerateIValidateOptionsSuffixes(string? value) - { - var raw = string.IsNullOrWhiteSpace(value) ? "Options;Settings" : value!; - var builder = ImmutableArray.CreateBuilder(); - foreach (var segment in raw.Split(';', ',')) - { - var suffix = segment.Trim(); - if (suffix.Length > 0) - builder.Add(suffix); - } - - return new(builder.ToImmutable()); - } - - static SchemaGenerationModel ApplyIValidateOptionsAutoDetection( - (SchemaGenerationModel Model, AutoDetectSettings Settings) input - ) - { - var (model, settings) = input; - if (!settings.AutoGenerate || settings.Suffixes.Count == 0) - return model; - - var builder = ImmutableArray.CreateBuilder>(model.ZodSchemas.Count); - foreach (var result in model.ZodSchemas) - { - if (!result.ShouldProcess) - { - builder.Add(result); - continue; - } - - var schema = result.Value; - if ( - schema.GenerateIValidateOptions is null - && schema.IsPrimary - && !schema.IsValueType - && settings.Suffixes.Any(suffix => schema.TargetType.Name.EndsWith(suffix, StringComparison.Ordinal)) - ) - { - builder.Add( - GeneratorResult.Create(schema with { GenerateIValidateOptions = true }) - ); - } - else - { - builder.Add(result); - } - } - - return model with - { - ZodSchemas = new(builder.ToImmutable()), - }; + .Select(static (pair, _) => GenerationSettingsResolver.Apply(pair)); } static GeneratorResult GetSchemasForGeneration( @@ -145,7 +69,8 @@ CancellationToken cancellationToken if (context.SemanticModel.GetDeclaredSymbol(context.TargetNode, cancellationToken) is not INamedTypeSymbol root) return default; - ExternalSchemaResolver externalSchemas = new(context.SemanticModel.Compilation); + var compilation = context.SemanticModel.Compilation; + ExternalSchemaResolver externalSchemas = new(compilation); var schemas = ImmutableArray.CreateBuilder(); HashSet seen = []; Queue<(INamedTypeSymbol Symbol, bool IsPrimary)> queue = new(); @@ -170,14 +95,14 @@ CancellationToken cancellationToken : $"{target.Name}Schema"; var schema = target with { Name = schemaName }; var targetCanBeNull = TypeHelpers.CanBeNull(symbol); - var properties = GetZodProperties(symbol, externalSchemas); + var properties = GetZodProperties(symbol, externalSchemas, compilation); properties = AddAutomaticScalarProperty(symbol, properties, externalSchemas); // 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 // generator only needs the descriptors: ZodSchemaAnalyzer resolves the same attributes and // reports the diagnostics, so a dropped rule is visible in the build instead of failing silently. - var typeRules = CustomRuleResolver.Resolve(symbol, symbol); + var typeRules = CustomRuleResolver.Resolve(symbol, symbol, isTypeLevel: true); var accessibility = symbol.ContainingType is null ? symbol.DeclaredAccessibility == Accessibility.Public ? TypeDeclarationAccessibility.Public @@ -192,6 +117,17 @@ CancellationToken cancellationToken if (customValidation.Value.HasCustomValidation && refinementHook.Value.IsImplemented) customValidation = CustomValidationMethodData.None; var isValueType = symbol.TypeKind == TypeKind.Struct; + + // A Purview.ValueObjects scalar serialises as its underlying value, so its JSON Schema is the + // underlying type's schema (for example a Guid scalar is a canonical Guid string, not Zod's + // RFC UUID format), not an object wrapping a Value property. + TypeIdentity? scalarUnderlyingType = null; + var scalarUnderlyingCanBeNull = false; + if (CustomRuleResolver.TryGetScalarUnderlyingValue(symbol, out var scalarValueType)) + { + scalarUnderlyingType = CreateTypeIdentity(TypeHelpers.UnwrapNullableType(scalarValueType)); + scalarUnderlyingCanBeNull = IsNullableProperty(scalarValueType); + } bool? generateIValidateOptions = zodSchemaAttribute.GenerateIValidateOptions ? true : zodSchemaAttribute.SuppressIValidateOptions ? false @@ -203,6 +139,7 @@ CancellationToken cancellationToken var generateValidateMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateValidateMethod; var generateParseMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateParseMethod; var validateEnumValues = !zodSchemaAttribute.Exists || zodSchemaAttribute.ValidateEnumValues; + var generateJsonSchema = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateJsonSchema; schemas.Add( new( @@ -221,7 +158,12 @@ CancellationToken cancellationToken generateParseMethod, typeRules, validateEnumValues, - isPrimary + isPrimary, + scalarUnderlyingType, + scalarUnderlyingCanBeNull, + generateJsonSchema, + false, + default ) ); @@ -233,7 +175,10 @@ var property in symbol m.DeclaredAccessibility == Accessibility.Public && !m.IsStatic && !m.IsIndexer - && (TypeHelpers.HasDataAnnotationAttribute(m) || IsPropertyWithNestedSchema(m, externalSchemas)) + && ( + TypeHelpers.HasDataAnnotationAttribute(GetValidationAttributes(m, compilation)) + || IsPropertyWithNestedSchema(m, externalSchemas) + ) ) ) { @@ -291,6 +236,25 @@ ImmutableArray> sets { var results = ImmutableArray.CreateBuilder>(); HashSet seen = []; + + // A type can be reached twice: once from its own [ZodSchema] attribute (primary) and once as a + // nested property of another schema (secondary). The attribute contexts are processed in an + // unspecified order, so keeping whichever arrives first lets a secondary copy displace the primary. + // That drops the primary-only members — the DI adapter ({Type}SchemaValidator) and its assembly + // registration — for any type that happens to be referenced by a type processed earlier. Collect + // the primaries first, then fill in the secondaries, so the primary always wins. + foreach (var set in sets) + { + if (!set.ShouldProcess) + continue; + + foreach (var schema in set.Value.Schemas) + { + if (schema.IsPrimary && seen.Add(schema.TargetType)) + results.Add(schema); + } + } + foreach (var set in sets) { if (!set.ShouldProcess) @@ -308,7 +272,8 @@ ImmutableArray> sets static EquatableArray> GetZodProperties( INamedTypeSymbol symbol, - ExternalSchemaResolver externalSchemas + ExternalSchemaResolver externalSchemas, + Compilation compilation ) { var properties = symbol @@ -317,12 +282,22 @@ ExternalSchemaResolver externalSchemas .Where(property => property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer ) - .Select(property => GetValidatablePropertyDescriptor(property, externalSchemas)) + .Select(property => GetValidatablePropertyDescriptor(property, externalSchemas, compilation)) .ToImmutableArray(); return new(properties); } + /// + /// Gets the validation attributes that apply to , merging in the attributes of + /// the primary-constructor parameter a positional record property is synthesized from. See + /// for why the parameter's attributes are not on the property. + /// + internal static ImmutableArray GetValidationAttributes( + IPropertySymbol property, + Compilation compilation + ) => PropertyAttributeResolver.Resolve(property, compilation); + /// /// Adds the schema property for an automatic scalar value object /// ([Scalar<TValue>] / [Scalar(typeof(TValue))]), whose underlying member is @@ -384,6 +359,7 @@ originalPropertyType is INamedTypeSymbol propertyName, propertyName, TypeHelpers.CanBeNull(valueType), + IsNullableProperty(valueType), isNullableValueType, isEnum, isFlagsEnum, @@ -395,6 +371,10 @@ originalPropertyType is INamedTypeSymbol new(string.Empty, string.Empty, false), false, EmptyValidationAttributes, + new([]), + null, + true, + false, new([]) ); @@ -426,7 +406,8 @@ originalPropertyType is INamedTypeSymbol internal static GeneratorResult GetValidatablePropertyDescriptor( IPropertySymbol property, - ExternalSchemaResolver? externalSchemas = null + ExternalSchemaResolver? externalSchemas, + Compilation compilation ) { var declaredType = CreateTypeIdentity(property.Type); @@ -439,7 +420,15 @@ internal static GeneratorResult GetValidatablePropertyDes var propertyType = declaredType.Value; var originalPropertyType = property.Type; + + // Two nullabilities are tracked. `CanBeNull` is whether the type can hold null at runtime (a + // reference type or Nullable), which governs whether [Required] is meaningful. `IsDeclaredNullable` + // follows the nullable reference-type annotation (a non-nullable `string` is required and its rules + // run unconditionally and reject null, while a `string?` stays nullable). A compilation with the + // nullable context disabled reports NullableAnnotation.None, where a reference type is treated as + // nullable. var propertyCanBeNull = TypeHelpers.CanBeNull(originalPropertyType); + var propertyDeclaredNullable = IsNullableProperty(originalPropertyType); var isNullableValueType = false; if ( originalPropertyType is INamedTypeSymbol @@ -469,20 +458,27 @@ originalPropertyType is INamedTypeSymbol && TypeHelpers.CanBeNull(GetCollectionElementTypeSymbol(originalPropertyType) ?? originalPropertyType); var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas); var lengthAccessor = ClassifyLengthAccessor(originalPropertyType); - var displayName = GetDisplayName(property); - - var displayAttribute = DisplayAttributeData.FromAttributeData(property); - var requiredAttribute = RequiredAttributeData.FromAttributeData(property); - var compareAttribute = CompareAttributeData.FromAttributeData(property); - var emailAddressAttribute = EmailAddressAttributeData.FromAttributeData(property); - var creditCardAttribute = CreditCardAttributeData.FromAttributeData(property); - var phoneAttribute = PhoneAttribute.FromAttributeData(property); - var urlAttribute = UrlAttribute.FromAttributeData(property); - var stringLengthAttribute = StringLengthAttribute.FromAttributeData(property); - var minLengthAttribute = MinLengthAttributeData.FromAttributeData(property); - var maxLengthAttribute = MaxLengthAttributeData.FromAttributeData(property); + var validationAttributes = GetValidationAttributes(property, compilation); + var displayName = GetDisplayName(validationAttributes, property.Name); + + var displayAttribute = DisplayAttributeData.FromAttributeData(validationAttributes); + var requiredAttribute = RequiredAttributeData.FromAttributeData(validationAttributes); + var compareAttribute = CompareAttributeData.FromAttributeData(validationAttributes); + var emailAddressAttribute = EmailAddressAttributeData.FromAttributeData(validationAttributes); + var creditCardAttribute = CreditCardAttributeData.FromAttributeData(validationAttributes); + var phoneAttribute = PhoneAttribute.FromAttributeData(validationAttributes); + var urlAttribute = UrlAttribute.FromAttributeData(validationAttributes); + var stringLengthAttribute = StringLengthAttribute.FromAttributeData(validationAttributes); + var minLengthAttribute = MinLengthAttributeData.FromAttributeData(validationAttributes); + var maxLengthAttribute = MaxLengthAttributeData.FromAttributeData(validationAttributes); var regularExpressionAttribute = GeneratorResult.Empty; - if (RegularExpressionAttributeData.TryFromAttributeData(property, out var regexData, out var attribute)) + if ( + RegularExpressionAttributeData.TryFromAttributeData( + validationAttributes, + out var regexData, + out var attribute + ) + ) { regularExpressionAttribute = attribute is not null && propertyType.SpecialType != SpecialType.System_String @@ -498,12 +494,13 @@ originalPropertyType is INamedTypeSymbol : GeneratorResult.Create(regexData); } - var base64StringAttribute = Base64StringAttributeData.FromAttributeData(property); - var deniedValuesAttribute = DeniedValuesAttributeData.FromAttributeData(property); - var allowedValuesAttribute = AllowedValuesAttributeData.FromAttributeData(property); + var base64StringAttribute = Base64StringAttributeData.FromAttributeData(validationAttributes); + var deniedValuesAttribute = DeniedValuesAttributeData.FromAttributeData(validationAttributes); + var allowedValuesAttribute = AllowedValuesAttributeData.FromAttributeData(validationAttributes); AddUnsupportedDataAnnotationsDiagnostics( - property, + validationAttributes, + property.Name, propertyType, originalPropertyType, urlAttribute, @@ -517,7 +514,7 @@ originalPropertyType is INamedTypeSymbol ); var lengthAttribute = GeneratorResult.Empty; - if (LengthAttributeData.TryFromAttributeData(property, out var lengthData, out attribute)) + if (LengthAttributeData.TryFromAttributeData(validationAttributes, out var lengthData, out attribute)) { lengthAttribute = BuildLengthAttributeResult( property, @@ -528,7 +525,7 @@ originalPropertyType is INamedTypeSymbol ); } - var rangeAttribute = RangeAttributeData.FromAttributeData(property.GetAttributes(), out attribute); + var rangeAttribute = RangeAttributeData.FromAttributeData(validationAttributes, out attribute); var rangeAttributeResult = TryBuildRangeBoundaryExpressions( originalPropertyType, rangeAttribute, @@ -552,9 +549,9 @@ rangeAttribute with ) ); - ValidateCompareProperty(property, compareAttribute, diagnostics); + ValidateCompareProperty(property, validationAttributes, compareAttribute, diagnostics); - ValidateErrorMessageResourceConfiguration(property, diagnostics); + ValidateErrorMessageResourceConfiguration(validationAttributes, property.Name, diagnostics); diagnostics.AddRange(regularExpressionAttribute.Diagnostics); diagnostics.AddRange(lengthAttribute.Diagnostics); @@ -565,8 +562,11 @@ rangeAttribute with var isEnum = enumType is { TypeKind: TypeKind.Enum }; var isFlagsEnum = isEnum && HasFlagsAttribute(enumType!); var ignoredEnumMembers = isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default; + var enumSerializedAsString = isEnum && EnumJsonResolver.IsSerializedAsString(enumType!, property); + var enumJsonNames = + isEnum && !isFlagsEnum ? EnumJsonResolver.GetJsonNames(enumType!, validationAttributes) : new([]); - var customRules = CustomRuleResolver.Resolve(property, originalPropertyType, diagnostics); + var customRules = CustomRuleResolver.Resolve(validationAttributes, originalPropertyType, diagnostics); var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable @@ -579,6 +579,7 @@ rangeAttribute with property.Name, displayName, propertyCanBeNull, + propertyDeclaredNullable, isNullableValueType, isEnum, isFlagsEnum, @@ -607,12 +608,94 @@ rangeAttribute with lengthAttribute, rangeAttributeResult ), - customRules + customRules, + GetJsonPropertyName(property), + NestedSchemaExportsJsonSchema(property, validationKind, externalSchemas), + enumSerializedAsString, + enumJsonNames ), diagnostics.ToImmutable() ); } + /// + /// Determines whether the nested complex type a property references exports JSON Schema. A nested type + /// can opt out with [ZodSchema(GenerateJsonSchema = false)], in which case the parent's schema + /// cannot reference it and falls back to an empty schema (reported as ZODSGEN045). + /// + /// The property to inspect. + /// The property's validation kind. + /// The resolver that decides schema ownership. + /// when the property has no nested schema, or the nested type exports one. + static bool NestedSchemaExportsJsonSchema( + IPropertySymbol property, + PropertyValidationKind validationKind, + ExternalSchemaResolver? externalSchemas + ) + { + if (validationKind is not (PropertyValidationKind.Complex or PropertyValidationKind.Collection)) + return true; + + var targetType = + validationKind == PropertyValidationKind.Collection + ? GetCollectionElementTypeSymbol(property.Type) + : TypeHelpers.UnwrapNullableType(property.Type); + + if (targetType is not INamedTypeSymbol namedType) + return true; + + // Only a type owned by this compilation can opt out; an external schema's export is that assembly's + // concern. + if (externalSchemas is not null && !externalSchemas.IsSchemaOwnedByThisCompilation(namedType)) + return true; + + var attributeData = ZodSchemaAttributeData.FromAttributeData(namedType, out _); + return !attributeData.Exists || attributeData.GenerateJsonSchema; + } + + /// + /// Reads an explicit [System.Text.Json.Serialization.JsonPropertyName] from a property, so the + /// exported JSON Schema names the property exactly as System.Text.Json writes it. + /// + /// The property to inspect. + /// The declared JSON name, or when none is declared. + static string? GetJsonPropertyName(IPropertySymbol property) + { + foreach (var attribute in property.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "JsonPropertyNameAttribute" } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != "System.Text.Json.Serialization" + || attribute.ConstructorArguments.Length != 1 + ) + { + continue; + } + + if (attribute.ConstructorArguments[0].Value is string { Length: > 0 } name) + return name; + } + + return null; + } + + /// + /// Determines whether a property accepts null, from its nullable reference-type annotation or its + /// construction. A non-nullable reference type (for example string) is + /// non-nullable, so its rules run unconditionally and reject null; a string? accepts null. + /// + /// The declared property type. + /// when the property accepts null. + /// + /// A compilation with the nullable context disabled reports for + /// every reference type; there the annotation cannot distinguish nullable from non-nullable, so a + /// reference type is treated as nullable (the pre-annotation behaviour). + /// + static bool IsNullableProperty(ITypeSymbol typeSymbol) => + typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || typeSymbol.NullableAnnotation == NullableAnnotation.Annotated + || (typeSymbol.NullableAnnotation == NullableAnnotation.None && typeSymbol.IsReferenceType); + /// /// Creates the TypeIdentity for a symbol, returning for symbols that /// cannot be represented (unresolved error types, type parameters, dynamic, pointers, ...). @@ -826,18 +909,18 @@ static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) return new(string.Empty, string.Empty, false); } - static string GetDisplayName(IPropertySymbol property) + static string GetDisplayName(ImmutableArray attributes, string propertyName) { - var display = DisplayAttributeData.FromAttributeData(property); - return display.Exists && !string.IsNullOrEmpty(display.Name) ? display.Name! : property.Name; + var display = DisplayAttributeData.FromAttributeData(attributes); + return display.Exists && !string.IsNullOrEmpty(display.Name) ? display.Name! : propertyName; } static Location GetAttributeLocation(AttributeData? attributeData) => attributeData?.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; - static AttributeData? FindAttribute(IPropertySymbol property, string metadataName) + static AttributeData? FindAttribute(ImmutableArray attributes, string metadataName) { - foreach (var attribute in property.GetAttributes()) + foreach (var attribute in attributes) { if (attribute.AttributeClass?.MetadataName == metadataName) return attribute; @@ -847,7 +930,7 @@ static Location GetAttributeLocation(AttributeData? attributeData) => } static void AddUnsupportedDataAnnotationsUsage( - IPropertySymbol property, + string propertyName, AttributeData? attribute, ImmutableArray.Builder diagnostics ) => @@ -856,18 +939,21 @@ ImmutableArray.Builder diagnostics DiagnosticLibrary.UnsupportedDataAnnotationsUsage, true, GetAttributeLocation(attribute), - property.Name + propertyName ) ); static void AddUnsupportedDataAnnotationsUsage( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, string attributeMetadataName, ImmutableArray.Builder diagnostics - ) => AddUnsupportedDataAnnotationsUsage(property, FindAttribute(property, attributeMetadataName), diagnostics); + ) => + AddUnsupportedDataAnnotationsUsage(propertyName, FindAttribute(attributes, attributeMetadataName), diagnostics); static void AddUnsupportedDataAnnotationsDiagnostics( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, TypeIdentity propertyType, ITypeSymbol originalPropertyType, UrlAttribute urlAttribute, @@ -883,15 +969,15 @@ ImmutableArray.Builder diagnostics var isString = propertyType.SpecialType == SpecialType.System_String; if (urlAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "UrlAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "UrlAttribute", diagnostics); if (phoneAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "PhoneAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "PhoneAttribute", diagnostics); if (creditCardAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "CreditCardAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "CreditCardAttribute", diagnostics); if (base64StringAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "Base64StringAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "Base64StringAttribute", diagnostics); if (emailAddressAttribute.Exists && !isString) - AddUnsupportedDataAnnotationsUsage(property, "EmailAddressAttribute", diagnostics); + AddUnsupportedDataAnnotationsUsage(attributes, propertyName, "EmailAddressAttribute", diagnostics); if ( (allowedValuesAttribute.Exists || deniedValuesAttribute.Exists) @@ -899,9 +985,10 @@ ImmutableArray.Builder diagnostics ) { var valuesAttribute = - FindAttribute(property, "AllowedValuesAttribute") ?? FindAttribute(property, "DeniedValuesAttribute"); + FindAttribute(attributes, "AllowedValuesAttribute") + ?? FindAttribute(attributes, "DeniedValuesAttribute"); if (valuesAttribute is not null) - AddUnsupportedDataAnnotationsUsage(property, valuesAttribute, diagnostics); + AddUnsupportedDataAnnotationsUsage(propertyName, valuesAttribute, diagnostics); } } @@ -948,6 +1035,7 @@ ITypeSymbol originalPropertyType static void ValidateCompareProperty( IPropertySymbol property, + ImmutableArray attributes, CompareAttributeData compareAttribute, ImmutableArray.Builder diagnostics ) @@ -965,7 +1053,7 @@ ImmutableArray.Builder diagnostics ReportableDiagnostic.Create( DiagnosticLibrary.ComparePropertyNotFound, true, - GetAttributeLocation(FindAttribute(property, "CompareAttribute")), + GetAttributeLocation(FindAttribute(attributes, "CompareAttribute")), property.Name, compareAttribute.OtherProperty ) @@ -998,11 +1086,12 @@ or SpecialType.System_Double } static void ValidateErrorMessageResourceConfiguration( - IPropertySymbol property, + ImmutableArray attributes, + string propertyName, ImmutableArray.Builder diagnostics ) { - foreach (var attribute in property.GetAttributes()) + foreach (var attribute in attributes) { if ( attribute.AttributeClass is null @@ -1036,7 +1125,7 @@ attribute.AttributeClass is null DiagnosticLibrary.InvalidDataAnnotationsErrorMessage, true, GetAttributeLocation(attribute), - property.Name + propertyName ) ); } diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index be5e145..35d00a7 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -28,6 +28,19 @@ enum PropertyValidationKind Unsupported, } +/// +/// How a string-serialized enum's member names are transformed, mirroring the +/// JsonNamingPolicy a JsonStringEnumConverter may be constructed with. +/// +enum EnumJsonNamingPolicy +{ + /// Member names are used as declared. + Unchanged, + + /// Member names are camelCased, matching JsonNamingPolicy.CamelCase. + CamelCase, +} + readonly record struct LengthAccessor(string LengthExpression, string Origin, bool IsSupported); /// This is the target of the attribute, the type where the attribute was defined. @@ -52,6 +65,29 @@ enum PropertyValidationKind /// [ZodSchema] attribute opts out. /// /// True if this is the primary schema for the target type, false if it is a secondary schema. +/// +/// When the target is a Purview.ValueObjects scalar, the underlying value type whose JSON wire form the +/// scalar serialises as (for example a Guid scalar serialises as a canonical Guid string). Null +/// otherwise. +/// +/// +/// Whether the scalar's underlying value may be null (a nullable reference or nullable value scalar). +/// +/// +/// Whether the generated schema exports JSON Schema (the BuildJsonSchema method and the +/// IJsonSchemaProvider implementation on the adapter). Controlled by +/// [ZodSchema(GenerateJsonSchema = false)] and the assembly-level ZodSharpGenerateJsonSchemas +/// switch. +/// +/// +/// Whether enums in this schema are serialized as strings. Set by the assembly-level +/// ZodSharpEnumSerialization=String switch (for a global JsonStringEnumConverter); a property +/// can also opt in with a per-type/property [JsonConverter(typeof(JsonStringEnumConverter))]. +/// +/// +/// How a string-serialized enum's member names are transformed, set by the assembly-level +/// ZodSharpEnumNamingPolicy switch. +/// readonly record struct ZodSchemaDescriptor( TypeIdentity TargetType, TypeIdentity SchemaType, @@ -68,13 +104,23 @@ readonly record struct ZodSchemaDescriptor( bool GenerateParseMethod, EquatableArray TypeRules, bool ValidateEnumValues, - bool IsPrimary + bool IsPrimary, + TypeIdentity? ScalarUnderlyingType, + bool ScalarUnderlyingCanBeNull, + bool GenerateJsonSchema, + bool EnumAsString, + EnumJsonNamingPolicy EnumNamingPolicy ); /// The declared type of the property, with a nullable wrapper removed. /// The name of the property. /// The name reported in error messages, honouring a [Display] attribute. -/// Indicates whether the property can be null. +/// Indicates whether the property type can hold null at runtime (a reference type or Nullable<T>). +/// +/// Indicates whether the property is declared nullable (the nullable reference-type annotation, or +/// Nullable<T>). This governs null acceptance in validation and the exported JSON Schema: a +/// non-nullable reference type is required and its rules run unconditionally and reject null. +/// /// Indicates whether the property type is a Nullable<T>. /// Indicates whether the property type is an enum. /// Indicates whether the enum type is declared with the [Flags] attribute. @@ -90,11 +136,28 @@ bool IsPrimary /// Indicates whether ranges compare through IComparable. /// The DataAnnotations attributes declared on the property. /// The rules bound to the property through [ZodRule]-mapped attributes. +/// +/// The explicit [JsonPropertyName] value, when one is declared. Used to name the property in the +/// exported JSON Schema so it matches the System.Text.Json wire form. +/// +/// +/// Whether the nested complex type's generated schema exports JSON Schema. When the +/// parent's schema cannot reference it and falls back to an empty schema (reported as ZODSGEN045). +/// +/// +/// Whether the property's enum is serialized as a string (a per-type or per-property +/// [JsonConverter(typeof(JsonStringEnumConverter))]). The assembly-level switch is applied separately. +/// +/// +/// 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. +/// readonly record struct ZodPropertyDescriptor( TypeIdentity PropertyType, string Name, string DisplayName, bool CanBeNull, + bool IsDeclaredNullable, bool IsNullableValueType, bool IsEnum, bool IsFlagsEnum, @@ -106,7 +169,11 @@ readonly record struct ZodPropertyDescriptor( LengthAccessor LengthAccessor, bool CompareViaCompareTo, ValidationAttributes ValidationAttributes, - EquatableArray CustomRules + EquatableArray CustomRules, + string? JsonPropertyName, + bool NestedSchemaExportsJsonSchema, + bool EnumSerializedAsString, + EquatableArray EnumJsonNames ); [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs index 709b437..fd03140 100644 --- a/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs +++ b/src/src/SourceGenerators/Models/ZodSchemaAttributeData.cs @@ -9,5 +9,6 @@ readonly partial record struct ZodSchemaAttributeData( string? CustomValidationMethodName, [Property(DefaultValue = false)] bool GenerateIValidateOptions, [Property(DefaultValue = false)] bool SuppressIValidateOptions, - [Property(DefaultValue = true)] bool ValidateEnumValues + [Property(DefaultValue = true)] bool ValidateEnumValues, + [Property(DefaultValue = true)] bool GenerateJsonSchema ); diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 9a0d3d2..c86df93 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -43,6 +43,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.RuleAttributeWithoutSchema, DiagnosticLibrary.NativeUnionRecommended, DiagnosticLibrary.AutomaticScalarSchemaUnavailable, + DiagnosticLibrary.NestedSchemaExportDisabled, ]; public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; @@ -124,6 +125,9 @@ ImmutableHashSet schemaReachableTypes // validations, so a rule that resolves to nothing is reported here rather than dropped silently. ReportTypeRuleDiagnostics(context, type, typeLocation); + if (zodSchemaData.GenerateJsonSchema && ReadGenerateJsonSchemas(context.Options)) + ReportNestedSchemaExportDisabled(context, type, externalSchemas, typeLocation); + if (!hasDataAnnotations) { context.ReportDiagnostic( @@ -177,17 +181,25 @@ ImmutableHashSet schemaReachableTypes foreach (var property in type.GetMembers().OfType()) { + // A positional record property inherits the attributes of the primary-constructor parameter it is + // synthesized from, so the analyzer resolves the same effective set the generator validates with. + var validationAttributes = SourceGenLibrary.GetValidationAttributes(property, context.Compilation); + if ( property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer - || !TypeHelpers.HasDataAnnotationAttribute(property) + || !TypeHelpers.HasDataAnnotationAttribute(validationAttributes) ) { continue; } - var propertyResult = SourceGenLibrary.GetValidatablePropertyDescriptor(property, externalSchemas); + var propertyResult = SourceGenLibrary.GetValidatablePropertyDescriptor( + property, + externalSchemas, + context.Compilation + ); foreach (var diagnosticInfo in propertyResult.Diagnostics) { var diagnostic = diagnosticInfo.ToDiagnostic(); @@ -217,7 +229,7 @@ ImmutableHashSet schemaReachableTypes static void ReportTypeRuleDiagnostics(SymbolAnalysisContext context, INamedTypeSymbol type, Location typeLocation) { var diagnostics = ImmutableArray.CreateBuilder(); - _ = CustomRuleResolver.Resolve(type, type, diagnostics); + _ = CustomRuleResolver.Resolve(type, type, diagnostics, isTypeLevel: true); foreach (var diagnosticInfo in diagnostics) { @@ -310,6 +322,59 @@ static Location GetMemberLocation(ISymbol member) return Location.None; } + /// + /// Reads the assembly-level JSON Schema switch (ZodSharpGenerateJsonSchemas). On by default; only + /// an explicit false disables it. + /// + /// The analyzer options. + /// when JSON Schema export is enabled for the compilation. + static bool ReadGenerateJsonSchemas(AnalyzerOptions options) => + !options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + out var value + ) + || !bool.TryParse(value, out var enabled) + || enabled; + + /// + /// Reports ZODSGEN045 for each property whose nested [ZodSchema] type disables JSON Schema + /// export, so the parent's exported schema cannot describe it (it falls back to an empty schema). + /// + /// The analysis context the diagnostics are reported to. + /// The exporting schema type. + /// The resolver that decides schema ownership. + /// The location to fall back to when a property carries none. + static void ReportNestedSchemaExportDisabled( + SymbolAnalysisContext context, + INamedTypeSymbol type, + ExternalSchemaResolver externalSchemas, + Location typeLocation + ) + { + foreach (var property in type.GetMembers().OfType()) + { + if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) + continue; + + if (!SourceGenLibrary.TryGetNestedSchemaType(property, externalSchemas, out var nested)) + continue; + + var nestedData = ZodSchemaAttributeData.FromAttributeData(nested, out _); + if (!nestedData.Exists || nestedData.GenerateJsonSchema) + continue; + + var location = GetMemberLocation(property); + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.NestedSchemaExportDisabled, + location == Location.None ? typeLocation : location, + property.Name, + nested.Name + ) + ); + } + } + /// /// Collects the types that will receive a generated schema: every [ZodSchema] type in this /// assembly plus, transitively, the complex property types the generator discovers and emits secondary @@ -393,7 +458,13 @@ ImmutableHashSet schemaReachableTypes if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) continue; - ReportRuleAttributes(context, property.GetAttributes(), type.Name); + // A positional record property inherits the attributes of the primary-constructor parameter it is + // synthesized from, so the same effective set the generator validates with is reported here. + ReportRuleAttributes( + context, + SourceGenLibrary.GetValidationAttributes(property, context.Compilation), + type.Name + ); } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 0793279..4971ba2 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -200,6 +200,9 @@ CancellationToken cancellationToken GeneratePathFields(writer, outputContext.ZodSchema, cancellationToken); GenerateStaticAttributeFields(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateJsonSchema) + GenerateJsonSchemaMethod(writer, outputContext.ZodSchema, cancellationToken); + if (outputContext.ZodSchema.GenerateValidateMethod) { GenerateValidationHelpers(writer, cancellationToken); @@ -254,6 +257,7 @@ CancellationToken cancellationToken Accessibility = schema.TargetAccessibility, IsPartial = true, BaseType = baseType, + Interfaces = schema.GenerateJsonSchema ? [JsonSchemaProviderIdentity.AsTypeReference()] : [], }, body => { @@ -276,6 +280,23 @@ CancellationToken cancellationToken ); GenerateValidateAsyncMethod(body, schema, cancellationToken); + + if (!schema.GenerateJsonSchema) + return; + + body.XmlSummary( + "Builds the JSON Schema (Draft 2020-12) definition for the target type.", + "Delegates to the generated schema, so an attribute-generated validator can be exported." + ) + .XmlReturn("The JSON Schema definition.") + .Method( + new MethodDeclarationOptions( + "BuildJsonSchema", + JsonSchemaDefinitionIdentity.AsTypeReference(), + TypeDeclarationAccessibility.Public + ), + methodBody => methodBody.Return($"{schema.SchemaType.Name}.BuildJsonSchema()") + ); } ); } @@ -714,10 +735,24 @@ CancellationToken cancellationToken block = writer.ElseScope(); GenerateValueSetValidations(writer, property, emitEnumRule); } + else if (!property.IsDeclaredNullable && property.CanBeNull) + { + // A non-nullable reference type rejects null — the exported JSON Schema marks it required — so + // the rules below run only for a present value. This keeps null acceptance consistent with the + // schema and makes length/count access null-safe. + writer.Rule( + propertyName, + $"value.{propertyName} == null", + "invalid_type", + $"Field '{displayName}' cannot be null" + ); + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } else { GenerateValueSetValidations(writer, property, emitEnumRule); - if (property.CanBeNull) + if (property.IsDeclaredNullable) block = writer.IfBlockScope($"value.{propertyName} != null"); } @@ -880,7 +915,7 @@ static void GenerateComplexTypeValidation(CodeWriter writer, ZodPropertyDescript var schemaType = property.NestedSchemaType.Value; writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - if (property.CanBeNull) + if (property.IsDeclaredNullable) { using (writer.IfBlockScope($"{propertyValueName} is not null")) { diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs new file mode 100644 index 0000000..b983ea1 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -0,0 +1,475 @@ +using System.Globalization; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; +using ZodSharp.SourceGenerators.Models.DataAttributes; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + const string JsonSchemaDefinitionTypeName = "global::ZodSharp.JsonSchema.JsonSchemaDefinition"; + const string JsonSchemaRulesTypeName = "global::ZodSharp.JsonSchema.JsonSchemaRules"; + + static readonly TypeIdentity JsonSchemaDefinitionIdentity = new("JsonSchemaDefinition", "ZodSharp.JsonSchema"); + static readonly TypeIdentity JsonSchemaProviderIdentity = new("IJsonSchemaProvider", "ZodSharp.JsonSchema"); + + /// + /// Emits the BuildJsonSchema method on the generated schema class: a complete JSON Schema + /// (Draft 2020-12) definition built from the same metadata the validator uses. This is the bridge that + /// lets an attribute-generated schema be exported through Z.ToJsonSchema without rebuilding its + /// rules by hand. + /// + /// The writer positioned inside the generated schema class. + /// The schema descriptor. + /// The cancellation token. + static void GenerateJsonSchemaMethod( + CodeWriter writer, + ZodSchemaDescriptor schema, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + writer + .XmlSummary( + $"Builds the JSON Schema (Draft 2020-12) definition for {CodeWriter.XmlSee(schema.TargetType.Name)}.", + "The definition is produced from the same metadata as Validate, so the two cannot drift apart." + ) + .XmlReturn("The JSON Schema definition (without document metadata such as $schema/$id)."); + + writer.Method( + new MethodDeclarationOptions( + "BuildJsonSchema", + JsonSchemaDefinitionIdentity.AsTypeReference(), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + }, + body => + { + if (schema.ScalarUnderlyingType is { } underlying) + { + EmitScalarJsonSchema(body, schema, underlying); + return; + } + + body.Assignment( + "var", + "schema", + $"new {JsonSchemaDefinitionTypeName} {{ Type = \"object\", Properties = new global::System.Collections.Generic.Dictionary(global::System.StringComparer.Ordinal), Required = new global::System.Collections.Generic.List(), AdditionalProperties = false }}" + ); + body.NewLine(); + + foreach (var property in schema.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!property.ShouldProcess) + continue; + + EmitJsonSchemaProperty(body, property.Value, schema); + body.NewLine(); + } + + body.Return("schema"); + } + ); + + writer.NewLine(); + } + + /// + /// Emits the JSON Schema for a scalar value object: the underlying value's schema (a Guid scalar is a + /// canonical Guid string, not Zod's RFC UUID format), with the scalar's own rules applied and a + /// null branch when the underlying value is nullable. + /// + static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, TypeIdentity underlying) + { + body.Assignment("var", "inner", BuildBaseSchemaExpression(underlying)); + + foreach (var rule in schema.TypeRules) + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); + + foreach (var property in schema.Properties) + { + if (!property.ShouldProcess) + continue; + + EmitAttributeConstraints(body, property.Value, "inner"); + foreach (var rule in property.Value.CustomRules) + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule)); + } + + body.Return( + schema.ScalarUnderlyingCanBeNull + ? $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ inner, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}" + : "inner" + ); + } + + static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor property, ZodSchemaDescriptor schema) + { + var localName = CodeGenHelpers.GetLocalIdentifier(property.Name, "JsonSchema"); + var nameLiteral = GetJsonName(property).StringLiteral(); + var hasRequiredAttribute = + property.ValidationAttributes.Required.ShouldProcess && property.ValidationAttributes.Required.Value.Exists; + // A rule that rejects an absent value ([RequiredZod]) makes the property effectively non-null even + // when its annotation is nullable, matching how the generated validator emits it outside the guard. + var hasRequiredRule = property.CustomRules.Any(rule => rule.IsRequired); + var isRequired = hasRequiredAttribute || !property.IsDeclaredNullable; + var isNullable = property.IsDeclaredNullable && !hasRequiredAttribute && !hasRequiredRule; + + body.Assignment("var", localName, BuildPropertySchemaExpression(property, schema)); + + EmitAttributeConstraints(body, property, localName); + + foreach (var rule in property.CustomRules) + body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule)); + + if (isNullable) + { + body.Assignment( + $"schema.Properties[{nameLiteral}]", + $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {localName}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}" + ); + } + else + { + body.Assignment($"schema.Properties[{nameLiteral}]", localName); + } + + if (isRequired) + body.MethodCallOn("schema.Required", "Add", nameLiteral); + } + + /// + /// Builds the C# expression for a property's base schema (its JSON type, nested schema, enum values, or + /// array items), before any rule or attribute constraint is applied. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static string BuildPropertySchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) => + property.ValidationKind switch + { + PropertyValidationKind.Complex when property.NestedSchemaType is { } nested => + property.NestedSchemaExportsJsonSchema + ? $"{nested.AsTypeReference().RenderFullName}.BuildJsonSchema()" + : $"new {JsonSchemaDefinitionTypeName}()", + PropertyValidationKind.Collection => BuildCollectionSchemaExpression(property), + // A [Flags] enum is not validated against its defined members (any combination is valid), so the + // schema must not enumerate them either; the wire form is an integer bitmask. + PropertyValidationKind.Enum when property.IsEnum && property.IsFlagsEnum => Schema("integer"), + PropertyValidationKind.Enum when property.IsEnum => BuildEnumSchemaExpression(property, schema), + _ => BuildBaseSchemaExpression(property.PropertyType), + }; + + static string BuildCollectionSchemaExpression(ZodPropertyDescriptor property) + { + var items = + property.NestedSchemaType is { } nested + ? ( + property.NestedSchemaExportsJsonSchema + ? $"{nested.AsTypeReference().RenderFullName}.BuildJsonSchema()" + : $"new {JsonSchemaDefinitionTypeName}()" + ) + : property.ElementType is { } element ? BuildBaseSchemaExpression(element) + : $"new {JsonSchemaDefinitionTypeName}()"; + + return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"array\", Items = {items} }}"; + } + + static string BuildEnumSchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) + { + // A string-serialized enum (a per-type/per-property JsonStringEnumConverter, or the assembly-level + // ZodSharpEnumSerialization=String switch) lists the member JSON names rather than their numeric + // values. A naming policy mirrors the converter's JsonNamingPolicy. + if (property.EnumSerializedAsString || schema.EnumAsString) + { + var names = property.EnumJsonNames.Select(name => + EnumJsonResolver.ApplyNamingPolicy(name, schema.EnumNamingPolicy).StringLiteral() + ); + return $"new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", names)} }} }}"; + } + + var enumType = property.PropertyType.AsTypeReference().RenderFullName; + var members = + property.IgnoredEnumMembers.Count == 0 ? string.Empty : string.Join(", ", property.IgnoredEnumMembers); + return $"{JsonSchemaRulesTypeName}.EnumSchema<{enumType}>({members})"; + } + + /// + /// Maps a property type to the JSON Schema its System.Text.Json wire form implies. A scalar with no + /// JSON Schema representation (or a type the generator cannot classify) exports as the empty schema + /// (any) rather than being guessed. + /// + static string BuildBaseSchemaExpression(TypeIdentity type) + { + if (type.SpecialType is SpecialType.System_String or SpecialType.System_Char) + return Schema("string"); + + if (type.SpecialType == SpecialType.System_Boolean) + return Schema("boolean"); + + if (IsIntegerType(type.SpecialType)) + return Schema("integer"); + + if (IsFloatingType(type.SpecialType)) + return Schema("number"); + + if (type.SpecialType == SpecialType.System_Object) + return $"new {JsonSchemaDefinitionTypeName}()"; + + 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")) + return Schema("string", "date-time"); + + if (IsWellKnown(type, "System", "DateOnly")) + return Schema("string", "date"); + + if (IsWellKnown(type, "System", "TimeOnly")) + return Schema("string", "time"); + + if (IsWellKnown(type, "System", "TimeSpan")) + return Schema("string", "duration"); + + if (IsWellKnown(type, "System", "Version")) + return Schema("string"); + + // A type with no JSON Schema representation (or a type the generator cannot classify) exports as the empty schema (any) rather than being guessed. + return $"new {JsonSchemaDefinitionTypeName}()"; + } + + /// + /// The property name as System.Text.Json writes it: an explicit [JsonPropertyName] wins, otherwise + /// the camelCase form that matches the ASP.NET Core / JsonSerializerDefaults.Web default. + /// + /// + /// A non-camelCase global naming policy configured at runtime is not visible to the generator, so a + /// consumer using one must adjust the exported names (or declare [JsonPropertyName]). + /// + static string GetJsonName(ZodPropertyDescriptor property) => + property.JsonPropertyName + ?? ( + property.Name.Length == 0 || char.IsLower(property.Name[0]) + ? property.Name + : char.ToLowerInvariant(property.Name[0]) + property.Name.Substring(1) + ); + + static string Schema(string type) => $"new {JsonSchemaDefinitionTypeName} {{ Type = \"{type}\" }}"; + + static string Schema(string type, string format) => + $"new {JsonSchemaDefinitionTypeName} {{ Type = \"{type}\", Format = \"{format}\" }}"; + + static bool IsWellKnown(TypeIdentity type, string @namespace, string name) => + type.Name == name && type.Namespace == @namespace; + + static bool IsIntegerType(SpecialType specialType) => + specialType + is SpecialType.System_SByte + or SpecialType.System_Byte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64; + + static bool IsFloatingType(SpecialType specialType) => + specialType is SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal; + + static string BuildRuleInstanceExpression(CustomRuleDescriptor rule) + { + // A scalar rule adapter wraps a rule written against the scalar's underlying value; the wrapped rule + // owns the JSON Schema meaning, so construct that one. The adapter itself has none. + var ruleType = (rule.AdaptedFrom ?? rule.RuleType).AsTypeReference().RenderFullName; + var arguments = rule.Arguments.Count == 0 ? string.Empty : $"({string.Join(", ", rule.Arguments)})"; + return $"new {ruleType}{arguments}"; + } + + static void EmitAttributeConstraints(CodeWriter body, ZodPropertyDescriptor property, string localName) + { + var attributes = property.ValidationAttributes; + EmitLengthConstraints(body, property, attributes, localName); + EmitFormatConstraints(body, attributes, localName); + EmitRangeConstraint(body, attributes.Range, localName); + EmitValueConstraints(body, attributes, localName); + EmitUnsupportedAttributeConstraints(body, attributes, localName); + } + + static void EmitLengthConstraints( + CodeWriter body, + ZodPropertyDescriptor property, + ValidationAttributes attributes, + string localName + ) + { + // [MinLength]/[MaxLength] apply to strings and collections; the JSON Schema keyword differs. + var isCollection = property.ValidationKind == PropertyValidationKind.Collection; + var minKeyword = isCollection ? "MinItems" : "MinLength"; + var maxKeyword = isCollection ? "MaxItems" : "MaxLength"; + + if (attributes.MinLength.ShouldProcess && attributes.MinLength.Value.Exists) + body.Assignment( + $"{localName}.{minKeyword}", + attributes.MinLength.Value.Length.ToString(CultureInfo.InvariantCulture) + ); + + if ( + attributes.MaxLength.ShouldProcess + && attributes.MaxLength.Value.Exists + && attributes.MaxLength.Value.Length >= 0 + ) + { + body.Assignment( + $"{localName}.{maxKeyword}", + attributes.MaxLength.Value.Length.ToString(CultureInfo.InvariantCulture) + ); + } + + if (attributes.StringLength.ShouldProcess && attributes.StringLength.Value.Exists) + { + if (attributes.StringLength.Value.MinimumLength > 0) + { + body.Assignment( + $"{localName}.MinLength", + attributes.StringLength.Value.MinimumLength.ToString(CultureInfo.InvariantCulture) + ); + } + + body.Assignment( + $"{localName}.MaxLength", + attributes.StringLength.Value.MaximumLength.ToString(CultureInfo.InvariantCulture) + ); + } + + if (attributes.Length.ShouldProcess && attributes.Length.Value.Exists) + { + var isString = property.PropertyType.SpecialType == SpecialType.System_String; + var minimum = attributes.Length.Value.MinimumLength.ToString(CultureInfo.InvariantCulture); + var maximum = attributes.Length.Value.MaximumLength.ToString(CultureInfo.InvariantCulture); + body.Assignment(isString ? $"{localName}.MinLength" : $"{localName}.MinItems", minimum); + body.Assignment(isString ? $"{localName}.MaxLength" : $"{localName}.MaxItems", maximum); + } + } + + static void EmitFormatConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.EmailAddress.ShouldProcess && attributes.EmailAddress.Value.Exists) + body.Assignment($"{localName}.Format", "email".StringLiteral()); + + if (attributes.Url.ShouldProcess && attributes.Url.Value.Exists) + body.Assignment($"{localName}.Format", "uri".StringLiteral()); + + if ( + attributes.RegularExpression.ShouldProcess + && attributes.RegularExpression.Value.Exists + && attributes.RegularExpression.Value.Pattern is { Length: > 0 } pattern + ) + { + body.Assignment($"{localName}.Pattern", pattern.StringLiteral()); + } + + if (attributes.Base64String.ShouldProcess && attributes.Base64String.Value.Exists) + body.Assignment($"{localName}.ContentEncoding", "base64".StringLiteral()); + } + + static void EmitValueConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.AllowedValues.ShouldProcess && attributes.AllowedValues.Value.Exists) + { + var values = RenderJsonValues(attributes.AllowedValues.Value.Values); + if (values.Count > 0) + body.Assignment($"{localName}.Enum", $"new() {{ {string.Join(", ", values)} }}"); + } + + if (attributes.DeniedValues.ShouldProcess && attributes.DeniedValues.Value.Exists) + { + var values = RenderJsonValues(attributes.DeniedValues.Value.Values); + if (values.Count > 0) + { + body.Assignment( + $"{localName}.Not", + $"new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {string.Join(", ", values)} }} }}" + ); + } + } + } + + static void EmitUnsupportedAttributeConstraints(CodeWriter body, ValidationAttributes attributes, string localName) + { + if (attributes.CreditCard.ShouldProcess && attributes.CreditCard.Value.Exists) + body.MethodCallOn( + JsonSchemaRulesTypeName, + "MarkUnsupported", + localName, + "CreditCardAttribute".StringLiteral() + ); + + if (attributes.Phone.ShouldProcess && attributes.Phone.Value.Exists) + body.MethodCallOn(JsonSchemaRulesTypeName, "MarkUnsupported", localName, "PhoneAttribute".StringLiteral()); + } + + static void EmitRangeConstraint(CodeWriter body, GeneratorResult range, string localName) + { + if (!range.ShouldProcess || !range.Value.Exists) + return; + + var data = range.Value; + if (data.Minimum is int minimumInt && data.Maximum is int maximumInt) + { + body.Assignment($"{localName}.Minimum", minimumInt.ToString(CultureInfo.InvariantCulture)); + body.Assignment($"{localName}.Maximum", maximumInt.ToString(CultureInfo.InvariantCulture)); + return; + } + + if (data.Minimum is double minimumDouble && data.Maximum is double maximumDouble) + { + body.Assignment($"{localName}.Minimum", minimumDouble.ToString("R", CultureInfo.InvariantCulture)); + body.Assignment($"{localName}.Maximum", maximumDouble.ToString("R", CultureInfo.InvariantCulture)); + } + } + + static List RenderJsonValues(EquatableArray values) + { + List rendered = [with(values.Count)]; + for (var i = 0; i < values.Count; i++) + { + if (TryRenderJsonValue(values[i], out var expression)) + rendered.Add(expression); + } + + return rendered; + } + + static bool TryRenderJsonValue(TypedConstant constant, out string expression) + { + if (constant.IsNull) + { + expression = "null"; + return true; + } + + expression = constant.Value switch + { + string text => text.StringLiteral(), + char character => character.ToString().StringLiteral(), + bool boolean => boolean ? "true" : "false", + byte value => value.ToString(CultureInfo.InvariantCulture), + sbyte value => value.ToString(CultureInfo.InvariantCulture), + short value => value.ToString(CultureInfo.InvariantCulture), + ushort value => value.ToString(CultureInfo.InvariantCulture), + int value => value.ToString(CultureInfo.InvariantCulture), + uint value => value.ToString(CultureInfo.InvariantCulture), + long value => value.ToString(CultureInfo.InvariantCulture), + ulong value => value.ToString(CultureInfo.InvariantCulture), + float value => value.ToString("R", CultureInfo.InvariantCulture), + double value => value.ToString("R", CultureInfo.InvariantCulture), + decimal value => value.ToString(CultureInfo.InvariantCulture), + _ => string.Empty, + }; + + return expression.Length > 0; + } +} diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 7c234ae..41de244 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -66,6 +66,42 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con ); } + // Convert the base schema (type and its keywords, enum, const, $ref) first, then layer the + // composition keywords on top. An explicit type makes the composition an intersection with the base; + // without one the composition stands alone. This mirrors Zod's fromJSONSchema, and is what lets an + // `allOf` of pattern branches (for example a Guid plus a UUID-version constraint) keep every + // constraint instead of taking only the first branch. + var baseSchema = ConvertBaseSchema(schema, ctx); + var hasExplicitType = schema.Type != null || schema.Enum is { Count: > 0 } || schema.Const != null; + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + ZodUnion union = new(anyOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.OneOf is { Count: > 0 } oneOf) + { + // oneOf is semantically different (exactly one must match) but we approximate with union + ZodUnion union = new(oneOf.Select(s => ConvertSchema(s, ctx)).ToArray()); + baseSchema = hasExplicitType ? Z.Intersection(baseSchema, union) : union; + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + var result = hasExplicitType ? baseSchema : ConvertSchema(allOf[0], ctx); + var start = hasExplicitType ? 0 : 1; + for (var i = start; i < allOf.Count; i++) + result = Z.Intersection(result, ConvertSchema(allOf[i], ctx)); + + baseSchema = result; + } + + return baseSchema; + } + + static IZodSchema ConvertBaseSchema(JsonSchemaDefinition schema, ConversionContext ctx) + { // Handle $ref if (schema.Ref != null) { @@ -91,27 +127,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return CreateLiteralSchema(schema.Const); } - // Handle composition keywords - if (schema.AnyOf != null && schema.AnyOf.Count > 0) - { - var options = schema.AnyOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.OneOf != null && schema.OneOf.Count > 0) - { - // oneOf is semantically different (exactly one must match) but we approximate with union - var options = schema.OneOf.Select(s => ConvertSchema(s, ctx)).ToArray(); - return new ZodUnion(options); - } - - if (schema.AllOf != null && schema.AllOf.Count > 0) - { - // allOf requires all to match - we can only fully support this for objects - // For now, just use the first schema - return ConvertSchema(schema.AllOf[0], ctx); - } - // Handle type if (schema.Type == null) { @@ -119,7 +134,7 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return new AnySchema(); } - var result = schema.Type switch + return schema.Type switch { "string" => ConvertStringSchema(schema), "number" => ConvertNumberSchema(schema, isInteger: false), @@ -130,8 +145,6 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con "array" => ConvertArraySchema(schema, ctx), _ => new AnySchema(), }; - - return result; } [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index a88d5c0..c92451c 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -99,6 +99,14 @@ public class JsonSchemaDefinition /// additionalProperties - Schema for additional properties, or false to disallow public object? AdditionalProperties { get; set; } + // ========== Negation ========== + + /// not - The data must not match this schema (for example a sentinel value that is rejected). + public JsonSchemaDefinition? Not { get; set; } + + /// contentEncoding - The encoding of a string (for example base64). + public string? ContentEncoding { get; set; } + // ========== Composition ========== /// anyOf - Match any of the schemas @@ -143,4 +151,19 @@ public class JsonSchemaDefinition /// nullable - OpenAPI 3.0 nullable flag public bool? Nullable { get; set; } + + // ========== Extensions ========== + + /// + /// Names of validation rules that could not be represented as JSON Schema keywords. An exported schema + /// that lists a rule here is incomplete — a consumer cannot rely on it to reproduce that rule's + /// validation — so the omission is surfaced in the schema rather than dropped silently. + /// + /// + /// Emitted as the vendor extension x-zodsharp-unsupported. The name is declared explicitly so both + /// serializers write it verbatim (Newtonsoft.Json maps it through + /// ZodSharp.JsonSchema.NewtonsoftJson.JsonSchemaNamingStrategy). + /// + [JsonPropertyName("x-zodsharp-unsupported")] + public List? UnsupportedRules { get; set; } } diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs index 2e6cab9..ae0ad15 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaIntrospection.cs @@ -31,3 +31,19 @@ interface IJsonSchemaLiteralInfo /// object? LiteralValue { get; } } + +/// +/// A non-generic view of an enum schema, for the JSON Schema exporter. +/// +/// +/// ZodNativeEnum<TEnum> is generic, so the exporter cannot pattern-match it without naming +/// TEnum. The values are boxed and expressed in their JSON wire form (the numeric value of a native +/// enum, matching System.Text.Json's default enum handling). +/// +interface IJsonSchemaEnumInfo +{ + /// + /// Gets the allowed values, boxed so they are reachable without naming the enum type. + /// + IReadOnlyList EnumValues { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs new file mode 100644 index 0000000..7d669bf --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs @@ -0,0 +1,212 @@ +using System.Globalization; + +namespace ZodSharp.JsonSchema; + +/// +/// Implemented by a schema (or validator) that can describe its own JSON Schema, so +/// can export it. +/// +/// +/// The source generator emits this on every generated {Type}SchemaValidator. That is what lets an +/// attribute-generated [ZodSchema] schema be exported through Z.ToJsonSchema without rebuilding +/// its rules by hand with Z.Object()/Z.String(). +/// +public interface IJsonSchemaProvider +{ + /// Builds the JSON Schema definition for the schema's target type. + /// The JSON Schema definition, without document metadata such as $schema/$id. + JsonSchemaDefinition BuildJsonSchema(); +} + +/// +/// Implemented by a validation rule that can describe its own JSON Schema constraint, so the exporter +/// preserves it instead of dropping it. +/// +/// +/// A rule that cannot be represented (or that simply does not implement this interface) is reported through +/// ; it is never dropped silently. +/// +public interface IJsonSchemaRule +{ + /// Applies this rule's JSON Schema constraint(s) to . + /// The schema being built for the value the rule validates. + void ApplyTo(JsonSchemaDefinition schema); +} + +/// +/// Helpers for applying rules to a schema during export. +/// +public static class JsonSchemaRules +{ + /// + /// Applies to when it implements + /// ; otherwise records the rule as unsupported so the exported schema is + /// visibly incomplete rather than silently weaker. + /// + /// The schema the rule constrains. + /// The rule instance. + public static void Apply(JsonSchemaDefinition schema, object rule) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(rule); + + if (rule is IJsonSchemaRule mapped) + { + mapped.ApplyTo(schema); + return; + } + + MarkUnsupported(schema, rule.GetType().Name); + } + + /// + /// Records in . + /// + /// The schema that could not reproduce the rule. + /// The rule's type name. + public static void MarkUnsupported(JsonSchemaDefinition schema, string ruleName) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrEmpty(ruleName); + + schema.UnsupportedRules ??= []; + if (!schema.UnsupportedRules.Contains(ruleName, StringComparer.Ordinal)) + schema.UnsupportedRules.Add(ruleName); + } + + /// + /// Adds a pattern constraint to without discarding a pattern that is + /// already present. JSON Schema has a single pattern keyword, so two rules that both constrain + /// the string's shape would otherwise silently overwrite one another (last writer wins). + /// + /// The schema the pattern constrains. + /// The pattern to require in addition to any already present. + /// + /// The first pattern uses the pattern keyword, which keeps single-constraint exports unchanged. + /// A second, different pattern promotes the constraints to allOf, with each branch carrying the + /// schema's type: a consumer that discards a type-less branch (for example Zod's + /// fromJSONSchema, which maps one to z.any()) would otherwise drop the constraint. + /// + public static void ConstrainPattern(JsonSchemaDefinition schema, string pattern) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentException.ThrowIfNullOrEmpty(pattern); + + // Fast path: nothing constrains the pattern yet, so the single keyword is enough. + if (schema.Pattern is null && schema.AllOf is not { Count: > 0 }) + { + schema.Pattern = pattern; + return; + } + + if (string.Equals(schema.Pattern, pattern, StringComparison.Ordinal)) + return; + + if ( + schema.Pattern is null + && schema.AllOf is { } branches + && branches.Any(branch => string.Equals(branch.Pattern, pattern, StringComparison.Ordinal)) + ) + { + return; + } + + schema.AllOf ??= []; + + if (schema.Pattern is { Length: > 0 } current) + { + schema.AllOf.Insert(0, PatternBranch(schema.Type, current)); + schema.Pattern = null; + } + + schema.AllOf.Add(PatternBranch(schema.Type, pattern)); + } + + static JsonSchemaDefinition PatternBranch(string? type, string pattern) => + new() { Type = type ?? "string", Pattern = pattern }; + + /// + /// Builds the JSON Schema for a native enum: the defined members, minus any + /// member, in their numeric wire form (System.Text.Json's default enum representation). + /// + /// The enum type. + /// Members excluded from the allowed set. + /// The enum schema. + public static JsonSchemaDefinition EnumSchema(params TEnum[] disallowed) + where TEnum : struct, Enum + { + var disallowedSet = disallowed is { Length: > 0 } ? new HashSet(disallowed) : null; + + return new JsonSchemaDefinition + { + Enum = + [ + .. Enum.GetValues() + .Where(value => disallowedSet is null || !disallowedSet.Contains(value)) + .Select(static value => + Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(TEnum)), CultureInfo.InvariantCulture) + ), + ], + }; + } +} + +/// +/// Converts a rule's typed bound to the a JSON Schema numeric keyword expects. +/// +/// +/// Rules are generic over the value they validate, so the bound is only known at the call site. A bound that +/// is not numeric (for example a bound closed by ZodDate) is reported as +/// unsupported by the caller rather than silently mistranslated. +/// +static class JsonSchemaNumbers +{ + /// + /// Converts to a when it is a numeric type. + /// + /// The boxed bound. + /// The converted value when the method returns . + /// when the value is numeric. + internal static bool TryToDouble(object? value, out double number) + { + switch (value) + { + case byte b: + number = b; + return true; + case sbyte sb: + number = sb; + return true; + case short s: + number = s; + return true; + case ushort us: + number = us; + return true; + case int i: + number = i; + return true; + case uint ui: + number = ui; + return true; + case long l: + number = l; + return true; + case ulong ul: + number = ul; + return true; + case float f: + number = f; + return true; + case double d: + number = d; + return true; + case decimal m: + number = (double)m; + return true; + default: + number = 0; + return false; + } + } +} diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 605a3c3..6b280ac 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -61,6 +61,12 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) while (schema is IJsonSchemaInnerSchema wrapper) schema = wrapper.InnerSchema; + // A generated [ZodSchema] validator (or any schema) that describes its own JSON Schema takes over + // here. This is the bridge that makes an attribute-generated schema exportable without rebuilding + // its rules by hand with Z.Object()/Z.String(). + if (schema is IJsonSchemaProvider provider) + return provider.BuildJsonSchema(); + // Handle circular references if (ctx.Seen.Contains(schema)) { @@ -120,15 +126,20 @@ static JsonSchemaDefinition ConvertString(ZodString schema) result.Format = "uri"; break; - case UUIDRule: - result.Format = "uuid"; + case UUIDRule uuid: + uuid.ApplyTo(result); + break; + + case GuidRule guid: + guid.ApplyTo(result); break; case RegexRule regex: - result.Pattern = regex.Pattern.ToString(); + JsonSchemaRules.ConstrainPattern(result, regex.Pattern.ToString()); break; default: + ApplyUnmappedRule(result, rule); break; } } @@ -178,6 +189,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema) break; default: + ApplyUnmappedRule(result, rule); break; } } @@ -246,6 +258,13 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) var schemaType = schema.GetType(); var typeName = schemaType.Name; + // Handle enum schemas (ZodNativeEnum / ZodEnum) through their introspection seam rather than + // by type name: a generic enum cannot be pattern-matched without naming its type. + if (schema is IJsonSchemaEnumInfo enumInfo) + { + return new JsonSchemaDefinition { Enum = [.. enumInfo.EnumValues] }; + } + // Handle ZodArray. Matched through the internal IJsonSchemaArrayInfo seam rather than by type // name plus private-field reflection: the old lookups named fields and rule types that do not // exist, so element schemas and bounds never reached the exported schema. See IJsonSchemaArrayInfo. @@ -297,4 +316,21 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) // Default: empty schema (any) return new JsonSchemaDefinition(); } + + /// + /// Applies a rule the switch did not recognise. A rule that describes its own JSON Schema constraint + /// 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) + { + if (rule is IJsonSchemaRule mapped) + { + mapped.ApplyTo(schema); + return; + } + + if (rule is IZodRule) + JsonSchemaRules.MarkUnsupported(schema, rule.GetType().Name); + } } diff --git a/src/src/ZodSharp/Rules/AllowedValuesRule.cs b/src/src/ZodSharp/Rules/AllowedValuesRule.cs index a8397c6..6d14d2a 100644 --- a/src/src/ZodSharp/Rules/AllowedValuesRule.cs +++ b/src/src/ZodSharp/Rules/AllowedValuesRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct AllowedValuesRule : Core.IValidationRule, Core.IZodRule +public readonly record struct AllowedValuesRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -59,6 +59,15 @@ public AllowedValuesRule(T[]? values, string? message = null, string? code = nul /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema enum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (_values is { Length: > 0 }) + schema.Enum = [.. _values.Cast()]; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/DeniedValuesRule.cs b/src/src/ZodSharp/Rules/DeniedValuesRule.cs index 83b11c4..961ce1b 100644 --- a/src/src/ZodSharp/Rules/DeniedValuesRule.cs +++ b/src/src/ZodSharp/Rules/DeniedValuesRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct DeniedValuesRule : Core.IValidationRule, Core.IZodRule +public readonly record struct DeniedValuesRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -59,6 +59,15 @@ public DeniedValuesRule(T[]? values, string? message = null, string? code = null /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies a JSON Schema not constraint against the denied values. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (_values is { Length: > 0 }) + schema.Not = new JsonSchema.JsonSchemaDefinition { Enum = [.. _values.Cast()] }; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index bab31b8..b5986af 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct EmailRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct EmailRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -61,6 +65,14 @@ public EmailRule(string? message = null, string? code = null) public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + /// Applies the JSON Schema email string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "email"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index d62d791..bf6ac59 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct EndsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct EndsWithRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -66,6 +72,14 @@ public EndsWithRule( public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _suffix, value.ToString()); + /// Applies a JSON Schema anchored pattern for the required suffix. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_suffix) + "$"); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EnumRule.cs b/src/src/ZodSharp/Rules/EnumRule.cs index 633b166..0012f35 100644 --- a/src/src/ZodSharp/Rules/EnumRule.cs +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace ZodSharp.Rules; /// @@ -18,7 +20,7 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct EnumRule : Core.IValidationRule, Core.IZodRule +public readonly record struct EnumRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where TEnum : struct, Enum { /// Gets the error code reported when the rule fails. @@ -73,6 +75,27 @@ public bool IsValid(in TEnum value) => public string GetErrorMessage(in TEnum value) => RuleMessage.Format(_message ?? MessageFormat, value, typeof(TEnum).Name); + /// + /// Applies the JSON Schema enum constraint listing the defined members that are not disallowed, + /// in their numeric wire form (System.Text.Json's default enum representation). + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + + // A lambda inside a struct cannot capture instance members, so copy the field to a local first. + var disallowed = _disallowed; + schema.Enum = + [ + .. Enum.GetValues() + .Where(value => disallowed is null || Array.IndexOf(disallowed, value) < 0) + .Select(static value => + Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(TEnum)), CultureInfo.InvariantCulture) + ), + ]; + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs index 0f7d264..bb3807c 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct EvenRule : Core.IValidationRule, Core.IZodRule +public readonly record struct EvenRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,14 @@ public EvenRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema multipleOf: 2 constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MultipleOf = 2; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index f1a2246..4fcd560 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule +public readonly record struct FiniteRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,16 @@ public FiniteRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// + /// Applies nothing: JSON has no representation for infinity or NaN, so every JSON number is finite and + /// the constraint is implicit in the number type. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index 34f516e..42d517a 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -6,7 +6,10 @@ namespace ZodSharp.Rules; /// /// The comparable type [Core.ZodRule] -public readonly record struct GreaterThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct GreaterThanOrEqualRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +49,17 @@ public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); + /// Applies the JSON Schema minimum constraint (inclusive). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MinValue, out var minimum)) + schema.Minimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(GreaterThanOrEqualRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index 537e115..7d87ddb 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct GreaterThanRule : Core.IValidationRule, Core.IZodRule +public readonly record struct GreaterThanRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public GreaterThanRule(T exclusiveMinimum, string? message = null, string? code public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMinimum, value); + /// Applies the JSON Schema exclusiveMinimum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(_exclusiveMinimum, out var minimum)) + schema.ExclusiveMinimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(GreaterThanRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs index a3743fc..d9ada14 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -6,7 +6,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct GuidRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct GuidRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -14,6 +18,14 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Invalid GUID: {0}"; + /// + /// The JSON Schema pattern for a canonical 8-4-4-4-12 hexadecimal identifier (any version or + /// variant nibble). The source generator emits this for a , so the exported + /// constraint matches rather than Zod's RFC UUID validator. + /// + public const string JsonSchemaPattern = + "^[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}$"; + readonly string _message; /// @@ -78,6 +90,14 @@ public bool IsValid(ReadOnlySpan value) public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, value.ToString()); + /// Applies a JSON Schema pattern for an 8-4-4-4-12 hexadecimal identifier. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaPattern); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index aceb05b..a523ab9 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct IncludesRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct IncludesRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -58,6 +64,14 @@ public IncludesRule(string substring, string? message = null, string? code = nul public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _substring, value.ToString()); + /// Applies a JSON Schema pattern for the required substring. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_substring)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index a31816c..b3907c1 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule +public readonly record struct IntRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -36,6 +36,14 @@ public IntRule(string? message = null, string? code = null) /// public bool IsValid(in T value) => value % T.One == T.Zero; + /// Applies the JSON Schema integer type. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Type = "integer"; + } + /// /// Gets the error message for a failed validation. /// diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 29aaf32..2d7d5c5 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -6,7 +6,10 @@ namespace ZodSharp.Rules; /// /// The comparable type [Core.ZodRule] -public readonly record struct LessThanOrEqualRule : Core.IValidationRule, Core.IZodRule +public readonly record struct LessThanOrEqualRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +49,17 @@ public LessThanOrEqualRule(T maxValue, string? message = null, string? code = nu /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); + /// Applies the JSON Schema maximum constraint (inclusive). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MaxValue, out var maximum)) + schema.Maximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(LessThanOrEqualRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 6f03e0c..9bc348f 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct LessThanRule : Core.IValidationRule, Core.IZodRule +public readonly record struct LessThanRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public LessThanRule(T exclusiveMaximum, string? message = null, string? code = n public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _exclusiveMaximum, value); + /// Applies the JSON Schema exclusiveMaximum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(_exclusiveMaximum, out var maximum)) + schema.ExclusiveMaximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(LessThanRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 3c71f43..49ab650 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct MaxLengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -66,4 +70,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema maxLength constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MaxLength = MaxLength; + } } diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index afab6a2..7a023fe 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct MaxValueRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MaxValueRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -46,6 +46,17 @@ public MaxValueRule(T maxValue, string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); + /// Applies the JSON Schema maximum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MaxValue, out var maximum)) + schema.Maximum = maximum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MaxValueRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index 986d7a3..e770d38 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct MinLengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; @@ -66,4 +70,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema minLength constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MinLength = MinLength; + } } diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 34c755a..9b1a088 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -6,7 +6,7 @@ namespace ZodSharp.Rules; /// /// The numeric type [Core.ZodRule] -public readonly record struct MinValueRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MinValueRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : IComparable { /// Gets the Zod-compatible error code reported when the rule fails. @@ -47,6 +47,17 @@ public MinValueRule(T minValue, string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); + /// Applies the JSON Schema minimum constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(MinValue, out var minimum)) + schema.Minimum = minimum; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MinValueRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index afca4a8..463ae81 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule +public readonly record struct MultipleOfRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -71,6 +71,17 @@ public bool IsValid(in T value) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, Divisor, value); + /// Applies the JSON Schema multipleOf constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (JsonSchema.JsonSchemaNumbers.TryToDouble(Divisor, out var divisor)) + schema.MultipleOf = divisor; + else + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(MultipleOfRule<>)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index 0440c74..3e923ea 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -27,13 +27,19 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule +public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Value is a sentinel value, but got {0}"; + /// Gets the message format; {0} is a description of the offending sentinel value. + public const string MessageFormat = "Value has a sentinel value, but got {0}"; + + /// + /// The JSON Schema pattern for a canonical Guid that is not the all-zero . + /// + 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}$"; readonly string _message; @@ -55,12 +61,48 @@ 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, where the wire form is known. + /// + /// The schema for the value being validated. + /// + /// 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. + /// + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + + if (typeof(T) == typeof(string)) + { + schema.MinLength = 1; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "\\S"); + return; + } + + if (typeof(T) == typeof(Guid)) + { + // Expressed as a pattern rather than `not: { const: ... }` so it survives converters that do not + // implement JSON Schema `not` (for example Zod's fromJSONSchema). The negative lookahead rejects + // the all-zero UUID while the body requires the canonical 8-4-4-4-12 form. Composed through + // ConstrainPattern so a second pattern (for example a UUID version constraint) is not lost. + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, GuidJsonSchemaPattern); + return; + } + + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(NonSentinelRule<>)); + } + /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + public string GetErrorMessage(in T value) => + RuleMessage.Format(_message ?? MessageFormat, SentinelValues.Describe(value)); /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); @@ -119,4 +161,65 @@ public static bool IsSentinel(in T value) return false; } + + /// + /// Describes the sentinel represents, for use in an error message (for example + /// "an empty GUID"). A value that is not a known sentinel is described by its own string + /// representation. + /// + /// The value to describe. + /// A human-readable description of the value. + public static string Describe(in T value) + { + if (typeof(T) == typeof(Guid)) + return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty ? "an empty GUID" : Raw(in value); + + if (typeof(T) == typeof(DateTime)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTime.MinValue ? "DateTime.MinValue" + : typed == DateTime.MaxValue ? "DateTime.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(DateTimeOffset)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateTimeOffset.MinValue ? "DateTimeOffset.MinValue" + : typed == DateTimeOffset.MaxValue ? "DateTimeOffset.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(DateOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == DateOnly.MinValue ? "DateOnly.MinValue" + : typed == DateOnly.MaxValue ? "DateOnly.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(TimeOnly)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed == TimeOnly.MinValue ? "TimeOnly.MinValue" + : typed == TimeOnly.MaxValue ? "TimeOnly.MaxValue" + : Raw(in value); + } + + if (typeof(T) == typeof(string)) + { + var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + return typed switch + { + null => "null", + { Length: 0 } => "an empty string", + _ when string.IsNullOrWhiteSpace(typed) => "whitespace", + _ => typed, + }; + } + + return Raw(in value); + } + + static string Raw(in T value) => value?.ToString() ?? "null"; } diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs index 9edb38f..dfc656e 100644 --- a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -19,7 +19,10 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct NullOrNonWhiteSpaceRule : Core.IValidationRule, Core.IZodRule +public readonly record struct NullOrNonWhiteSpaceRule + : Core.IValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -47,6 +50,19 @@ public NullOrNonWhiteSpaceRule(string? message = null, string? code = null) /// True if valid, false otherwise public bool IsValid(in string? value) => value is null || !string.IsNullOrWhiteSpace(value); + /// + /// Applies the JSON Schema constraint for a null-or-non-whitespace string: at least one non-whitespace + /// character when the value is present. Whether null is permitted is expressed by the property's + /// nullability, not by this rule. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.MinLength = 1; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "\\S"); + } + /// /// Gets the error message for a failed validation. /// diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs index a5073bf..4ec4793 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -8,7 +8,7 @@ namespace ZodSharp.Rules; /// /// The numeric type; any is supported. [Core.ZodRule] -public readonly record struct OddRule : Core.IValidationRule, Core.IZodRule +public readonly record struct OddRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule where T : INumber { /// Gets the Zod-compatible error code reported when the rule fails. @@ -44,6 +44,14 @@ public OddRule(string? message = null, string? code = null) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema constraint that rejects multiples of two. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Not = new JsonSchema.JsonSchemaDefinition { MultipleOf = 2 }; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index ca5bf3c..a8048a1 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct RegexRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct RegexRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -121,4 +125,12 @@ public bool IsValid(ReadOnlySpan value) string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// Applies the JSON Schema pattern constraint. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Pattern.ToString()); + } } diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs index d8911d8..650bf69 100644 --- a/src/src/ZodSharp/Rules/RequiredRule.cs +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -26,7 +26,11 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct RequiredRule : Core.IValidationRule, Core.IZodRule, Core.IRequiredRule +public readonly record struct RequiredRule + : Core.IValidationRule, + Core.IZodRule, + Core.IRequiredRule, + JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "missing_field"; @@ -88,6 +92,17 @@ public bool IsValid(in T value) /// The error message public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// + /// Applies nothing: presence is expressed by the object schema's required list, which the exporter + /// builds from the property's nullability (and any [Required] attribute), not by a per-property + /// keyword. + /// + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + } + /// Gets the error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index ef9a879..c06f92f 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -5,7 +5,7 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule +public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; @@ -41,6 +41,16 @@ public bool IsValid(in double value) => /// The error message public string GetErrorMessage(in double value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema bounds of a safe integer (an int-range whole number). + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Type = "integer"; + schema.Minimum = int.MinValue; + schema.Maximum = int.MaxValue; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 1be1fef..3824ef7 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace ZodSharp.Rules; /// @@ -5,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct StartsWithRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct StartsWithRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -66,6 +72,14 @@ public StartsWithRule( public string GetErrorMessage(ReadOnlySpan value) => RuleMessage.Format(_message ?? MessageFormat, _prefix, value.ToString()); + /// Applies a JSON Schema anchored pattern for the required prefix. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "^" + Regex.Escape(_prefix)); + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index e1a661d..246edfc 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -5,7 +5,7 @@ namespace ZodSharp.Rules; /// This will allocate by utilising /// [Core.ZodRule] -public readonly record struct UriRule : Core.IValidationRule, Core.IZodRule +public readonly record struct UriRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -44,6 +44,14 @@ public bool IsValid(in string value) => /// The error message public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, _uriKind, value); + /// Applies the JSON Schema uri string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uri"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 4ca3a13..9848fee 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Rules; Justification = "Name is real" )] [Core.ZodRule] -public readonly record struct UrlRule : Core.IValidationRule, Core.IZodRule +public readonly record struct UrlRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -61,6 +61,14 @@ public bool IsValid(in string value) => /// The error message public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + /// Applies the JSON Schema uri string format. + /// The schema for the value being validated. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uri"; + } + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index f901334..be72d44 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -5,7 +5,12 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct UUIDRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct UUIDRule + : Core.IValidationRule, + Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; @@ -78,6 +83,53 @@ public bool IsValid(ReadOnlySpan value) return IsValidVersionless(value); } + /// + /// Validates that the 's bytes form a UUID with the required version and a valid RFC + /// variant. This lets the same rule (and the generated [UUID] attribute) validate a + /// [Scalar<Guid>] value object or a plain member, not just a string. + /// + /// The value to validate. + /// True if valid, false otherwise. + public bool IsValid(in Guid value) + { + Span bytes = stackalloc byte[16]; + value.TryWriteBytes(bytes, bigEndian: true, out _); + + if (_version is UuidVersion version) + return (bytes[6] >> 4) == (int)version && IsValidVariantNibble(bytes[8]); + + // nil and max are allowed regardless of version/variant (Zod parity). + if (IsNilOrMax(bytes)) + return true; + + var versionNibble = bytes[6] >> 4; + return versionNibble is >= 1 and <= 8 && IsValidVariantNibble(bytes[8]); + } + + static bool IsValidVariantNibble(byte b) => (b >> 4) is 8 or 9 or 10 or 11; + + static bool IsNilOrMax(ReadOnlySpan bytes) + { + var first = bytes[0]; + if (first is not (0x00 or 0xFF)) + return false; + + foreach (var b in bytes) + { + if (b != first) + return false; + } + + return true; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in Guid value) => GetErrorMessageCore(value.ToString("D")); + /// /// Gets the error message for a failed validation. /// @@ -138,6 +190,36 @@ static bool HasValidStructure(ReadOnlySpan value) static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; + /// + /// Applies the JSON Schema uuid string format, and a version-pinning pattern when the rule + /// requires a specific RFC 9562 version. + /// + /// The schema for the value being validated. + /// + /// JSON Schema has no standard keyword for the RFC version nibble, and a consumer maps + /// format: "uuid" to a versionless RFC validator (for example Zod's fromJSONSchema). The + /// extra pattern carries the version and variant, so a versioned rule is not silently widened on export. + /// + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "uuid"; + + if (_version is UuidVersion version) + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaVersionPattern(version)); + } + + /// + /// Builds the JSON Schema pattern that pins an RFC 9562 version and the RFC variant nibble. + /// + /// The required version (V1-V8). + /// An anchored pattern for a canonical UUID of the requested version. + /// Thrown when is . + public static string JsonSchemaVersionPattern(UuidVersion version) => + version == UuidVersion.None + ? throw new ArgumentOutOfRangeException(nameof(version), version, "UUID version must be between V1 and V8.") + : $"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-{(int)version}[0-9a-fA-F]{{3}}-[89abAB][0-9a-fA-F]{{3}}-[0-9a-fA-F]{{12}}$"; + /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Schemas/ZodEnum.cs b/src/src/ZodSharp/Schemas/ZodEnum.cs index 8d1c408..743f3ea 100644 --- a/src/src/ZodSharp/Schemas/ZodEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodEnum.cs @@ -12,7 +12,7 @@ namespace ZodSharp.Schemas; /// /// The allowed string values. [SuppressMessage("Naming", "CA1711", Justification = "Name mirrors Zod's z.enum API.")] -public class ZodEnum(HashSet allowedValues) : ZodType +public class ZodEnum(HashSet allowedValues) : ZodType, JsonSchema.IJsonSchemaEnumInfo { static readonly string[] EmptyPath = []; @@ -21,6 +21,8 @@ public class ZodEnum(HashSet allowedValues) : ZodType /// public new IReadOnlyCollection AllowedValues => allowedValues; + IReadOnlyList JsonSchema.IJsonSchemaEnumInfo.EnumValues => [.. allowedValues.Cast()]; + /// /// Validates that the string is one of the allowed values. /// diff --git a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs index 5573e8f..74a3b22 100644 --- a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -13,7 +14,7 @@ namespace ZodSharp.Schemas; /// Initializes a new instance of the class. /// [SuppressMessage("Naming", "CA1711", Justification = "Name mirrors Zod's z.nativeEnum API.")] -public class ZodNativeEnum() : ZodType +public class ZodNativeEnum() : ZodType, JsonSchema.IJsonSchemaEnumInfo where TEnum : struct, Enum { static readonly string[] EmptyPath = []; @@ -22,6 +23,24 @@ public class ZodNativeEnum() : ZodType // Enum.IsDefined reflection path. static readonly HashSet DefinedValues = [.. System.Enum.GetValues()]; + // The JSON Schema wire form of each member. System.Text.Json serialises a native enum as its numeric + // value by default, so the exported enum lists the underlying numbers rather than the member names. + static readonly List JsonEnumValues = + [ + .. System + .Enum.GetValues() + .Select(static value => + (object?) + Convert.ChangeType( + value, + System.Enum.GetUnderlyingType(typeof(TEnum)), + CultureInfo.InvariantCulture + ) + ), + ]; + + IReadOnlyList JsonSchema.IJsonSchemaEnumInfo.EnumValues => JsonEnumValues; + /// /// Validates that the value is a defined enum member. /// diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index e6ff327..634896f 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -1,5 +1,6 @@ using ZodSharp.JsonSchema; using ZodSharp.JsonSchema.NewtonsoftJson; +using ZodSharp.Rules; namespace ZodSharp.Json.Schema; @@ -135,4 +136,30 @@ public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeyw await Assert.That(json).Contains("\"$ref\""); await Assert.That(json).Contains("\"$defs\""); } + + [Test] + public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the shape a Guid scalar with [NonSentinel] exports as: the canonical Guid pattern and the + // non-empty pattern as two allOf branches. + JsonSchemaDefinition definition = new() + { + Type = "string", + AllOf = + [ + new JsonSchemaDefinition { Type = "string", Pattern = GuidRule.JsonSchemaPattern }, + new JsonSchemaDefinition { Type = "string", Pattern = NonSentinelRule.GuidJsonSchemaPattern }, + ], + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert — every branch is applied: the non-RFC Guid passes, the empty Guid and a malformed value fail. + await Assert.That(schema.Validate("9f41585f-31c5-fc4b-0d04-b1fe5b523139").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); + } } diff --git a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs new file mode 100644 index 0000000..abdc9ec --- /dev/null +++ b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs @@ -0,0 +1,329 @@ +using CliWrap; +using CliWrap.Buffered; + +namespace ZodSharp.Repository; + +/// +/// Runs the TypeScript/Zod cross-platform suites in tests/ts/ from the .NET test run, as child +/// processes through CliWrap, so just test proves both halves of the cross-platform parity +/// guarantee instead of only the C# half. +/// +/// +/// +/// The two sides are one guarantee: the C# tests write fixtures under +/// src/tests/cross-platform/ and the vitest suites assert that Zod agrees with what C# produced. +/// Until this existed, the vitest half ran only in the dedicated cross-platform job in +/// .github/workflows/pr.yml, so a TypeScript-side regression could not fail a local run — and a +/// gap in that job's own wiring (a producer it never ran) went unnoticed for a whole release. +/// +/// +/// This is a duplicate of that job's work, deliberately: the job stays the authoritative gate because it +/// controls producer ordering, while these tests make the same failure reproducible with one command on a +/// developer machine. One test per vitest suite keeps a failure attributable to a file, and +/// fails when a suite is added that is +/// not listed here, so the duplication cannot silently fall behind. +/// +/// +/// These tests never skip. A skipped test proves nothing, and the parity guarantee is not optional. +/// The runner is resolved per run: bun when it is on PATH (the shared build pipeline and the +/// cross-platform job both have it), otherwise node against vitest's own entry point. If +/// neither is available, or the install or the run fails, the test fails and says which step and why. +/// +/// +public sealed class CrossPlatformVitestTests +{ + /// + /// How long to wait for a producer's fixtures to appear. src/tests/cross-platform/ is + /// gitignored, so a clean checkout starts with nothing, and the C# producers live in other assemblies + /// that the test platform may still be running in parallel with this one. Waiting removes that race + /// without nesting a second dotnet test inside this one. + /// + static readonly TimeSpan FixtureWaitTimeout = TimeSpan.FromMinutes(2); + + /// + /// Installing dependencies on a cold checkout fetches the whole tree, so this is generous. + /// + static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(10); + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + /// + /// Resolved once per test run: the toolchain probe and any install are shared by every suite, and + /// running two installs concurrently in the same working directory would corrupt node_modules/. + /// + static readonly SemaphoreSlim RunnerGate = new(1, 1); + static VitestRunner? CachedRunner; + + /// + /// A vitest suite and the fixture directory its assertions read. Keep in step with + /// tests/ts/*.test.ts and with the producer steps in .github/workflows/pr.yml. + /// + public sealed record VitestSuite(string TestFile, string FixtureDirectory) + { + public override string ToString() => TestFile; + } + + /// + /// How to invoke vitest. are the leading arguments, to which the suite's + /// test-file path is appended. + /// + sealed record VitestRunner(string Name, string Executable, IReadOnlyList Arguments); + + 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"), + ]; + + [Test] + [MethodDataSource(nameof(Suites))] + public async Task Vitest_GivenACrossPlatformSuite_PassesAgainstTheCSharpFixtures( + VitestSuite suite, + CancellationToken cancellationToken + ) + { + // Arrange + var root = RepositoryRoot.Value; + var runner = await GetRunnerAsync(root, cancellationToken); + + var fixtureDirectory = Path.Combine(root, suite.FixtureDirectory.Replace('/', Path.DirectorySeparatorChar)); + if (!await WaitForFixturesAsync(fixtureDirectory, cancellationToken)) + { + Assert.Fail( + $"No fixtures in '{suite.FixtureDirectory}' after waiting {FixtureWaitTimeout.TotalSeconds:N0}s, so " + + "this suite would verify nothing. Its C# producer did not run — run the whole suite with " + + "`just test`, which covers every producer." + ); + } + + // Act — vitest exits non-zero on failure; capture rather than throw so the output reaches the assertion. + var result = await Cli.Wrap(runner.Executable) + .WithArguments([.. runner.Arguments, suite.TestFile]) + .WithWorkingDirectory(root) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(0) + .Because( + $"vitest ({runner.Name}) failed for {suite.TestFile}:{Environment.NewLine}" + + $"{result.StandardOutput}{Environment.NewLine}{result.StandardError}" + ); + } + + /// + /// Every tests/ts/*.test.ts file must be listed in . Without this, adding a + /// vitest suite leaves it driven only by the CI job, which is the gap this class exists to close. + /// + [Test] + public async Task VitestSuites_GivenEveryTestFileOnDisk_AreAllCovered() + { + // Arrange + var testsDirectory = Path.Combine(RepositoryRoot.Value, "tests", "ts"); + var covered = Suites().Select(suite => Path.GetFileName(suite().TestFile)).ToHashSet(StringComparer.Ordinal); + + // Act + var onDisk = Directory + .EnumerateFiles(testsDirectory, "*.test.ts", SearchOption.AllDirectories) + .Select(Path.GetFileName) + .ToArray(); + + // Assert + await Assert.That(onDisk).IsNotEmpty().Because($"'{testsDirectory}' declares no vitest suites"); + + var uncovered = onDisk.Where(file => !covered.Contains(file!)).ToArray(); + await Assert + .That(uncovered) + .IsEmpty() + .Because( + "every vitest suite needs an entry in Suites() (and a producer step in pr.yml): " + + string.Join(", ", uncovered) + ); + } + + static async Task GetRunnerAsync(string root, CancellationToken cancellationToken) + { + await RunnerGate.WaitAsync(cancellationToken); + try + { + return CachedRunner ??= await ResolveRunnerAsync(root, cancellationToken); + } + finally + { + RunnerGate.Release(); + } + } + + /// + /// Prefers bun, falls back to node, and fails when neither can run the suites. + /// + /// + /// The node path invokes vitest's entry point directly rather than going through npx or + /// node_modules/.bin. Both are unreliable here: the .bin shims are written by whichever + /// package manager installed the tree (a bun install leaves vitest.exe/vitest.bunx and no + /// node shim), and npx resolves to a .cmd or .ps1 shim on Windows, which is not + /// directly executable as a child process. The entry point is declared by vitest's own + /// package.json bin field and is the same file either shim would invoke. + /// + static async Task ResolveRunnerAsync(string root, CancellationToken cancellationToken) + { + var bun = FindOnPath("bun"); + var node = FindOnPath("node"); + + if (bun is null && node is null) + { + Assert.Fail( + "Neither bun nor node is on PATH, so the TypeScript cross-platform suites cannot run. Install " + + "either one: bun is what CI uses, and node is supported as the fallback." + ); + } + + await EnsureDependenciesInstalledAsync(root, bun, cancellationToken); + + if (bun is not null) + return new VitestRunner("bun", bun, ["run", "vitest", "run"]); + + var entryPoint = VitestEntryPoint(root); + if (!File.Exists(entryPoint)) + { + Assert.Fail( + $"vitest's entry point is missing at '{entryPoint}' after installing dependencies, so the " + + "TypeScript cross-platform suites cannot run." + ); + } + + return new VitestRunner("node", node!, [entryPoint, "run"]); + } + + /// + /// Installs node_modules/ when vitest is not already present. node_modules/ is gitignored, + /// so a fresh checkout and a CI runner both start without it. + /// + /// + /// bun installs from bun.lock; npm has no lockfile to read here, so it resolves from + /// package.json. That is why bun is preferred for the install as well as the run. + /// + static async Task EnsureDependenciesInstalledAsync(string root, string? bun, CancellationToken cancellationToken) + { + if (File.Exists(VitestEntryPoint(root))) + return; + + var installer = + bun is not null ? new VitestRunner("bun", bun, ["install", "--frozen-lockfile"]) + : FindOnPath("npm") is { } npm ? new VitestRunner("npm", npm, ["install"]) + : null; + + if (installer is null) + { + Assert.Fail( + $"vitest is not installed at '{VitestEntryPoint(root)}' and neither bun nor npm is on PATH to " + + "install it, so the TypeScript cross-platform suites cannot run." + ); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(InstallTimeout); + + var result = await Cli.Wrap(installer!.Executable) + .WithArguments(installer.Arguments) + .WithWorkingDirectory(root) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(timeout.Token); + + if (result.ExitCode != 0) + { + Assert.Fail( + $"`{installer.Name} {string.Join(' ', installer.Arguments)}` failed with exit code " + + $"{result.ExitCode}, so the TypeScript cross-platform suites cannot run:" + + $"{Environment.NewLine}{result.StandardOutput}{Environment.NewLine}{result.StandardError}" + ); + } + } + + static string VitestEntryPoint(string root) => Path.Combine(root, "node_modules", "vitest", "vitest.mjs"); + + /// + /// Polls for a non-empty fixture directory. Returns false once elapses. + /// + static async Task WaitForFixturesAsync(string fixtureDirectory, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + FixtureWaitTimeout; + + while (true) + { + if ( + Directory.Exists(fixtureDirectory) + && Directory.EnumerateFiles(fixtureDirectory, "*.json", SearchOption.AllDirectories).Any() + ) + return true; + + if (DateTimeOffset.UtcNow >= deadline) + return false; + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + + /// + /// Resolves an executable's full path from PATH, so CliWrap starts it without depending on the host's + /// own resolution. Returns null when it is not found. + /// + static string? FindOnPath(string executable) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? []; + + // On Windows an executable is found by appending a PATHEXT suffix; elsewhere the name is used as-is, + // which the single empty suffix expresses. Only genuinely executable images are considered: a .cmd or + // .ps1 shim cannot be started directly as a child process. + var extensions = OperatingSystem.IsWindows() ? [".EXE", ".COM"] : new[] { string.Empty }; + + foreach (var path in paths.Where(path => !string.IsNullOrWhiteSpace(path))) + { + foreach (var extension in extensions) + { + string candidate; + try + { + candidate = Path.Combine(path, executable + extension); + } + catch (ArgumentException) + { + // A malformed PATH entry is not this test's problem. + continue; + } + + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } + + /// + /// Walks up from the test binaries to the repository root, identified by the markers this repository + /// keeps there. A relative ../../../../../.. hop would silently resolve elsewhere if the output + /// path depth ever changed. + /// + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "vitest.config.ts")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries both AGENTS.md and vitest.config.ts." + ); + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..5f27085 --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + + false + + + + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..4392cc3 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace ZodSharp.Repository; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Skills install into one flat directory per agent, shared with every skill a developer has + // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is + // whichever installed second. + var unprefixed = Evaluator + .Catalogue.Skills.Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-zodsharp", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index 56bdf5a..7cba360 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -9,8 +9,19 @@ namespace ZodSharp.SourceGenerators; /// (RS2000/RS2001), which report at build time when a diagnostic is missing from either file. /// /// -/// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block. The unshipped -/// file declares no rules; the next diagnostic added goes there and moves across when it ships. +/// +/// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block and +/// ZODSGEN045 under ## Release 2.1.0. A rule stays recorded under the release that introduced +/// it, so an existing block is never rewritten. The unshipped file declares no rules; the next diagnostic +/// added goes there and moves across when it ships. +/// +/// +/// accepts a rule row with or without a leading pipe, because the two files are +/// written in different table styles — AnalyzerReleases.Shipped.md delimits rows with pipes and +/// AnalyzerReleases.Unshipped.md does not. Matching only the pipe-delimited form made +/// vacuous: a rule parked in the +/// unshipped file parsed as no rules at all, so the guard passed while the catalogue was wrong. +/// /// public partial class AnalyzerReleaseTrackingTests { @@ -72,6 +83,8 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN042", "ZODSGEN043", "ZODSGEN044", + // Release 2.1.0. + "ZODSGEN045", ]; /// @@ -92,6 +105,7 @@ public async Task ShippedReleases_GivenEveryRelease_DeclareEveryDiagnosticId(Can // Assert — each release block the catalogue has published, and the full rule set across them. await Assert.That(markdown).Contains("## Release 2.0.0"); await Assert.That(markdown).Contains("## Release 2.0.2"); + await Assert.That(markdown).Contains("## Release 2.1.0"); await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedShippedRuleIds)); } @@ -143,7 +157,7 @@ static async Task ReadReleaseFileAsync(string fileName, CancellationToke static string[] ReadRuleIds(string markdown) => [.. RuleIdRegex().Matches(markdown).Select(static match => match.Groups[1].Value)]; - [GeneratedRegex(@"\|[ \t]*(ZODS[A-Z]{3}\d{3})[ \t]*\|")] + [GeneratedRegex(@"(?m)^[ \t]*\|?[ \t]*(ZODS[A-Z]{3}\d{3})[ \t]*\|")] private static partial Regex RuleIdRegex(); static string SortedRuleIds(IEnumerable ruleIds) => diff --git a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs index a826a93..79a6278 100644 --- a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs +++ b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs @@ -36,7 +36,7 @@ CancellationToken cancellationToken var source = Source; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -78,7 +78,7 @@ CancellationToken cancellationToken var source = Source; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var generated = result.GetSource(); // Assert @@ -110,7 +110,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -152,7 +152,7 @@ public static partial class SizedErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -202,7 +202,7 @@ public static partial class SizeErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -243,7 +243,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -285,7 +285,7 @@ public static partial class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -329,7 +329,7 @@ public static class ConcurrentErrorType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -353,7 +353,7 @@ public static partial class SomeType """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs index accc552..9a72769 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs @@ -34,6 +34,8 @@ public sealed class ZodSchemaAttribute : System.Attribute public string? CustomValidationMethodName { get; init; } public bool GenerateIValidateOptions { get; init; } = false; public bool SuppressIValidateOptions { get; init; } = false; + public bool ValidateEnumValues { get; init; } = true; + public bool GenerateJsonSchema { get; init; } = true; } """, ]; diff --git a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj index 977ffb1..9b4a2d4 100644 --- a/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj +++ b/src/tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj @@ -6,6 +6,9 @@ + + diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs new file mode 100644 index 0000000..df84c33 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.JsonSchemaExport.cs @@ -0,0 +1,90 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task JsonSchemaExport_GivenExportedTypeReferencingDisabledNestedType_ProducesZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange — Wrapper exports JSON Schema but Inner opts out, so Wrapper's schema cannot describe it. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } + + [Test] + public async Task JsonSchemaExport_GivenNestedTypeThatExports_ProducesNoZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } + + [Test] + public async Task JsonSchemaExport_GivenOptedOutTypeThatIsNotReferenced_ProducesNoZODSGEN045( + CancellationToken cancellationToken + ) + { + // Arrange — opting a type out is not itself a diagnostic; only a reference from an exported type is. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Standalone + { + public bool Flag { get; set; } + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.NestedSchemaExportDisabled); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs new file mode 100644 index 0000000..e38c109 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.PositionalRecords.cs @@ -0,0 +1,112 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +/// +/// The analyzer resolves the same effective attribute set as the generator, so a validation attribute written +/// on a positional record parameter is reported from the parameter it was applied to. +/// +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task Analyze_GivenRuleAttributeOnPositionalRecordParameterWithoutSchema_ReportsZODSGEN033( + CancellationToken cancellationToken + ) + { + // Arrange - the attribute is applied to the parameter, not to the synthesized property, so the analyzer + // must read it from the parameter to keep reporting a rule that will not run. + const string source = """ + using System; + using ZodSharp.Rules; + + namespace Testing + { + public sealed record Summary([NonSentinel] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } + + [Test] + public async Task Analyze_GivenRuleAttributeOnPositionalRecordParameterWithSchema_ReportsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary([NonSentinel] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RuleAttributeWithoutSchema); + } + + [Test] + public async Task Analyze_GivenUnsupportedDataAnnotationOnPositionalRecordParameter_ReportsZODSGEN006( + CancellationToken cancellationToken + ) + { + // Arrange - an email rule cannot validate a DateTimeOffset, whichever target the attribute lands on. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Summary([EmailAddress] DateTimeOffset LastObservedAt); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedDataAnnotationsUsage); + } + + [Test] + public async Task Analyze_GivenSupportedDataAnnotationOnPositionalRecordParameter_ReportsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedDataAnnotationsUsage); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs index 346be42..6e9f30e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs @@ -23,7 +23,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.AllSyntaxTrees.Length).IsEqualTo(ExpectedFileCount); @@ -42,7 +42,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -74,7 +74,7 @@ public class Empty { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the refinement name is no longer configured by convention; the OnZodValidate hook // is declared by the generator instead. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs index b9245de..25a90c0 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs @@ -23,7 +23,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert var schema = driverResult.GetSource("UserZod.g.cs", HintNameMatchMode.Suffix); @@ -55,7 +55,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -85,7 +85,7 @@ public class User """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("UserSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -117,7 +117,7 @@ public class Basket """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); // Assert @@ -146,7 +146,7 @@ public class Basket """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 161d174..4d4a405 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -90,7 +90,11 @@ public readonly partial record struct InstallationId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("InstallationIdSchema"); // Assert — the rule is closed over the underlying Guid and adapted to the value object. @@ -128,7 +132,11 @@ public readonly partial record struct InstallationId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("InstallationIdSchema"); // Assert @@ -163,7 +171,11 @@ public readonly partial record struct Nickname { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NicknameSchema"); // Assert — the wrapped rule and the adapter's TValue keep the nullable annotation. @@ -194,7 +206,11 @@ public readonly partial record struct Nickname { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NicknameSchema"); // Assert @@ -223,7 +239,11 @@ public readonly partial record struct Score { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("ScoreSchema"); // Assert — the synthesized nullable property is guarded, so null is accepted. @@ -261,12 +281,12 @@ public readonly partial record struct TenantId { } """; // Act - var genericResult = await GenerateAsync( + using var genericResult = await GenerateAsync( genericSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken ); - var typeofResult = await GenerateAsync( + using var typeofResult = await GenerateAsync( typeofSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken @@ -298,7 +318,11 @@ public readonly partial record struct Name { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("NameSchema"); // Assert — [RequiredZod] is closed over the underlying string? and adapted, with an empty path. @@ -348,7 +372,11 @@ public readonly partial record struct ExternalId { } """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("ExternalIdSchema"); // Assert — the wrapped rule owns the identity, not the adapter. @@ -389,7 +417,7 @@ public readonly partial record struct InstallationId """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -415,4 +443,159 @@ public readonly partial record struct InstallationId await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithoutMessage_NamesTheScalarInTheDefaultMessage( + CancellationToken cancellationToken + ) + { + // Arrange — the property is declared so the harness can compile the generated schema. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert — the rule's own default message is reworded to name the scalar, since the error has an + // empty path and nothing else identifies it. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(\"TenantId has a sentinel value, but got {0}\", null!)" + ); + } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithoutMessage_ReportsTheScalarNameAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange — the manual `[Scalar]` form with a declared `Value` property, exactly as a consumer writes it. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.TenantId")!; + var validate = assembly.GetType("Testing.TenantIdSchema")!.GetMethod("Validate")!; + + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + // Assert + await Assert.That((bool)emptyResult.GetType().GetProperty("IsSuccess")!.GetValue(emptyResult)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Message).IsEqualTo("TenantId has a sentinel value, but got an empty GUID"); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + } + + [Test] + public async Task AutomaticScalar_GivenTypeLevelRuleWithMessage_KeepsTheSuppliedMessage( + CancellationToken cancellationToken + ) + { + // Arrange — an explicit message must never be rewritten. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "TenantId must not be empty.")] + [ZodSchema] + public readonly partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generated = driverResult.GetSource("TenantIdSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(\"TenantId must not be empty.\", null!)" + ); + } + + [Test] + public async Task PropertyRule_GivenRuleWithoutMessage_KeepsTheGenericSubject(CancellationToken cancellationToken) + { + // Arrange — a rule on a member reports the member through the error path, so the rule's own default + // message is left untouched. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public partial class Tenant + { + [NonSentinel] + public Guid TenantId { get; set; } + } + } + """; + + // Act + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generated = driverResult.GetSource("TenantSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs index 4d908ba..0adf44f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BasicGen.cs @@ -16,7 +16,7 @@ class BasicModel { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.EnsureValid).ThrowsNothing(); @@ -46,7 +46,7 @@ namespace Testing "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource(); // Assert — generated file starts with auto-generated header diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs index db24042..4f8227a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.BuiltInRuleAttributes.cs @@ -31,7 +31,7 @@ public readonly record struct RequiredRule(string? Message = null) : IValidation """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RequiredZodAttribute"); // Assert - the DataAnnotations name is left alone and the rule's attribute is emitted under the "Zod" @@ -67,7 +67,7 @@ public readonly record struct ThresholdRule(T threshold) : IValidationRule """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ThresholdAttribute"); // Assert @@ -104,7 +104,7 @@ public readonly record struct UpperBoundRule(T maxValue, string? message = nu """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -119,6 +119,7 @@ public readonly record struct UpperBoundRule(T maxValue, string? message = nu await Assert.That(generated).ContainsGeneratedCode("public string? Message { get; set; } = null;"); await Assert.That(attributeType.GetConstructor([typeof(double)])).IsNotNull(); await Assert.That(attributeType.GetConstructor([])).IsNull(); + driverResult.AssertNoCompilationErrors(); } @@ -145,7 +146,11 @@ public partial class Sample // Act - the harness normally rejects a run whose compilation has errors, so validation is disabled here // to assert the error explicitly. - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert - a missing required value is a compile error at the attribute's usage site (CS7036), not a // generator diagnostic that only surfaces when a schema is generated. @@ -176,7 +181,7 @@ public readonly record struct WholeRule(string? message = null) : IValidation """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WholeAttribute"); // Assert @@ -207,7 +212,7 @@ public readonly record struct BlobRule(Guid Blob, string? Message = null) : IVal """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN043"); @@ -237,7 +242,7 @@ public readonly record struct BlobRule(Guid Blob, string? Message = null) : IVal """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN032"); @@ -279,7 +284,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(BuiltInAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(BuiltInAttributeSource, cancellationToken); var generated = driverResult.GetSource("ContactSchema"); // Assert - each shipped attribute resolves to its built-in rule. The regex attribute mirrors the @@ -305,7 +310,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( BuiltInAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -386,7 +391,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(BuiltInScalarAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(BuiltInScalarAttributeSource, cancellationToken); var generated = driverResult.GetSource("TenantIdSchema"); // Assert - the shipped open-generic attribute closes over the scalar's underlying value and the wrapped @@ -412,7 +417,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( BuiltInScalarAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -472,7 +477,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(NewBuiltInAttributeSource, cancellationToken); var generated = driverResult.GetSource("MetricsSchema"); // Assert - the generic bound rules are closed with the member type (their double bound converted to @@ -500,7 +505,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( NewBuiltInAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -561,7 +566,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(OptionalValueAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(OptionalValueAttributeSource, cancellationToken); var generated = driverResult.GetSource("PreferencesSchema"); // Assert - the string comparison and IP family flow into the rule (rendered as their enum value) and @@ -618,7 +623,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(UuidAttributeSource, cancellationToken); + using var driverResult = await GenerateAsync(UuidAttributeSource, cancellationToken); var generated = driverResult.GetSource("IdentifiersSchema"); // Assert - the bare [UUID] maps to the versionless overload and [UUID(UuidVersion.V4)] to the versioned @@ -643,7 +648,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UuidAttributeSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -708,7 +713,7 @@ public partial class Account """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AccountSchema"); // Assert - the open generic is closed with the member type and carries both options. @@ -743,7 +748,7 @@ public sealed class RequiredParityModel } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -792,7 +797,7 @@ public partial class Choice """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ChoiceSchema"); // Assert @@ -825,7 +830,7 @@ public partial class Choice """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -869,7 +874,7 @@ public partial class Rating """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RatingSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs index 73248d0..62b6279 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs @@ -39,7 +39,7 @@ public class Limits } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("LimitsSchema"); await Assert @@ -122,7 +122,7 @@ public class Limits } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs index 78b0639..516c17e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComplexTypes.cs @@ -30,7 +30,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -83,7 +83,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -147,7 +147,7 @@ public class Child } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -205,7 +205,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -257,7 +257,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -317,7 +317,7 @@ public class Child } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs index b316cab..506c559 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CompositionMethods.cs @@ -21,7 +21,7 @@ public class ComposeModel "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("ComposeModelSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("ApplyAnd"); @@ -46,7 +46,7 @@ public class NoComposeModel "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("NoComposeModelSchema"); // Assert @@ -72,7 +72,7 @@ public class RefineModel "; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -127,7 +127,7 @@ public static class CompositionUser // Act — compile and invoke, proving the generated validator can be cast to // IZodSchema and used in the composition API. - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -142,6 +142,7 @@ public static class CompositionUser var getSchemaMethod = helper.GetMethod("GetSchema")!; var schema = getSchemaMethod.Invoke(null, [])!; + await Assert.That(schema).IsNotNull(); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs index 18b9621..444635b 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -41,7 +41,7 @@ public class User public async Task CustomRule_GivenZodRuleMappedAttribute_EmitsRuleValidation(CancellationToken cancellationToken) { // Arrange / Act - var driverResult = await GenerateAsync(CustomRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(CustomRuleSource, cancellationToken); var generated = driverResult.GetSource("UserSchema"); // Assert @@ -58,7 +58,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( CustomRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -116,7 +116,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions { ThrowOnGenerationException = false }, cancellationToken @@ -159,7 +159,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert — a required, non-nullable Code cannot be defaulted to null, so the rule is not emitted. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs index 4da4cf0..1f610ce 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs @@ -15,7 +15,7 @@ namespace Testing public class NoCustom { public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("NoCustomSchema"); // No async state machine @@ -51,7 +51,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithDefaultSchema"); await Assert @@ -85,7 +85,7 @@ internal static ValueTask> ValidateRulesAsync( } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithOverrideSchema"); await Assert.That(generated).ContainsGeneratedCode("await global::Testing.WithOverride.ValidateRulesAsync"); @@ -121,7 +121,7 @@ internal static ValueTask> CustomValidationAsync( // The second overload has 3 params so it won't match — only one valid candidate. // This test verifies the generator doesn't falsely report ambiguity. - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); await Assert.That(driverResult.EnsureValid).ThrowsNothing(); } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs index 43211ca..998b118 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidationRuntime.cs @@ -30,7 +30,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -79,7 +79,7 @@ internal static ValueTask> CustomValidationAsync( } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -133,7 +133,7 @@ internal static ValueTask> CustomValidationAsync } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -184,7 +184,7 @@ internal static ValueTask> CustomValidationA } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs index 9fc5fa4..c7f1800 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -10,7 +10,7 @@ partial class ZodSchemaGeneratorTests [Test] public async Task GeneratedValidate_GivenUserWithValidData_ReturnsSuccess(CancellationToken cancellationToken) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -44,7 +44,7 @@ public async Task GeneratedValidate_GivenInvalidUser_ReturnsFailureWithExpectedP CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -64,7 +64,7 @@ CancellationToken cancellationToken [Test] public async Task GeneratedParse_GivenInvalidUser_ThrowsZodException(CancellationToken cancellationToken) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( UserSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -87,7 +87,7 @@ public async Task Generate_GivenDataAnnotations_GeneratedSourceContainsExpectedV CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync(UserSource, cancellationToken); + using var driverResult = await GenerateAsync(UserSource, cancellationToken); var generatedSource = driverResult.GetSource("UserSchema"); @@ -138,7 +138,7 @@ public sealed class LengthExamples } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("LengthExamplesSchema"); @@ -178,7 +178,7 @@ public sealed class AttributeExamples } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -216,7 +216,7 @@ public sealed class BackslashPatternModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -260,7 +260,7 @@ public sealed class ResourceModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -310,7 +310,7 @@ public sealed class Inventory } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -362,7 +362,7 @@ public sealed class AttributeRuntimeModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -440,7 +440,7 @@ public sealed class FullAttributeCoverageModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -511,7 +511,7 @@ public sealed class StringFormatModel } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("StringFormatModelSchema"); await Assert @@ -565,7 +565,7 @@ public sealed class StringFormatRuntimeModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -627,7 +627,7 @@ public sealed class StringFormatValidModel } "; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs index 859d136..927c3ed 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs @@ -36,7 +36,7 @@ public async Task EnumProperty_GivenNoAttributes_GeneratesEnumRuleValidation(Can const string source = EnumModel; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -60,7 +60,7 @@ public async Task EnumProperty_GivenUndefinedValue_ReportsInvalidEnumValue(Cance const string source = EnumModel; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -91,7 +91,7 @@ public async Task EnumProperty_GivenDefinedValue_ReturnsSuccess(CancellationToke const string source = EnumModel; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -138,7 +138,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -174,7 +174,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -212,7 +212,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -249,7 +249,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert - the denied value is the rule's disallowed set, and no standalone check duplicates it. @@ -290,7 +290,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -341,7 +341,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -380,7 +380,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -434,7 +434,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -489,7 +489,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("ModelSchema"); // Assert @@ -527,7 +527,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -585,7 +585,7 @@ public class Model """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs index 4277301..3641d91 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ErrorMessage.cs @@ -26,7 +26,7 @@ public sealed class RouteOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -65,7 +65,7 @@ public sealed class PagingOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs index 4e86aa6..1a57e46 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs @@ -110,7 +110,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -128,7 +128,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var generated = driverResult.GetSource("AppOptionsSchema"); @@ -146,7 +146,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); @@ -166,7 +166,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsCollectionSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsCollectionSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -187,7 +187,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -208,7 +208,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(AppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -226,7 +226,7 @@ CancellationToken cancellationToken { // Arrange // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( LocalOptionsSource, new ZodSourceGeneratorTestOptions(), cancellationToken @@ -273,7 +273,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AdditionalReferences = [contractReference] }; // Act - var driverResult = await GenerateAsync(NestedAppOptionsSource, options, cancellationToken); + using var driverResult = await GenerateAsync(NestedAppOptionsSource, options, cancellationToken); // Assert var hintNames = GeneratedHintNames(driverResult); @@ -298,7 +298,7 @@ .. driverResult async Task CompileContractAsync(string source, CancellationToken cancellationToken) { ZodSourceGeneratorTestOptions options = new() { CompilationAssemblyName = "Testing.Contracts" }; - var contractRun = await GenerateAsync(source, options, cancellationToken); + using var contractRun = await GenerateAsync(source, options, cancellationToken); using MemoryStream stream = new(); var emitResult = contractRun.CompilationResult.Compilation.Emit(stream, cancellationToken: cancellationToken); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs index 9075225..8be5138 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GeneratedStructure.cs @@ -19,7 +19,7 @@ public class Customer "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("CustomerSchema"); // Assert @@ -41,7 +41,7 @@ public class Customer { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("CustomerSchema"); // Assert @@ -68,7 +68,7 @@ public record Order { } "; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult.GetSource("CustomerSchema")).IsNotEmpty(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index fa6b97e..dfb5433 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -53,7 +53,7 @@ public partial record struct Sequence public async Task GenericRule_GivenUnboundGenericRule_ClosesWithPropertyType(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); var assetSchema = driverResult.GetSource("AssetIdSchema"); var sequenceSchema = driverResult.GetSource("SequenceSchema"); @@ -66,7 +66,7 @@ public async Task GenericRule_GivenUnboundGenericRule_ClosesWithPropertyType(Can public async Task GenericRule_GivenRuleOwningIdentity_EmitsRuleCodeExpression(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(GenericRuleSource, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule implements IZodRule, so its own identity is preferred over the attribute's. @@ -80,7 +80,7 @@ await Assert public async Task GenericRule_GivenScalarLikeStruct_FailsAtRuntimeForEmptyGuid(CancellationToken cancellationToken) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( GenericRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -146,7 +146,7 @@ public class Broken """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("BrokenSchema"); // Assert @@ -222,7 +222,7 @@ CancellationToken cancellationToken // Arrange — the attribute maps to a value-object-only rule and is applied to a `string` member and to // a scalar value object. // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( RuleFamilySource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -257,7 +257,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -314,7 +314,7 @@ public partial class Repository """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -364,7 +364,7 @@ public partial record struct AssetId """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the attribute-level Code wins over the [ZodRule] Code. diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs new file mode 100644 index 0000000..e9d7f66 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -0,0 +1,716 @@ +using System.Collections.Immutable; +using ZodSharp.JsonSchema; +using ZodSharp.Rules; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task JsonSchema_GivenZodSchema_EmitsBuildJsonSchemaMethod(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("BuildJsonSchema"); + await Assert.That(generated).ContainsGeneratedCode("JsonSchemaDefinition"); + } + + [Test] + public async Task JsonSchema_GivenZodSchema_ValidatorAdapterImplementsTheProvider( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert — the adapter is what Z.ToJsonSchema receives, so it must forward to the schema. + await Assert.That(generated).ContainsGeneratedCode("IJsonSchemaProvider"); + await Assert.That(generated).ContainsGeneratedCode("WidgetSchema.BuildJsonSchema()"); + } + + [Test] + public async Task JsonSchema_GivenNonSentinelString_ExportsTheNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [NonSentinel] + public string Name { get; set; } = """"; + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.WidgetSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — a non-nullable string is required and its rules reject null, so the property schema is the + // string shape directly (no null branch). + await Assert.That(definition.Type).IsEqualTo("object"); + await Assert.That(definition.Properties).IsNotNull(); + var nameSchema = definition.Properties!["name"]; + await Assert.That(nameSchema.Type).IsEqualTo("string"); + await Assert.That(nameSchema.MinLength).IsEqualTo(1); + await Assert.That(nameSchema.Pattern).IsEqualTo("\\S"); + await Assert.That(definition.Required!).Contains("name"); + } + + [Test] + public async Task JsonSchema_GivenNestedSchema_ExportsTheNestedDefinition(CancellationToken cancellationToken) + { // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } + + [ZodSchema] + public class Inner + { + public bool Flag { get; set; } + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.WrapperSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert + var innerSchema = definition.Properties!["inner"]; + await Assert.That(innerSchema.Type).IsEqualTo("object"); + await Assert.That(innerSchema.Properties!["flag"].Type).IsEqualTo("boolean"); + } + + [Test] + public async Task JsonSchema_GivenGenerateJsonSchemaFalse_DoesNotEmitTheExportMembers( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Widget + { + public string Name { get; set; } = """"; + } +}"; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("WidgetSchema"); + + // Assert — the validation members are still generated; only the export members are omitted. + await Assert.That(generated).ContainsGeneratedCode("Validate"); + await Assert.That(generated).DoesNotContain("BuildJsonSchema"); + await Assert.That(generated).DoesNotContain("IJsonSchemaProvider"); + } + + [Test] + public async Task JsonSchema_GivenNestedExportDisabled_FallsBackToAnEmptySchema(CancellationToken cancellationToken) + { + // Arrange — Inner opts out of JSON Schema, so Wrapper's exported schema cannot reference it. + const string source = + @" +namespace Testing +{ + [ZodSchema(GenerateJsonSchema = false)] + public class Inner + { + public bool Flag { get; set; } + } + + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var definition = (JsonSchemaDefinition) + assembly.GetType("Testing.WrapperSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the property is an empty (any) schema rather than a reference to a missing method. + var innerSchema = definition.Properties!["inner"]; + await Assert.That(innerSchema.Type).IsNull(); + await Assert.That(innerSchema.Properties).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenFlagsEnum_ExportsAnIntegerNotAnEnumeration(CancellationToken cancellationToken) + { + // Arrange — a [Flags] enum accepts combinations that are not defined members, so enumerating the + // members would reject valid values. + const string source = + @" +using System; + +namespace Testing +{ + [Flags] + public enum Permissions + { + None = 0, + Read = 1, + Write = 2, + } + + [ZodSchema] + public class Widget + { + public Permissions Permissions { get; set; } + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var definition = (JsonSchemaDefinition) + assembly.GetType("Testing.WidgetSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert + var permissions = definition.Properties!["permissions"]; + await Assert.That(permissions.Type).IsEqualTo("integer"); + await Assert.That(permissions.Enum).IsNull(); + } + + [Test] + public async Task JsonSchema_GivenNumericEnum_ExportsNumericValues(CancellationToken cancellationToken) + { + // Arrange — the System.Text.Json default serialises an enum as its numeric value. + const string source = EnumSchemaSource; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum).IsNotNull(); + await Assert.That(status.Enum!).Contains(0); + await Assert.That(status.Enum!).Contains(1); + await Assert.That(status.Enum!).DoesNotContain("Active"); + } + + [Test] + public async Task JsonSchema_GivenStringEnumConverterOnTheEnum_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the enum declares its converter, so System.Text.Json writes the member names. + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum).IsNotNull(); + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + await Assert.That(status.Enum!).DoesNotContain(0); + } + + [Test] + public async Task JsonSchema_GivenStringEnumConverterOnTheProperty_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the converter is on the property rather than the enum. + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + } + + [Test] + public async Task JsonSchema_GivenJsonStringEnumMemberName_ExportsTheCustomName(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +using System.Text.Json.Serialization; + +namespace Testing +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Status + { + [JsonStringEnumMemberName(""active"")] Active = 0, + [JsonStringEnumMemberName(""inactive"")] Inactive = 1, + } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + // Act + var definition = await BuildEnumDefinitionAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("active"); + await Assert.That(status.Enum!).Contains("inactive"); + await Assert.That(status.Enum!).DoesNotContain("Active"); + } + + [Test] + public async Task JsonSchema_GivenAssemblyEnumSerializationString_ExportsMemberNames( + CancellationToken cancellationToken + ) + { + // Arrange — the assembly-level switch matches a global JsonStringEnumConverter. + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property:ZodSharpEnumSerialization", + "String" + ), + }; + + // Act + var definition = await BuildEnumDefinitionAsync(EnumSchemaSource, options, cancellationToken); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("Active"); + await Assert.That(status.Enum!).Contains("Inactive"); + } + + [Test] + public async Task JsonSchema_GivenAssemblyEnumNamingPolicyCamelCase_ExportsCamelCaseNames( + CancellationToken cancellationToken + ) + { + // Arrange + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + AnalyzerConfigOptions = ImmutableDictionary + .Empty.Add("build_property:ZodSharpEnumSerialization", "String") + .Add("build_property:ZodSharpEnumNamingPolicy", "CamelCase"), + }; + + // Act + var definition = await BuildEnumDefinitionAsync(EnumSchemaSource, options, cancellationToken); + + // Assert + var status = definition.Properties!["status"]; + await Assert.That(status.Enum!).Contains("active"); + await Assert.That(status.Enum!).Contains("inactive"); + } + + [Test] + public async Task JsonSchema_GivenNonNullableAndNullableStrings_MatchesValidationRequiredness( + CancellationToken cancellationToken + ) + { + // Arrange — a non-nullable string is required and its rule rejects null; a nullable string keeps a + // null branch. + const string source = + @" +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public sealed class Item + { + [NonSentinel] + public string Name { get; set; } = """"; + + [NullOrNonWhiteSpace] + public string? Branch { get; set; } + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var schemaType = assembly.GetType("Testing.ItemSchema")!; + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the non-nullable string is required with no null branch. + await Assert.That(definition.Required!).Contains("name"); + var name = definition.Properties!["name"]; + await Assert.That(name.Type).IsEqualTo("string"); + await Assert.That(name.MinLength).IsEqualTo(1); + await Assert.That(name.Pattern).IsEqualTo("\\S"); + await Assert.That(name.AnyOf).IsNull(); + + // Assert — the nullable string keeps a null branch and is not required. + await Assert.That(definition.Required!).DoesNotContain("branch"); + var branch = definition.Properties!["branch"].AnyOf!; + await Assert.That(branch.Any(candidate => candidate.Type == "null")).IsTrue(); + var branchText = branch.Single(candidate => candidate.Type == "string"); + await Assert.That(branchText.MinLength).IsEqualTo(1); + await Assert.That(branchText.Pattern).IsEqualTo("\\S"); + + // Assert — runtime validation agrees with the schema for the same fixtures. + var itemType = assembly.GetType("Testing.Item")!; + var validate = schemaType.GetMethod("Validate")!; + + bool IsValid(string? name, string? branch) + { + var item = Activator.CreateInstance(itemType)!; + itemType.GetProperty("Name")!.SetValue(item, name); + itemType.GetProperty("Branch")!.SetValue(item, branch); + var result = validate.Invoke(null, [item])!; + return (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + } + + await Assert.That(IsValid(null, null)).IsFalse(); + await Assert.That(IsValid("", null)).IsFalse(); + await Assert.That(IsValid(" ", null)).IsFalse(); + await Assert.That(IsValid("name", null)).IsTrue(); + await Assert.That(IsValid("name", " ")).IsFalse(); + await Assert.That(IsValid("name", "main")).IsTrue(); + } + + [Test] + public async Task JsonSchema_GivenGuidProperty_ExportsTheCanonicalPatternWithoutUuidFormat( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + + namespace Testing + { + [ZodSchema] + public class Widget + { + public Guid Id { get; set; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert — a C# Guid accepts any 128-bit value, so it must not carry Zod's RFC UUID format. + var id = definition.Properties!["id"]; + await Assert.That(id.Type).IsEqualTo("string"); + await Assert.That(id.Format).IsNull(); + await Assert.That(id.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + } + + [Test] + public async Task JsonSchema_GivenUuidAttributes_ExportsFormatAndVersionPattern(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Widget + { + [UUID] + public string Any { get; set; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string V4 { get; set; } = string.Empty; + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert — the versionless rule is the uuid format alone; the versioned rule pins the version with a + // pattern, since JSON Schema has no versioned uuid format. + var any = definition.Properties!["any"]; + await Assert.That(any.Format).IsEqualTo("uuid"); + await Assert.That(any.Pattern).IsNull(); + + var v4 = definition.Properties!["v4"]; + await Assert.That(v4.Format).IsEqualTo("uuid"); + await Assert.That(v4.Pattern).IsEqualTo(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V4)); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarWithNonSentinel_ComposesBothPatternsThroughAllOf( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + public partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "TenantIdSchema", cancellationToken); + + // Assert — the canonical Guid pattern and the non-empty pattern are both preserved. + await Assert.That(definition.Type).IsEqualTo("string"); + await Assert.That(definition.Format).IsNull(); + await Assert.That(definition.Pattern).IsNull(); + await Assert.That(definition.AllOf!).Count().IsEqualTo(2); + await Assert.That(definition.AllOf!.Select(branch => branch.Pattern)).Contains(GuidRule.JsonSchemaPattern); + await Assert + .That(definition.AllOf!.Select(branch => branch.Pattern)) + .Contains(NonSentinelRule.GuidJsonSchemaPattern); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarWithUuidVersion_ExportsUuidFormatAndVersionPattern( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [UUID(UuidVersion.V7)] + public partial record struct AssetId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "AssetIdSchema", cancellationToken); + + // Assert — a Guid-backed scalar can require a UUID version; the base Guid pattern and the version + // pattern compose, so the version is not lost. + await Assert.That(definition.Type).IsEqualTo("string"); + await Assert.That(definition.Format).IsEqualTo("uuid"); + await Assert.That(definition.AllOf).IsNotNull(); + await Assert.That(definition.AllOf!.Select(branch => branch.Pattern)).Contains(GuidRule.JsonSchemaPattern); + await Assert + .That(definition.AllOf!.Select(branch => branch.Pattern)) + .Contains(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V7)); + } + + [Test] + public async Task JsonSchema_GivenGuidScalarNestedInObjectAndArray_ExportsThePatternAtEachLevel( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + public partial record struct TenantId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class Holder + { + public Guid Plain { get; set; } + + public System.Collections.Generic.List PlainGuids { get; set; } = new(); + + public TenantId Tenant { get; set; } + + public System.Collections.Generic.List Tenants { get; set; } = new(); + } + } + """; + + // Act + var definition = await BuildDefinitionAsync(source, "HolderSchema", cancellationToken); + + // Assert — a plain Guid and a Guid-backed scalar keep their constraint at the property, element and + // nested-scalar level. + await Assert.That(definition.Properties!["plain"].Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + await Assert.That(definition.Properties!["plainGuids"].Items!.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + await Assert.That(definition.Properties!["tenant"].AllOf!).Count().IsEqualTo(2); + await Assert.That(definition.Properties!["tenants"].Items!.AllOf!).Count().IsEqualTo(2); + } + + async Task BuildDefinitionAsync( + string source, + string schemaTypeName, + CancellationToken cancellationToken + ) + { + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + return (JsonSchemaDefinition) + assembly.GetType($"Testing.{schemaTypeName}")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + } + + const string EnumSchemaSource = + @" +namespace Testing +{ + public enum Status { Active = 0, Inactive = 1 } + + [ZodSchema] + public class Widget + { + public Status Status { get; set; } + } +}"; + + async Task BuildEnumDefinitionAsync( + string source, + ZodSourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + // The emitted assembly is loaded into a collectible context; dispose it once the schema has been + // built so the context unloads instead of accumulating for the lifetime of the test run. + using var driverResult = await GenerateAsync(source, options, cancellationToken); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + return (JsonSchemaDefinition) + assembly.GetType("Testing.WidgetSchema")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs new file mode 100644 index 0000000..2341b7c --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs @@ -0,0 +1,256 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using SecurityDriven; +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// Produces the shared Guid/UUID parity artifacts consumed by the TypeScript cross-platform test +/// (tests/ts/guid-uuid-parity.test.ts). Each artifact records a schema exported by the generator +/// (BuildJsonSchema) alongside payloads and the outcome of the generated C# validator, so the TS side +/// can prove that importing the exported schema with Zod's z.fromJSONSchema agrees with C#. +/// +/// +/// The producer lives here because the generator test harness can compile a [ZodSchema] model — and +/// the stand-in Purview.ValueObjects scalar contracts — without the repository referencing the +/// value-objects package. FastGuid is used for the time-bound identifier values the consumer generates; +/// those are deliberately not RFC UUIDs. +/// +public partial class ZodSchemaGeneratorTests +{ + const string ParityOutputRelativePath = "src/tests/cross-platform/guid-parity"; + + static readonly JsonSerializerOptions ParitySerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + const string NonRfcGuid = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + const string PlainGuid = "1025f652-88f2-4b16-b4e1-a82d3b5f47ce"; + const string V4Uuid = "550e8400-e29b-41d4-a716-446655440000"; + const string V7Uuid = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + const string NilUuid = "00000000-0000-0000-0000-000000000000"; + + /// + /// The models compiled for the parity run: a Guid holder (plain, optional, non-empty, list, scalar and + /// scalar list), a UUID holder (versionless, V4, V7), and a bare Guid-backed scalar. + /// + const string GuidParitySource = + "using System.Linq;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\n" + + AutomaticScalarPrelude + + """ + namespace Testing + { + /// Reads/writes a Guid-backed scalar as its canonical string wire form. + public sealed class GuidScalarJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) => + typeToConvert.GetInterfaces().Any(i => + i.IsGenericType + && i.GetGenericTypeDefinition() == typeof(Purview.ValueObjects.IScalarValueObject<,>) + && i.GetGenericArguments()[1] == typeof(Guid)); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter)Activator.CreateInstance( + typeof(GuidScalarJsonConverter<>).MakeGenericType(typeToConvert) + )!; + } + + public sealed class GuidScalarJsonConverter : JsonConverter + where TSelf : Purview.ValueObjects.IScalarValueObject + { + public override TSelf Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetGuid(); + var boxed = Activator.CreateInstance(typeToConvert)!; + typeToConvert.GetProperty("Value")!.SetValue(boxed, value); + return (TSelf)boxed; + } + + public override void Write(Utf8JsonWriter writer, TSelf value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [NonSentinel] + [JsonConverter(typeof(GuidScalarJsonConverterFactory))] + public partial record struct TenantId : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + + [ZodSchema] + public class GuidHolder + { + public Guid Plain { get; set; } + + public Guid? Optional { get; set; } + + [NonSentinel] + public Guid NonEmpty { get; set; } + + public System.Collections.Generic.List PlainGuids { get; set; } = new(); + + public TenantId Tenant { get; set; } + + public System.Collections.Generic.List Tenants { get; set; } = new(); + } + + [ZodSchema] + public class UuidHolder + { + [UUID] + public string Any { get; set; } = string.Empty; + + [UUID(UuidVersion.V4)] + public string V4 { get; set; } = string.Empty; + + [UUID(UuidVersion.V7)] + public string V7 { get; set; } = string.Empty; + } + } + """; + + [Test] + public async Task JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — real time-bound identifier values. FastGuid's database generators are not RFC UUIDs, so + // they must survive the exported schema. + var sqlServerGuid = FastGuid.NewSqlServerGuid(); + var postgreSqlGuid = FastGuid.NewPostgreSqlGuid(); + var v7Guid = FastGuid.UUID.CreateVersion7(); + + using var driverResult = await GenerateAsync( + GuidParitySource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var outputDir = Path.Combine("..", "..", "..", "..", "..", "..", ParityOutputRelativePath); + + // Act & Assert + await WriteParityArtifactAsync( + assembly, + "GuidHolder", + [ + ("reproduction", GuidHolderPayload(NonRfcGuid, PlainGuid), true), + ("fastguid-sqlserver", GuidHolderPayload($"{sqlServerGuid:D}", $"{sqlServerGuid:D}"), true), + ("fastguid-postgresql", GuidHolderPayload($"{postgreSqlGuid:D}", $"{postgreSqlGuid:D}"), true), + ("fastguid-v7", GuidHolderPayload($"{v7Guid:D}", $"{v7Guid:D}"), true), + ("empty-plain", GuidHolderPayload(NilUuid, PlainGuid), true), + ("empty-nonsentinel", GuidHolderPayload(PlainGuid, NilUuid), false), + ("malformed", GuidHolderPayload("not-a-guid", PlainGuid), false), + ], + outputDir, + cancellationToken + ); + + await WriteParityArtifactAsync( + assembly, + "UuidHolder", + [ + ("versionless-accepts-v4", UuidHolderPayload(V4Uuid, V4Uuid, V7Uuid), true), + ("versionless-accepts-v7", UuidHolderPayload(V7Uuid, V4Uuid, V7Uuid), true), + ("non-rfc-rejected-by-versionless", UuidHolderPayload(NonRfcGuid, V4Uuid, V7Uuid), false), + ("v4-mismatch", UuidHolderPayload(V4Uuid, V7Uuid, V7Uuid), false), + ("v7-mismatch", UuidHolderPayload(V7Uuid, V4Uuid, V4Uuid), false), + ], + outputDir, + cancellationToken + ); + + await WriteParityArtifactAsync( + assembly, + "TenantId", + [ + ("reproduction", JsonString(NonRfcGuid), true), + ("fastguid", JsonString($"{sqlServerGuid:D}"), true), + ("empty", JsonString(NilUuid), false), + ("malformed", JsonString("not-a-guid"), false), + ], + outputDir, + cancellationToken + ); + } + + static string GuidHolderPayload(string plain, string nonEmpty) => + $$""" + { + "plain": "{{plain}}", + "nonEmpty": "{{nonEmpty}}", + "plainGuids": ["{{plain}}"], + "tenant": "{{PlainGuid}}", + "tenants": ["{{PlainGuid}}"] + } + """; + + static string UuidHolderPayload(string any, string v4, string v7) => + $$""" + { + "any": "{{any}}", + "v4": "{{v4}}", + "v7": "{{v7}}" + } + """; + + static string JsonString(string value) => $"\"{value}\""; + + static async Task WriteParityArtifactAsync( + Assembly assembly, + string typeName, + IReadOnlyList<(string Name, string Value, bool Valid)> cases, + string outputDir, + CancellationToken cancellationToken + ) + { + var schemaType = assembly.GetType($"Testing.{typeName}Schema")!; + var modelType = assembly.GetType($"Testing.{typeName}")!; + var schema = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + var validate = schemaType.GetMethod("Validate")!; + JsonSerializerOptions jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + List results = [with(cases.Count)]; + foreach (var (name, value, expected) in cases) + { + var actual = ComputeValidity(modelType, validate, value, jsonOptions); + await Assert.That(actual).IsEqualTo(expected).Because($"{typeName}/{name}"); + results.Add(new ParityCase(name, value, actual)); + } + + ParityArtifact artifact = new(typeName, schema, results); + var json = JsonSerializer.Serialize(artifact, ParitySerializerOptions); + + Directory.CreateDirectory(outputDir); + await File.WriteAllTextAsync(Path.Combine(outputDir, $"{typeName}.json"), json, cancellationToken); + } + + static bool ComputeValidity(Type modelType, MethodInfo validate, string value, JsonSerializerOptions jsonOptions) + { + try + { + var model = JsonSerializer.Deserialize(value, modelType, jsonOptions); + if (model is null) + return false; + + var result = validate.Invoke(null, [model])!; + return (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + } + catch (JsonException) + { + // A payload the C# wire contract cannot even deserialize is rejected end to end. + return false; + } + } + + sealed record ParityCase(string Name, string Value, bool Valid); + + sealed record ParityArtifact(string Name, JsonSchemaDefinition Schema, List Cases); +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs index c686457..d8dfc6a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.NestedClasses.cs @@ -25,7 +25,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("InnerSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("private static partial class InnerSchema"); @@ -53,7 +53,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); await Assert.That(driverResult.GetSource("InnerSchema")).IsNotEmpty(); } @@ -80,7 +80,7 @@ private class Inner } }"; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -124,7 +124,7 @@ private class Inner } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("InnerSchema"); await Assert.That(generatedSource).IsNotNull(); @@ -168,7 +168,7 @@ sealed partial class WebAppKitOptions } "; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("WebAppKitOptionsSchema"); await Assert.That(generatedSource).IsNotNull(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs index 4e1c8d1..c626766 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs @@ -35,7 +35,7 @@ public sealed class AdminPortal } """; - var driverResult = await GenerateAsync(source, new ZodSourceGeneratorTestOptions(), cancellationToken); + using var driverResult = await GenerateAsync(source, new ZodSourceGeneratorTestOptions(), cancellationToken); var generated = driverResult.GetSource("AdminPortalSchema"); await Assert @@ -68,7 +68,7 @@ public sealed class AdminPortal } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AdminPortalSchema"); await Assert.That(generated).DoesNotContain("AdminPortalValidator"); @@ -79,7 +79,7 @@ public async Task OptionsValidator_GivenDefaultAutoDetect_GeneratesForOptionsSuf CancellationToken cancellationToken ) { - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( OptionsValidatorSource, new ZodSourceGeneratorTestOptions(), cancellationToken @@ -109,7 +109,7 @@ public sealed class AdminPortalOptions } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AdminPortalOptionsSchema"); await Assert.That(generated).DoesNotContain("AdminPortalOptionsValidator"); @@ -128,7 +128,7 @@ CancellationToken cancellationToken ), }; - var driverResult = await GenerateAsync(OptionsValidatorSource, options, cancellationToken); + using var driverResult = await GenerateAsync(OptionsValidatorSource, options, cancellationToken); var generated = driverResult.GetSource("AdminPortalOptionsSchema"); await Assert.That(generated).DoesNotContain("AdminPortalOptionsValidator"); @@ -162,7 +162,7 @@ public sealed class AdminPortalOptions ), }; - var driverResult = await GenerateAsync(source, options, cancellationToken); + using var driverResult = await GenerateAsync(source, options, cancellationToken); var configSource = driverResult.GetSource("AdminPortalConfigSchema"); await Assert.That(configSource).ContainsGeneratedCode("public sealed partial class AdminPortalConfigValidator"); @@ -190,7 +190,7 @@ public sealed class AdminPortalOptions } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs new file mode 100644 index 0000000..c2b9445 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -0,0 +1,832 @@ +using System.Collections.Immutable; +using System.Reflection; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// Validation attributes written on a positional record parameter are applied to the parameter, not to the +/// synthesized property, whenever the attribute is valid on both targets. The generator must therefore merge +/// the parameter's attributes into the property's, or every rule on a positional record is silently dropped. +/// +partial class ZodSchemaGeneratorTests +{ + /// + /// A [ZodSchema] positional record carrying a rule attribute on a + /// parameter: the case that reported NonSentinelRule<DateTimeOffset> failures as passing. + /// + const string NonSentinelDateTimeOffsetSource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + [Test] + public async Task Generate_GivenNonSentinelOnPositionalDateTimeOffsetParameter_ClosesTheRuleWithThePropertyType( + CancellationToken cancellationToken + ) + { + // Act + using var driverResult = await GenerateAsync(NonSentinelDateTimeOffsetSource, cancellationToken); + var generated = driverResult.GetSource("SummarySchema"); + + // Assert - the open generic is closed with the property's own type, not dropped. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN006"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN030"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN031"); + } + + [Test] + public async Task Generate_GivenNonSentinelOnEverySentinelParameterType_ClosesTheRuleWithEachType( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Identifiers( + [NonSentinel] + Guid Id, + [NonSentinel] + DateTime When, + [NonSentinel] + DateTimeOffset LastObservedAt, + [NonSentinel] + DateOnly Day, + [NonSentinel] + TimeOnly Hour + ); + } + """; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("IdentifiersSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule(null!, null!)"); + } + + [Test] + public async Task Generate_GivenNonSentinelOnPositionalRecordParameter_CarriesTheMessageOverride( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel(Message = "Last observed must not be the default.")] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("SummarySchema"); + + // Assert - the named Message argument flows into the rule like it does for a declared property. + await Assert + .That(generated) + .ContainsGeneratedCode( + """new global::ZodSharp.Rules.NonSentinelRule("Last observed must not be the default.", null!)""" + ); + } + + [Test] + public async Task Generate_GivenBothTargetedAndPropertyTargetedNonSentinel_EmitsTheRuleOnce( + CancellationToken cancellationToken + ) + { + // Arrange - `[NonSentinel]` lands on the parameter and `[property: NonSentinel]` on the property, so the + // merged set must not apply the same attribute type twice. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + [property: NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("SummarySchema")!; + + // Assert — the merged attribute set must not apply the same rule twice. The rule is constructed once + // in Validate and once in BuildJsonSchema (the exported JSON Schema), so a duplicate application would + // show up as four occurrences rather than two. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::ZodSharp.Rules.NonSentinelRule(null!, null!)" + ); + await Assert.That(CountOccurrences(generated, "NonSentinelRule")).IsEqualTo(2); + } + + [Test] + public async Task Generate_GivenDataAnnotationsOnPositionalRecordParameters_ResolvesThem( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account( + [Display(Name = "Account name")] + [Required] + string Name, + [StringLength(10, MinimumLength = 3)] + string Handle, + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset Window + ); + } + """; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var generated = driverResult.GetSource("AccountSchema"); + + // Assert - the display name, the length bounds and the parsed DateTimeOffset bounds all come from the + // parameters, so the emitted validation is the one a declared property would get. + await Assert.That(generated).ContainsGeneratedCode("Required field 'Account name' is null"); + await Assert.That(generated).ContainsGeneratedCode("if (handleLength < 3)"); + await Assert.That(generated).ContainsGeneratedCode("if (handleLength > 10)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.DateTimeOffset RangeMinimum_Window = global::System.DateTimeOffset.Parse(\"2020-01-01\", global::System.Globalization.CultureInfo.InvariantCulture);" + ); + } + + /// + /// The acceptance path for the reported failure: a positional record parameter + /// set to default must be rejected by the generated validator. + /// + [Test] + public async Task GeneratedValidate_GivenDefaultDateTimeOffsetOnPositionalRecord_RejectsTheSummary( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType, [default(DateTimeOffset)])!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Path.Length).IsEqualTo(1); + await Assert.That(errors[0].Path[0]).IsEqualTo("LastObservedAt"); + } + + [Test] + public async Task GeneratedValidate_GivenDateTimeOffsetBoundsOnPositionalRecord_RejectsBothBounds( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + + object?[][] bounds = + [ + [DateTimeOffset.MinValue], + [DateTimeOffset.MaxValue], + ]; + + foreach (var bound in bounds) + { + var summary = Activator.CreateInstance(modelType, bound)!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + } + } + + [Test] + public async Task GeneratedValidate_GivenSetDateTimeOffsetOnPositionalRecord_AcceptsTheSummary( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileSummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType, [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)])!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsTrue(); + } + + /// + /// A positional record shaped like the reported one: every member carries a rule, so only the member whose + /// value is a sentinel is rejected. + /// + const string InventorySummarySource = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + public enum Visibility + { + Public, + Private, + } + + [ZodSchema] + public sealed record InventorySummary( + [NonSentinel] + string Name, + [NonSentinel] + string CanonicalReference, + [NullOrNonWhiteSpace] + string? DefaultBranch, + Visibility Visibility, + bool IsArchived, + [NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_RejectsOnlyTheSentinelMember( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + [ + "changeops", + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + default(DateTimeOffset), + ] + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Path[0]).IsEqualTo("LastObservedAt"); + } + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_WithValidValuesAcceptsIt( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + [ + "changeops", + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), + ] + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenFullPositionalRecord_RejectsASentinelStringToo( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileInventorySummarySchema(cancellationToken); + var modelType = assembly.GetType("Testing.InventorySummary")!; + var summary = Activator.CreateInstance( + modelType, + [ + string.Empty, + "purview-dev/changeops", + null, + Enum.ToObject(modelType.GetProperty("Visibility")!.PropertyType, 1), + false, + new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), + ] + )!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Path[0]).IsEqualTo("Name"); + } + + [Test] + public async Task GeneratedValidate_GivenNonSentinelOnPositionalStringParameter_RejectsAnEmptyValue( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Named([NonSentinel] string Name); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.NamedSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Named")!; + + var emptyResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [string.Empty])!); + var namedResult = InvokeValidate(validate, Activator.CreateInstance(modelType, ["changeops"])!); + + // Assert + await Assert.That(IsSuccess(emptyResult)).IsFalse(); + await Assert.That(IsSuccess(namedResult)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenNullOrNonWhiteSpaceOnPositionalParameter_AcceptsNullButNotWhitespace( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Branch([NullOrNonWhiteSpace] string? DefaultBranch); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.BranchSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Branch")!; + + var nullResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [null])!); + var whitespaceResult = InvokeValidate(validate, Activator.CreateInstance(modelType, [" "])!); + + // Assert + await Assert.That(IsSuccess(nullResult)).IsTrue(); + await Assert.That(IsSuccess(whitespaceResult)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenPropertyTargetedNonSentinel_ReportsASingleError( + CancellationToken cancellationToken + ) + { + // Arrange - the explicit `property:` target already puts the attribute on the property, so merging the + // parameter's copy must not add a second rule. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary( + [NonSentinel] + [property: NonSentinel] + DateTimeOffset LastObservedAt + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.SummarySchema", cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType, [default(DateTimeOffset)])!; + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(GetErrors(result)).HasSingleItem(); + } + + [Test] + public async Task GeneratedValidate_GivenRequiredOnPositionalRecordParameter_ReportsMissingField( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account([Required] string Name); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Account")!; + var missing = Activator.CreateInstance(modelType, [null])!; + + // Act + var result = InvokeValidate(validate, missing); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + await Assert + .That(GetErrors(result).Any(static error => error.Path.Contains("Name") && error.Code == "missing_field")) + .IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenDisplayOnPositionalRecordParameter_ReportsTheDisplayName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Account( + [Display(Name = "Account name")] + [Required] + string Name + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.AccountSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Account")!; + var missing = Activator.CreateInstance(modelType, [null])!; + + // Act + var result = InvokeValidate(validate, missing); + + // Assert + await Assert.That(GetErrors(result)[0].Message).Contains("Account name"); + } + + [Test] + public async Task GeneratedValidate_GivenRangeOnPositionalDateTimeOffsetParameter_EnforcesTheBounds( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed record Window( + [Range(typeof(DateTimeOffset), "2020-01-01", "2030-12-31", ParseLimitsInInvariantCulture = true)] + DateTimeOffset When + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.WindowSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Window")!; + + var inRange = Activator.CreateInstance(modelType, [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)])!; + var outOfRange = Activator.CreateInstance(modelType, [new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)])!; + + // Act + var inRangeResult = InvokeValidate(validate, inRange); + var outOfRangeResult = InvokeValidate(validate, outOfRange); + + // Assert + await Assert.That(IsSuccess(inRangeResult)).IsTrue(); + await Assert.That(IsSuccess(outOfRangeResult)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenRecordStructWithPositionalParameters_ValidatesItsMembers( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public readonly record struct Observation( + [NonSentinel] + DateTimeOffset LastObservedAt, + [NonSentinel] + Guid Id + ); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.ObservationSchema", cancellationToken); + var modelType = assembly.GetType("Testing.Observation")!; + + var invalid = Activator.CreateInstance(modelType, [default(DateTimeOffset), Guid.Empty])!; + var valid = Activator.CreateInstance( + modelType, + [new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), Guid.NewGuid()] + )!; + + // Act + var invalidResult = InvokeValidate(validate, invalid); + var validResult = InvokeValidate(validate, valid); + + // Assert + await Assert.That(IsSuccess(invalidResult)).IsFalse(); + await Assert.That(IsSuccess(validResult)).IsTrue(); + } + + [Test] + public async Task GeneratedValidate_GivenNonPositionalRecord_KeepsValidatingItsDeclaredProperties( + CancellationToken cancellationToken + ) + { + // Arrange - a record whose properties are declared in the body is unaffected by the merge. + const string source = """ + using System; + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed record Summary + { + [NonSentinel] + public DateTimeOffset LastObservedAt { get; init; } + } + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.SummarySchema", cancellationToken); + var modelType = assembly.GetType("Testing.Summary")!; + var summary = Activator.CreateInstance(modelType)!; + modelType.GetProperty("LastObservedAt")!.SetValue(summary, default(DateTimeOffset)); + + // Act + var result = InvokeValidate(validate, summary); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + } + + [Test] + public async Task GeneratedValidate_GivenNestedSchemaAsPositionalParameter_ReportsTheNestedErrors( + CancellationToken cancellationToken + ) + { + // Arrange - the nested schema is discovered through the positional parameter's data annotation. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed class Customer + { + [Required] + public string Name { get; set; } = string.Empty; + } + + [ZodSchema] + public sealed record Order([Required] Customer Customer); + } + """; + + var (assembly, validate) = await CompileAsync(source, "Testing.OrderSchema", cancellationToken); + var customerType = assembly.GetType("Testing.Customer")!; + var orderType = assembly.GetType("Testing.Order")!; + + var invalidCustomer = Activator.CreateInstance(customerType)!; + customerType.GetProperty("Name")!.SetValue(invalidCustomer, null); + + var validCustomer = Activator.CreateInstance(customerType)!; + customerType.GetProperty("Name")!.SetValue(validCustomer, "changeops"); + + // Act + var invalidResult = InvokeValidate(validate, Activator.CreateInstance(orderType, [invalidCustomer])!); + var validResult = InvokeValidate(validate, Activator.CreateInstance(orderType, [validCustomer])!); + + // Assert + await Assert.That(IsSuccess(invalidResult)).IsFalse(); + await Assert.That(IsSuccess(validResult)).IsTrue(); + } + + /// + /// A custom [ZodRule]-mapped attribute written on a positional record parameter, the shape a + /// project-local rule such as [NullOrValidString] takes. + /// + const string CustomRuleOnPositionalParameterSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule + { + public bool IsValid(in string value) => value.IndexOf(' ') < 0; + + public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; + } + + [ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] + public sealed class NoWhitespaceAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [ZodSchema] + public sealed record User([NoWhitespace(Message = "No spaces allowed.")] string Name); + } + """; + + [Test] + public async Task Generate_GivenCustomRuleAttributeOnPositionalRecordParameter_EmitsTheRule( + CancellationToken cancellationToken + ) + { + // Act + using var driverResult = await GenerateAsync(CustomRuleOnPositionalParameterSource, cancellationToken); + var generated = driverResult.GetSource("UserSchema"); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("new global::Testing.NoWhitespaceRule("); + await Assert.That(generated).ContainsGeneratedCode("\"No spaces allowed.\""); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN030"); + await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN031"); + } + + [Test] + public async Task GeneratedValidate_GivenCustomRuleAttributeOnPositionalRecordParameter_FailsValidationAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange + var (assembly, validate) = await CompileAsync( + CustomRuleOnPositionalParameterSource, + "Testing.UserSchema", + cancellationToken + ); + var modelType = assembly.GetType("Testing.User")!; + var instance = Activator.CreateInstance(modelType, ["John Doe"])!; + + // Act + var result = InvokeValidate(validate, instance); + + // Assert + await Assert.That(IsSuccess(result)).IsFalse(); + var errors = GetErrors(result); + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("invalid_string"); + await Assert.That(errors[0].Origin).IsEqualTo("string"); + } + + async Task<(Assembly Assembly, MethodInfo Validate)> CompileSummarySchema(CancellationToken cancellationToken) => + await CompileAsync(NonSentinelDateTimeOffsetSource, "Testing.SummarySchema", cancellationToken); + + async Task<(Assembly Assembly, MethodInfo Validate)> CompileInventorySummarySchema( + CancellationToken cancellationToken + ) => await CompileAsync(InventorySummarySource, "Testing.InventorySummarySchema", cancellationToken); + + async Task<(Assembly Assembly, MethodInfo Validate)> CompileAsync( + string source, + string schemaTypeName, + CancellationToken cancellationToken + ) + { + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var validate = assembly.GetType(schemaTypeName)!.GetMethod("Validate")!; + return (assembly, validate); + } + + static object InvokeValidate(MethodInfo validate, object value) => validate.Invoke(null, [value])!; + + static bool IsSuccess(object result) => (bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!; + + static ImmutableArray GetErrors(object result) => + (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + + static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs index 7c4c475..da48965 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.RuleAttributeGeneration.cs @@ -27,7 +27,7 @@ public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? M """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("NoWhitespaceAttribute"); // Assert @@ -69,7 +69,7 @@ public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? M """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -125,7 +125,7 @@ public readonly record struct OtherRule(string? Message = null) : IValidationRul """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the hand-authored attribute is left alone, the unmapped rule still gets one. await Assert.That(driverResult.GetSource("OtherAttribute")).Contains("class OtherAttribute"); @@ -159,7 +159,7 @@ public sealed class NoWhitespaceAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the hand-authored declaration wins, nothing is generated, and the suppression is reported // rather than silent (previously the hand-authored mapping re-mapped the name with no notice). @@ -209,7 +209,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( GenericRuleMarkerSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -245,7 +245,7 @@ public readonly record struct NonWhiteSpaceStringRule(string? Message = null) ); // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -290,7 +290,7 @@ public readonly record struct SingleRule(int Step = 1, string? Message = null) : """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -344,7 +344,7 @@ public partial class Sample """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -431,7 +431,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert — the generic half of the family is not covered by the declared mapping. await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); @@ -451,7 +451,7 @@ CancellationToken cancellationToken ); // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); @@ -491,7 +491,7 @@ public sealed class WidgetAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN038"); @@ -524,7 +524,7 @@ public sealed class NoSpacesAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN038"); @@ -552,7 +552,7 @@ public readonly record struct RankRule(string? Code = null, string? Message = nu """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("RankAttribute"); // Assert — the warning is raised and the attribute is still generated. @@ -588,7 +588,7 @@ public readonly record struct RankRule(string? Code = null, string? Message = nu """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).DoesNotHaveDiagnostic("ZODSGEN039"); @@ -635,7 +635,7 @@ public partial class Sample """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -676,7 +676,7 @@ public sealed class RankAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostic("ZODSGEN039"); @@ -712,7 +712,7 @@ public sealed class RankAttribute : ValidationAttribute { } """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(driverResult).HasDiagnostics("ZODSGEN039", 1); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 9e5b727..7f14884 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -158,7 +158,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( NullableScalarAdaptationSource, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken @@ -186,7 +186,7 @@ CancellationToken cancellationToken ) { // Act - var driverResult = await GenerateAsync(ScalarAdaptationSource, cancellationToken); + using var driverResult = await GenerateAsync(ScalarAdaptationSource, cancellationToken); var generated = driverResult.GetSource("TenantIdSchema"); // Assert — the rule is constructed for the Guid and wrapped in the scalar adapter, which validates @@ -214,7 +214,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( ScalarAdaptationSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -306,7 +306,7 @@ public partial record struct AssetId : Purview.ValueObjects.IScalarValueObject context) } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("PagingSchema"); await Assert @@ -71,7 +71,7 @@ public partial class Paging } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("PagingSchema"); // The validator neither allocates a context nor invokes the hook when no body is supplied. @@ -98,7 +98,7 @@ public class Paging } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var emitted = driverResult.AllSyntaxTrees.Select(static tree => tree.GetText().ToString()); await Assert @@ -138,7 +138,7 @@ partial void OnZodValidate(RefineCtx context) } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -193,7 +193,7 @@ partial void OnZodValidate(RefineCtx context) } """; - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index 5b622fa..041a7e5 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -53,7 +53,7 @@ public partial record struct AssetId : IScalarValueObject public async Task TypeRule_GivenRuleAttributeOnSchemaType_EmitsWholeObjectRule(CancellationToken cancellationToken) { // Act - var driverResult = await GenerateAsync(TypeRuleSource, cancellationToken); + using var driverResult = await GenerateAsync(TypeRuleSource, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule runs against the value object itself with an empty path. @@ -121,7 +121,7 @@ public partial record struct AssetId : IScalarValueObject """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert @@ -200,7 +200,11 @@ public readonly partial record struct AssetId // Act — the harness normally compiles the generated code, which a single synthetic compilation cannot // do for this shape: the interface the constraint asks for is contributed by another generator, so the // emitted rule is asserted instead of compiled. - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("AssetIdSchema"); // Assert — the rule still closes over the value object and validates the whole value. @@ -217,7 +221,7 @@ CancellationToken cancellationToken ) { // Arrange - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( TypeRuleSource, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -303,7 +307,7 @@ public class Order """; // Act - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("AssetIdSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs index e915338..905be1d 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs @@ -30,7 +30,11 @@ public partial class Broken """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("BrokenSchema"); // Assert @@ -66,7 +70,7 @@ public partial class WithDynamic """; // Act - var driverResult = await GenerateAsync( + using var driverResult = await GenerateAsync( source, new ZodSourceGeneratorTestOptions().Compile(), cancellationToken @@ -106,7 +110,11 @@ public partial class BrokenCollection """; // Act - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var driverResult = await GenerateAsync( + source, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); var generated = driverResult.GetSource("BrokenCollectionSchema"); // Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs index fe911e0..6c24579 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs @@ -30,7 +30,7 @@ internal static ValueTask> CustomValidationAsync( } """; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generated = driverResult.GetSource("WithBothSchema"); // The refinement hook is honoured... diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs index c93d7a4..d7c71a2 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs @@ -18,7 +18,7 @@ public class Widget public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("WidgetSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("class WidgetSchemaValidator"); @@ -28,6 +28,41 @@ public class Widget await Assert.That(generatedSource).ContainsGeneratedCode("return WidgetSchema.Validate(value);"); } + [Test] + public async Task Generate_GivenNestedTypeDeclaredAfterReferrer_KeepsTheNestedTypesOwnValidatorAdapter( + CancellationToken cancellationToken + ) + { + // Arrange — `Wrapper` is declared first, so its attribute context discovers `Inner` as a secondary + // (nested) schema before `Inner`'s own [ZodSchema] context is processed. Deduplication must still + // keep the primary copy, otherwise the nested type silently loses its DI adapter and registration. + const string source = + @" +namespace Testing +{ + [ZodSchema] + public class Wrapper + { + public Inner Inner { get; set; } = new(); + } + + [ZodSchema] + public partial record struct Inner + { + public string? Name { get; set; } + } +}"; + + // Act + using var driverResult = await GenerateAsync(source, cancellationToken); + var schemaSource = driverResult.GetSource("InnerSchema"); + var registrationSource = driverResult.GetSource("InnerSchemaRegistration"); + + // Assert — the primary schema wins, so the adapter and its registration are emitted. + await Assert.That(schemaSource).ContainsGeneratedCode("class InnerSchemaValidator"); + await Assert.That(registrationSource).ContainsGeneratedCode("typeof(global::Testing.InnerSchemaValidator)"); + } + [Test] public async Task Generate_GivenZodSchema_EmitsModuleAttribute(CancellationToken cancellationToken) { @@ -38,7 +73,7 @@ namespace Testing [ZodSchema] public class Gadget { } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var allGenerated = string.Join("\n", driverResult.AllSyntaxTrees.Select(static t => t.GetText().ToString())); await Assert.That(allGenerated).ContainsGeneratedCode("ZodSchemaGenerated"); @@ -60,7 +95,7 @@ public class Gizmo public string? Name { get; set; } } }"; - var driverResult = await GenerateAsync(source, cancellationToken); + using var driverResult = await GenerateAsync(source, cancellationToken); var generatedSource = driverResult.GetSource("GizmoSchema"); await Assert.That(generatedSource).ContainsGeneratedCode("ValidateAsync"); @@ -97,7 +132,7 @@ async ValueTask> CustomValidationAsync(Gizmo value, Canc }} }} }}"; - var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.Compile, cancellationToken); + using var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.Compile, cancellationToken); var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); var gizmoType = assembly.GetType("Testing.Gizmo"); diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index 6cd4467..622184d 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -1,5 +1,6 @@ using ZodSharp.JsonSchema; using ZodSharp.JsonSchema.SystemTextJson; +using ZodSharp.Rules; namespace ZodSharp.Json.Schema; @@ -148,4 +149,30 @@ public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeyw await Assert.That(json).Contains("\"$ref\""); await Assert.That(json).Contains("\"$defs\""); } + + [Test] + public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the shape a Guid scalar with [NonSentinel] exports as: the canonical Guid pattern and the + // non-empty pattern as two allOf branches. + JsonSchemaDefinition definition = new() + { + Type = "string", + AllOf = + [ + new JsonSchemaDefinition { Type = "string", Pattern = GuidRule.JsonSchemaPattern }, + new JsonSchemaDefinition { Type = "string", Pattern = NonSentinelRule.GuidJsonSchemaPattern }, + ], + }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert — every branch is applied: the non-RFC Guid passes, the empty Guid and a malformed value fail. + await Assert.That(schema.Validate("9f41585f-31c5-fc4b-0d04-b1fe5b523139").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse(); + await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse(); + } } diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs new file mode 100644 index 0000000..98b17a6 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaGuidTests.cs @@ -0,0 +1,128 @@ +using System.Text.RegularExpressions; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// Coverage for Guid/UUID export semantics. A C# accepts any 128-bit value, so it must +/// not be exported as Zod's RFC UUID format; a UUID rule must keep its version; and two rules that both +/// constrain pattern must not overwrite one another. +/// +public class ToJsonSchemaGuidTests +{ + const string NonRfcGuid = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + + [Test] + public async Task ToJsonSchema_GivenGuidString_ExportsCanonicalPatternWithoutUuidFormat( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().Guid()); + + // Assert — no `format`, so a consumer does not apply its RFC UUID validator. + await Assert.That(jsonSchema.Type).IsEqualTo("string"); + await Assert.That(jsonSchema.Format).IsNull(); + await Assert.That(jsonSchema.Pattern).IsEqualTo(GuidRule.JsonSchemaPattern); + } + + [Test] + public async Task ToJsonSchema_GivenGuidString_AcceptsNonRfcAndRejectsMalformed(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var pattern = Z.ToJsonSchema(Z.String().Guid()).Pattern!; + + // Assert — the reproduction value (version nibble `f`, variant nibble `0`) is a valid Guid. + await Assert.That(Regex.IsMatch(NonRfcGuid, pattern)).IsTrue(); + await Assert.That(Regex.IsMatch(Guid.Empty.ToString("D"), pattern)).IsTrue(); + await Assert.That(Regex.IsMatch("9f41585f-31c5-fc4b-0d04-b1fe5b52313", pattern)).IsFalse(); + await Assert.That(Regex.IsMatch("not-a-guid", pattern)).IsFalse(); + } + + [Test] + public async Task ToJsonSchema_GivenVersionlessUuid_ExportsUuidFormatOnly(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().UUID()); + + // Assert + await Assert.That(jsonSchema.Format).IsEqualTo("uuid"); + await Assert.That(jsonSchema.Pattern).IsNull(); + } + + [Test] + public async Task ToJsonSchema_GivenVersionedUuid_ExportsUuidFormatAndVersionPattern( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().UUID(UuidVersion.V4)); + + // Assert — the version is carried by the pattern, since JSON Schema has no versioned uuid format. + await Assert.That(jsonSchema.Format).IsEqualTo("uuid"); + await Assert.That(jsonSchema.Pattern).IsEqualTo(UUIDRule.JsonSchemaVersionPattern(UuidVersion.V4)); + await Assert.That(Regex.IsMatch("550e8400-e29b-41d4-a716-446655440000", jsonSchema.Pattern!)).IsTrue(); + await Assert.That(Regex.IsMatch("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", jsonSchema.Pattern!)).IsFalse(); + } + + [Test] + public async Task ToJsonSchema_GivenTwoPatternRules_PreservesBothThroughAllOf(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act — StartsWith and EndsWith both constrain `pattern`; neither may overwrite the other. + var jsonSchema = Z.ToJsonSchema(Z.String().StartsWith("a").EndsWith("z")); + + // Assert + await Assert.That(jsonSchema.Pattern).IsNull(); + await Assert.That(jsonSchema.AllOf).IsNotNull(); + await Assert.That(jsonSchema.AllOf!).Count().IsEqualTo(2); + await Assert.That(jsonSchema.AllOf!.Select(branch => branch.Pattern)).Contains("^a"); + await Assert.That(jsonSchema.AllOf!.Select(branch => branch.Pattern)).Contains("z$"); + } + + [Test] + public async Task ConstrainPattern_GivenTwoPatterns_PromotesToTypedAllOfBranches( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.ConstrainPattern(schema, "^a"); + JsonSchemaRules.ConstrainPattern(schema, "z$"); + + // Assert — each branch carries the type, because Zod's fromJSONSchema drops a type-less branch. + await Assert.That(schema.Pattern).IsNull(); + await Assert.That(schema.AllOf!).Count().IsEqualTo(2); + await Assert.That(schema.AllOf!.All(branch => branch.Type == "string")).IsTrue(); + } + + [Test] + public async Task ConstrainPattern_GivenTheSamePatternTwice_KeepsASingleKeyword(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.ConstrainPattern(schema, "^a"); + JsonSchemaRules.ConstrainPattern(schema, "^a"); + + // Assert + await Assert.That(schema.Pattern).IsEqualTo("^a"); + await Assert.That(schema.AllOf).IsNull(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs new file mode 100644 index 0000000..8841807 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRuleMappingTests.cs @@ -0,0 +1,127 @@ +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// Coverage for the rules the JSON Schema exporter now maps explicitly, and for the unsupported-rule +/// marker that replaces the silent empty fallback. +/// +public class ToJsonSchemaRuleMappingTests +{ + [Test] + public async Task ToJsonSchema_GivenNonSentinelString_ExportsNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().NonSentinel()); + + // Assert + await Assert.That(jsonSchema.Type).IsEqualTo("string"); + await Assert.That(jsonSchema.MinLength).IsEqualTo(1); + await Assert.That(jsonSchema.Pattern).IsEqualTo("\\S"); + } + + [Test] + public async Task ToJsonSchema_GivenNullOrNonWhiteSpace_ExportsNonWhitespaceConstraint( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().AddRule(new NullOrNonWhiteSpaceRule())); + + // Assert + await Assert.That(jsonSchema.MinLength).IsEqualTo(1); + await Assert.That(jsonSchema.Pattern).IsEqualTo("\\S"); + } + + [Test] + public async Task Apply_GivenNonSentinelGuid_ExportsUuidPatternThatRejectsTheEmptyGuid( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — a Guid scalar is validated through its underlying value, so the rule is applied to the + // string schema the generator builds rather than added to a ZodString. + JsonSchemaDefinition schema = new() { Type = "string", Format = "uuid" }; + + // Act + JsonSchemaRules.Apply(schema, new NonSentinelRule()); + + // Assert + await Assert.That(schema.Pattern).IsNotNull(); + await Assert.That(schema.Pattern!).Contains("00000000-0000-0000-0000-000000000000"); + } + + [Test] + public async Task ToJsonSchema_GivenNativeEnum_ExportsTheDefinedValues(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var jsonSchema = Z.ToJsonSchema(Z.Enum()); + + // Assert + await Assert.That(jsonSchema.Enum).IsNotNull(); + await Assert.That(jsonSchema.Enum!).Count().IsEqualTo(3); + await Assert.That(jsonSchema.Enum!).Contains(0); + await Assert.That(jsonSchema.Enum!).Contains(1); + await Assert.That(jsonSchema.Enum!).Contains(2); + } + + [Test] + public async Task Apply_GivenRuleWithoutJsonSchemaMapping_ReportsItAsUnsupported( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + JsonSchemaDefinition schema = new() { Type = "string" }; + + // Act + JsonSchemaRules.Apply(schema, new UnmappedRule()); + + // Assert + await Assert.That(schema.UnsupportedRules).IsNotNull(); + await Assert.That(schema.UnsupportedRules!).Contains(nameof(UnmappedRule)); + } + + [Test] + public async Task EnumSchema_GivenDisallowedMember_ExcludesIt(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var schema = JsonSchemaRules.EnumSchema(SampleStatus.Disabled); + + // Assert + await Assert.That(schema.Enum).IsNotNull(); + await Assert.That(schema.Enum!).DoesNotContain(2); + } +} + +enum SampleStatus +{ + Active = 0, + Inactive = 1, + Disabled = 2, +} + +/// A rule with no JSON Schema mapping, standing in for a consumer rule that has not opted in. +readonly record struct UnmappedRule : IValidationRule, IZodRule +{ + public bool IsValid(in string value) => true; + + public string GetErrorMessage(in string value) => "unmapped"; + + string? IZodRule.Code => null; + + string? IZodRule.Origin => null; +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs index 9c4672b..0f0eb41 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/NonSentinelRuleTests.cs @@ -13,9 +13,11 @@ public async Task IsValid_GivenGuidEmpty_ReturnsFalse() // Act var isValid = rule.IsValid(Guid.Empty); + var isDefaultValid = rule.IsValid(default); // Assert await Assert.That(isValid).IsFalse(); + await Assert.That(isDefaultValid).IsFalse(); } [Test] @@ -40,10 +42,12 @@ public async Task IsValid_GivenDateTimeBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateTime.MinValue); var maxIsValid = rule.IsValid(DateTime.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -68,10 +72,12 @@ public async Task IsValid_GivenDateTimeOffsetBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateTimeOffset.MinValue); var maxIsValid = rule.IsValid(DateTimeOffset.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -96,10 +102,12 @@ public async Task IsValid_GivenDateOnlyBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(DateOnly.MinValue); var maxIsValid = rule.IsValid(DateOnly.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -124,10 +132,12 @@ public async Task IsValid_GivenTimeOnlyBounds_ReturnsFalse() // Act var minIsValid = rule.IsValid(TimeOnly.MinValue); var maxIsValid = rule.IsValid(TimeOnly.MaxValue); + var defaultIsValid = rule.IsValid(default); // Assert await Assert.That(minIsValid).IsFalse(); await Assert.That(maxIsValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -154,9 +164,11 @@ public async Task IsValid_GivenNullEmptyOrWhitespaceString_ReturnsFalse(string? // Act var isValid = rule.IsValid(value!); + var defaultIsValid = rule.IsValid(default!); // Assert await Assert.That(isValid).IsFalse(); + await Assert.That(defaultIsValid).IsFalse(); } [Test] @@ -196,11 +208,38 @@ public async Task GetErrorMessage_GivenNoCustomMessage_FormatsDefaultMessage() var expected = string.Format( CultureInfo.CurrentCulture, CompositeFormat.Parse(NonSentinelRule.MessageFormat), - Guid.Empty + "an empty GUID" ); // Assert await Assert.That(message).IsEqualTo(expected); + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got an empty GUID"); + } + + [Test] + public async Task GetErrorMessage_GivenEmptyString_DescribesTheSentinel() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var message = rule.GetErrorMessage(""); + + // Assert + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got an empty string"); + } + + [Test] + public async Task GetErrorMessage_GivenDateTimeMinValue_DescribesTheSentinel() + { + // Arrange + NonSentinelRule rule = new(); + + // Act + var message = rule.GetErrorMessage(DateTime.MinValue); + + // Assert + await Assert.That(message).IsEqualTo("Value has a sentinel value, but got DateTime.MinValue"); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs new file mode 100644 index 0000000..836dca6 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs @@ -0,0 +1,71 @@ +namespace ZodSharp.Rules; + +/// +/// A is a UUID, so the UUID rule validates its bytes directly. This is what lets a +/// [Scalar<Guid>] or a plain member carry a UUID-version constraint through the +/// generated [UUID] attribute. +/// +public class UuidRuleGuidTests +{ + const string Nil = "00000000-0000-0000-0000-000000000000"; + const string Max = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + const string V4 = "550e8400-e29b-41d4-a716-446655440000"; + const string V7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + + // Version nibble `f` and variant nibble `0`, so it is a valid Guid but not an RFC UUID. + const string NonRfc = "9f41585f-31c5-fc4b-0d04-b1fe5b523139"; + + [Test] + [Arguments(Nil, true)] + [Arguments(Max, true)] + [Arguments(V4, true)] + [Arguments(V7, true)] + [Arguments(NonRfc, false)] + public async Task Versionless_GivenGuid_MatchesTheRfcUuidPredicate(string value, bool expected) + { + // Arrange + UUIDRule rule = new(); + + // Act + var isValid = rule.IsValid(Guid.Parse(value)); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + [Arguments(UuidVersion.V4, V4, true)] + [Arguments(UuidVersion.V4, V7, false)] + [Arguments(UuidVersion.V4, Nil, false)] + [Arguments(UuidVersion.V4, NonRfc, false)] + [Arguments(UuidVersion.V7, V7, true)] + [Arguments(UuidVersion.V7, V4, false)] + [Arguments(UuidVersion.V7, NonRfc, false)] + public async Task Versioned_GivenGuid_RequiresTheDeclaredVersion(UuidVersion version, string value, bool expected) + { + // Arrange + UUIDRule rule = new(version); + + // Act + var isValid = rule.IsValid(Guid.Parse(value)); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task Versioned_GivenGuid_UsesTheVersionedMessageFormat(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + UUIDRule rule = new(UuidVersion.V4); + + // Act + var message = rule.GetErrorMessage(Guid.Parse(V7)); + + // Assert + await Assert.That(message).Contains("v4"); + await Assert.That(message).Contains(V7); + } +} diff --git a/tests/ts/guid-uuid-parity.test.ts b/tests/ts/guid-uuid-parity.test.ts new file mode 100644 index 0000000..e60f32d --- /dev/null +++ b/tests/ts/guid-uuid-parity.test.ts @@ -0,0 +1,58 @@ +/** + * Guid/UUID cross-platform parity: C# (Purview.ZodSharp) -> Zod. + * + * The C# side (SourceGenerators.UnitTests) exports each generated schema with `BuildJsonSchema()` and writes + * a shared payload set with the outcome of the generated C# validator. This suite imports each exported + * schema with Zod's built-in `z.fromJSONSchema()` and checks it agrees with C# for the same payloads — no + * hand-written TypeScript equivalent schemas. + */ +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", "guid-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("Guid/UUID 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) { + 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); + }); + } + } +});