@amritk/generate-parsers 0.16.0 → 0.16.2
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 +45 -0
- package/dist/generators/assert-supported-keywords.d.ts +26 -0
- package/dist/generators/assert-supported-keywords.js +74 -0
- package/dist/generators/generate-enum-check.js +16 -7
- package/dist/generators/generate-parser-function.js +189 -54
- package/dist/generators/generate-schema-checks.js +161 -1
- package/dist/generators/generate-strict-assertion.d.ts +22 -1
- package/dist/generators/generate-strict-assertion.js +247 -25
- package/dist/generators/generate-type-checks.d.ts +9 -0
- package/dist/generators/generate-type-checks.js +49 -1
- package/dist/generators/generate-validation-expression.d.ts +23 -0
- package/dist/generators/generate-validation-expression.js +81 -5
- package/dist/generators/subschema-match.d.ts +29 -0
- package/dist/generators/subschema-match.js +286 -0
- package/dist/helpers/get-default-value.js +8 -0
- package/package.json +2 -2
|
@@ -13,9 +13,32 @@ export declare const scalarItemTypeCheck: (itemSchema: JSONSchema, accessor: str
|
|
|
13
13
|
* Anything richer (objects, unions, `$ref`s) needs a real item parser instead.
|
|
14
14
|
*/
|
|
15
15
|
export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The tuple `prefixItems` array when the schema declares one, else `null`.
|
|
18
|
+
* `prefixItems` (JSON Schema 2020-12) positions each element against its own
|
|
19
|
+
* subschema; the draft array-form `items` is handled by the existing item
|
|
20
|
+
* paths, not here.
|
|
21
|
+
*/
|
|
22
|
+
export declare const getPrefixItems: (schema: JSONSchema) => readonly JSONSchema[] | null;
|
|
23
|
+
/**
|
|
24
|
+
* True when a sibling `items: false` (2020-12) or `additionalItems: false`
|
|
25
|
+
* (draft) caps a tuple's length — any element past the declared prefix is
|
|
26
|
+
* disallowed, so the coercer drops it and strict mode rejects it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const prefixItemsCapsLength: (schema: JSONSchema) => boolean;
|
|
16
29
|
/**
|
|
17
30
|
* Generates a validation expression that checks if a value matches the expected type.
|
|
18
31
|
* Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
|
|
19
32
|
* Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
|
|
20
33
|
*/
|
|
21
34
|
export declare const generateValidationExpression: (key: string, schema: JSONSchema, defaultValue: string, isRequired: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, accessorOverride?: string, knownNotUndefined?: boolean, caseInsensitive?: boolean) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Builds the coercion transform for a tuple `prefixItems` array: an index-matched
|
|
37
|
+
* `.map` where each declared position runs through its own subschema's coercion
|
|
38
|
+
* (present positions only — a shorter input keeps its absent trailing positions),
|
|
39
|
+
* and every element past the tuple passes through unchanged. When `capLength` is
|
|
40
|
+
* set (a sibling `items: false`), a trailing `.slice(0, N)` drops those extras.
|
|
41
|
+
* `accessor` must already be known to be an array (the caller guards with
|
|
42
|
+
* `Array.isArray`). `$ref` positions resolve against `rootSchema` when provided.
|
|
43
|
+
*/
|
|
44
|
+
export declare const generatePrefixItemsMap: (accessor: string, prefix: readonly JSONSchema[], capLength: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, caseInsensitive?: boolean) => string;
|
|
@@ -16,9 +16,14 @@ import { generateSchemaChecks } from './generate-schema-checks.js';
|
|
|
16
16
|
* epoch number); other classes have no safe generic coercion, so we return null
|
|
17
17
|
* and the caller falls back to the default.
|
|
18
18
|
*/
|
|
19
|
-
const getInstanceCoercion = (accessor, instanceOf) => {
|
|
19
|
+
const getInstanceCoercion = (accessor, instanceOf, defaultValue) => {
|
|
20
|
+
// A bare `new Date(bad)` yields an `Invalid Date` — an object that is
|
|
21
|
+
// `instanceof Date` yet whose every operation is `NaN`, silently poisoning
|
|
22
|
+
// downstream code. Construct once, then fall back to the default when the
|
|
23
|
+
// result is invalid. This sits on the coercion (failure) branch only, so the
|
|
24
|
+
// hot path never pays for it.
|
|
20
25
|
if (instanceOf === 'Date')
|
|
21
|
-
return `new Date(${accessor} as string | number | Date)`;
|
|
26
|
+
return `((_d) => Number.isNaN(_d.getTime()) ? ${defaultValue} : _d)(new Date(${accessor} as string | number | Date))`;
|
|
22
27
|
return null;
|
|
23
28
|
};
|
|
24
29
|
/**
|
|
@@ -57,6 +62,29 @@ export const isCoercibleItemSchema = (itemSchema) => {
|
|
|
57
62
|
return true;
|
|
58
63
|
return isSchemaObject(itemSchema) && hasEnum(itemSchema) && itemSchema.enum.length > 0;
|
|
59
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* The tuple `prefixItems` array when the schema declares one, else `null`.
|
|
67
|
+
* `prefixItems` (JSON Schema 2020-12) positions each element against its own
|
|
68
|
+
* subschema; the draft array-form `items` is handled by the existing item
|
|
69
|
+
* paths, not here.
|
|
70
|
+
*/
|
|
71
|
+
export const getPrefixItems = (schema) => {
|
|
72
|
+
if (!isSchemaObject(schema))
|
|
73
|
+
return null;
|
|
74
|
+
const prefix = schema['prefixItems'];
|
|
75
|
+
return Array.isArray(prefix) ? prefix : null;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* True when a sibling `items: false` (2020-12) or `additionalItems: false`
|
|
79
|
+
* (draft) caps a tuple's length — any element past the declared prefix is
|
|
80
|
+
* disallowed, so the coercer drops it and strict mode rejects it.
|
|
81
|
+
*/
|
|
82
|
+
export const prefixItemsCapsLength = (schema) => {
|
|
83
|
+
if (!isSchemaObject(schema))
|
|
84
|
+
return false;
|
|
85
|
+
const s = schema;
|
|
86
|
+
return s['items'] === false || s['additionalItems'] === false;
|
|
87
|
+
};
|
|
60
88
|
/**
|
|
61
89
|
* A boolean type check for a single JSON Schema primitive type name. Unlike
|
|
62
90
|
* {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
|
|
@@ -104,13 +132,20 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
|
|
|
104
132
|
switch (schema.type) {
|
|
105
133
|
case 'string':
|
|
106
134
|
return `String(${accessor})`;
|
|
107
|
-
case 'number':
|
|
108
|
-
case 'integer': {
|
|
135
|
+
case 'number': {
|
|
109
136
|
// Number() of a non-numeric string produces NaN, which silently poisons
|
|
110
137
|
// arithmetic. Guard with Number.isFinite and fall back to the default.
|
|
111
138
|
const n = `Number(${accessor})`;
|
|
112
139
|
return `(Number.isFinite(${n}) ? ${n} : ${defaultValue})`;
|
|
113
140
|
}
|
|
141
|
+
case 'integer': {
|
|
142
|
+
// An `integer` must coerce to a whole number, or the "repaired" value still
|
|
143
|
+
// fails validation (a bare `Number.isFinite` accepts `1.5`). Require
|
|
144
|
+
// integrality and fall back to the default otherwise — matching the
|
|
145
|
+
// root-level integer parser, which yields the default for a non-integer.
|
|
146
|
+
const n = `Number(${accessor})`;
|
|
147
|
+
return `(Number.isInteger(${n}) ? ${n} : ${defaultValue})`;
|
|
148
|
+
}
|
|
114
149
|
case 'boolean':
|
|
115
150
|
return `Boolean(${accessor})`;
|
|
116
151
|
case 'array':
|
|
@@ -140,12 +175,15 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
140
175
|
const instanceOf = getMjstInstanceOf(schema);
|
|
141
176
|
if (instanceOf) {
|
|
142
177
|
const valid = `${accessor} instanceof ${instanceOf}`;
|
|
143
|
-
|
|
178
|
+
// The coercer's own invalid-value fallback matches the branch it sits on:
|
|
179
|
+
// the default for a required property, `undefined` for an optional one.
|
|
144
180
|
if (isRequired) {
|
|
181
|
+
const coercion = getInstanceCoercion(accessor, instanceOf, defaultValue);
|
|
145
182
|
return coercion
|
|
146
183
|
? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : ${defaultValue})`
|
|
147
184
|
: `${valid} ? ${accessor} : ${defaultValue}`;
|
|
148
185
|
}
|
|
186
|
+
const coercion = getInstanceCoercion(accessor, instanceOf, 'undefined');
|
|
149
187
|
return coercion
|
|
150
188
|
? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : undefined)`
|
|
151
189
|
: `${valid} ? ${accessor} : undefined`;
|
|
@@ -239,6 +277,17 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
239
277
|
if (!hasType(schema) && !hasEnum(schema)) {
|
|
240
278
|
return `${accessor} ?? ${defaultValue}`;
|
|
241
279
|
}
|
|
280
|
+
// Tuple `prefixItems`: coerce each declared position through its own subschema
|
|
281
|
+
// and, with a sibling `items: false`/`additionalItems: false`, drop any element
|
|
282
|
+
// past the tuple length. A shorter input keeps its absent trailing positions.
|
|
283
|
+
// The fast path only takes a tuple whose present positions are already
|
|
284
|
+
// well-typed, so a mistyped position reaches here.
|
|
285
|
+
const prefixItems = getPrefixItems(schema);
|
|
286
|
+
if (hasType(schema) && schema.type === 'array' && prefixItems) {
|
|
287
|
+
const mapped = generatePrefixItemsMap(accessor, prefixItems, prefixItemsCapsLength(schema), rootSchema, visitedRefs, caseInsensitive);
|
|
288
|
+
const built = `(Array.isArray(${accessor}) ? ${mapped} : ${defaultValue})`;
|
|
289
|
+
return isRequired ? built : `(${accessor} !== undefined ? ${built} : undefined)`;
|
|
290
|
+
}
|
|
242
291
|
// An array of scalar or enum items: coerce each element so e.g. `number[]`
|
|
243
292
|
// actually contains numbers and an enum member set holds. The fast path only
|
|
244
293
|
// takes a well-typed array, so a mistyped element reaches here. ($ref and
|
|
@@ -393,3 +442,30 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
393
442
|
return `${combinedCheck} ? ${accessor} : ${enumFallback('undefined')}`;
|
|
394
443
|
}
|
|
395
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* Builds the coercion transform for a tuple `prefixItems` array: an index-matched
|
|
447
|
+
* `.map` where each declared position runs through its own subschema's coercion
|
|
448
|
+
* (present positions only — a shorter input keeps its absent trailing positions),
|
|
449
|
+
* and every element past the tuple passes through unchanged. When `capLength` is
|
|
450
|
+
* set (a sibling `items: false`), a trailing `.slice(0, N)` drops those extras.
|
|
451
|
+
* `accessor` must already be known to be an array (the caller guards with
|
|
452
|
+
* `Array.isArray`). `$ref` positions resolve against `rootSchema` when provided.
|
|
453
|
+
*/
|
|
454
|
+
export const generatePrefixItemsMap = (accessor, prefix, capLength, rootSchema, visitedRefs, caseInsensitive) => {
|
|
455
|
+
const branches = prefix.map((rawPos, i) => {
|
|
456
|
+
// Resolve a position `$ref` to its target here: generateValidationExpression's
|
|
457
|
+
// own ref path rebuilds the accessor from the property key, which would
|
|
458
|
+
// discard the `_el` element accessor an override needs — so hand it the
|
|
459
|
+
// concrete schema directly instead.
|
|
460
|
+
let pos = rawPos;
|
|
461
|
+
if (isSchemaObject(pos) && hasRef(pos) && rootSchema) {
|
|
462
|
+
const resolved = resolveRef(pos.$ref, rootSchema);
|
|
463
|
+
if (resolved)
|
|
464
|
+
pos = resolved;
|
|
465
|
+
}
|
|
466
|
+
const posExpr = generateValidationExpression('', pos, getDefaultValue(pos), true, rootSchema, visitedRefs, '_el', true, caseInsensitive);
|
|
467
|
+
return `_i === ${i} ? ${posExpr}`;
|
|
468
|
+
});
|
|
469
|
+
const mapped = `(${accessor} as unknown[]).map((_el, _i) => ${[...branches, '_el'].join(' : ')})`;
|
|
470
|
+
return capLength ? `${mapped}.slice(0, ${prefix.length})` : mapped;
|
|
471
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a boolean expression that is `true` *exactly* when `accessor` matches
|
|
4
|
+
* `schema` — sound in **both** directions (true ⇒ valid, false ⇒ invalid) — or
|
|
5
|
+
* `null` when the schema uses a form this generator cannot prove inline.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately self-contained: it never emits a `$ref` shape-validator
|
|
8
|
+
* call (those can be conservative `=> false` stubs, which would break false-
|
|
9
|
+
* soundness) and never touches combinators. That keeps the expression usable
|
|
10
|
+
* both for *counting* matches (`contains`) and for *rejecting* non-matches
|
|
11
|
+
* (`propertyNames`, `dependentSchemas`) in strict mode, where a wrong verdict
|
|
12
|
+
* would silently accept or reject a document. Anything richer returns `null`,
|
|
13
|
+
* and `assertNoUnsupportedKeywords` turns that into a generation-time error.
|
|
14
|
+
*
|
|
15
|
+
* Constraint keywords apply per JSON Schema semantics: a `pattern`/`maxLength`
|
|
16
|
+
* with no `type` still constrains string instances (and is a no-op for other
|
|
17
|
+
* types), so a type-less schema guards each family by its runtime type rather
|
|
18
|
+
* than ignoring it — the subtlety `propertyNames: { maxLength: 3 }` depends on.
|
|
19
|
+
*
|
|
20
|
+
* `depth` seeds the `.every` element variable so nested array matches never
|
|
21
|
+
* shadow one another.
|
|
22
|
+
*/
|
|
23
|
+
export declare const subschemaMatchExpr: (accessor: string, schema: JSONSchema, depth?: number) => string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Whether {@link subschemaMatchExpr} can build an exact matcher for `schema`.
|
|
26
|
+
* The generation-time guard uses this to decide whether a `contains`,
|
|
27
|
+
* `propertyNames`, or `dependentSchemas` subschema is enforceable.
|
|
28
|
+
*/
|
|
29
|
+
export declare const canMatchSubschema: (schema: JSONSchema) => boolean;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
|
+
import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
|
+
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
4
|
+
import { hasAdditionalProperties, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
|
+
import { generateEnumCheck } from './generate-enum-check.js';
|
|
6
|
+
/**
|
|
7
|
+
* Keywords `subschemaMatchExpr` reads and enforces. A subschema carrying any
|
|
8
|
+
* keyword outside this set (and the annotation set below) cannot be matched
|
|
9
|
+
* *exactly*, so the matcher bails to `null` and the generation-time guard turns
|
|
10
|
+
* that into a hard error rather than a permissive parser.
|
|
11
|
+
*/
|
|
12
|
+
const HANDLED_KEYWORDS = new Set([
|
|
13
|
+
'type',
|
|
14
|
+
'enum',
|
|
15
|
+
'const',
|
|
16
|
+
'pattern',
|
|
17
|
+
'minLength',
|
|
18
|
+
'maxLength',
|
|
19
|
+
'minimum',
|
|
20
|
+
'maximum',
|
|
21
|
+
'exclusiveMinimum',
|
|
22
|
+
'exclusiveMaximum',
|
|
23
|
+
'multipleOf',
|
|
24
|
+
'minItems',
|
|
25
|
+
'maxItems',
|
|
26
|
+
'uniqueItems',
|
|
27
|
+
'items',
|
|
28
|
+
'required',
|
|
29
|
+
'properties',
|
|
30
|
+
'minProperties',
|
|
31
|
+
'maxProperties',
|
|
32
|
+
'additionalProperties',
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* Keywords that only annotate (Ajv, our differential oracle, does not assert on
|
|
36
|
+
* them under `strict: false`), so their presence never blocks an exact match.
|
|
37
|
+
* `format` is annotation-only in 2020-12 by default, matching the oracle.
|
|
38
|
+
*/
|
|
39
|
+
const ANNOTATION_KEYWORDS = new Set([
|
|
40
|
+
'title',
|
|
41
|
+
'description',
|
|
42
|
+
'$comment',
|
|
43
|
+
'$id',
|
|
44
|
+
'$schema',
|
|
45
|
+
'$anchor',
|
|
46
|
+
'$vocabulary',
|
|
47
|
+
'$defs',
|
|
48
|
+
'definitions',
|
|
49
|
+
'examples',
|
|
50
|
+
'default',
|
|
51
|
+
'deprecated',
|
|
52
|
+
'readOnly',
|
|
53
|
+
'writeOnly',
|
|
54
|
+
'format',
|
|
55
|
+
'contentMediaType',
|
|
56
|
+
'contentEncoding',
|
|
57
|
+
'contentSchema',
|
|
58
|
+
]);
|
|
59
|
+
const isObjectExpr = (acc) => `typeof ${acc} === "object" && ${acc} !== null && !Array.isArray(${acc})`;
|
|
60
|
+
/** True when `additionalProperties` is a constraining (non-boolean) schema. */
|
|
61
|
+
const hasSchemaAdditionalProperties = (schema) => hasAdditionalProperties(schema) &&
|
|
62
|
+
typeof schema.additionalProperties !== 'boolean';
|
|
63
|
+
/**
|
|
64
|
+
* String-family constraint checks (no type assertion). Every JSON Schema string
|
|
65
|
+
* keyword only asserts on string instances, so these are combined with a
|
|
66
|
+
* `typeof === "string"` — either as the assertion (a `type: 'string'` schema) or
|
|
67
|
+
* as a guard (`typeof !== "string" || …` for a type-less schema).
|
|
68
|
+
*/
|
|
69
|
+
const stringConstraints = (acc, schema) => {
|
|
70
|
+
const c = [];
|
|
71
|
+
if (hasPattern(schema))
|
|
72
|
+
c.push(`/${escapeRegexPattern(schema.pattern)}/.test(${acc})`);
|
|
73
|
+
if (hasMinLength(schema))
|
|
74
|
+
c.push(`${acc}.length >= ${schema.minLength}`);
|
|
75
|
+
if (hasMaxLength(schema))
|
|
76
|
+
c.push(`${acc}.length <= ${schema.maxLength}`);
|
|
77
|
+
return c;
|
|
78
|
+
};
|
|
79
|
+
/** Number-family constraint checks (no type assertion). `integer` requires an explicit type. */
|
|
80
|
+
const numberConstraints = (acc, schema, integer) => {
|
|
81
|
+
const c = [];
|
|
82
|
+
if (integer)
|
|
83
|
+
c.push(`Number.isInteger(${acc})`);
|
|
84
|
+
if (hasMinimum(schema))
|
|
85
|
+
c.push(`${acc} >= ${schema.minimum}`);
|
|
86
|
+
if (hasMaximum(schema))
|
|
87
|
+
c.push(`${acc} <= ${schema.maximum}`);
|
|
88
|
+
if (hasExclusiveMinimum(schema))
|
|
89
|
+
c.push(`${acc} > ${schema.exclusiveMinimum}`);
|
|
90
|
+
if (hasExclusiveMaximum(schema))
|
|
91
|
+
c.push(`${acc} < ${schema.exclusiveMaximum}`);
|
|
92
|
+
if (hasMultipleOf(schema))
|
|
93
|
+
c.push(multipleOfPassExpr(acc, schema.multipleOf));
|
|
94
|
+
return c;
|
|
95
|
+
};
|
|
96
|
+
/** Array-family constraint checks (no `Array.isArray` assertion). `null` when an item schema is unprovable. */
|
|
97
|
+
const arrayConstraints = (acc, schema, depth) => {
|
|
98
|
+
const c = [];
|
|
99
|
+
if (hasMinItems(schema))
|
|
100
|
+
c.push(`${acc}.length >= ${schema.minItems}`);
|
|
101
|
+
if (hasMaxItems(schema))
|
|
102
|
+
c.push(`${acc}.length <= ${schema.maxItems}`);
|
|
103
|
+
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
104
|
+
c.push(`new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`);
|
|
105
|
+
}
|
|
106
|
+
if (hasItems(schema)) {
|
|
107
|
+
if (Array.isArray(schema.items))
|
|
108
|
+
return null;
|
|
109
|
+
const el = `_i${depth}`;
|
|
110
|
+
const itemMatch = subschemaMatchExpr(el, schema.items, depth + 1);
|
|
111
|
+
if (itemMatch === null)
|
|
112
|
+
return null;
|
|
113
|
+
if (itemMatch !== 'true')
|
|
114
|
+
c.push(`(${acc} as unknown[]).every((${el}) => ${itemMatch})`);
|
|
115
|
+
}
|
|
116
|
+
return c;
|
|
117
|
+
};
|
|
118
|
+
/** Object-family constraint checks (no object-type assertion). `null` when a property schema is unprovable. */
|
|
119
|
+
const objectConstraints = (acc, schema, depth) => {
|
|
120
|
+
// A schema-valued additionalProperties record would leave record values unproven.
|
|
121
|
+
if (hasSchemaAdditionalProperties(schema))
|
|
122
|
+
return null;
|
|
123
|
+
const c = [];
|
|
124
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
125
|
+
if (hasProperties(schema)) {
|
|
126
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
127
|
+
const propAcc = safeAccessor(acc, key);
|
|
128
|
+
const propMatch = subschemaMatchExpr(propAcc, propSchema, depth + 1);
|
|
129
|
+
if (propMatch === null)
|
|
130
|
+
return null;
|
|
131
|
+
if (required.has(key)) {
|
|
132
|
+
c.push(propMatch === 'true'
|
|
133
|
+
? `${JSON.stringify(key)} in ${acc}`
|
|
134
|
+
: `(${JSON.stringify(key)} in ${acc} && ${propMatch})`);
|
|
135
|
+
}
|
|
136
|
+
else if (propMatch !== 'true') {
|
|
137
|
+
c.push(`(${propAcc} === undefined || ${propMatch})`);
|
|
138
|
+
}
|
|
139
|
+
required.delete(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const key of required)
|
|
143
|
+
c.push(`${JSON.stringify(key)} in ${acc}`);
|
|
144
|
+
if (hasMinProperties(schema))
|
|
145
|
+
c.push(`Object.keys(${acc}).length >= ${schema.minProperties}`);
|
|
146
|
+
if (hasMaxProperties(schema))
|
|
147
|
+
c.push(`Object.keys(${acc}).length <= ${schema.maxProperties}`);
|
|
148
|
+
if (hasAdditionalProperties(schema) && schema.additionalProperties === false) {
|
|
149
|
+
const allowed = JSON.stringify(hasProperties(schema) ? Object.keys(schema.properties) : []);
|
|
150
|
+
c.push(`Object.keys(${acc}).every((_k) => ${allowed}.includes(_k))`);
|
|
151
|
+
}
|
|
152
|
+
return c;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Builds a boolean expression that is `true` *exactly* when `accessor` matches
|
|
156
|
+
* `schema` — sound in **both** directions (true ⇒ valid, false ⇒ invalid) — or
|
|
157
|
+
* `null` when the schema uses a form this generator cannot prove inline.
|
|
158
|
+
*
|
|
159
|
+
* This is deliberately self-contained: it never emits a `$ref` shape-validator
|
|
160
|
+
* call (those can be conservative `=> false` stubs, which would break false-
|
|
161
|
+
* soundness) and never touches combinators. That keeps the expression usable
|
|
162
|
+
* both for *counting* matches (`contains`) and for *rejecting* non-matches
|
|
163
|
+
* (`propertyNames`, `dependentSchemas`) in strict mode, where a wrong verdict
|
|
164
|
+
* would silently accept or reject a document. Anything richer returns `null`,
|
|
165
|
+
* and `assertNoUnsupportedKeywords` turns that into a generation-time error.
|
|
166
|
+
*
|
|
167
|
+
* Constraint keywords apply per JSON Schema semantics: a `pattern`/`maxLength`
|
|
168
|
+
* with no `type` still constrains string instances (and is a no-op for other
|
|
169
|
+
* types), so a type-less schema guards each family by its runtime type rather
|
|
170
|
+
* than ignoring it — the subtlety `propertyNames: { maxLength: 3 }` depends on.
|
|
171
|
+
*
|
|
172
|
+
* `depth` seeds the `.every` element variable so nested array matches never
|
|
173
|
+
* shadow one another.
|
|
174
|
+
*/
|
|
175
|
+
export const subschemaMatchExpr = (accessor, schema, depth = 0) => {
|
|
176
|
+
// Boolean schemas: `true` matches everything, `false` matches nothing.
|
|
177
|
+
if (schema === true)
|
|
178
|
+
return 'true';
|
|
179
|
+
if (schema === false)
|
|
180
|
+
return 'false';
|
|
181
|
+
if (!isSchemaObject(schema))
|
|
182
|
+
return null;
|
|
183
|
+
// Any keyword we neither handle nor know to be a pure annotation could narrow
|
|
184
|
+
// the value set; matching would then be unsound, so bail.
|
|
185
|
+
for (const key of Object.keys(schema)) {
|
|
186
|
+
if (!HANDLED_KEYWORDS.has(key) && !ANNOTATION_KEYWORDS.has(key))
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
// An array-form `type` (multi-type / nullable) is a disjunction we do not
|
|
190
|
+
// prove inline here; `hasType` is false for it, so without this it would fall
|
|
191
|
+
// through to a permissive result.
|
|
192
|
+
if ('type' in schema && typeof schema.type !== 'string')
|
|
193
|
+
return null;
|
|
194
|
+
const checks = [];
|
|
195
|
+
// `const` — a single exact value. Primitives compare with `===`; a structural
|
|
196
|
+
// const would need deep equality, which this flat matcher does not attempt.
|
|
197
|
+
if (hasConst(schema)) {
|
|
198
|
+
const c = schema.const;
|
|
199
|
+
if (c !== null && typeof c === 'object')
|
|
200
|
+
return null;
|
|
201
|
+
checks.push(`${accessor} === ${JSON.stringify(c)}`);
|
|
202
|
+
}
|
|
203
|
+
// `enum` — membership in a fixed set (generateEnumCheck is exact).
|
|
204
|
+
if (hasEnum(schema)) {
|
|
205
|
+
if (schema.enum.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
checks.push(generateEnumCheck(accessor, schema.enum));
|
|
208
|
+
}
|
|
209
|
+
if (hasType(schema)) {
|
|
210
|
+
const typeChecks = buildTypedChecks(accessor, schema, depth);
|
|
211
|
+
if (typeChecks === null)
|
|
212
|
+
return null;
|
|
213
|
+
checks.push(...typeChecks);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const typeless = buildTypelessChecks(accessor, schema, depth);
|
|
217
|
+
if (typeless === null)
|
|
218
|
+
return null;
|
|
219
|
+
checks.push(...typeless);
|
|
220
|
+
}
|
|
221
|
+
// A schema with no proven constraint matches every value.
|
|
222
|
+
if (checks.length === 0)
|
|
223
|
+
return 'true';
|
|
224
|
+
return checks.length === 1 ? checks[0] : `(${checks.join(' && ')})`;
|
|
225
|
+
};
|
|
226
|
+
/** Type assertion plus that type's constraints, for a single-`type` schema. */
|
|
227
|
+
const buildTypedChecks = (accessor, schema, depth) => {
|
|
228
|
+
switch (schema.type) {
|
|
229
|
+
case 'string':
|
|
230
|
+
return [`typeof ${accessor} === "string"`, ...stringConstraints(accessor, schema)];
|
|
231
|
+
case 'number':
|
|
232
|
+
return [`typeof ${accessor} === "number"`, ...numberConstraints(accessor, schema, false)];
|
|
233
|
+
case 'integer':
|
|
234
|
+
return [`typeof ${accessor} === "number"`, ...numberConstraints(accessor, schema, true)];
|
|
235
|
+
case 'boolean':
|
|
236
|
+
return [`typeof ${accessor} === "boolean"`];
|
|
237
|
+
case 'null':
|
|
238
|
+
return [`${accessor} === null`];
|
|
239
|
+
case 'array': {
|
|
240
|
+
const constraints = arrayConstraints(accessor, schema, depth);
|
|
241
|
+
if (constraints === null)
|
|
242
|
+
return null;
|
|
243
|
+
return [`Array.isArray(${accessor})`, ...constraints];
|
|
244
|
+
}
|
|
245
|
+
case 'object': {
|
|
246
|
+
const constraints = objectConstraints(accessor, schema, depth);
|
|
247
|
+
if (constraints === null)
|
|
248
|
+
return null;
|
|
249
|
+
return [isObjectExpr(accessor), ...constraints];
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Constraints on a schema with no `type`: each family only asserts on instances
|
|
257
|
+
* of its type, so guard each present family by its runtime type. A value of a
|
|
258
|
+
* different type satisfies the family vacuously.
|
|
259
|
+
*/
|
|
260
|
+
const buildTypelessChecks = (accessor, schema, depth) => {
|
|
261
|
+
const checks = [];
|
|
262
|
+
const strChecks = stringConstraints(accessor, schema);
|
|
263
|
+
if (strChecks.length > 0)
|
|
264
|
+
checks.push(`(typeof ${accessor} !== "string" || ${joinAnd(strChecks)})`);
|
|
265
|
+
const numChecks = numberConstraints(accessor, schema, false);
|
|
266
|
+
if (numChecks.length > 0)
|
|
267
|
+
checks.push(`(typeof ${accessor} !== "number" || ${joinAnd(numChecks)})`);
|
|
268
|
+
const arrChecks = arrayConstraints(accessor, schema, depth);
|
|
269
|
+
if (arrChecks === null)
|
|
270
|
+
return null;
|
|
271
|
+
if (arrChecks.length > 0)
|
|
272
|
+
checks.push(`(!Array.isArray(${accessor}) || ${joinAnd(arrChecks)})`);
|
|
273
|
+
const objChecks = objectConstraints(accessor, schema, depth);
|
|
274
|
+
if (objChecks === null)
|
|
275
|
+
return null;
|
|
276
|
+
if (objChecks.length > 0)
|
|
277
|
+
checks.push(`(!(${isObjectExpr(accessor)}) || ${joinAnd(objChecks)})`);
|
|
278
|
+
return checks;
|
|
279
|
+
};
|
|
280
|
+
const joinAnd = (checks) => (checks.length === 1 ? checks[0] : `(${checks.join(' && ')})`);
|
|
281
|
+
/**
|
|
282
|
+
* Whether {@link subschemaMatchExpr} can build an exact matcher for `schema`.
|
|
283
|
+
* The generation-time guard uses this to decide whether a `contains`,
|
|
284
|
+
* `propertyNames`, or `dependentSchemas` subschema is enforceable.
|
|
285
|
+
*/
|
|
286
|
+
export const canMatchSubschema = (schema) => subschemaMatchExpr('_x', schema) !== null;
|
|
@@ -50,6 +50,14 @@ export const getDefaultValue = (schema) => {
|
|
|
50
50
|
if (getMjstPrimitive(schema) === 'bigint') {
|
|
51
51
|
return '0n';
|
|
52
52
|
}
|
|
53
|
+
// Array-form `type` (e.g. `["string","null"]`): `hasType` is false for it, so
|
|
54
|
+
// without this a required-but-missing value would default to `undefined` —
|
|
55
|
+
// violating both `required` and the declared type. Derive the default from the
|
|
56
|
+
// first listed type so the fallback is a valid member.
|
|
57
|
+
const rawType = schema['type'];
|
|
58
|
+
if (Array.isArray(rawType) && rawType.length > 0 && typeof rawType[0] === 'string') {
|
|
59
|
+
return getDefaultValue({ ...schema, type: rawType[0] });
|
|
60
|
+
}
|
|
53
61
|
if (!hasType(schema)) {
|
|
54
62
|
return 'undefined';
|
|
55
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"json-schema-typed": "^8.0.1",
|
|
51
|
-
"@amritk/helpers": "0.
|
|
51
|
+
"@amritk/helpers": "0.13.1",
|
|
52
52
|
"@amritk/generate-markdown": "0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|