@amritk/generate-validators 0.6.0 → 0.7.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 +24 -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 +166 -39
- 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,30 @@ 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, so they validate as fast as an Ajv-compiled function once emitted —
|
|
99
|
+
and emitting them is far cheaper than compiling a schema at startup. Measured
|
|
100
|
+
on Bun 1.3 (Linux x64), validating valid input at steady state:
|
|
101
|
+
|
|
102
|
+
| schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
|
|
103
|
+
|:--|--:|--:|--:|--:|
|
|
104
|
+
| small (4 fields) | **~9.5M** ops/s | ~9.3M ops/s | ~4.8M ops/s | ~1.7M ops/s |
|
|
105
|
+
| order (nested + array) | **~3.7M** ops/s | ~3.5M ops/s | ~2.0M ops/s | ~0.4M ops/s |
|
|
106
|
+
|
|
107
|
+
Preparing a validator costs ~0.15–0.20 ms for mjst codegen and ~0.12–0.21 ms for
|
|
108
|
+
a TypeBox `TypeCompiler` compile, versus ~13–14 ms for an Ajv compile. All four
|
|
109
|
+
libraries agree on every verdict; parity is asserted before timing (TypeBox is
|
|
110
|
+
given uuid/email format checkers so every library does the same work).
|
|
111
|
+
Micro-benchmark figures vary by machine and runtime — reproduce with:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bun run bench
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
95
119
|
## Related packages
|
|
96
120
|
|
|
97
121
|
- [`@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,7 @@
|
|
|
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
5
|
/**
|
|
6
6
|
* Derives the validator function name from a type name.
|
|
7
7
|
* e.g. "InfoObject" → "validateInfoObject"
|
|
@@ -17,15 +17,16 @@ const typeofString = (type) => {
|
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Generates the inline condition that is TRUE when `accessor` does NOT equal the
|
|
20
|
-
* `const` value. Primitives compare with `!==`; objects/arrays compare
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* `const` value. Primitives compare with `!==`; objects/arrays compare with the
|
|
21
|
+
* runtime `valuesEqual` helper so a reordered-but-equal value still matches (the
|
|
22
|
+
* interpreter uses order-independent deep equality, and `JSON.stringify` would
|
|
23
|
+
* disagree because it is key-order sensitive).
|
|
23
24
|
*/
|
|
24
25
|
const constMismatchCondition = (accessor, value) => {
|
|
25
26
|
if (value === null || typeof value !== 'object') {
|
|
26
27
|
return `${accessor} !== ${JSON.stringify(value)}`;
|
|
27
28
|
}
|
|
28
|
-
return
|
|
29
|
+
return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
|
|
29
30
|
};
|
|
30
31
|
/**
|
|
31
32
|
* Generates the inline condition that is TRUE when a value is the wrong type.
|
|
@@ -47,23 +48,57 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
47
48
|
return '';
|
|
48
49
|
}
|
|
49
50
|
};
|
|
51
|
+
const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
|
|
52
|
+
/**
|
|
53
|
+
* Generates the unknown-key sweep for `additionalProperties: false`, mirroring
|
|
54
|
+
* the interpreter's behaviour (same error message, one error per extra key).
|
|
55
|
+
* The known-keys Set is hoisted to module scope and the sweep uses `for...in`
|
|
56
|
+
* — the same allocation-free shape Ajv compiles to — so the hot path costs one
|
|
57
|
+
* Set lookup per key. Schemas that combine it with `patternProperties` are
|
|
58
|
+
* skipped: the generator does not evaluate key patterns yet, so rejecting
|
|
59
|
+
* every undeclared key would wrongly fail keys the patterns allow.
|
|
60
|
+
*/
|
|
61
|
+
const generateStrictKeyChecks = (schema, ctx) => {
|
|
62
|
+
if (!isSchemaObject(schema))
|
|
63
|
+
return [];
|
|
64
|
+
if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
|
|
65
|
+
return [];
|
|
66
|
+
if ('patternProperties' in schema)
|
|
67
|
+
return [];
|
|
68
|
+
const known = Object.keys(hasProperties(schema) ? schema.properties : {});
|
|
69
|
+
const setName = `_knownKeys${ctx.hoisted.length}`;
|
|
70
|
+
ctx.hoisted.push(`const ${setName} = new Set(${JSON.stringify(known)})`);
|
|
71
|
+
const d = ctx.depth;
|
|
72
|
+
return [
|
|
73
|
+
` for (const _key${d} in ${ctx.objVar}) {`,
|
|
74
|
+
` if (!${setName}.has(_key${d})) {`,
|
|
75
|
+
` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
|
|
76
|
+
` }`,
|
|
77
|
+
` }`,
|
|
78
|
+
];
|
|
79
|
+
};
|
|
50
80
|
/**
|
|
51
81
|
* Generates validation lines for a single property in an object schema.
|
|
52
|
-
* Handles $ref delegation, enum checks, type checks,
|
|
82
|
+
* Handles $ref delegation, enum checks, type checks, string/number constraints,
|
|
83
|
+
* and recursion into inline nested objects.
|
|
53
84
|
*/
|
|
54
|
-
const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
85
|
+
const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
55
86
|
if (!isSchemaObject(propSchema))
|
|
56
87
|
return [];
|
|
57
|
-
const raw =
|
|
58
|
-
const path =
|
|
88
|
+
const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
|
|
89
|
+
const path = `\`${ctx.pathPrefix}/${key}\``;
|
|
90
|
+
// Missing-property errors report at the parent object's path. At the root
|
|
91
|
+
// that is the `_path` parameter itself; inside nested objects it is the
|
|
92
|
+
// parent's accumulated static path.
|
|
93
|
+
const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
59
94
|
const lines = [];
|
|
60
95
|
// $ref — delegate to the imported validator
|
|
61
96
|
if (hasRef(propSchema)) {
|
|
62
97
|
const ref = propSchema.$ref;
|
|
63
98
|
const vName = validatorName(refToName(ref, suffix));
|
|
64
99
|
if (isRequired) {
|
|
65
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
66
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
100
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
101
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
67
102
|
lines.push(` } else {`);
|
|
68
103
|
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
69
104
|
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
@@ -81,8 +116,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
81
116
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
82
117
|
if (instanceOf) {
|
|
83
118
|
if (isRequired) {
|
|
84
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
85
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
119
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
120
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
86
121
|
lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
|
|
87
122
|
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
88
123
|
lines.push(` }`);
|
|
@@ -98,8 +133,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
98
133
|
const primitive = getMjstPrimitive(propSchema);
|
|
99
134
|
if (primitive) {
|
|
100
135
|
if (isRequired) {
|
|
101
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
102
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
136
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
137
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
103
138
|
lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
|
|
104
139
|
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
105
140
|
lines.push(` }`);
|
|
@@ -116,8 +151,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
116
151
|
const mismatch = constMismatchCondition(raw, propSchema.const);
|
|
117
152
|
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
118
153
|
if (isRequired) {
|
|
119
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
120
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
154
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
155
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
121
156
|
lines.push(` } else if (${mismatch}) {`);
|
|
122
157
|
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
123
158
|
lines.push(` }`);
|
|
@@ -134,8 +169,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
134
169
|
const allowed = JSON.stringify(propSchema.enum);
|
|
135
170
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
136
171
|
if (isRequired) {
|
|
137
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
138
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
172
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
173
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
139
174
|
lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
|
|
140
175
|
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
|
|
141
176
|
lines.push(` }`);
|
|
@@ -153,8 +188,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
153
188
|
const wrongType = wrongTypeCondition(raw, t);
|
|
154
189
|
const typLabel = typeofString(t);
|
|
155
190
|
if (isRequired) {
|
|
156
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
157
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
191
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
192
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
158
193
|
if (wrongType) {
|
|
159
194
|
lines.push(` } else if (${wrongType}) {`);
|
|
160
195
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
@@ -189,13 +224,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
189
224
|
// Number constraints
|
|
190
225
|
if (t === 'number' || t === 'integer') {
|
|
191
226
|
if (hasMinimum(propSchema)) {
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
// Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
|
|
228
|
+
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
229
|
+
const op = strict ? '<=' : '<';
|
|
230
|
+
const rel = strict ? '>' : '>=';
|
|
231
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
|
|
232
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
194
233
|
lines.push(` }`);
|
|
195
234
|
}
|
|
196
235
|
if (hasMaximum(propSchema)) {
|
|
197
|
-
|
|
198
|
-
|
|
236
|
+
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
237
|
+
const op = strict ? '>=' : '>';
|
|
238
|
+
const rel = strict ? '<' : '<=';
|
|
239
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
|
|
240
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
199
241
|
lines.push(` }`);
|
|
200
242
|
}
|
|
201
243
|
if (hasExclusiveMinimum(propSchema)) {
|
|
@@ -221,7 +263,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
221
263
|
const vName = validatorName(refToName(itemSchema.$ref, suffix));
|
|
222
264
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
223
265
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
224
|
-
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}
|
|
266
|
+
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
|
|
225
267
|
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
|
|
226
268
|
lines.push(` }`);
|
|
227
269
|
lines.push(` }`);
|
|
@@ -234,15 +276,100 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
234
276
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
235
277
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
236
278
|
lines.push(` const _item = ${raw}[_i]`);
|
|
237
|
-
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}
|
|
279
|
+
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
|
|
238
280
|
lines.push(` }`);
|
|
239
281
|
lines.push(` }`);
|
|
240
282
|
}
|
|
241
283
|
}
|
|
242
284
|
}
|
|
285
|
+
// Inline nested object — recurse so the nested fields are actually
|
|
286
|
+
// validated. Without this only the "must be object" shape check above
|
|
287
|
+
// runs and everything inside the nested object silently passes.
|
|
288
|
+
if (t === 'object') {
|
|
289
|
+
lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
|
|
290
|
+
}
|
|
243
291
|
}
|
|
244
292
|
return lines;
|
|
245
293
|
};
|
|
294
|
+
/**
|
|
295
|
+
* Generates the recursive checks for an inline nested object property, i.e. an
|
|
296
|
+
* object schema written directly under `properties` rather than referenced via
|
|
297
|
+
* `$ref` (those delegate to the referenced validator instead). The value is
|
|
298
|
+
* narrowed into its own block-scoped variable and each nested property runs
|
|
299
|
+
* through the same per-property generator, so nesting works to any depth.
|
|
300
|
+
*/
|
|
301
|
+
const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
302
|
+
if (!isSchemaObject(propSchema))
|
|
303
|
+
return [];
|
|
304
|
+
const child = {
|
|
305
|
+
objVar: `_obj${ctx.depth + 1}`,
|
|
306
|
+
pathPrefix: `${ctx.pathPrefix}/${key}`,
|
|
307
|
+
depth: ctx.depth + 1,
|
|
308
|
+
hoisted: ctx.hoisted,
|
|
309
|
+
};
|
|
310
|
+
const required = new Set(hasRequired(propSchema) ? propSchema.required : []);
|
|
311
|
+
const properties = hasProperties(propSchema) ? propSchema.properties : {};
|
|
312
|
+
const innerLines = [];
|
|
313
|
+
for (const [childKey, childSchema] of Object.entries(properties)) {
|
|
314
|
+
innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
|
|
315
|
+
}
|
|
316
|
+
innerLines.push(...generateStrictKeyChecks(propSchema, child));
|
|
317
|
+
if (innerLines.length === 0)
|
|
318
|
+
return [];
|
|
319
|
+
// The shape check for the property itself already ran (or the property is
|
|
320
|
+
// optional), so re-guard here instead of assuming the value is an object.
|
|
321
|
+
return [
|
|
322
|
+
` if (typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})) {`,
|
|
323
|
+
` const ${child.objVar} = ${raw} as Record<string, unknown>`,
|
|
324
|
+
...innerLines.map((line) => ` ${line}`),
|
|
325
|
+
` }`,
|
|
326
|
+
];
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Generates the `propertyNames` loop: every object key is a string, so we apply
|
|
330
|
+
* the string-relevant constraints of the subschema (or delegate to a `$ref`'s
|
|
331
|
+
* validator). This keeps the generator in step with the interpreter, which runs
|
|
332
|
+
* the whole subschema against each key — not just the `pattern` form.
|
|
333
|
+
*/
|
|
334
|
+
const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
335
|
+
if (!isSchemaObject(nameSchema))
|
|
336
|
+
return [];
|
|
337
|
+
const at = '`${_path}/${_name}`';
|
|
338
|
+
const checks = [];
|
|
339
|
+
if (hasRef(nameSchema)) {
|
|
340
|
+
const vName = validatorName(refToName(nameSchema.$ref, suffix));
|
|
341
|
+
checks.push(` const _nr = ${vName}(_name, ${at})`);
|
|
342
|
+
checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
if (hasPattern(nameSchema)) {
|
|
346
|
+
const re = escapeRegexPattern(nameSchema.pattern);
|
|
347
|
+
const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
|
|
348
|
+
checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
349
|
+
}
|
|
350
|
+
if (hasMinLength(nameSchema)) {
|
|
351
|
+
const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
|
|
352
|
+
checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
353
|
+
}
|
|
354
|
+
if (hasMaxLength(nameSchema)) {
|
|
355
|
+
const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
|
|
356
|
+
checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
357
|
+
}
|
|
358
|
+
if (hasEnum(nameSchema)) {
|
|
359
|
+
const allowed = JSON.stringify(nameSchema.enum);
|
|
360
|
+
const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
361
|
+
const msg = JSON.stringify(`property name must be one of: ${label}`);
|
|
362
|
+
checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
363
|
+
}
|
|
364
|
+
if (hasConst(nameSchema)) {
|
|
365
|
+
const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
|
|
366
|
+
checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (checks.length === 0)
|
|
370
|
+
return [];
|
|
371
|
+
return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
|
|
372
|
+
};
|
|
246
373
|
/**
|
|
247
374
|
* Generates a validator function body for an object schema, checking each
|
|
248
375
|
* property's presence and type and collecting all errors.
|
|
@@ -251,9 +378,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
251
378
|
const vName = validatorName(typeName);
|
|
252
379
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
253
380
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
381
|
+
const ctx = createRootContext();
|
|
254
382
|
const propertyLines = [];
|
|
255
383
|
for (const [key, propSchema] of Object.entries(properties)) {
|
|
256
|
-
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
|
|
384
|
+
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
|
|
257
385
|
if (checks.length > 0) {
|
|
258
386
|
propertyLines.push(...checks);
|
|
259
387
|
}
|
|
@@ -269,6 +397,8 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
269
397
|
propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
270
398
|
propertyLines.push(` }`);
|
|
271
399
|
}
|
|
400
|
+
// additionalProperties: false rejects every key not declared in properties
|
|
401
|
+
propertyLines.push(...generateStrictKeyChecks(schema, ctx));
|
|
272
402
|
// dependentRequired — when a trigger property is present, its dependencies must be too.
|
|
273
403
|
if (hasDependentRequired(schema)) {
|
|
274
404
|
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
@@ -282,20 +412,17 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
282
412
|
}
|
|
283
413
|
}
|
|
284
414
|
}
|
|
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(` }`);
|
|
415
|
+
// propertyNames — every key (always a string) must satisfy the subschema. This
|
|
416
|
+
// mirrors the interpreter, which runs the full subschema against each key.
|
|
417
|
+
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
418
|
+
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
|
|
295
419
|
}
|
|
296
420
|
const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
|
|
421
|
+
// Hoisted statements (e.g. known-keys Sets) come first so every call of the
|
|
422
|
+
// validator reuses them instead of rebuilding them.
|
|
423
|
+
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
297
424
|
return [
|
|
298
|
-
|
|
425
|
+
`${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
299
426
|
` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
|
|
300
427
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
301
428
|
` }`,
|
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"url": "https://github.com/amritk/mjst/issues"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
-
"dist"
|
|
28
|
-
"src"
|
|
27
|
+
"dist"
|
|
29
28
|
],
|
|
30
29
|
"publishConfig": {
|
|
31
30
|
"access": "public"
|
|
@@ -33,23 +32,27 @@
|
|
|
33
32
|
"scripts": {
|
|
34
33
|
"build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
|
|
35
34
|
"types:check": "tsgo -p . --noEmit",
|
|
36
|
-
"test": "NODE_ENV=production vitest run --root ../.. generate-validators"
|
|
35
|
+
"test": "NODE_ENV=production vitest run --root ../.. generate-validators",
|
|
36
|
+
"bench": "bun run ./bench/run.ts"
|
|
37
37
|
},
|
|
38
38
|
"imports": {
|
|
39
39
|
"#generators/*": "./src/generators/*.ts"
|
|
40
40
|
},
|
|
41
41
|
"exports": {
|
|
42
42
|
".": {
|
|
43
|
-
"development": "./src/index.ts",
|
|
44
43
|
"default": "./dist/index.js",
|
|
45
44
|
"types": "./dist/index.d.ts"
|
|
46
45
|
}
|
|
47
46
|
},
|
|
48
47
|
"dependencies": {
|
|
49
48
|
"json-schema-typed": "^8.0.1",
|
|
50
|
-
"@amritk/helpers": "0.
|
|
49
|
+
"@amritk/helpers": "0.9.0"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
|
-
"@scalar/openapi-parser": "^0.26.1"
|
|
52
|
+
"@scalar/openapi-parser": "^0.26.1",
|
|
53
|
+
"@sinclair/typebox": "^0.34.49",
|
|
54
|
+
"ajv": "^8.17.1",
|
|
55
|
+
"ajv-formats": "^3.0.1",
|
|
56
|
+
"zod": "^4.4.3"
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../../src/generators/build-schema.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAmBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,eACnB,UAAU,gBACR,MAAM,0BAEnB,OAAO,CAAC,aAAa,EAAE,CAuBzB,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"collect-validator-imports.d.ts","sourceRoot":"","sources":["../../src/generators/collect-validator-imports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;GAEG;AACH,KAAK,8BAA8B,GAAG;IACpC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IACzD;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAoED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,WAAY,UAAU,YAAY,8BAA8B,KAAG,MAAM,EA6B5G,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"generate-files.d.ts","sourceRoot":"","sources":["../../src/generators/generate-files.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAKjE;;GAEG;AACH,KAAK,4BAA4B,GAAG;IAClC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,qBAAqB,WACxB,UAAU,YACR,MAAM,YACN,4BAA4B,KACrC,MA0BF,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"generate-validator-function.d.ts","sourceRoot":"","sources":["../../src/generators/generate-validator-function.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA+ejE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,WAAY,UAAU,YAAY,MAAM,sBAAgB,MAM7F,CAAA"}
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}
|