@amritk/generate-validators 0.11.5 → 0.11.6
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
CHANGED
|
@@ -92,6 +92,27 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
92
92
|
|
|
93
93
|
---
|
|
94
94
|
|
|
95
|
+
## Semantics
|
|
96
|
+
|
|
97
|
+
Generated validators track the `@amritk/runtime-validators` interpreter. Array
|
|
98
|
+
items are validated in full — an item's type, `$ref`, nested `properties` /
|
|
99
|
+
`required`, and scalar constraints (`minLength`, `minimum`, …) are all enforced,
|
|
100
|
+
recursing to any depth — and the boolean guard (`isX`) reaches the identical
|
|
101
|
+
verdict. Validating array item *contents* costs throughput proportional to the
|
|
102
|
+
per-item work (a bare `string[]` is free; a closed object with several fields is
|
|
103
|
+
meaningfully slower), which is why array-heavy schemas validate more slowly than
|
|
104
|
+
scalar/object ones.
|
|
105
|
+
|
|
106
|
+
One divergence is worth calling out: **`NaN` satisfies a constrained number.**
|
|
107
|
+
Because the numeric bound checks are the exact negation of the error condition
|
|
108
|
+
(e.g. `!(x < minimum)`), and every comparison against `NaN` is `false`, a `NaN`
|
|
109
|
+
passes `minimum`/`maximum`/`exclusive*`/`multipleOf`. This matches the interpreter
|
|
110
|
+
but differs from validators (e.g. Ajv) that reject `NaN` for `type: "number"`.
|
|
111
|
+
`NaN` never appears in parsed JSON; guard against it upstream if your values can
|
|
112
|
+
be non-JSON.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
95
116
|
## Benchmarks
|
|
96
117
|
|
|
97
118
|
Generated validators are straight-line, monomorphic TypeScript with no generic
|
|
@@ -45,6 +45,35 @@ export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
|
45
45
|
}
|
|
46
46
|
return true
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when every element of \`arr\` is distinct under structural equality
|
|
51
|
+
* ({@link valuesEqual}). Backs generated \`uniqueItems\` checks whose items may be
|
|
52
|
+
* objects or arrays, where a \`JSON.stringify\` dedupe key would be key-order
|
|
53
|
+
* sensitive and let a reordered-but-equal duplicate (\`{ a: 1, b: 2 }\` vs
|
|
54
|
+
* \`{ b: 2, a: 1 }\`) slip through. A native \`Set\` dedupes the all-primitive case
|
|
55
|
+
* in one linear pass; object/array elements fall back to an exact pairwise
|
|
56
|
+
* structural comparison.
|
|
57
|
+
*/
|
|
58
|
+
export const allUnique = (arr: readonly unknown[]): boolean => {
|
|
59
|
+
const len = arr.length
|
|
60
|
+
if (len < 2) return true
|
|
61
|
+
let allPrimitive = true
|
|
62
|
+
for (let i = 0; i < len; i++) {
|
|
63
|
+
const v = arr[i]
|
|
64
|
+
if (v !== null && typeof v === 'object') {
|
|
65
|
+
allPrimitive = false
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (allPrimitive) return new Set(arr).size === len
|
|
70
|
+
for (let i = 0; i < len; i++) {
|
|
71
|
+
for (let j = i + 1; j < len; j++) {
|
|
72
|
+
if (valuesEqual(arr[i], arr[j])) return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
48
77
|
`;
|
|
49
78
|
/**
|
|
50
79
|
* Builds all TypeScript validator files from a JSON Schema by traversing all
|
|
@@ -54,7 +54,7 @@ const collectDirectRefs = (value, refs = []) => {
|
|
|
54
54
|
// generated files, not inlined by this validator. `collectDirectRefs`
|
|
55
55
|
// self-guards on non-objects, so a keyword that is a boolean or missing is a
|
|
56
56
|
// harmless no-op.
|
|
57
|
-
const subSchemaMaps = ['properties', 'patternProperties'];
|
|
57
|
+
const subSchemaMaps = ['properties', 'patternProperties', 'dependentSchemas'];
|
|
58
58
|
for (const mapKey of subSchemaMaps) {
|
|
59
59
|
const map = schema[mapKey];
|
|
60
60
|
if (typeof map === 'object' && map !== null && !Array.isArray(map)) {
|
|
@@ -37,11 +37,14 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
37
37
|
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
|
|
38
38
|
// `.js` extension so the relative import resolves under Node ESM, not only Bun.
|
|
39
39
|
let result = `import type { ValidationResult, ValidationError } from './validation-result.js'\n`;
|
|
40
|
-
// `const` checks
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// Structural `const` checks call the runtime `valuesEqual` helper; structural
|
|
41
|
+
// `uniqueItems` checks call `allUnique`. Both live in `validation-result.js`;
|
|
42
|
+
// import each only when the generated body (validator or boolean guard) uses
|
|
43
|
+
// it, so files that need neither carry no unused import.
|
|
44
|
+
const body = validatorFunction + booleanGuard;
|
|
45
|
+
const runtimeHelpers = ['valuesEqual', 'allUnique'].filter((name) => body.includes(`${name}(`));
|
|
46
|
+
if (runtimeHelpers.length > 0) {
|
|
47
|
+
result += `import { ${runtimeHelpers.join(', ')} } from './validation-result.js'\n`;
|
|
45
48
|
}
|
|
46
49
|
for (const imp of refImports) {
|
|
47
50
|
result += imp + '\n';
|
|
@@ -3,7 +3,7 @@ import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extens
|
|
|
3
3
|
import { multipleOfFailExpr, multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
4
4
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
5
5
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
6
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
6
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
7
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
8
8
|
/**
|
|
9
9
|
* Derives the validator function name from a type name.
|
|
@@ -31,6 +31,43 @@ const constMismatchCondition = (accessor, value) => {
|
|
|
31
31
|
}
|
|
32
32
|
return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
|
|
33
33
|
};
|
|
34
|
+
const SCALAR_ITEM_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']);
|
|
35
|
+
/**
|
|
36
|
+
* True when a schema's values are provably JSON scalars — its `type` is present
|
|
37
|
+
* and every listed type is a primitive. Conservative: a `$ref`, a boolean/absent
|
|
38
|
+
* schema, an `object`/`array` type, or a missing `type` all fail this test.
|
|
39
|
+
*/
|
|
40
|
+
const schemaIsScalarOnly = (schema) => {
|
|
41
|
+
if (!isSchemaObject(schema))
|
|
42
|
+
return false;
|
|
43
|
+
const t = schema['type'];
|
|
44
|
+
if (t === undefined)
|
|
45
|
+
return false;
|
|
46
|
+
const types = Array.isArray(t) ? t : [t];
|
|
47
|
+
return types.length > 0 && types.every((x) => typeof x === 'string' && SCALAR_ITEM_TYPES.has(x));
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* True when an array's elements can only be JSON scalars, so a `uniqueItems`
|
|
51
|
+
* check can dedupe by the cheap `JSON.stringify` projection. When items may be
|
|
52
|
+
* objects or arrays this returns false, and the check must instead compare
|
|
53
|
+
* structurally (the `allUnique` runtime helper): `JSON.stringify` is key-order
|
|
54
|
+
* sensitive and would treat `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` as distinct,
|
|
55
|
+
* disagreeing with the interpreter's order-independent deep equality.
|
|
56
|
+
*/
|
|
57
|
+
const arrayItemsAreScalarOnly = (schema) => {
|
|
58
|
+
const prefix = schema['prefixItems'];
|
|
59
|
+
if (Array.isArray(prefix)) {
|
|
60
|
+
if (!prefix.every((p) => schemaIsScalarOnly(p)))
|
|
61
|
+
return false;
|
|
62
|
+
// Tuple tail: a closed tuple (`items`/`additionalItems: false`) has no tail;
|
|
63
|
+
// otherwise the tail schema must itself be scalar-only.
|
|
64
|
+
const tail = 'items' in schema ? schema['items'] : schema['additionalItems'];
|
|
65
|
+
if (tail === false)
|
|
66
|
+
return true;
|
|
67
|
+
return schemaIsScalarOnly(tail);
|
|
68
|
+
}
|
|
69
|
+
return schemaIsScalarOnly(schema['items']);
|
|
70
|
+
};
|
|
34
71
|
/**
|
|
35
72
|
* Generates the inline condition that is TRUE when a value is the wrong type.
|
|
36
73
|
*/
|
|
@@ -405,35 +442,45 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
405
442
|
lines.push(` }`);
|
|
406
443
|
}
|
|
407
444
|
}
|
|
408
|
-
// Array
|
|
445
|
+
// Array items. `$ref` items delegate to the referenced validator. Any other
|
|
446
|
+
// item subschema is validated in full — matching the interpreter — but wrapped
|
|
447
|
+
// in a per-item boolean fast-check (`booleanLeafExpr`): a valid item passes the
|
|
448
|
+
// flat expression and skips the error-collecting body entirely, so the common
|
|
449
|
+
// valid case stays allocation-free (the same hot/cold split the top-level
|
|
450
|
+
// validator uses). This keeps array-heavy throughput close to a bare type check
|
|
451
|
+
// while still fully validating every item. The loop variables carry the nesting
|
|
452
|
+
// depth so item loops can nest (array-of-arrays) without colliding.
|
|
409
453
|
if (hasItems(propSchema)) {
|
|
410
454
|
const itemSchema = propSchema.items;
|
|
455
|
+
const iv = `_i${ctx.depth}`;
|
|
456
|
+
const itemPath = `\`${path.slice(1, -1)}/\${${iv}}\``;
|
|
411
457
|
if (hasRef(itemSchema)) {
|
|
412
458
|
const vName = validatorName(refToName(itemSchema.$ref, suffix));
|
|
413
459
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
414
|
-
lines.push(` for (let
|
|
415
|
-
lines.push(` const _ir = ${vName}(${raw}[
|
|
460
|
+
lines.push(` for (let ${iv} = 0; ${iv} < ${raw}.length; ${iv}++) {`);
|
|
461
|
+
lines.push(` const _ir = ${vName}(${raw}[${iv}], ${itemPath})`);
|
|
416
462
|
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
|
|
417
463
|
lines.push(` }`);
|
|
418
464
|
lines.push(` }`);
|
|
419
465
|
}
|
|
420
|
-
else if (
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (itemWrong) {
|
|
466
|
+
else if (isSchemaObject(itemSchema)) {
|
|
467
|
+
const itemVar = `_item${ctx.depth}`;
|
|
468
|
+
const detail = generateValueChecks('', itemVar, itemPath, itemSchema, suffix, ctx, true);
|
|
469
|
+
if (detail.length > 0) {
|
|
425
470
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
426
|
-
lines.push(` for (let
|
|
427
|
-
lines.push(` const
|
|
428
|
-
lines.push(
|
|
471
|
+
lines.push(` for (let ${iv} = 0; ${iv} < ${raw}.length; ${iv}++) {`);
|
|
472
|
+
lines.push(` const ${itemVar} = ${raw}[${iv}]`);
|
|
473
|
+
lines.push(...detail.map((l) => ` ${l}`));
|
|
429
474
|
lines.push(` }`);
|
|
430
475
|
lines.push(` }`);
|
|
431
476
|
}
|
|
432
477
|
}
|
|
433
478
|
}
|
|
434
|
-
// Array length / uniqueness. `uniqueItems` dedupes by a
|
|
435
|
-
// for primitives
|
|
436
|
-
//
|
|
479
|
+
// Array length / uniqueness. `uniqueItems` dedupes scalar items by a cheap
|
|
480
|
+
// `JSON.stringify` projection (exact for primitives, what the type guard also
|
|
481
|
+
// uses), but falls back to the structural `allUnique` helper when items may be
|
|
482
|
+
// objects/arrays — `JSON.stringify` is key-order sensitive and would disagree
|
|
483
|
+
// with the interpreter's order-independent deep equality.
|
|
437
484
|
if (hasMinItems(propSchema) ||
|
|
438
485
|
hasMaxItems(propSchema) ||
|
|
439
486
|
(hasUniqueItems(propSchema) && propSchema.uniqueItems === true) ||
|
|
@@ -450,7 +497,10 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
450
497
|
lines.push(` }`);
|
|
451
498
|
}
|
|
452
499
|
if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
|
|
453
|
-
|
|
500
|
+
const dupCond = arrayItemsAreScalarOnly(sp)
|
|
501
|
+
? `new Set((${raw} as unknown[]).map((_u) => JSON.stringify(_u))).size !== ${raw}.length`
|
|
502
|
+
: `!allUnique(${raw} as unknown[])`;
|
|
503
|
+
lines.push(` if (Array.isArray(${raw}) && ${dupCond}) {`);
|
|
454
504
|
lines.push(` errors.push({ message: 'must NOT have duplicate items', path: ${path} })`);
|
|
455
505
|
lines.push(` }`);
|
|
456
506
|
}
|
|
@@ -499,34 +549,49 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
499
549
|
};
|
|
500
550
|
/**
|
|
501
551
|
* Validates a value located at a *dynamic* key (a `patternProperties` or
|
|
502
|
-
* `additionalProperties`
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
552
|
+
* `additionalProperties` value), an array item, a combinator branch, or a
|
|
553
|
+
* `dependentSchemas` subschema against `propSchema`. `raw` and `path` are
|
|
554
|
+
* caller-supplied expressions (e.g. `obj[_k]` and `` `${_path}/${_k}` ``) so the
|
|
555
|
+
* checks read a runtime location. By default the leaf checks are
|
|
556
|
+
* `!== undefined`-guarded (an absent optional value is valid); pass
|
|
557
|
+
* `required = true` for values that must be present (array items — a sparse hole
|
|
558
|
+
* reads as `undefined` and must fail), which drops that guard. `_key` is unused
|
|
559
|
+
* (the location is fully encoded by `path`) but kept for positional-call parity
|
|
560
|
+
* with the combinator generators.
|
|
507
561
|
*/
|
|
508
|
-
const generateValueChecks = (
|
|
562
|
+
const generateValueChecks = (_key, raw, path, propSchema, suffix, ctx, required = false) => {
|
|
509
563
|
if (!isSchemaObject(propSchema))
|
|
510
564
|
return [];
|
|
511
565
|
const lines = [];
|
|
566
|
+
// Optional values skip validation when absent, so their leaf checks are
|
|
567
|
+
// `!== undefined`-guarded. Array items are unconditionally present — a sparse
|
|
568
|
+
// hole reads as `undefined` and must FAIL its type/const/enum check — so
|
|
569
|
+
// `required` drops the guard.
|
|
570
|
+
const presence = required ? '' : `${raw} !== undefined && `;
|
|
512
571
|
if (hasRef(propSchema)) {
|
|
513
572
|
const vName = validatorName(refToName(propSchema.$ref, suffix));
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
573
|
+
if (required) {
|
|
574
|
+
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
575
|
+
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
lines.push(` if (${raw} !== undefined) {`);
|
|
579
|
+
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
580
|
+
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
581
|
+
lines.push(` }`);
|
|
582
|
+
}
|
|
518
583
|
return lines;
|
|
519
584
|
}
|
|
520
585
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
521
586
|
if (instanceOf) {
|
|
522
|
-
lines.push(` if (${
|
|
587
|
+
lines.push(` if (${presence}!(${raw} instanceof ${instanceOf})) {`);
|
|
523
588
|
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
524
589
|
lines.push(` }`);
|
|
525
590
|
return lines;
|
|
526
591
|
}
|
|
527
592
|
const primitive = getMjstPrimitive(propSchema);
|
|
528
593
|
if (primitive) {
|
|
529
|
-
lines.push(` if (${
|
|
594
|
+
lines.push(` if (${presence}typeof ${raw} !== "${primitive}") {`);
|
|
530
595
|
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
531
596
|
lines.push(` }`);
|
|
532
597
|
return lines;
|
|
@@ -534,7 +599,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
534
599
|
if (hasConst(propSchema)) {
|
|
535
600
|
const mismatch = constMismatchCondition(raw, propSchema.const);
|
|
536
601
|
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
537
|
-
lines.push(` if (${
|
|
602
|
+
lines.push(` if (${presence}${mismatch}) {`);
|
|
538
603
|
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
539
604
|
lines.push(` }`);
|
|
540
605
|
return lines;
|
|
@@ -542,7 +607,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
542
607
|
if (hasEnum(propSchema)) {
|
|
543
608
|
const allowed = JSON.stringify(propSchema.enum);
|
|
544
609
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
545
|
-
lines.push(` if (${
|
|
610
|
+
lines.push(` if (${presence}!(${allowed} as unknown[]).includes(${raw})) {`);
|
|
546
611
|
lines.push(` errors.push({ message: ${JSON.stringify(`must be one of: ${label}`)}, path: ${path} })`);
|
|
547
612
|
lines.push(` }`);
|
|
548
613
|
return lines;
|
|
@@ -552,7 +617,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
552
617
|
const wrongType = wrongTypeCondition(raw, t);
|
|
553
618
|
const typLabel = typeofString(t);
|
|
554
619
|
if (wrongType) {
|
|
555
|
-
lines.push(` if (${
|
|
620
|
+
lines.push(` if (${presence}(${wrongType})) {`);
|
|
556
621
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
557
622
|
lines.push(` }`);
|
|
558
623
|
}
|
|
@@ -561,8 +626,20 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
561
626
|
// gate on keyword presence + a runtime-type guard, so a type-less subschema
|
|
562
627
|
// (a combinator branch like `{ required: [...] }` or `{ minItems: 2 }`) is
|
|
563
628
|
// still validated rather than collapsing to "matches everything".
|
|
564
|
-
|
|
565
|
-
|
|
629
|
+
//
|
|
630
|
+
// This value lives at `path` (a template literal), so anchor the recursion's
|
|
631
|
+
// context there and one nesting level deeper: any nested object/array it emits
|
|
632
|
+
// then builds paths relative to THIS value and mints collision-free variable
|
|
633
|
+
// names, independent of the caller's context. `key` is intentionally dropped
|
|
634
|
+
// (set to `''`) because `path` already locates the value.
|
|
635
|
+
const valueCtx = {
|
|
636
|
+
objVar: ctx.objVar,
|
|
637
|
+
pathPrefix: path.slice(1, -1),
|
|
638
|
+
depth: ctx.depth + 1,
|
|
639
|
+
hoisted: ctx.hoisted,
|
|
640
|
+
};
|
|
641
|
+
lines.push(...generateConstraintChecks('', raw, path, propSchema, suffix, valueCtx));
|
|
642
|
+
lines.push(...generateCombinatorChecks('', raw, path, propSchema, suffix, valueCtx));
|
|
566
643
|
return lines;
|
|
567
644
|
};
|
|
568
645
|
/**
|
|
@@ -698,7 +775,10 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
|
698
775
|
return [];
|
|
699
776
|
const child = {
|
|
700
777
|
objVar: `_obj${ctx.depth + 1}`,
|
|
701
|
-
|
|
778
|
+
// When `key` is empty the value is located AT `ctx.pathPrefix` already (e.g. an
|
|
779
|
+
// inline object reached through a combinator branch or a dynamic-key value), so
|
|
780
|
+
// appending `/${key}` would emit a spurious `//` or trailing `/` in error paths.
|
|
781
|
+
pathPrefix: key === '' ? ctx.pathPrefix : `${ctx.pathPrefix}/${key}`,
|
|
702
782
|
depth: ctx.depth + 1,
|
|
703
783
|
hoisted: ctx.hoisted,
|
|
704
784
|
};
|
|
@@ -712,6 +792,9 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
|
712
792
|
innerLines.push(...generatePatternAndAdditionalChecks(propSchema, suffix, child));
|
|
713
793
|
innerLines.push(...generateStrictKeyChecks(propSchema, child));
|
|
714
794
|
innerLines.push(...generateDependentRequiredChecks(propSchema, child));
|
|
795
|
+
innerLines.push(...generateDependentSchemasChecks(propSchema, suffix, child));
|
|
796
|
+
innerLines.push(...generateDependenciesChecks(propSchema, suffix, child));
|
|
797
|
+
innerLines.push(...generateMinMaxPropertiesChecks(propSchema, child));
|
|
715
798
|
if (hasPropertyNames(propSchema) && isSchemaObject(propSchema.propertyNames)) {
|
|
716
799
|
innerLines.push(...generatePropertyNameChecks(propSchema.propertyNames, suffix, child));
|
|
717
800
|
}
|
|
@@ -727,49 +810,29 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
|
727
810
|
];
|
|
728
811
|
};
|
|
729
812
|
/**
|
|
730
|
-
* Generates the `propertyNames` loop: every object key is a string,
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
813
|
+
* Generates the `propertyNames` loop: every object key is a string, and the
|
|
814
|
+
* *whole* subschema is validated against each key — not just the
|
|
815
|
+
* `pattern`/length/`enum`/`const`/`$ref` subset. Delegating to
|
|
816
|
+
* {@link generateValueChecks} keeps the generator in lockstep with the
|
|
817
|
+
* interpreter, which runs `matchesSchema(nameSchema, key)` per key, so a
|
|
818
|
+
* subschema carrying a combinator, `type`, `multipleOf`, etc. is enforced too.
|
|
819
|
+
* A key is always a present string, so the value checks run in `required` mode
|
|
820
|
+
* (no `!== undefined` guard).
|
|
734
821
|
*/
|
|
735
822
|
const generatePropertyNameChecks = (nameSchema, suffix, ctx) => {
|
|
736
823
|
if (!isSchemaObject(nameSchema))
|
|
737
824
|
return [];
|
|
738
825
|
const at = `\`${ctx.pathPrefix}/\${_name}\``;
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
if (hasPattern(nameSchema)) {
|
|
747
|
-
const re = escapeRegexPattern(nameSchema.pattern);
|
|
748
|
-
const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
|
|
749
|
-
checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
750
|
-
}
|
|
751
|
-
if (hasMinLength(nameSchema)) {
|
|
752
|
-
const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
|
|
753
|
-
checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
754
|
-
}
|
|
755
|
-
if (hasMaxLength(nameSchema)) {
|
|
756
|
-
const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
|
|
757
|
-
checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
758
|
-
}
|
|
759
|
-
if (hasEnum(nameSchema)) {
|
|
760
|
-
const allowed = JSON.stringify(nameSchema.enum);
|
|
761
|
-
const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
762
|
-
const msg = JSON.stringify(`property name must be one of: ${label}`);
|
|
763
|
-
checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
764
|
-
}
|
|
765
|
-
if (hasConst(nameSchema)) {
|
|
766
|
-
const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
|
|
767
|
-
checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
826
|
+
const nameCtx = {
|
|
827
|
+
objVar: ctx.objVar,
|
|
828
|
+
pathPrefix: `${ctx.pathPrefix}/\${_name}`,
|
|
829
|
+
depth: ctx.depth + 1,
|
|
830
|
+
hoisted: ctx.hoisted,
|
|
831
|
+
};
|
|
832
|
+
const checks = generateValueChecks('', '_name', at, nameSchema, suffix, nameCtx, true);
|
|
770
833
|
if (checks.length === 0)
|
|
771
834
|
return [];
|
|
772
|
-
return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks, ` }`];
|
|
835
|
+
return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks.map((line) => ` ${line}`), ` }`];
|
|
773
836
|
};
|
|
774
837
|
/**
|
|
775
838
|
* Emits `dependentRequired` checks: when a trigger key is present, each of its
|
|
@@ -794,6 +857,129 @@ const generateDependentRequiredChecks = (schema, ctx) => {
|
|
|
794
857
|
}
|
|
795
858
|
return lines;
|
|
796
859
|
};
|
|
860
|
+
/**
|
|
861
|
+
* Emits `dependentSchemas` checks (2020-12): when a trigger property is present,
|
|
862
|
+
* the *whole object* must also match the associated subschema. Mirrors the
|
|
863
|
+
* interpreter, which applies the subschema in place against the object. A `true`
|
|
864
|
+
* subschema permits everything (no-op); a `false` subschema makes the trigger's
|
|
865
|
+
* presence always invalid.
|
|
866
|
+
*/
|
|
867
|
+
const generateDependentSchemasChecks = (schema, suffix, ctx) => {
|
|
868
|
+
if (!isSchemaObject(schema))
|
|
869
|
+
return [];
|
|
870
|
+
const dep = schema['dependentSchemas'];
|
|
871
|
+
if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
|
|
872
|
+
return [];
|
|
873
|
+
const obj = ctx.objVar;
|
|
874
|
+
const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
875
|
+
const objPath = `\`${ctx.pathPrefix}\``;
|
|
876
|
+
const lines = [];
|
|
877
|
+
for (const [trigger, sub] of Object.entries(dep)) {
|
|
878
|
+
if (sub === true)
|
|
879
|
+
continue;
|
|
880
|
+
if (sub === false) {
|
|
881
|
+
const msg = JSON.stringify(`must NOT have property '${trigger}'`);
|
|
882
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
|
|
883
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
884
|
+
lines.push(` }`);
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (!isSchemaObject(sub))
|
|
888
|
+
continue;
|
|
889
|
+
// The subschema applies to the object itself, so validate the current object
|
|
890
|
+
// variable against it and gate the whole block on the trigger's presence.
|
|
891
|
+
const checks = generateValueChecks('', obj, objPath, sub, suffix, ctx);
|
|
892
|
+
if (checks.length === 0)
|
|
893
|
+
continue;
|
|
894
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
|
|
895
|
+
lines.push(...checks.map((line) => ` ${line}`));
|
|
896
|
+
lines.push(` }`);
|
|
897
|
+
}
|
|
898
|
+
return lines;
|
|
899
|
+
};
|
|
900
|
+
/**
|
|
901
|
+
* Emits draft-07 `dependencies` — the dual-form predecessor of
|
|
902
|
+
* `dependentRequired` + `dependentSchemas`. When a trigger property is present,
|
|
903
|
+
* an array value requires each listed key, and a schema value is applied to the
|
|
904
|
+
* *whole object*. Mirrors the interpreter, which branches on the value's shape.
|
|
905
|
+
* A `false` subschema makes the trigger's mere presence invalid; a `true`
|
|
906
|
+
* subschema is a no-op.
|
|
907
|
+
*/
|
|
908
|
+
const generateDependenciesChecks = (schema, suffix, ctx) => {
|
|
909
|
+
if (!isSchemaObject(schema))
|
|
910
|
+
return [];
|
|
911
|
+
const dep = schema['dependencies'];
|
|
912
|
+
if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
|
|
913
|
+
return [];
|
|
914
|
+
const obj = ctx.objVar;
|
|
915
|
+
const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
916
|
+
const objPath = `\`${ctx.pathPrefix}\``;
|
|
917
|
+
const lines = [];
|
|
918
|
+
for (const [trigger, value] of Object.entries(dep)) {
|
|
919
|
+
// Array form: each listed key must be present when the trigger is.
|
|
920
|
+
if (Array.isArray(value)) {
|
|
921
|
+
for (const key of value) {
|
|
922
|
+
if (typeof key !== 'string')
|
|
923
|
+
continue;
|
|
924
|
+
const msg = JSON.stringify(`must have property '${key}' when '${trigger}' is present`);
|
|
925
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(key)} in ${obj})) {`);
|
|
926
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
927
|
+
lines.push(` }`);
|
|
928
|
+
}
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
// Schema form: the subschema applies to the object itself.
|
|
932
|
+
if (value === true)
|
|
933
|
+
continue;
|
|
934
|
+
if (value === false) {
|
|
935
|
+
const msg = JSON.stringify(`must NOT have property '${trigger}'`);
|
|
936
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
|
|
937
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
938
|
+
lines.push(` }`);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (!isSchemaObject(value))
|
|
942
|
+
continue;
|
|
943
|
+
const checks = generateValueChecks('', obj, objPath, value, suffix, ctx);
|
|
944
|
+
if (checks.length === 0)
|
|
945
|
+
continue;
|
|
946
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
|
|
947
|
+
lines.push(...checks.map((line) => ` ${line}`));
|
|
948
|
+
lines.push(` }`);
|
|
949
|
+
}
|
|
950
|
+
return lines;
|
|
951
|
+
};
|
|
952
|
+
/**
|
|
953
|
+
* Emits `minProperties` / `maxProperties` bounds on the object's key count,
|
|
954
|
+
* mirroring the interpreter (which counts the object's own enumerable keys and
|
|
955
|
+
* reports at the object node). Counting once into a depth-scoped local keeps the
|
|
956
|
+
* two bounds from re-walking the keys.
|
|
957
|
+
*/
|
|
958
|
+
const generateMinMaxPropertiesChecks = (schema, ctx) => {
|
|
959
|
+
if (!isSchemaObject(schema))
|
|
960
|
+
return [];
|
|
961
|
+
const hasMin = hasMinProperties(schema);
|
|
962
|
+
const hasMax = hasMaxProperties(schema);
|
|
963
|
+
if (!hasMin && !hasMax)
|
|
964
|
+
return [];
|
|
965
|
+
const obj = ctx.objVar;
|
|
966
|
+
const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
967
|
+
const count = `_pc${ctx.depth}`;
|
|
968
|
+
const lines = [` const ${count} = Object.keys(${obj}).length`];
|
|
969
|
+
if (hasMin) {
|
|
970
|
+
const msg = JSON.stringify(`must have at least ${schema.minProperties} properties`);
|
|
971
|
+
lines.push(` if (${count} < ${schema.minProperties}) {`);
|
|
972
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
973
|
+
lines.push(` }`);
|
|
974
|
+
}
|
|
975
|
+
if (hasMax) {
|
|
976
|
+
const msg = JSON.stringify(`must have at most ${schema.maxProperties} properties`);
|
|
977
|
+
lines.push(` if (${count} > ${schema.maxProperties}) {`);
|
|
978
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
979
|
+
lines.push(` }`);
|
|
980
|
+
}
|
|
981
|
+
return lines;
|
|
982
|
+
};
|
|
797
983
|
/**
|
|
798
984
|
* Builds the `&&` conditions that prove a single property is valid, or `null`
|
|
799
985
|
* when the property carries any keyword the slow path enforces beyond a bare
|
|
@@ -803,6 +989,17 @@ const generateDependentRequiredChecks = (schema, ctx) => {
|
|
|
803
989
|
* input, never weakening a verdict. `objAcc` is the expression yielding the
|
|
804
990
|
* parent object (already narrowed to a record); `key` indexes into it.
|
|
805
991
|
*/
|
|
992
|
+
/**
|
|
993
|
+
* Whether an object schema carries a combinator keyword (`allOf`, `anyOf`,
|
|
994
|
+
* `oneOf`, `not`, `if`) that the error-collecting slow path enforces but neither
|
|
995
|
+
* flat guard can mirror. When present, both guards must bail so their early
|
|
996
|
+
* `return true` never accepts a document the combinator would reject.
|
|
997
|
+
*/
|
|
998
|
+
const hasObjectLevelCombinator = (schema) => {
|
|
999
|
+
if (!isSchemaObject(schema))
|
|
1000
|
+
return false;
|
|
1001
|
+
return hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema;
|
|
1002
|
+
};
|
|
806
1003
|
const guardPropConditions = (key, propSchema, objAcc) => {
|
|
807
1004
|
if (!isSchemaObject(propSchema))
|
|
808
1005
|
return null;
|
|
@@ -898,10 +1095,17 @@ const arrayRejectedByRequiredProp = (keys, required, properties) => {
|
|
|
898
1095
|
const guardObjectConditions = (schema, raw, objAcc) => {
|
|
899
1096
|
if (!isObjectSchema(schema))
|
|
900
1097
|
return null;
|
|
901
|
-
if (hasDependentRequired(schema) || hasPropertyNames(schema))
|
|
1098
|
+
if (hasDependentRequired(schema) || hasPropertyNames(schema) || 'dependentSchemas' in schema)
|
|
1099
|
+
return null;
|
|
1100
|
+
if (hasMinProperties(schema) || hasMaxProperties(schema) || 'dependencies' in schema)
|
|
902
1101
|
return null;
|
|
903
1102
|
if (isSchemaObject(schema) && 'patternProperties' in schema)
|
|
904
1103
|
return null;
|
|
1104
|
+
// Object-level combinators are enforced by the slow path but cannot be mirrored
|
|
1105
|
+
// by this flat guard, so bail — otherwise the guard's early `return true` would
|
|
1106
|
+
// accept documents the combinators reject.
|
|
1107
|
+
if (hasObjectLevelCombinator(schema))
|
|
1108
|
+
return null;
|
|
905
1109
|
let strict = false;
|
|
906
1110
|
if (hasAdditionalProperties(schema)) {
|
|
907
1111
|
// Only `additionalProperties: false` is guardable (via the key-count trick
|
|
@@ -969,6 +1173,14 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
969
1173
|
propertyLines.push(...generateStrictKeyChecks(schema, ctx));
|
|
970
1174
|
// dependentRequired — when a trigger property is present, its dependencies must be too.
|
|
971
1175
|
propertyLines.push(...generateDependentRequiredChecks(schema, ctx));
|
|
1176
|
+
// dependentSchemas — when a trigger property is present, the whole object must
|
|
1177
|
+
// also match the associated subschema.
|
|
1178
|
+
propertyLines.push(...generateDependentSchemasChecks(schema, suffix, ctx));
|
|
1179
|
+
// dependencies (draft-07) — the dual-form predecessor of dependentRequired +
|
|
1180
|
+
// dependentSchemas.
|
|
1181
|
+
propertyLines.push(...generateDependenciesChecks(schema, suffix, ctx));
|
|
1182
|
+
// minProperties / maxProperties — bound the object's key count.
|
|
1183
|
+
propertyLines.push(...generateMinMaxPropertiesChecks(schema, ctx));
|
|
972
1184
|
// propertyNames — every key (always a string) must satisfy the subschema. This
|
|
973
1185
|
// mirrors the interpreter, which runs the full subschema against each key.
|
|
974
1186
|
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
@@ -1040,32 +1252,6 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
1040
1252
|
* e.g. "InfoObject" → "isInfoObject"
|
|
1041
1253
|
*/
|
|
1042
1254
|
const guardName = (typeName) => `is${typeName}`;
|
|
1043
|
-
/**
|
|
1044
|
-
* Positive type check for a value — the negation of {@link wrongTypeCondition}.
|
|
1045
|
-
* Used by the boolean type-guard, which proves validity with `&&` conditions
|
|
1046
|
-
* rather than collecting errors. Object is a shape-only check (matching the
|
|
1047
|
-
* validator, which never recurses into array items or untyped object values).
|
|
1048
|
-
*/
|
|
1049
|
-
const rightTypeCondition = (accessor, type) => {
|
|
1050
|
-
switch (type) {
|
|
1051
|
-
case 'string':
|
|
1052
|
-
return `typeof ${accessor} === 'string'`;
|
|
1053
|
-
case 'number':
|
|
1054
|
-
return `typeof ${accessor} === 'number'`;
|
|
1055
|
-
case 'integer':
|
|
1056
|
-
return `typeof ${accessor} === 'number' && Number.isInteger(${accessor})`;
|
|
1057
|
-
case 'boolean':
|
|
1058
|
-
return `typeof ${accessor} === 'boolean'`;
|
|
1059
|
-
case 'array':
|
|
1060
|
-
return `Array.isArray(${accessor})`;
|
|
1061
|
-
case 'null':
|
|
1062
|
-
return `${accessor} === null`;
|
|
1063
|
-
case 'object':
|
|
1064
|
-
return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
|
|
1065
|
-
default:
|
|
1066
|
-
return null;
|
|
1067
|
-
}
|
|
1068
|
-
};
|
|
1069
1255
|
/**
|
|
1070
1256
|
* Builds the membership test for an `enum`, matching the slow path's
|
|
1071
1257
|
* `[...].includes(value)` verdict exactly. For the common all-primitive case it
|
|
@@ -1163,10 +1349,10 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
1163
1349
|
}
|
|
1164
1350
|
};
|
|
1165
1351
|
/**
|
|
1166
|
-
* Boolean expression for an array value. Mirrors the validator
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1352
|
+
* Boolean expression for an array value. Mirrors the validator: array shape,
|
|
1353
|
+
* `minItems`/`maxItems`/`uniqueItems`, and each item validated in full via
|
|
1354
|
+
* {@link booleanLeafExpr}. Returns `null` for `$ref` items, or when an item schema
|
|
1355
|
+
* can't be expressed flat, so the whole guard defers to the validator.
|
|
1170
1356
|
*
|
|
1171
1357
|
* Item iteration goes through `Array.from` rather than `Array.prototype.every`
|
|
1172
1358
|
* because `every` *skips holes* in a sparse array (`[, 'x']`), whereas the
|
|
@@ -1183,7 +1369,11 @@ const booleanArrayExpr = (schema, acc) => {
|
|
|
1183
1369
|
if (hasMaxItems(schema))
|
|
1184
1370
|
parts.push(`${acc}.length <= ${schema.maxItems}`);
|
|
1185
1371
|
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
1186
|
-
|
|
1372
|
+
// Same scalar-vs-structural split as the validator, so the guard's verdict
|
|
1373
|
+
// matches the slow path's for object items in a reordered key order.
|
|
1374
|
+
parts.push(arrayItemsAreScalarOnly(schema)
|
|
1375
|
+
? `new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`
|
|
1376
|
+
: `allUnique(${acc} as unknown[])`);
|
|
1187
1377
|
}
|
|
1188
1378
|
const base = parts.join(' && ');
|
|
1189
1379
|
if (!hasItems(schema))
|
|
@@ -1193,12 +1383,14 @@ const booleanArrayExpr = (schema, acc) => {
|
|
|
1193
1383
|
return base;
|
|
1194
1384
|
if (hasRef(items))
|
|
1195
1385
|
return null;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1386
|
+
// Validate each item in full, mirroring the validator's per-item checks so the
|
|
1387
|
+
// guard reaches the identical verdict. `booleanLeafExpr` returns `null` for item
|
|
1388
|
+
// schemas it can't express flat — bail so the validator decides, keeping the
|
|
1389
|
+
// guard from ever accepting what the slow path would reject.
|
|
1390
|
+
const itemExpr = booleanLeafExpr(items, '_it');
|
|
1391
|
+
if (itemExpr === null)
|
|
1392
|
+
return null;
|
|
1393
|
+
return `${base} && Array.from(${acc} as unknown[]).every((_it) => (${itemExpr}))`;
|
|
1202
1394
|
};
|
|
1203
1395
|
/**
|
|
1204
1396
|
* Builds the `&&` conditions proving an object value is valid (same verdict as
|
|
@@ -1210,10 +1402,16 @@ const booleanObjectParts = (schema, raw, objAcc) => {
|
|
|
1210
1402
|
if (!isObjectSchema(schema))
|
|
1211
1403
|
return null;
|
|
1212
1404
|
// These need per-key loops or cross-references the flat form can't express.
|
|
1213
|
-
if (hasDependentRequired(schema) || hasPropertyNames(schema))
|
|
1405
|
+
if (hasDependentRequired(schema) || hasPropertyNames(schema) || 'dependentSchemas' in schema)
|
|
1406
|
+
return null;
|
|
1407
|
+
if (hasMinProperties(schema) || hasMaxProperties(schema) || 'dependencies' in schema)
|
|
1214
1408
|
return null;
|
|
1215
1409
|
if (isSchemaObject(schema) && 'patternProperties' in schema)
|
|
1216
1410
|
return null;
|
|
1411
|
+
// Object-level combinators change the verdict but can't be expressed flat, so
|
|
1412
|
+
// defer to the validator rather than emit a guard that ignores them.
|
|
1413
|
+
if (hasObjectLevelCombinator(schema))
|
|
1414
|
+
return null;
|
|
1217
1415
|
let strict = false;
|
|
1218
1416
|
if (hasAdditionalProperties(schema)) {
|
|
1219
1417
|
// Only `additionalProperties: false` is expressible; a schema needs per-key
|
|
@@ -1272,8 +1470,12 @@ const booleanObjectParts = (schema, raw, objAcc) => {
|
|
|
1272
1470
|
export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
|
|
1273
1471
|
const name = guardName(typeName);
|
|
1274
1472
|
const fallback = `export const ${name} = (input: unknown): input is ${typeName} => ${validatorName(typeName)}(input) === true`;
|
|
1275
|
-
|
|
1276
|
-
|
|
1473
|
+
// Fold `nullable: true` into `anyOf` so the guard's verdict matches the
|
|
1474
|
+
// validator's (which applies the same rewrite). Without this a nullable node's
|
|
1475
|
+
// guard would reject `null` while the validator accepts it.
|
|
1476
|
+
const rewritten = rewriteNullable(schema);
|
|
1477
|
+
if (isObjectSchema(rewritten)) {
|
|
1478
|
+
const parts = booleanObjectParts(rewritten, 'input', 'obj');
|
|
1277
1479
|
if (parts === null)
|
|
1278
1480
|
return fallback;
|
|
1279
1481
|
return [
|
|
@@ -1286,7 +1488,7 @@ export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
|
|
|
1286
1488
|
].join('\n');
|
|
1287
1489
|
}
|
|
1288
1490
|
// Non-object roots (scalar, enum, array) can often be expressed inline too.
|
|
1289
|
-
const expr = booleanLeafExpr(
|
|
1491
|
+
const expr = booleanLeafExpr(rewritten, 'input');
|
|
1290
1492
|
if (expr === null)
|
|
1291
1493
|
return fallback;
|
|
1292
1494
|
return `export const ${name} = (input: unknown): input is ${typeName} => ${expr}`;
|
|
@@ -1487,6 +1689,76 @@ const assertNoUnsupportedKeywords = (schema, typeName) => {
|
|
|
1487
1689
|
};
|
|
1488
1690
|
visit(schema);
|
|
1489
1691
|
};
|
|
1692
|
+
/** Keywords whose value is a single subschema (or a boolean schema). */
|
|
1693
|
+
const SINGLE_SUBSCHEMA_KEYS = new Set([
|
|
1694
|
+
'additionalProperties',
|
|
1695
|
+
'additionalItems',
|
|
1696
|
+
'contains',
|
|
1697
|
+
'propertyNames',
|
|
1698
|
+
'not',
|
|
1699
|
+
'if',
|
|
1700
|
+
'then',
|
|
1701
|
+
'else',
|
|
1702
|
+
'unevaluatedProperties',
|
|
1703
|
+
'unevaluatedItems',
|
|
1704
|
+
]);
|
|
1705
|
+
/** Keywords whose value is an array of subschemas. */
|
|
1706
|
+
const SUBSCHEMA_LIST_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
|
|
1707
|
+
/** Keywords whose value is a map of names to subschemas. */
|
|
1708
|
+
const SUBSCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']);
|
|
1709
|
+
/**
|
|
1710
|
+
* Rewrites OpenAPI 3.0 `nullable: true` into a form the generator already
|
|
1711
|
+
* enforces. A node `{ nullable: true, ...rest }` accepts a value iff the value is
|
|
1712
|
+
* `null` OR it matches `rest` — exactly `{ anyOf: [{ type: 'null' }, rest] }`.
|
|
1713
|
+
* Emitting that lets the existing `anyOf` machinery (and its guard bail-outs)
|
|
1714
|
+
* mirror the interpreter, which short-circuits every keyword when a `nullable`
|
|
1715
|
+
* node sees `null`.
|
|
1716
|
+
*
|
|
1717
|
+
* The walk descends only into genuine subschema positions (never into `enum` /
|
|
1718
|
+
* `const` / `default` data), so a data value that merely looks like a schema is
|
|
1719
|
+
* never rewritten. Returns a fresh tree; the caller's schema is untouched.
|
|
1720
|
+
*/
|
|
1721
|
+
const rewriteNullable = (node) => {
|
|
1722
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node))
|
|
1723
|
+
return node;
|
|
1724
|
+
const src = node;
|
|
1725
|
+
const out = {};
|
|
1726
|
+
for (const [key, value] of Object.entries(src)) {
|
|
1727
|
+
if (key === 'nullable')
|
|
1728
|
+
continue; // folded into the `anyOf` wrapper below
|
|
1729
|
+
if (SINGLE_SUBSCHEMA_KEYS.has(key)) {
|
|
1730
|
+
out[key] = rewriteNullable(value);
|
|
1731
|
+
}
|
|
1732
|
+
else if (key === 'items') {
|
|
1733
|
+
// `items` is either a single subschema (2020-12) or a tuple array (draft).
|
|
1734
|
+
out[key] = Array.isArray(value) ? value.map(rewriteNullable) : rewriteNullable(value);
|
|
1735
|
+
}
|
|
1736
|
+
else if (SUBSCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
1737
|
+
out[key] = value.map(rewriteNullable);
|
|
1738
|
+
}
|
|
1739
|
+
else if (SUBSCHEMA_MAP_KEYS.has(key) && typeof value === 'object' && value !== null) {
|
|
1740
|
+
const mapped = {};
|
|
1741
|
+
for (const [name, sub] of Object.entries(value))
|
|
1742
|
+
mapped[name] = rewriteNullable(sub);
|
|
1743
|
+
out[key] = mapped;
|
|
1744
|
+
}
|
|
1745
|
+
else if (key === 'dependencies' && typeof value === 'object' && value !== null) {
|
|
1746
|
+
// Dual-form: an array value lists required keys (data), a schema value is a
|
|
1747
|
+
// subschema.
|
|
1748
|
+
const mapped = {};
|
|
1749
|
+
for (const [name, sub] of Object.entries(value)) {
|
|
1750
|
+
mapped[name] = Array.isArray(sub) ? sub : rewriteNullable(sub);
|
|
1751
|
+
}
|
|
1752
|
+
out[key] = mapped;
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
out[key] = value;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
if (src['nullable'] === true)
|
|
1759
|
+
return { anyOf: [{ type: 'null' }, out] };
|
|
1760
|
+
return out;
|
|
1761
|
+
};
|
|
1490
1762
|
/**
|
|
1491
1763
|
* Generates a TypeScript validator function from a JSON Schema.
|
|
1492
1764
|
*
|
|
@@ -1511,8 +1783,11 @@ const assertNoUnsupportedKeywords = (schema, typeName) => {
|
|
|
1511
1783
|
*/
|
|
1512
1784
|
export const generateValidatorFunction = (schema, typeName, suffix = '') => {
|
|
1513
1785
|
assertNoUnsupportedKeywords(schema, typeName);
|
|
1514
|
-
|
|
1515
|
-
|
|
1786
|
+
// Fold OpenAPI `nullable: true` into the `anyOf` form the generator already
|
|
1787
|
+
// enforces, so the emitted checks match the interpreter's null short-circuit.
|
|
1788
|
+
const rewritten = rewriteNullable(schema);
|
|
1789
|
+
if (isObjectSchema(rewritten)) {
|
|
1790
|
+
return generateObjectValidator(rewritten, typeName, suffix);
|
|
1516
1791
|
}
|
|
1517
|
-
return generateScalarValidator(
|
|
1792
|
+
return generateScalarValidator(rewritten, typeName, suffix);
|
|
1518
1793
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -46,9 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"json-schema-typed": "^8.0.1",
|
|
49
|
-
"@amritk/helpers": "0.
|
|
49
|
+
"@amritk/helpers": "0.13.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
+
"@amritk/runtime-validators": "0.7.0",
|
|
52
53
|
"@ryoppippi/unplugin-typia": "^2.6.5",
|
|
53
54
|
"@scalar/openapi-parser": "^0.26.1",
|
|
54
55
|
"@sinclair/typebox": "^0.34.49",
|