@amritk/generate-validators 0.10.0 → 0.11.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.
|
@@ -2,7 +2,7 @@ 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
4
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
5
|
-
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 { 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
6
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
7
7
|
/**
|
|
8
8
|
* Derives the validator function name from a type name.
|
|
@@ -38,12 +38,15 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
38
38
|
case 'string':
|
|
39
39
|
return `typeof ${accessor} !== 'string'`;
|
|
40
40
|
case 'number':
|
|
41
|
-
case 'integer':
|
|
42
41
|
return `typeof ${accessor} !== 'number'`;
|
|
42
|
+
case 'integer':
|
|
43
|
+
return `typeof ${accessor} !== 'number' || !Number.isInteger(${accessor})`;
|
|
43
44
|
case 'boolean':
|
|
44
45
|
return `typeof ${accessor} !== 'boolean'`;
|
|
45
46
|
case 'array':
|
|
46
47
|
return `!Array.isArray(${accessor})`;
|
|
48
|
+
case 'null':
|
|
49
|
+
return `${accessor} !== null`;
|
|
47
50
|
case 'object':
|
|
48
51
|
return `typeof ${accessor} !== 'object' || ${accessor} === null || Array.isArray(${accessor})`;
|
|
49
52
|
default:
|
|
@@ -101,6 +104,28 @@ const generateStrictKeyChecks = (schema, ctx) => {
|
|
|
101
104
|
` }`,
|
|
102
105
|
];
|
|
103
106
|
};
|
|
107
|
+
/**
|
|
108
|
+
* Emits presence checks for `required` keys that have no `properties` entry.
|
|
109
|
+
* Keys present in `properties` get their missing-property check from
|
|
110
|
+
* {@link generatePropertyChecks}; a required key with no schema of its own would
|
|
111
|
+
* otherwise go unchecked, so its presence is enforced here to match the
|
|
112
|
+
* interpreter and Ajv.
|
|
113
|
+
*/
|
|
114
|
+
const generateMissingRequiredChecks = (schema, ctx) => {
|
|
115
|
+
if (!isSchemaObject(schema) || !hasRequired(schema))
|
|
116
|
+
return [];
|
|
117
|
+
const props = hasProperties(schema) ? schema.properties : {};
|
|
118
|
+
const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const key of schema.required) {
|
|
121
|
+
if (Object.hasOwn(props, key))
|
|
122
|
+
continue;
|
|
123
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
124
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
125
|
+
lines.push(` }`);
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
};
|
|
104
129
|
/**
|
|
105
130
|
* Generates validation lines for a single property in an object schema.
|
|
106
131
|
* Handles $ref delegation, enum checks, type checks, string/number constraints,
|
|
@@ -225,92 +250,391 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
225
250
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
226
251
|
lines.push(` }`);
|
|
227
252
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
253
|
+
lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
|
|
254
|
+
}
|
|
255
|
+
// Keywords that can sit alongside or instead of `type`: combinators
|
|
256
|
+
// (`allOf`/`anyOf`/`oneOf`/`not`/`if`) for any schema, plus the constraint
|
|
257
|
+
// checks for a *type-less* schema (e.g. a bare `{ required: [...] }` or
|
|
258
|
+
// `{ minItems: 2 }` property). A typed schema already ran its constraints in
|
|
259
|
+
// the `hasType` branch above, so it only needs the combinators here.
|
|
260
|
+
const extraLines = hasType(propSchema)
|
|
261
|
+
? generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx)
|
|
262
|
+
: [
|
|
263
|
+
...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
|
|
264
|
+
...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
|
|
265
|
+
];
|
|
266
|
+
if (extraLines.length > 0) {
|
|
267
|
+
if (!hasType(propSchema) && isRequired) {
|
|
268
|
+
// No `type` to anchor a missing-property check, so enforce presence here.
|
|
269
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
270
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
271
|
+
lines.push(` } else {`);
|
|
272
|
+
lines.push(...extraLines);
|
|
273
|
+
lines.push(` }`);
|
|
247
274
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
253
|
-
const op = strict ? '<=' : '<';
|
|
254
|
-
const rel = strict ? '>' : '>=';
|
|
255
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
|
|
256
|
-
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
257
|
-
lines.push(` }`);
|
|
258
|
-
}
|
|
259
|
-
if (hasMaximum(propSchema)) {
|
|
260
|
-
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
261
|
-
const op = strict ? '>=' : '>';
|
|
262
|
-
const rel = strict ? '<' : '<=';
|
|
263
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
|
|
264
|
-
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
265
|
-
lines.push(` }`);
|
|
266
|
-
}
|
|
267
|
-
if (hasExclusiveMinimum(propSchema)) {
|
|
268
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`);
|
|
269
|
-
lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`);
|
|
270
|
-
lines.push(` }`);
|
|
271
|
-
}
|
|
272
|
-
if (hasExclusiveMaximum(propSchema)) {
|
|
273
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`);
|
|
274
|
-
lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`);
|
|
275
|
-
lines.push(` }`);
|
|
276
|
-
}
|
|
277
|
-
if (hasMultipleOf(propSchema)) {
|
|
278
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`);
|
|
279
|
-
lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`);
|
|
280
|
-
lines.push(` }`);
|
|
281
|
-
}
|
|
275
|
+
else {
|
|
276
|
+
lines.push(` if (${raw} !== undefined) {`);
|
|
277
|
+
lines.push(...extraLines);
|
|
278
|
+
lines.push(` }`);
|
|
282
279
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
280
|
+
}
|
|
281
|
+
return lines;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Emits the value-shape constraints for a typed value: string (pattern,
|
|
285
|
+
* min/maxLength), number/integer (bounds, multipleOf), typed/`$ref` array items,
|
|
286
|
+
* and recursion into an inline nested object. Shared by the named-property path
|
|
287
|
+
* ({@link generatePropertyChecks}) and the dynamic-key path
|
|
288
|
+
* ({@link generateValueChecks}) so both enforce identical rules. `raw` and `path`
|
|
289
|
+
* are arbitrary expressions, so the same logic serves a static `obj.key` and a
|
|
290
|
+
* `patternProperties` / `additionalProperties` value read at a runtime key.
|
|
291
|
+
*/
|
|
292
|
+
const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
293
|
+
if (!isSchemaObject(propSchema))
|
|
294
|
+
return [];
|
|
295
|
+
const sp = propSchema;
|
|
296
|
+
const lines = [];
|
|
297
|
+
// Each block is gated on the *presence of its keywords*, not a declared `type`,
|
|
298
|
+
// and every emitted check carries its own runtime-type guard (`typeof` /
|
|
299
|
+
// `Array.isArray`). So a type-less schema (e.g. an `allOf` / `anyOf` / `not`
|
|
300
|
+
// branch that is just `{ required: [...] }` or `{ minItems: 2 }`) is validated
|
|
301
|
+
// against the value's runtime type, matching the interpreter.
|
|
302
|
+
// String constraints
|
|
303
|
+
if (hasPattern(propSchema) || hasMinLength(propSchema) || hasMaxLength(propSchema)) {
|
|
304
|
+
if (hasPattern(propSchema)) {
|
|
305
|
+
const re = escapeRegexPattern(propSchema.pattern);
|
|
306
|
+
const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
|
|
307
|
+
lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
|
|
308
|
+
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
309
|
+
lines.push(` }`);
|
|
310
|
+
}
|
|
311
|
+
if (hasMinLength(propSchema)) {
|
|
312
|
+
lines.push(` if (typeof ${raw} === 'string' && ${raw}.length < ${propSchema.minLength}) {`);
|
|
313
|
+
lines.push(` errors.push({ message: 'must have at least ${propSchema.minLength} characters', path: ${path} })`);
|
|
314
|
+
lines.push(` }`);
|
|
315
|
+
}
|
|
316
|
+
if (hasMaxLength(propSchema)) {
|
|
317
|
+
lines.push(` if (typeof ${raw} === 'string' && ${raw}.length > ${propSchema.maxLength}) {`);
|
|
318
|
+
lines.push(` errors.push({ message: 'must have at most ${propSchema.maxLength} characters', path: ${path} })`);
|
|
319
|
+
lines.push(` }`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Number constraints
|
|
323
|
+
if (hasMinimum(propSchema) ||
|
|
324
|
+
hasMaximum(propSchema) ||
|
|
325
|
+
hasExclusiveMinimum(propSchema) ||
|
|
326
|
+
hasExclusiveMaximum(propSchema) ||
|
|
327
|
+
hasMultipleOf(propSchema)) {
|
|
328
|
+
if (hasMinimum(propSchema)) {
|
|
329
|
+
// Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
|
|
330
|
+
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
331
|
+
const op = strict ? '<=' : '<';
|
|
332
|
+
const rel = strict ? '>' : '>=';
|
|
333
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
|
|
334
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
335
|
+
lines.push(` }`);
|
|
336
|
+
}
|
|
337
|
+
if (hasMaximum(propSchema)) {
|
|
338
|
+
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
339
|
+
const op = strict ? '>=' : '>';
|
|
340
|
+
const rel = strict ? '<' : '<=';
|
|
341
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
|
|
342
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
343
|
+
lines.push(` }`);
|
|
344
|
+
}
|
|
345
|
+
if (hasExclusiveMinimum(propSchema)) {
|
|
346
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`);
|
|
347
|
+
lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`);
|
|
348
|
+
lines.push(` }`);
|
|
349
|
+
}
|
|
350
|
+
if (hasExclusiveMaximum(propSchema)) {
|
|
351
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`);
|
|
352
|
+
lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`);
|
|
353
|
+
lines.push(` }`);
|
|
354
|
+
}
|
|
355
|
+
if (hasMultipleOf(propSchema)) {
|
|
356
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`);
|
|
357
|
+
lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`);
|
|
358
|
+
lines.push(` }`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Array with typed items
|
|
362
|
+
if (hasItems(propSchema)) {
|
|
363
|
+
const itemSchema = propSchema.items;
|
|
364
|
+
if (hasRef(itemSchema)) {
|
|
365
|
+
const vName = validatorName(refToName(itemSchema.$ref, suffix));
|
|
366
|
+
lines.push(` if (Array.isArray(${raw})) {`);
|
|
367
|
+
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
368
|
+
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
|
|
369
|
+
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
|
|
370
|
+
lines.push(` }`);
|
|
371
|
+
lines.push(` }`);
|
|
372
|
+
}
|
|
373
|
+
else if (hasType(itemSchema)) {
|
|
374
|
+
const itemType = itemSchema.type;
|
|
375
|
+
const itemWrong = wrongTypeCondition('_item', itemType);
|
|
376
|
+
const itemLabel = typeofString(itemType);
|
|
377
|
+
if (itemWrong) {
|
|
288
378
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
289
379
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
290
|
-
lines.push(` const
|
|
291
|
-
lines.push(` if (
|
|
380
|
+
lines.push(` const _item = ${raw}[_i]`);
|
|
381
|
+
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
|
|
292
382
|
lines.push(` }`);
|
|
293
383
|
lines.push(` }`);
|
|
294
384
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Array length / uniqueness. `uniqueItems` dedupes by a JSON projection — exact
|
|
388
|
+
// for primitives (what the type guard also uses); deep-but-key-ordered for
|
|
389
|
+
// objects, the same pragmatic trade-off the rest of the generator makes.
|
|
390
|
+
if (hasMinItems(propSchema) ||
|
|
391
|
+
hasMaxItems(propSchema) ||
|
|
392
|
+
(hasUniqueItems(propSchema) && propSchema.uniqueItems === true) ||
|
|
393
|
+
isSchemaObject(sp['contains']) ||
|
|
394
|
+
Array.isArray(sp['prefixItems'])) {
|
|
395
|
+
if (hasMinItems(propSchema)) {
|
|
396
|
+
lines.push(` if (Array.isArray(${raw}) && ${raw}.length < ${propSchema.minItems}) {`);
|
|
397
|
+
lines.push(` errors.push({ message: 'must have at least ${propSchema.minItems} items', path: ${path} })`);
|
|
398
|
+
lines.push(` }`);
|
|
399
|
+
}
|
|
400
|
+
if (hasMaxItems(propSchema)) {
|
|
401
|
+
lines.push(` if (Array.isArray(${raw}) && ${raw}.length > ${propSchema.maxItems}) {`);
|
|
402
|
+
lines.push(` errors.push({ message: 'must have at most ${propSchema.maxItems} items', path: ${path} })`);
|
|
403
|
+
lines.push(` }`);
|
|
404
|
+
}
|
|
405
|
+
if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
|
|
406
|
+
lines.push(` if (Array.isArray(${raw}) && new Set((${raw} as unknown[]).map((_u) => JSON.stringify(_u))).size !== ${raw}.length) {`);
|
|
407
|
+
lines.push(` errors.push({ message: 'must NOT have duplicate items', path: ${path} })`);
|
|
408
|
+
lines.push(` }`);
|
|
409
|
+
}
|
|
410
|
+
// `contains` — at least `minContains` (default 1) and at most `maxContains`
|
|
411
|
+
// items must match the subschema. `minContains: 0` makes any array (even
|
|
412
|
+
// empty) satisfy the lower bound.
|
|
413
|
+
if (isSchemaObject(sp['contains'])) {
|
|
414
|
+
const min = typeof sp['minContains'] === 'number' ? sp['minContains'] : 1;
|
|
415
|
+
const max = typeof sp['maxContains'] === 'number' ? sp['maxContains'] : undefined;
|
|
416
|
+
const matchExpr = generateMatchesExpr('_c', sp['contains'], suffix, ctx);
|
|
417
|
+
const bound = max !== undefined ? `_cn < ${min} || _cn > ${max}` : `_cn < ${min}`;
|
|
418
|
+
lines.push(` if (Array.isArray(${raw})) {`);
|
|
419
|
+
lines.push(` const _cn = (${raw} as unknown[]).filter((_c) => ${matchExpr}).length`);
|
|
420
|
+
lines.push(` if (${bound}) {`);
|
|
421
|
+
lines.push(` errors.push({ message: 'array does not contain the required matching items', path: ${path} })`);
|
|
422
|
+
lines.push(` }`);
|
|
423
|
+
lines.push(` }`);
|
|
424
|
+
}
|
|
425
|
+
// Tuple `prefixItems` — each position validated against its own subschema; a
|
|
426
|
+
// sibling `items: false` (or draft `additionalItems: false`) caps the length.
|
|
427
|
+
const prefix = sp['prefixItems'];
|
|
428
|
+
if (Array.isArray(prefix)) {
|
|
429
|
+
lines.push(` if (Array.isArray(${raw})) {`);
|
|
430
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
431
|
+
const itemChecks = generateValueChecks('', `${raw}[${i}]`, `\`${path.slice(1, -1)}/${i}\``, prefix[i], suffix, ctx);
|
|
432
|
+
if (itemChecks.length > 0) {
|
|
433
|
+
lines.push(` if (${raw}.length > ${i}) {`);
|
|
434
|
+
lines.push(...itemChecks.map((l) => ` ${l}`));
|
|
304
435
|
lines.push(` }`);
|
|
305
|
-
lines.push(` }`);
|
|
306
436
|
}
|
|
307
437
|
}
|
|
438
|
+
if (sp['items'] === false || sp['additionalItems'] === false) {
|
|
439
|
+
lines.push(` if (${raw}.length > ${prefix.length}) {`);
|
|
440
|
+
lines.push(` errors.push({ message: 'must NOT have more than ${prefix.length} items', path: ${path} })`);
|
|
441
|
+
lines.push(` }`);
|
|
442
|
+
}
|
|
443
|
+
lines.push(` }`);
|
|
308
444
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
445
|
+
}
|
|
446
|
+
// Inline nested object — recurse so the nested fields are actually validated.
|
|
447
|
+
// Unconditional: `generateInlineObjectChecks` self-gates (returns `[]` when the
|
|
448
|
+
// schema has no object keywords) and each check is guarded by an `isObject`
|
|
449
|
+
// runtime check, so this is a no-op for non-object schemas.
|
|
450
|
+
lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
|
|
451
|
+
return lines;
|
|
452
|
+
};
|
|
453
|
+
/**
|
|
454
|
+
* Validates a value located at a *dynamic* key (a `patternProperties` or
|
|
455
|
+
* `additionalProperties` schema value) against `propSchema`. Mirrors the
|
|
456
|
+
* optional-property branch of {@link generatePropertyChecks} — the value is
|
|
457
|
+
* always present, so each check is the same `!== undefined`-guarded form — but
|
|
458
|
+
* `raw` and `path` are caller-supplied expressions (e.g. `obj[_k]` and
|
|
459
|
+
* `` `${_path}/${_k}` ``) so the checks read a runtime key.
|
|
460
|
+
*/
|
|
461
|
+
const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
462
|
+
if (!isSchemaObject(propSchema))
|
|
463
|
+
return [];
|
|
464
|
+
const lines = [];
|
|
465
|
+
if (hasRef(propSchema)) {
|
|
466
|
+
const vName = validatorName(refToName(propSchema.$ref, suffix));
|
|
467
|
+
lines.push(` if (${raw} !== undefined) {`);
|
|
468
|
+
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
469
|
+
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
470
|
+
lines.push(` }`);
|
|
471
|
+
return lines;
|
|
472
|
+
}
|
|
473
|
+
const instanceOf = getMjstInstanceOf(propSchema);
|
|
474
|
+
if (instanceOf) {
|
|
475
|
+
lines.push(` if (${raw} !== undefined && !(${raw} instanceof ${instanceOf})) {`);
|
|
476
|
+
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
477
|
+
lines.push(` }`);
|
|
478
|
+
return lines;
|
|
479
|
+
}
|
|
480
|
+
const primitive = getMjstPrimitive(propSchema);
|
|
481
|
+
if (primitive) {
|
|
482
|
+
lines.push(` if (${raw} !== undefined && typeof ${raw} !== "${primitive}") {`);
|
|
483
|
+
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
484
|
+
lines.push(` }`);
|
|
485
|
+
return lines;
|
|
486
|
+
}
|
|
487
|
+
if (hasConst(propSchema)) {
|
|
488
|
+
const mismatch = constMismatchCondition(raw, propSchema.const);
|
|
489
|
+
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
490
|
+
lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
|
|
491
|
+
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
492
|
+
lines.push(` }`);
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
if (hasEnum(propSchema)) {
|
|
496
|
+
const allowed = JSON.stringify(propSchema.enum);
|
|
497
|
+
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
498
|
+
lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`);
|
|
499
|
+
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
|
|
500
|
+
lines.push(` }`);
|
|
501
|
+
return lines;
|
|
502
|
+
}
|
|
503
|
+
if (hasType(propSchema)) {
|
|
504
|
+
const t = propSchema.type;
|
|
505
|
+
const wrongType = wrongTypeCondition(raw, t);
|
|
506
|
+
const typLabel = typeofString(t);
|
|
507
|
+
if (wrongType) {
|
|
508
|
+
lines.push(` if (${raw} !== undefined && (${wrongType})) {`);
|
|
509
|
+
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
510
|
+
lines.push(` }`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Constraint and combinator checks run regardless of a declared `type`: they
|
|
514
|
+
// gate on keyword presence + a runtime-type guard, so a type-less subschema
|
|
515
|
+
// (a combinator branch like `{ required: [...] }` or `{ minItems: 2 }`) is
|
|
516
|
+
// still validated rather than collapsing to "matches everything".
|
|
517
|
+
lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
|
|
518
|
+
lines.push(...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx));
|
|
519
|
+
return lines;
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* A boolean expression that is `true` when `raw` matches `sub`. Reuses the value
|
|
523
|
+
* checks but collects their errors into a throwaway local buffer, so the same
|
|
524
|
+
* logic that produces error messages also answers the yes/no question the
|
|
525
|
+
* combinators (`anyOf`/`oneOf`/`not`/`if`) and `contains` need.
|
|
526
|
+
*/
|
|
527
|
+
const generateMatchesExpr = (raw, sub, suffix, ctx) => {
|
|
528
|
+
if (sub === true)
|
|
529
|
+
return 'true';
|
|
530
|
+
if (sub === false)
|
|
531
|
+
return 'false';
|
|
532
|
+
if (!isSchemaObject(sub))
|
|
533
|
+
return 'true';
|
|
534
|
+
const checks = generateValueChecks('', raw, '`${_path}`', sub, suffix, ctx);
|
|
535
|
+
if (checks.length === 0)
|
|
536
|
+
return 'true';
|
|
537
|
+
// The checks push to `errors`; redirect them to the IIFE-local `_m`. The outer
|
|
538
|
+
// validator's `errors.push` → `(errors ??= [])` rewrite never sees these (they
|
|
539
|
+
// are already `_m.push`), and nested match IIFEs each shadow their own `_m`.
|
|
540
|
+
const body = checks.join('\n').replaceAll('errors.push(', '_m.push(');
|
|
541
|
+
return `((): boolean => { const _m: ValidationError[] = []\n${body}\n return _m.length === 0 })()`;
|
|
542
|
+
};
|
|
543
|
+
/**
|
|
544
|
+
* Emits the combinator keywords (`allOf`, `anyOf`, `oneOf`, `not`,
|
|
545
|
+
* `if`/`then`/`else`). `allOf` surfaces each branch's errors directly; the others
|
|
546
|
+
* evaluate branch membership as a boolean via {@link generateMatchesExpr}.
|
|
547
|
+
*/
|
|
548
|
+
const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
|
|
549
|
+
if (!isSchemaObject(schema))
|
|
550
|
+
return [];
|
|
551
|
+
const lines = [];
|
|
552
|
+
if (hasAllOf(schema)) {
|
|
553
|
+
for (const branch of schema.allOf)
|
|
554
|
+
lines.push(...generateValueChecks(key, raw, path, branch, suffix, ctx));
|
|
555
|
+
}
|
|
556
|
+
if (hasAnyOf(schema) && schema.anyOf.length > 0) {
|
|
557
|
+
const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx));
|
|
558
|
+
lines.push(` if (!(${conds.join(' || ')})) {`);
|
|
559
|
+
lines.push(` errors.push({ message: 'must match a schema in anyOf', path: ${path} })`);
|
|
560
|
+
lines.push(` }`);
|
|
561
|
+
}
|
|
562
|
+
if (hasOneOf(schema) && schema.oneOf.length > 0) {
|
|
563
|
+
const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx)} ? 1 : 0)`);
|
|
564
|
+
lines.push(` if ((${conds.join(' + ')}) !== 1) {`);
|
|
565
|
+
lines.push(` errors.push({ message: 'must match exactly one schema in oneOf', path: ${path} })`);
|
|
566
|
+
lines.push(` }`);
|
|
567
|
+
}
|
|
568
|
+
const not = schema['not'];
|
|
569
|
+
if (not !== undefined && (isSchemaObject(not) || typeof not === 'boolean')) {
|
|
570
|
+
const cond = generateMatchesExpr(raw, not, suffix, ctx);
|
|
571
|
+
lines.push(` if (${cond}) {`);
|
|
572
|
+
lines.push(` errors.push({ message: 'must NOT match the schema in not', path: ${path} })`);
|
|
573
|
+
lines.push(` }`);
|
|
574
|
+
}
|
|
575
|
+
const ifSchema = schema['if'];
|
|
576
|
+
if (ifSchema !== undefined && (isSchemaObject(ifSchema) || typeof ifSchema === 'boolean')) {
|
|
577
|
+
const thenSchema = schema['then'];
|
|
578
|
+
const elseSchema = schema['else'];
|
|
579
|
+
const thenLines = thenSchema !== undefined ? generateValueChecks(key, raw, path, thenSchema, suffix, ctx) : [];
|
|
580
|
+
const elseLines = elseSchema !== undefined ? generateValueChecks(key, raw, path, elseSchema, suffix, ctx) : [];
|
|
581
|
+
if (thenLines.length > 0 || elseLines.length > 0) {
|
|
582
|
+
lines.push(` if (${generateMatchesExpr(raw, ifSchema, suffix, ctx)}) {`);
|
|
583
|
+
lines.push(...thenLines);
|
|
584
|
+
lines.push(` } else {`);
|
|
585
|
+
lines.push(...elseLines);
|
|
586
|
+
lines.push(` }`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return lines;
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Emits validation for `patternProperties` and a schema-form
|
|
593
|
+
* `additionalProperties` (the `false` form is handled by
|
|
594
|
+
* {@link generateStrictKeyChecks}). For each object key, every matching
|
|
595
|
+
* `patternProperties` subschema runs against the value; keys reached by neither
|
|
596
|
+
* `properties` nor any pattern fall through to `additionalProperties`. This
|
|
597
|
+
* mirrors the runtime interpreter, which validates these values rather than only
|
|
598
|
+
* gating extra keys.
|
|
599
|
+
*/
|
|
600
|
+
const generatePatternAndAdditionalChecks = (schema, suffix, ctx) => {
|
|
601
|
+
if (!isSchemaObject(schema))
|
|
602
|
+
return [];
|
|
603
|
+
const obj = ctx.objVar;
|
|
604
|
+
const d = ctx.depth;
|
|
605
|
+
const lines = [];
|
|
606
|
+
const patternsRecord = 'patternProperties' in schema && typeof schema.patternProperties === 'object' && schema.patternProperties !== null
|
|
607
|
+
? schema.patternProperties
|
|
608
|
+
: {};
|
|
609
|
+
const patternEntries = Object.entries(patternsRecord);
|
|
610
|
+
for (const [pattern, sub] of patternEntries) {
|
|
611
|
+
const re = escapeRegexPattern(pattern);
|
|
612
|
+
const kv = `_pk${d}`;
|
|
613
|
+
const valueChecks = generateValueChecks(`\${${kv}}`, `${obj}[${kv}]`, `\`${ctx.pathPrefix}/\${${kv}}\``, sub, suffix, ctx);
|
|
614
|
+
if (valueChecks.length === 0)
|
|
615
|
+
continue;
|
|
616
|
+
lines.push(` for (const ${kv} in ${obj}) {`);
|
|
617
|
+
lines.push(` if (/${re}/.test(${kv})) {`);
|
|
618
|
+
lines.push(...valueChecks.map((line) => ` ${line}`));
|
|
619
|
+
lines.push(` }`);
|
|
620
|
+
lines.push(` }`);
|
|
621
|
+
}
|
|
622
|
+
// Schema-form `additionalProperties` validates every key reached by neither a
|
|
623
|
+
// declared property nor any `patternProperties` regex.
|
|
624
|
+
if (hasAdditionalProperties(schema) && isSchemaObject(schema.additionalProperties)) {
|
|
625
|
+
const additional = schema.additionalProperties;
|
|
626
|
+
const kv = `_ak${d}`;
|
|
627
|
+
const valueChecks = generateValueChecks(`\${${kv}}`, `${obj}[${kv}]`, `\`${ctx.pathPrefix}/\${${kv}}\``, additional, suffix, ctx);
|
|
628
|
+
if (valueChecks.length > 0) {
|
|
629
|
+
const known = Object.keys(hasProperties(schema) ? schema.properties : {});
|
|
630
|
+
lines.push(` for (const ${kv} in ${obj}) {`);
|
|
631
|
+
if (known.length > 0)
|
|
632
|
+
lines.push(` if (${JSON.stringify(known)}.includes(${kv})) continue`);
|
|
633
|
+
for (const pattern of Object.keys(patternsRecord)) {
|
|
634
|
+
lines.push(` if (/${escapeRegexPattern(pattern)}/.test(${kv})) continue`);
|
|
635
|
+
}
|
|
636
|
+
lines.push(...valueChecks.map((line) => ` ${line}`));
|
|
637
|
+
lines.push(` }`);
|
|
314
638
|
}
|
|
315
639
|
}
|
|
316
640
|
return lines;
|
|
@@ -337,7 +661,13 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
|
337
661
|
for (const [childKey, childSchema] of Object.entries(properties)) {
|
|
338
662
|
innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
|
|
339
663
|
}
|
|
664
|
+
innerLines.push(...generateMissingRequiredChecks(propSchema, child));
|
|
665
|
+
innerLines.push(...generatePatternAndAdditionalChecks(propSchema, suffix, child));
|
|
340
666
|
innerLines.push(...generateStrictKeyChecks(propSchema, child));
|
|
667
|
+
innerLines.push(...generateDependentRequiredChecks(propSchema, child));
|
|
668
|
+
if (hasPropertyNames(propSchema) && isSchemaObject(propSchema.propertyNames)) {
|
|
669
|
+
innerLines.push(...generatePropertyNameChecks(propSchema.propertyNames, suffix, child));
|
|
670
|
+
}
|
|
341
671
|
if (innerLines.length === 0)
|
|
342
672
|
return [];
|
|
343
673
|
// The shape check for the property itself already ran (or the property is
|
|
@@ -355,10 +685,10 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
|
355
685
|
* validator). This keeps the generator in step with the interpreter, which runs
|
|
356
686
|
* the whole subschema against each key — not just the `pattern` form.
|
|
357
687
|
*/
|
|
358
|
-
const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
688
|
+
const generatePropertyNameChecks = (nameSchema, suffix, ctx) => {
|
|
359
689
|
if (!isSchemaObject(nameSchema))
|
|
360
690
|
return [];
|
|
361
|
-
const at =
|
|
691
|
+
const at = `\`${ctx.pathPrefix}/\${_name}\``;
|
|
362
692
|
const checks = [];
|
|
363
693
|
if (hasRef(nameSchema)) {
|
|
364
694
|
const vName = validatorName(refToName(nameSchema.$ref, suffix));
|
|
@@ -392,7 +722,30 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
|
392
722
|
}
|
|
393
723
|
if (checks.length === 0)
|
|
394
724
|
return [];
|
|
395
|
-
return [` for (const _name of Object.keys(
|
|
725
|
+
return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks, ` }`];
|
|
726
|
+
};
|
|
727
|
+
/**
|
|
728
|
+
* Emits `dependentRequired` checks: when a trigger key is present, each of its
|
|
729
|
+
* declared dependencies must be present too. Reads the object and reports at the
|
|
730
|
+
* current node via `ctx`, so it works at the root and inside nested objects.
|
|
731
|
+
*/
|
|
732
|
+
const generateDependentRequiredChecks = (schema, ctx) => {
|
|
733
|
+
if (!isSchemaObject(schema) || !hasDependentRequired(schema))
|
|
734
|
+
return [];
|
|
735
|
+
const obj = ctx.objVar;
|
|
736
|
+
const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
737
|
+
const lines = [];
|
|
738
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
739
|
+
if (!Array.isArray(deps))
|
|
740
|
+
continue;
|
|
741
|
+
for (const dep of deps) {
|
|
742
|
+
const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
|
|
743
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(dep)} in ${obj})) {`);
|
|
744
|
+
lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
|
|
745
|
+
lines.push(` }`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return lines;
|
|
396
749
|
};
|
|
397
750
|
/**
|
|
398
751
|
* Builds the `&&` conditions that prove a single property is valid, or `null`
|
|
@@ -414,6 +767,10 @@ const guardPropConditions = (key, propSchema, objAcc) => {
|
|
|
414
767
|
hasEnum(propSchema) ||
|
|
415
768
|
hasConst(propSchema) ||
|
|
416
769
|
hasOneOf(propSchema) ||
|
|
770
|
+
hasAnyOf(propSchema) ||
|
|
771
|
+
hasAllOf(propSchema) ||
|
|
772
|
+
'not' in propSchema ||
|
|
773
|
+
'if' in propSchema ||
|
|
417
774
|
getMjstInstanceOf(propSchema) !== undefined ||
|
|
418
775
|
getMjstPrimitive(propSchema) !== undefined ||
|
|
419
776
|
hasPattern(propSchema) ||
|
|
@@ -432,13 +789,14 @@ const guardPropConditions = (key, propSchema, objAcc) => {
|
|
|
432
789
|
switch (propSchema.type) {
|
|
433
790
|
case 'string':
|
|
434
791
|
return [`typeof ${raw} === 'string'`];
|
|
435
|
-
// mjst treats `integer` like `number` (it never enforces integrality), so a
|
|
436
|
-
// `typeof === 'number'` guard matches the slow path's verdict exactly.
|
|
437
792
|
case 'number':
|
|
438
|
-
case 'integer':
|
|
439
793
|
return [`typeof ${raw} === 'number'`];
|
|
794
|
+
case 'integer':
|
|
795
|
+
return [`typeof ${raw} === 'number'`, `Number.isInteger(${raw})`];
|
|
440
796
|
case 'boolean':
|
|
441
797
|
return [`typeof ${raw} === 'boolean'`];
|
|
798
|
+
case 'null':
|
|
799
|
+
return [`${raw} === null`];
|
|
442
800
|
case 'object':
|
|
443
801
|
// Member access into the nested record is only reached after the shape
|
|
444
802
|
// check ahead of it in the `&&` chain, so the cast is always safe.
|
|
@@ -509,6 +867,12 @@ const guardObjectConditions = (schema, raw, objAcc) => {
|
|
|
509
867
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
510
868
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
511
869
|
const keys = Object.keys(properties);
|
|
870
|
+
// A required key with no `properties` entry has no cheap guard condition, so
|
|
871
|
+
// defer to the slow path (which checks its presence).
|
|
872
|
+
for (const key of required) {
|
|
873
|
+
if (!Object.hasOwn(properties, key))
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
512
876
|
// The object shape-check only needs `!Array.isArray` when no required field
|
|
513
877
|
// check would already reject an array (see `arrayRejectedByRequiredProp`).
|
|
514
878
|
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
|
|
@@ -549,38 +913,29 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
549
913
|
propertyLines.push(...checks);
|
|
550
914
|
}
|
|
551
915
|
}
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
propertyLines.push(` for (const _key of Object.keys(obj)) {`);
|
|
558
|
-
propertyLines.push(` if (${JSON.stringify(Object.keys(properties))}.includes(_key)) continue`);
|
|
559
|
-
propertyLines.push(` const _r = ${vRefName}(obj[_key as keyof typeof obj], \`\${_path}/\${_key}\`)`);
|
|
560
|
-
propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
561
|
-
propertyLines.push(` }`);
|
|
562
|
-
}
|
|
916
|
+
// Required keys with no `properties` entry still need a presence check.
|
|
917
|
+
propertyLines.push(...generateMissingRequiredChecks(schema, ctx));
|
|
918
|
+
// patternProperties values and a schema-form additionalProperties are
|
|
919
|
+
// validated here (the `false` form is handled by generateStrictKeyChecks).
|
|
920
|
+
propertyLines.push(...generatePatternAndAdditionalChecks(schema, suffix, ctx));
|
|
563
921
|
// additionalProperties: false rejects every key not declared in properties
|
|
564
922
|
propertyLines.push(...generateStrictKeyChecks(schema, ctx));
|
|
565
923
|
// dependentRequired — when a trigger property is present, its dependencies must be too.
|
|
566
|
-
|
|
567
|
-
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
568
|
-
if (!Array.isArray(deps))
|
|
569
|
-
continue;
|
|
570
|
-
for (const dep of deps) {
|
|
571
|
-
const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
|
|
572
|
-
propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`);
|
|
573
|
-
propertyLines.push(` errors.push({ message: ${msg}, path: _path })`);
|
|
574
|
-
propertyLines.push(` }`);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
924
|
+
propertyLines.push(...generateDependentRequiredChecks(schema, ctx));
|
|
578
925
|
// propertyNames — every key (always a string) must satisfy the subschema. This
|
|
579
926
|
// mirrors the interpreter, which runs the full subschema against each key.
|
|
580
927
|
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
581
|
-
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
|
|
928
|
+
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix, ctx));
|
|
582
929
|
}
|
|
583
|
-
|
|
930
|
+
// Combinators declared alongside the object's properties (e.g. an object with
|
|
931
|
+
// `allOf` refining it further) are validated against the object value itself.
|
|
932
|
+
propertyLines.push(...generateCombinatorChecks('', 'obj', '`${_path}`', schema, suffix, ctx));
|
|
933
|
+
// Lazily allocate the errors array so a valid input never builds one — the same
|
|
934
|
+
// allocation-free happy path the runtime interpreter uses. Each emitted
|
|
935
|
+
// `errors.push(...)` becomes a create-on-first-use push; nothing is allocated
|
|
936
|
+
// until the first actual error, so the common valid case stays alloc-free even
|
|
937
|
+
// when the schema is too rich for the boolean guard.
|
|
938
|
+
const body = (propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '').replaceAll('errors.push(', '(errors ??= []).push(');
|
|
584
939
|
// Hoisted statements (e.g. known-keys Sets) come first so every call of the
|
|
585
940
|
// validator reuses them instead of rebuilding them.
|
|
586
941
|
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
@@ -602,9 +957,9 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
602
957
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
603
958
|
` }`,
|
|
604
959
|
``,
|
|
605
|
-
`
|
|
960
|
+
` let errors: ValidationError[] | undefined`,
|
|
606
961
|
body,
|
|
607
|
-
` return errors
|
|
962
|
+
` return errors !== undefined ? { valid: false, errors } : true`,
|
|
608
963
|
`}`,
|
|
609
964
|
].join('\n');
|
|
610
965
|
// No guard: the exported validator is the error-collecting function itself.
|
|
@@ -649,18 +1004,38 @@ const rightTypeCondition = (accessor, type) => {
|
|
|
649
1004
|
case 'string':
|
|
650
1005
|
return `typeof ${accessor} === 'string'`;
|
|
651
1006
|
case 'number':
|
|
652
|
-
case 'integer':
|
|
653
1007
|
return `typeof ${accessor} === 'number'`;
|
|
1008
|
+
case 'integer':
|
|
1009
|
+
return `typeof ${accessor} === 'number' && Number.isInteger(${accessor})`;
|
|
654
1010
|
case 'boolean':
|
|
655
1011
|
return `typeof ${accessor} === 'boolean'`;
|
|
656
1012
|
case 'array':
|
|
657
1013
|
return `Array.isArray(${accessor})`;
|
|
1014
|
+
case 'null':
|
|
1015
|
+
return `${accessor} === null`;
|
|
658
1016
|
case 'object':
|
|
659
1017
|
return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
|
|
660
1018
|
default:
|
|
661
1019
|
return null;
|
|
662
1020
|
}
|
|
663
1021
|
};
|
|
1022
|
+
/**
|
|
1023
|
+
* Builds the membership test for an `enum`, matching the slow path's
|
|
1024
|
+
* `[...].includes(value)` verdict exactly. For the common all-primitive case it
|
|
1025
|
+
* emits a parenthesized `a === x || a === y` chain — no per-call array
|
|
1026
|
+
* allocation and no linear scan, so it stays on the allocation-free hot path —
|
|
1027
|
+
* and falls back to `.includes` when a member is an object/array (reference
|
|
1028
|
+
* equality) or `NaN` (where `includes`'s SameValueZero differs from `===`).
|
|
1029
|
+
*/
|
|
1030
|
+
const enumMembershipExpr = (values, acc) => {
|
|
1031
|
+
const allPrimitive = values.length > 0 &&
|
|
1032
|
+
values.every((v) => (v === null || typeof v !== 'object') && typeof v !== 'function') &&
|
|
1033
|
+
!values.some((v) => typeof v === 'number' && Number.isNaN(v));
|
|
1034
|
+
if (allPrimitive) {
|
|
1035
|
+
return `(${values.map((v) => `${acc} === ${JSON.stringify(v)}`).join(' || ')})`;
|
|
1036
|
+
}
|
|
1037
|
+
return `(${JSON.stringify(values)} as unknown[]).includes(${acc})`;
|
|
1038
|
+
};
|
|
664
1039
|
/**
|
|
665
1040
|
* Builds a boolean expression that is TRUE iff `acc` satisfies `schema`, with the
|
|
666
1041
|
* *exact same verdict* as the error-collecting validator — or `null` when the
|
|
@@ -680,13 +1055,16 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
680
1055
|
'anyOf' in schema ||
|
|
681
1056
|
'allOf' in schema ||
|
|
682
1057
|
'not' in schema ||
|
|
1058
|
+
'if' in schema ||
|
|
1059
|
+
'contains' in schema ||
|
|
1060
|
+
'prefixItems' in schema ||
|
|
683
1061
|
getMjstInstanceOf(schema) !== undefined ||
|
|
684
1062
|
getMjstPrimitive(schema) !== undefined) {
|
|
685
1063
|
return null;
|
|
686
1064
|
}
|
|
687
1065
|
// enum — same membership test the validator uses.
|
|
688
1066
|
if (hasEnum(schema)) {
|
|
689
|
-
return
|
|
1067
|
+
return enumMembershipExpr(schema.enum, acc);
|
|
690
1068
|
}
|
|
691
1069
|
if (!hasType(schema))
|
|
692
1070
|
return null;
|
|
@@ -709,6 +1087,8 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
709
1087
|
case 'number':
|
|
710
1088
|
case 'integer': {
|
|
711
1089
|
const parts = [`typeof ${acc} === 'number'`];
|
|
1090
|
+
if (t === 'integer')
|
|
1091
|
+
parts.push(`Number.isInteger(${acc})`);
|
|
712
1092
|
if (hasMinimum(schema))
|
|
713
1093
|
parts.push(`!(${acc} ${hasStrictExclusiveMinimum(schema) ? '<=' : '<'} ${schema.minimum})`);
|
|
714
1094
|
if (hasMaximum(schema))
|
|
@@ -723,6 +1103,8 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
723
1103
|
}
|
|
724
1104
|
case 'boolean':
|
|
725
1105
|
return `typeof ${acc} === 'boolean'`;
|
|
1106
|
+
case 'null':
|
|
1107
|
+
return `${acc} === null`;
|
|
726
1108
|
case 'object': {
|
|
727
1109
|
const parts = booleanObjectParts(schema, acc, `(${acc} as Record<string, unknown>)`);
|
|
728
1110
|
return parts === null ? null : parts.join(' && ');
|
|
@@ -738,9 +1120,25 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
738
1120
|
* array shape and — for typed items — only each item's *type* (objects are shape-
|
|
739
1121
|
* checked, not recursed into); it never enforces `minItems`/`maxItems` or item
|
|
740
1122
|
* constraints. Returns `null` for `$ref` items (those defer to the validator).
|
|
1123
|
+
*
|
|
1124
|
+
* Item iteration goes through `Array.from` rather than `Array.prototype.every`
|
|
1125
|
+
* because `every` *skips holes* in a sparse array (`[, 'x']`), whereas the
|
|
1126
|
+
* validator's index-based `for` loop reads a hole as `undefined` and rejects it.
|
|
1127
|
+
* Materialising the array first makes the guard's verdict match the slow path's
|
|
1128
|
+
* on sparse input — the guard must never accept what the slow path would reject.
|
|
741
1129
|
*/
|
|
742
1130
|
const booleanArrayExpr = (schema, acc) => {
|
|
743
|
-
const
|
|
1131
|
+
const parts = [`Array.isArray(${acc})`];
|
|
1132
|
+
// Length / uniqueness, mirroring the validator's checks exactly so the guard's
|
|
1133
|
+
// verdict matches the slow path's.
|
|
1134
|
+
if (hasMinItems(schema))
|
|
1135
|
+
parts.push(`${acc}.length >= ${schema.minItems}`);
|
|
1136
|
+
if (hasMaxItems(schema))
|
|
1137
|
+
parts.push(`${acc}.length <= ${schema.maxItems}`);
|
|
1138
|
+
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
1139
|
+
parts.push(`new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`);
|
|
1140
|
+
}
|
|
1141
|
+
const base = parts.join(' && ');
|
|
744
1142
|
if (!hasItems(schema))
|
|
745
1143
|
return base;
|
|
746
1144
|
const items = schema.items;
|
|
@@ -753,7 +1151,7 @@ const booleanArrayExpr = (schema, acc) => {
|
|
|
753
1151
|
const itemCheck = rightTypeCondition('_it', items.type);
|
|
754
1152
|
if (itemCheck === null)
|
|
755
1153
|
return base;
|
|
756
|
-
return `${base} && (${acc} as unknown[]).every((_it) => ${itemCheck})`;
|
|
1154
|
+
return `${base} && Array.from(${acc} as unknown[]).every((_it) => ${itemCheck})`;
|
|
757
1155
|
};
|
|
758
1156
|
/**
|
|
759
1157
|
* Builds the `&&` conditions proving an object value is valid (same verdict as
|
|
@@ -914,20 +1312,18 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
914
1312
|
].join('\n');
|
|
915
1313
|
}
|
|
916
1314
|
// oneOf — try each branch, return errors from all if none match
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
})
|
|
925
|
-
.filter(Boolean)
|
|
926
|
-
.join('\n');
|
|
1315
|
+
// Top-level combinators (`allOf` / `anyOf` / `oneOf` / `not` / `if`), validated
|
|
1316
|
+
// against the input via the shared combinator generator — correct `oneOf`
|
|
1317
|
+
// (exactly one) and inline branches included, not just `$ref` branches.
|
|
1318
|
+
if (hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema) {
|
|
1319
|
+
const ctx = createRootContext();
|
|
1320
|
+
const checks = generateCombinatorChecks('', 'input', '`${_path}`', schema, suffix, ctx);
|
|
1321
|
+
const body = checks.join('\n').replaceAll('errors.push(', '(errors ??= []).push(');
|
|
927
1322
|
return [
|
|
928
1323
|
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
929
|
-
|
|
930
|
-
|
|
1324
|
+
` let errors: ValidationError[] | undefined`,
|
|
1325
|
+
body,
|
|
1326
|
+
` return errors !== undefined ? { valid: false, errors } : true`,
|
|
931
1327
|
`}`,
|
|
932
1328
|
].join('\n');
|
|
933
1329
|
}
|
|
@@ -978,9 +1374,9 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
978
1374
|
` if (${wrongType}) {`,
|
|
979
1375
|
` return { valid: false, errors: [{ message: 'must be ${typLabel}', path: _path }] }`,
|
|
980
1376
|
` }`,
|
|
981
|
-
`
|
|
982
|
-
constraintLines.join('\n'),
|
|
983
|
-
` return errors
|
|
1377
|
+
` let errors: ValidationError[] | undefined`,
|
|
1378
|
+
constraintLines.join('\n').replaceAll('errors.push(', '(errors ??= []).push('),
|
|
1379
|
+
` return errors !== undefined ? { valid: false, errors } : true`,
|
|
984
1380
|
`}`,
|
|
985
1381
|
].join('\n');
|
|
986
1382
|
}
|