@amritk/generate-parsers 0.16.0 → 0.16.1
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-parser-function.js +136 -52
- 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 +213 -12
- 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 +72 -3
- package/dist/generators/subschema-match.d.ts +29 -0
- package/dist/generators/subschema-match.js +286 -0
- package/package.json +2 -2
|
@@ -1,8 +1,158 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
3
|
import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
4
|
-
import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { hasAdditionalProperties, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
5
|
import { generateEnumCheck } from './generate-enum-check.js';
|
|
6
|
+
// Keywords that only make sense on a given type. A branch that carries any of
|
|
7
|
+
// them but no explicit `type` is treated as that type for discrimination.
|
|
8
|
+
const OBJECT_KEYWORDS = [
|
|
9
|
+
'properties',
|
|
10
|
+
'required',
|
|
11
|
+
'minProperties',
|
|
12
|
+
'maxProperties',
|
|
13
|
+
'additionalProperties',
|
|
14
|
+
'patternProperties',
|
|
15
|
+
'dependentRequired',
|
|
16
|
+
'propertyNames',
|
|
17
|
+
];
|
|
18
|
+
const ARRAY_KEYWORDS = ['items', 'prefixItems', 'minItems', 'maxItems', 'uniqueItems', 'contains'];
|
|
19
|
+
const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'];
|
|
20
|
+
const NUMBER_KEYWORDS = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'];
|
|
21
|
+
const countKeywords = (schema, keywords) => keywords.reduce((total, keyword) => (keyword in schema ? total + 1 : total), 0);
|
|
22
|
+
/** Resolves the JSON Schema type of a single literal (a `const` value). */
|
|
23
|
+
const typeOfLiteral = (value) => {
|
|
24
|
+
if (value === null)
|
|
25
|
+
return 'null';
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return 'array';
|
|
28
|
+
const type = typeof value;
|
|
29
|
+
if (type === 'boolean')
|
|
30
|
+
return 'boolean';
|
|
31
|
+
if (type === 'number' || type === 'bigint')
|
|
32
|
+
return 'number';
|
|
33
|
+
if (type === 'string')
|
|
34
|
+
return 'string';
|
|
35
|
+
return 'object';
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Infers the type of a schema branch that omits an explicit `type`.
|
|
39
|
+
*
|
|
40
|
+
* A `const` pins the value to one literal, so its type is definitive. An enum
|
|
41
|
+
* of only `null`s can only be the null type. Otherwise each candidate type is
|
|
42
|
+
* scored by how many of its characteristic keywords the schema carries and the
|
|
43
|
+
* highest wins, with ties resolved in `object > array > string > number` order
|
|
44
|
+
* (so `{ minProperties, minItems }` is an object and `{ minItems, maxItems,
|
|
45
|
+
* minLength }` is an array). Returns undefined when nothing points at a type —
|
|
46
|
+
* e.g. a bare `{ description }` — so the caller emits no checks.
|
|
47
|
+
*/
|
|
48
|
+
const inferSchemaType = (schema) => {
|
|
49
|
+
if (!isSchemaObject(schema))
|
|
50
|
+
return undefined;
|
|
51
|
+
if (hasConst(schema)) {
|
|
52
|
+
return typeOfLiteral(schema.const);
|
|
53
|
+
}
|
|
54
|
+
if (hasEnum(schema) && schema.enum.length > 0 && schema.enum.every((value) => value === null)) {
|
|
55
|
+
return 'null';
|
|
56
|
+
}
|
|
57
|
+
const scores = {
|
|
58
|
+
object: countKeywords(schema, OBJECT_KEYWORDS),
|
|
59
|
+
array: countKeywords(schema, ARRAY_KEYWORDS),
|
|
60
|
+
string: countKeywords(schema, STRING_KEYWORDS),
|
|
61
|
+
number: countKeywords(schema, NUMBER_KEYWORDS),
|
|
62
|
+
};
|
|
63
|
+
// Iterating in priority order and only replacing on a strictly-greater score
|
|
64
|
+
// means the earliest (highest-priority) type wins any tie.
|
|
65
|
+
let best;
|
|
66
|
+
for (const type of ['object', 'array', 'string', 'number']) {
|
|
67
|
+
if (scores[type] > 0 && (best === undefined || scores[type] > scores[best])) {
|
|
68
|
+
best = type;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return best;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Emits the checks for a branch whose type was inferred (not explicit). It
|
|
75
|
+
* mirrors the explicit-type switch below, with two deliberate differences: the
|
|
76
|
+
* enum membership check uses the `.includes` form (appended by the caller) and
|
|
77
|
+
* `multipleOf` uses the plain `% === 0` form, matching the inference spec.
|
|
78
|
+
*/
|
|
79
|
+
const generateInferredChecks = (accessor, schema, type) => {
|
|
80
|
+
const checks = [];
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'object':
|
|
83
|
+
checks.push(`typeof ${accessor} === "object" && ${accessor} !== null && !Array.isArray(${accessor})`);
|
|
84
|
+
if (hasRequired(schema) && schema.required.length > 0) {
|
|
85
|
+
for (const requiredKey of schema.required) {
|
|
86
|
+
checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (hasMinProperties(schema)) {
|
|
90
|
+
checks.push(`Object.keys(${accessor}).length >= ${schema.minProperties}`);
|
|
91
|
+
}
|
|
92
|
+
if (hasMaxProperties(schema)) {
|
|
93
|
+
checks.push(`Object.keys(${accessor}).length <= ${schema.maxProperties}`);
|
|
94
|
+
}
|
|
95
|
+
if (hasAdditionalProperties(schema) && schema.additionalProperties === false && hasProperties(schema)) {
|
|
96
|
+
const allowedKeysStr = JSON.stringify(Object.keys(schema.properties));
|
|
97
|
+
checks.push(`Object.keys(${accessor}).every(k => ${allowedKeysStr}.includes(k))`);
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case 'array':
|
|
101
|
+
checks.push(`Array.isArray(${accessor})`);
|
|
102
|
+
if (hasMinItems(schema)) {
|
|
103
|
+
checks.push(`${accessor}.length >= ${schema.minItems}`);
|
|
104
|
+
}
|
|
105
|
+
if (hasMaxItems(schema)) {
|
|
106
|
+
checks.push(`${accessor}.length <= ${schema.maxItems}`);
|
|
107
|
+
}
|
|
108
|
+
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
109
|
+
checks.push(`new Set(${accessor}).size === ${accessor}.length`);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
case 'string':
|
|
113
|
+
checks.push(`typeof ${accessor} === "string"`);
|
|
114
|
+
if (hasPattern(schema)) {
|
|
115
|
+
checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${accessor})`);
|
|
116
|
+
}
|
|
117
|
+
if (hasMinLength(schema)) {
|
|
118
|
+
checks.push(`${accessor}.length >= ${schema.minLength}`);
|
|
119
|
+
}
|
|
120
|
+
if (hasMaxLength(schema)) {
|
|
121
|
+
checks.push(`${accessor}.length <= ${schema.maxLength}`);
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case 'number':
|
|
125
|
+
checks.push(`typeof ${accessor} === "number"`);
|
|
126
|
+
if (hasMinimum(schema)) {
|
|
127
|
+
checks.push(`${accessor} >= ${schema.minimum}`);
|
|
128
|
+
}
|
|
129
|
+
if (hasMaximum(schema)) {
|
|
130
|
+
checks.push(`${accessor} <= ${schema.maximum}`);
|
|
131
|
+
}
|
|
132
|
+
if (hasExclusiveMinimum(schema)) {
|
|
133
|
+
checks.push(`${accessor} > ${schema.exclusiveMinimum}`);
|
|
134
|
+
}
|
|
135
|
+
if (hasExclusiveMaximum(schema)) {
|
|
136
|
+
checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
|
|
137
|
+
}
|
|
138
|
+
if (hasMultipleOf(schema)) {
|
|
139
|
+
checks.push(`${accessor} % ${schema.multipleOf} === 0`);
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case 'boolean':
|
|
143
|
+
checks.push(`typeof ${accessor} === "boolean"`);
|
|
144
|
+
break;
|
|
145
|
+
case 'null':
|
|
146
|
+
// A `null` inferred from an all-null enum is covered by the membership
|
|
147
|
+
// check the caller appends; only a `const: null` needs its own identity
|
|
148
|
+
// check here.
|
|
149
|
+
if (hasConst(schema)) {
|
|
150
|
+
checks.push(`${accessor} === null`);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
return checks;
|
|
155
|
+
};
|
|
6
156
|
/**
|
|
7
157
|
* Generates validation checks for a single schema (used in union validation).
|
|
8
158
|
*/
|
|
@@ -18,7 +168,17 @@ export const generateSchemaChecks = (accessor, schema) => {
|
|
|
18
168
|
if (primitive) {
|
|
19
169
|
return [`typeof ${accessor} === "${primitive}"`];
|
|
20
170
|
}
|
|
171
|
+
// Without an explicit `type`, a branch's keywords still imply one. Inferring
|
|
172
|
+
// it lets union discrimination reject the branch instead of matching anything.
|
|
21
173
|
if (!hasType(schema)) {
|
|
174
|
+
const inferred = inferSchemaType(schema);
|
|
175
|
+
if (!inferred) {
|
|
176
|
+
return checks;
|
|
177
|
+
}
|
|
178
|
+
checks.push(...generateInferredChecks(accessor, schema, inferred));
|
|
179
|
+
if (hasEnum(schema) && schema.enum.length > 0) {
|
|
180
|
+
checks.push(`${JSON.stringify(schema.enum)}.includes(${accessor} as never)`);
|
|
181
|
+
}
|
|
22
182
|
return checks;
|
|
23
183
|
}
|
|
24
184
|
switch (schema.type) {
|
|
@@ -14,6 +14,27 @@ export type StrictAssertionContext = {
|
|
|
14
14
|
readonly rootSchema?: Record<string, unknown>;
|
|
15
15
|
readonly stripUnknown?: boolean;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Strict-mode `contains` / `minContains` / `maxContains`: at least `minContains`
|
|
19
|
+
* (default 1) and at most `maxContains` (default ∞) items of the array must
|
|
20
|
+
* match the `contains` subschema. Runtime-gated on `Array.isArray` so a
|
|
21
|
+
* non-array value is left to the type check, and wrapped in a block so the
|
|
22
|
+
* per-array count variable never collides with a sibling check. `minContains: 0`
|
|
23
|
+
* makes the lower bound trivially satisfied. Emits nothing when there is no
|
|
24
|
+
* `contains` or its subschema is not matchable inline (the generation-time guard
|
|
25
|
+
* rejects the latter, so a strict parser never silently drops the constraint).
|
|
26
|
+
*/
|
|
27
|
+
export declare const generateContainsCheck: (acc: string, schema: JSONSchema, label: string) => string[];
|
|
28
|
+
/**
|
|
29
|
+
* Emits the object-level keyword checks (`dependentRequired`, `dependentSchemas`,
|
|
30
|
+
* `propertyNames`) for the object reached by `obj`. When `guard` is true the
|
|
31
|
+
* whole block is wrapped in a runtime `isObject` check — used for object-typed
|
|
32
|
+
* *properties*, whose value may be absent or a non-object (the type check
|
|
33
|
+
* reports that separately). The root object and inline sub-parsers pass
|
|
34
|
+
* `guard = false` because the enclosing parser has already proven the value is
|
|
35
|
+
* an object.
|
|
36
|
+
*/
|
|
37
|
+
export declare const generateObjectKeywordChecks: (obj: string, schema: JSONSchema, label: string, guard: boolean) => string[];
|
|
17
38
|
/**
|
|
18
39
|
* Generates strict-mode assertions for the body of an object parser.
|
|
19
40
|
* Throws on:
|
|
@@ -30,4 +51,4 @@ export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeNam
|
|
|
30
51
|
* Generates a single strict-mode assertion line for a non-object scalar parser.
|
|
31
52
|
* Returns null when the schema has no type information to assert on.
|
|
32
53
|
*/
|
|
33
|
-
export declare const generateScalarStrictAssertion: (schema: JSONSchema, typeName: string) => string | null;
|
|
54
|
+
export declare const generateScalarStrictAssertion: (schema: JSONSchema, typeName: string, rootSchema?: Record<string, unknown>) => string | null;
|
|
@@ -2,11 +2,13 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
3
|
import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
|
|
4
4
|
import { quoteJsString } from '@amritk/helpers/quote-js-string';
|
|
5
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
5
6
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
6
|
-
import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
|
+
import { hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
8
|
import { generateEnumCheck } from './generate-enum-check.js';
|
|
8
|
-
import { canEnforceUnion, generateUnionCheck, getUnionBranches } from './generate-type-checks.js';
|
|
9
|
-
import { scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
9
|
+
import { canEnforceUnion, generateUnionCheck, getUnionBranches, isInlineObjectProperty } from './generate-type-checks.js';
|
|
10
|
+
import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
11
|
+
import { subschemaMatchExpr } from './subschema-match.js';
|
|
10
12
|
/**
|
|
11
13
|
* Returns the inline condition that is true when `accessor` is the wrong type
|
|
12
14
|
* for the given JSON Schema primitive type.
|
|
@@ -55,7 +57,7 @@ const throwError = (message, suffixExpr) => {
|
|
|
55
57
|
* Generates strict-mode constraint checks for a typed property
|
|
56
58
|
* (pattern, length, min/max, multipleOf).
|
|
57
59
|
*/
|
|
58
|
-
const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
60
|
+
const generateConstraintChecks = (acc, propSchema, typeName, key, context = {}) => {
|
|
59
61
|
if (!isSchemaObject(propSchema) || !hasType(propSchema))
|
|
60
62
|
return [];
|
|
61
63
|
const t = propSchema.type;
|
|
@@ -110,6 +112,8 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
|
110
112
|
if (itemCheck) {
|
|
111
113
|
lines.push(` if (Array.isArray(${acc}) && !${acc}.every((_it) => ${itemCheck.check})) ${throwError(`${field} ${itemCheck.message}`)};`);
|
|
112
114
|
}
|
|
115
|
+
// Tuple `prefixItems`: assert each position and cap length under items:false.
|
|
116
|
+
lines.push(...generatePrefixItemsAssertion(acc, field, propSchema, context.rootSchema));
|
|
113
117
|
}
|
|
114
118
|
return lines;
|
|
115
119
|
};
|
|
@@ -131,6 +135,172 @@ const generateItemCheck = (schema) => {
|
|
|
131
135
|
return null;
|
|
132
136
|
return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
|
|
133
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Strict-mode `contains` / `minContains` / `maxContains`: at least `minContains`
|
|
140
|
+
* (default 1) and at most `maxContains` (default ∞) items of the array must
|
|
141
|
+
* match the `contains` subschema. Runtime-gated on `Array.isArray` so a
|
|
142
|
+
* non-array value is left to the type check, and wrapped in a block so the
|
|
143
|
+
* per-array count variable never collides with a sibling check. `minContains: 0`
|
|
144
|
+
* makes the lower bound trivially satisfied. Emits nothing when there is no
|
|
145
|
+
* `contains` or its subschema is not matchable inline (the generation-time guard
|
|
146
|
+
* rejects the latter, so a strict parser never silently drops the constraint).
|
|
147
|
+
*/
|
|
148
|
+
export const generateContainsCheck = (acc, schema, label) => {
|
|
149
|
+
if (!isSchemaObject(schema))
|
|
150
|
+
return [];
|
|
151
|
+
const sp = schema;
|
|
152
|
+
if (!('contains' in sp))
|
|
153
|
+
return [];
|
|
154
|
+
const match = subschemaMatchExpr('_c', sp['contains']);
|
|
155
|
+
if (match === null)
|
|
156
|
+
return [];
|
|
157
|
+
const min = typeof sp['minContains'] === 'number' ? sp['minContains'] : 1;
|
|
158
|
+
const max = typeof sp['maxContains'] === 'number' ? sp['maxContains'] : undefined;
|
|
159
|
+
const bound = max !== undefined ? `_cn < ${min} || _cn > ${max}` : `_cn < ${min}`;
|
|
160
|
+
return [
|
|
161
|
+
` if (Array.isArray(${acc})) {`,
|
|
162
|
+
` const _cn = (${acc} as unknown[]).filter((_c) => ${match}).length;`,
|
|
163
|
+
` if (${bound}) ${throwError(`${label} array does not contain the required matching items`)};`,
|
|
164
|
+
` }`,
|
|
165
|
+
];
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Strict-mode `dependentRequired`: when a trigger key is present, every declared
|
|
169
|
+
* dependency key must be present too.
|
|
170
|
+
*/
|
|
171
|
+
const generateDependentRequiredChecks = (obj, schema, label) => {
|
|
172
|
+
if (!hasDependentRequired(schema))
|
|
173
|
+
return [];
|
|
174
|
+
const lines = [];
|
|
175
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
176
|
+
if (!Array.isArray(deps))
|
|
177
|
+
continue;
|
|
178
|
+
for (const dep of deps) {
|
|
179
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(dep)} in ${obj})) ${throwError(`${label} must have property '${dep}' when '${trigger}' is present`)};`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return lines;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Strict-mode `dependentSchemas` (2020-12): when a trigger property is present,
|
|
186
|
+
* the *whole object* must also match the associated subschema. A `true`
|
|
187
|
+
* subschema is a no-op; a `false` subschema makes the trigger's presence always
|
|
188
|
+
* invalid. Object subschemas are enforced via {@link subschemaMatchExpr}; a
|
|
189
|
+
* subschema it cannot match inline is rejected at generation time by the guard.
|
|
190
|
+
*/
|
|
191
|
+
const generateDependentSchemasChecks = (obj, schema, label) => {
|
|
192
|
+
const dep = schema['dependentSchemas'];
|
|
193
|
+
if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
|
|
194
|
+
return [];
|
|
195
|
+
const lines = [];
|
|
196
|
+
for (const [trigger, sub] of Object.entries(dep)) {
|
|
197
|
+
if (sub === true)
|
|
198
|
+
continue;
|
|
199
|
+
if (sub === false) {
|
|
200
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) ${throwError(`${label} must NOT have property '${trigger}'`)};`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const match = subschemaMatchExpr(obj, sub);
|
|
204
|
+
if (match === null || match === 'true')
|
|
205
|
+
continue;
|
|
206
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${match})) ${throwError(`${label} does not satisfy the schema required when '${trigger}' is present`)};`);
|
|
207
|
+
}
|
|
208
|
+
return lines;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Resolves a tuple position's schema through a single `$ref` (via `rootSchema`)
|
|
212
|
+
* so the assertion can read its `type`/`enum`. A `$ref` with no resolvable target
|
|
213
|
+
* (or no root document) is returned as-is, leaving the position unasserted.
|
|
214
|
+
*/
|
|
215
|
+
const resolvePositionSchema = (pos, rootSchema) => {
|
|
216
|
+
if (isSchemaObject(pos) && hasRef(pos) && rootSchema) {
|
|
217
|
+
const resolved = resolveRef(pos.$ref, rootSchema);
|
|
218
|
+
if (resolved)
|
|
219
|
+
return resolved;
|
|
220
|
+
}
|
|
221
|
+
return pos;
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Strict-mode assertion lines for a tuple `prefixItems`: each present position is
|
|
225
|
+
* asserted against its subschema (a scalar type, an enum, or a `$ref`/inline
|
|
226
|
+
* schema resolved to one via `rootSchema`), and a sibling `items: false` /
|
|
227
|
+
* `additionalItems: false` rejects any extra element. `acc` is the array
|
|
228
|
+
* accessor and `field` the error-message prefix (`[Type] field 'k'` or
|
|
229
|
+
* `[Type]`). Mirrors the validators' tuple pass, but throws on the first
|
|
230
|
+
* violation instead of collecting errors. Positions whose schema is richer than
|
|
231
|
+
* a scalar/enum are left to their own downstream handling (like array `items`).
|
|
232
|
+
*/
|
|
233
|
+
const generatePrefixItemsAssertion = (acc, field, schema, rootSchema) => {
|
|
234
|
+
const prefix = getPrefixItems(schema);
|
|
235
|
+
if (!prefix)
|
|
236
|
+
return [];
|
|
237
|
+
const lines = [];
|
|
238
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
239
|
+
const pos = resolvePositionSchema(prefix[i], rootSchema);
|
|
240
|
+
const el = `${acc}[${i}]`;
|
|
241
|
+
// A shorter input simply has no element at this position — `prefixItems`
|
|
242
|
+
// does not require presence (that is `minItems`' job), so guard on length.
|
|
243
|
+
const present = `Array.isArray(${acc}) && ${acc}.length > ${i}`;
|
|
244
|
+
if (isSchemaObject(pos) && hasEnum(pos) && pos.enum.length > 0) {
|
|
245
|
+
const allowed = JSON.stringify(pos.enum);
|
|
246
|
+
const label = pos.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
247
|
+
lines.push(` if (${present} && !(${allowed} as readonly unknown[]).includes(${el})) ${throwError(`${field}[${i}] must be one of: ${label}`)};`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (isSchemaObject(pos) && hasType(pos)) {
|
|
251
|
+
const pt = pos.type;
|
|
252
|
+
const wrong = wrongTypeCondition(el, pt);
|
|
253
|
+
if (wrong) {
|
|
254
|
+
lines.push(` if (${present} && (${wrong})) ${throwError(`${field}[${i}] expected ${typeLabel(pt)}, got `, `typeof ${el}`)};`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (prefixItemsCapsLength(schema)) {
|
|
259
|
+
lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${prefix.length}) ${throwError(`${field} must NOT have more than ${prefix.length} items`)};`);
|
|
260
|
+
}
|
|
261
|
+
return lines;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Strict-mode `propertyNames`: every own key of the object (keys are always
|
|
265
|
+
* strings) must satisfy the name subschema. A trivially-true matcher imposes no
|
|
266
|
+
* constraint, so no loop is emitted; an unmatchable subschema is rejected at
|
|
267
|
+
* generation time by the guard.
|
|
268
|
+
*/
|
|
269
|
+
const generatePropertyNameChecks = (obj, schema, label) => {
|
|
270
|
+
if (!hasPropertyNames(schema) || !isSchemaObject(schema.propertyNames))
|
|
271
|
+
return [];
|
|
272
|
+
const match = subschemaMatchExpr('_name', schema.propertyNames);
|
|
273
|
+
if (match === null || match === 'true')
|
|
274
|
+
return [];
|
|
275
|
+
return [
|
|
276
|
+
` for (const _name of Object.keys(${obj})) {`,
|
|
277
|
+
` if (!(${match})) ${throwError(`${label} invalid property name: `, '_name')};`,
|
|
278
|
+
` }`,
|
|
279
|
+
];
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Emits the object-level keyword checks (`dependentRequired`, `dependentSchemas`,
|
|
283
|
+
* `propertyNames`) for the object reached by `obj`. When `guard` is true the
|
|
284
|
+
* whole block is wrapped in a runtime `isObject` check — used for object-typed
|
|
285
|
+
* *properties*, whose value may be absent or a non-object (the type check
|
|
286
|
+
* reports that separately). The root object and inline sub-parsers pass
|
|
287
|
+
* `guard = false` because the enclosing parser has already proven the value is
|
|
288
|
+
* an object.
|
|
289
|
+
*/
|
|
290
|
+
export const generateObjectKeywordChecks = (obj, schema, label, guard) => {
|
|
291
|
+
if (!isSchemaObject(schema))
|
|
292
|
+
return [];
|
|
293
|
+
const inner = [
|
|
294
|
+
...generateDependentRequiredChecks(obj, schema, label),
|
|
295
|
+
...generateDependentSchemasChecks(obj, schema, label),
|
|
296
|
+
...generatePropertyNameChecks(obj, schema, label),
|
|
297
|
+
];
|
|
298
|
+
if (inner.length === 0)
|
|
299
|
+
return [];
|
|
300
|
+
if (!guard)
|
|
301
|
+
return inner;
|
|
302
|
+
return [` if (isObject(${obj})) {`, ...inner.map((line) => ` ${line}`), ` }`];
|
|
303
|
+
};
|
|
134
304
|
/**
|
|
135
305
|
* Generates strict-mode lines for a single property of an object schema.
|
|
136
306
|
* Properties with a `$ref` are skipped here — the nested parser handles its own
|
|
@@ -205,7 +375,24 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
205
375
|
lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
|
|
206
376
|
}
|
|
207
377
|
}
|
|
208
|
-
lines.push(...generateConstraintChecks(acc, propSchema, typeName, key));
|
|
378
|
+
lines.push(...generateConstraintChecks(acc, propSchema, typeName, key, context));
|
|
379
|
+
}
|
|
380
|
+
// Type-independent constraints, each runtime-gated on the value's shape:
|
|
381
|
+
// - `contains` asserts on arrays (no-op otherwise),
|
|
382
|
+
// - the object-level keywords assert on objects.
|
|
383
|
+
// Gated on a single `isSchemaObject` + a few `in` checks so a plain scalar
|
|
384
|
+
// property — the common case — skips the costlier `isInlineObjectProperty`
|
|
385
|
+
// probe and the check builders entirely. Inline object properties are
|
|
386
|
+
// deep-validated by their own sub-parser (which runs these same object-level
|
|
387
|
+
// checks), so emitting them here too would be premature and redundant.
|
|
388
|
+
if (isSchemaObject(propSchema)) {
|
|
389
|
+
const r = propSchema;
|
|
390
|
+
if ('contains' in r)
|
|
391
|
+
lines.push(...generateContainsCheck(acc, propSchema, field));
|
|
392
|
+
if (('dependentRequired' in r || 'dependentSchemas' in r || 'propertyNames' in r) &&
|
|
393
|
+
!isInlineObjectProperty(propSchema)) {
|
|
394
|
+
lines.push(...generateObjectKeywordChecks(acc, propSchema, field, true));
|
|
395
|
+
}
|
|
209
396
|
}
|
|
210
397
|
return lines;
|
|
211
398
|
};
|
|
@@ -223,12 +410,23 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
223
410
|
export const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
|
|
224
411
|
const lines = [];
|
|
225
412
|
lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
|
|
226
|
-
if (!
|
|
413
|
+
if (!isSchemaObject(schema))
|
|
227
414
|
return lines;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
415
|
+
if (hasProperties(schema)) {
|
|
416
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
417
|
+
const props = schema.properties;
|
|
418
|
+
for (const key in props) {
|
|
419
|
+
lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Object-level keywords for the object itself. `input` is already proven an
|
|
423
|
+
// object above, so no runtime guard is needed. Emitted even when the schema
|
|
424
|
+
// has no `properties` (e.g. a constrained-key map: `{ type: 'object',
|
|
425
|
+
// propertyNames: {...} }`). Gated on a cheap presence check so a keyword-free
|
|
426
|
+
// object skips the three check builders.
|
|
427
|
+
const sr = schema;
|
|
428
|
+
if ('dependentRequired' in sr || 'dependentSchemas' in sr || 'propertyNames' in sr) {
|
|
429
|
+
lines.push(...generateObjectKeywordChecks('input', schema, `[${typeName}]`, false));
|
|
232
430
|
}
|
|
233
431
|
return lines;
|
|
234
432
|
};
|
|
@@ -236,7 +434,7 @@ export const generateObjectStrictAssertion = (schema, typeName, context = {}) =>
|
|
|
236
434
|
* Generates a single strict-mode assertion line for a non-object scalar parser.
|
|
237
435
|
* Returns null when the schema has no type information to assert on.
|
|
238
436
|
*/
|
|
239
|
-
export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
437
|
+
export const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
|
|
240
438
|
const got = 'input === null ? "null" : typeof input';
|
|
241
439
|
const instanceOf = getMjstInstanceOf(schema);
|
|
242
440
|
if (instanceOf) {
|
|
@@ -254,12 +452,15 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
|
254
452
|
return null;
|
|
255
453
|
const lines = [` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`];
|
|
256
454
|
// Root-level arrays enforce scalar/enum item types too — the same gap the
|
|
257
|
-
// property path closes in generateConstraintChecks
|
|
455
|
+
// property path closes in generateConstraintChecks — plus `contains`.
|
|
258
456
|
if (t === 'array') {
|
|
259
457
|
const itemCheck = generateItemCheck(schema);
|
|
260
458
|
if (itemCheck) {
|
|
261
459
|
lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`[${typeName}] ${itemCheck.message}`)};`);
|
|
262
460
|
}
|
|
461
|
+
lines.push(...generateContainsCheck('input', schema, `[${typeName}]`));
|
|
462
|
+
// Tuple `prefixItems`: assert each position and cap length under items:false.
|
|
463
|
+
lines.push(...generatePrefixItemsAssertion('input', `[${typeName}]`, schema, rootSchema));
|
|
263
464
|
}
|
|
264
465
|
return lines.join('\n');
|
|
265
466
|
};
|
|
@@ -41,6 +41,15 @@ export declare const isInlineObjectArrayProperty: (propSchema: JSONSchema) => bo
|
|
|
41
41
|
* turn into a membership check).
|
|
42
42
|
*/
|
|
43
43
|
export declare const getUnionBranches: (schema: JSONSchema) => readonly JSONSchema[] | null;
|
|
44
|
+
/**
|
|
45
|
+
* True when a schema carries a keyword the strict slow path enforces but which
|
|
46
|
+
* no flat type check can mirror: `contains` (array item counting),
|
|
47
|
+
* `dependentRequired` / `dependentSchemas` (cross-property presence), or
|
|
48
|
+
* `propertyNames` (per-key constraints). A fast path or shape validator that
|
|
49
|
+
* accepted such a value on a bare type check would skip the enforcement, so
|
|
50
|
+
* both must bail when this is true — the slow path then runs the real checks.
|
|
51
|
+
*/
|
|
52
|
+
export declare const hasFastPathBlockingKeyword: (schema: JSONSchema) => boolean;
|
|
44
53
|
/**
|
|
45
54
|
* Generates a fast-path type check expression for a property.
|
|
46
55
|
* Returns null if the schema is too complex for a simple type check.
|
|
@@ -5,7 +5,7 @@ import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
|
5
5
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
6
6
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
7
7
|
import { generateEnumCheck } from './generate-enum-check.js';
|
|
8
|
-
import { scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
8
|
+
import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
9
9
|
/**
|
|
10
10
|
* Boolean type-check expression builders shared by the shape validators, the
|
|
11
11
|
* parsers' fast paths, and the strict-mode assertions. They live in their own
|
|
@@ -86,6 +86,23 @@ export const getUnionBranches = (schema) => {
|
|
|
86
86
|
return schema.anyOf;
|
|
87
87
|
return null;
|
|
88
88
|
};
|
|
89
|
+
/**
|
|
90
|
+
* True when a schema carries a keyword the strict slow path enforces but which
|
|
91
|
+
* no flat type check can mirror: `contains` (array item counting),
|
|
92
|
+
* `dependentRequired` / `dependentSchemas` (cross-property presence), or
|
|
93
|
+
* `propertyNames` (per-key constraints). A fast path or shape validator that
|
|
94
|
+
* accepted such a value on a bare type check would skip the enforcement, so
|
|
95
|
+
* both must bail when this is true — the slow path then runs the real checks.
|
|
96
|
+
*/
|
|
97
|
+
export const hasFastPathBlockingKeyword = (schema) => {
|
|
98
|
+
if (!isSchemaObject(schema))
|
|
99
|
+
return false;
|
|
100
|
+
const record = schema;
|
|
101
|
+
// Plain `in` checks (not the typed `hasDependentRequired`): this runs several
|
|
102
|
+
// times per property during codegen, and a present-but-malformed keyword still
|
|
103
|
+
// belongs on the slow path, where the typed guards handle it.
|
|
104
|
+
return ('contains' in record || 'dependentSchemas' in record || 'propertyNames' in record || 'dependentRequired' in record);
|
|
105
|
+
};
|
|
89
106
|
/**
|
|
90
107
|
* Generates a fast-path type check expression for a property.
|
|
91
108
|
* Returns null if the schema is too complex for a simple type check.
|
|
@@ -97,6 +114,19 @@ export const getUnionBranches = (schema) => {
|
|
|
97
114
|
export const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
98
115
|
if (!isSchemaObject(schema))
|
|
99
116
|
return null;
|
|
117
|
+
// Keywords the strict slow path enforces but no flat check can prove: bail so
|
|
118
|
+
// neither the fast path nor the shape validator accepts a value they would
|
|
119
|
+
// otherwise wave through untested. Inlined here (rather than calling
|
|
120
|
+
// hasFastPathBlockingKeyword) to reuse the isSchemaObject narrowing above —
|
|
121
|
+
// this is a codegen hot path hit several times per property. Keep in sync with
|
|
122
|
+
// hasFastPathBlockingKeyword.
|
|
123
|
+
const record = schema;
|
|
124
|
+
if ('contains' in record ||
|
|
125
|
+
'dependentSchemas' in record ||
|
|
126
|
+
'propertyNames' in record ||
|
|
127
|
+
'dependentRequired' in record) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
100
130
|
// $ref via shape predicate (deep fast-path through nested types).
|
|
101
131
|
if (useRefImports && hasRef(schema)) {
|
|
102
132
|
const refName = refToName(schema.$ref, suffix);
|
|
@@ -195,6 +225,24 @@ export const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix
|
|
|
195
225
|
if (itemCheck)
|
|
196
226
|
checks.push(`${varName}.every((_it) => ${itemCheck})`);
|
|
197
227
|
}
|
|
228
|
+
// Tuple `prefixItems`: every present position must already match its
|
|
229
|
+
// subschema, and a sibling `items: false` bars extra elements. Positions
|
|
230
|
+
// are checked with ref imports disabled, so no imported shape-validator
|
|
231
|
+
// call is introduced here — a position needing one returns null and simply
|
|
232
|
+
// disables the fast path, keeping the union trust-walk sound without a
|
|
233
|
+
// mirrored prefixItems traversal. A mistyped tuple then routes to the slow
|
|
234
|
+
// path (coerced) or the strict assertion (thrown).
|
|
235
|
+
const prefix = getPrefixItems(schema);
|
|
236
|
+
if (prefix) {
|
|
237
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
238
|
+
const posCheck = generatePropertyTypeCheck(`${varName}[${i}]`, prefix[i], false, suffix);
|
|
239
|
+
if (posCheck === null)
|
|
240
|
+
return null;
|
|
241
|
+
checks.push(`(${varName}.length <= ${i} || (${posCheck}))`);
|
|
242
|
+
}
|
|
243
|
+
if (prefixItemsCapsLength(schema))
|
|
244
|
+
checks.push(`${varName}.length <= ${prefix.length}`);
|
|
245
|
+
}
|
|
198
246
|
break;
|
|
199
247
|
}
|
|
200
248
|
case 'object':
|
|
@@ -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;
|