@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
|
@@ -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 { hasConst, 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,12 +57,11 @@ 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,
|
|
60
|
+
const generateConstraintChecks = (acc, propSchema, field, context = {}) => {
|
|
59
61
|
if (!isSchemaObject(propSchema) || !hasType(propSchema))
|
|
60
62
|
return [];
|
|
61
63
|
const t = propSchema.type;
|
|
62
64
|
const lines = [];
|
|
63
|
-
const field = `[${typeName}] field '${key}'`;
|
|
64
65
|
if (t === 'string') {
|
|
65
66
|
if (hasPattern(propSchema)) {
|
|
66
67
|
const pattern = escapeRegexPattern(propSchema.pattern);
|
|
@@ -110,6 +111,8 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
|
110
111
|
if (itemCheck) {
|
|
111
112
|
lines.push(` if (Array.isArray(${acc}) && !${acc}.every((_it) => ${itemCheck.check})) ${throwError(`${field} ${itemCheck.message}`)};`);
|
|
112
113
|
}
|
|
114
|
+
// Tuple `prefixItems`: assert each position and cap length under items:false.
|
|
115
|
+
lines.push(...generatePrefixItemsAssertion(acc, field, propSchema, context.rootSchema));
|
|
113
116
|
}
|
|
114
117
|
return lines;
|
|
115
118
|
};
|
|
@@ -131,6 +134,172 @@ const generateItemCheck = (schema) => {
|
|
|
131
134
|
return null;
|
|
132
135
|
return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
|
|
133
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* Strict-mode `contains` / `minContains` / `maxContains`: at least `minContains`
|
|
139
|
+
* (default 1) and at most `maxContains` (default ∞) items of the array must
|
|
140
|
+
* match the `contains` subschema. Runtime-gated on `Array.isArray` so a
|
|
141
|
+
* non-array value is left to the type check, and wrapped in a block so the
|
|
142
|
+
* per-array count variable never collides with a sibling check. `minContains: 0`
|
|
143
|
+
* makes the lower bound trivially satisfied. Emits nothing when there is no
|
|
144
|
+
* `contains` or its subschema is not matchable inline (the generation-time guard
|
|
145
|
+
* rejects the latter, so a strict parser never silently drops the constraint).
|
|
146
|
+
*/
|
|
147
|
+
export const generateContainsCheck = (acc, schema, label) => {
|
|
148
|
+
if (!isSchemaObject(schema))
|
|
149
|
+
return [];
|
|
150
|
+
const sp = schema;
|
|
151
|
+
if (!('contains' in sp))
|
|
152
|
+
return [];
|
|
153
|
+
const match = subschemaMatchExpr('_c', sp['contains']);
|
|
154
|
+
if (match === null)
|
|
155
|
+
return [];
|
|
156
|
+
const min = typeof sp['minContains'] === 'number' ? sp['minContains'] : 1;
|
|
157
|
+
const max = typeof sp['maxContains'] === 'number' ? sp['maxContains'] : undefined;
|
|
158
|
+
const bound = max !== undefined ? `_cn < ${min} || _cn > ${max}` : `_cn < ${min}`;
|
|
159
|
+
return [
|
|
160
|
+
` if (Array.isArray(${acc})) {`,
|
|
161
|
+
` const _cn = (${acc} as unknown[]).filter((_c) => ${match}).length;`,
|
|
162
|
+
` if (${bound}) ${throwError(`${label} array does not contain the required matching items`)};`,
|
|
163
|
+
` }`,
|
|
164
|
+
];
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Strict-mode `dependentRequired`: when a trigger key is present, every declared
|
|
168
|
+
* dependency key must be present too.
|
|
169
|
+
*/
|
|
170
|
+
const generateDependentRequiredChecks = (obj, schema, label) => {
|
|
171
|
+
if (!hasDependentRequired(schema))
|
|
172
|
+
return [];
|
|
173
|
+
const lines = [];
|
|
174
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
175
|
+
if (!Array.isArray(deps))
|
|
176
|
+
continue;
|
|
177
|
+
for (const dep of deps) {
|
|
178
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(dep)} in ${obj})) ${throwError(`${label} must have property '${dep}' when '${trigger}' is present`)};`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return lines;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Strict-mode `dependentSchemas` (2020-12): when a trigger property is present,
|
|
185
|
+
* the *whole object* must also match the associated subschema. A `true`
|
|
186
|
+
* subschema is a no-op; a `false` subschema makes the trigger's presence always
|
|
187
|
+
* invalid. Object subschemas are enforced via {@link subschemaMatchExpr}; a
|
|
188
|
+
* subschema it cannot match inline is rejected at generation time by the guard.
|
|
189
|
+
*/
|
|
190
|
+
const generateDependentSchemasChecks = (obj, schema, label) => {
|
|
191
|
+
const dep = schema['dependentSchemas'];
|
|
192
|
+
if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
|
|
193
|
+
return [];
|
|
194
|
+
const lines = [];
|
|
195
|
+
for (const [trigger, sub] of Object.entries(dep)) {
|
|
196
|
+
if (sub === true)
|
|
197
|
+
continue;
|
|
198
|
+
if (sub === false) {
|
|
199
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj}) ${throwError(`${label} must NOT have property '${trigger}'`)};`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const match = subschemaMatchExpr(obj, sub);
|
|
203
|
+
if (match === null || match === 'true')
|
|
204
|
+
continue;
|
|
205
|
+
lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${match})) ${throwError(`${label} does not satisfy the schema required when '${trigger}' is present`)};`);
|
|
206
|
+
}
|
|
207
|
+
return lines;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Resolves a tuple position's schema through a single `$ref` (via `rootSchema`)
|
|
211
|
+
* so the assertion can read its `type`/`enum`. A `$ref` with no resolvable target
|
|
212
|
+
* (or no root document) is returned as-is, leaving the position unasserted.
|
|
213
|
+
*/
|
|
214
|
+
const resolvePositionSchema = (pos, rootSchema) => {
|
|
215
|
+
if (isSchemaObject(pos) && hasRef(pos) && rootSchema) {
|
|
216
|
+
const resolved = resolveRef(pos.$ref, rootSchema);
|
|
217
|
+
if (resolved)
|
|
218
|
+
return resolved;
|
|
219
|
+
}
|
|
220
|
+
return pos;
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Strict-mode assertion lines for a tuple `prefixItems`: each present position is
|
|
224
|
+
* asserted against its subschema (a scalar type, an enum, or a `$ref`/inline
|
|
225
|
+
* schema resolved to one via `rootSchema`), and a sibling `items: false` /
|
|
226
|
+
* `additionalItems: false` rejects any extra element. `acc` is the array
|
|
227
|
+
* accessor and `field` the error-message prefix (`[Type] field 'k'` or
|
|
228
|
+
* `[Type]`). Mirrors the validators' tuple pass, but throws on the first
|
|
229
|
+
* violation instead of collecting errors. Positions whose schema is richer than
|
|
230
|
+
* a scalar/enum are left to their own downstream handling (like array `items`).
|
|
231
|
+
*/
|
|
232
|
+
const generatePrefixItemsAssertion = (acc, field, schema, rootSchema) => {
|
|
233
|
+
const prefix = getPrefixItems(schema);
|
|
234
|
+
if (!prefix)
|
|
235
|
+
return [];
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
238
|
+
const pos = resolvePositionSchema(prefix[i], rootSchema);
|
|
239
|
+
const el = `${acc}[${i}]`;
|
|
240
|
+
// A shorter input simply has no element at this position — `prefixItems`
|
|
241
|
+
// does not require presence (that is `minItems`' job), so guard on length.
|
|
242
|
+
const present = `Array.isArray(${acc}) && ${acc}.length > ${i}`;
|
|
243
|
+
if (isSchemaObject(pos) && hasEnum(pos) && pos.enum.length > 0) {
|
|
244
|
+
const allowed = JSON.stringify(pos.enum);
|
|
245
|
+
const label = pos.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
246
|
+
lines.push(` if (${present} && !(${allowed} as readonly unknown[]).includes(${el})) ${throwError(`${field}[${i}] must be one of: ${label}`)};`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (isSchemaObject(pos) && hasType(pos)) {
|
|
250
|
+
const pt = pos.type;
|
|
251
|
+
const wrong = wrongTypeCondition(el, pt);
|
|
252
|
+
if (wrong) {
|
|
253
|
+
lines.push(` if (${present} && (${wrong})) ${throwError(`${field}[${i}] expected ${typeLabel(pt)}, got `, `typeof ${el}`)};`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (prefixItemsCapsLength(schema)) {
|
|
258
|
+
lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${prefix.length}) ${throwError(`${field} must NOT have more than ${prefix.length} items`)};`);
|
|
259
|
+
}
|
|
260
|
+
return lines;
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Strict-mode `propertyNames`: every own key of the object (keys are always
|
|
264
|
+
* strings) must satisfy the name subschema. A trivially-true matcher imposes no
|
|
265
|
+
* constraint, so no loop is emitted; an unmatchable subschema is rejected at
|
|
266
|
+
* generation time by the guard.
|
|
267
|
+
*/
|
|
268
|
+
const generatePropertyNameChecks = (obj, schema, label) => {
|
|
269
|
+
if (!hasPropertyNames(schema) || !isSchemaObject(schema.propertyNames))
|
|
270
|
+
return [];
|
|
271
|
+
const match = subschemaMatchExpr('_name', schema.propertyNames);
|
|
272
|
+
if (match === null || match === 'true')
|
|
273
|
+
return [];
|
|
274
|
+
return [
|
|
275
|
+
` for (const _name of Object.keys(${obj})) {`,
|
|
276
|
+
` if (!(${match})) ${throwError(`${label} invalid property name: `, '_name')};`,
|
|
277
|
+
` }`,
|
|
278
|
+
];
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Emits the object-level keyword checks (`dependentRequired`, `dependentSchemas`,
|
|
282
|
+
* `propertyNames`) for the object reached by `obj`. When `guard` is true the
|
|
283
|
+
* whole block is wrapped in a runtime `isObject` check — used for object-typed
|
|
284
|
+
* *properties*, whose value may be absent or a non-object (the type check
|
|
285
|
+
* reports that separately). The root object and inline sub-parsers pass
|
|
286
|
+
* `guard = false` because the enclosing parser has already proven the value is
|
|
287
|
+
* an object.
|
|
288
|
+
*/
|
|
289
|
+
export const generateObjectKeywordChecks = (obj, schema, label, guard) => {
|
|
290
|
+
if (!isSchemaObject(schema))
|
|
291
|
+
return [];
|
|
292
|
+
const inner = [
|
|
293
|
+
...generateDependentRequiredChecks(obj, schema, label),
|
|
294
|
+
...generateDependentSchemasChecks(obj, schema, label),
|
|
295
|
+
...generatePropertyNameChecks(obj, schema, label),
|
|
296
|
+
];
|
|
297
|
+
if (inner.length === 0)
|
|
298
|
+
return [];
|
|
299
|
+
if (!guard)
|
|
300
|
+
return inner;
|
|
301
|
+
return [` if (isObject(${obj})) {`, ...inner.map((line) => ` ${line}`), ` }`];
|
|
302
|
+
};
|
|
134
303
|
/**
|
|
135
304
|
* Generates strict-mode lines for a single property of an object schema.
|
|
136
305
|
* Properties with a `$ref` are skipped here — the nested parser handles its own
|
|
@@ -205,7 +374,24 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
205
374
|
lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
|
|
206
375
|
}
|
|
207
376
|
}
|
|
208
|
-
lines.push(...generateConstraintChecks(acc, propSchema,
|
|
377
|
+
lines.push(...generateConstraintChecks(acc, propSchema, field, context));
|
|
378
|
+
}
|
|
379
|
+
// Type-independent constraints, each runtime-gated on the value's shape:
|
|
380
|
+
// - `contains` asserts on arrays (no-op otherwise),
|
|
381
|
+
// - the object-level keywords assert on objects.
|
|
382
|
+
// Gated on a single `isSchemaObject` + a few `in` checks so a plain scalar
|
|
383
|
+
// property — the common case — skips the costlier `isInlineObjectProperty`
|
|
384
|
+
// probe and the check builders entirely. Inline object properties are
|
|
385
|
+
// deep-validated by their own sub-parser (which runs these same object-level
|
|
386
|
+
// checks), so emitting them here too would be premature and redundant.
|
|
387
|
+
if (isSchemaObject(propSchema)) {
|
|
388
|
+
const r = propSchema;
|
|
389
|
+
if ('contains' in r)
|
|
390
|
+
lines.push(...generateContainsCheck(acc, propSchema, field));
|
|
391
|
+
if (('dependentRequired' in r || 'dependentSchemas' in r || 'propertyNames' in r) &&
|
|
392
|
+
!isInlineObjectProperty(propSchema)) {
|
|
393
|
+
lines.push(...generateObjectKeywordChecks(acc, propSchema, field, true));
|
|
394
|
+
}
|
|
209
395
|
}
|
|
210
396
|
return lines;
|
|
211
397
|
};
|
|
@@ -223,12 +409,23 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
223
409
|
export const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
|
|
224
410
|
const lines = [];
|
|
225
411
|
lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
|
|
226
|
-
if (!
|
|
412
|
+
if (!isSchemaObject(schema))
|
|
227
413
|
return lines;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
414
|
+
if (hasProperties(schema)) {
|
|
415
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
416
|
+
const props = schema.properties;
|
|
417
|
+
for (const key in props) {
|
|
418
|
+
lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Object-level keywords for the object itself. `input` is already proven an
|
|
422
|
+
// object above, so no runtime guard is needed. Emitted even when the schema
|
|
423
|
+
// has no `properties` (e.g. a constrained-key map: `{ type: 'object',
|
|
424
|
+
// propertyNames: {...} }`). Gated on a cheap presence check so a keyword-free
|
|
425
|
+
// object skips the three check builders.
|
|
426
|
+
const sr = schema;
|
|
427
|
+
if ('dependentRequired' in sr || 'dependentSchemas' in sr || 'propertyNames' in sr) {
|
|
428
|
+
lines.push(...generateObjectKeywordChecks('input', schema, `[${typeName}]`, false));
|
|
232
429
|
}
|
|
233
430
|
return lines;
|
|
234
431
|
};
|
|
@@ -236,7 +433,7 @@ export const generateObjectStrictAssertion = (schema, typeName, context = {}) =>
|
|
|
236
433
|
* Generates a single strict-mode assertion line for a non-object scalar parser.
|
|
237
434
|
* Returns null when the schema has no type information to assert on.
|
|
238
435
|
*/
|
|
239
|
-
export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
436
|
+
export const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
|
|
240
437
|
const got = 'input === null ? "null" : typeof input';
|
|
241
438
|
const instanceOf = getMjstInstanceOf(schema);
|
|
242
439
|
if (instanceOf) {
|
|
@@ -246,20 +443,45 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
|
246
443
|
if (primitive) {
|
|
247
444
|
return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
|
|
248
445
|
}
|
|
249
|
-
if (!isSchemaObject(schema)
|
|
446
|
+
if (!isSchemaObject(schema))
|
|
250
447
|
return null;
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
448
|
+
const label = `[${typeName}]`;
|
|
449
|
+
const lines = [];
|
|
450
|
+
// `const` / `enum` apply to a root value regardless of a declared `type` — a
|
|
451
|
+
// root `{ enum: [...] }` or `{ const: ... }` must reject a non-member, not
|
|
452
|
+
// silently coerce it. `const` is compared through `JSON.stringify` so it works
|
|
453
|
+
// for any JSON value; the value must match exactly.
|
|
454
|
+
if (hasConst(schema)) {
|
|
455
|
+
const wanted = JSON.stringify(JSON.stringify(schema.const));
|
|
456
|
+
lines.push(` if (JSON.stringify(input) !== ${wanted}) ${throwError(`${label} must be ${JSON.stringify(schema.const)}`)};`);
|
|
457
|
+
}
|
|
458
|
+
else if (hasEnum(schema)) {
|
|
459
|
+
const allowed = JSON.stringify(schema.enum);
|
|
460
|
+
const enumLabel = schema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
461
|
+
lines.push(` if (!(${allowed} as readonly unknown[]).includes(input)) ${throwError(`${label} must be one of: ${enumLabel}`)};`);
|
|
462
|
+
}
|
|
463
|
+
if (hasType(schema)) {
|
|
464
|
+
const t = schema.type;
|
|
465
|
+
const wrongType = wrongTypeCondition('input', t);
|
|
466
|
+
if (wrongType) {
|
|
467
|
+
lines.push(` if (${wrongType}) ${throwError(`${label} expected ${typeLabel(t)}, got `, got)};`);
|
|
468
|
+
}
|
|
469
|
+
// Root-level arrays enforce scalar/enum item types too — the same gap the
|
|
470
|
+
// property path closes in generateConstraintChecks — plus `contains`.
|
|
471
|
+
if (t === 'array') {
|
|
472
|
+
const itemCheck = generateItemCheck(schema);
|
|
473
|
+
if (itemCheck) {
|
|
474
|
+
lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`${label} ${itemCheck.message}`)};`);
|
|
475
|
+
}
|
|
476
|
+
lines.push(...generateContainsCheck('input', schema, label));
|
|
477
|
+
// Tuple `prefixItems`: assert each position and cap length under items:false.
|
|
478
|
+
lines.push(...generatePrefixItemsAssertion('input', label, schema, rootSchema));
|
|
262
479
|
}
|
|
480
|
+
// String (pattern, min/maxLength), number/integer (bounds, multipleOf) and
|
|
481
|
+
// array (length, uniqueItems) constraints — previously enforced only for
|
|
482
|
+
// named properties, so a root scalar accepted e.g. `{type:'string',minLength:5}`
|
|
483
|
+
// violations.
|
|
484
|
+
lines.push(...generateConstraintChecks('input', schema, label, rootSchema ? { rootSchema } : {}));
|
|
263
485
|
}
|
|
264
|
-
return lines.join('\n');
|
|
486
|
+
return lines.length > 0 ? lines.join('\n') : null;
|
|
265
487
|
};
|
|
@@ -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':
|