zod 4.5.1 → 4.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/package.json +1 -1
- package/src/v4/classic/schemas.ts +3 -6
- package/src/v4/classic/tests/detached-methods.test.ts +25 -1
- package/src/v4/classic/tests/to-json-schema.test.ts +138 -6
- package/src/v4/core/json-schema-processors.ts +93 -5
- package/src/v4/core/to-json-schema.ts +5 -0
- package/src/v4/core/util.ts +2 -1
- package/src/v4/core/versions.ts +1 -1
- package/v4/classic/schemas.cjs +3 -6
- package/v4/classic/schemas.js +3 -6
- package/v4/core/json-schema-processors.cjs +118 -6
- package/v4/core/json-schema-processors.js +95 -6
- package/v4/core/to-json-schema.cjs +3 -0
- package/v4/core/to-json-schema.d.cts +2 -0
- package/v4/core/to-json-schema.d.ts +2 -0
- package/v4/core/to-json-schema.js +3 -0
- package/v4/core/util.cjs +2 -1
- package/v4/core/util.js +2 -1
- package/v4/core/versions.cjs +1 -1
- package/v4/core/versions.js +1 -1
package/README.md
CHANGED
|
@@ -115,8 +115,6 @@ await schema.parseAsync("hello");
|
|
|
115
115
|
|
|
116
116
|
### AOT compilation
|
|
117
117
|
|
|
118
|
-
**Canary only** — compilation has not shipped in a stable release yet. Install with `npm install zod@canary`.
|
|
119
|
-
|
|
120
118
|
For hot validation paths, `z.compile(schema)` returns a schema clone with an ahead-of-time compiled fast path. Valid inputs take the compiled path; invalid inputs fall back to the regular parser so error reporting stays identical.
|
|
121
119
|
|
|
122
120
|
Across a 55-schema benchmark the median speedup is **2.4x**, and it scales with how much work the schema does per parse: a large array of objects is ~9x, a 20-key object ~9x, a nested object ~4.5x, while a bare `z.string()` gains nothing — compilation removes per-node dispatch and allocation, and a single `typeof` has none to remove.
|
package/package.json
CHANGED
|
@@ -305,7 +305,7 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
|
|
|
305
305
|
},
|
|
306
306
|
// `spa` is an alias: same function object as `safeParseAsync`, as before.
|
|
307
307
|
get spa(): ZodType["safeParseAsync"] {
|
|
308
|
-
return this
|
|
308
|
+
return this?.safeParseAsync;
|
|
309
309
|
},
|
|
310
310
|
set spa(value: ZodType["safeParseAsync"]) {
|
|
311
311
|
util.own(this, "spa", value);
|
|
@@ -334,11 +334,8 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
|
|
|
334
334
|
async safeDecodeAsync(data, params) {
|
|
335
335
|
return parse.safeDecodeAsync(this, data, params);
|
|
336
336
|
},
|
|
337
|
-
|
|
338
|
-
return
|
|
339
|
-
},
|
|
340
|
-
set toJSONSchema(value: ZodType["toJSONSchema"]) {
|
|
341
|
-
util.own(this, "toJSONSchema", value);
|
|
337
|
+
toJSONSchema(params) {
|
|
338
|
+
return createToJSONSchemaMethod(this, {})(params);
|
|
342
339
|
},
|
|
343
340
|
// Reads through to the registry on every access, so it must not cache.
|
|
344
341
|
get description(): string | undefined {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "vitest";
|
|
1
|
+
import { expect, test, vi } from "vitest";
|
|
2
2
|
import * as z from "zod/v4";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -195,3 +195,27 @@ test("broad sweep: detaching builder methods does not throw or produce a corrupt
|
|
|
195
195
|
|
|
196
196
|
expect(broken).toEqual([]);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
test("vi.spyOn wraps a prototype method that was never read", () => {
|
|
200
|
+
const schema = z.object({ a: z.string() });
|
|
201
|
+
const spy = vi.spyOn(schema, "safeParse").mockReturnValue({ success: true, data: { a: "mocked" } });
|
|
202
|
+
|
|
203
|
+
expect(schema.safeParse({})).toEqual({ success: true, data: { a: "mocked" } });
|
|
204
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
205
|
+
|
|
206
|
+
spy.mockRestore();
|
|
207
|
+
expect(schema.safeParse({ a: "x" })).toEqual({ success: true, data: { a: "x" } });
|
|
208
|
+
expect(schema.safeParse({}).success).toBe(false);
|
|
209
|
+
|
|
210
|
+
const json = vi.spyOn(schema, "toJSONSchema").mockReturnValue({ type: "null" } as any);
|
|
211
|
+
expect(schema.toJSONSchema()).toEqual({ type: "null" });
|
|
212
|
+
json.mockRestore();
|
|
213
|
+
expect(schema.toJSONSchema().type).toBe("object");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a getter member answers a bare read without a receiver", () => {
|
|
217
|
+
const schema = z.string();
|
|
218
|
+
const get = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(schema), "spa")!.get!;
|
|
219
|
+
expect(get.call(undefined)).toBeUndefined();
|
|
220
|
+
expect(schema.spa).toBe(schema.safeParseAsync);
|
|
221
|
+
});
|
|
@@ -1149,7 +1149,7 @@ describe("toJSONSchema", () => {
|
|
|
1149
1149
|
]);
|
|
1150
1150
|
});
|
|
1151
1151
|
|
|
1152
|
-
test("record
|
|
1152
|
+
test("record stringifies numeric enum keys for propertyNames and required", () => {
|
|
1153
1153
|
enum NumberEnum {
|
|
1154
1154
|
Zero = 0,
|
|
1155
1155
|
One = 1,
|
|
@@ -1164,20 +1164,124 @@ describe("toJSONSchema", () => {
|
|
|
1164
1164
|
},
|
|
1165
1165
|
"propertyNames": {
|
|
1166
1166
|
"enum": [
|
|
1167
|
-
0,
|
|
1168
|
-
1,
|
|
1167
|
+
"0",
|
|
1168
|
+
"1",
|
|
1169
1169
|
],
|
|
1170
|
-
"type": "
|
|
1170
|
+
"type": "string",
|
|
1171
1171
|
},
|
|
1172
1172
|
"required": [
|
|
1173
|
-
0,
|
|
1174
|
-
1,
|
|
1173
|
+
"0",
|
|
1174
|
+
"1",
|
|
1175
1175
|
],
|
|
1176
1176
|
"type": "object",
|
|
1177
1177
|
}
|
|
1178
1178
|
`);
|
|
1179
1179
|
});
|
|
1180
1180
|
|
|
1181
|
+
test("record with a numeric key emits propertyNames over the numeric-string form", () => {
|
|
1182
|
+
expect(z.toJSONSchema(z.record(z.number(), z.boolean())).propertyNames).toEqual({
|
|
1183
|
+
type: "string",
|
|
1184
|
+
pattern: "^-?\\d+(?:\\.\\d+)?$",
|
|
1185
|
+
});
|
|
1186
|
+
// range checks can't apply to a key, so only the integer shape survives
|
|
1187
|
+
expect(z.toJSONSchema(z.record(z.int32(), z.boolean())).propertyNames).toEqual({
|
|
1188
|
+
type: "string",
|
|
1189
|
+
pattern: "^-?\\d+$",
|
|
1190
|
+
});
|
|
1191
|
+
expect(z.toJSONSchema(z.record(z.literal([1, 2]), z.boolean()))).toMatchObject({
|
|
1192
|
+
propertyNames: { type: "string", enum: ["1", "2"] },
|
|
1193
|
+
required: ["1", "2"],
|
|
1194
|
+
});
|
|
1195
|
+
expect(z.toJSONSchema(z.record(z.literal(1), z.boolean())).propertyNames).toEqual({
|
|
1196
|
+
type: "string",
|
|
1197
|
+
const: "1",
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("record key rewrite reaches through wrappers and union branches", () => {
|
|
1202
|
+
const numericString = { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" };
|
|
1203
|
+
// a wrapper only carries its inner type once the refs are flattened, so this is decided after the record itself is emitted
|
|
1204
|
+
expect(
|
|
1205
|
+
z.toJSONSchema(
|
|
1206
|
+
z.record(
|
|
1207
|
+
z.lazy(() => z.number()),
|
|
1208
|
+
z.boolean()
|
|
1209
|
+
)
|
|
1210
|
+
).propertyNames
|
|
1211
|
+
).toEqual(numericString);
|
|
1212
|
+
expect(z.toJSONSchema(z.record(z.number().pipe(z.number()), z.boolean())).propertyNames).toEqual(numericString);
|
|
1213
|
+
expect(z.toJSONSchema(z.record(z.number().readonly(), z.boolean())).propertyNames).toMatchObject(numericString);
|
|
1214
|
+
expect(z.toJSONSchema(z.record(z.union([z.literal("Tuna"), z.literal(21)]), z.string()))).toMatchObject({
|
|
1215
|
+
propertyNames: {
|
|
1216
|
+
anyOf: [
|
|
1217
|
+
{ type: "string", const: "Tuna" },
|
|
1218
|
+
{ type: "string", const: "21" },
|
|
1219
|
+
],
|
|
1220
|
+
},
|
|
1221
|
+
required: ["Tuna", "21"],
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
test("record with a numeric key inlines an extracted key, and leaves a string one referenced", () => {
|
|
1226
|
+
expect(z.toJSONSchema(z.record(z.number().meta({ id: "Num" }), z.boolean())).propertyNames).toEqual({
|
|
1227
|
+
type: "string",
|
|
1228
|
+
pattern: "^-?\\d+(?:\\.\\d+)?$",
|
|
1229
|
+
});
|
|
1230
|
+
expect(z.toJSONSchema(z.record(z.string().meta({ id: "Str" }), z.boolean())).propertyNames).toEqual({
|
|
1231
|
+
$ref: "#/$defs/Str",
|
|
1232
|
+
});
|
|
1233
|
+
// the value position still wants the number form, so the two cannot share one def
|
|
1234
|
+
const key = z.number().meta({ id: "Shared" });
|
|
1235
|
+
expect(z.toJSONSchema(z.object({ a: key, b: z.record(key, z.string()) }))).toMatchObject({
|
|
1236
|
+
properties: { a: { $ref: "#/$defs/Shared" }, b: { propertyNames: { type: "string" } } },
|
|
1237
|
+
$defs: { Shared: { type: "number" } },
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test("record key rewrite reaches a wrapped record", () => {
|
|
1242
|
+
// the flatten copies a record's properties onto its wrapper by reference, so the rewrite has to find every copy
|
|
1243
|
+
expect(z.toJSONSchema(z.record(z.number(), z.boolean()).optional()).propertyNames).toEqual({
|
|
1244
|
+
type: "string",
|
|
1245
|
+
pattern: "^-?\\d+(?:\\.\\d+)?$",
|
|
1246
|
+
});
|
|
1247
|
+
expect(z.toJSONSchema(z.object({ a: z.record(z.literal([1, 2]), z.boolean()).optional() }))).toMatchObject({
|
|
1248
|
+
properties: { a: { propertyNames: { type: "string", enum: ["1", "2"] }, required: ["1", "2"] } },
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test("record with a recursive key converts without looping", () => {
|
|
1253
|
+
const numeric: any = z.lazy(() => z.union([z.number(), numeric]));
|
|
1254
|
+
expect(z.toJSONSchema(z.record(numeric, z.boolean())).propertyNames).toMatchObject({
|
|
1255
|
+
anyOf: [{ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" }, { $ref: "#/$defs/__schema0" }],
|
|
1256
|
+
});
|
|
1257
|
+
// a key with nothing to re-express keeps the reference it had
|
|
1258
|
+
const stringy: any = z.lazy(() => z.union([z.string(), stringy]));
|
|
1259
|
+
expect(z.toJSONSchema(z.record(stringy, z.boolean())).propertyNames).toEqual({ $ref: "#/$defs/__schema0" });
|
|
1260
|
+
expect(
|
|
1261
|
+
z.toJSONSchema(z.record(z.union([z.literal("a"), z.literal("b")]).meta({ id: "Keys" }), z.boolean()))
|
|
1262
|
+
.propertyNames
|
|
1263
|
+
).toEqual({ $ref: "#/$defs/Keys" });
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
test("record with a heterogeneous key stringifies only its numeric members", () => {
|
|
1267
|
+
// a mixed key carries no `type`, so the numeric members are caught by value rather than by type
|
|
1268
|
+
expect(z.toJSONSchema(z.record(z.literal(["a", 1]), z.boolean()))).toMatchObject({
|
|
1269
|
+
propertyNames: { enum: ["a", "1"] },
|
|
1270
|
+
required: ["a", "1"],
|
|
1271
|
+
});
|
|
1272
|
+
// a member no key can spell is left as it was, since the parser only ever retries a key as a number
|
|
1273
|
+
expect(z.toJSONSchema(z.record(z.literal(["a", true]) as any, z.boolean())).propertyNames).toEqual({
|
|
1274
|
+
enum: ["a", true],
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("record stringifies required for every target", () => {
|
|
1279
|
+
const schema = z.record(z.literal([1, 2]), z.boolean());
|
|
1280
|
+
for (const target of ["draft-2020-12", "draft-7", "draft-4", "openapi-3.0"] as const) {
|
|
1281
|
+
expect(z.toJSONSchema(schema, { target }).required).toEqual(["1", "2"]);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1181
1285
|
test("strict record with regex key uses propertyNames", () => {
|
|
1182
1286
|
const schema = z.record(z.string().regex(/^label:[a-z]{2}$/), z.string());
|
|
1183
1287
|
|
|
@@ -3022,6 +3126,34 @@ test("large registry converts in linear time", () => {
|
|
|
3022
3126
|
expect(folded.elapsed).toBeLessThan(plain.elapsed * 4 + 100);
|
|
3023
3127
|
});
|
|
3024
3128
|
|
|
3129
|
+
test("a registry of records with numeric keys converts in linear time", () => {
|
|
3130
|
+
const count = 2000;
|
|
3131
|
+
const convert = (key: (i: number) => z.core.$ZodType) => {
|
|
3132
|
+
const registry = z.registry<{ id: string }>();
|
|
3133
|
+
for (let i = 0; i < count; i++) {
|
|
3134
|
+
registry.add(z.object({ m: z.record(key(i) as z.core.$ZodRecordKey, z.boolean()) }), { id: `Type${i}` });
|
|
3135
|
+
}
|
|
3136
|
+
const start = performance.now();
|
|
3137
|
+
const { schemas } = z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` });
|
|
3138
|
+
return { schemas, elapsed: performance.now() - start };
|
|
3139
|
+
};
|
|
3140
|
+
|
|
3141
|
+
const string = convert(() => z.string());
|
|
3142
|
+
const numeric = convert(() => z.number());
|
|
3143
|
+
expect(numeric.schemas.Type0).toMatchObject({
|
|
3144
|
+
properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } },
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
// The rewrite has to find every carrier the flatten copied `propertyNames` onto, which means a pass over the whole seen map. Running that once per record rather than once per conversion cost ~10x at this size. A string key needs no rewrite at all, so comparing against it keeps this independent of how fast the machine is.
|
|
3148
|
+
expect(numeric.elapsed).toBeLessThan(string.elapsed * 2 + 50);
|
|
3149
|
+
|
|
3150
|
+
// An extracted key resolves through a map built once per conversion rather than a search per reference. There is no stable timing control for that — extraction has its own $defs cost, which swamps the difference — so this only pins the shape.
|
|
3151
|
+
const extracted = convert((i) => z.number().meta({ id: `Key${i}` }));
|
|
3152
|
+
expect(extracted.schemas.Type0).toMatchObject({
|
|
3153
|
+
properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } },
|
|
3154
|
+
});
|
|
3155
|
+
});
|
|
3156
|
+
|
|
3025
3157
|
test("registry extracts unregistered subschemas into __shared", () => {
|
|
3026
3158
|
const registry = z.registry<{ id: string }>();
|
|
3027
3159
|
const address = z.object({ street: z.string() }).meta({ id: "Address" });
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type * as checks from "./checks.js";
|
|
2
2
|
import type * as JSONSchema from "./json-schema.js";
|
|
3
|
+
import * as regexes from "./regexes.js";
|
|
3
4
|
import type { $ZodRegistry } from "./registries.js";
|
|
4
5
|
import type * as schemas from "./schemas.js";
|
|
5
6
|
import {
|
|
6
7
|
type ProcessParams,
|
|
7
8
|
type Processor,
|
|
8
9
|
type RegistryToJSONSchemaParams,
|
|
10
|
+
type Seen,
|
|
9
11
|
type ToJSONSchemaContext,
|
|
10
12
|
type ToJSONSchemaParams,
|
|
11
13
|
type ZodStandardJSONSchemaPayload,
|
|
@@ -46,11 +48,11 @@ export const stringProcessor: Processor<schemas.$ZodString> = (schema, ctx, _jso
|
|
|
46
48
|
}
|
|
47
49
|
if (contentEncoding) json.contentEncoding = contentEncoding;
|
|
48
50
|
if (patterns && patterns.size > 0) {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
else if (
|
|
51
|
+
const patternList = [...patterns];
|
|
52
|
+
if (patternList.length === 1) json.pattern = patternList[0]!.source;
|
|
53
|
+
else if (patternList.length > 1) {
|
|
52
54
|
json.allOf = [
|
|
53
|
-
...
|
|
55
|
+
...patternList.map((regex) => ({
|
|
54
56
|
...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
|
|
55
57
|
? ({ type: "string" } as const)
|
|
56
58
|
: {}),
|
|
@@ -475,6 +477,85 @@ export const tupleProcessor: Processor<schemas.$ZodTuple> = (schema, ctx, _json,
|
|
|
475
477
|
if (typeof maximum === "number") json.maxItems = maximum;
|
|
476
478
|
};
|
|
477
479
|
|
|
480
|
+
/** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
|
|
481
|
+
* numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
|
|
482
|
+
* behind a wrapper only carries its own `type` before then, and a union key only has its branches.
|
|
483
|
+
*
|
|
484
|
+
* A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
|
|
485
|
+
* than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
|
|
486
|
+
* exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
|
|
487
|
+
* accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
|
|
488
|
+
* outright. */
|
|
489
|
+
function stringifyKeyNames(
|
|
490
|
+
bySchema: Map<JSONSchema.BaseSchema, Seen>,
|
|
491
|
+
json: JSONSchema.BaseSchema,
|
|
492
|
+
visited: Set<JSONSchema.BaseSchema>
|
|
493
|
+
): JSONSchema.BaseSchema {
|
|
494
|
+
// an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
|
|
495
|
+
if (json.$ref) {
|
|
496
|
+
// a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
|
|
497
|
+
if (visited.has(json)) return json;
|
|
498
|
+
visited.add(json);
|
|
499
|
+
const def = bySchema.get(json)?.def;
|
|
500
|
+
if (!def) return json;
|
|
501
|
+
const inlined = stringifyKeyNames(bySchema, def, visited);
|
|
502
|
+
return inlined === def ? json : inlined;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
506
|
+
const branches = json[keyword];
|
|
507
|
+
if (!Array.isArray(branches)) continue;
|
|
508
|
+
const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
|
|
509
|
+
// rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
|
|
510
|
+
if (mapped.some((branch, i) => branch !== branches[i])) json = { ...json, [keyword]: mapped };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
|
|
514
|
+
const types = Array.isArray(json.type) ? json.type : [json.type];
|
|
515
|
+
const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
|
|
516
|
+
// a heterogeneous key carries no type at all, so its numeric members are caught here instead
|
|
517
|
+
const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
|
|
518
|
+
if (!numericType && !values?.some((v) => typeof v === "number")) return json;
|
|
519
|
+
|
|
520
|
+
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
|
|
521
|
+
if (rest.enum) rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
|
|
522
|
+
else if (typeof rest.const === "number") rest.const = String(rest.const);
|
|
523
|
+
// a heterogeneous key keeps its absent type: the stringified members already say what a key may be
|
|
524
|
+
if (!numericType) return rest;
|
|
525
|
+
rest.type = "string";
|
|
526
|
+
if (!values) rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
|
|
527
|
+
return rest;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
|
|
531
|
+
const pendingRecords = new WeakMap<ToJSONSchemaContext, schemas.$ZodType[]>();
|
|
532
|
+
|
|
533
|
+
function rewriteKeyNames(ctx: ToJSONSchemaContext): void {
|
|
534
|
+
// an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
|
|
535
|
+
const bySchema = new Map<JSONSchema.BaseSchema, Seen>();
|
|
536
|
+
for (const entry of ctx.seen.values()) {
|
|
537
|
+
if (entry.def && !bySchema.has(entry.schema)) bySchema.set(entry.schema, entry);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const rewrites = new Map<JSONSchema.BaseSchema, JSONSchema.BaseSchema>();
|
|
541
|
+
for (const record of pendingRecords.get(ctx) ?? []) {
|
|
542
|
+
const seen = ctx.seen.get(record);
|
|
543
|
+
const names = (seen?.def ?? seen?.schema)?.propertyNames;
|
|
544
|
+
if (!names || names === true || rewrites.has(names)) continue;
|
|
545
|
+
const rewritten = stringifyKeyNames(bySchema, names, new Set());
|
|
546
|
+
if (rewritten !== names) rewrites.set(names, rewritten);
|
|
547
|
+
}
|
|
548
|
+
if (!rewrites.size) return;
|
|
549
|
+
|
|
550
|
+
// the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
|
|
551
|
+
for (const entry of ctx.seen.values()) {
|
|
552
|
+
for (const carrier of [entry.schema, entry.def]) {
|
|
553
|
+
const rewritten = carrier && rewrites.get(carrier.propertyNames as JSONSchema.BaseSchema);
|
|
554
|
+
if (rewritten) carrier!.propertyNames = rewritten;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
478
559
|
export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _json, params) => {
|
|
479
560
|
const json = _json as JSONSchema.ObjectSchema;
|
|
480
561
|
const def = schema._zod.def as schemas.$ZodRecordDef;
|
|
@@ -502,6 +583,13 @@ export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _jso
|
|
|
502
583
|
...params,
|
|
503
584
|
path: [...params.path, "propertyNames"],
|
|
504
585
|
});
|
|
586
|
+
let pending = pendingRecords.get(ctx);
|
|
587
|
+
if (!pending) {
|
|
588
|
+
pending = [];
|
|
589
|
+
pendingRecords.set(ctx, pending);
|
|
590
|
+
ctx.deferred.push(() => rewriteKeyNames(ctx));
|
|
591
|
+
}
|
|
592
|
+
pending.push(schema);
|
|
505
593
|
}
|
|
506
594
|
json.additionalProperties = process(def.valueType, ctx as any, {
|
|
507
595
|
...params,
|
|
@@ -519,7 +607,7 @@ export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _jso
|
|
|
519
607
|
);
|
|
520
608
|
|
|
521
609
|
if (validKeyValues.length > 0) {
|
|
522
|
-
json.required = validKeyValues
|
|
610
|
+
json.required = validKeyValues.map(String);
|
|
523
611
|
}
|
|
524
612
|
}
|
|
525
613
|
};
|
|
@@ -142,6 +142,8 @@ export interface ToJSONSchemaContext {
|
|
|
142
142
|
reused: "ref" | "inline";
|
|
143
143
|
/** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
|
|
144
144
|
intersections: JSONSchema.BaseSchema[][];
|
|
145
|
+
/** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
|
|
146
|
+
deferred: (() => void)[];
|
|
145
147
|
external?:
|
|
146
148
|
| {
|
|
147
149
|
registry: $ZodRegistry<{ id?: string | undefined }>;
|
|
@@ -180,6 +182,7 @@ export function initializeContext(params: JSONSchemaGeneratorParams): ToJSONSche
|
|
|
180
182
|
cycles: params?.cycles ?? "ref",
|
|
181
183
|
reused: params?.reused ?? "inline",
|
|
182
184
|
intersections: [],
|
|
185
|
+
deferred: [],
|
|
183
186
|
external: params?.external ?? undefined,
|
|
184
187
|
};
|
|
185
188
|
}
|
|
@@ -660,6 +663,8 @@ export function finalize<T extends schemas.$ZodType>(
|
|
|
660
663
|
}
|
|
661
664
|
}
|
|
662
665
|
|
|
666
|
+
for (const rewrite of ctx.deferred) rewrite();
|
|
667
|
+
|
|
663
668
|
// After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
|
|
664
669
|
if (ctx.intersections.length) {
|
|
665
670
|
const carriers = new Map<JSONSchema.BaseSchema[], JSONSchema.BaseSchema[]>();
|
package/src/v4/core/util.ts
CHANGED
|
@@ -1093,7 +1093,8 @@ function defineBound(proto: object, key: string, fn: AnyFunc): void {
|
|
|
1093
1093
|
Object.defineProperty(proto, key, {
|
|
1094
1094
|
configurable: true,
|
|
1095
1095
|
get(this: any) {
|
|
1096
|
-
|
|
1096
|
+
// vitest's spyOn calls a prototype getter bare to find the function it wraps, so a nullish receiver answers the raw method
|
|
1097
|
+
return this == null ? fn : own(this, key, fn.bind(this));
|
|
1097
1098
|
},
|
|
1098
1099
|
set(this: any, value: unknown) {
|
|
1099
1100
|
own(this, key, value);
|
package/src/v4/core/versions.ts
CHANGED
package/v4/classic/schemas.cjs
CHANGED
|
@@ -270,7 +270,7 @@ exports.ZodType = core.$constructor("ZodType", (inst, def) => {
|
|
|
270
270
|
},
|
|
271
271
|
// `spa` is an alias: same function object as `safeParseAsync`, as before.
|
|
272
272
|
get spa() {
|
|
273
|
-
return this
|
|
273
|
+
return this?.safeParseAsync;
|
|
274
274
|
},
|
|
275
275
|
set spa(value) {
|
|
276
276
|
index_js_1.util.own(this, "spa", value);
|
|
@@ -299,11 +299,8 @@ exports.ZodType = core.$constructor("ZodType", (inst, def) => {
|
|
|
299
299
|
async safeDecodeAsync(data, params) {
|
|
300
300
|
return parse.safeDecodeAsync(this, data, params);
|
|
301
301
|
},
|
|
302
|
-
|
|
303
|
-
return
|
|
304
|
-
},
|
|
305
|
-
set toJSONSchema(value) {
|
|
306
|
-
index_js_1.util.own(this, "toJSONSchema", value);
|
|
302
|
+
toJSONSchema(params) {
|
|
303
|
+
return (0, to_json_schema_js_1.createToJSONSchemaMethod)(this, {})(params);
|
|
307
304
|
},
|
|
308
305
|
// Reads through to the registry on every access, so it must not cache.
|
|
309
306
|
get description() {
|
package/v4/classic/schemas.js
CHANGED
|
@@ -144,7 +144,7 @@ export const ZodType = /*@__PURE__*/ core.$constructor("ZodType", (inst, def) =>
|
|
|
144
144
|
},
|
|
145
145
|
// `spa` is an alias: same function object as `safeParseAsync`, as before.
|
|
146
146
|
get spa() {
|
|
147
|
-
return this
|
|
147
|
+
return this?.safeParseAsync;
|
|
148
148
|
},
|
|
149
149
|
set spa(value) {
|
|
150
150
|
util.own(this, "spa", value);
|
|
@@ -173,11 +173,8 @@ export const ZodType = /*@__PURE__*/ core.$constructor("ZodType", (inst, def) =>
|
|
|
173
173
|
async safeDecodeAsync(data, params) {
|
|
174
174
|
return parse.safeDecodeAsync(this, data, params);
|
|
175
175
|
},
|
|
176
|
-
|
|
177
|
-
return
|
|
178
|
-
},
|
|
179
|
-
set toJSONSchema(value) {
|
|
180
|
-
util.own(this, "toJSONSchema", value);
|
|
176
|
+
toJSONSchema(params) {
|
|
177
|
+
return createToJSONSchemaMethod(this, {})(params);
|
|
181
178
|
},
|
|
182
179
|
// Reads through to the registry on every access, so it must not cache.
|
|
183
180
|
get description() {
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
2
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
26
|
exports.allProcessors = exports.lazyProcessor = exports.optionalProcessor = exports.promiseProcessor = exports.readonlyProcessor = exports.pipeProcessor = exports.catchProcessor = exports.prefaultProcessor = exports.defaultProcessor = exports.nonoptionalProcessor = exports.nullableProcessor = exports.recordProcessor = exports.tupleProcessor = exports.intersectionProcessor = exports.unionProcessor = exports.objectProcessor = exports.arrayProcessor = exports.setProcessor = exports.mapProcessor = exports.transformProcessor = exports.functionProcessor = exports.customProcessor = exports.successProcessor = exports.fileProcessor = exports.templateLiteralProcessor = exports.nanProcessor = exports.literalProcessor = exports.enumProcessor = exports.dateProcessor = exports.unknownProcessor = exports.anyProcessor = exports.neverProcessor = exports.voidProcessor = exports.undefinedProcessor = exports.nullProcessor = exports.symbolProcessor = exports.bigintProcessor = exports.booleanProcessor = exports.numberProcessor = exports.stringProcessor = void 0;
|
|
4
27
|
exports.toJSONSchema = toJSONSchema;
|
|
28
|
+
const regexes = __importStar(require("./regexes.cjs"));
|
|
5
29
|
const to_json_schema_js_1 = require("./to-json-schema.cjs");
|
|
6
30
|
const util_js_1 = require("./util.cjs");
|
|
7
31
|
const formatMap = {
|
|
@@ -34,12 +58,12 @@ const stringProcessor = (schema, ctx, _json, _params) => {
|
|
|
34
58
|
if (contentEncoding)
|
|
35
59
|
json.contentEncoding = contentEncoding;
|
|
36
60
|
if (patterns && patterns.size > 0) {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
json.pattern =
|
|
40
|
-
else if (
|
|
61
|
+
const patternList = [...patterns];
|
|
62
|
+
if (patternList.length === 1)
|
|
63
|
+
json.pattern = patternList[0].source;
|
|
64
|
+
else if (patternList.length > 1) {
|
|
41
65
|
json.allOf = [
|
|
42
|
-
...
|
|
66
|
+
...patternList.map((regex) => ({
|
|
43
67
|
...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
|
|
44
68
|
? { type: "string" }
|
|
45
69
|
: {}),
|
|
@@ -461,6 +485,87 @@ const tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
461
485
|
json.maxItems = maximum;
|
|
462
486
|
};
|
|
463
487
|
exports.tupleProcessor = tupleProcessor;
|
|
488
|
+
/** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
|
|
489
|
+
* numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
|
|
490
|
+
* behind a wrapper only carries its own `type` before then, and a union key only has its branches.
|
|
491
|
+
*
|
|
492
|
+
* A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
|
|
493
|
+
* than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
|
|
494
|
+
* exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
|
|
495
|
+
* accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
|
|
496
|
+
* outright. */
|
|
497
|
+
function stringifyKeyNames(bySchema, json, visited) {
|
|
498
|
+
// an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
|
|
499
|
+
if (json.$ref) {
|
|
500
|
+
// a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
|
|
501
|
+
if (visited.has(json))
|
|
502
|
+
return json;
|
|
503
|
+
visited.add(json);
|
|
504
|
+
const def = bySchema.get(json)?.def;
|
|
505
|
+
if (!def)
|
|
506
|
+
return json;
|
|
507
|
+
const inlined = stringifyKeyNames(bySchema, def, visited);
|
|
508
|
+
return inlined === def ? json : inlined;
|
|
509
|
+
}
|
|
510
|
+
for (const keyword of ["anyOf", "oneOf"]) {
|
|
511
|
+
const branches = json[keyword];
|
|
512
|
+
if (!Array.isArray(branches))
|
|
513
|
+
continue;
|
|
514
|
+
const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
|
|
515
|
+
// rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
|
|
516
|
+
if (mapped.some((branch, i) => branch !== branches[i]))
|
|
517
|
+
json = { ...json, [keyword]: mapped };
|
|
518
|
+
}
|
|
519
|
+
// a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
|
|
520
|
+
const types = Array.isArray(json.type) ? json.type : [json.type];
|
|
521
|
+
const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
|
|
522
|
+
// a heterogeneous key carries no type at all, so its numeric members are caught here instead
|
|
523
|
+
const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
|
|
524
|
+
if (!numericType && !values?.some((v) => typeof v === "number"))
|
|
525
|
+
return json;
|
|
526
|
+
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
|
|
527
|
+
if (rest.enum)
|
|
528
|
+
rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
|
|
529
|
+
else if (typeof rest.const === "number")
|
|
530
|
+
rest.const = String(rest.const);
|
|
531
|
+
// a heterogeneous key keeps its absent type: the stringified members already say what a key may be
|
|
532
|
+
if (!numericType)
|
|
533
|
+
return rest;
|
|
534
|
+
rest.type = "string";
|
|
535
|
+
if (!values)
|
|
536
|
+
rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
|
|
537
|
+
return rest;
|
|
538
|
+
}
|
|
539
|
+
/** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
|
|
540
|
+
const pendingRecords = new WeakMap();
|
|
541
|
+
function rewriteKeyNames(ctx) {
|
|
542
|
+
// an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
|
|
543
|
+
const bySchema = new Map();
|
|
544
|
+
for (const entry of ctx.seen.values()) {
|
|
545
|
+
if (entry.def && !bySchema.has(entry.schema))
|
|
546
|
+
bySchema.set(entry.schema, entry);
|
|
547
|
+
}
|
|
548
|
+
const rewrites = new Map();
|
|
549
|
+
for (const record of pendingRecords.get(ctx) ?? []) {
|
|
550
|
+
const seen = ctx.seen.get(record);
|
|
551
|
+
const names = (seen?.def ?? seen?.schema)?.propertyNames;
|
|
552
|
+
if (!names || names === true || rewrites.has(names))
|
|
553
|
+
continue;
|
|
554
|
+
const rewritten = stringifyKeyNames(bySchema, names, new Set());
|
|
555
|
+
if (rewritten !== names)
|
|
556
|
+
rewrites.set(names, rewritten);
|
|
557
|
+
}
|
|
558
|
+
if (!rewrites.size)
|
|
559
|
+
return;
|
|
560
|
+
// the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
|
|
561
|
+
for (const entry of ctx.seen.values()) {
|
|
562
|
+
for (const carrier of [entry.schema, entry.def]) {
|
|
563
|
+
const rewritten = carrier && rewrites.get(carrier.propertyNames);
|
|
564
|
+
if (rewritten)
|
|
565
|
+
carrier.propertyNames = rewritten;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
464
569
|
const recordProcessor = (schema, ctx, _json, params) => {
|
|
465
570
|
const json = _json;
|
|
466
571
|
const def = schema._zod.def;
|
|
@@ -487,6 +592,13 @@ const recordProcessor = (schema, ctx, _json, params) => {
|
|
|
487
592
|
...params,
|
|
488
593
|
path: [...params.path, "propertyNames"],
|
|
489
594
|
});
|
|
595
|
+
let pending = pendingRecords.get(ctx);
|
|
596
|
+
if (!pending) {
|
|
597
|
+
pending = [];
|
|
598
|
+
pendingRecords.set(ctx, pending);
|
|
599
|
+
ctx.deferred.push(() => rewriteKeyNames(ctx));
|
|
600
|
+
}
|
|
601
|
+
pending.push(schema);
|
|
490
602
|
}
|
|
491
603
|
json.additionalProperties = (0, to_json_schema_js_1.process)(def.valueType, ctx, {
|
|
492
604
|
...params,
|
|
@@ -500,7 +612,7 @@ const recordProcessor = (schema, ctx, _json, params) => {
|
|
|
500
612
|
if (keyValues && !def.partial && !omittableOnInput) {
|
|
501
613
|
const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number");
|
|
502
614
|
if (validKeyValues.length > 0) {
|
|
503
|
-
json.required = validKeyValues;
|
|
615
|
+
json.required = validKeyValues.map(String);
|
|
504
616
|
}
|
|
505
617
|
}
|
|
506
618
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as regexes from "./regexes.js";
|
|
1
2
|
import { extractDefs, finalize, handleUnrepresentable, initializeContext, process, } from "./to-json-schema.js";
|
|
2
3
|
import { assignProp, getEnumValues } from "./util.js";
|
|
3
4
|
const formatMap = {
|
|
@@ -30,12 +31,12 @@ export const stringProcessor = (schema, ctx, _json, _params) => {
|
|
|
30
31
|
if (contentEncoding)
|
|
31
32
|
json.contentEncoding = contentEncoding;
|
|
32
33
|
if (patterns && patterns.size > 0) {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
json.pattern =
|
|
36
|
-
else if (
|
|
34
|
+
const patternList = [...patterns];
|
|
35
|
+
if (patternList.length === 1)
|
|
36
|
+
json.pattern = patternList[0].source;
|
|
37
|
+
else if (patternList.length > 1) {
|
|
37
38
|
json.allOf = [
|
|
38
|
-
...
|
|
39
|
+
...patternList.map((regex) => ({
|
|
39
40
|
...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
|
|
40
41
|
? { type: "string" }
|
|
41
42
|
: {}),
|
|
@@ -429,6 +430,87 @@ export const tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
429
430
|
if (typeof maximum === "number")
|
|
430
431
|
json.maxItems = maximum;
|
|
431
432
|
};
|
|
433
|
+
/** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
|
|
434
|
+
* numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
|
|
435
|
+
* behind a wrapper only carries its own `type` before then, and a union key only has its branches.
|
|
436
|
+
*
|
|
437
|
+
* A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
|
|
438
|
+
* than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
|
|
439
|
+
* exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
|
|
440
|
+
* accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
|
|
441
|
+
* outright. */
|
|
442
|
+
function stringifyKeyNames(bySchema, json, visited) {
|
|
443
|
+
// an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
|
|
444
|
+
if (json.$ref) {
|
|
445
|
+
// a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
|
|
446
|
+
if (visited.has(json))
|
|
447
|
+
return json;
|
|
448
|
+
visited.add(json);
|
|
449
|
+
const def = bySchema.get(json)?.def;
|
|
450
|
+
if (!def)
|
|
451
|
+
return json;
|
|
452
|
+
const inlined = stringifyKeyNames(bySchema, def, visited);
|
|
453
|
+
return inlined === def ? json : inlined;
|
|
454
|
+
}
|
|
455
|
+
for (const keyword of ["anyOf", "oneOf"]) {
|
|
456
|
+
const branches = json[keyword];
|
|
457
|
+
if (!Array.isArray(branches))
|
|
458
|
+
continue;
|
|
459
|
+
const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
|
|
460
|
+
// rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
|
|
461
|
+
if (mapped.some((branch, i) => branch !== branches[i]))
|
|
462
|
+
json = { ...json, [keyword]: mapped };
|
|
463
|
+
}
|
|
464
|
+
// a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
|
|
465
|
+
const types = Array.isArray(json.type) ? json.type : [json.type];
|
|
466
|
+
const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
|
|
467
|
+
// a heterogeneous key carries no type at all, so its numeric members are caught here instead
|
|
468
|
+
const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
|
|
469
|
+
if (!numericType && !values?.some((v) => typeof v === "number"))
|
|
470
|
+
return json;
|
|
471
|
+
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
|
|
472
|
+
if (rest.enum)
|
|
473
|
+
rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
|
|
474
|
+
else if (typeof rest.const === "number")
|
|
475
|
+
rest.const = String(rest.const);
|
|
476
|
+
// a heterogeneous key keeps its absent type: the stringified members already say what a key may be
|
|
477
|
+
if (!numericType)
|
|
478
|
+
return rest;
|
|
479
|
+
rest.type = "string";
|
|
480
|
+
if (!values)
|
|
481
|
+
rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
|
|
482
|
+
return rest;
|
|
483
|
+
}
|
|
484
|
+
/** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
|
|
485
|
+
const pendingRecords = new WeakMap();
|
|
486
|
+
function rewriteKeyNames(ctx) {
|
|
487
|
+
// an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
|
|
488
|
+
const bySchema = new Map();
|
|
489
|
+
for (const entry of ctx.seen.values()) {
|
|
490
|
+
if (entry.def && !bySchema.has(entry.schema))
|
|
491
|
+
bySchema.set(entry.schema, entry);
|
|
492
|
+
}
|
|
493
|
+
const rewrites = new Map();
|
|
494
|
+
for (const record of pendingRecords.get(ctx) ?? []) {
|
|
495
|
+
const seen = ctx.seen.get(record);
|
|
496
|
+
const names = (seen?.def ?? seen?.schema)?.propertyNames;
|
|
497
|
+
if (!names || names === true || rewrites.has(names))
|
|
498
|
+
continue;
|
|
499
|
+
const rewritten = stringifyKeyNames(bySchema, names, new Set());
|
|
500
|
+
if (rewritten !== names)
|
|
501
|
+
rewrites.set(names, rewritten);
|
|
502
|
+
}
|
|
503
|
+
if (!rewrites.size)
|
|
504
|
+
return;
|
|
505
|
+
// the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
|
|
506
|
+
for (const entry of ctx.seen.values()) {
|
|
507
|
+
for (const carrier of [entry.schema, entry.def]) {
|
|
508
|
+
const rewritten = carrier && rewrites.get(carrier.propertyNames);
|
|
509
|
+
if (rewritten)
|
|
510
|
+
carrier.propertyNames = rewritten;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
432
514
|
export const recordProcessor = (schema, ctx, _json, params) => {
|
|
433
515
|
const json = _json;
|
|
434
516
|
const def = schema._zod.def;
|
|
@@ -455,6 +537,13 @@ export const recordProcessor = (schema, ctx, _json, params) => {
|
|
|
455
537
|
...params,
|
|
456
538
|
path: [...params.path, "propertyNames"],
|
|
457
539
|
});
|
|
540
|
+
let pending = pendingRecords.get(ctx);
|
|
541
|
+
if (!pending) {
|
|
542
|
+
pending = [];
|
|
543
|
+
pendingRecords.set(ctx, pending);
|
|
544
|
+
ctx.deferred.push(() => rewriteKeyNames(ctx));
|
|
545
|
+
}
|
|
546
|
+
pending.push(schema);
|
|
458
547
|
}
|
|
459
548
|
json.additionalProperties = process(def.valueType, ctx, {
|
|
460
549
|
...params,
|
|
@@ -468,7 +557,7 @@ export const recordProcessor = (schema, ctx, _json, params) => {
|
|
|
468
557
|
if (keyValues && !def.partial && !omittableOnInput) {
|
|
469
558
|
const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number");
|
|
470
559
|
if (validKeyValues.length > 0) {
|
|
471
|
-
json.required = validKeyValues;
|
|
560
|
+
json.required = validKeyValues.map(String);
|
|
472
561
|
}
|
|
473
562
|
}
|
|
474
563
|
};
|
|
@@ -47,6 +47,7 @@ function initializeContext(params) {
|
|
|
47
47
|
cycles: params?.cycles ?? "ref",
|
|
48
48
|
reused: params?.reused ?? "inline",
|
|
49
49
|
intersections: [],
|
|
50
|
+
deferred: [],
|
|
50
51
|
external: params?.external ?? undefined,
|
|
51
52
|
};
|
|
52
53
|
}
|
|
@@ -480,6 +481,8 @@ function finalize(ctx, schema) {
|
|
|
480
481
|
compactTypeUnion(entry[1].def ?? entry[1].schema);
|
|
481
482
|
}
|
|
482
483
|
}
|
|
484
|
+
for (const rewrite of ctx.deferred)
|
|
485
|
+
rewrite();
|
|
483
486
|
// After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
|
|
484
487
|
if (ctx.intersections.length) {
|
|
485
488
|
const carriers = new Map();
|
|
@@ -115,6 +115,8 @@ export interface ToJSONSchemaContext {
|
|
|
115
115
|
reused: "ref" | "inline";
|
|
116
116
|
/** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
|
|
117
117
|
intersections: JSONSchema.BaseSchema[][];
|
|
118
|
+
/** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
|
|
119
|
+
deferred: (() => void)[];
|
|
118
120
|
external?: {
|
|
119
121
|
registry: $ZodRegistry<{
|
|
120
122
|
id?: string | undefined;
|
|
@@ -115,6 +115,8 @@ export interface ToJSONSchemaContext {
|
|
|
115
115
|
reused: "ref" | "inline";
|
|
116
116
|
/** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
|
|
117
117
|
intersections: JSONSchema.BaseSchema[][];
|
|
118
|
+
/** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
|
|
119
|
+
deferred: (() => void)[];
|
|
118
120
|
external?: {
|
|
119
121
|
registry: $ZodRegistry<{
|
|
120
122
|
id?: string | undefined;
|
|
@@ -39,6 +39,7 @@ export function initializeContext(params) {
|
|
|
39
39
|
cycles: params?.cycles ?? "ref",
|
|
40
40
|
reused: params?.reused ?? "inline",
|
|
41
41
|
intersections: [],
|
|
42
|
+
deferred: [],
|
|
42
43
|
external: params?.external ?? undefined,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -472,6 +473,8 @@ export function finalize(ctx, schema) {
|
|
|
472
473
|
compactTypeUnion(entry[1].def ?? entry[1].schema);
|
|
473
474
|
}
|
|
474
475
|
}
|
|
476
|
+
for (const rewrite of ctx.deferred)
|
|
477
|
+
rewrite();
|
|
475
478
|
// After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
|
|
476
479
|
if (ctx.intersections.length) {
|
|
477
480
|
const carriers = new Map();
|
package/v4/core/util.cjs
CHANGED
|
@@ -813,7 +813,8 @@ function defineBound(proto, key, fn) {
|
|
|
813
813
|
Object.defineProperty(proto, key, {
|
|
814
814
|
configurable: true,
|
|
815
815
|
get() {
|
|
816
|
-
|
|
816
|
+
// vitest's spyOn calls a prototype getter bare to find the function it wraps, so a nullish receiver answers the raw method
|
|
817
|
+
return this == null ? fn : own(this, key, fn.bind(this));
|
|
817
818
|
},
|
|
818
819
|
set(value) {
|
|
819
820
|
own(this, key, value);
|
package/v4/core/util.js
CHANGED
|
@@ -744,7 +744,8 @@ function defineBound(proto, key, fn) {
|
|
|
744
744
|
Object.defineProperty(proto, key, {
|
|
745
745
|
configurable: true,
|
|
746
746
|
get() {
|
|
747
|
-
|
|
747
|
+
// vitest's spyOn calls a prototype getter bare to find the function it wraps, so a nullish receiver answers the raw method
|
|
748
|
+
return this == null ? fn : own(this, key, fn.bind(this));
|
|
748
749
|
},
|
|
749
750
|
set(value) {
|
|
750
751
|
own(this, key, value);
|
package/v4/core/versions.cjs
CHANGED
package/v4/core/versions.js
CHANGED