@amritk/generate-parsers 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generators/build-schema.d.ts +6 -1
- package/dist/generators/build-schema.js +20 -5
- package/dist/generators/generate-files.d.ts +8 -0
- package/dist/generators/generate-files.js +12 -5
- package/dist/generators/generate-parser-function.d.ts +9 -1
- package/dist/generators/generate-parser-function.js +297 -80
- package/dist/generators/generate-strict-assertion.js +14 -8
- package/dist/generators/generate-type-checks.d.ts +17 -0
- package/dist/generators/generate-type-checks.js +77 -9
- package/dist/generators/generate-validation-expression.d.ts +7 -0
- package/dist/generators/generate-validation-expression.js +17 -5
- package/dist/helpers/collect-helpers.d.ts +1 -9
- package/dist/helpers/collect-helpers.js +36 -9
- package/dist/helpers/collect-imports.d.ts +15 -0
- package/dist/helpers/collect-imports.js +36 -14
- package/dist/index.d.ts +1 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
2
|
import type { HelpersMode } from '../helpers/collect-helpers.js';
|
|
3
|
+
import type { ImportExtension } from '../helpers/collect-imports.js';
|
|
3
4
|
import type { SchemaExtensions } from '../types/schema-extensions.js';
|
|
4
5
|
/**
|
|
5
6
|
* Represents a generated file with its filename and content.
|
|
@@ -45,6 +46,10 @@ export type GeneratedFile = {
|
|
|
45
46
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
46
47
|
* (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
47
48
|
* is used verbatim and is not affected by this suffix.
|
|
49
|
+
* @param importExt - Extension used on every relative import specifier in the generated
|
|
50
|
+
* output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
|
|
51
|
+
* `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the literal on-disk
|
|
52
|
+
* paths so the generated sources run directly under Node's type stripping.
|
|
48
53
|
* @returns An array of generated TypeScript files
|
|
49
54
|
*
|
|
50
55
|
* @example
|
|
@@ -77,4 +82,4 @@ export type GeneratedFile = {
|
|
|
77
82
|
* });
|
|
78
83
|
* ```
|
|
79
84
|
*/
|
|
80
|
-
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
85
|
+
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension) => Promise<GeneratedFile[]>;
|
|
@@ -16,7 +16,7 @@ import { generateFile } from './generate-files.js';
|
|
|
16
16
|
* on this lookup), it falls back to the always-published compiled `dist/<helper>.js`
|
|
17
17
|
* — which tsc already emits with `.js`-extension imports — and writes it verbatim.
|
|
18
18
|
*/
|
|
19
|
-
const readHelperSource = async (helper) => {
|
|
19
|
+
const readHelperSource = async (helper, importExt) => {
|
|
20
20
|
const require = createRequire(import.meta.url);
|
|
21
21
|
const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
|
|
22
22
|
try {
|
|
@@ -31,10 +31,17 @@ const readHelperSource = async (helper) => {
|
|
|
31
31
|
// never matches) and is also semantically correct: an import keyword always
|
|
32
32
|
// sits at a word boundary. The dist smoke test (scripts/dist-smoke.test.ts)
|
|
33
33
|
// fails the build if this ever regresses.
|
|
34
|
-
return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g,
|
|
34
|
+
return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, `from '$1.${importExt}'`) };
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
37
|
const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
|
|
38
|
+
// With `.ts` import specifiers the on-disk file must literally be `.ts`, so
|
|
39
|
+
// ship the compiled JS (valid TS) under that name and retarget its own
|
|
40
|
+
// sibling imports to match. The pattern keeps the same `\bfrom` shape as
|
|
41
|
+
// above so tsc-alias's scanner can't corrupt it (see the comment there).
|
|
42
|
+
if (importExt === 'ts') {
|
|
43
|
+
return { ext: 'ts', content: compiled.replace(/\bfrom '(\.\/[^'"]+)\.js'/g, "from '$1.ts'") };
|
|
44
|
+
}
|
|
38
45
|
return { ext: 'js', content: compiled };
|
|
39
46
|
}
|
|
40
47
|
};
|
|
@@ -75,6 +82,10 @@ const readHelperSource = async (helper) => {
|
|
|
75
82
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
76
83
|
* (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
77
84
|
* is used verbatim and is not affected by this suffix.
|
|
85
|
+
* @param importExt - Extension used on every relative import specifier in the generated
|
|
86
|
+
* output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
|
|
87
|
+
* `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the literal on-disk
|
|
88
|
+
* paths so the generated sources run directly under Node's type stripping.
|
|
78
89
|
* @returns An array of generated TypeScript files
|
|
79
90
|
*
|
|
80
91
|
* @example
|
|
@@ -107,7 +118,7 @@ const readHelperSource = async (helper) => {
|
|
|
107
118
|
* });
|
|
108
119
|
* ```
|
|
109
120
|
*/
|
|
110
|
-
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '') => {
|
|
121
|
+
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js') => {
|
|
111
122
|
const files = [];
|
|
112
123
|
const usedHelpers = new Set();
|
|
113
124
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
@@ -125,6 +136,7 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
125
136
|
helpersImportPrefix,
|
|
126
137
|
readonly,
|
|
127
138
|
typeSuffix,
|
|
139
|
+
importExt,
|
|
128
140
|
...(node.ref !== undefined ? { selfRef: node.ref } : {}),
|
|
129
141
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
130
142
|
...(strict !== undefined ? { strict } : {}),
|
|
@@ -139,10 +151,13 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
139
151
|
// typesOnly skips parser generation entirely, so no runtime helpers are needed.
|
|
140
152
|
if (helpersMode === 'embedded' && !typesOnly) {
|
|
141
153
|
for (const helper of usedHelpers) {
|
|
142
|
-
const { ext, content } = await readHelperSource(helper);
|
|
154
|
+
const { ext, content } = await readHelperSource(helper, importExt);
|
|
143
155
|
files.push({ filename: `_helpers/${helper}.${ext}`, content });
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
|
-
files.push({
|
|
158
|
+
files.push({
|
|
159
|
+
filename: 'index.ts',
|
|
160
|
+
content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false, importExt }),
|
|
161
|
+
});
|
|
147
162
|
return files;
|
|
148
163
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
2
|
import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
|
|
3
|
+
import { type ImportExtension } from '../helpers/collect-imports.js';
|
|
3
4
|
/**
|
|
4
5
|
* Options for controlling what gets generated in a file.
|
|
5
6
|
*/
|
|
@@ -63,6 +64,13 @@ type GenerateFileOptions = {
|
|
|
63
64
|
* Defaults to `''` (no suffix). Set to e.g. `'Object'` to emit `ContactObject`.
|
|
64
65
|
*/
|
|
65
66
|
readonly typeSuffix?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Extension used on every relative import specifier (cross-file `$ref`
|
|
69
|
+
* imports and embedded-helper imports). Defaults to `'js'` (the TS NodeNext
|
|
70
|
+
* form); `'ts'` makes the generated sources runnable under Node's type
|
|
71
|
+
* stripping.
|
|
72
|
+
*/
|
|
73
|
+
readonly importExt?: ImportExtension;
|
|
66
74
|
};
|
|
67
75
|
/** Result of generating a single parser file. */
|
|
68
76
|
export type GeneratedFileContent = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition';
|
|
2
2
|
import { collectHelpers, } from '../helpers/collect-helpers.js';
|
|
3
|
-
import { collectImports } from '../helpers/collect-imports.js';
|
|
3
|
+
import { collectImports, collectImportTypeNames } from '../helpers/collect-imports.js';
|
|
4
4
|
import { generateParserFunction, generateShapeValidator } from './generate-parser-function.js';
|
|
5
5
|
/**
|
|
6
6
|
* Generates a complete TypeScript file from a JSON Schema.
|
|
@@ -37,10 +37,11 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
37
37
|
const rootSchema = options?.rootSchema;
|
|
38
38
|
const helpersMode = options?.helpersMode ?? 'package';
|
|
39
39
|
const typeSuffix = options?.typeSuffix ?? '';
|
|
40
|
+
const importExt = options?.importExt ?? 'js';
|
|
40
41
|
const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false, typeSuffix });
|
|
41
42
|
if (typesOnly) {
|
|
42
43
|
// In types-only mode, skip the parser function and use type-only imports
|
|
43
|
-
const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema, typeSuffix });
|
|
44
|
+
const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema, typeSuffix, importExt });
|
|
44
45
|
let result = '';
|
|
45
46
|
if (imports.length > 0) {
|
|
46
47
|
result += imports[0];
|
|
@@ -52,18 +53,24 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
52
53
|
return { content: result + typeDefinition, usedHelpers: new Set() };
|
|
53
54
|
}
|
|
54
55
|
const stripUnknown = options?.stripUnknown ?? false;
|
|
56
|
+
// Names this file imports for its $refs: synthesized private sub-type names
|
|
57
|
+
// dedup against them so they can never shadow an imported identifier. The
|
|
58
|
+
// parser and the shape validator must receive the SAME set — their derived
|
|
59
|
+
// sub-names have to agree.
|
|
60
|
+
const reservedNames = collectImportTypeNames(schema, { selfRef, rootSchema, typeSuffix });
|
|
55
61
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
56
62
|
useRefImports: true,
|
|
57
63
|
typeSuffix,
|
|
64
|
+
reservedNames,
|
|
58
65
|
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
59
66
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
60
67
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
61
68
|
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
62
69
|
});
|
|
63
|
-
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown);
|
|
70
|
+
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
|
|
64
71
|
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
|
|
65
|
-
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
|
|
66
|
-
const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix }), ...helpers.imports];
|
|
72
|
+
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
|
|
73
|
+
const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix, importExt }), ...helpers.imports];
|
|
67
74
|
// Build file output using string concatenation instead of array join for performance
|
|
68
75
|
let result = '';
|
|
69
76
|
if (imports.length > 0) {
|
|
@@ -42,6 +42,14 @@ type GenerateParserOptions = {
|
|
|
42
42
|
* dispatch to the branch parsers, instead of falling back to a passthrough cast.
|
|
43
43
|
*/
|
|
44
44
|
readonly rootSchema?: Record<string, unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* Type names the generated file imports for its `$ref`s. Synthesized private
|
|
47
|
+
* sub-type names (nested objects, array items, the root array's `{Type}Item`)
|
|
48
|
+
* dedup against this set so they can never shadow an imported identifier —
|
|
49
|
+
* which would both fail to compile (duplicate declaration) and silently
|
|
50
|
+
* validate against the wrong schema.
|
|
51
|
+
*/
|
|
52
|
+
readonly reservedNames?: ReadonlySet<string>;
|
|
45
53
|
};
|
|
46
54
|
/**
|
|
47
55
|
* Generates a safe parser as an arrow function that never throws.
|
|
@@ -71,5 +79,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
|
|
|
71
79
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
72
80
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
73
81
|
*/
|
|
74
|
-
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean) => string;
|
|
82
|
+
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean, reservedNames?: ReadonlySet<string>) => string;
|
|
75
83
|
export {};
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
|
+
import { quoteJsString } from '@amritk/helpers/quote-js-string';
|
|
2
3
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
4
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
4
5
|
import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
|
|
5
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
6
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
6
7
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
7
8
|
import { findDiscriminator } from '../helpers/find-discriminator.js';
|
|
8
9
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
9
10
|
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
10
11
|
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
|
|
11
|
-
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, shapeValidatorName, } from './generate-type-checks.js';
|
|
12
|
-
import { generateValidationExpression,
|
|
12
|
+
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName, } from './generate-type-checks.js';
|
|
13
|
+
import { generateValidationExpression, isCoercibleItemSchema } from './generate-validation-expression.js';
|
|
13
14
|
/**
|
|
14
15
|
* Generates the parser function name from a type name.
|
|
15
16
|
* Converts "UserObject" to "parseUserObject".
|
|
@@ -350,7 +351,7 @@ const generateRefUnionDispatch = (functionName, typeName, branches, strict, ctx)
|
|
|
350
351
|
// first branch's parser, preserving the coercing parser's lenient contract while
|
|
351
352
|
// still running real validation/coercion instead of a blind cast.
|
|
352
353
|
const fallback = strict
|
|
353
|
-
? `(() => { throw new Error(${
|
|
354
|
+
? `(() => { throw new Error(${quoteJsString(`[${typeName}] value does not match any union branch`)}); })()`
|
|
354
355
|
: `${cases[0]?.parser}(input)`;
|
|
355
356
|
let expr = fallback;
|
|
356
357
|
for (let i = cases.length - 1; i >= 0; i--) {
|
|
@@ -394,7 +395,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
394
395
|
if (branches && !unionCtx.stripUnknown && canEnforceUnion(branches, unionCtx.rootSchema)) {
|
|
395
396
|
const check = generateUnionCheck('input', branches, unionCtx.useRefImports, unionCtx.suffix);
|
|
396
397
|
if (check !== null) {
|
|
397
|
-
return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!(${check})) throw new Error(${
|
|
398
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!(${check})) throw new Error(${quoteJsString(`[${typeName}] value does not match any union branch`)});\n return input as ${typeName};\n};`;
|
|
398
399
|
}
|
|
399
400
|
}
|
|
400
401
|
}
|
|
@@ -437,6 +438,50 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
437
438
|
return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
|
|
438
439
|
}
|
|
439
440
|
}
|
|
441
|
+
// Root-level arrays with rich item schemas delegate every element to a real
|
|
442
|
+
// parser, so nested enums and $refs inside array items are validated instead
|
|
443
|
+
// of spread through unchecked. $ref items call the imported parser; inline
|
|
444
|
+
// object items get a private item sub-parser in the same file. (Scalar and
|
|
445
|
+
// enum items are covered below: the lax switch coerces them element-wise and
|
|
446
|
+
// generateScalarStrictAssertion enforces them in strict mode.)
|
|
447
|
+
if (isSchemaObject(schema) &&
|
|
448
|
+
hasType(schema) &&
|
|
449
|
+
schema.type === 'array' &&
|
|
450
|
+
hasItems(schema) &&
|
|
451
|
+
!Array.isArray(schema.items)) {
|
|
452
|
+
const items = schema.items;
|
|
453
|
+
const notArrayThrow = `if (!Array.isArray(input)) throw new Error(\`[${typeName}] expected array, got \${input === null ? "null" : typeof input}\`);`;
|
|
454
|
+
// validateArray identity-returns the input array when every element parses
|
|
455
|
+
// to itself; this parser is EXPORTED, and exported parsers never alias the
|
|
456
|
+
// value the caller passed in (matching the scalar root-array path's
|
|
457
|
+
// `[...input]` copy), so materialize a copy exactly when that happens —
|
|
458
|
+
// element references are still shared, like every other fast path.
|
|
459
|
+
const delegatedBody = (itemParserName) => ` const _parsed = validateArray(input, ${itemParserName});\n return (_parsed === input ? [..._parsed] : _parsed) as ${typeName};`;
|
|
460
|
+
const delegated = (itemParserName) => strict
|
|
461
|
+
? `export const ${functionName} = (input: unknown): ${typeName} => {\n ${notArrayThrow}\n${delegatedBody(itemParserName)}\n};`
|
|
462
|
+
: `export const ${functionName} = (input: unknown): ${typeName} => {\n${delegatedBody(itemParserName)}\n};`;
|
|
463
|
+
if (unionCtx?.useRefImports && isSchemaObject(items) && hasRef(items)) {
|
|
464
|
+
return delegated(generateParserName(refToName(items.$ref, unionCtx.suffix)));
|
|
465
|
+
}
|
|
466
|
+
if (isInlineObjectProperty(items)) {
|
|
467
|
+
const reserved = unionCtx?.reservedNames ?? NO_RESERVED_NAMES;
|
|
468
|
+
// Dedup against imported identifiers: a root `List` whose items carry a
|
|
469
|
+
// $ref to `#/$defs/listItem` imports parseListItem — a bare `ListItem`
|
|
470
|
+
// here would shadow it (TS2440) and self-recurse instead of delegating.
|
|
471
|
+
let itemName = `${typeName}_Item`;
|
|
472
|
+
while (reserved.has(itemName))
|
|
473
|
+
itemName = `${itemName}_`;
|
|
474
|
+
const useRefImports = unionCtx?.useRefImports ?? false;
|
|
475
|
+
const suffix = unionCtx?.suffix ?? '';
|
|
476
|
+
const stripUnknown = unionCtx?.stripUnknown ?? false;
|
|
477
|
+
const preamble = [
|
|
478
|
+
`type ${itemName} = ${typeName}[number];`,
|
|
479
|
+
generateShapeValidator(items, itemName, useRefImports, suffix, false, stripUnknown, reserved),
|
|
480
|
+
generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved),
|
|
481
|
+
].join('\n\n');
|
|
482
|
+
return `${preamble}\n\n${delegated(generateParserName(itemName))}`;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
440
485
|
if (!isSchemaObject(schema) || !hasType(schema)) {
|
|
441
486
|
// Schema without type information cannot be validated beyond a cast
|
|
442
487
|
return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
|
|
@@ -463,8 +508,8 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
463
508
|
case 'boolean':
|
|
464
509
|
return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
|
|
465
510
|
case 'array': {
|
|
466
|
-
// Coerce each element when the item schema is a single scalar type.
|
|
467
|
-
if (hasItems(schema) && !Array.isArray(schema.items) &&
|
|
511
|
+
// Coerce each element when the item schema is a single scalar type or an enum.
|
|
512
|
+
if (hasItems(schema) && !Array.isArray(schema.items) && isCoercibleItemSchema(schema.items)) {
|
|
468
513
|
const item = schema.items;
|
|
469
514
|
const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true);
|
|
470
515
|
return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? (input as unknown[]).map((_it) => ${itemExpr}) as ${typeName} : [] as ${typeName};`;
|
|
@@ -495,56 +540,55 @@ const toVarName = (key) => {
|
|
|
495
540
|
const safe = key.replace(/[^a-zA-Z0-9_$]/g, '_');
|
|
496
541
|
return `_${safe}`;
|
|
497
542
|
};
|
|
543
|
+
/** PascalCases a property key for use in a synthesized sub-type name. */
|
|
544
|
+
const pascalCaseKey = (key) => key
|
|
545
|
+
.replace(/[^a-zA-Z0-9_$]/g, '_')
|
|
546
|
+
.split('_')
|
|
547
|
+
.filter((part) => part.length > 0)
|
|
548
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
549
|
+
.join('');
|
|
550
|
+
/** Shared empty reserved-name set for callers with no import context. */
|
|
551
|
+
const NO_RESERVED_NAMES = new Set();
|
|
498
552
|
/**
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* generated file so their fields are actually parsed; anything involving
|
|
503
|
-
* composition, conditionals, enums, or record semantics keeps the existing
|
|
504
|
-
* code paths.
|
|
505
|
-
*/
|
|
506
|
-
const isInlineObjectProperty = (propSchema) => {
|
|
507
|
-
if (!isSchemaObject(propSchema))
|
|
508
|
-
return false;
|
|
509
|
-
if (hasRef(propSchema) || hasEnum(propSchema) || hasConst(propSchema))
|
|
510
|
-
return false;
|
|
511
|
-
if (hasOneOf(propSchema) || hasAnyOf(propSchema) || hasAllOf(propSchema))
|
|
512
|
-
return false;
|
|
513
|
-
if ('patternProperties' in propSchema || 'not' in propSchema || 'if' in propSchema)
|
|
514
|
-
return false;
|
|
515
|
-
// additionalProperties-as-schema records go through the record paths instead
|
|
516
|
-
if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
|
|
517
|
-
return false;
|
|
518
|
-
return isObjectSchema(propSchema) && hasProperties(propSchema);
|
|
519
|
-
};
|
|
520
|
-
/**
|
|
521
|
-
* Builds the property-key → synthesized-type-name map for every inline nested
|
|
522
|
-
* object property of a schema, e.g. parent `Order` + key `shipTo` →
|
|
523
|
-
* `OrderShipTo`. Both the parser and the shape validator derive the map
|
|
524
|
-
* independently, so the naming (including collision suffixes) is a pure
|
|
525
|
-
* function of the schema and parent type name.
|
|
553
|
+
* Shared empty result for the common no-inline-sub-types case, so the per-node
|
|
554
|
+
* calls (parser and validator, for every object node, on every generation)
|
|
555
|
+
* allocate nothing. Callers only read from the maps.
|
|
526
556
|
*/
|
|
527
|
-
const
|
|
528
|
-
|
|
557
|
+
const EMPTY_SUB_TYPES = { objects: new Map(), arrayItems: new Map() };
|
|
558
|
+
const collectInlineSubTypes = (schema, typeName, reservedNames = NO_RESERVED_NAMES) => {
|
|
529
559
|
if (!hasProperties(schema))
|
|
530
|
-
return
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
560
|
+
return EMPTY_SUB_TYPES;
|
|
561
|
+
const props = schema.properties;
|
|
562
|
+
// Single pass with lazy allocation: most object nodes have neither inline
|
|
563
|
+
// object properties nor inline-object array items, and this runs for every
|
|
564
|
+
// node in both the parser and the validator — so nothing is allocated until
|
|
565
|
+
// the first match, and the classification predicates run exactly once per
|
|
566
|
+
// property (an eager pre-scan would re-evaluate them for matching schemas).
|
|
567
|
+
let objects = null;
|
|
568
|
+
let arrayItems = null;
|
|
569
|
+
let used = null;
|
|
570
|
+
const claim = (base) => {
|
|
571
|
+
used ??= new Set();
|
|
572
|
+
let subName = base;
|
|
573
|
+
while (used.has(subName) || reservedNames.has(subName))
|
|
543
574
|
subName = `${subName}_`;
|
|
544
575
|
used.add(subName);
|
|
545
|
-
|
|
576
|
+
return subName;
|
|
577
|
+
};
|
|
578
|
+
for (const key in props) {
|
|
579
|
+
const propSchema = props[key];
|
|
580
|
+
if (isInlineObjectProperty(propSchema)) {
|
|
581
|
+
objects ??= new Map();
|
|
582
|
+
objects.set(key, claim(`${typeName}_${pascalCaseKey(key) || 'Value'}`));
|
|
583
|
+
}
|
|
584
|
+
else if (isInlineObjectArrayProperty(propSchema)) {
|
|
585
|
+
arrayItems ??= new Map();
|
|
586
|
+
arrayItems.set(key, claim(`${typeName}_${pascalCaseKey(key) || 'Value'}Item`));
|
|
587
|
+
}
|
|
546
588
|
}
|
|
547
|
-
|
|
589
|
+
if (objects === null && arrayItems === null)
|
|
590
|
+
return EMPTY_SUB_TYPES;
|
|
591
|
+
return { objects: objects ?? EMPTY_SUB_TYPES.objects, arrayItems: arrayItems ?? EMPTY_SUB_TYPES.arrayItems };
|
|
548
592
|
};
|
|
549
593
|
/**
|
|
550
594
|
* True when the parser must reject (strict mode) or strip (coerce mode) every
|
|
@@ -562,6 +606,44 @@ const hasStrictKeys = (schema) => {
|
|
|
562
606
|
return false;
|
|
563
607
|
return true;
|
|
564
608
|
};
|
|
609
|
+
/**
|
|
610
|
+
* When every declared property is required (and every required key is
|
|
611
|
+
* declared, with a real schema), the fast-path known-keys test can be an
|
|
612
|
+
* own-key *count* comparison instead of a per-key `for..in` walk: the typed
|
|
613
|
+
* property checks already prove all N declared keys are present, so
|
|
614
|
+
* `Object.keys(input).length === N` proves there are no undeclared extras —
|
|
615
|
+
* measurably cheaper on the hot path. Returns the declared-key count, or null
|
|
616
|
+
* when the cheaper form would be unsound (an optional or non-schema property
|
|
617
|
+
* breaks the presence proof).
|
|
618
|
+
*/
|
|
619
|
+
const exactKeyCountOf = (schema) => {
|
|
620
|
+
if (!isSchemaObject(schema) || !hasProperties(schema) || !hasRequired(schema))
|
|
621
|
+
return null;
|
|
622
|
+
const props = schema.properties;
|
|
623
|
+
const required = schema.required;
|
|
624
|
+
// Allocation-free for the common small object: this runs for every object
|
|
625
|
+
// node, in both the parser and the validator, on every generation, and the
|
|
626
|
+
// O(n²) `includes` beats building a Set for a handful of keys. Wide schemas
|
|
627
|
+
// (generated API models) flip to a one-time Set so a 200-property object
|
|
628
|
+
// doesn't pay ~20k string comparisons per node.
|
|
629
|
+
const requiredLookup = required.length > 16 ? new Set(required) : null;
|
|
630
|
+
let declaredCount = 0;
|
|
631
|
+
for (const key in props) {
|
|
632
|
+
declaredCount++;
|
|
633
|
+
// Every declared key must be required, and its check must prove presence —
|
|
634
|
+
// all schema-object checks fail on undefined, but a true/false schema
|
|
635
|
+
// literal emits no check at all.
|
|
636
|
+
const isRequired = requiredLookup !== null ? requiredLookup.has(key) : required.includes(key);
|
|
637
|
+
if (!isRequired || !isSchemaObject(props[key]))
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
// Every declared key is required (above) and the counts match, so the
|
|
641
|
+
// required list is exactly the declared keys (a duplicated required entry
|
|
642
|
+
// would leave some declared key uncovered and fail the loop).
|
|
643
|
+
if (declaredCount === 0 || required.length !== declaredCount)
|
|
644
|
+
return null;
|
|
645
|
+
return declaredCount;
|
|
646
|
+
};
|
|
565
647
|
/**
|
|
566
648
|
* Determines if a property needs a local variable or can be inlined.
|
|
567
649
|
* Schema-object properties are always cached: the slow-path ternary chain
|
|
@@ -582,7 +664,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
|
|
|
582
664
|
* - Fast path that returns input directly when all properties are already valid
|
|
583
665
|
* - Slow path with optimized expressions
|
|
584
666
|
*/
|
|
585
|
-
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema) => {
|
|
667
|
+
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES) => {
|
|
586
668
|
const functionName = generateParserName(typeName);
|
|
587
669
|
const exportPrefix = exported ? 'export ' : '';
|
|
588
670
|
if (!hasProperties(schema)) {
|
|
@@ -591,18 +673,35 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
591
673
|
}
|
|
592
674
|
return `${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => isObject(input) ? input as ${typeName} : {} as ${typeName};`;
|
|
593
675
|
}
|
|
594
|
-
const
|
|
676
|
+
const schemaProps = schema.properties;
|
|
677
|
+
// Keys once, values via lookup — Object.entries allocates a tuple per
|
|
678
|
+
// property and this runs for every object node on every generation.
|
|
679
|
+
const propertyKeys = Object.keys(schemaProps);
|
|
595
680
|
// Inline nested object properties get a private sub-parser (plus shape
|
|
596
681
|
// predicate and type alias) in the same file, so their fields are parsed for
|
|
597
682
|
// real instead of only passing an isObject check. The sub-parser recurses
|
|
598
|
-
// through this same generator, so nesting works to any depth.
|
|
599
|
-
|
|
683
|
+
// through this same generator, so nesting works to any depth. Array
|
|
684
|
+
// properties with inline object items get the same treatment for their
|
|
685
|
+
// element type, so nested enums and $refs *inside array items* are validated
|
|
686
|
+
// too (each element runs through the item sub-parser via validateArray).
|
|
687
|
+
const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
|
|
600
688
|
const preamble = [];
|
|
601
689
|
for (const [key, subName] of subTypeNames) {
|
|
602
|
-
const propSchema =
|
|
690
|
+
const propSchema = schemaProps[key];
|
|
603
691
|
preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
|
|
604
|
-
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
|
|
605
|
-
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema));
|
|
692
|
+
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
|
|
693
|
+
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
|
|
694
|
+
}
|
|
695
|
+
for (const [key, subName] of subItemNames) {
|
|
696
|
+
const propSchema = schemaProps[key];
|
|
697
|
+
const itemSchema = propSchema.items;
|
|
698
|
+
// NonNullable strips the `| undefined` an optional array property carries.
|
|
699
|
+
preamble.push(`type ${subName} = NonNullable<${typeName}[${JSON.stringify(key)}]>[number];`);
|
|
700
|
+
preamble.push(generateShapeValidator(itemSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
|
|
701
|
+
// Hand-rolled loop instead of Array.prototype.every on the guard path — the
|
|
702
|
+
// callback protocol costs a few percent on element-heavy hot paths.
|
|
703
|
+
preamble.push(`const _every${subName} = (arr: readonly unknown[]): boolean => {\n for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;\n return true;\n};`);
|
|
704
|
+
preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
|
|
606
705
|
}
|
|
607
706
|
// additionalProperties: false — the per-key "is this undeclared" test inlines
|
|
608
707
|
// `!==` comparisons for a short key list and hoists a Set only for a long one
|
|
@@ -615,13 +714,25 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
615
714
|
// `strictKeys` makes an extra a hard error (the throw block further down).
|
|
616
715
|
const strictKeys = hasStrictKeys(schema);
|
|
617
716
|
const stripKeys = strictKeys || stripUnknown;
|
|
618
|
-
|
|
619
|
-
|
|
717
|
+
// When every declared property is required, the fast-path no-extras test is
|
|
718
|
+
// the cheaper own-key count (see exactKeyCountOf) and the `_hasOnlyKnownKeys`
|
|
719
|
+
// predicate is not emitted at all — the shape validator derives the same
|
|
720
|
+
// decision from the schema, so the cross-function contract stays in sync.
|
|
721
|
+
const exactKeyCount = stripKeys ? exactKeyCountOf(schema) : null;
|
|
722
|
+
const strictKeyCheck = unknownKeyCheck(propertyKeys, `_knownKeys${typeName}`);
|
|
723
|
+
if (stripKeys && exactKeyCount === null) {
|
|
620
724
|
for (const declaration of strictKeyCheck.declarations) {
|
|
621
725
|
preamble.push(`${declaration};`);
|
|
622
726
|
}
|
|
623
727
|
preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (${strictKeyCheck.isUnknown('_k')}) return false;\n return true;\n};`);
|
|
624
728
|
}
|
|
729
|
+
else if (strict && strictKeys) {
|
|
730
|
+
// The unknown-key throw loop below still needs the hoisted Set (when the
|
|
731
|
+
// key list is long enough to use one).
|
|
732
|
+
for (const declaration of strictKeyCheck.declarations) {
|
|
733
|
+
preamble.push(`${declaration};`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
625
736
|
const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
|
|
626
737
|
// First pass: gather per-property info and decide whether a fast path is
|
|
627
738
|
// possible. A fast path returns the input (or a direct strip-build) for
|
|
@@ -641,7 +752,8 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
641
752
|
// declarations (TS2451). Dedupe by suffixing `_` until unique — the same
|
|
642
753
|
// approach `collectInlineObjectProperties` uses — so each key gets its own var.
|
|
643
754
|
const usedVarNames = new Set();
|
|
644
|
-
for (const
|
|
755
|
+
for (const key of propertyKeys) {
|
|
756
|
+
const propSchema = schemaProps[key];
|
|
645
757
|
const isRequired = isPropertyRequired(key, schema);
|
|
646
758
|
let varName = toVarName(key);
|
|
647
759
|
while (usedVarNames.has(varName))
|
|
@@ -658,10 +770,15 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
658
770
|
}
|
|
659
771
|
const subName = subTypeNames.get(key);
|
|
660
772
|
// Inline nested objects fast-path through their private shape predicate, the
|
|
661
|
-
// same way $ref properties use the imported one.
|
|
662
|
-
|
|
773
|
+
// same way $ref properties use the imported one. Arrays of inline objects
|
|
774
|
+
// additionally prove every element via the private item predicate.
|
|
775
|
+
let check = subName
|
|
663
776
|
? `${shapeValidatorName(subName)}(${varName})`
|
|
664
777
|
: generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
|
|
778
|
+
const itemSubName = subItemNames.get(key);
|
|
779
|
+
if (check !== null && itemSubName) {
|
|
780
|
+
check = `${check} && _every${itemSubName}(${varName})`;
|
|
781
|
+
}
|
|
665
782
|
if (check === null) {
|
|
666
783
|
canFastPath = false;
|
|
667
784
|
}
|
|
@@ -676,9 +793,16 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
676
793
|
// fire on inputs that carry no undeclared key. For strict + additionalProperties:
|
|
677
794
|
// false the cold path rejects extras instead; folding the known-keys term into
|
|
678
795
|
// the guard lets the guard run *before* that rejection, so a valid clean input
|
|
679
|
-
// never pays for the per-property assertions.
|
|
796
|
+
// never pays for the per-property assertions. With every declared property
|
|
797
|
+
// required, the own-key count is equivalent and cheaper than the per-key walk —
|
|
798
|
+
// but only for plain objects: a crafted prototype could satisfy the typed
|
|
799
|
+
// checks through inherited properties while the own-key count still matches,
|
|
800
|
+
// so non-plain inputs route to the slow path, where the for..in walk keeps
|
|
801
|
+
// the historical inherited-key rejection.
|
|
680
802
|
if (stripKeys) {
|
|
681
|
-
fastPathChecks.push(
|
|
803
|
+
fastPathChecks.push(exactKeyCount !== null
|
|
804
|
+
? `Object.getPrototypeOf(input) === Object.prototype && Object.keys(input).length === ${exactKeyCount}`
|
|
805
|
+
: `_hasOnlyKnownKeys${typeName}(input)`);
|
|
682
806
|
}
|
|
683
807
|
// The deep guard proves the whole shape (so `{ ...input }` can be returned),
|
|
684
808
|
// using the nested shape predicates and the known-keys term above.
|
|
@@ -784,6 +908,21 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
784
908
|
}
|
|
785
909
|
continue;
|
|
786
910
|
}
|
|
911
|
+
// Arrays of inline objects run every element through the private item
|
|
912
|
+
// sub-parser, the same way arrays of $refs delegate via validateArray.
|
|
913
|
+
// In strict mode the item parser throws on a bad element; in coerce mode
|
|
914
|
+
// it repairs the element to a valid instance.
|
|
915
|
+
const itemSubName = subItemNames.get(key);
|
|
916
|
+
if (itemSubName) {
|
|
917
|
+
const itemParserName = generateParserName(itemSubName);
|
|
918
|
+
if (isRequired) {
|
|
919
|
+
objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${itemParserName}),`);
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${itemParserName}) }),`);
|
|
923
|
+
}
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
787
926
|
// Handle direct $ref properties via imported parsers
|
|
788
927
|
if (shouldUseRefImport(propSchema, useRefImports)) {
|
|
789
928
|
const ref = propSchema.$ref;
|
|
@@ -872,6 +1011,18 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
872
1011
|
lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
|
|
873
1012
|
return;
|
|
874
1013
|
}
|
|
1014
|
+
// A *private* (nested-object / array-item) parser whose deep guard proved
|
|
1015
|
+
// the input is exactly the declared shape — typed checks plus the
|
|
1016
|
+
// no-undeclared-keys term, recursively via sub-predicates — can hand the
|
|
1017
|
+
// input back by reference instead of allocating a literal. That is the
|
|
1018
|
+
// same sharing the parent's own fast-path literal performs (`items:
|
|
1019
|
+
// _items`), and it is what keeps clean array elements allocation-free.
|
|
1020
|
+
// Exported root parsers keep returning a fresh object so callers never
|
|
1021
|
+
// alias the value they passed in.
|
|
1022
|
+
if (!exported) {
|
|
1023
|
+
lines.push(` if (${deepGuard}) return input as ${typeName};`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
875
1026
|
const fields = [];
|
|
876
1027
|
for (const { key, varName, isRequired, propSchema } of propInfo) {
|
|
877
1028
|
if (!isSchemaObject(propSchema)) {
|
|
@@ -907,13 +1058,45 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
907
1058
|
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
908
1059
|
}).slice(1);
|
|
909
1060
|
if (shallowGuard) {
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1061
|
+
// A private sub-parser hands input that is already exactly the declared
|
|
1062
|
+
// shape (no extras at any level) back by reference, so a clean array
|
|
1063
|
+
// element or nested object costs no allocation. The deep guard is
|
|
1064
|
+
// evaluated as the shallow guard plus only its *residual* terms (deeper
|
|
1065
|
+
// per-property checks and the no-extras term), so a carries-extras input
|
|
1066
|
+
// never runs the same typed checks twice before taking the strip build.
|
|
1067
|
+
if (!exported && deepGuard) {
|
|
1068
|
+
const residual = [];
|
|
1069
|
+
for (let i = 0; i < fastPathChecks.length; i++) {
|
|
1070
|
+
const deep = fastPathChecks[i];
|
|
1071
|
+
const shallow = shallowChecks[i];
|
|
1072
|
+
if (deep === shallow)
|
|
1073
|
+
continue;
|
|
1074
|
+
// When the deep check extends the shallow one by conjunction (e.g.
|
|
1075
|
+
// `Array.isArray(_x) && _everyItem(_x)`), the shallow guard already
|
|
1076
|
+
// proved the prefix — re-evaluating it per clean parse was pure
|
|
1077
|
+
// waste on the hot path, so only the extension joins the residual.
|
|
1078
|
+
residual.push(shallow !== undefined && deep.startsWith(`${shallow} && `) ? deep.slice(shallow.length + 4) : deep);
|
|
1079
|
+
}
|
|
1080
|
+
lines.push(` if (${shallowGuard}) {`);
|
|
1081
|
+
lines.push(residual.length > 0
|
|
1082
|
+
? ` if (${residual.join(' && ')}) return input as ${typeName};`
|
|
1083
|
+
: ` return input as ${typeName};`);
|
|
1084
|
+
lines.push(` } else {`);
|
|
1085
|
+
for (const assertionLine of assertionLines) {
|
|
1086
|
+
lines.push(` ${assertionLine}`);
|
|
1087
|
+
}
|
|
1088
|
+
lines.push(` }`);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
// stripUnknown: a well-typed input skips the assertions and goes
|
|
1092
|
+
// straight to the strip build (which removes extras and recurses into
|
|
1093
|
+
// sub-parsers).
|
|
1094
|
+
lines.push(` if (!(${shallowGuard})) {`);
|
|
1095
|
+
for (const assertionLine of assertionLines) {
|
|
1096
|
+
lines.push(` ${assertionLine}`);
|
|
1097
|
+
}
|
|
1098
|
+
lines.push(` }`);
|
|
915
1099
|
}
|
|
916
|
-
lines.push(` }`);
|
|
917
1100
|
emitReturn(lines, buildObjectLines(true));
|
|
918
1101
|
}
|
|
919
1102
|
else {
|
|
@@ -924,6 +1107,13 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
924
1107
|
lines.push(assertionLine);
|
|
925
1108
|
}
|
|
926
1109
|
if (strictKeys) {
|
|
1110
|
+
// for..in (not Object.keys) deliberately: this loop is cold — the fast
|
|
1111
|
+
// path already proved the key set — but swapping in the keys-array
|
|
1112
|
+
// iterator here once regressed the *hot* path several percent on CI:
|
|
1113
|
+
// the extra dead-path bytecode changed the engine's inlining of the
|
|
1114
|
+
// whole parser. The fast-path no-extras test uses own-key semantics;
|
|
1115
|
+
// an inherited-key mismatch merely lands here and keeps the historical
|
|
1116
|
+
// for..in rejection.
|
|
927
1117
|
lines.push(` for (const _k in input) {`);
|
|
928
1118
|
lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
|
|
929
1119
|
lines.push(` }`);
|
|
@@ -1347,7 +1537,9 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1347
1537
|
useRefImports,
|
|
1348
1538
|
suffix,
|
|
1349
1539
|
stripUnknown,
|
|
1540
|
+
logWarnings,
|
|
1350
1541
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1542
|
+
...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
|
|
1351
1543
|
});
|
|
1352
1544
|
}
|
|
1353
1545
|
// Handle schemas with both properties AND patternProperties.
|
|
@@ -1361,7 +1553,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1361
1553
|
// properties AND conditional keywords use all declared properties rather than
|
|
1362
1554
|
// only the if/then fragment.
|
|
1363
1555
|
if (hasProperties(schema)) {
|
|
1364
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema);
|
|
1556
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames);
|
|
1365
1557
|
}
|
|
1366
1558
|
// Handle conditional schemas (if/then/else) for schemas without explicit properties.
|
|
1367
1559
|
if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
|
|
@@ -1379,7 +1571,9 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1379
1571
|
useRefImports,
|
|
1380
1572
|
suffix,
|
|
1381
1573
|
stripUnknown,
|
|
1574
|
+
logWarnings,
|
|
1382
1575
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1576
|
+
...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
|
|
1383
1577
|
});
|
|
1384
1578
|
}
|
|
1385
1579
|
// Handle schemas with patternProperties (but no properties)
|
|
@@ -1433,7 +1627,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
|
|
|
1433
1627
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
1434
1628
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
1435
1629
|
*/
|
|
1436
|
-
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
|
|
1630
|
+
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false, reservedNames = NO_RESERVED_NAMES) => {
|
|
1437
1631
|
const fnName = shapeValidatorName(typeName);
|
|
1438
1632
|
const exportPrefix = exported ? 'export ' : '';
|
|
1439
1633
|
const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
|
|
@@ -1477,18 +1671,26 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1477
1671
|
'else' in schema) {
|
|
1478
1672
|
return stub;
|
|
1479
1673
|
}
|
|
1480
|
-
const
|
|
1674
|
+
const schemaProps = schema.properties;
|
|
1675
|
+
const propertyKeys = Object.keys(schemaProps);
|
|
1481
1676
|
// Same deterministic naming as the parser's sub-parser generation, so the
|
|
1482
1677
|
// referenced private shape predicates exist in the same file.
|
|
1483
|
-
const subTypeNames =
|
|
1678
|
+
const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
|
|
1484
1679
|
// When the parser strips extras (additionalProperties: false or stripUnknown),
|
|
1485
1680
|
// an input carrying an undeclared key is *not* fast-path eligible — the parser
|
|
1486
1681
|
// would strip it rather than return `{ ...input }` — so the shape only matches
|
|
1487
|
-
// when every key is declared.
|
|
1488
|
-
//
|
|
1489
|
-
|
|
1682
|
+
// when every key is declared. With every declared property required, the
|
|
1683
|
+
// no-extras test is the cheaper own-key count appended after the typed checks
|
|
1684
|
+
// (which prove all N keys present); otherwise it is the `_hasOnlyKnownKeys`
|
|
1685
|
+
// walk the parser emits under the same `stripKeys` condition.
|
|
1686
|
+
const validatorStripKeys = hasStrictKeys(schema) || stripUnknown;
|
|
1687
|
+
const validatorKeyCount = validatorStripKeys ? exactKeyCountOf(schema) : null;
|
|
1688
|
+
const strictKeysGuard = validatorStripKeys && validatorKeyCount === null
|
|
1689
|
+
? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;`
|
|
1690
|
+
: '';
|
|
1490
1691
|
const checks = [];
|
|
1491
|
-
for (const
|
|
1692
|
+
for (const key of propertyKeys) {
|
|
1693
|
+
const propSchema = schemaProps[key];
|
|
1492
1694
|
// Records of refs require iterating every value — too expensive for the fast path.
|
|
1493
1695
|
if (shouldUseRecordRefImport(propSchema, useRefImports)) {
|
|
1494
1696
|
return stub;
|
|
@@ -1496,9 +1698,16 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1496
1698
|
const accessor = safeAccessor('input', key);
|
|
1497
1699
|
const isRequired = isPropertyRequired(key, schema);
|
|
1498
1700
|
const subName = subTypeNames.get(key);
|
|
1499
|
-
|
|
1701
|
+
let check = subName
|
|
1500
1702
|
? `${shapeValidatorName(subName)}(${accessor})`
|
|
1501
1703
|
: generatePropertyTypeCheck(accessor, propSchema, useRefImports, suffix);
|
|
1704
|
+
// Arrays of inline objects prove every element via the private item loop
|
|
1705
|
+
// helper (emitted alongside the item sub-parser), matching the parser's
|
|
1706
|
+
// fast-path guard.
|
|
1707
|
+
const itemSubName = subItemNames.get(key);
|
|
1708
|
+
if (check !== null && itemSubName) {
|
|
1709
|
+
check = `${check} && _every${itemSubName}(${accessor})`;
|
|
1710
|
+
}
|
|
1502
1711
|
if (check === null) {
|
|
1503
1712
|
return stub;
|
|
1504
1713
|
}
|
|
@@ -1509,6 +1718,14 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1509
1718
|
checks.push(`(${accessor} === undefined || ${check})`);
|
|
1510
1719
|
}
|
|
1511
1720
|
}
|
|
1721
|
+
// The count form is sound only in conjunction with the typed checks above
|
|
1722
|
+
// (they prove every declared key present) and only for plain objects (a
|
|
1723
|
+
// crafted prototype could satisfy those checks through inherited
|
|
1724
|
+
// properties), so it joins the chain last, prototype-guarded like the
|
|
1725
|
+
// parser's own fast path.
|
|
1726
|
+
if (validatorKeyCount !== null) {
|
|
1727
|
+
checks.push(`Object.getPrototypeOf(input) === Object.prototype && Object.keys(input).length === ${validatorKeyCount}`);
|
|
1728
|
+
}
|
|
1512
1729
|
if (checks.length === 0) {
|
|
1513
1730
|
if (strictKeysGuard) {
|
|
1514
1731
|
return `${exportPrefix}const ${fnName} = (input: unknown): boolean => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
3
|
import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
|
|
4
|
+
import { quoteJsString } from '@amritk/helpers/quote-js-string';
|
|
4
5
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
5
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';
|
|
6
7
|
import { generateEnumCheck } from './generate-enum-check.js';
|
|
@@ -21,6 +22,11 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
21
22
|
return `(typeof ${accessor} !== "number" || !Number.isInteger(${accessor}))`;
|
|
22
23
|
case 'boolean':
|
|
23
24
|
return `typeof ${accessor} !== "boolean"`;
|
|
25
|
+
case 'null':
|
|
26
|
+
// Missing from this switch historically, which meant a null-typed
|
|
27
|
+
// property was never enforced on the assertion path — the strict-mode
|
|
28
|
+
// differential fuzzer caught non-null values sailing through.
|
|
29
|
+
return `${accessor} !== null`;
|
|
24
30
|
case 'array':
|
|
25
31
|
return `!Array.isArray(${accessor})`;
|
|
26
32
|
case 'object':
|
|
@@ -36,14 +42,13 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
36
42
|
const typeLabel = (type) => (type === 'integer' ? 'number' : type);
|
|
37
43
|
/**
|
|
38
44
|
* Emits `throw new Error(<message>[ + <suffixExpr>])`. The static message may
|
|
39
|
-
* contain schema-controlled text (property names, patterns, enum values), so
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* message (e.g. `typeof input`).
|
|
45
|
+
* contain schema-controlled text (property names, patterns, enum values), so
|
|
46
|
+
* it goes through the shared {@link quoteJsString} escape-or-quote decision.
|
|
47
|
+
* `suffixExpr`, when given, is a runtime expression appended to the message
|
|
48
|
+
* (e.g. `typeof input`).
|
|
44
49
|
*/
|
|
45
50
|
const throwError = (message, suffixExpr) => {
|
|
46
|
-
const literal =
|
|
51
|
+
const literal = quoteJsString(message);
|
|
47
52
|
return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
|
|
48
53
|
};
|
|
49
54
|
/**
|
|
@@ -221,8 +226,9 @@ export const generateObjectStrictAssertion = (schema, typeName, context = {}) =>
|
|
|
221
226
|
if (!hasProperties(schema) || !isSchemaObject(schema))
|
|
222
227
|
return lines;
|
|
223
228
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const props = schema.properties;
|
|
230
|
+
for (const key in props) {
|
|
231
|
+
lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
|
|
226
232
|
}
|
|
227
233
|
return lines;
|
|
228
234
|
};
|
|
@@ -18,6 +18,23 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
18
18
|
* whether `input` is already in the shape produced by the parser's fast path.
|
|
19
19
|
*/
|
|
20
20
|
export declare const shapeValidatorName: (typeName: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Matches an inline nested object property — an object schema written directly
|
|
23
|
+
* under `properties` with its own `properties`, rather than referenced via
|
|
24
|
+
* `$ref`. These get a private sub-parser (and shape predicate) in the same
|
|
25
|
+
* generated file so their fields are actually parsed; anything involving
|
|
26
|
+
* composition, conditionals, enums, or record semantics keeps the existing
|
|
27
|
+
* code paths.
|
|
28
|
+
*/
|
|
29
|
+
export declare const isInlineObjectProperty: (propSchema: JSONSchema) => propSchema is JSONSchema.Object;
|
|
30
|
+
/**
|
|
31
|
+
* Matches an array property whose `items` is an inline object schema — the
|
|
32
|
+
* array-items analogue of {@link isInlineObjectProperty}. These get a private
|
|
33
|
+
* item sub-parser (and shape predicate) so element values — including nested
|
|
34
|
+
* enums and `$ref`s — are actually validated instead of only passing an
|
|
35
|
+
* `Array.isArray` check.
|
|
36
|
+
*/
|
|
37
|
+
export declare const isInlineObjectArrayProperty: (propSchema: JSONSchema) => boolean;
|
|
21
38
|
/**
|
|
22
39
|
* Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
|
|
23
40
|
* schema is not a union (or mixes in other composition keywords we cannot
|
|
@@ -25,6 +25,51 @@ import { scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
|
25
25
|
* whether `input` is already in the shape produced by the parser's fast path.
|
|
26
26
|
*/
|
|
27
27
|
export const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
|
|
28
|
+
/**
|
|
29
|
+
* Matches an inline nested object property — an object schema written directly
|
|
30
|
+
* under `properties` with its own `properties`, rather than referenced via
|
|
31
|
+
* `$ref`. These get a private sub-parser (and shape predicate) in the same
|
|
32
|
+
* generated file so their fields are actually parsed; anything involving
|
|
33
|
+
* composition, conditionals, enums, or record semantics keeps the existing
|
|
34
|
+
* code paths.
|
|
35
|
+
*/
|
|
36
|
+
export const isInlineObjectProperty = (propSchema) => {
|
|
37
|
+
if (!isSchemaObject(propSchema))
|
|
38
|
+
return false;
|
|
39
|
+
if (hasRef(propSchema) || hasEnum(propSchema) || hasConst(propSchema))
|
|
40
|
+
return false;
|
|
41
|
+
if (hasOneOf(propSchema) || hasAnyOf(propSchema) || hasAllOf(propSchema))
|
|
42
|
+
return false;
|
|
43
|
+
// `then`/`else` must be excluded even without `if`: generateShapeValidator
|
|
44
|
+
// stubs any schema carrying them, so admitting one here would wire an
|
|
45
|
+
// always-false predicate into parent guards — and into validators the
|
|
46
|
+
// strict-union trust walk treats as false-sound, making a strict union
|
|
47
|
+
// reject valid input.
|
|
48
|
+
if ('patternProperties' in propSchema || 'not' in propSchema)
|
|
49
|
+
return false;
|
|
50
|
+
if ('if' in propSchema || 'then' in propSchema || 'else' in propSchema)
|
|
51
|
+
return false;
|
|
52
|
+
// additionalProperties-as-schema records go through the record paths instead
|
|
53
|
+
if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
|
|
54
|
+
return false;
|
|
55
|
+
return isObjectSchema(propSchema) && hasProperties(propSchema);
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Matches an array property whose `items` is an inline object schema — the
|
|
59
|
+
* array-items analogue of {@link isInlineObjectProperty}. These get a private
|
|
60
|
+
* item sub-parser (and shape predicate) so element values — including nested
|
|
61
|
+
* enums and `$ref`s — are actually validated instead of only passing an
|
|
62
|
+
* `Array.isArray` check.
|
|
63
|
+
*/
|
|
64
|
+
export const isInlineObjectArrayProperty = (propSchema) => {
|
|
65
|
+
if (!isSchemaObject(propSchema))
|
|
66
|
+
return false;
|
|
67
|
+
if (!('type' in propSchema) || propSchema.type !== 'array')
|
|
68
|
+
return false;
|
|
69
|
+
if (!hasItems(propSchema) || Array.isArray(propSchema.items))
|
|
70
|
+
return false;
|
|
71
|
+
return isInlineObjectProperty(propSchema.items);
|
|
72
|
+
};
|
|
28
73
|
/**
|
|
29
74
|
* Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
|
|
30
75
|
* schema is not a union (or mixes in other composition keywords we cannot
|
|
@@ -295,6 +340,33 @@ const canTrustPropertyCheck = (schema, rootSchema, visiting) => {
|
|
|
295
340
|
return false;
|
|
296
341
|
}
|
|
297
342
|
};
|
|
343
|
+
/**
|
|
344
|
+
* Mirrors the per-property check {@link generateShapeValidator} emits for a
|
|
345
|
+
* generated file's exported validator, which is *deeper* than
|
|
346
|
+
* {@link generatePropertyTypeCheck}: inline object properties and arrays of
|
|
347
|
+
* inline object items are checked through private sub-predicates generated
|
|
348
|
+
* from the same schema, and a sub-predicate is a conservative `=> false` stub
|
|
349
|
+
* whenever some nested property check cannot be built. A validator built on a
|
|
350
|
+
* stub returns false on *valid* input, so trusting it requires every reachable
|
|
351
|
+
* sub-predicate to be a real check, recursively.
|
|
352
|
+
*/
|
|
353
|
+
const canTrustShapeProperty = (propSchema, rootSchema, visiting) => {
|
|
354
|
+
if (!isSchemaObject(propSchema))
|
|
355
|
+
return false;
|
|
356
|
+
// Record-valued additionalProperties: records of refs stub the validator;
|
|
357
|
+
// stay conservative for any schema-valued record, matching the historical
|
|
358
|
+
// bail in canTrustReferencedValidator.
|
|
359
|
+
if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
|
|
360
|
+
return false;
|
|
361
|
+
if (isInlineObjectProperty(propSchema)) {
|
|
362
|
+
return Object.values(propSchema.properties ?? {}).every((sub) => canTrustShapeProperty(sub, rootSchema, visiting));
|
|
363
|
+
}
|
|
364
|
+
if (isInlineObjectArrayProperty(propSchema)) {
|
|
365
|
+
const items = propSchema.items;
|
|
366
|
+
return Object.values(items.properties ?? {}).every((sub) => canTrustShapeProperty(sub, rootSchema, visiting));
|
|
367
|
+
}
|
|
368
|
+
return canTrustPropertyCheck(propSchema, rootSchema, visiting);
|
|
369
|
+
};
|
|
298
370
|
/** Mirrors {@link generateInlineObjectCheck}: non-null AND every property check is trustworthy. */
|
|
299
371
|
const canTrustInlineObjectCheck = (schema, rootSchema, visiting) => {
|
|
300
372
|
if (!isSchemaObject(schema) || !isObjectSchema(schema) || !hasProperties(schema))
|
|
@@ -354,15 +426,11 @@ const canTrustReferencedValidator = (ref, rootSchema, visiting) => {
|
|
|
354
426
|
'else' in resolved) {
|
|
355
427
|
return false;
|
|
356
428
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
364
|
-
return canTrustPropertyCheck(propSchema, rootSchema, visiting);
|
|
365
|
-
});
|
|
429
|
+
// Walk with the deep shape-validator mirror: the emitted validator routes
|
|
430
|
+
// inline object properties (and arrays of inline object items) through
|
|
431
|
+
// private sub-predicates, so trust must recurse the same way instead of
|
|
432
|
+
// stopping at a shallow isObject/Array.isArray reading.
|
|
433
|
+
return Object.values(resolved.properties).every((propSchema) => canTrustShapeProperty(propSchema, rootSchema, visiting));
|
|
366
434
|
}
|
|
367
435
|
finally {
|
|
368
436
|
visiting.delete(ref);
|
|
@@ -6,6 +6,13 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
6
6
|
* objects, arrays, unions, `$ref`s, and anything richer.
|
|
7
7
|
*/
|
|
8
8
|
export declare const scalarItemTypeCheck: (itemSchema: JSONSchema, accessor: string) => string | null;
|
|
9
|
+
/**
|
|
10
|
+
* True when an array's `items` schema can be coerced element-by-element by the
|
|
11
|
+
* mapping slow path: single scalar types (via {@link scalarItemTypeCheck}) and
|
|
12
|
+
* enums (whose validation expression coerces a non-member to the first member).
|
|
13
|
+
* Anything richer (objects, unions, `$ref`s) needs a real item parser instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
|
|
9
16
|
/**
|
|
10
17
|
* Generates a validation expression that checks if a value matches the expected type.
|
|
11
18
|
* Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
|
|
@@ -46,6 +46,17 @@ export const scalarItemTypeCheck = (itemSchema, accessor) => {
|
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* True when an array's `items` schema can be coerced element-by-element by the
|
|
51
|
+
* mapping slow path: single scalar types (via {@link scalarItemTypeCheck}) and
|
|
52
|
+
* enums (whose validation expression coerces a non-member to the first member).
|
|
53
|
+
* Anything richer (objects, unions, `$ref`s) needs a real item parser instead.
|
|
54
|
+
*/
|
|
55
|
+
export const isCoercibleItemSchema = (itemSchema) => {
|
|
56
|
+
if (scalarItemTypeCheck(itemSchema, '_it') !== null)
|
|
57
|
+
return true;
|
|
58
|
+
return isSchemaObject(itemSchema) && hasEnum(itemSchema) && itemSchema.enum.length > 0;
|
|
59
|
+
};
|
|
49
60
|
/**
|
|
50
61
|
* A boolean type check for a single JSON Schema primitive type name. Unlike
|
|
51
62
|
* {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
|
|
@@ -228,15 +239,16 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
228
239
|
if (!hasType(schema) && !hasEnum(schema)) {
|
|
229
240
|
return `${accessor} ?? ${defaultValue}`;
|
|
230
241
|
}
|
|
231
|
-
// An array of scalar items: coerce each element so e.g. `number[]`
|
|
232
|
-
// contains numbers
|
|
233
|
-
// element reaches here. ($ref
|
|
234
|
-
// object
|
|
242
|
+
// An array of scalar or enum items: coerce each element so e.g. `number[]`
|
|
243
|
+
// actually contains numbers and an enum member set holds. The fast path only
|
|
244
|
+
// takes a well-typed array, so a mistyped element reaches here. ($ref and
|
|
245
|
+
// inline-object items use the caller's validateArray path; union items still
|
|
246
|
+
// pass through.)
|
|
235
247
|
if (hasType(schema) &&
|
|
236
248
|
schema.type === 'array' &&
|
|
237
249
|
hasItems(schema) &&
|
|
238
250
|
!Array.isArray(schema.items) &&
|
|
239
|
-
|
|
251
|
+
isCoercibleItemSchema(schema.items)) {
|
|
240
252
|
const itemSchema = schema.items;
|
|
241
253
|
const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true);
|
|
242
254
|
const mapped = `(Array.isArray(${accessor}) ? (${accessor} as unknown[]).map((_it) => ${itemExpr}) : ${defaultValue})`;
|
|
@@ -12,12 +12,4 @@ export type CollectedHelpers = {
|
|
|
12
12
|
*/
|
|
13
13
|
readonly used: Set<RuntimeHelperName>;
|
|
14
14
|
};
|
|
15
|
-
|
|
16
|
-
* Detects which runtime helpers a generated parser body references.
|
|
17
|
-
*
|
|
18
|
-
* @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
|
|
19
|
-
* directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
|
|
20
|
-
* build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
|
|
21
|
-
* `_helpers/` directory at the output root.
|
|
22
|
-
*/
|
|
23
|
-
export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string) => CollectedHelpers;
|
|
15
|
+
export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string, importExt?: 'js' | 'ts') => CollectedHelpers;
|
|
@@ -11,9 +11,9 @@ const EMBEDDED_NAMED_EXPORTS = {
|
|
|
11
11
|
'validate-record': 'validateRecord',
|
|
12
12
|
'has-ref': 'hasRef',
|
|
13
13
|
};
|
|
14
|
-
const embeddedImport = (helper, prefix) =>
|
|
15
|
-
//
|
|
16
|
-
`import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}
|
|
14
|
+
const embeddedImport = (helper, prefix, ext) =>
|
|
15
|
+
// An explicit extension so the embedded-helper import resolves under Node ESM, not only Bun.
|
|
16
|
+
`import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.${ext}';`;
|
|
17
17
|
/**
|
|
18
18
|
* Detects which runtime helpers a generated parser body references.
|
|
19
19
|
*
|
|
@@ -21,16 +21,43 @@ const embeddedImport = (helper, prefix) =>
|
|
|
21
21
|
* directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
|
|
22
22
|
* build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
|
|
23
23
|
* `_helpers/` directory at the output root.
|
|
24
|
+
* @param importExt - Extension used on embedded-helper import specifiers.
|
|
25
|
+
* Defaults to `'js'` (the TS NodeNext form); `'ts'` makes the output runnable
|
|
26
|
+
* under Node's type stripping.
|
|
24
27
|
*/
|
|
25
|
-
|
|
28
|
+
// One alternation pass over the generated source instead of four full-text
|
|
29
|
+
// `.includes` scans — an absent helper name used to cost a complete rescan
|
|
30
|
+
// each, which showed up at several percent of total generation time.
|
|
31
|
+
const HELPER_USAGE = /validateArray|validateRecord|isObject|hasRef\(/g;
|
|
32
|
+
export const collectHelpers = (parserFunction, mode, helpersImportPrefix = './', importExt = 'js') => {
|
|
26
33
|
const imports = [];
|
|
27
34
|
const used = new Set();
|
|
28
|
-
const importFor = (helper) => mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper];
|
|
29
|
-
|
|
35
|
+
const importFor = (helper) => mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix, importExt) : PACKAGE_IMPORTS[helper];
|
|
36
|
+
let sawValidateArray = false;
|
|
37
|
+
let sawValidateRecord = false;
|
|
38
|
+
let sawIsObject = false;
|
|
39
|
+
let sawHasRef = false;
|
|
40
|
+
HELPER_USAGE.lastIndex = 0;
|
|
41
|
+
let match = HELPER_USAGE.exec(parserFunction);
|
|
42
|
+
while (match !== null) {
|
|
43
|
+
const token = match[0];
|
|
44
|
+
if (token === 'validateArray')
|
|
45
|
+
sawValidateArray = true;
|
|
46
|
+
else if (token === 'validateRecord')
|
|
47
|
+
sawValidateRecord = true;
|
|
48
|
+
else if (token === 'isObject')
|
|
49
|
+
sawIsObject = true;
|
|
50
|
+
else
|
|
51
|
+
sawHasRef = true;
|
|
52
|
+
if (sawValidateArray && sawValidateRecord && sawIsObject && sawHasRef)
|
|
53
|
+
break;
|
|
54
|
+
match = HELPER_USAGE.exec(parserFunction);
|
|
55
|
+
}
|
|
56
|
+
if (sawValidateArray) {
|
|
30
57
|
imports.push(importFor('validate-array'));
|
|
31
58
|
used.add('validate-array');
|
|
32
59
|
}
|
|
33
|
-
if (
|
|
60
|
+
if (sawValidateRecord) {
|
|
34
61
|
imports.push(importFor('validate-record'));
|
|
35
62
|
used.add('validate-record');
|
|
36
63
|
// The embedded validate-record.ts imports is-object, so it must be shipped too —
|
|
@@ -38,11 +65,11 @@ export const collectHelpers = (parserFunction, mode, helpersImportPrefix = './')
|
|
|
38
65
|
// parser body itself references `isObject` (handled below).
|
|
39
66
|
used.add('is-object');
|
|
40
67
|
}
|
|
41
|
-
if (
|
|
68
|
+
if (sawIsObject) {
|
|
42
69
|
imports.push(importFor('is-object'));
|
|
43
70
|
used.add('is-object');
|
|
44
71
|
}
|
|
45
|
-
if (
|
|
72
|
+
if (sawHasRef) {
|
|
46
73
|
imports.push(importFor('has-ref'));
|
|
47
74
|
used.add('has-ref');
|
|
48
75
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/** Extension emitted on every relative import specifier in generated code. */
|
|
3
|
+
export type ImportExtension = 'js' | 'ts';
|
|
2
4
|
/**
|
|
3
5
|
* Options for controlling how imports are collected.
|
|
4
6
|
*/
|
|
@@ -26,6 +28,12 @@ type CollectImportsOptions = {
|
|
|
26
28
|
* Defaults to `''` (no suffix).
|
|
27
29
|
*/
|
|
28
30
|
readonly typeSuffix?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Extension used on every relative import specifier. Defaults to `'js'`
|
|
33
|
+
* (the TS NodeNext form); `'ts'` makes the output runnable under Node's
|
|
34
|
+
* type stripping.
|
|
35
|
+
*/
|
|
36
|
+
readonly importExt?: ImportExtension;
|
|
29
37
|
};
|
|
30
38
|
/**
|
|
31
39
|
* Collects all import statements needed for a schema by finding $ref references
|
|
@@ -67,4 +75,11 @@ type CollectImportsOptions = {
|
|
|
67
75
|
* ```
|
|
68
76
|
*/
|
|
69
77
|
export declare const collectImports: (schema: JSONSchema, options?: CollectImportsOptions) => string[];
|
|
78
|
+
/**
|
|
79
|
+
* The type names a generated file will import for its `$ref`s — the same
|
|
80
|
+
* dedup/skip rules as {@link collectImports}. Generators seed their private
|
|
81
|
+
* sub-type naming with this set so a synthesized name (e.g. a root array's
|
|
82
|
+
* `FooItem`) can never shadow an imported identifier.
|
|
83
|
+
*/
|
|
84
|
+
export declare const collectImportTypeNames: (schema: JSONSchema, options?: CollectImportsOptions) => Set<string>;
|
|
70
85
|
export {};
|
|
@@ -2,11 +2,12 @@ import { refToFilename } from '@amritk/helpers/ref-to-filename';
|
|
|
2
2
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
3
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
4
4
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
|
|
5
|
-
// Emit an explicit
|
|
6
|
-
// requires
|
|
7
|
-
//
|
|
8
|
-
// accepted by Bun, esbuild, webpack, and tsc alike.
|
|
9
|
-
|
|
5
|
+
// Emit an explicit extension on the relative specifier. Node's ESM resolver
|
|
6
|
+
// requires one (extensionless relative imports only work under a bundler or
|
|
7
|
+
// Bun). `./x.js` pointing at a sibling `x.ts` is the standard TS NodeNext form
|
|
8
|
+
// — accepted by Bun, esbuild, webpack, and tsc alike — while `./x.ts` is the
|
|
9
|
+
// literal on-disk path Node's type stripping needs to run the sources directly.
|
|
10
|
+
const getImportPathForFilename = (filename, ext) => `./${filename}.${ext}`;
|
|
10
11
|
/**
|
|
11
12
|
* Collects all import statements needed for a schema by finding $ref references
|
|
12
13
|
* that are directly used in the type definition (from properties, items, etc.).
|
|
@@ -48,6 +49,32 @@ const getImportPathForFilename = (filename) => `./${filename}.js`;
|
|
|
48
49
|
*/
|
|
49
50
|
export const collectImports = (schema, options) => {
|
|
50
51
|
const typesOnly = options?.typesOnly === true;
|
|
52
|
+
const importExt = options?.importExt ?? 'js';
|
|
53
|
+
const importMap = new Map();
|
|
54
|
+
for (const [filename, typeName] of collectImportTargets(schema, options)) {
|
|
55
|
+
const importPath = getImportPathForFilename(filename, importExt);
|
|
56
|
+
// In types-only mode, omit the parser function import since there is no parser to call
|
|
57
|
+
const importStatement = typesOnly
|
|
58
|
+
? `import type { ${typeName} } from '${importPath}';`
|
|
59
|
+
: `import { type ${typeName}, parse${typeName}, validate${typeName}Shape } from '${importPath}';`;
|
|
60
|
+
importMap.set(filename, importStatement);
|
|
61
|
+
}
|
|
62
|
+
return Array.from(importMap.values()).sort();
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The type names a generated file will import for its `$ref`s — the same
|
|
66
|
+
* dedup/skip rules as {@link collectImports}. Generators seed their private
|
|
67
|
+
* sub-type naming with this set so a synthesized name (e.g. a root array's
|
|
68
|
+
* `FooItem`) can never shadow an imported identifier.
|
|
69
|
+
*/
|
|
70
|
+
export const collectImportTypeNames = (schema, options) => {
|
|
71
|
+
const names = new Set();
|
|
72
|
+
for (const [, typeName] of collectImportTargets(schema, options))
|
|
73
|
+
names.add(typeName);
|
|
74
|
+
return names;
|
|
75
|
+
};
|
|
76
|
+
/** Shared `$ref` walk: filename → derived type name for every import target. */
|
|
77
|
+
const collectImportTargets = (schema, options) => {
|
|
51
78
|
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : undefined;
|
|
52
79
|
const rootSchema = options?.rootSchema;
|
|
53
80
|
const typeSuffix = options?.typeSuffix;
|
|
@@ -194,8 +221,8 @@ export const collectImports = (schema, options) => {
|
|
|
194
221
|
if (typeof schema === 'object' && schema !== null && 'else' in schema) {
|
|
195
222
|
collectRefsFromValue(schema.else);
|
|
196
223
|
}
|
|
197
|
-
// Convert refs to
|
|
198
|
-
const
|
|
224
|
+
// Convert refs to import targets, deduplicating by filename.
|
|
225
|
+
const targets = new Map();
|
|
199
226
|
for (const ref of refs) {
|
|
200
227
|
// For URI refs, skip if the base URI is not resolvable in the root schema.
|
|
201
228
|
// This prevents generating imports for external schemas that were never generated as files.
|
|
@@ -209,12 +236,7 @@ export const collectImports = (schema, options) => {
|
|
|
209
236
|
if (selfFilename !== undefined && filename === selfFilename) {
|
|
210
237
|
continue;
|
|
211
238
|
}
|
|
212
|
-
|
|
213
|
-
// In types-only mode, omit the parser function import since there is no parser to call
|
|
214
|
-
const importStatement = typesOnly
|
|
215
|
-
? `import type { ${typeName} } from '${importPath}';`
|
|
216
|
-
: `import { type ${typeName}, parse${typeName}, validate${typeName}Shape } from '${importPath}';`;
|
|
217
|
-
importMap.set(filename, importStatement);
|
|
239
|
+
targets.set(filename, typeName);
|
|
218
240
|
}
|
|
219
|
-
return
|
|
241
|
+
return targets;
|
|
220
242
|
};
|
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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.11.0",
|
|
52
52
|
"@amritk/generate-markdown": "0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|