@amritk/generate-parsers 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/generators/assert-supported-keywords.d.ts +26 -0
- package/dist/generators/assert-supported-keywords.js +74 -0
- package/dist/generators/generate-parser-function.js +136 -52
- package/dist/generators/generate-schema-checks.js +161 -1
- package/dist/generators/generate-strict-assertion.d.ts +22 -1
- package/dist/generators/generate-strict-assertion.js +213 -12
- package/dist/generators/generate-type-checks.d.ts +9 -0
- package/dist/generators/generate-type-checks.js +49 -1
- package/dist/generators/generate-validation-expression.d.ts +23 -0
- package/dist/generators/generate-validation-expression.js +72 -3
- package/dist/generators/subschema-match.d.ts +29 -0
- package/dist/generators/subschema-match.js +286 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -156,6 +156,51 @@ The generator handles:
|
|
|
156
156
|
|
|
157
157
|
---
|
|
158
158
|
|
|
159
|
+
## Benchmarks
|
|
160
|
+
|
|
161
|
+
Generated parsers are plain, straight-line TypeScript — no schema walking and no
|
|
162
|
+
generic dispatch at runtime, because the schema was already spent at build time.
|
|
163
|
+
Each parser reads the exact keys it declares, coerces or asserts them inline, and
|
|
164
|
+
returns a fresh typed object, so on valid input it runs several times faster than
|
|
165
|
+
libraries that interpret a schema (or a schema-shaped object graph) on every
|
|
166
|
+
call. The `bench/` suite replicates the `parseSafe` (assert + strip undeclared
|
|
167
|
+
keys) and `parseStrict` (assert + reject undeclared keys) halves of
|
|
168
|
+
[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
|
|
169
|
+
against the other *pure* parsers — the ones that return a new typed value rather
|
|
170
|
+
than mutating in place — [zod](https://zod.dev) and
|
|
171
|
+
[TypeBox](https://github.com/sinclairzx81/typebox). Measured on Bun 1.3 (Linux
|
|
172
|
+
x64), parsing valid input at steady state:
|
|
173
|
+
|
|
174
|
+
| case | mode | mjst (generated) | zod | typebox |
|
|
175
|
+
|:--|:--|--:|--:|--:|
|
|
176
|
+
| user (4 fields) | parseSafe | **~11M** ops/s | ~2.5M ops/s | ~1.0M ops/s |
|
|
177
|
+
| order (nested + array) | parseSafe | **~3.9M** ops/s | ~0.46M ops/s | ~0.13M ops/s |
|
|
178
|
+
| user (4 fields) | parseStrict | **~9M** ops/s | ~1.4M ops/s | ~1.3M ops/s |
|
|
179
|
+
| order (nested + array) | parseStrict | **~5.4M** ops/s | ~0.27M ops/s | ~0.20M ops/s |
|
|
180
|
+
|
|
181
|
+
The upstream `assert` case (seven scalar roots plus a nested object) runs faster
|
|
182
|
+
still — tens of millions of ops/s — but at that size the numbers swing enough
|
|
183
|
+
run-to-run that the ratio, not the absolute, is the honest signal: mjst lands
|
|
184
|
+
~5–30× ahead of zod across every case above.
|
|
185
|
+
|
|
186
|
+
The trade is a one-shot **prepare** cost that only mjst pays — generating the
|
|
187
|
+
parser source — which measures **~0.1–0.8 ms** per schema here (zod and TypeBox
|
|
188
|
+
author or interpret their parsers with no separate build step, so there is
|
|
189
|
+
nothing to time). That is trivially amortized: you generate once at build time
|
|
190
|
+
and run the emitted code forever.
|
|
191
|
+
|
|
192
|
+
Every library is checked for agreement — same stripped/rejected output, same
|
|
193
|
+
throws on bad input — before it is timed, and each is timed in its own isolated
|
|
194
|
+
process over a pool of distinct inputs, reporting the median of many trials, so
|
|
195
|
+
the optimiser can't hoist the work away. Micro-benchmark figures vary by machine
|
|
196
|
+
and runtime — reproduce with:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bun run bench
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
159
204
|
## Related packages
|
|
160
205
|
|
|
161
206
|
- [`@amritk/mjst`](../cli) — CLI wrapping this generator
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
|
|
4
|
+
* parser generator cannot enforce but which narrows the set of valid documents.
|
|
5
|
+
* Mirrors generate-validators' `assertNoUnsupportedKeywords`: failing loudly at
|
|
6
|
+
* generation time is strictly better than silently emitting a parser that
|
|
7
|
+
* accepts input the schema forbids — a strict parser promises to "throw on
|
|
8
|
+
* violations".
|
|
9
|
+
*
|
|
10
|
+
* Two families are rejected:
|
|
11
|
+
*
|
|
12
|
+
* - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
|
|
13
|
+
* (`false` or a subschema). The generator has no support for the "which
|
|
14
|
+
* properties/items were evaluated" bookkeeping these require; only the
|
|
15
|
+
* runtime interpreter does. A `true` value permits everything and is allowed.
|
|
16
|
+
*
|
|
17
|
+
* - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
|
|
18
|
+
* form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
|
|
19
|
+
* a schema-valued record, …). The parser enforces these keywords only for
|
|
20
|
+
* subschemas it can match exactly; anything else would be silently ignored.
|
|
21
|
+
*
|
|
22
|
+
* This runs for strict generation only — coercing parsers are documented to be
|
|
23
|
+
* permissive (they repair rather than reject), so ignoring these keywords there
|
|
24
|
+
* is the contract, not a lie.
|
|
25
|
+
*/
|
|
26
|
+
export declare const assertNoUnsupportedKeywords: (schema: JSONSchema, typeName: string) => void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isSchemaObject } from '@amritk/helpers/schema-guards';
|
|
2
|
+
import { canMatchSubschema } from './subschema-match.js';
|
|
3
|
+
/**
|
|
4
|
+
* Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
|
|
5
|
+
* parser generator cannot enforce but which narrows the set of valid documents.
|
|
6
|
+
* Mirrors generate-validators' `assertNoUnsupportedKeywords`: failing loudly at
|
|
7
|
+
* generation time is strictly better than silently emitting a parser that
|
|
8
|
+
* accepts input the schema forbids — a strict parser promises to "throw on
|
|
9
|
+
* violations".
|
|
10
|
+
*
|
|
11
|
+
* Two families are rejected:
|
|
12
|
+
*
|
|
13
|
+
* - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
|
|
14
|
+
* (`false` or a subschema). The generator has no support for the "which
|
|
15
|
+
* properties/items were evaluated" bookkeeping these require; only the
|
|
16
|
+
* runtime interpreter does. A `true` value permits everything and is allowed.
|
|
17
|
+
*
|
|
18
|
+
* - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
|
|
19
|
+
* form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
|
|
20
|
+
* a schema-valued record, …). The parser enforces these keywords only for
|
|
21
|
+
* subschemas it can match exactly; anything else would be silently ignored.
|
|
22
|
+
*
|
|
23
|
+
* This runs for strict generation only — coercing parsers are documented to be
|
|
24
|
+
* permissive (they repair rather than reject), so ignoring these keywords there
|
|
25
|
+
* is the contract, not a lie.
|
|
26
|
+
*/
|
|
27
|
+
export const assertNoUnsupportedKeywords = (schema, typeName) => {
|
|
28
|
+
const fail = (keyword, detail) => {
|
|
29
|
+
throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would ` +
|
|
30
|
+
`silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this ` +
|
|
31
|
+
`schema with @amritk/generate-validators, or remove the keyword.`);
|
|
32
|
+
};
|
|
33
|
+
const visit = (node) => {
|
|
34
|
+
if (typeof node !== 'object' || node === null)
|
|
35
|
+
return;
|
|
36
|
+
if (Array.isArray(node)) {
|
|
37
|
+
for (const item of node)
|
|
38
|
+
visit(item);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const record = node;
|
|
42
|
+
// `true` permits everything → no constraint → safe to ignore.
|
|
43
|
+
if ('unevaluatedProperties' in record && record['unevaluatedProperties'] !== true) {
|
|
44
|
+
fail('unevaluatedProperties', 'does not implement it');
|
|
45
|
+
}
|
|
46
|
+
if ('unevaluatedItems' in record && record['unevaluatedItems'] !== true) {
|
|
47
|
+
fail('unevaluatedItems', 'does not implement it');
|
|
48
|
+
}
|
|
49
|
+
if ('contains' in record && !canMatchSubschema(record['contains'])) {
|
|
50
|
+
fail('contains', 'cannot prove its subschema inline');
|
|
51
|
+
}
|
|
52
|
+
if ('propertyNames' in record && isSchemaObject(record['propertyNames'])) {
|
|
53
|
+
if (!canMatchSubschema(record['propertyNames'])) {
|
|
54
|
+
fail('propertyNames', 'cannot prove its subschema inline');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const dependentSchemas = record['dependentSchemas'];
|
|
58
|
+
if (typeof dependentSchemas === 'object' && dependentSchemas !== null && !Array.isArray(dependentSchemas)) {
|
|
59
|
+
for (const sub of Object.values(dependentSchemas)) {
|
|
60
|
+
// Boolean subschemas are enforced directly (see generateDependentSchemasChecks).
|
|
61
|
+
if (typeof sub === 'boolean')
|
|
62
|
+
continue;
|
|
63
|
+
if (!canMatchSubschema(sub)) {
|
|
64
|
+
fail('dependentSchemas', 'cannot prove one of its subschemas inline');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// `for..in` (not `Object.values`) to recurse without allocating an array per
|
|
69
|
+
// node — this walk runs for every strict parser generated.
|
|
70
|
+
for (const key in record)
|
|
71
|
+
visit(record[key]);
|
|
72
|
+
};
|
|
73
|
+
visit(schema);
|
|
74
|
+
};
|