@amritk/validation 0.3.1 → 0.4.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/AI.md +16 -0
- package/README.md +83 -1
- package/dist/parsers/generators/build-schema.js +12 -2
- package/dist/parsers/generators/generate-exact-half.d.ts +50 -0
- package/dist/parsers/generators/generate-exact-half.js +165 -0
- package/dist/parsers/generators/generate-files.d.ts +8 -0
- package/dist/parsers/generators/generate-files.js +11 -1
- package/dist/parsers/generators/generate-parser-function.d.ts +6 -0
- package/dist/parsers/generators/generate-parser-function.js +51 -13
- package/dist/parsers/helpers/collect-helpers.d.ts +1 -1
- package/dist/parsers/helpers/collect-helpers.js +25 -2
- package/dist/parsers/helpers/collect-imports.js +21 -0
- package/dist/rehome-parser-file.js +11 -1
- package/dist/validators/generators/build-schema.d.ts +1 -1
- package/dist/validators/generators/build-schema.js +46 -12
- package/dist/validators/generators/collect-validator-imports.d.ts +7 -0
- package/dist/validators/generators/collect-validator-imports.js +4 -1
- package/dist/validators/generators/generate-coerce-function.d.ts +46 -1
- package/dist/validators/generators/generate-coerce-function.js +181 -53
- package/dist/validators/generators/generate-files.js +9 -4
- package/dist/validators/generators/generate-validator-function.d.ts +48 -0
- package/dist/validators/generators/generate-validator-function.js +146 -27
- package/package.json +3 -3
package/AI.md
CHANGED
|
@@ -89,4 +89,20 @@ spelled as a flag: everything that judges a document, nothing that builds one.
|
|
|
89
89
|
of two, and that a build asking for no validator mode ships no
|
|
90
90
|
`validation-result.ts`.
|
|
91
91
|
|
|
92
|
+
9. **`coerceX` is not Ajv's `coerceTypes`, on purpose.** It never modifies its
|
|
93
|
+
input (no defensive clone needed), never coerces `null` in either direction,
|
|
94
|
+
and reads only clean numerals as numbers (`" "`, `"0x10"`, `"Infinity"` are
|
|
95
|
+
rejected). It coerces inside `anyOf`/`oneOf`/`allOf`/`if`: a union branch the
|
|
96
|
+
value already matches wins uncoerced (so `false` stays `false` under
|
|
97
|
+
`anyOf: [string, { const: false }]`, where Ajv answers `"false"`), and two
|
|
98
|
+
branches that would coerce to different values leave it as written. Do not
|
|
99
|
+
tell a user a value will come out the way Ajv would produce it inside a union.
|
|
100
|
+
10. **The coercing `parseX` returns exactly what `coerceX` returns** whenever
|
|
101
|
+
`coerceX` accepts the document, and only repairs what `coerceX` would reject.
|
|
102
|
+
A parser file for a definition with `anyOf`/`oneOf`/`allOf`/`if`/`not` (or one
|
|
103
|
+
such a definition reaches) therefore exports `matchesX` and `coerceXInput`
|
|
104
|
+
too, and its repairing parser is private (`_parseXRepair`, kept out of the
|
|
105
|
+
barrel — a leading `_` marks a name for sibling files only). Strict and
|
|
106
|
+
`stripUnknown` parsers are unchanged.
|
|
107
|
+
|
|
92
108
|
Only the `.` entry. Install: `bun add @amritk/validation`.
|
package/README.md
CHANGED
|
@@ -92,9 +92,90 @@ never fails. Coercion moves a value that is already right but written in the
|
|
|
92
92
|
wrong type; repair also substitutes a value the schema supplies for one that
|
|
93
93
|
cannot be coerced, and reports the validator's own errors as the repairs.
|
|
94
94
|
|
|
95
|
+
**`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
|
|
96
|
+
coercing `parseX` returns the very same value — through a union, an `allOf`, an
|
|
97
|
+
`if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
|
|
98
|
+
have rejected. A differential test pins that over random schemas. To get there,
|
|
99
|
+
a definition whose own tree has an `anyOf`/`oneOf`/`allOf`/`if`/`not` carries an
|
|
100
|
+
exact test (`matchesX`) and the coercion walk (`coerceXInput`) in front of its
|
|
101
|
+
repairing parser, and every definition such a one reaches carries them too. A
|
|
102
|
+
schema without combinators emits exactly what it always did; one with them
|
|
103
|
+
emits more — about twice the parser code on a large OpenAPI document — which is
|
|
104
|
+
what a second, exact opinion on each definition costs. A `strict` or
|
|
105
|
+
`stripUnknown` parser has no `coerceX` to agree with and is unchanged.
|
|
106
|
+
|
|
95
107
|
`parse` and `parseStrict` are the same function name under two contracts, so
|
|
96
108
|
asking for both is an error rather than a silent choice.
|
|
97
109
|
|
|
110
|
+
### Moving off Ajv
|
|
111
|
+
|
|
112
|
+
`coerceX` is built to replace Ajv compiled with `{ allErrors: true,
|
|
113
|
+
coerceTypes: true }`, and it is better on each count: the answer, the speed and
|
|
114
|
+
the safety of the input.
|
|
115
|
+
|
|
116
|
+
**It accepts only what the schema accepts.** Anything `coerceX` hands back as
|
|
117
|
+
valid, `validateX` has just accepted, and a differential fuzz over random
|
|
118
|
+
schemas with `anyOf`, `oneOf`, `allOf` and `if`/`then`/`else` checks every
|
|
119
|
+
accepted document against an Ajv that does not coerce. Where the two differ, it
|
|
120
|
+
is on purpose:
|
|
121
|
+
|
|
122
|
+
- **`null` is never coerced**, in either direction. Ajv reads `null` as `""`, `0`
|
|
123
|
+
or `false` for a string, number or boolean field, and reads those back as
|
|
124
|
+
`null`. A `null` usually means "not set", and turning it into a real value
|
|
125
|
+
erases that. Here it goes to the validator as written, and the validator
|
|
126
|
+
rejects it.
|
|
127
|
+
- **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
|
|
128
|
+
and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
|
|
129
|
+
cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
|
|
130
|
+
- **At a union, a branch the value already matches wins.** Ajv coerces into the
|
|
131
|
+
first branch that will take the value. Given `anyOf: [{ type: 'string' }, {
|
|
132
|
+
const: false }]`, Ajv turns `false` into `"false"`; `coerceX` keeps `false`.
|
|
133
|
+
When no branch matches as written, each branch coerces the value its own way
|
|
134
|
+
and the result is taken only if every branch that then accepts it agrees.
|
|
135
|
+
Two different readings leave the value alone for the validator to report, so
|
|
136
|
+
the order the branches were written in never changes the answer.
|
|
137
|
+
- **A branch that fails leaves nothing behind.** Ajv coerces in place while it
|
|
138
|
+
tries each branch, so under `oneOf: [{ type: 'integer' }, { type: 'string' }]`
|
|
139
|
+
it turns `0` into `"0"` while trying the string branch, sees two matches, and
|
|
140
|
+
rejects a valid document. `coerceX` accepts `0` as written.
|
|
141
|
+
- **Coercion reaches every applicator.** Scalars are coerced through `$ref`,
|
|
142
|
+
`allOf` (each subschema in turn), `if`/`then`/`else` (toward `then` when the
|
|
143
|
+
value matches `if`, `else` otherwise), and union branches, nested to any depth.
|
|
144
|
+
|
|
145
|
+
**It never touches the input.** Ajv coerces in place, so a caller that keeps its
|
|
146
|
+
document has to clone it first. `coerceX` returns a new value that shares
|
|
147
|
+
everything it did not touch, or the input itself when nothing needed coercing.
|
|
148
|
+
|
|
149
|
+
**It is faster.** Against Ajv cloning first, so both leave the caller's document
|
|
150
|
+
alone (`bun run bench:validators:coerce`, or
|
|
151
|
+
`bench:validators:coerce:node`):
|
|
152
|
+
|
|
153
|
+
| schema | runtime | valid input | needs coercing | cannot be coerced |
|
|
154
|
+
|:--|:--|--:|--:|--:|
|
|
155
|
+
| small (4 fields) | Bun | 5.4× | 1.7× | 1.5× |
|
|
156
|
+
| small (4 fields) | Node | 9.9× | 2.1× | 2.2× |
|
|
157
|
+
| order (nested + array) | Bun | 11× | 1.6× | 1.9× |
|
|
158
|
+
| order (nested + array) | Node | 8.4× | 1.8× | 2.2× |
|
|
159
|
+
| assert-loose | Bun | 19× | 1.4× | 3.0× |
|
|
160
|
+
| assert-loose | Node | 36× | 1.6× | 3.1× |
|
|
161
|
+
| config (unions via `$ref`) | Bun | 7.2× | 1.7× | 3.7× |
|
|
162
|
+
| config (unions via `$ref`) | Node | 5.2× | 1.5× | 4.6× |
|
|
163
|
+
|
|
164
|
+
<sub>Each cell is how many times faster `coerceX` is than Ajv on the same input,
|
|
165
|
+
each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
|
|
166
|
+
valid document is the common case and the widest gap: where `isX` is a
|
|
167
|
+
standalone guard, `coerceX` answers it with that guard and hands the input
|
|
168
|
+
straight back, without walking it. On valid input Ajv rewrites nothing, so it can
|
|
169
|
+
also be timed without the clone; `coerceX` is still ahead there, by 1.2–3.3×.
|
|
170
|
+
The config case is the shape `--coerce` exists for: every coercible scalar sits
|
|
171
|
+
inside a union reached through `$ref`, and every union branch is tested by a
|
|
172
|
+
named function rather than a closure built per call.</sub>
|
|
173
|
+
|
|
174
|
+
The errors differ in one way worth knowing if you key diagnostics on
|
|
175
|
+
`path + keyword + params`: an `additionalProperties` error points **at the
|
|
176
|
+
undeclared key** (`/readme/example_requests`) where Ajv points at the object
|
|
177
|
+
holding it (`/readme`). `params.additionalProperty` names the key in both.
|
|
178
|
+
|
|
98
179
|
---
|
|
99
180
|
|
|
100
181
|
## API
|
|
@@ -146,7 +227,8 @@ could import its 17 KiB of error types and runtime helpers.
|
|
|
146
227
|
|
|
147
228
|
## Is it faster?
|
|
148
229
|
|
|
149
|
-
No, and it should not be.
|
|
230
|
+
Than the two engines it wraps? No, and it should not be. (Than Ajv, yes — see
|
|
231
|
+
[Moving off Ajv](#moving-off-ajv).) Ask this package for one mode and it emits **the exact
|
|
150
232
|
bytes** the engine that owns that mode emits — which the test suite pins per
|
|
151
233
|
mode, by fingerprint, not by reading the output. Identical code cannot
|
|
152
234
|
run at a different speed, so there is no runtime claim to make here and none is
|
|
@@ -6,6 +6,7 @@ import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
|
|
|
6
6
|
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
7
7
|
import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
|
|
8
8
|
import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
|
|
9
|
+
import { planExactHalves } from "./generate-exact-half.js";
|
|
9
10
|
import { generateFile } from "./generate-files.js";
|
|
10
11
|
const readHelperSource = async (helper, importExt) => {
|
|
11
12
|
const require2 = createRequire(import.meta.url);
|
|
@@ -32,10 +33,18 @@ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logW
|
|
|
32
33
|
const files = [];
|
|
33
34
|
const usedHelpers = /* @__PURE__ */ new Set();
|
|
34
35
|
const foldedSchema = foldNullable(rootSchema);
|
|
36
|
+
const nodes = [];
|
|
35
37
|
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
36
38
|
if (node.filename === "index")
|
|
37
39
|
return;
|
|
38
|
-
|
|
40
|
+
nodes.push({
|
|
41
|
+
node,
|
|
42
|
+
schema: extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
const exactHalves = typesOnly === true || strict === true || stripUnknown ? /* @__PURE__ */ new Map() : planExactHalves(nodes.map(({ node, schema }) => ({ typeName: node.typeName, schema, rootSchema: node.rootSchema })), typeSuffix);
|
|
46
|
+
for (const { node, schema: extended } of nodes) {
|
|
47
|
+
const exactHalf = exactHalves.get(node.typeName);
|
|
39
48
|
const result = generateFile(extended, node.typeName, {
|
|
40
49
|
selfFilename: node.filename,
|
|
41
50
|
typesOnly: typesOnly ?? false,
|
|
@@ -51,12 +60,13 @@ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logW
|
|
|
51
60
|
...strict !== void 0 ? { strict } : {},
|
|
52
61
|
...stripUnknown ? { stripUnknown } : {},
|
|
53
62
|
...caseInsensitive ? { caseInsensitive } : {},
|
|
63
|
+
...exactHalf !== void 0 ? { exactHalf } : {},
|
|
54
64
|
unknownKeys
|
|
55
65
|
});
|
|
56
66
|
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
57
67
|
for (const helper of result.usedHelpers)
|
|
58
68
|
usedHelpers.add(helper);
|
|
59
|
-
}
|
|
69
|
+
}
|
|
60
70
|
if (helpersMode === "embedded" && !typesOnly) {
|
|
61
71
|
for (const helper of usedHelpers) {
|
|
62
72
|
const { ext, content } = await readHelperSource(helper, importExt);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* The exact, coercing half of a coercing parser file: `matchesX`, a yes/no test
|
|
4
|
+
* that agrees with `validateX` on every value, and `coerceXInput`, the walk
|
|
5
|
+
* `coerceX` runs. `wrap` says whether `parseX` itself runs them ahead of its
|
|
6
|
+
* repair, or whether the file only carries them for another file to call.
|
|
7
|
+
*/
|
|
8
|
+
export type ExactHalf = {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly wrap: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** The name of a definition's exact yes/no test. */
|
|
13
|
+
export declare const exactMatcherName: (typeName: string) => string;
|
|
14
|
+
/** The name of a definition's coercion walk, as the parser exports it. */
|
|
15
|
+
export declare const exactCoercerName: (typeName: string) => string;
|
|
16
|
+
/** The name the repairing parser takes when `parseX` is the wrapper in front of it. */
|
|
17
|
+
export declare const repairParserName: (typeName: string) => string;
|
|
18
|
+
/** One definition as the planner sees it. */
|
|
19
|
+
export type ExactHalfNode = {
|
|
20
|
+
readonly typeName: string;
|
|
21
|
+
readonly schema: JSONSchema;
|
|
22
|
+
readonly rootSchema: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Decides which definitions of a coercing parser build carry the exact half,
|
|
26
|
+
* and emits it for each.
|
|
27
|
+
*
|
|
28
|
+
* Every definition whose own tree has a combinator is wrapped. Everything such
|
|
29
|
+
* a definition reaches through `$ref` carries the half without the wrapper,
|
|
30
|
+
* because the wrapped definition's test and walk call into it. A definition the
|
|
31
|
+
* emitters cannot express drops out, and so does everything that would have
|
|
32
|
+
* called into it, until what is left is closed — so a file never imports a half
|
|
33
|
+
* that was not written.
|
|
34
|
+
*/
|
|
35
|
+
export declare const planExactHalves: (nodes: readonly ExactHalfNode[], typeSuffix: string) => ReadonlyMap<string, ExactHalf>;
|
|
36
|
+
/**
|
|
37
|
+
* The imports a file's exact half needs: each `$ref` target's test and walk that
|
|
38
|
+
* the half actually calls.
|
|
39
|
+
*
|
|
40
|
+
* Its own lines rather than names added to the parser's import collector. That
|
|
41
|
+
* collector walks only the positions the repairing parser reads, and the exact
|
|
42
|
+
* half reads them all — a `$ref` under `not`, inside `propertyNames`, beside
|
|
43
|
+
* another `$ref`. Two import statements from one module are fine; the same name
|
|
44
|
+
* imported twice is not, and the two sets of names never meet.
|
|
45
|
+
*/
|
|
46
|
+
export declare const exactHalfImports: (schema: JSONSchema, code: string, options: {
|
|
47
|
+
readonly selfFilename?: string;
|
|
48
|
+
readonly typeSuffix: string;
|
|
49
|
+
readonly importExt: 'js' | 'ts';
|
|
50
|
+
}) => string[];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { identifierMentions } from "@amritk/helpers/identifier-mentions";
|
|
2
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
3
|
+
import { refToFilename } from "@amritk/helpers/ref-to-filename";
|
|
4
|
+
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
5
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
6
|
+
import { generateCoerceWalk } from "../../validators/generators/generate-coerce-function.js";
|
|
7
|
+
const exactMatcherName = (typeName) => `matches${typeName}`;
|
|
8
|
+
const exactCoercerName = (typeName) => `coerce${typeName}Input`;
|
|
9
|
+
const repairParserName = (typeName) => `_parse${typeName}Repair`;
|
|
10
|
+
const COMBINATOR_KEYS = ["anyOf", "oneOf", "allOf", "if", "not"];
|
|
11
|
+
const SINGLE_SUBSCHEMA_KEYS = [
|
|
12
|
+
"additionalProperties",
|
|
13
|
+
"additionalItems",
|
|
14
|
+
"contains",
|
|
15
|
+
"propertyNames",
|
|
16
|
+
"not",
|
|
17
|
+
"if",
|
|
18
|
+
"then",
|
|
19
|
+
"else",
|
|
20
|
+
"unevaluatedProperties",
|
|
21
|
+
"unevaluatedItems"
|
|
22
|
+
];
|
|
23
|
+
const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
24
|
+
const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
25
|
+
const scanOwnTree = (schema) => {
|
|
26
|
+
const refs = /* @__PURE__ */ new Set();
|
|
27
|
+
let combinator = false;
|
|
28
|
+
const visit = (node) => {
|
|
29
|
+
if (!isSchemaObject(node))
|
|
30
|
+
return;
|
|
31
|
+
const record = node;
|
|
32
|
+
const ref = readKey(record, "$ref");
|
|
33
|
+
if (typeof ref === "string")
|
|
34
|
+
refs.add(ref);
|
|
35
|
+
if (COMBINATOR_KEYS.some((key) => readKey(record, key) !== void 0))
|
|
36
|
+
combinator = true;
|
|
37
|
+
for (const key of SINGLE_SUBSCHEMA_KEYS)
|
|
38
|
+
visit(readKey(record, key));
|
|
39
|
+
const items = readKey(record, "items");
|
|
40
|
+
if (Array.isArray(items))
|
|
41
|
+
items.forEach(visit);
|
|
42
|
+
else
|
|
43
|
+
visit(items);
|
|
44
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
45
|
+
const list = readKey(record, key);
|
|
46
|
+
if (Array.isArray(list))
|
|
47
|
+
list.forEach(visit);
|
|
48
|
+
}
|
|
49
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
50
|
+
const map = readKey(record, key);
|
|
51
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
52
|
+
for (const value of Object.values(map))
|
|
53
|
+
if (!Array.isArray(value))
|
|
54
|
+
visit(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
visit(schema);
|
|
59
|
+
return { refs, combinator };
|
|
60
|
+
};
|
|
61
|
+
const emitExactHalf = (schema, typeName, typeSuffix, rootSchema, wrap) => {
|
|
62
|
+
let walk;
|
|
63
|
+
try {
|
|
64
|
+
walk = generateCoerceWalk(schema, {
|
|
65
|
+
typeSuffix,
|
|
66
|
+
rootSchema,
|
|
67
|
+
refCoercer: (ref) => exactCoercerName(refToName(ref, typeSuffix)),
|
|
68
|
+
refGuard: exactMatcherName,
|
|
69
|
+
hoistNamespace: "_exact",
|
|
70
|
+
withRootMatch: true
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const { declarations, expression, rootMatch } = walk;
|
|
76
|
+
const matcher = exactMatcherName(typeName);
|
|
77
|
+
const matcherSource = typeof rootMatch === "string" ? [
|
|
78
|
+
`export const ${matcher} = (${/\binput\b/.test(rootMatch) ? "input" : "_input"}: unknown): boolean => {`,
|
|
79
|
+
.../\b_path\b/.test(rootMatch) ? [` const _path = ''`] : [],
|
|
80
|
+
rootMatch,
|
|
81
|
+
`}`
|
|
82
|
+
].join("\n") : `export const ${matcher} = (_input: unknown): boolean => ${rootMatch === true}`;
|
|
83
|
+
const text = [...declarations, matcherSource].join("\n");
|
|
84
|
+
const needsErrorType = /\bValidationError\b/.test(text);
|
|
85
|
+
if (needsErrorType && typeName === "ValidationError")
|
|
86
|
+
return null;
|
|
87
|
+
const coercer = exactCoercerName(typeName);
|
|
88
|
+
const parts = [
|
|
89
|
+
...needsErrorType ? ["type ValidationError = { message: string; path: string; keyword: string; params: Record<string, unknown> }"] : [],
|
|
90
|
+
...declarations,
|
|
91
|
+
matcherSource,
|
|
92
|
+
`export const ${coercer} = (input: unknown): unknown => ${expression ?? "input"}`
|
|
93
|
+
];
|
|
94
|
+
if (wrap) {
|
|
95
|
+
const walked = expression === null ? [] : [` const value = ${coercer}(input)`, ` if (${matcher}(value)) return value as ${typeName}`];
|
|
96
|
+
parts.push([
|
|
97
|
+
`export const parse${typeName} = (input: unknown): ${typeName} => {`,
|
|
98
|
+
` if (${matcher}(input)) return input as ${typeName}`,
|
|
99
|
+
...walked,
|
|
100
|
+
` return ${repairParserName(typeName)}(input)`,
|
|
101
|
+
`}`
|
|
102
|
+
].join("\n"));
|
|
103
|
+
}
|
|
104
|
+
return parts.join("\n\n");
|
|
105
|
+
};
|
|
106
|
+
const planExactHalves = (nodes, typeSuffix) => {
|
|
107
|
+
const byName = new Map(nodes.map((node) => [node.typeName, node]));
|
|
108
|
+
const scans = new Map(nodes.map((node) => [node.typeName, scanOwnTree(node.schema)]));
|
|
109
|
+
const targetsOf = (typeName) => [...scans.get(typeName)?.refs ?? []].map((ref) => refToName(ref, typeSuffix));
|
|
110
|
+
const wrapped = new Set(nodes.filter((node) => scans.get(node.typeName)?.combinator === true).map((n) => n.typeName));
|
|
111
|
+
const closure = /* @__PURE__ */ new Set();
|
|
112
|
+
const queue = [...wrapped];
|
|
113
|
+
while (queue.length > 0) {
|
|
114
|
+
const name = queue.pop();
|
|
115
|
+
if (closure.has(name))
|
|
116
|
+
continue;
|
|
117
|
+
closure.add(name);
|
|
118
|
+
for (const target of targetsOf(name))
|
|
119
|
+
if (!closure.has(target))
|
|
120
|
+
queue.push(target);
|
|
121
|
+
}
|
|
122
|
+
const emitted = /* @__PURE__ */ new Map();
|
|
123
|
+
const failed = /* @__PURE__ */ new Set();
|
|
124
|
+
for (const name of closure) {
|
|
125
|
+
const node = byName.get(name);
|
|
126
|
+
const code = node === void 0 ? null : emitExactHalf(node.schema, name, typeSuffix, node.rootSchema, wrapped.has(name));
|
|
127
|
+
if (code === null)
|
|
128
|
+
failed.add(name);
|
|
129
|
+
else
|
|
130
|
+
emitted.set(name, code);
|
|
131
|
+
}
|
|
132
|
+
let changed = true;
|
|
133
|
+
while (changed) {
|
|
134
|
+
changed = false;
|
|
135
|
+
for (const name of emitted.keys()) {
|
|
136
|
+
if (targetsOf(name).some((target) => failed.has(target) || !byName.has(target))) {
|
|
137
|
+
emitted.delete(name);
|
|
138
|
+
failed.add(name);
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return new Map([...emitted].map(([name, code]) => [name, { code, wrap: wrapped.has(name) }]));
|
|
144
|
+
};
|
|
145
|
+
const exactHalfImports = (schema, code, options) => {
|
|
146
|
+
const mentions = identifierMentions(code);
|
|
147
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const ref of scanOwnTree(schema).refs) {
|
|
149
|
+
const filename = refToFilename(ref);
|
|
150
|
+
if (filename === options.selfFilename || byFile.has(filename))
|
|
151
|
+
continue;
|
|
152
|
+
const typeName = refToName(ref, options.typeSuffix);
|
|
153
|
+
const names = [exactMatcherName(typeName), exactCoercerName(typeName)].filter((name) => mentions(name));
|
|
154
|
+
if (names.length > 0)
|
|
155
|
+
byFile.set(filename, names);
|
|
156
|
+
}
|
|
157
|
+
return [...byFile].map(([filename, names]) => `import { ${names.join(", ")} } from './${filename}.${options.importExt}';`).sort();
|
|
158
|
+
};
|
|
159
|
+
export {
|
|
160
|
+
exactCoercerName,
|
|
161
|
+
exactHalfImports,
|
|
162
|
+
exactMatcherName,
|
|
163
|
+
planExactHalves,
|
|
164
|
+
repairParserName
|
|
165
|
+
};
|
|
@@ -2,6 +2,7 @@ import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy'
|
|
|
2
2
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
3
|
import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
|
|
4
4
|
import { type ImportExtension } from '../helpers/collect-imports.js';
|
|
5
|
+
import { type ExactHalf } from './generate-exact-half.js';
|
|
5
6
|
/**
|
|
6
7
|
* Options for controlling what gets generated in a file.
|
|
7
8
|
*/
|
|
@@ -99,6 +100,13 @@ type GenerateFileOptions = {
|
|
|
99
100
|
* {@link UnknownKeysStrategy} for the trade-off between the two.
|
|
100
101
|
*/
|
|
101
102
|
readonly unknownKeys?: UnknownKeysStrategy;
|
|
103
|
+
/**
|
|
104
|
+
* The exact, coercing half this definition carries, planned across the whole
|
|
105
|
+
* build by `planExactHalves`: a coercing parser that agrees with `coerceX` on
|
|
106
|
+
* every document `coerceX` accepts. Absent for a strict, stripping or
|
|
107
|
+
* types-only build, and for a definition that needs none.
|
|
108
|
+
*/
|
|
109
|
+
readonly exactHalf?: ExactHalf;
|
|
102
110
|
};
|
|
103
111
|
/** Result of generating a single parser file. */
|
|
104
112
|
export type GeneratedFileContent = {
|
|
@@ -2,6 +2,7 @@ import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition
|
|
|
2
2
|
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
3
3
|
import { collectHelpers } from "../helpers/collect-helpers.js";
|
|
4
4
|
import { collectImports, collectImportTypeNames } from "../helpers/collect-imports.js";
|
|
5
|
+
import { exactHalfImports, repairParserName } from "./generate-exact-half.js";
|
|
5
6
|
import { generateParserFunction, generateShapeValidator } from "./generate-parser-function.js";
|
|
6
7
|
const generateFile = (schema, typeName, options) => {
|
|
7
8
|
const typesOnly = options?.typesOnly === true;
|
|
@@ -44,7 +45,9 @@ const generateFile = (schema, typeName, options) => {
|
|
|
44
45
|
});
|
|
45
46
|
const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
|
|
46
47
|
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames, unknownKeys);
|
|
48
|
+
const exactHalf = options?.exactHalf;
|
|
47
49
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
50
|
+
...exactHalf?.wrap === true ? { entryName: repairParserName(typeName) } : {},
|
|
48
51
|
useRefImports: true,
|
|
49
52
|
typeSuffix,
|
|
50
53
|
reservedNames,
|
|
@@ -59,7 +62,9 @@ const generateFile = (schema, typeName, options) => {
|
|
|
59
62
|
});
|
|
60
63
|
const combinedFunctions = `${shapeValidator}
|
|
61
64
|
|
|
62
|
-
${parserFunction}
|
|
65
|
+
${parserFunction}` + (exactHalf === void 0 ? "" : `
|
|
66
|
+
|
|
67
|
+
${exactHalf.code}`);
|
|
63
68
|
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
|
|
64
69
|
const imports = [
|
|
65
70
|
...collectImports(schema, {
|
|
@@ -72,6 +77,11 @@ ${parserFunction}`;
|
|
|
72
77
|
// the functions the emitted code calls are one question about one text.
|
|
73
78
|
usedIn: typeDefinition + "\n\n" + combinedFunctions
|
|
74
79
|
}),
|
|
80
|
+
...exactHalf === void 0 ? [] : exactHalfImports(schema, exactHalf.code, {
|
|
81
|
+
...options?.selfFilename !== void 0 ? { selfFilename: options.selfFilename } : {},
|
|
82
|
+
typeSuffix,
|
|
83
|
+
importExt
|
|
84
|
+
}),
|
|
75
85
|
...helpers.imports
|
|
76
86
|
];
|
|
77
87
|
let result = "";
|
|
@@ -4,6 +4,12 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
4
4
|
* Options for controlling parser function generation behavior.
|
|
5
5
|
*/
|
|
6
6
|
type GenerateParserOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The name to emit this definition's parser under, in place of `parseX`. Used
|
|
9
|
+
* when `parseX` is a wrapper that runs the exact, coercing half first and falls
|
|
10
|
+
* back to this parser only for a value no coercion can make valid.
|
|
11
|
+
*/
|
|
12
|
+
readonly entryName?: string;
|
|
7
13
|
/**
|
|
8
14
|
* When true, properties with $ref will call the imported parser function
|
|
9
15
|
* instead of inlining the validation logic. This is used when generating
|
|
@@ -12,14 +12,13 @@ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
|
12
12
|
import { findDiscriminator } from "../helpers/find-discriminator.js";
|
|
13
13
|
import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
|
|
14
14
|
import { assertNoUnsupportedKeywords } from "./assert-supported-keywords.js";
|
|
15
|
-
import { generateEnumCaseInsensitiveCoercion } from "./generate-enum-check.js";
|
|
15
|
+
import { generateEnumCaseInsensitiveCoercion, generateEnumCheck } from "./generate-enum-check.js";
|
|
16
16
|
import { generateBackstopAssertion, generateCompositionChecks, generateContainsCheck, generateKeyedValueChecks, generateObjectKeywordChecks, generateObjectSizeChecks, generateObjectStrictAssertion, generateScalarStrictAssertion, inlineAllOfMembers } from "./generate-strict-assertion.js";
|
|
17
17
|
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, hasFastPathBlockingKeyword, isExclusiveUnion, isInlineObjectArrayProperty, isInlineObjectProperty, isInlineUnionProperty, isUnionArrayProperty, shapeValidatorName } from "./generate-type-checks.js";
|
|
18
18
|
import { generateUniqueItemsCheck } from "./generate-unique-items-check.js";
|
|
19
19
|
import { generatePrefixItemsMap, generateValidationExpression, getPrefixItems, isCoercibleItemSchema, prefixItemsCapsLength } from "./generate-validation-expression.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
20
|
+
let entryOverride = null;
|
|
21
|
+
const generateParserName = (typeName) => entryOverride !== null && entryOverride.typeName === typeName ? entryOverride.name : `parse${typeName}`;
|
|
23
22
|
const safeLiteralKey = (key) => key === "__proto__" ? '["__proto__"]' : safeKey(key);
|
|
24
23
|
const isPropertyRequired = (key, schema) => {
|
|
25
24
|
if (!isSchemaObject(schema)) {
|
|
@@ -224,6 +223,7 @@ const SCALAR_ROOT_CONSTRAINTS = [
|
|
|
224
223
|
"exclusiveMaximum",
|
|
225
224
|
"multipleOf"
|
|
226
225
|
];
|
|
226
|
+
const COERCED_SCALAR_ROOT_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean"]);
|
|
227
227
|
const isConstrainedScalarRoot = (schema) => {
|
|
228
228
|
if (!isSchemaObject(schema) || !hasType(schema))
|
|
229
229
|
return false;
|
|
@@ -472,11 +472,11 @@ ${assertions}
|
|
|
472
472
|
return isPrimitive ? `export const ${functionName} = (input: unknown): ${typeName} => input === ${literal} ? input as ${typeName} : ${literal} as ${typeName};` : `export const ${functionName} = (input: unknown): ${typeName} => ${literal} as ${typeName};`;
|
|
473
473
|
}
|
|
474
474
|
if (hasEnum(schema) && schema.enum.length > 0) {
|
|
475
|
-
const values = JSON.stringify(schema.enum);
|
|
476
475
|
const fallback = JSON.stringify(schema.enum[0]);
|
|
477
476
|
const ci = unionCtx?.caseInsensitive ? generateEnumCaseInsensitiveCoercion("input", schema.enum, fallback) : null;
|
|
478
477
|
const coerced = ci ? `(${ci})` : fallback;
|
|
479
|
-
|
|
478
|
+
const member = generateEnumCheck("input", schema.enum);
|
|
479
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => ${member} ? input as ${typeName} : ${coerced} as ${typeName};`;
|
|
480
480
|
}
|
|
481
481
|
if (hasOneOf(schema) || hasAnyOf(schema)) {
|
|
482
482
|
const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
|
|
@@ -584,8 +584,22 @@ ${assertion}
|
|
|
584
584
|
return ${returnExpr};
|
|
585
585
|
};`;
|
|
586
586
|
}
|
|
587
|
-
if (isConstrainedScalarRoot(schema)) {
|
|
588
|
-
const expr = generateValidationExpression(
|
|
587
|
+
if (isConstrainedScalarRoot(schema) || COERCED_SCALAR_ROOT_TYPES.has(schema.type)) {
|
|
588
|
+
const expr = generateValidationExpression(
|
|
589
|
+
"",
|
|
590
|
+
schema,
|
|
591
|
+
getDefaultValue(schema),
|
|
592
|
+
true,
|
|
593
|
+
unionCtx?.rootSchema,
|
|
594
|
+
void 0,
|
|
595
|
+
"input",
|
|
596
|
+
// A parser's `input` can be `undefined` — a parent building the default for
|
|
597
|
+
// a missing required property calls `parseX(undefined)` — and coercing that
|
|
598
|
+
// to the string `"undefined"` is worse than the type's own default. The
|
|
599
|
+
// constrained path keeps what it always passed.
|
|
600
|
+
isConstrainedScalarRoot(schema),
|
|
601
|
+
unionCtx?.caseInsensitive
|
|
602
|
+
);
|
|
589
603
|
return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
|
|
590
604
|
}
|
|
591
605
|
switch (schema.type) {
|
|
@@ -847,10 +861,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
847
861
|
const propertyKeys = Object.keys(schemaProps);
|
|
848
862
|
const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
|
|
849
863
|
const preamble = [];
|
|
864
|
+
const prunable = /* @__PURE__ */ new Map();
|
|
850
865
|
for (const [key, subName] of subTypeNames) {
|
|
851
866
|
const propSchema = schemaProps[key];
|
|
852
867
|
preamble.push(`type ${subName} = NonNullable<${typeName}>[${JSON.stringify(key)}];`);
|
|
853
868
|
const subShapeValidator = generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames, unknownKeys);
|
|
869
|
+
prunable.set(subShapeValidator, `${shapeValidatorName(subName)}(`);
|
|
854
870
|
preamble.push(subShapeValidator);
|
|
855
871
|
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive, subShapeValidator, unknownKeys));
|
|
856
872
|
}
|
|
@@ -859,11 +875,14 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
859
875
|
const itemSchema = propSchema.items;
|
|
860
876
|
preamble.push(`type ${subName} = NonNullable<NonNullable<${typeName}>[${JSON.stringify(key)}]>[number];`);
|
|
861
877
|
const itemShapeValidator = generateShapeValidator(itemSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames, unknownKeys);
|
|
878
|
+
prunable.set(itemShapeValidator, `${shapeValidatorName(subName)}(`);
|
|
862
879
|
preamble.push(itemShapeValidator);
|
|
863
|
-
|
|
880
|
+
const everyLoop = `const _every${subName} = (arr: readonly unknown[]): boolean => {
|
|
864
881
|
for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;
|
|
865
882
|
return true;
|
|
866
|
-
}
|
|
883
|
+
};`;
|
|
884
|
+
prunable.set(everyLoop, `_every${subName}(`);
|
|
885
|
+
preamble.push(everyLoop);
|
|
867
886
|
const unionBranches = strict ? null : getUnionBranches(itemSchema);
|
|
868
887
|
const scoredItemParser = unionBranches === null ? null : generateScoredUnionParser(generateParserName(subName), subName, unionBranches, {
|
|
869
888
|
useRefImports,
|
|
@@ -1373,9 +1392,21 @@ ${assertBodyLines.join("\n")}
|
|
|
1373
1392
|
}
|
|
1374
1393
|
lines.push(`}`);
|
|
1375
1394
|
const fn = lines.join("\n");
|
|
1376
|
-
|
|
1395
|
+
let kept = preamble;
|
|
1396
|
+
if (shapeValidatorSource !== void 0) {
|
|
1397
|
+
let changed = true;
|
|
1398
|
+
while (changed) {
|
|
1399
|
+
const current = kept;
|
|
1400
|
+
kept = current.filter((entry) => {
|
|
1401
|
+
const call = prunable.get(entry);
|
|
1402
|
+
return call === void 0 || fn.includes(call) || shapeValidatorSource.includes(call) || current.some((other) => other !== entry && other.includes(call));
|
|
1403
|
+
});
|
|
1404
|
+
changed = kept.length !== current.length;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (kept.length === 0)
|
|
1377
1408
|
return fn;
|
|
1378
|
-
return `${
|
|
1409
|
+
return `${kept.join("\n\n")}
|
|
1379
1410
|
|
|
1380
1411
|
${fn}`;
|
|
1381
1412
|
};
|
|
@@ -1837,7 +1868,14 @@ const generateParserFunction = (schema, typeName, rawOptions) => {
|
|
|
1837
1868
|
...options.stripUnknown !== void 0 ? { stripUnknown: options.stripUnknown } : {}
|
|
1838
1869
|
});
|
|
1839
1870
|
}
|
|
1840
|
-
|
|
1871
|
+
if (options?.entryName === void 0)
|
|
1872
|
+
return selectParserStrategy(schema, typeName, options);
|
|
1873
|
+
entryOverride = { typeName, name: options.entryName };
|
|
1874
|
+
try {
|
|
1875
|
+
return selectParserStrategy(schema, typeName, options);
|
|
1876
|
+
} finally {
|
|
1877
|
+
entryOverride = null;
|
|
1878
|
+
}
|
|
1841
1879
|
};
|
|
1842
1880
|
const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeName);
|
|
1843
1881
|
const generateShapeValidator = (schema, typeName, useRefImports, suffix = "", exported = true, stripUnknown = false, reservedNames = NO_RESERVED_NAMES, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Names (without `.ts`) of runtime helpers that can be embedded into generated output. */
|
|
2
|
-
export type RuntimeHelperName = 'is-object' | 'validate-array' | 'validate-record' | 'has-ref';
|
|
2
|
+
export type RuntimeHelperName = 'is-object' | 'validate-array' | 'validate-record' | 'has-ref' | 'coercion-runtime';
|
|
3
3
|
/** Controls how generated parsers reference their runtime helpers. */
|
|
4
4
|
export type HelpersMode = 'package' | 'embedded';
|
|
5
5
|
export type CollectedHelpers = {
|
|
@@ -3,14 +3,27 @@ const PACKAGE_IMPORTS = {
|
|
|
3
3
|
"validate-array": "import { validateArray } from '@amritk/helpers/validate-array';",
|
|
4
4
|
"validate-record": "import { validateRecord } from '@amritk/helpers/validate-record';",
|
|
5
5
|
// hasRef has historically lived in the schema-guards subpath; preserve that for package mode.
|
|
6
|
-
"has-ref": "import { hasRef } from '@amritk/helpers/schema-guards';"
|
|
6
|
+
"has-ref": "import { hasRef } from '@amritk/helpers/schema-guards';",
|
|
7
|
+
// Never used: the coercion runtime exports several functions and a file imports
|
|
8
|
+
// only the ones it calls, so its line is built per file (see below).
|
|
9
|
+
"coercion-runtime": ""
|
|
7
10
|
};
|
|
8
11
|
const EMBEDDED_NAMED_EXPORTS = {
|
|
9
12
|
"is-object": "isObject",
|
|
10
13
|
"validate-array": "validateArray",
|
|
11
14
|
"validate-record": "validateRecord",
|
|
12
|
-
"has-ref": "hasRef"
|
|
15
|
+
"has-ref": "hasRef",
|
|
16
|
+
"coercion-runtime": ""
|
|
13
17
|
};
|
|
18
|
+
const COERCION_RUNTIME = [
|
|
19
|
+
"coerceScalar",
|
|
20
|
+
"coerceUnion",
|
|
21
|
+
"valuesEqual",
|
|
22
|
+
"allUnique",
|
|
23
|
+
"everyItem",
|
|
24
|
+
"escapePointer"
|
|
25
|
+
];
|
|
26
|
+
const COERCION_RUNTIME_USAGE = /\b(coerceScalar|coerceUnion|valuesEqual|allUnique|everyItem|escapePointer)\(/g;
|
|
14
27
|
const embeddedImport = (helper, prefix, ext) => (
|
|
15
28
|
// An explicit extension so the embedded-helper import resolves under Node ESM, not only Bun.
|
|
16
29
|
`import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.${ext}';`
|
|
@@ -57,6 +70,16 @@ const collectHelpers = (parserFunction, mode, helpersImportPrefix = "./", import
|
|
|
57
70
|
imports.push(importFor("has-ref"));
|
|
58
71
|
used.add("has-ref");
|
|
59
72
|
}
|
|
73
|
+
const runtime = /* @__PURE__ */ new Set();
|
|
74
|
+
COERCION_RUNTIME_USAGE.lastIndex = 0;
|
|
75
|
+
for (let hit = COERCION_RUNTIME_USAGE.exec(parserFunction); hit !== null; hit = COERCION_RUNTIME_USAGE.exec(parserFunction)) {
|
|
76
|
+
runtime.add(hit[1]);
|
|
77
|
+
}
|
|
78
|
+
if (runtime.size > 0) {
|
|
79
|
+
const names = COERCION_RUNTIME.filter((name) => runtime.has(name)).join(", ");
|
|
80
|
+
imports.push(mode === "embedded" ? `import { ${names} } from '${helpersImportPrefix}_helpers/coercion-runtime.${importExt}';` : `import { ${names} } from '@amritk/helpers/coercion-runtime';`);
|
|
81
|
+
used.add("coercion-runtime");
|
|
82
|
+
}
|
|
60
83
|
return { imports, used };
|
|
61
84
|
};
|
|
62
85
|
export {
|