From 616e8e69d2405ac0284ae7564a500c8cab3579b1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 22 Oct 2026 19:48:58 +0100 Subject: [PATCH] feat: validate and export dictionaries as map schemas Resolve a collection's per-entry type from IEnumerable, so a Dictionary<,> validates its values instead of its keys. Generated validators report errors at ["Prop", key], honour AllowNulls for values, and follow nested [ZodSchema] value types. Export dictionaries as { type: object, additionalProperties }, map size constraints to minProperties/maxProperties, and give ZodRecord the same export plus additionalProperties import. JSON Schema import reads an object with additionalProperties as a record. Bump the package version to 2.4.0. --- CHANGELOG.md | 47 ++ bun.lock | 170 +++---- docs/wiki/Arrays-and-Other-Schemas.md | 8 +- docs/wiki/Cross-Platform-Interop.md | 7 + docs/wiki/JsonSchema-Export.md | 25 +- docs/wiki/JsonSchema-Import.md | 21 + docs/wiki/Release-Flow.md | 2 +- docs/wiki/Source-Generator-DataAnnotations.md | 2 +- docs/wiki/Source-Generator.md | 64 ++- package.json | 6 +- skills/purview-zodsharp-diagnostics/SKILL.md | 2 +- skills/purview-zodsharp-json/SKILL.md | 5 + .../SKILL.md | 8 + src/Directory.Build.targets | 8 +- .../NewtonsoftJson/FromJsonSchemaParser.cs | 159 ++++++- .../Helpers/SourceGenLibrary.cs | 178 +++++--- .../Models/SchemaGenerationModel.cs | 27 +- .../ZodSchemaGenerator.BuildSchema.cs | 201 +++++++-- ...ZodSchemaGenerator.CollectionValidators.cs | 10 +- .../ZodSchemaGenerator.JsonSchema.cs | 39 +- .../SystemTextJson/FromJsonSchemaParser.cs | 158 ++++++- .../ZodSharp/CollectionBehaviorAttribute.cs | 6 +- .../JsonSchema/IJsonSchemaRecordInfo.cs | 23 + .../JsonSchema/JsonSchemaDefinition.cs | 6 + .../JsonSchema/ToJsonSchemaConverter.cs | 11 + src/src/ZodSharp/Schemas/ZodRecord.cs | 32 +- src/src/ZodSharp/Sdk/README.md | 2 + .../ZodSharpSchemaDefaultsAttribute.cs | 2 +- .../Json/NewtonsoftJsonRecordTests.cs | 75 ++++ .../Json/SystemTextJsonRecordTests.cs | 94 ++++ ...dSchemaAnalyzerTests.CollectionBehavior.cs | 54 +++ .../ZodSchemaGeneratorTests.Dictionaries.cs | 421 ++++++++++++++++++ .../JsonSchema/ToJsonSchemaRecordTests.cs | 47 ++ .../Schemas/ZodRecordTests.cs | 44 ++ 34 files changed, 1647 insertions(+), 317 deletions(-) create mode 100644 src/src/ZodSharp/JsonSchema/IJsonSchemaRecordInfo.cs create mode 100644 src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonRecordTests.cs create mode 100644 src/tests/JsonInterop.UnitTests/Json/SystemTextJsonRecordTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRecordTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd5ba1..8b17ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,53 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## 2.4.0 + +### Added + +- **Dictionary validation.** A property whose type implements `IDictionary` or + `IReadOnlyDictionary` (`Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, + `ImmutableDictionary`, custom implementations) is now a map rather than a sequence. The generated validator + iterates the entries, validates every **value** against the value type's nested schema — discovered + recursively, with or without its own `[ZodSchema]` — and reports a failure at the entry's key + (`["Variants", "blue"]`), matching Zod's `z.record` issue path. Keys are used only in error paths. + `AllowNulls`, and therefore `[CollectionBehavior]`, `[ZodSchema(AllowNulls = ...)]`, the assembly default + and `ZodSharpAllowNulls`, now govern a map's values. `[Length]`/`[MinLength]`/`[MaxLength]` count entries. +- **Dictionary JSON Schema export.** A dictionary exports as + `{ "type": "object", "additionalProperties": }` instead of an array of pairs, and its size + constraints export as `minProperties`/`maxProperties` (new `JsonSchemaDefinition.MinProperties` and + `MaxProperties` members). A nullable value exports a `null` branch on `additionalProperties`. +- **Record JSON Schema export and import.** `Z.ToJsonSchema(Z.Record(valueSchema))` now exports the same + object/`additionalProperties` shape, and `Z.FromJsonSchema` imports an object with `additionalProperties` + as a record in both JSON integration packages (`additionalProperties: true` treats every member as `any`). + See [JSON Schema Export](docs/wiki/JsonSchema-Export.md#dictionaries) and + [JSON Schema Import](docs/wiki/JsonSchema-Import.md#records). +- **`IEnumerable` properties validate their elements.** A property declared as the enumerable interface + itself now resolves its element type, validates elements and exports its `items` schema, like `T[]`. + +### Fixed + +- **Collection element resolution for dictionaries.** The element type was taken from whichever + `IEnumerable`-derived interface appeared first, so a `Dictionary` resolved its **key** type + (`string`) as the element. The generated validator then emitted `if (item is null)` over a + `KeyValuePair` (`CS0037`), exported `items: {"type":"string"}`, and skipped the value schema + entirely. The per-entry type now comes from `IEnumerable` / the dictionary value. +- **`ZodRecord` null values.** A null value is now the value schema's decision: `Z.Record(Z.String())` + rejects it (matching `z.record(z.string(), T)`), while a nullable value schema accepts it. Previously null + values were silently accepted and dropped. Re-keyed errors also preserve the inner issue's `origin`, + `minimum`, `maximum`, `inclusive` and `category`. +- **Nullable value-type elements export their base schema.** `List` / `Dictionary` now + export `integer` (plus the `null` branch where allowed) instead of the empty schema `Nullable` + produced. + +### Upgrade notes + +- A `Dictionary` property is now validated per value rather than per key. If a dictionary was + relying on the old (incorrect) behaviour, add the value type's `[ZodSchema]` where desired; no source change + is required otherwise. +- `Z.Record(nonNullableValueSchema)` now rejects a null dictionary value. Use a nullable/optional value + schema to keep accepting one. + ## 2.3.0 ### Added diff --git a/bun.lock b/bun.lock index 0b62f9f..04a6ac1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,182 +5,132 @@ "": { "name": "zodsharp", "devDependencies": { - "vitest": "^5.0.0", - "zod": "^4.6.2", + "vitest": "^5.0.5", + "zod": "^4.6.7", }, }, }, "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="], + "@vitest/mocker": ["@vitest/mocker@5.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="], + "@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "@vitest/mocker": ["@vitest/mocker@5.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "@vitest/spy": ["@vitest/spy@5.0.0", "", {}, "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "magic-string": ["magic-string@1.3.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - "rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - "tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="], + "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], - "vitest": ["vitest@5.0.0", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.0", "@vitest/browser-preview": "5.0.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.0", "@vitest/coverage-v8": "5.0.0", "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q=="], + "vitest": ["vitest@5.0.5", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], - "zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], } } diff --git a/docs/wiki/Arrays-and-Other-Schemas.md b/docs/wiki/Arrays-and-Other-Schemas.md index 61e48e3..2c67ef7 100644 --- a/docs/wiki/Arrays-and-Other-Schemas.md +++ b/docs/wiki/Arrays-and-Other-Schemas.md @@ -84,6 +84,12 @@ var schema2 = Z.Literal(42); var schema = Z.Record(Z.Number()); ``` +A null value is the value schema's decision: `Z.Record(Z.String())` rejects it, while +`Z.Record(Z.Optional(Z.String()))` accepts it. The schema exports and imports as +`{ "type": "object", "additionalProperties": … }` (see +[JSON Schema Export](JsonSchema-Export.md#dictionaries)), and a generated `Dictionary` +property is validated the same way. + ## Tuple `ZodTuple` validates fixed-length tuples. Two- and three-element overloads exist; input is `object?[]`. @@ -119,4 +125,4 @@ optional.Validate(null); // Success optional.Validate("value"); // Success ``` -For a generated collection, element nullability follows the element type: `T[]`/`List` rejects `null` elements, while `T?[]`/`List` accepts them — the same distinction as `z.array(T)` versus `z.array(T.nullable())`. See [Collection null items](Source-Generator.md#collection-null-items). \ No newline at end of file +For a generated collection, element nullability follows the element type: `T[]`/`List` rejects `null` elements, while `T?[]`/`List` accepts them — the same distinction as `z.array(T)` versus `z.array(T.nullable())`. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) is a map instead: values are validated like elements, and it maps to `z.record(z.string(), V)`. See [Collection null items](Source-Generator.md#collection-null-items). \ No newline at end of file diff --git a/docs/wiki/Cross-Platform-Interop.md b/docs/wiki/Cross-Platform-Interop.md index b0a846f..c856748 100644 --- a/docs/wiki/Cross-Platform-Interop.md +++ b/docs/wiki/Cross-Platform-Interop.md @@ -45,6 +45,13 @@ The same interop goal is available without fixtures via JSON Schema: A generated collection maps to Zod's array element type: `T[]` / `List` ↔ `z.array(T)`, and a nullable element (`T?[]`, `List`, or a property that allows nulls via `[CollectionBehavior(AllowNulls = true)]`) ↔ `z.array(T.nullable())`. A collection whose elements may be null exports `items.anyOf: [ …, { "type": "null" } ]`, and both `z.fromJSONSchema` and `Z.FromJsonSchema` read that shape back. Null elements are rejected by default, so a generated `T[]` matches `z.array(T)` exactly — see [Collection null items](Source-Generator.md#collection-null-items). +A dictionary property (`IDictionary` / `IReadOnlyDictionary`) is a map: +`Dictionary` ↔ `z.record(z.string(), V)`. The generated schema exports +`{ "type": "object", "additionalProperties": … }` (with `minProperties`/`maxProperties` for size constraints), +and its failures use the same key-prefixed paths as `z.record`. Both `z.fromJSONSchema` and +`Z.FromJsonSchema` read that shape back as a record — see +[JSON Schema Export](JsonSchema-Export.md#dictionaries). + The temporal parity suite (`tests/ts/temporal-parity.test.ts`) proves the same loop for `[NonSentinel]` on `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` under the default preserve-offset contract, including the offset-equivalent bounds. The C# producer diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index 3cd8dd8..f94e87b 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -43,9 +43,10 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ ## Supported schema types -`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`. +`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodRecord`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`. - Objects emit `additionalProperties: false`. +- Records (`Z.Record(valueSchema)`) emit `{ "type": "object", "additionalProperties": }`. - 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`). @@ -125,6 +126,28 @@ A type that exports JSON Schema must not reference a type whose export is disabl analyzer reports **ZODSGEN045** naming the property and the disabled type, so the gap is surfaced at build time rather than shipped silently. +## Dictionaries + +System.Text.Json writes a dictionary as a JSON object, so a generated dictionary property +(`IDictionary` / `IReadOnlyDictionary`) exports an object rather than an array: + +```json +"variants": { + "type": "object", + "additionalProperties": { "type": "object", "properties": { … } } +} +``` + +- `additionalProperties` is the **value** type's schema. A nested `[ZodSchema]` value type is referenced + inline, exactly as an element schema is for an array; a type whose export is disabled falls back to `{}` + and reports `ZODSGEN045`. +- A nullable value (`Dictionary`, `Dictionary`, or + `[CollectionBehavior(AllowNulls = true)]`) wraps the value schema in + `anyOf: [ …, { "type": "null" } ]`, mirroring the sequence `items` branch. +- `[Length]`/`[MinLength]`/`[MaxLength]` export `minProperties`/`maxProperties` (not `minItems`/`maxItems`). +- The TypeScript counterpart is `z.record(z.string(), V)`. Importing an object with `additionalProperties` + produces a record schema — see [JSON Schema Import](JsonSchema-Import.md#records). + ## Validation rules and JSON Schema Every rule that can describe its own JSON Schema constraint implements `ZodSharp.JsonSchema.IJsonSchemaRule`. diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index ab15b62..67432a6 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -43,6 +43,27 @@ var result = userSchema.Validate(userData); - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. A nullable item schema (`items.anyOf` containing a `{"type":"null"}` branch) imports as a nullable element, matching `z.array(T.nullable())` and the shape the generated exporter emits for an allow-null collection. +- Records — an object with no `properties` and an `additionalProperties` schema imports as a record: every member is validated by the value schema, and a failure is reported at the member's key (`["a"]`). `additionalProperties: true` treats every member as `any`; `false` (and an absent value) keeps the object contract. + +## Records + +```csharp +var schema = Z.FromJsonSchema( + """ + { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + } + """ +); + +schema.Validate(new Dictionary { ["a"] = "x" }); // Success +schema.Validate(new Dictionary { ["a"] = "" }); // Failure at ["a"] +``` + +This is the shape the generated exporter writes for a dictionary property, so a generated dictionary schema +round-trips through `Z.FromJsonSchema` in either integration package. The result is a record schema (the +`Z.Record` shape), not an object with fixed properties. ## Limitations diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 4fe8a3e..7cf2a99 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,7 +4,7 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g ## Versioning -The package version comes from `package.json` (`version` field). The current stable line is `2.2.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). +The package version comes from `package.json` (`version` field). The current stable line is `2.4.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 1ac70ed..1e6fc3c 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -86,7 +86,7 @@ var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); ## Collection null items -A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`). Control it per property with `[CollectionBehavior(AllowNulls = true/false)]`, per element type with `T?`, per type with `[ZodSchema(AllowNulls = ...)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]` and the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins. See [Collection null items](Source-Generator.md#collection-null-items) for the full precedence, and `ZODSGEN046` for the attribute-applicability warning. +A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`). Control it per property with `[CollectionBehavior(AllowNulls = true/false)]`, per element type with `T?`, per type with `[ZodSchema(AllowNulls = ...)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]` and the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) applies the same setting to its **values**, and reports a failure at the entry's key (`["Items", "a"]`). See [Collection null items](Source-Generator.md#collection-null-items) for the full precedence, and `ZODSGEN046` for the attribute-applicability warning. ## Range diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 3b2a8d7..0d3bc5f 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -61,7 +61,7 @@ All options are optional. | `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`. | -| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements. Unset applies the assembly default; `true`/`false` overrides it for this type. See [Collection null items](#collection-null-items). | +| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements (or `null` dictionary values). Unset applies the assembly default; `true`/`false` overrides it for this type. See [Collection null items](#collection-null-items). | > [!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. @@ -172,6 +172,46 @@ A collection property rejects `null` elements by default: the generated validato Precedence, highest first: the property attribute, the element's `T?` annotation, the containing type's `[ZodSchema]` option, the assembly attribute, the `ZodSharpAllowNulls` MSBuild property, then the built-in default (reject). +### Dictionaries + +A property whose type implements `IDictionary` or `IReadOnlyDictionary` is a +**map**, not a sequence: `Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, `ImmutableDictionary` +and custom implementations all qualify. The generated validator iterates the entries, validates each +**value** against the value type's nested schema (discovered recursively, with or without its own +`[ZodSchema]`), and reports a failure at the entry's key — `["Variants", "blue"]` — matching Zod's +`z.record` issue path. Keys are used only in error paths; they are not validated. + +The null-items setting above applies to a map's **values**: `Dictionary` rejects a null +value by default, while `Dictionary` or `[CollectionBehavior(AllowNulls = true)]` accepts +one. `Dictionary` has no null case at all, so `[CollectionBehavior]` on it is reported as +`ZODSGEN046`. `[Length]`/`[MinLength]`/`[MaxLength]` count entries, exactly as they count elements for a +sequence. + +```csharp +using ZodSharp; + +[assembly: ZodSharpSchemaDefaults(AllowNulls = false)] + +[ZodSchema] +public class RepositoryInventoryResponse +{ + // Rejected by default: a null element reports invalid_type at ["Items", index]. + public RepositoryInventoryItem[] Items { get; set; } = []; + + // Explicit opt-in for one property. + [CollectionBehavior(AllowNulls = true)] + public RepositoryInventoryItem[] OptionalItems { get; set; } = []; + + // A map: the setting governs null values, and failures are reported at ["Variants", key]. + public Dictionary Variants { get; set; } = []; +} +``` + +> [!NOTE] +> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` on a property that is not a collection of nullable elements (or a dictionary of nullable values) is reported as `ZODSGEN046`. + +A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported. A dictionary exports `{ "type": "object", "additionalProperties": … }` — see [JSON Schema Export](JsonSchema-Export.md#dictionaries). + ## DateTimeOffset wire form The JSON Schema generated for `[NonSentinel] DateTimeOffset` depends on the wire form assumed for the value. @@ -211,28 +251,6 @@ package's `UtcDateTimeOffsetConverter` (and `PreserveOffset` under a global UTC narrows the accepted wire form to a zero offset. See [JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). -```csharp -using ZodSharp; - -[assembly: ZodSharpSchemaDefaults(AllowNulls = false)] - -[ZodSchema] -public class RepositoryInventoryResponse -{ - // Rejected by default: a null element reports invalid_type at ["Items", index]. - public RepositoryInventoryItem[] Items { get; set; } = []; - - // Explicit opt-in for one property. - [CollectionBehavior(AllowNulls = true)] - public RepositoryInventoryItem[] OptionalItems { get; set; } = []; -} -``` - -> [!NOTE] -> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` on a property that is not a collection of nullable elements is reported as `ZODSGEN046`. - -A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported. - ## Automatic enum validation Every non-flags enum property is validated automatically: the generated validator rejects a value that is not a defined member of the enum type. The check is emitted as an `EnumRule` (see [Validation Rules Reference](Validation-Rules-Reference.md#enum-rules)) and reports `invalid_enum_value`. diff --git a/package.json b/package.json index b5ed0cc..cb55986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.3.0", + "version": "2.4.0", "private": true, "license": "MIT", "author": { @@ -21,8 +21,8 @@ "generate-fixtures": "bun run src/ts/generate-fixtures.ts" }, "devDependencies": { - "vitest": "^5.0.0", - "zod": "^4.6.2" + "vitest": "^5.0.5", + "zod": "^4.6.7" }, "engines": { "bun": ">=1.4.2" diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md index 4ef7c60..101e872 100644 --- a/skills/purview-zodsharp-diagnostics/SKILL.md +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -91,7 +91,7 @@ All of these mean the method the generator was told to call cannot be called: | ID | Meaning | | --- | --- | -| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements (a scalar, or a collection whose element cannot be `null`), so the setting can never take effect. | +| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. | ## Unions, value objects, JSON Schema diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index 2109cf5..adaedda 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -147,6 +147,11 @@ Things worth knowing: `z.array(T.nullable())`. A collection whose elements may be null exports `items.anyOf: [ …, { "type": "null" } ]`; by default the generated validator rejects a null element with `invalid_type` at the element's path, matching `z.array(T)`. +- **Dictionaries map to `z.record(z.string(), V)`.** A property implementing `IDictionary<,>` or + `IReadOnlyDictionary<,>` exports `{ "type": "object", "additionalProperties": }` (a nullable value + wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`. + `Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with + `additionalProperties` back as a record in both JSON integrations. ## JSON Schema import diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 228a53d..31d140e 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -90,6 +90,14 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; an unset level defers to the next. An explicit `AllowNulls = true` exports a `null` branch on the collection's `items` schema. +- **Dictionaries are maps, not arrays.** A property whose type implements `IDictionary` + or `IReadOnlyDictionary` (for example `Dictionary`, `SortedDictionary`, + `ConcurrentDictionary`, `ImmutableDictionary`) validates every **value** against the value type's + nested schema and reports a failure at `["Items", "key"]`. Keys are used only in error paths, and + `AllowNulls` governs a null value (`Dictionary` rejects it by default; `Child?` or + `[CollectionBehavior(AllowNulls = true)]` accepts it). A dictionary exports as + `{ "type": "object", "additionalProperties": … }` and `[Length]`/`[MinLength]`/`[MaxLength]` map to + `minProperties`/`maxProperties` — never `z.array`/`items`. - Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if requested). diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 99f8bea..5cde60d 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -7,14 +7,14 @@ version ships. This lives in the repo because the baseline is package-specific; Purview.BuildSdk has no default for it. - 2.3.0 validates against 2.2.0 with no suppressions: the temporal rule and DateTimeOffset wire-form - additions are additive public API. An intended change from here needs a CompatibilitySuppressions.xml next - to the project that owns it (generate one with + 2.4.0 validates against 2.3.0 with no suppressions: the dictionary validation, record JSON Schema and + minProperties/maxProperties additions are additive public API. An intended change from here needs a + CompatibilitySuppressions.xml next to the project that owns it (generate one with `dotnet pack /p:GenerateCompatibilitySuppressionFile=true`), and that file is then the evidence behind the matching upgrade note — a change that is not listed in both is a defect. --> true - 2.2.0 + 2.3.0 diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index c041bba..0246696 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -230,6 +230,17 @@ static IZodSchema ConvertNumberSchema(JsonSchemaDefinition schem )] static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schema, ConversionContext ctx) { + // An object with no declared properties whose additionalProperties is a schema is a record: every + // member is validated by the same value schema. This is the shape the generated exporter writes for a + // dictionary property, and matches Zod's z.record(z.string(), V). + if ( + schema.Properties is not { Count: > 0 } + && TryConvertAdditionalProperties(schema.AdditionalProperties, ctx, out var recordValueSchema) + ) + { + return new RecordSchemaWrapper(recordValueSchema); + } + var builder = Z.Object(); HashSet requiredSet = [.. schema.Required ?? []]; @@ -254,6 +265,66 @@ static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schem return new ObjectSchemaWrapper(builder.Build()); } + /// + /// Converts the additionalProperties value of an object schema to the record's value schema. A + /// schema object (or a true wildcard) means the object is a record; false or an absent value + /// means it is not. + /// + /// The raw value. + /// The conversion context. + /// The converted value schema, when the object is a record. + /// when the object is a record. + static bool TryConvertAdditionalProperties( + object? additionalProperties, + ConversionContext ctx, + out IZodSchema valueSchema + ) + { + switch (additionalProperties) + { + case JsonSchemaDefinition definition: + valueSchema = ConvertSchema(definition, ctx); + return true; + + // A string-parsed schema keeps additionalProperties as a JObject, because the model declares the + // member as object. + case JObject nested: + var parsed = nested.ToObject( + JsonSerializer.Create(JsonSchemaSerializerOptions.Reading) + ); + valueSchema = parsed is null ? new AnySchema() : ConvertSchema(parsed, ctx); + return true; + + case true: + valueSchema = new AnySchema(); + return true; + + default: + valueSchema = null!; + return false; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance" + )] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static object? ConvertJToken(JToken token) + { + return token.Type switch + { + JTokenType.String => token.Value(), + JTokenType.Integer => token.Value(), + JTokenType.Float => token.Value(), + JTokenType.Boolean => token.Value(), + JTokenType.Null => null, + JTokenType.Object => token, + JTokenType.Array => token, + _ => token, + }; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "CA1859:Use concrete types when possible for improved performance" @@ -533,26 +604,82 @@ public ValueTask> ValidateAsync( object value, CancellationToken cancellationToken = default ) => new(Validate(value)); + } - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", - "CA1859:Use concrete types when possible for improved performance" - )] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] - static object? ConvertJToken(JToken token) + /// + /// Validates a JSON object as a record: every member is validated by valueSchema and a failure is + /// reported at the member's key, matching Z.Record and Zod's z.record issue path. + /// + class RecordSchemaWrapper(IZodSchema valueSchema) : IZodSchema + { + public ValidationResult Validate(object value) { - return token.Type switch + Dictionary dict; + + if (value is Dictionary d) { - JTokenType.String => token.Value(), - JTokenType.Integer => token.Value(), - JTokenType.Float => token.Value(), - JTokenType.Boolean => token.Value(), - JTokenType.Null => null, - JTokenType.Object => token, - JTokenType.Array => token, - _ => token, - }; + dict = d; + } + else if (value is JObject jo) + { + dict = []; + foreach (var prop in jo.Properties()) + dict[prop.Name] = ConvertJToken(prop.Value); + } + else + { + return ValidationResult.Failure( + new ValidationError( + "invalid_type", + $"Expected object, but got {value?.GetType().Name ?? "null"}", + [] + ) + ); + } + + Dictionary validated = []; + List errors = []; + + foreach (var (key, item) in dict) + { + var result = valueSchema.Validate(item!); + if (!result.IsSuccess) + { + foreach (var error in result.Errors) + { + var path = new string[error.Path.Length + 1]; + path[0] = key; + error.Path.CopyTo(0, path, 1, error.Path.Length); + errors.Add( + ValidationError.Create( + error.Code, + error.Message, + System.Collections.Immutable.ImmutableArray.Create(path), + error.Parameters, + error.Origin, + error.Minimum, + error.Maximum, + error.Inclusive, + error.Category + ) + ); + } + } + else + { + validated[key] = result.Value; + } + } + + return errors.Count > 0 + ? ValidationResult.Failure(errors) + : ValidationResult.Success(validated); } + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); } class ArraySchemaWrapper(IZodSchema elementSchema, int? minItems, int? maxItems) diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 42c2015..2367af3 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -213,17 +213,7 @@ var property in symbol static bool IsPropertyWithNestedSchema(IPropertySymbol property, ExternalSchemaResolver externalSchemas) { var propertyType = TypeHelpers.UnwrapNullableType(property.Type); - if (propertyType is IArrayTypeSymbol arrayType) - propertyType = arrayType.ElementType; - else if (propertyType is INamedTypeSymbol namedType) - { - var enumerable = namedType.AllInterfaces.FirstOrDefault( - TypeLibrary.System.Collections.Generic.IEnumerable.Equals - ); - if (enumerable is not null) - propertyType = enumerable.TypeArguments[0]; - } - + propertyType = GetCollectionItemTypeSymbol(propertyType) ?? propertyType; propertyType = TypeHelpers.UnwrapNullableType(propertyType); return propertyType is INamedTypeSymbol nested && externalSchemas.IsSchemaOwnedByThisCompilation(nested); } @@ -399,6 +389,8 @@ originalPropertyType is INamedTypeSymbol validationKind, null, false, + false, + false, null, null, new(string.Empty, string.Empty, false), @@ -484,14 +476,12 @@ originalPropertyType is INamedTypeSymbol var diagnostics = ImmutableArray.CreateBuilder(); var validationKind = GetPropertyValidationKind(propertyType, originalPropertyType, externalSchemas); - var elementType = - validationKind == PropertyValidationKind.Collection - ? GetCollectionElementTypeIdentity(originalPropertyType) - : null; - var elementTypeCanBeNull = - validationKind == PropertyValidationKind.Collection - && elementType is not null - && TypeHelpers.CanBeNull(GetCollectionElementTypeSymbol(originalPropertyType) ?? originalPropertyType); + var collectionItem = + validationKind == PropertyValidationKind.Collection ? GetCollectionItemInfo(originalPropertyType) : default; + var elementType = collectionItem.Identity; + var elementTypeCanBeNull = collectionItem.CanBeNull; + var elementTypeIsNullableValueType = collectionItem.IsNullableValueType; + var isMap = collectionItem.IsMap; var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas); var lengthAccessor = ClassifyLengthAccessor(originalPropertyType); var validationAttributes = GetValidationAttributes(property, compilation); @@ -629,6 +619,8 @@ rangeAttribute with validationKind, elementType, elementTypeCanBeNull, + elementTypeIsNullableValueType, + isMap, allowNulls, nestedSchemaType, lengthAccessor, @@ -682,9 +674,11 @@ static bool NestedSchemaExportsJsonSchema( var targetType = validationKind == PropertyValidationKind.Collection - ? GetCollectionElementTypeSymbol(property.Type) + ? GetCollectionItemTypeSymbol(property.Type) : TypeHelpers.UnwrapNullableType(property.Type); + targetType = targetType is null ? null : TypeHelpers.UnwrapNullableType(targetType); + if (targetType is not INamedTypeSymbol namedType) return true; @@ -851,48 +845,120 @@ static EquatableArray GetIgnoredEnumMembers(INamedTypeSymbol enumType) return ignored is null ? new(ImmutableArray.Empty) : new(ignored.ToImmutable()); } - static TypeIdentity? GetCollectionElementTypeIdentity(ITypeSymbol propertyType) + /// + /// Resolves the map arguments of a dictionary-like type: the IDictionary<TKey, TValue> + /// interface it implements, or IReadOnlyDictionary<TKey, TValue> when that is the only one. + /// The type itself is considered too, so a property declared as an interface is still a map. + /// + /// The type to inspect. + /// The dictionary key type, when is a map. + /// The dictionary value type, when is a map. + /// when the type is a dictionary. + /// + /// Detection is by interface, not by type name, so Dictionary, SortedDictionary, + /// ConcurrentDictionary, ImmutableDictionary and custom implementations all qualify. + /// + static bool TryGetMapArguments(ITypeSymbol type, out ITypeSymbol? keyType, out ITypeSymbol? valueType) { - if (propertyType is IArrayTypeSymbol arrayType) - return CreateTypeIdentity(arrayType.ElementType); + keyType = null; + valueType = null; - if (propertyType is not INamedTypeSymbol namedType) - return null; + INamedTypeSymbol? writable = null; + INamedTypeSymbol? readOnly = null; + + Consider(type as INamedTypeSymbol); - foreach (var iface in namedType.AllInterfaces) + if (type is INamedTypeSymbol namedType) { - if (TypeHelpers.Implements(iface, TypeLibrary.System.Collections.Generic.IEnumerable)) - return CreateTypeIdentity(iface.TypeArguments[0]); + foreach (var iface in namedType.AllInterfaces) + Consider(iface); } - return - namedType.IsGenericType - && TypeHelpers.Implements(namedType, TypeLibrary.System.Collections.Generic.IEnumerable) - ? CreateTypeIdentity(namedType.TypeArguments[0]) - : null; + if ((writable ?? readOnly) is not { TypeArguments.Length: 2 } map) + return false; + + keyType = map.TypeArguments[0]; + valueType = map.TypeArguments[1]; + return true; + + void Consider(INamedTypeSymbol? candidate) + { + if ( + candidate is null + || candidate.OriginalDefinition.ContainingNamespace.ToDisplayString() != "System.Collections.Generic" + || candidate.OriginalDefinition.MetadataName is not ("IDictionary`2" or "IReadOnlyDictionary`2") + ) + { + return; + } + + if (candidate.OriginalDefinition.MetadataName == "IDictionary`2") + writable ??= candidate; + else + readOnly ??= candidate; + } } - static ITypeSymbol? GetCollectionElementTypeSymbol(ITypeSymbol propertyType) + /// + /// Resolves the per-entry type a generated collection validates: a map's value type, otherwise the + /// sequence element type. + /// + /// The collection (or collection-like) type. + /// The value/element type, or when the type has none. + /// + /// The framework helper handles implemented interfaces (Dictionary<K, V> yields + /// KeyValuePair<K, V>), and the trailing case covers a property declared as the enumerable + /// interface itself, which the helper does not resolve. + /// + static ITypeSymbol? GetCollectionItemTypeSymbol(ITypeSymbol type) { - if (propertyType is IArrayTypeSymbol arrayType) + if (type is IArrayTypeSymbol arrayType) return arrayType.ElementType; - if (propertyType is not INamedTypeSymbol namedType) - return null; + if (TryGetMapArguments(type, out _, out var mapValueType)) + return mapValueType; - foreach (var iface in namedType.AllInterfaces) - { - if (TypeHelpers.Implements(iface, TypeLibrary.System.Collections.Generic.IEnumerable)) - return iface.TypeArguments[0]; - } + if (TypeHelpers.TryGetElementType(type, out var elementType)) + return elementType; - return - namedType.IsGenericType - && TypeHelpers.Implements(namedType, TypeLibrary.System.Collections.Generic.IEnumerable) - ? namedType.TypeArguments[0] + return type is INamedTypeSymbol { OriginalDefinition.MetadataName: "IEnumerable`1" } enumerable + ? enumerable.TypeArguments[0] : null; } + /// + /// The collection metadata a property descriptor carries: the validated per-entry type and how it behaves. + /// + /// The representable item type, with a nullable value-type wrapper removed. + /// Whether an item can be null at runtime. + /// Whether the item is a Nullable<T>. + /// Whether the collection is a dictionary. + readonly record struct CollectionItemInfo( + TypeIdentity? Identity, + bool CanBeNull, + bool IsNullableValueType, + bool IsMap + ); + + /// + /// Resolves the per-entry metadata for a collection property. + /// + /// The collection type. + /// The item metadata; all-false when the item type cannot be represented. + static CollectionItemInfo GetCollectionItemInfo(ITypeSymbol collectionType) + { + var itemType = GetCollectionItemTypeSymbol(collectionType); + var identity = itemType is null ? null : CreateTypeIdentity(TypeHelpers.UnwrapNullableType(itemType)); + + return new( + identity, + identity is not null && itemType is not null && TypeHelpers.CanBeNull(itemType), + identity is not null + && itemType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + TryGetMapArguments(collectionType, out _, out _) + ); + } + /// /// Reads the optional AllowNulls option from a [ZodSchema] attribute, or /// when the attribute is absent or leaves the option unset. @@ -979,7 +1045,7 @@ attribute is not null break; } - var elementType = GetCollectionElementTypeSymbol(collectionType); + var elementType = GetCollectionItemTypeSymbol(collectionType); if (elementType is not null && IsExplicitlyNullableElement(elementType)) return true; @@ -1022,7 +1088,7 @@ type is not IArrayTypeSymbol return false; } - var elementType = GetCollectionElementTypeSymbol(type); + var elementType = GetCollectionItemTypeSymbol(type); return elementType is not null && TypeHelpers.CanBeNull(elementType); } @@ -1034,9 +1100,11 @@ type is not IArrayTypeSymbol { var targetType = validationKind == PropertyValidationKind.Collection - ? GetCollectionElementTypeSymbol(property.Type) + ? GetCollectionItemTypeSymbol(property.Type) : property.Type; + targetType = targetType is null ? null : TypeHelpers.UnwrapNullableType(targetType); + if (targetType is not INamedTypeSymbol namedType) return null; @@ -1323,17 +1391,7 @@ out INamedTypeSymbol nested ) { var propertyType = TypeHelpers.UnwrapNullableType(property.Type); - if (propertyType is IArrayTypeSymbol array) - propertyType = array.ElementType; - else if (propertyType is INamedTypeSymbol named) - { - var enumerable = named.AllInterfaces.FirstOrDefault( - TypeLibrary.System.Collections.Generic.IEnumerable.Equals - ); - if (enumerable is not null) - propertyType = enumerable.TypeArguments[0]; - } - + propertyType = GetCollectionItemTypeSymbol(propertyType) ?? propertyType; propertyType = TypeHelpers.UnwrapNullableType(propertyType); nested = propertyType as INamedTypeSymbol ?? null!; return nested is not null && externalSchemas.IsSchemaOwnedByThisCompilation(nested); diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 032f679..fb7ce7f 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -149,13 +149,26 @@ readonly record struct ZodSchemaDescriptor( /// flags property. /// /// The kind of validation the property supports. -/// The collection element type, when the property is a collection. -/// Indicates whether a collection element can be null. +/// +/// The collection element type — the map value type when the property is a dictionary — when the property is +/// a collection. A nullable value type is unwrapped, so List<int?> carries int; +/// records the wrapper. +/// +/// Indicates whether a collection element (or map value) can be null. +/// +/// Indicates whether the collection element (or map value) is a Nullable<T>, so generated +/// validation has to read .Value when it passes the item to a nested schema. +/// +/// +/// Indicates whether the property is a dictionary (IDictionary<TKey, TValue> / +/// IReadOnlyDictionary<TKey, TValue>). A map is validated per value, keys are used only for +/// error paths, and the exported JSON Schema is an object with additionalProperties. +/// /// -/// Whether the collection may contain null elements, when the property is a collection. Before the -/// generation-settings pass this holds the property/type-level override (null when neither states one); that -/// pass resolves the assembly default into a concrete value, so the generator always sees a non-null value -/// for a collection property. +/// Whether the collection may contain null elements (or a map null values), when the property is a +/// collection. Before the generation-settings pass this holds the property/type-level override (null when +/// neither states one); that pass resolves the assembly default into a concrete value, so the generator always +/// sees a non-null value for a collection property. /// /// The generated schema type of a nested complex type, when there is one. /// The length expression and origin, when the property's length is countable. @@ -195,6 +208,8 @@ readonly record struct ZodPropertyDescriptor( PropertyValidationKind ValidationKind, TypeIdentity? ElementType, bool ElementTypeCanBeNull, + bool ElementTypeIsNullableValueType, + bool IsMap, bool? AllowNulls, TypeIdentity? NestedSchemaType, LengthAccessor LengthAccessor, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 0232b45..d05641b 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -864,76 +864,155 @@ static void GenerateCollectionElementValidation(CodeWriter writer, ZodPropertyDe if (!hasNestedSchema && !rejectNullItems) return; + var collectionValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Collection"); + + writer.Assignment("var", collectionValueName, $"value.{property.Name}"); + using (writer.IfBlockScope($"{collectionValueName} is not null")) + { + if (property.IsMap) + { + GenerateMapEntryValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); + } + else + { + GenerateSequenceItemValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); + } + } + + writer.NewLine(); + } + + /// + /// Emits the per-element validation loop for a sequence collection. + /// + static void GenerateSequenceItemValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + bool hasNestedSchema, + bool rejectNullItems, + string collectionValueName + ) + { var propertyName = property.Name; var displayName = property.DisplayName; - var collectionValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Collection"); var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); var indexName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Index"); + var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; - writer.Assignment("var", collectionValueName, $"value.{propertyName}"); - using (writer.IfBlockScope($"{collectionValueName} is not null")) + writer.Assignment("var", indexName, "0"); + using (writer.ForeachScope($"var {itemValueName} in {collectionValueName}")) { - writer.Assignment("var", indexName, "0"); - using (writer.ForeachScope($"var {itemValueName} in {collectionValueName}")) + if (property.ElementTypeCanBeNull) { - if (property.ElementTypeCanBeNull) + using (writer.IfBlockScope($"{itemValueName} is null")) { if (rejectNullItems) - { - using (writer.IfBlockScope($"{itemValueName} is null")) - { - WriteNullItemError(writer, propertyName, displayName, indexName); - writer.Line($"{indexName}++;"); - writer.Line("continue;"); - } - } - else - { - writer.IfBlock( - $"{itemValueName} is null", - ifBody => - { - ifBody.Line($"{indexName}++;"); - ifBody.Line("continue;"); - } - ); - } + WriteNullItemError(writer, propertyName, displayName, $"{indexName}.ToString()", "elements"); + + writer.Line($"{indexName}++;"); + writer.Line("continue;"); } + } - if (hasNestedSchema) + if (hasNestedSchema) + { + writer.Assignment( + "var", + itemResultName, + property.NestedSchemaType!.Value.StaticMember($"Validate({itemExpression})") + ); + using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) { - writer.Assignment( - "var", + writer.MethodCall( + "AddNestedErrors", + "ref errors", itemResultName, - property.NestedSchemaType!.Value.StaticMember($"Validate({itemValueName})") + propertyName.Surround(), + indexName ); - using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) - { - writer.MethodCall( - "AddNestedErrors", - "ref errors", - itemResultName, - propertyName.Surround(), - indexName - ); - } } - - writer.Line($"{indexName}++;"); } + + writer.Line($"{indexName}++;"); } + } - writer.NewLine(); + /// + /// Emits the per-value validation loop for a dictionary property. The key is used only in error paths, + /// matching Zod's z.record issue path, and is converted to a string inside the failing branch so a + /// valid dictionary allocates nothing for it. + /// + static void GenerateMapEntryValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + bool hasNestedSchema, + bool rejectNullItems, + string collectionValueName + ) + { + var propertyName = property.Name; + var displayName = property.DisplayName; + var entryName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Entry"); + var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); + var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); + var keyExpression = $"{entryName}.Key.ToString()"; + var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; + + using (writer.ForeachScope($"var {entryName} in {collectionValueName}")) + { + writer.Assignment("var", itemValueName, $"{entryName}.Value"); + + if (property.ElementTypeCanBeNull) + { + using (writer.IfBlockScope($"{itemValueName} is null")) + { + if (rejectNullItems) + WriteNullItemError(writer, propertyName, displayName, keyExpression, "values"); + + writer.Line("continue;"); + } + } + + if (hasNestedSchema) + { + writer.Assignment( + "var", + itemResultName, + property.NestedSchemaType!.Value.StaticMember($"Validate({itemExpression})") + ); + using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) + { + writer.MethodCall( + "AddNestedErrors", + "ref errors", + itemResultName, + propertyName.Surround(), + keyExpression + ); + } + } + } } /// - /// Emits the invalid_type error for a null element in a collection that rejects null elements, - /// at the element's own path (["Items", index]), matching Zod's per-element issue path. + /// Emits the invalid_type error for a null entry in a collection that rejects null entries, at the + /// entry's own path (["Items", index] or ["Items", key]), matching Zod's per-entry issue path. /// - static void WriteNullItemError(CodeWriter writer, string propertyName, string displayName, string indexName) + /// The writer to emit to. + /// The property name, used as the path prefix. + /// The display name used in the message. + /// The already-rendered path component (an index or a dictionary key). + /// The word describing a null entry (elements or values). + static void WriteNullItemError( + CodeWriter writer, + string propertyName, + string displayName, + string pathExpression, + string noun + ) { - var message = $"Field '{displayName}' cannot contain null elements".StringLiteral(); + var message = $"Field '{displayName}' cannot contain null {noun}".StringLiteral(); writer.IfBlock( "errors is null", @@ -946,7 +1025,7 @@ static void WriteNullItemError(CodeWriter writer, string propertyName, string di writer.MethodCallOn( "errors", "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({"invalid_type".Surround()}, {message}, global::System.Collections.Immutable.ImmutableArray.Create({propertyName.Surround()}, {indexName}.ToString()))" + $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({"invalid_type".Surround()}, {message}, global::System.Collections.Immutable.ImmutableArray.Create({propertyName.Surround()}, {pathExpression}))" ); } @@ -1150,6 +1229,34 @@ static void GenerateValidationHelpers(CodeWriter writer, CancellationToken cance ) ); + writer.Method( + new("AddNestedErrors", TypeDeclarationAccessibility.Private) + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions("T")], + Parameters = + [ + new("errors", listOfErrorsType.MakeNullable(writer)) { Modifier = ParameterModifier.Ref }, + new( + "nestedResult", + TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(typeParameterT).AsTypeReference() + ), + new("prefix", new TypeIdentity(SpecialType.System_String).AsTypeReference()), + new("key", new TypeIdentity(SpecialType.System_String).AsTypeReference()), + ], + }, + body => + body.Foreach( + "var error in nestedResult.Errors", + foreachBody => + foreachBody.MethodCall( + "AddError", + "ref errors", + $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create(error.Code, error.Message, {immutableArrayType}.Create(prefix, key).AddRange(error.Path), error.Parameters, error.Origin, error.Minimum, error.Maximum, error.Inclusive, error.Category)" + ) + ) + ); + writer.NewLine(); writer.Method( new( diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs index 5c8a31b..7894f20 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs @@ -34,6 +34,8 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); var propertyLengthName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Length"); var origin = lengthAccessor.Origin; + var singularNoun = property.IsMap ? "entry" : "element"; + var pluralNoun = property.IsMap ? "entries" : "elements"; writer.Assignment("var", propertyValueName, $"value.{propertyName}"); using (writer.IfBlockScope($"{propertyValueName} is not null")) @@ -48,14 +50,14 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript { var tooSmallMessage = BuildMessageExpression( length.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({length.MinimumLength}, {"element".Surround()}, {"elements".Surround()}) + {".".Surround()}", + $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({length.MinimumLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", displayName.StringLiteral(), length.MaximumLength.ToString(CultureInfo.InvariantCulture), length.MinimumLength.ToString(CultureInfo.InvariantCulture) ); var tooBigMessage = BuildMessageExpression( length.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({length.MaximumLength}, {"element".Surround()}, {"elements".Surround()}) + {".".Surround()}", + $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({length.MaximumLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", displayName.StringLiteral(), length.MaximumLength.ToString(CultureInfo.InvariantCulture), length.MinimumLength.ToString(CultureInfo.InvariantCulture) @@ -94,7 +96,7 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript var minLength = minLengthAttr.Value.Length; var messageExpression = BuildMessageExpression( minLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({minLength}, {"element".Surround()}, {"elements".Surround()}) + {".".Surround()}", + $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({minLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", displayName.StringLiteral(), minLength.ToString(CultureInfo.InvariantCulture) ); @@ -117,7 +119,7 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript var maxLength = maxLengthAttr.Value.Length; var messageExpression = BuildMessageExpression( maxLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({maxLength}, {"element".Surround()}, {"elements".Surround()}) + {".".Surround()}", + $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({maxLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", displayName.StringLiteral(), maxLength.ToString(CultureInfo.InvariantCulture) ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index 11cccba..8fc3aab 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -165,7 +165,7 @@ static string BuildPropertySchemaExpression(ZodPropertyDescriptor property, ZodS static string BuildCollectionSchemaExpression(ZodPropertyDescriptor property) { - var items = + var itemSchema = property.NestedSchemaType is { } nested ? ( property.NestedSchemaExportsJsonSchema @@ -177,10 +177,14 @@ property.NestedSchemaType is { } nested // A collection that allows null elements advertises it, mirroring how a nullable property is exported. if (property.AllowNulls == true) - items = - $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {items}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}"; - - return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"array\", Items = {items} }}"; + itemSchema = + $"new {JsonSchemaDefinitionTypeName} {{ AnyOf = new() {{ {itemSchema}, new {JsonSchemaDefinitionTypeName} {{ Type = \"null\" }} }} }}"; + + // A dictionary is written as a JSON object by System.Text.Json, so it exports as an object whose + // additionalProperties is the per-value schema rather than as an array of pairs. + return property.IsMap + ? $"new {JsonSchemaDefinitionTypeName} {{ Type = \"object\", AdditionalProperties = {itemSchema} }}" + : $"new {JsonSchemaDefinitionTypeName} {{ Type = \"array\", Items = {itemSchema} }}"; } static string BuildEnumSchemaExpression(ZodPropertyDescriptor property, ZodSchemaDescriptor schema) @@ -355,10 +359,17 @@ static void EmitLengthConstraints( string localName ) { - // [MinLength]/[MaxLength] apply to strings and collections; the JSON Schema keyword differs. + // [MinLength]/[MaxLength] apply to strings, collections and dictionaries; the JSON Schema keyword + // differs, and an object's member count is expressed with minProperties/maxProperties. var isCollection = property.ValidationKind == PropertyValidationKind.Collection; - var minKeyword = isCollection ? "MinItems" : "MinLength"; - var maxKeyword = isCollection ? "MaxItems" : "MaxLength"; + var minKeyword = + property.IsMap ? "MinProperties" + : isCollection ? "MinItems" + : "MinLength"; + var maxKeyword = + property.IsMap ? "MaxProperties" + : isCollection ? "MaxItems" + : "MaxLength"; if (attributes.MinLength.ShouldProcess && attributes.MinLength.Value.Exists) body.Assignment( @@ -397,10 +408,18 @@ string localName if (attributes.Length.ShouldProcess && attributes.Length.Value.Exists) { var isString = property.PropertyType.SpecialType == SpecialType.System_String; + var minimumKeyword = + isString ? "MinLength" + : property.IsMap ? "MinProperties" + : "MinItems"; + var maximumKeyword = + isString ? "MaxLength" + : property.IsMap ? "MaxProperties" + : "MaxItems"; 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); + body.Assignment($"{localName}.{minimumKeyword}", minimum); + body.Assignment($"{localName}.{maximumKeyword}", maximum); } } diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 41de244..c490301 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -228,6 +228,17 @@ static IZodSchema ConvertNumberSchema(JsonSchemaDefinition schem )] static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schema, ConversionContext ctx) { + // An object with no declared properties whose additionalProperties is a schema is a record: every + // member is validated by the same value schema. This is the shape the generated exporter writes for a + // dictionary property, and matches Zod's z.record(z.string(), V). + if ( + schema.Properties is not { Count: > 0 } + && TryConvertAdditionalProperties(schema.AdditionalProperties, ctx, out var recordValueSchema) + ) + { + return new RecordSchemaWrapper(recordValueSchema); + } + var builder = Z.Object(); HashSet requiredSet = [.. schema.Required ?? []]; @@ -252,6 +263,65 @@ static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schem return new ObjectSchemaWrapper(builder.Build()); } + /// + /// Converts the additionalProperties value of an object schema to the record's value schema. A + /// schema object (or a true wildcard) means the object is a record; false or an absent value + /// means it is not. + /// + /// The raw value. + /// The conversion context. + /// The converted value schema, when the object is a record. + /// when the object is a record. + static bool TryConvertAdditionalProperties( + object? additionalProperties, + ConversionContext ctx, + out IZodSchema valueSchema + ) + { + switch (additionalProperties) + { + case JsonSchemaDefinition definition: + valueSchema = ConvertSchema(definition, ctx); + return true; + + // A string-parsed schema keeps additionalProperties as a JsonElement, because the model declares + // the member as object. + case JsonElement { ValueKind: JsonValueKind.Object } element: + var parsed = JsonSerializer.Deserialize(element, JsonSchemaJsonContext.Default.JsonSchemaDefinition); + valueSchema = parsed is null ? new AnySchema() : ConvertSchema(parsed, ctx); + return true; + + case JsonElement { ValueKind: JsonValueKind.True }: + case true: + valueSchema = new AnySchema(); + return true; + + default: + valueSchema = null!; + return false; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance" + )] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static object? ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var longValue) ? longValue : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Object => element, + JsonValueKind.Array => element, + _ => element, + }; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "CA1859:Use concrete types when possible for improved performance" @@ -536,26 +606,82 @@ public ValueTask> ValidateAsync( object value, CancellationToken cancellationToken = default ) => new(Validate(value)); + } - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", - "CA1859:Use concrete types when possible for improved performance" - )] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] - static object? ConvertJsonElement(JsonElement element) + /// + /// Validates a JSON object as a record: every member is validated by valueSchema and a failure is + /// reported at the member's key, matching Z.Record and Zod's z.record issue path. + /// + class RecordSchemaWrapper(IZodSchema valueSchema) : IZodSchema + { + public ValidationResult Validate(object value) { - return element.ValueKind switch + Dictionary dict; + + if (value is Dictionary d) { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt64(out var longValue) ? longValue : element.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - JsonValueKind.Object => element, - JsonValueKind.Array => element, - _ => element, - }; + dict = d; + } + else if (value is JsonElement element && element.ValueKind == JsonValueKind.Object) + { + dict = []; + foreach (var prop in element.EnumerateObject()) + dict[prop.Name] = ConvertJsonElement(prop.Value); + } + else + { + return ValidationResult.Failure( + new ValidationError( + "invalid_type", + $"Expected object, but got {value?.GetType().Name ?? "null"}", + [] + ) + ); + } + + Dictionary validated = []; + List errors = []; + + foreach (var (key, item) in dict) + { + var result = valueSchema.Validate(item!); + if (!result.IsSuccess) + { + foreach (var error in result.Errors) + { + var path = new string[error.Path.Length + 1]; + path[0] = key; + error.Path.CopyTo(0, path, 1, error.Path.Length); + errors.Add( + ValidationError.Create( + error.Code, + error.Message, + System.Collections.Immutable.ImmutableArray.Create(path), + error.Parameters, + error.Origin, + error.Minimum, + error.Maximum, + error.Inclusive, + error.Category + ) + ); + } + } + else + { + validated[key] = result.Value; + } + } + + return errors.Count > 0 + ? ValidationResult.Failure(errors) + : ValidationResult.Success(validated); } + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); } class ArraySchemaWrapper(IZodSchema elementSchema, int? minItems, int? maxItems) diff --git a/src/src/ZodSharp/CollectionBehaviorAttribute.cs b/src/src/ZodSharp/CollectionBehaviorAttribute.cs index c7f7657..2661a83 100644 --- a/src/src/ZodSharp/CollectionBehaviorAttribute.cs +++ b/src/src/ZodSharp/CollectionBehaviorAttribute.cs @@ -6,9 +6,9 @@ namespace ZodSharp; /// /// /// The only setting today is , which controls whether a collection may contain -/// elements. Null elements are rejected by default. Set AllowNulls = true to -/// accept them, or AllowNulls = false to reject them even when the element type is explicitly -/// nullable (T?) or a wider default allows them. +/// elements (or a dictionary a value). Null entries are +/// rejected by default. Set AllowNulls = true to accept them, or AllowNulls = false to reject +/// them even when the element type is explicitly nullable (T?) or a wider default allows them. /// /// /// Apply to a collection property. The attribute is merged in from a positional record parameter, so diff --git a/src/src/ZodSharp/JsonSchema/IJsonSchemaRecordInfo.cs b/src/src/ZodSharp/JsonSchema/IJsonSchemaRecordInfo.cs new file mode 100644 index 0000000..806cea0 --- /dev/null +++ b/src/src/ZodSharp/JsonSchema/IJsonSchemaRecordInfo.cs @@ -0,0 +1,23 @@ +namespace ZodSharp.JsonSchema; + +/// +/// A non-generic view of a record (dictionary) schema, for the JSON Schema exporter. +/// +/// +/// +/// walks schemas through a non-generic , so it cannot +/// pattern-match ZodRecord<TValue> without knowing TValue. This seam exposes the value +/// schema the export needs; without it a record falls through to the generic fallback and exports as the +/// empty schema. +/// +/// +/// It is deliberately internal — it is a cross-type detail of this assembly, not public API. +/// +/// +interface IJsonSchemaRecordInfo +{ + /// + /// Gets the value schema, boxed so the member is reachable without naming the value type. + /// + object ValueSchema { get; } +} diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index c92451c..7884683 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -99,6 +99,12 @@ public class JsonSchemaDefinition /// additionalProperties - Schema for additional properties, or false to disallow public object? AdditionalProperties { get; set; } + /// minProperties - Minimum number of members in an object + public int? MinProperties { get; set; } + + /// maxProperties - Maximum number of members in an object + public int? MaxProperties { get; set; } + // ========== Negation ========== /// not - The data must not match this schema (for example a sentinel value that is rejected). diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 3477ff0..0f682ac 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -312,6 +312,17 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) }; } + // Handle ZodRecord through the internal IJsonSchemaRecordInfo seam. A dictionary is written as a + // JSON object, so its exported schema is an object whose additionalProperties is the value schema. + if (schema is IJsonSchemaRecordInfo recordInfo) + { + return new JsonSchemaDefinition + { + Type = "object", + AdditionalProperties = ConvertSchema(recordInfo.ValueSchema, ctx), + }; + } + // Handle ZodLiteral through its introspection seam rather than by type name and field name. if (schema is IJsonSchemaLiteralInfo literalInfo && literalInfo.LiteralValue is { } value) { diff --git a/src/src/ZodSharp/Schemas/ZodRecord.cs b/src/src/ZodSharp/Schemas/ZodRecord.cs index dc7fa68..8d82b26 100644 --- a/src/src/ZodSharp/Schemas/ZodRecord.cs +++ b/src/src/ZodSharp/Schemas/ZodRecord.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -13,10 +14,15 @@ namespace ZodSharp.Schemas; /// /// The schema for all values. public class ZodRecord(IZodSchema valueSchema) - : ZodType, Dictionary> + : ZodType, Dictionary>, + JsonSchema.IJsonSchemaRecordInfo { static readonly string[] EmptyPath = []; + // IJsonSchemaRecordInfo: the JSON Schema exporter's non-generic seam. A record exports as an object whose + // additionalProperties is the value schema. See IJsonSchemaRecordInfo. + object JsonSchema.IJsonSchemaRecordInfo.ValueSchema => valueSchema; + /// /// Validates that all values in the dictionary match the value schema. /// @@ -36,12 +42,8 @@ protected override ValidationResult> ParseInternal(Di foreach (var (key, itemValue) in value) { - if (itemValue is null) - { - validated[key] = default!; - continue; - } - + // A null value is the value schema's decision, not the record's: a record of a non-nullable schema + // rejects it (matching z.record(z.string(), T)), while a nullable value schema accepts it. var result = valueSchema.Validate(itemValue); if (!result.IsSuccess) { @@ -50,12 +52,24 @@ protected override ValidationResult> ParseInternal(Di var path = new string[error.Path.Length + 1]; path[0] = key; error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new(error.Code, error.Message, path, error.Parameters)); + errors.Add( + ValidationError.Create( + error.Code, + error.Message, + ImmutableArray.Create(path), + error.Parameters, + error.Origin, + error.Minimum, + error.Maximum, + error.Inclusive, + error.Category + ) + ); } } else { - validated[key] = result.Value!; + validated[key] = result.Value; } } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index a4c6c19..c7a057c 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -95,6 +95,8 @@ Enum properties are validated automatically: a value that is not a defined membe Collection elements reject `null` by default: a null element reports `invalid_type` at its own path (for example `["Items", 0]`). Opt in per property with `[CollectionBehavior(AllowNulls = true)]`, per element type with `T?` (for example `List`), per type with `[ZodSchema(AllowNulls = true)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = true)]` or the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; `[CollectionBehavior]` on a property that cannot hold null elements is reported as `ZODSGEN046`. +A dictionary property (`IDictionary` / `IReadOnlyDictionary`) is validated as a map: every value is checked against the value type's nested schema, a failure is reported at the entry's key (for example `["Variants", "blue"]`), and `AllowNulls` governs null values. It exports as `{ "type": "object", "additionalProperties": … }` rather than as an array. + ## Custom rules A rule is any struct implementing `ZodSharp.Core.IValidationRule`; attach it to a schema with the public `Rule`/`AddRule` API, or expose it as a DataAnnotations-style attribute that the source generator honours exactly like the built-ins. diff --git a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs index fef5674..cfaab6e 100644 --- a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs +++ b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs @@ -21,7 +21,7 @@ public sealed class ZodSharpSchemaDefaultsAttribute : Attribute { /// /// Gets or sets the assembly-wide default for whether collection properties allow - /// elements. + /// elements (or dictionary values). /// /// /// The setting only takes effect when it is explicitly assigned; an attribute that leaves it out defers to diff --git a/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonRecordTests.cs b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonRecordTests.cs new file mode 100644 index 0000000..9f195b3 --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonRecordTests.cs @@ -0,0 +1,75 @@ +using Newtonsoft.Json; +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.NewtonsoftJson; + +namespace ZodSharp.Json; + +/// +/// Record (dictionary) JSON Schema export and import through the Newtonsoft.Json integration. +/// +public class NewtonsoftJsonRecordTests +{ + const string RecordSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + } + """; + + [Test] + public async Task FromJsonSchema_GivenAdditionalProperties_ValidatesEveryValue() + { + // Arrange — an object with no declared properties and a value schema is a record. + var schema = Z.FromJsonSchema(RecordSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["a"] = "x" }); + var invalidResult = schema.Validate(new Dictionary { ["a"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + await Assert.That(invalidResult.Errors[0].Path[0]).IsEqualTo("a"); + } + + [Test] + public async Task FromJsonSchema_GivenAdditionalPropertiesTrue_ValidatesEveryValue() + { + // Arrange + const string source = + /*lang=json,strict*/ + """ + { "type": "object", "additionalProperties": true } + """; + + var schema = Z.FromJsonSchema(source); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = "anything", ["b"] = 42 }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task JsonSchemaSerialization_GivenRecordDefinition_WritesObjectKeywords() + { + // Arrange — AdditionalProperties is object-typed; the serialized form is what consumers see. + JsonSchemaDefinition definition = new() + { + Type = "object", + MinProperties = 1, + AdditionalProperties = new JsonSchemaDefinition { Type = "string" }, + }; + + // Act + var json = JsonConvert.SerializeObject(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"additionalProperties\""); + await Assert.That(json).Contains("\"type\": \"string\""); + await Assert.That(json).Contains("\"minProperties\": 1"); + } +} diff --git a/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonRecordTests.cs b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonRecordTests.cs new file mode 100644 index 0000000..68fe293 --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonRecordTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; + +namespace ZodSharp.Json; + +/// +/// Record (dictionary) JSON Schema export and import through the System.Text.Json integration. +/// +public class SystemTextJsonRecordTests +{ + const string RecordSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + } + """; + + [Test] + public async Task FromJsonSchema_GivenAdditionalProperties_ValidatesEveryValue() + { + // Arrange — an object with no declared properties and a value schema is a record. + var schema = Z.FromJsonSchema(RecordSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["a"] = "x" }); + var invalidResult = schema.Validate(new Dictionary { ["a"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + await Assert.That(invalidResult.Errors[0].Path[0]).IsEqualTo("a"); + } + + [Test] + public async Task FromJsonSchema_GivenAdditionalPropertiesTrue_ValidatesEveryValue() + { + // Arrange + const string source = + /*lang=json,strict*/ + """ + { "type": "object", "additionalProperties": true } + """; + + var schema = Z.FromJsonSchema(source); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = "anything", ["b"] = 42 }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task FromJsonSchema_GivenAdditionalPropertiesFalse_KeepsObjectSemantics() + { + // Arrange — false is not a value schema, so the object keeps its (empty) property contract. + const string source = + /*lang=json,strict*/ + """ + { "type": "object", "additionalProperties": false } + """; + + var schema = Z.FromJsonSchema(source); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = "anything" }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task JsonSchemaSerialization_GivenRecordDefinition_WritesObjectKeywords() + { + // Arrange — AdditionalProperties is object-typed; the serialized form is what consumers see. + JsonSchemaDefinition definition = new() + { + Type = "object", + MinProperties = 1, + AdditionalProperties = new JsonSchemaDefinition { Type = "string" }, + }; + + // Act + var json = JsonSerializer.Serialize(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"additionalProperties\""); + await Assert.That(json).Contains("\"type\": \"string\""); + await Assert.That(json).Contains("\"minProperties\": 1"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs index a13e35a..33a0067 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs @@ -78,4 +78,58 @@ public class Widget // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CollectionBehaviorNotApplicable); } + + [Test] + public async Task CollectionBehavior_GivenDictionaryOfNonNullableValues_ProducesZODSGEN046( + CancellationToken cancellationToken + ) + { + // Arrange — a Dictionary cannot contain a null value, so the setting has no effect. + const string source = + @" +using System.Collections.Generic; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [CollectionBehavior(AllowNulls = true)] + public Dictionary Counts { get; set; } = []; + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CollectionBehaviorNotApplicable); + } + + [Test] + public async Task CollectionBehavior_GivenDictionaryOfNullableValues_ProducesNoZODSGEN046( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +using System.Collections.Generic; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [CollectionBehavior(AllowNulls = true)] + public Dictionary Values { get; set; } = []; + } +}"; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CollectionBehaviorNotApplicable); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs new file mode 100644 index 0000000..bb42133 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs @@ -0,0 +1,421 @@ +using System.Collections.Immutable; +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task Dictionaries_GivenReferenceValueType_GeneratesCompilableValidator( + CancellationToken cancellationToken + ) + { + // Arrange — the shape that used to emit a null test over a KeyValuePair (CS0037). + const string source = + @" +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class VariantConfiguration + { + [Required(AllowEmptyStrings = false)] + public string ResourceName { get; set; } = string.Empty; + } + + [ZodSchema] + public class WebAppKitOptions + { + [Required] + public Dictionary Variants { get; set; } = []; + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert — the generated validator compiles, and it iterates the entries by value and key. + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + await Assert.That(assembly).IsNotNull(); + + var generated = driverResult.GetSource("WebAppKitOptionsSchema"); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).Contains("variantsEntry.Value"); + await Assert.That(generated!).Contains("variantsEntry.Key.ToString()"); + } + + [Test] + public async Task Dictionaries_GivenValueTypeValues_GeneratesNoNullTestForTheEntry( + CancellationToken cancellationToken + ) + { + // Arrange — KeyValuePair cannot be null, so no null test is emitted for the entry. + const string source = + @" +using System.Collections.Generic; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + public Dictionary Counts { get; set; } = []; + } +}"; + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + + // Assert + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + await Assert.That(assembly).IsNotNull(); + + var generated = driverResult.GetSource("WidgetSchema"); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).DoesNotContain("countsEntry is null"); + } + + [Test] + public async Task Dictionaries_GivenNestedValueSchema_ValidatesValuesAtKeyPath(CancellationToken cancellationToken) + { + // Arrange — the dictionary's value type has a schema, discovered through the map value. + const string source = + @" +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + [MinLength(3)] + public string Name { get; set; } = string.Empty; + } + + [ZodSchema] + public class Container + { + public Dictionary Items { get; set; } = []; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var generated = driverResult.GetSource("ContainerSchema"); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).ContainsGeneratedCode("ChildSchema.Validate("); + + // Act + var (isSuccess, errors) = ValidateDictionary(assembly!, "Container", "Items", ("a", "Name", "x")); + + // Assert — the nested error is reported at the entry's key, matching z.record's issue path. + await Assert.That(isSuccess).IsFalse(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Path.Length).IsEqualTo(3); + await Assert.That(errors[0].Path[0]).IsEqualTo("Items"); + await Assert.That(errors[0].Path[1]).IsEqualTo("a"); + await Assert.That(errors[0].Path[2]).IsEqualTo("Name"); + } + + [Test] + public async Task Dictionaries_GivenNullValue_ReportsInvalidTypeAtKeyPath(CancellationToken cancellationToken) + { + // Arrange — a non-nullable reference value type rejects a null value by default. + const string source = + @" +using System.Collections.Generic; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + public string Name { get; set; } = ""valid""; + } + + [ZodSchema] + public class Container + { + public Dictionary Items { get; set; } = []; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + // Act + var (isSuccess, errors) = ValidateDictionary(assembly!, "Container", "Items", ("a", null, null)); + + // Assert + await Assert.That(isSuccess).IsFalse(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_type"); + await Assert.That(errors[0].Path[1]).IsEqualTo("a"); + } + + [Test] + public async Task Dictionaries_GivenAllowNulls_SkipsNullValue(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +using System.Collections.Generic; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + public string Name { get; set; } = ""valid""; + } + + [ZodSchema] + public class Container + { + [CollectionBehavior(AllowNulls = true)] + public Dictionary Items { get; set; } = []; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + // Act + var (isSuccess, _) = ValidateDictionary(assembly!, "Container", "Items", ("a", null, null)); + + // Assert + await Assert.That(isSuccess).IsTrue(); + } + + [Test] + public async Task Dictionaries_JsonSchema_GivenNestedValue_ExportsObjectWithAdditionalProperties( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +using System.Collections.Generic; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + public string Name { get; set; } = ""valid""; + } + + [ZodSchema] + public class Container + { + public Dictionary Items { get; set; } = []; + } +}"; + + // Act + var definition = await BuildDefinitionAsync(source, "ContainerSchema", cancellationToken); + + // Assert — a dictionary is a JSON object, not an array of pairs or of key strings. + var items = definition.Properties!["items"]; + await Assert.That(items.Type).IsEqualTo("object"); + await Assert.That(items.Items).IsNull(); + + var additionalProperties = items.AdditionalProperties as JsonSchemaDefinition; + await Assert.That(additionalProperties).IsNotNull(); + await Assert.That(additionalProperties!.Type).IsEqualTo("object"); + await Assert.That(additionalProperties.Properties).ContainsKey("name"); + } + + [Test] + public async Task Dictionaries_JsonSchema_GivenNullableValue_ExportsNullValueBranch( + CancellationToken cancellationToken + ) + { + // Arrange — an explicitly nullable value opts the map into accepting null values. + const string source = + @" +using System.Collections.Generic; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + public string Name { get; set; } = ""valid""; + } + + [ZodSchema] + public class Container + { + public Dictionary Items { get; set; } = []; + } +}"; + + // Act + var definition = await BuildDefinitionAsync(source, "ContainerSchema", cancellationToken); + + // Assert + var additionalProperties = definition.Properties!["items"].AdditionalProperties as JsonSchemaDefinition; + await Assert.That(additionalProperties).IsNotNull(); + await Assert.That(additionalProperties!.AnyOf).Count().IsEqualTo(2); + await Assert.That(additionalProperties.AnyOf!.Any(static branch => branch.Type == "null")).IsTrue(); + } + + [Test] + public async Task Dictionaries_JsonSchema_GivenLength_ExportsMinMaxProperties(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [Length(1, 5)] + public Dictionary Counts { get; set; } = []; + } +}"; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert — member counts use the object keywords, not the array ones. + var counts = definition.Properties!["counts"]; + await Assert.That(counts.Type).IsEqualTo("object"); + await Assert.That(counts.MinProperties).IsEqualTo(1); + await Assert.That(counts.MaxProperties).IsEqualTo(5); + await Assert.That(counts.MinItems).IsNull(); + await Assert.That(counts.MaxItems).IsNull(); + } + + [Test] + public async Task Enumerables_GivenEnumerableProperty_ValidatesElements(CancellationToken cancellationToken) + { + // Arrange — a property declared as the enumerable interface itself still resolves its element type. + const string source = + @" +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Testing +{ + [ZodSchema] + public class Child + { + [MinLength(3)] + public string Name { get; set; } = string.Empty; + } + + [ZodSchema] + public class Container + { + public IEnumerable Items { get; set; } = []; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + + var containerType = assembly!.GetType("Testing.Container")!; + var childType = assembly.GetType("Testing.Child")!; + var child = Activator.CreateInstance(childType)!; + childType.GetProperty("Name")!.SetValue(child, "x"); + + var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(childType))!; + list.Add(child); + + var instance = Activator.CreateInstance(containerType)!; + containerType.GetProperty("Items")!.SetValue(instance, list); + + // Act + var result = assembly.GetType("Testing.ContainerSchema")!.GetMethod("Validate")!.Invoke(null, [instance])!; + dynamic dynResult = result; + + // Assert + await Assert.That((bool)dynResult.IsSuccess).IsFalse(); + var errors = (ImmutableArray)dynResult.Errors; + await Assert.That(errors[0].Path[1]).IsEqualTo("0"); + } + + /// + /// Builds a container whose dictionary property holds the supplied entries and validates it. + /// + static (bool IsSuccess, ImmutableArray Errors) ValidateDictionary( + System.Reflection.Assembly assembly, + string containerTypeName, + string propertyName, + params (string Key, string? Property, object? Value)[] entries + ) + { + var containerType = assembly.GetType($"Testing.{containerTypeName}")!; + var propertyType = containerType.GetProperty(propertyName)!.PropertyType; + var valueType = propertyType.GetGenericArguments()[1]; + var dictionary = (System.Collections.IDictionary) + Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(typeof(string), valueType))!; + + foreach (var (key, property, assignedValue) in entries) + { + object? value = null; + if (property is not null) + { + value = Activator.CreateInstance(valueType)!; + valueType.GetProperty(property)!.SetValue(value, assignedValue); + } + + dictionary[key] = value; + } + + var instance = Activator.CreateInstance(containerType)!; + containerType.GetProperty(propertyName)!.SetValue(instance, dictionary); + + var result = assembly + .GetType($"Testing.{containerTypeName}Schema")! + .GetMethod("Validate")! + .Invoke(null, [instance])!; + dynamic dynResult = result; + return ((bool)dynResult.IsSuccess, (ImmutableArray)dynResult.Errors); + } +} diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRecordTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRecordTests.cs new file mode 100644 index 0000000..5922ebb --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaRecordTests.cs @@ -0,0 +1,47 @@ +namespace ZodSharp.JsonSchema; + +/// +/// Tests for JSON Schema export. +/// +/// +/// A record is written as a JSON object, so it exports as type: object whose additionalProperties +/// is the value schema. Before the seam the generic record fell through to +/// the empty-schema fallback. +/// +public class ToJsonSchemaRecordTests +{ + [Test] + public async Task ToJsonSchema_GivenRecord_ExportsObjectWithAdditionalProperties() + { + // Arrange + var schema = Z.Record(Z.Number().Min(0)); + + // Act + var jsonSchema = Z.ToJsonSchema(schema); + + // Assert + await Assert.That(jsonSchema.Type).IsEqualTo("object"); + await Assert.That(jsonSchema.Properties).IsNull(); + + var additionalProperties = jsonSchema.AdditionalProperties as JsonSchemaDefinition; + await Assert.That(additionalProperties).IsNotNull(); + await Assert.That(additionalProperties!.Type).IsEqualTo("number"); + await Assert.That(additionalProperties.Minimum).IsEqualTo(0d); + } + + [Test] + public async Task ToJsonSchema_GivenRecordOfNullableValues_ExportsNullValueBranch() + { + // Arrange + var schema = Z.Record(Z.Nullable(Z.Number())); + + // Act + var jsonSchema = Z.ToJsonSchema(schema); + + // Assert + var additionalProperties = jsonSchema.AdditionalProperties as JsonSchemaDefinition; + await Assert.That(additionalProperties).IsNotNull(); + await Assert.That(additionalProperties!.AnyOf).Count().IsEqualTo(2); + await Assert.That(additionalProperties.AnyOf!.Any(static branch => branch.Type == "null")).IsTrue(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodRecordTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodRecordTests.cs index 55255cb..80da19c 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodRecordTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodRecordTests.cs @@ -51,6 +51,50 @@ public async Task Record_GivenNull_ReturnsFailure() await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_type"); } + [Test] + public async Task Record_GivenNullValue_ReturnsFailure() + { + // Arrange — a non-nullable value schema rejects a null value, matching z.record(z.string(), T). + var schema = Z.Record(Z.String()); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = null! }); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_type"); + await Assert.That(result.Errors[0].Path).Contains("a"); + } + + [Test] + public async Task Record_GivenNullableValueSchema_AcceptsNullValue() + { + // Arrange — null acceptance is the value schema's decision. + var schema = Z.Record(Z.Optional(Z.String())); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = null }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task Record_GivenInvalidValue_PreservesStructuredIssueMetadata() + { + // Arrange — the value schema's issue metadata survives the key-prefixed path rewrite. + var schema = Z.Record(Z.Array(Z.String()).Min(2)); + + // Act + var result = schema.Validate(new Dictionary { ["a"] = ["only"] }); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors[0].Path[0]).IsEqualTo("a"); + await Assert.That(result.Errors[0].Origin).IsEqualTo("array"); + await Assert.That(result.Errors[0].Minimum).IsEqualTo(2); + } + [Test] public async Task Record_GivenEmptyDictionary_ReturnsSuccess() {