@amritk/generate-validators 0.6.0 → 0.8.0
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 +37 -0
- package/dist/generators/build-schema.d.ts +0 -1
- package/dist/generators/build-schema.js +29 -0
- package/dist/generators/collect-validator-imports.d.ts +0 -1
- package/dist/generators/collect-validator-imports.js +5 -1
- package/dist/generators/generate-files.d.ts +0 -1
- package/dist/generators/generate-files.js +6 -0
- package/dist/generators/generate-validator-function.d.ts +0 -1
- package/dist/generators/generate-validator-function.js +309 -40
- package/dist/index.d.ts +0 -1
- package/package.json +10 -7
- package/dist/generators/build-schema.d.ts.map +0 -1
- package/dist/generators/collect-validator-imports.d.ts.map +0 -1
- package/dist/generators/generate-files.d.ts.map +0 -1
- package/dist/generators/generate-validator-function.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/generators/build-schema.test.ts +0 -163
- package/src/generators/build-schema.ts +0 -81
- package/src/generators/collect-validator-imports.ts +0 -134
- package/src/generators/generate-files.ts +0 -79
- package/src/generators/generate-validator-function.test.ts +0 -311
- package/src/generators/generate-validator-function.ts +0 -550
- package/src/index.ts +0 -2
package/README.md
CHANGED
|
@@ -92,6 +92,43 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
92
92
|
|
|
93
93
|
---
|
|
94
94
|
|
|
95
|
+
## Benchmarks
|
|
96
|
+
|
|
97
|
+
Generated validators are straight-line, monomorphic TypeScript with no generic
|
|
98
|
+
dispatch. On the happy path they run a single allocation-free boolean guard — a
|
|
99
|
+
pure `&&` chain of `typeof` checks (plus an `Object.keys().length` count when an
|
|
100
|
+
object is closed with `additionalProperties: false`) — and only fall back to the
|
|
101
|
+
error-collecting body when something is actually wrong. That makes a valid-input
|
|
102
|
+
check as cheap as TypeBox's compiled checker while still emitting full
|
|
103
|
+
JSON-Pointer errors for invalid input, and emitting the validator stays far
|
|
104
|
+
cheaper than compiling a schema at startup. Measured on Bun 1.3 (Linux x64),
|
|
105
|
+
validating valid input at steady state:
|
|
106
|
+
|
|
107
|
+
| schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
|
|
108
|
+
|:--|--:|--:|--:|--:|
|
|
109
|
+
| small (4 fields) | **~37M** ops/s | ~10M ops/s | ~4.9M ops/s | ~2.0M ops/s |
|
|
110
|
+
| order (nested + array) | **~11M** ops/s | ~3.7M ops/s | ~2.0M ops/s | ~0.5M ops/s |
|
|
111
|
+
| assert-loose | **~67M** ops/s | ~40M ops/s | ~57M ops/s | ~3.2M ops/s |
|
|
112
|
+
| assert-strict | **~47M** ops/s | ~19M ops/s | ~36M ops/s | ~1.3M ops/s |
|
|
113
|
+
|
|
114
|
+
The `assert-loose` / `assert-strict` rows are the exact shape used by
|
|
115
|
+
[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
|
|
116
|
+
(seven scalar roots plus a nested object); the boolean guard lets mjst edge out
|
|
117
|
+
TypeBox's compiled checker on both, with and without `additionalProperties:
|
|
118
|
+
false`.
|
|
119
|
+
|
|
120
|
+
Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a
|
|
121
|
+
TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. All four
|
|
122
|
+
libraries agree on every verdict; parity is asserted before timing (TypeBox is
|
|
123
|
+
given uuid/email format checkers so every library does the same work).
|
|
124
|
+
Micro-benchmark figures vary by machine and runtime — reproduce with:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bun run bench
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
95
132
|
## Related packages
|
|
96
133
|
|
|
97
134
|
- [`@amritk/generate-parsers`](../generate-parsers) — type definitions plus parsers that coerce input
|
|
@@ -16,6 +16,35 @@ export type ValidationError = {
|
|
|
16
16
|
* and a list of errors when it is not.
|
|
17
17
|
*/
|
|
18
18
|
export type ValidationResult = true | { valid: false; errors: ValidationError[] }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Structural deep equality used by generated \`const\` checks. Objects compare by
|
|
22
|
+
* their key sets rather than serialization, so \`{ a: 1, b: 2 }\` and
|
|
23
|
+
* \`{ b: 2, a: 1 }\` are equal — unlike \`JSON.stringify\`, which is key-order
|
|
24
|
+
* sensitive and would reject a reordered-but-equal value.
|
|
25
|
+
*/
|
|
26
|
+
export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
27
|
+
if (a === b) return true
|
|
28
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
29
|
+
const aArray = Array.isArray(a)
|
|
30
|
+
const bArray = Array.isArray(b)
|
|
31
|
+
if (aArray !== bArray) return false
|
|
32
|
+
if (aArray) {
|
|
33
|
+
const aa = a as unknown[]
|
|
34
|
+
const bb = b as unknown[]
|
|
35
|
+
if (aa.length !== bb.length) return false
|
|
36
|
+
for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i])) return false
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
const ao = a as Record<string, unknown>
|
|
40
|
+
const bo = b as Record<string, unknown>
|
|
41
|
+
const keys = Object.keys(ao)
|
|
42
|
+
if (keys.length !== Object.keys(bo).length) return false
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key])) return false
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
19
48
|
`;
|
|
20
49
|
/**
|
|
21
50
|
* Builds all TypeScript validator files from a JSON Schema by traversing all
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { refToFilename } from '@amritk/helpers/ref-to-filename';
|
|
2
2
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
3
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
4
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasProperties, hasRef, } from '@amritk/helpers/schema-guards';
|
|
5
5
|
/**
|
|
6
6
|
* Generates an import statement for a single $ref, importing both the type
|
|
7
7
|
* and the validator function from the ref's generated file.
|
|
@@ -43,6 +43,10 @@ const collectDirectRefs = (schema) => {
|
|
|
43
43
|
if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties)) {
|
|
44
44
|
refs.push(prop.additionalProperties.$ref);
|
|
45
45
|
}
|
|
46
|
+
// Inline nested objects are validated recursively by the generator, so any
|
|
47
|
+
// $refs anywhere inside them must become imports as well.
|
|
48
|
+
if (hasProperties(prop))
|
|
49
|
+
refs.push(...collectDirectRefs(prop));
|
|
46
50
|
}
|
|
47
51
|
if (hasItems(schema) && hasRef(schema.items)) {
|
|
48
52
|
refs.push(schema.items.$ref);
|
|
@@ -33,6 +33,12 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
33
33
|
const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
|
|
34
34
|
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
|
|
35
35
|
let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`;
|
|
36
|
+
// `const` checks on object/array values call the runtime `valuesEqual` helper.
|
|
37
|
+
// Only import it when the generated body actually uses it, so files without a
|
|
38
|
+
// structural `const` do not carry an unused import.
|
|
39
|
+
if (validatorFunction.includes('valuesEqual(')) {
|
|
40
|
+
result += `import { valuesEqual } from './validation-result'\n`;
|
|
41
|
+
}
|
|
36
42
|
for (const imp of refImports) {
|
|
37
43
|
result += imp + '\n';
|
|
38
44
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
3
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
4
|
-
import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
|
+
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
5
6
|
/**
|
|
6
7
|
* Derives the validator function name from a type name.
|
|
7
8
|
* e.g. "InfoObject" → "validateInfoObject"
|
|
@@ -17,15 +18,16 @@ const typeofString = (type) => {
|
|
|
17
18
|
};
|
|
18
19
|
/**
|
|
19
20
|
* Generates the inline condition that is TRUE when `accessor` does NOT equal the
|
|
20
|
-
* `const` value. Primitives compare with `!==`; objects/arrays compare
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* `const` value. Primitives compare with `!==`; objects/arrays compare with the
|
|
22
|
+
* runtime `valuesEqual` helper so a reordered-but-equal value still matches (the
|
|
23
|
+
* interpreter uses order-independent deep equality, and `JSON.stringify` would
|
|
24
|
+
* disagree because it is key-order sensitive).
|
|
23
25
|
*/
|
|
24
26
|
const constMismatchCondition = (accessor, value) => {
|
|
25
27
|
if (value === null || typeof value !== 'object') {
|
|
26
28
|
return `${accessor} !== ${JSON.stringify(value)}`;
|
|
27
29
|
}
|
|
28
|
-
return
|
|
30
|
+
return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
|
|
29
31
|
};
|
|
30
32
|
/**
|
|
31
33
|
* Generates the inline condition that is TRUE when a value is the wrong type.
|
|
@@ -47,23 +49,79 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
47
49
|
return '';
|
|
48
50
|
}
|
|
49
51
|
};
|
|
52
|
+
const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
|
|
53
|
+
/**
|
|
54
|
+
* Returns the `patternProperties` regex sources, or an empty array when the
|
|
55
|
+
* schema declares none. The keys of `patternProperties` are the patterns.
|
|
56
|
+
*/
|
|
57
|
+
const patternPropertySources = (schema) => {
|
|
58
|
+
if (!isSchemaObject(schema) || !('patternProperties' in schema))
|
|
59
|
+
return [];
|
|
60
|
+
const patterns = schema.patternProperties;
|
|
61
|
+
if (typeof patterns !== 'object' || patterns === null)
|
|
62
|
+
return [];
|
|
63
|
+
return Object.keys(patterns);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Generates the unknown-key sweep for `additionalProperties: false`, mirroring
|
|
67
|
+
* the interpreter's behaviour (same error message, one error per extra key).
|
|
68
|
+
* The sweep uses `for...in` — the same allocation-free shape Ajv compiles to.
|
|
69
|
+
* The per-key "is this declared" test comes from `unknownKeyCheck`, which
|
|
70
|
+
* inlines `!==` comparisons for small key counts (faster than `Set.has` and
|
|
71
|
+
* allocation-free) and hoists a known-keys `Set` only when the list is long.
|
|
72
|
+
* When the schema also declares `patternProperties`, a key matching any pattern
|
|
73
|
+
* is not "additional": the patterns are compiled once at module scope (the same
|
|
74
|
+
* regex-caching the interpreter does) and a key survives the sweep if it is a
|
|
75
|
+
* known key or matches any pattern.
|
|
76
|
+
*/
|
|
77
|
+
const generateStrictKeyChecks = (schema, ctx) => {
|
|
78
|
+
if (!isSchemaObject(schema))
|
|
79
|
+
return [];
|
|
80
|
+
if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
|
|
81
|
+
return [];
|
|
82
|
+
const known = Object.keys(hasProperties(schema) ? schema.properties : {});
|
|
83
|
+
const d = ctx.depth;
|
|
84
|
+
const check = unknownKeyCheck(known, `_knownKeys${ctx.hoisted.length}`);
|
|
85
|
+
ctx.hoisted.push(...check.declarations);
|
|
86
|
+
// A key that matches any `patternProperties` regex is allowed, so only keys
|
|
87
|
+
// outside both the known keys and every pattern count as additional.
|
|
88
|
+
const patterns = patternPropertySources(schema);
|
|
89
|
+
let patternGuard = '';
|
|
90
|
+
if (patterns.length > 0) {
|
|
91
|
+
const patternsName = `_patterns${ctx.hoisted.length}`;
|
|
92
|
+
ctx.hoisted.push(`const ${patternsName} = [${patterns.map((p) => `new RegExp(${JSON.stringify(p)})`).join(', ')}]`);
|
|
93
|
+
patternGuard = ` && !${patternsName}.some((re) => re.test(_key${d}))`;
|
|
94
|
+
}
|
|
95
|
+
return [
|
|
96
|
+
` for (const _key${d} in ${ctx.objVar}) {`,
|
|
97
|
+
` if (${check.isUnknown(`_key${d}`)}${patternGuard}) {`,
|
|
98
|
+
` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
|
|
99
|
+
` }`,
|
|
100
|
+
` }`,
|
|
101
|
+
];
|
|
102
|
+
};
|
|
50
103
|
/**
|
|
51
104
|
* Generates validation lines for a single property in an object schema.
|
|
52
|
-
* Handles $ref delegation, enum checks, type checks,
|
|
105
|
+
* Handles $ref delegation, enum checks, type checks, string/number constraints,
|
|
106
|
+
* and recursion into inline nested objects.
|
|
53
107
|
*/
|
|
54
|
-
const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
108
|
+
const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
55
109
|
if (!isSchemaObject(propSchema))
|
|
56
110
|
return [];
|
|
57
|
-
const raw =
|
|
58
|
-
const path =
|
|
111
|
+
const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
|
|
112
|
+
const path = `\`${ctx.pathPrefix}/${key}\``;
|
|
113
|
+
// Missing-property errors report at the parent object's path. At the root
|
|
114
|
+
// that is the `_path` parameter itself; inside nested objects it is the
|
|
115
|
+
// parent's accumulated static path.
|
|
116
|
+
const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
59
117
|
const lines = [];
|
|
60
118
|
// $ref — delegate to the imported validator
|
|
61
119
|
if (hasRef(propSchema)) {
|
|
62
120
|
const ref = propSchema.$ref;
|
|
63
121
|
const vName = validatorName(refToName(ref, suffix));
|
|
64
122
|
if (isRequired) {
|
|
65
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
66
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
123
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
124
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
67
125
|
lines.push(` } else {`);
|
|
68
126
|
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
69
127
|
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
@@ -81,8 +139,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
81
139
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
82
140
|
if (instanceOf) {
|
|
83
141
|
if (isRequired) {
|
|
84
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
85
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
142
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
143
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
86
144
|
lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
|
|
87
145
|
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
88
146
|
lines.push(` }`);
|
|
@@ -98,8 +156,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
98
156
|
const primitive = getMjstPrimitive(propSchema);
|
|
99
157
|
if (primitive) {
|
|
100
158
|
if (isRequired) {
|
|
101
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
102
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
159
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
160
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
103
161
|
lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
|
|
104
162
|
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
105
163
|
lines.push(` }`);
|
|
@@ -116,8 +174,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
116
174
|
const mismatch = constMismatchCondition(raw, propSchema.const);
|
|
117
175
|
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
118
176
|
if (isRequired) {
|
|
119
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
120
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
177
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
178
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
121
179
|
lines.push(` } else if (${mismatch}) {`);
|
|
122
180
|
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
123
181
|
lines.push(` }`);
|
|
@@ -134,8 +192,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
134
192
|
const allowed = JSON.stringify(propSchema.enum);
|
|
135
193
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
136
194
|
if (isRequired) {
|
|
137
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
138
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
195
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
196
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
139
197
|
lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
|
|
140
198
|
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
|
|
141
199
|
lines.push(` }`);
|
|
@@ -153,8 +211,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
153
211
|
const wrongType = wrongTypeCondition(raw, t);
|
|
154
212
|
const typLabel = typeofString(t);
|
|
155
213
|
if (isRequired) {
|
|
156
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
157
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
214
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
215
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
158
216
|
if (wrongType) {
|
|
159
217
|
lines.push(` } else if (${wrongType}) {`);
|
|
160
218
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
@@ -189,13 +247,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
189
247
|
// Number constraints
|
|
190
248
|
if (t === 'number' || t === 'integer') {
|
|
191
249
|
if (hasMinimum(propSchema)) {
|
|
192
|
-
|
|
193
|
-
|
|
250
|
+
// Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
|
|
251
|
+
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
252
|
+
const op = strict ? '<=' : '<';
|
|
253
|
+
const rel = strict ? '>' : '>=';
|
|
254
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
|
|
255
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
194
256
|
lines.push(` }`);
|
|
195
257
|
}
|
|
196
258
|
if (hasMaximum(propSchema)) {
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
260
|
+
const op = strict ? '>=' : '>';
|
|
261
|
+
const rel = strict ? '<' : '<=';
|
|
262
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
|
|
263
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
199
264
|
lines.push(` }`);
|
|
200
265
|
}
|
|
201
266
|
if (hasExclusiveMinimum(propSchema)) {
|
|
@@ -221,7 +286,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
221
286
|
const vName = validatorName(refToName(itemSchema.$ref, suffix));
|
|
222
287
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
223
288
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
224
|
-
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}
|
|
289
|
+
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
|
|
225
290
|
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
|
|
226
291
|
lines.push(` }`);
|
|
227
292
|
lines.push(` }`);
|
|
@@ -234,15 +299,207 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
234
299
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
235
300
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
236
301
|
lines.push(` const _item = ${raw}[_i]`);
|
|
237
|
-
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}
|
|
302
|
+
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
|
|
238
303
|
lines.push(` }`);
|
|
239
304
|
lines.push(` }`);
|
|
240
305
|
}
|
|
241
306
|
}
|
|
242
307
|
}
|
|
308
|
+
// Inline nested object — recurse so the nested fields are actually
|
|
309
|
+
// validated. Without this only the "must be object" shape check above
|
|
310
|
+
// runs and everything inside the nested object silently passes.
|
|
311
|
+
if (t === 'object') {
|
|
312
|
+
lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
|
|
313
|
+
}
|
|
243
314
|
}
|
|
244
315
|
return lines;
|
|
245
316
|
};
|
|
317
|
+
/**
|
|
318
|
+
* Generates the recursive checks for an inline nested object property, i.e. an
|
|
319
|
+
* object schema written directly under `properties` rather than referenced via
|
|
320
|
+
* `$ref` (those delegate to the referenced validator instead). The value is
|
|
321
|
+
* narrowed into its own block-scoped variable and each nested property runs
|
|
322
|
+
* through the same per-property generator, so nesting works to any depth.
|
|
323
|
+
*/
|
|
324
|
+
const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
325
|
+
if (!isSchemaObject(propSchema))
|
|
326
|
+
return [];
|
|
327
|
+
const child = {
|
|
328
|
+
objVar: `_obj${ctx.depth + 1}`,
|
|
329
|
+
pathPrefix: `${ctx.pathPrefix}/${key}`,
|
|
330
|
+
depth: ctx.depth + 1,
|
|
331
|
+
hoisted: ctx.hoisted,
|
|
332
|
+
};
|
|
333
|
+
const required = new Set(hasRequired(propSchema) ? propSchema.required : []);
|
|
334
|
+
const properties = hasProperties(propSchema) ? propSchema.properties : {};
|
|
335
|
+
const innerLines = [];
|
|
336
|
+
for (const [childKey, childSchema] of Object.entries(properties)) {
|
|
337
|
+
innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
|
|
338
|
+
}
|
|
339
|
+
innerLines.push(...generateStrictKeyChecks(propSchema, child));
|
|
340
|
+
if (innerLines.length === 0)
|
|
341
|
+
return [];
|
|
342
|
+
// The shape check for the property itself already ran (or the property is
|
|
343
|
+
// optional), so re-guard here instead of assuming the value is an object.
|
|
344
|
+
return [
|
|
345
|
+
` if (typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})) {`,
|
|
346
|
+
` const ${child.objVar} = ${raw} as Record<string, unknown>`,
|
|
347
|
+
...innerLines.map((line) => ` ${line}`),
|
|
348
|
+
` }`,
|
|
349
|
+
];
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* Generates the `propertyNames` loop: every object key is a string, so we apply
|
|
353
|
+
* the string-relevant constraints of the subschema (or delegate to a `$ref`'s
|
|
354
|
+
* validator). This keeps the generator in step with the interpreter, which runs
|
|
355
|
+
* the whole subschema against each key — not just the `pattern` form.
|
|
356
|
+
*/
|
|
357
|
+
const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
358
|
+
if (!isSchemaObject(nameSchema))
|
|
359
|
+
return [];
|
|
360
|
+
const at = '`${_path}/${_name}`';
|
|
361
|
+
const checks = [];
|
|
362
|
+
if (hasRef(nameSchema)) {
|
|
363
|
+
const vName = validatorName(refToName(nameSchema.$ref, suffix));
|
|
364
|
+
checks.push(` const _nr = ${vName}(_name, ${at})`);
|
|
365
|
+
checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
if (hasPattern(nameSchema)) {
|
|
369
|
+
const re = escapeRegexPattern(nameSchema.pattern);
|
|
370
|
+
const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
|
|
371
|
+
checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
372
|
+
}
|
|
373
|
+
if (hasMinLength(nameSchema)) {
|
|
374
|
+
const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
|
|
375
|
+
checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
376
|
+
}
|
|
377
|
+
if (hasMaxLength(nameSchema)) {
|
|
378
|
+
const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
|
|
379
|
+
checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
380
|
+
}
|
|
381
|
+
if (hasEnum(nameSchema)) {
|
|
382
|
+
const allowed = JSON.stringify(nameSchema.enum);
|
|
383
|
+
const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
384
|
+
const msg = JSON.stringify(`property name must be one of: ${label}`);
|
|
385
|
+
checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
386
|
+
}
|
|
387
|
+
if (hasConst(nameSchema)) {
|
|
388
|
+
const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
|
|
389
|
+
checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (checks.length === 0)
|
|
393
|
+
return [];
|
|
394
|
+
return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Builds the `&&` conditions that prove a single property is valid, or `null`
|
|
398
|
+
* when the property carries any keyword the slow path enforces beyond a bare
|
|
399
|
+
* type check (pattern, min/max, enum, const, `$ref`, items, x-mjst, …). A `null`
|
|
400
|
+
* makes the whole guard bail so that input still flows through the slow,
|
|
401
|
+
* error-collecting path — the guard only ever returns true for *provably* valid
|
|
402
|
+
* input, never weakening a verdict. `objAcc` is the expression yielding the
|
|
403
|
+
* parent object (already narrowed to a record); `key` indexes into it.
|
|
404
|
+
*/
|
|
405
|
+
const guardPropConditions = (key, propSchema, objAcc) => {
|
|
406
|
+
if (!isSchemaObject(propSchema))
|
|
407
|
+
return null;
|
|
408
|
+
const raw = `${objAcc}[${JSON.stringify(key)}]`;
|
|
409
|
+
// Anything the slow path enforces past a typeof is cheaper to leave to the
|
|
410
|
+
// slow path than to mirror here, so bail and keep the guard sound.
|
|
411
|
+
if (hasRef(propSchema) ||
|
|
412
|
+
hasEnum(propSchema) ||
|
|
413
|
+
hasConst(propSchema) ||
|
|
414
|
+
hasOneOf(propSchema) ||
|
|
415
|
+
getMjstInstanceOf(propSchema) !== undefined ||
|
|
416
|
+
getMjstPrimitive(propSchema) !== undefined ||
|
|
417
|
+
hasPattern(propSchema) ||
|
|
418
|
+
hasMinLength(propSchema) ||
|
|
419
|
+
hasMaxLength(propSchema) ||
|
|
420
|
+
hasMinimum(propSchema) ||
|
|
421
|
+
hasMaximum(propSchema) ||
|
|
422
|
+
hasExclusiveMinimum(propSchema) ||
|
|
423
|
+
hasExclusiveMaximum(propSchema) ||
|
|
424
|
+
hasMultipleOf(propSchema) ||
|
|
425
|
+
hasItems(propSchema)) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
if (!hasType(propSchema))
|
|
429
|
+
return null;
|
|
430
|
+
switch (propSchema.type) {
|
|
431
|
+
case 'string':
|
|
432
|
+
return [`typeof ${raw} === 'string'`];
|
|
433
|
+
// mjst treats `integer` like `number` (it never enforces integrality), so a
|
|
434
|
+
// `typeof === 'number'` guard matches the slow path's verdict exactly.
|
|
435
|
+
case 'number':
|
|
436
|
+
case 'integer':
|
|
437
|
+
return [`typeof ${raw} === 'number'`];
|
|
438
|
+
case 'boolean':
|
|
439
|
+
return [`typeof ${raw} === 'boolean'`];
|
|
440
|
+
case 'object':
|
|
441
|
+
// Member access into the nested record is only reached after the shape
|
|
442
|
+
// check ahead of it in the `&&` chain, so the cast is always safe.
|
|
443
|
+
return guardObjectConditions(propSchema, raw, `(${raw} as Record<string, unknown>)`);
|
|
444
|
+
// Arrays need a per-item loop the guard can't express, and any other type
|
|
445
|
+
// (null, multi-type, untyped) is left to the slow path.
|
|
446
|
+
default:
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* Builds the allocation-free boolean guard for an object schema as a list of
|
|
452
|
+
* `&&` conditions, or `null` when the schema can't be proven valid by a cheap
|
|
453
|
+
* expression. The conditions are ordered so every member access is guarded by
|
|
454
|
+
* the object-shape check that precedes it in the `&&` chain.
|
|
455
|
+
*
|
|
456
|
+
* The guard only handles the happy path: every declared property must be
|
|
457
|
+
* required and a bare-typed scalar or a likewise-guardable nested object. Any
|
|
458
|
+
* optional property, object-level constraint the slow path enforces
|
|
459
|
+
* (`patternProperties`, `propertyNames`, `dependentRequired`, an
|
|
460
|
+
* `additionalProperties` *schema*), or unguardable property makes it bail, and
|
|
461
|
+
* the validator falls back to its full error-collecting body.
|
|
462
|
+
*/
|
|
463
|
+
const guardObjectConditions = (schema, raw, objAcc) => {
|
|
464
|
+
if (!isObjectSchema(schema))
|
|
465
|
+
return null;
|
|
466
|
+
if (hasDependentRequired(schema) || hasPropertyNames(schema))
|
|
467
|
+
return null;
|
|
468
|
+
if (isSchemaObject(schema) && 'patternProperties' in schema)
|
|
469
|
+
return null;
|
|
470
|
+
let strict = false;
|
|
471
|
+
if (hasAdditionalProperties(schema)) {
|
|
472
|
+
// Only `additionalProperties: false` is guardable (via the key-count trick
|
|
473
|
+
// below); an additional-properties *schema* needs per-key validation.
|
|
474
|
+
if (schema.additionalProperties === false)
|
|
475
|
+
strict = true;
|
|
476
|
+
else
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
480
|
+
const properties = hasProperties(schema) ? schema.properties : {};
|
|
481
|
+
const keys = Object.keys(properties);
|
|
482
|
+
const conditions = [`typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})`];
|
|
483
|
+
for (const key of keys) {
|
|
484
|
+
// An optional property would need an `=== undefined ||` branch and breaks
|
|
485
|
+
// the key-count trick, so the guard only covers all-required objects.
|
|
486
|
+
if (!required.has(key))
|
|
487
|
+
return null;
|
|
488
|
+
const propConditions = guardPropConditions(key, properties[key], objAcc);
|
|
489
|
+
if (propConditions === null)
|
|
490
|
+
return null;
|
|
491
|
+
conditions.push(...propConditions);
|
|
492
|
+
}
|
|
493
|
+
if (strict) {
|
|
494
|
+
// `additionalProperties: false` with every declared property required: once
|
|
495
|
+
// the typeof checks confirm each key is present, an exact key count proves
|
|
496
|
+
// there are no extras — TypeBox's trick, with no loop and no Set.
|
|
497
|
+
if (!keys.every((key) => required.has(key)))
|
|
498
|
+
return null;
|
|
499
|
+
conditions.push(`Object.keys(${objAcc}).length === ${keys.length}`);
|
|
500
|
+
}
|
|
501
|
+
return conditions;
|
|
502
|
+
};
|
|
246
503
|
/**
|
|
247
504
|
* Generates a validator function body for an object schema, checking each
|
|
248
505
|
* property's presence and type and collecting all errors.
|
|
@@ -251,9 +508,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
251
508
|
const vName = validatorName(typeName);
|
|
252
509
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
253
510
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
511
|
+
const ctx = createRootContext();
|
|
254
512
|
const propertyLines = [];
|
|
255
513
|
for (const [key, propSchema] of Object.entries(properties)) {
|
|
256
|
-
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
|
|
514
|
+
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
|
|
257
515
|
if (checks.length > 0) {
|
|
258
516
|
propertyLines.push(...checks);
|
|
259
517
|
}
|
|
@@ -269,6 +527,8 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
269
527
|
propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
270
528
|
propertyLines.push(` }`);
|
|
271
529
|
}
|
|
530
|
+
// additionalProperties: false rejects every key not declared in properties
|
|
531
|
+
propertyLines.push(...generateStrictKeyChecks(schema, ctx));
|
|
272
532
|
// dependentRequired — when a trigger property is present, its dependencies must be too.
|
|
273
533
|
if (hasDependentRequired(schema)) {
|
|
274
534
|
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
@@ -282,26 +542,35 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
282
542
|
}
|
|
283
543
|
}
|
|
284
544
|
}
|
|
285
|
-
// propertyNames
|
|
286
|
-
//
|
|
287
|
-
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)
|
|
288
|
-
|
|
289
|
-
const msg = JSON.stringify(`property name must match pattern ${schema.propertyNames.pattern}`);
|
|
290
|
-
propertyLines.push(` for (const _name of Object.keys(obj)) {`);
|
|
291
|
-
propertyLines.push(` if (!/${re}/.test(_name)) {`);
|
|
292
|
-
propertyLines.push(` errors.push({ message: ${msg}, path: \`\${_path}/\${_name}\` })`);
|
|
293
|
-
propertyLines.push(` }`);
|
|
294
|
-
propertyLines.push(` }`);
|
|
545
|
+
// propertyNames — every key (always a string) must satisfy the subschema. This
|
|
546
|
+
// mirrors the interpreter, which runs the full subschema against each key.
|
|
547
|
+
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
548
|
+
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
|
|
295
549
|
}
|
|
296
550
|
const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
|
|
551
|
+
// Hoisted statements (e.g. known-keys Sets) come first so every call of the
|
|
552
|
+
// validator reuses them instead of rebuilding them.
|
|
553
|
+
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
554
|
+
// A pure boolean guard for the happy path: when every property is present and
|
|
555
|
+
// well-typed (and, for strict objects, there are no extras) it returns true
|
|
556
|
+
// without allocating an `errors` array or walking the slow path. It returns
|
|
557
|
+
// true only for provably valid input; anything it can't prove cheaply falls
|
|
558
|
+
// through to the error-collecting body below, which produces the same verdict
|
|
559
|
+
// and full JSON-Pointer errors. Schemas with constraints the guard can't
|
|
560
|
+
// express produce no guard at all (`null`), leaving behaviour unchanged.
|
|
561
|
+
const guard = guardObjectConditions(schema, 'input', 'obj');
|
|
562
|
+
const guardBlock = guard
|
|
563
|
+
? [` if (`, guard.map((condition) => ` ${condition}`).join(' &&\n'), ` ) {`, ` return true`, ` }`, ``]
|
|
564
|
+
: [];
|
|
297
565
|
return [
|
|
298
|
-
|
|
566
|
+
`${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
567
|
+
` const obj = input as Record<string, unknown>`,
|
|
568
|
+
...guardBlock,
|
|
299
569
|
` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
|
|
300
570
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
301
571
|
` }`,
|
|
302
572
|
``,
|
|
303
573
|
` const errors: ValidationError[] = []`,
|
|
304
|
-
` const obj = input as Record<string, unknown>`,
|
|
305
574
|
body,
|
|
306
575
|
` return errors.length > 0 ? { valid: false, errors } : true`,
|
|
307
576
|
`}`,
|
package/dist/index.d.ts
CHANGED