|
1 | 1 | import { Validator } from "@seriousme/openapi-schema-validator"; |
2 | | -import { describe, expect, test } from "vitest"; |
| 2 | +import { describe, expect, test, vi } from "vitest"; |
3 | 3 | import * as z from "zod"; |
4 | 4 | // import * as zCore from "zod/v4/core"; |
5 | 5 |
|
@@ -3235,67 +3235,85 @@ test("basic registry", () => { |
3235 | 3235 | `); |
3236 | 3236 | }); |
3237 | 3237 |
|
3238 | | -test("large registry converts in linear time", () => { |
3239 | | - const count = 2000; |
3240 | | - const convert = (withIntersection: boolean) => { |
| 3238 | +function countMapScanWork<T>(convert: () => T): { result: T; work: number } { |
| 3239 | + const entries = Map.prototype.entries; |
| 3240 | + const values = Map.prototype.values; |
| 3241 | + let work = 0; |
| 3242 | + const entriesSpy = vi.spyOn(Map.prototype, "entries").mockImplementation(function (this: Map<unknown, unknown>) { |
| 3243 | + work += this.size; |
| 3244 | + return entries.call(this); |
| 3245 | + } as typeof Map.prototype.entries); |
| 3246 | + const valuesSpy = vi.spyOn(Map.prototype, "values").mockImplementation(function (this: Map<unknown, unknown>) { |
| 3247 | + work += this.size; |
| 3248 | + return values.call(this); |
| 3249 | + } as typeof Map.prototype.values); |
| 3250 | + try { |
| 3251 | + return { result: convert(), work }; |
| 3252 | + } finally { |
| 3253 | + entriesSpy.mockRestore(); |
| 3254 | + valuesSpy.mockRestore(); |
| 3255 | + } |
| 3256 | +} |
| 3257 | + |
| 3258 | +test("large registry conversion performs linear map-scan work", () => { |
| 3259 | + const count = 64; |
| 3260 | + const convert = (size: number, withIntersection: boolean) => { |
3241 | 3261 | const registry = z.registry<{ id: string }>(); |
3242 | | - for (let i = 0; i < count; i++) { |
| 3262 | + for (let i = 0; i < size; i++) { |
3243 | 3263 | registry.add( |
3244 | 3264 | z.object({ id: z.string(), name: z.string(), count: z.number(), nested: z.object({ a: z.boolean() }) }), |
3245 | 3265 | { id: `Type${i}` } |
3246 | 3266 | ); |
3247 | 3267 | } |
3248 | 3268 | if (withIntersection) registry.add(z.object({ a: z.string() }).and(z.object({ b: z.string() })), { id: "Inter" }); |
3249 | 3269 |
|
3250 | | - const start = performance.now(); |
3251 | | - const { schemas } = z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` }); |
3252 | | - return { schemas, elapsed: performance.now() - start }; |
| 3270 | + return countMapScanWork(() => z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` })); |
3253 | 3271 | }; |
3254 | 3272 |
|
3255 | | - const plain = convert(false); |
3256 | | - expect(Object.keys(plain.schemas)).toHaveLength(count); |
3257 | | - expect(plain.schemas.Type0).toMatchObject({ |
| 3273 | + const plain = convert(count, false); |
| 3274 | + const plainDouble = convert(count * 2, false); |
| 3275 | + expect(Object.keys(plain.result.schemas)).toHaveLength(count); |
| 3276 | + expect(plain.result.schemas.Type0).toMatchObject({ |
3258 | 3277 | $id: "https://example.com/Type0.json", |
3259 | 3278 | type: "object", |
3260 | 3279 | properties: { nested: { type: "object" } }, |
3261 | 3280 | }); |
3262 | | - expect(plain.schemas[`Type${count - 1}`]!.$id).toBe(`https://example.com/Type${count - 1}.json`); |
| 3281 | + expect(plain.result.schemas[`Type${count - 1}`]!.$id).toBe(`https://example.com/Type${count - 1}.json`); |
| 3282 | + expect(plain.work).toBeGreaterThan(0); |
| 3283 | + expect(plainDouble.work).toBeLessThanOrEqual(plain.work * 2); |
3263 | 3284 |
|
3264 | | - // The whole-map passes in extractDefs/finalize used to re-run once per registered schema, which made this quadratic: ~9s of CPU at this size before the passes were hoisted, ~50ms after. |
3265 | | - expect(plain.elapsed).toBeLessThan(5000); |
3266 | | - |
3267 | | - // The intersection fold walks the whole map as well, so it has to run inside the same guard. Hoisting it back out costs ~10x at this size. Comparing the two conversions rather than asserting a fixed budget keeps this independent of how fast the machine is. |
3268 | | - const folded = convert(true); |
3269 | | - expect(folded.schemas.Inter).toMatchObject({ type: "object", properties: { a: {}, b: {} } }); |
3270 | | - expect(folded.elapsed).toBeLessThan(plain.elapsed * 4 + 100); |
| 3285 | + const folded = convert(count, true); |
| 3286 | + const foldedDouble = convert(count * 2, true); |
| 3287 | + expect(folded.result.schemas.Inter).toMatchObject({ type: "object", properties: { a: {}, b: {} } }); |
| 3288 | + expect(folded.work).toBeGreaterThan(0); |
| 3289 | + expect(foldedDouble.work).toBeLessThanOrEqual(folded.work * 2); |
3271 | 3290 | }); |
3272 | 3291 |
|
3273 | | -test("a registry of records with numeric keys converts in linear time", () => { |
3274 | | - const count = 2000; |
3275 | | - const convert = (key: (i: number) => z.core.$ZodType) => { |
| 3292 | +test("a registry of records with numeric keys performs linear map-scan work", () => { |
| 3293 | + const count = 64; |
| 3294 | + const convert = (size: number, key: (i: number) => z.core.$ZodType) => { |
3276 | 3295 | const registry = z.registry<{ id: string }>(); |
3277 | | - for (let i = 0; i < count; i++) { |
| 3296 | + for (let i = 0; i < size; i++) { |
3278 | 3297 | registry.add(z.object({ m: z.record(key(i) as z.core.$ZodRecordKey, z.boolean()) }), { id: `Type${i}` }); |
3279 | 3298 | } |
3280 | | - const start = performance.now(); |
3281 | | - const { schemas } = z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` }); |
3282 | | - return { schemas, elapsed: performance.now() - start }; |
| 3299 | + return countMapScanWork(() => z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` })); |
3283 | 3300 | }; |
3284 | 3301 |
|
3285 | | - const string = convert(() => z.string()); |
3286 | | - const numeric = convert(() => z.number()); |
3287 | | - expect(numeric.schemas.Type0).toMatchObject({ |
| 3302 | + const numeric = convert(count, () => z.number()); |
| 3303 | + const numericDouble = convert(count * 2, () => z.number()); |
| 3304 | + expect(numeric.result.schemas.Type0).toMatchObject({ |
3288 | 3305 | properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } }, |
3289 | 3306 | }); |
| 3307 | + expect(numeric.work).toBeGreaterThan(0); |
| 3308 | + expect(numericDouble.work).toBeLessThanOrEqual(numeric.work * 2); |
3290 | 3309 |
|
3291 | | - // 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. |
3292 | | - expect(numeric.elapsed).toBeLessThan(string.elapsed * 2 + 50); |
3293 | | - |
3294 | | - // 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. |
3295 | | - const extracted = convert((i) => z.number().meta({ id: `Key${i}` })); |
3296 | | - expect(extracted.schemas.Type0).toMatchObject({ |
| 3310 | + const extracted = convert(count, (i) => z.number().meta({ id: `Key${i}` })); |
| 3311 | + const extractedDouble = convert(count * 2, (i) => z.number().meta({ id: `Key${i}` })); |
| 3312 | + expect(extracted.result.schemas.Type0).toMatchObject({ |
3297 | 3313 | properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } }, |
3298 | 3314 | }); |
| 3315 | + expect(extracted.work).toBeGreaterThan(0); |
| 3316 | + expect(extractedDouble.work).toBeLessThanOrEqual(extracted.work * 2); |
3299 | 3317 | }); |
3300 | 3318 |
|
3301 | 3319 | test("registry extracts unregistered subschemas into __shared", () => { |
@@ -3849,6 +3867,31 @@ test("input JSON schema resolves requiredness past transform and catch wrappers" |
3849 | 3867 | expect(required(z.object({ a: z.transform((v: unknown) => String(v)).pipe(z.string()) }))).toEqual(["a"]); |
3850 | 3868 | }); |
3851 | 3869 |
|
| 3870 | +test("object required keys preserve shape order and direction", () => { |
| 3871 | + const symbol = Symbol("symbol"); |
| 3872 | + const shape: z.ZodRawShape = { |
| 3873 | + 10: z.string(), |
| 3874 | + 2: z.string().optional(), |
| 3875 | + plain: z.string(), |
| 3876 | + optional: z.string().optional(), |
| 3877 | + defaulted: z.string().default("x"), |
| 3878 | + caught: z.string().catch("x"), |
| 3879 | + [symbol]: z.string(), |
| 3880 | + }; |
| 3881 | + Object.defineProperty(shape, "__proto__", { value: z.string(), enumerable: true }); |
| 3882 | + Object.defineProperty(shape, "hidden", { value: z.string(), enumerable: false }); |
| 3883 | + |
| 3884 | + const input = z.toJSONSchema(z.object(shape), { io: "input", unrepresentable: "any" }); |
| 3885 | + const output = z.toJSONSchema(z.object(shape), { io: "output", unrepresentable: "any" }); |
| 3886 | + |
| 3887 | + expect(Object.keys(input.properties!)).toEqual(["2", "10", "plain", "optional", "defaulted", "caught", "__proto__"]); |
| 3888 | + expect(input.required).toEqual(["10", "plain", "caught", "__proto__"]); |
| 3889 | + expect(output.required).toEqual(["10", "plain", "defaulted", "caught", "__proto__"]); |
| 3890 | + expect(Object.prototype.hasOwnProperty.call(input.properties!, "__proto__")).toBe(true); |
| 3891 | + expect(input.properties).not.toHaveProperty("hidden"); |
| 3892 | + expect(Object.getOwnPropertySymbols(input.properties!)).toEqual([]); |
| 3893 | +}); |
| 3894 | + |
3852 | 3895 | test("strip output-side examples from input JSON schema for codec", () => { |
3853 | 3896 | const codec = z |
3854 | 3897 | .codec(z.string(), z.number(), { decode: (s) => Number(s), encode: (n) => String(n) }) |
|
0 commit comments