fast-json-stringify 7.2.0 → 7.3.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/.github/workflows/release.yml +1 -1
- package/README.md +48 -3
- package/benchmark/bench-cmp-lib.js +10 -0
- package/benchmark/bench.js +74 -0
- package/index.js +48 -4
- package/lib/projection.js +325 -0
- package/package.json +1 -1
- package/test/array.test.js +29 -0
- package/test/object-mechanism-gate.test.js +18 -0
- package/test/object-mechanism-native.test.js +301 -0
- package/types/index.d.ts +9 -0
- package/types/index.tst.ts +4 -0
|
@@ -19,6 +19,6 @@ jobs:
|
|
|
19
19
|
permissions:
|
|
20
20
|
id-token: write # required for npm provenance via OIDC
|
|
21
21
|
contents: write # required to push the release commit and the tag
|
|
22
|
-
uses: fastify/workflows/.github/workflows/reusable-release.yml@
|
|
22
|
+
uses: fastify/workflows/.github/workflows/reusable-release.yml@731d0cca9a6a5b9586dad6b0326d49001ab9216f
|
|
23
23
|
with:
|
|
24
24
|
semver: ${{ inputs.semver }}
|
package/README.md
CHANGED
|
@@ -135,9 +135,8 @@ are rejected before compilation.
|
|
|
135
135
|
- `inlineValidators`: when using standalone mode, embed Ajv-generated validator functions in the output instead of compiling schemas at runtime. [More details](#standalone)
|
|
136
136
|
- `largeArrayMechanism`: set the mechanism that should be used to handle large
|
|
137
137
|
(by default `20000` or more items) arrays. [More details](#largearrays)
|
|
138
|
-
- `
|
|
139
|
-
`if/then/else` are compiled during `build()` instead of lazily, on the first serialization
|
|
140
|
-
that reaches them. This makes `build()` slower but removes a potentially large one-off cost
|
|
138
|
+
- `objectMechanism`: set the mechanism used to serialize objects. `'native'` projects the input into schema-shaped scratch objects and delegates to the native `JSON.stringify`, which is much faster on Node.js 25+. [More details](#objectmechanism)
|
|
139
|
+
- `compileValidators`: when `true`, the `ajv` validators used by `anyOf`, `oneOf` and `if/then/else` are compiled during `build()` instead of lazily, on the first serialization that reaches them. This makes `build()` slower but removes a potentially large one-off cost
|
|
141
140
|
from the first `stringify()` call. Defaults to `false`.
|
|
142
141
|
|
|
143
142
|
|
|
@@ -673,6 +672,52 @@ In [standalone mode](#standalone) the cutoffs are baked into the generated
|
|
|
673
672
|
code, so the generated module inherits the cutoffs of the Node.js version that
|
|
674
673
|
ran `build()`, not of the one that runs the module.
|
|
675
674
|
|
|
675
|
+
<a name="objectmechanism"></a>
|
|
676
|
+
#### Object mechanism
|
|
677
|
+
|
|
678
|
+
By default every object is serialized by generated code that appends the JSON
|
|
679
|
+
text property by property. Since V8 13.8 (Node.js 25+) the native
|
|
680
|
+
`JSON.stringify` has a side-effect-free fast path that no JavaScript string
|
|
681
|
+
building can match, but that fast path bails out as soon as the input carries a
|
|
682
|
+
`Date`, a `toJSON`, a class prototype or any property that is not plain data.
|
|
683
|
+
|
|
684
|
+
With `objectMechanism: 'native'`, `fast-json-stringify` instead *projects* the
|
|
685
|
+
input into preallocated scratch objects whose keys are exactly the schema
|
|
686
|
+
properties, in schema order, and hands the scratch to the native
|
|
687
|
+
`JSON.stringify`. The scratch is plain by construction, so the fast path is
|
|
688
|
+
always taken, whatever the input looks like, while the schema is still applied:
|
|
689
|
+
undeclared properties are dropped, `required` is enforced, `default` applied,
|
|
690
|
+
types coerced and `format: date-time` / `date` / `time` rendered as before.
|
|
691
|
+
The output is byte-identical to the default mechanism.
|
|
692
|
+
|
|
693
|
+
```js
|
|
694
|
+
const stringify = fastJson(schema, { objectMechanism: 'native' })
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
The projection covers schemas made only of:
|
|
698
|
+
|
|
699
|
+
- `object` with `properties` (no `additionalProperties`, `patternProperties`,
|
|
700
|
+
`nullable`, `dependencies`, combinators or `$ref`), with at least one property;
|
|
701
|
+
- `string` (optionally with `format: date-time`, `date` or `time`), `number`,
|
|
702
|
+
`integer`, `boolean`, without `nullable`, `const`, `enum`, combinators or `$ref`;
|
|
703
|
+
- the two-element forms `['string', 'null']`, `['number', 'null']` and
|
|
704
|
+
`['boolean', 'null']` (in either order), with the same strict type dispatch
|
|
705
|
+
as the default mechanism;
|
|
706
|
+
- `array` whose `items` is one such schema (no tuples), nested to any depth;
|
|
707
|
+
every array element gets its own scratch objects.
|
|
708
|
+
|
|
709
|
+
Any other schema silently uses the default mechanism, so the option is always
|
|
710
|
+
safe to enable. The projection only pays off where V8 has the fast path, so on
|
|
711
|
+
Node.js 24 and older the option is a no-op and the default mechanism is used.
|
|
712
|
+
On Node.js 25+ the projection serializes plain objects within 5-15% of a bare
|
|
713
|
+
`JSON.stringify` (the default mechanism is 35-50% behind it there), and objects
|
|
714
|
+
coming from an ORM (extra columns, `Date` instances, class prototypes) several
|
|
715
|
+
times faster than both the default mechanism and `JSON.stringify` itself.
|
|
716
|
+
|
|
717
|
+
The scratch objects are reused across calls; `JSON.stringify` is synchronous,
|
|
718
|
+
so this is safe, and no reference to the last input is retained beyond the
|
|
719
|
+
primitive values written into the scratch.
|
|
720
|
+
|
|
676
721
|
<a name="unsafe"></a>
|
|
677
722
|
#### Unsafe string
|
|
678
723
|
By default, the library escapes all strings. With the 'unsafe' format, the string isn't escaped. This has a potentially dangerous security issue. You can use it only if you are sure that your data doesn't need escaping. The advantage is a significant performance improvement.
|
|
@@ -114,6 +114,7 @@ const CJSStringifyString = CJS({ type: 'string' })
|
|
|
114
114
|
|
|
115
115
|
const FJS = require('..')
|
|
116
116
|
const stringify = FJS(schema)
|
|
117
|
+
const stringifyNative = FJS(schema, { objectMechanism: 'native' })
|
|
117
118
|
const stringifyArrayDefault = FJS(arraySchema)
|
|
118
119
|
const stringifyArrayJSONStringify = FJS(arraySchema, {
|
|
119
120
|
largeArrayMechanism: 'json-stringify'
|
|
@@ -282,6 +283,10 @@ suite.add('fast-json-stringify: obj', function () {
|
|
|
282
283
|
Buffer.byteLength(stringify(obj))
|
|
283
284
|
})
|
|
284
285
|
|
|
286
|
+
suite.add('fast-json-stringify [native]: obj', function () {
|
|
287
|
+
Buffer.byteLength(stringifyNative(obj))
|
|
288
|
+
})
|
|
289
|
+
|
|
285
290
|
suite.add('json-accelerator: obj', function () {
|
|
286
291
|
Buffer.byteLength(accelStringify(obj))
|
|
287
292
|
})
|
|
@@ -312,8 +317,10 @@ suite.add('compile-json-stringify: date', function () {
|
|
|
312
317
|
|
|
313
318
|
for (const { name, schema: responseSchema, input } of apiResponses) {
|
|
314
319
|
const stringifyResponse = FJS(responseSchema)
|
|
320
|
+
const stringifyResponseNative = FJS(responseSchema, { objectMechanism: 'native' })
|
|
315
321
|
// Check outside the timed loop that the schema preserves the complete response.
|
|
316
322
|
assert.deepStrictEqual(JSON.parse(stringifyResponse(input)), JSON.parse(JSON.stringify(input)))
|
|
323
|
+
assert.equal(stringifyResponseNative(input), stringifyResponse(input))
|
|
317
324
|
|
|
318
325
|
suite.add(`JSON.stringify: ${name}`, function () {
|
|
319
326
|
Buffer.byteLength(JSON.stringify(input))
|
|
@@ -321,6 +328,9 @@ for (const { name, schema: responseSchema, input } of apiResponses) {
|
|
|
321
328
|
suite.add(`fast-json-stringify: ${name}`, function () {
|
|
322
329
|
Buffer.byteLength(stringifyResponse(input))
|
|
323
330
|
})
|
|
331
|
+
suite.add(`fast-json-stringify [native]: ${name}`, function () {
|
|
332
|
+
Buffer.byteLength(stringifyResponseNative(input))
|
|
333
|
+
})
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
if (process.argv.includes('--fjs-vs-json')) {
|
package/benchmark/bench.js
CHANGED
|
@@ -300,6 +300,80 @@ const benchmarks = [
|
|
|
300
300
|
},
|
|
301
301
|
input: { firstName: 'Max', lastName: 'Power', age: 22 }
|
|
302
302
|
},
|
|
303
|
+
{
|
|
304
|
+
name: 'nested object with array and required',
|
|
305
|
+
schema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
required: ['id'],
|
|
308
|
+
properties: {
|
|
309
|
+
id: { type: 'integer' },
|
|
310
|
+
name: { type: 'string' },
|
|
311
|
+
email: { type: 'string' },
|
|
312
|
+
active: { type: 'boolean' },
|
|
313
|
+
score: { type: 'number' },
|
|
314
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
315
|
+
address: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
properties: {
|
|
318
|
+
street: { type: 'string' },
|
|
319
|
+
city: { type: 'string' },
|
|
320
|
+
zip: { type: 'string' }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' } }
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: 'nested object with array and required (objectMechanism: native)',
|
|
329
|
+
schema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
required: ['id'],
|
|
332
|
+
properties: {
|
|
333
|
+
id: { type: 'integer' },
|
|
334
|
+
name: { type: 'string' },
|
|
335
|
+
email: { type: 'string' },
|
|
336
|
+
active: { type: 'boolean' },
|
|
337
|
+
score: { type: 'number' },
|
|
338
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
339
|
+
address: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
street: { type: 'string' },
|
|
343
|
+
city: { type: 'string' },
|
|
344
|
+
zip: { type: 'string' }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
options: { objectMechanism: 'native' },
|
|
350
|
+
input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' } }
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'nested object with extra props and Date (objectMechanism: native)',
|
|
354
|
+
schema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
required: ['id'],
|
|
357
|
+
properties: {
|
|
358
|
+
id: { type: 'integer' },
|
|
359
|
+
name: { type: 'string' },
|
|
360
|
+
email: { type: 'string' },
|
|
361
|
+
active: { type: 'boolean' },
|
|
362
|
+
score: { type: 'number' },
|
|
363
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
364
|
+
address: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
street: { type: 'string' },
|
|
368
|
+
city: { type: 'string' },
|
|
369
|
+
zip: { type: 'string' }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
options: { objectMechanism: 'native' },
|
|
375
|
+
input: { id: 1, name: 'John Doe', email: 'john@example.com', active: true, score: 42.195, tags: ['a', 'b', 'c'], address: { street: '1 Example Street', city: 'London', zip: 'SW1A 1AA' }, passwordHash: 'x', createdAt: new Date(0) }
|
|
376
|
+
},
|
|
303
377
|
{
|
|
304
378
|
name: 'oneOf external refs compile and first stringify',
|
|
305
379
|
compile: true,
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const { MergeError } = require('@fastify/merge-json-schemas')
|
|
|
7
7
|
|
|
8
8
|
const Serializer = require('./lib/serializer')
|
|
9
9
|
const Validator = require('./lib/validator')
|
|
10
|
+
const { buildProjection } = require('./lib/projection')
|
|
10
11
|
const Location = require('./lib/location')
|
|
11
12
|
const validate = require('./lib/schema-validator')
|
|
12
13
|
const mergeSchemas = require('./lib/merge-schemas')
|
|
@@ -41,6 +42,9 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
|
|
|
41
42
|
// TODO: drop the Node 20 tier (and this version check) once Node 20 is no
|
|
42
43
|
// longer supported.
|
|
43
44
|
const nodeMajor = Number.parseInt(process.versions.node, 10)
|
|
45
|
+
// V8 13.8 (Node 25+) introduced the side-effect-free JSON.stringify fast path
|
|
46
|
+
// that objectMechanism: 'native' relies on.
|
|
47
|
+
const PROJECTION_SUPPORTED = nodeMajor >= 25
|
|
44
48
|
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)]
|
|
45
49
|
const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string
|
|
46
50
|
const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
|
|
@@ -97,6 +101,11 @@ const validLargeArrayMechanisms = new Set([
|
|
|
97
101
|
'json-stringify'
|
|
98
102
|
])
|
|
99
103
|
|
|
104
|
+
const validObjectMechanisms = new Set([
|
|
105
|
+
'default',
|
|
106
|
+
'native'
|
|
107
|
+
])
|
|
108
|
+
|
|
100
109
|
// Keywords that do not change the output, so a $ref carrying only these
|
|
101
110
|
// can still be dereferenced directly instead of merged with its target.
|
|
102
111
|
const IGNORED_REF_SIBLING_KEYWORDS = new Set([
|
|
@@ -442,6 +451,7 @@ function build (schema, options) {
|
|
|
442
451
|
buildingSet: new Set(),
|
|
443
452
|
largeArraySize: 2e4,
|
|
444
453
|
largeArrayMechanism: 'default',
|
|
454
|
+
objectMechanism: 'default',
|
|
445
455
|
uid: 0
|
|
446
456
|
}
|
|
447
457
|
|
|
@@ -479,6 +489,14 @@ function build (schema, options) {
|
|
|
479
489
|
}
|
|
480
490
|
}
|
|
481
491
|
|
|
492
|
+
if (options.objectMechanism) {
|
|
493
|
+
if (validObjectMechanisms.has(options.objectMechanism)) {
|
|
494
|
+
context.objectMechanism = options.objectMechanism
|
|
495
|
+
} else {
|
|
496
|
+
throw new Error(`Unsupported object mechanism ${options.objectMechanism}`)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
482
500
|
if (options.largeArraySize) {
|
|
483
501
|
const largeArraySizeType = typeof options.largeArraySize
|
|
484
502
|
let parsedNumber
|
|
@@ -500,7 +518,24 @@ function build (schema, options) {
|
|
|
500
518
|
if (hasRef) {
|
|
501
519
|
detectRecursiveSchemas(context, location)
|
|
502
520
|
}
|
|
503
|
-
|
|
521
|
+
|
|
522
|
+
// objectMechanism: 'native' projects the input into shape-stable scratch
|
|
523
|
+
// objects and delegates to the native JSON.stringify. Only whole schemas
|
|
524
|
+
// made of objects, arrays and primitives qualify; anything else falls back
|
|
525
|
+
// to the string codegen below, so output never changes for unsupported shapes.
|
|
526
|
+
// The projection only pays off where V8 has the side-effect-free
|
|
527
|
+
// JSON.stringify fast path (V8 13.8+, Node 25+): on older runtimes the
|
|
528
|
+
// string codegen is up to 2x faster, so the option is a no-op there.
|
|
529
|
+
let code = null
|
|
530
|
+
let projection = null
|
|
531
|
+
// `forceProjection` is an internal, undocumented escape hatch used by the
|
|
532
|
+
// test suite to exercise the projection on runtimes where it is gated off.
|
|
533
|
+
if (context.objectMechanism === 'native' && (options.forceProjection === true || PROJECTION_SUPPORTED)) {
|
|
534
|
+
projection = buildProjection(schema)
|
|
535
|
+
}
|
|
536
|
+
if (projection === null) {
|
|
537
|
+
code = buildValue(context, location, 'input')
|
|
538
|
+
}
|
|
504
539
|
|
|
505
540
|
let contextFunctionCode = `
|
|
506
541
|
${serializerFns}
|
|
@@ -522,7 +557,9 @@ function build (schema, options) {
|
|
|
522
557
|
// and the overhead of the intermediate variable 'json'. We can avoid the
|
|
523
558
|
// wrapping and the unnecessary memory allocation by aliasing 'anonymous0' to
|
|
524
559
|
// 'main'
|
|
525
|
-
if (
|
|
560
|
+
if (projection !== null) {
|
|
561
|
+
contextFunctionCode += projection
|
|
562
|
+
} else if (code === 'json += anonymous0(input)') {
|
|
526
563
|
contextFunctionCode += `
|
|
527
564
|
${context.functions.join('\n')}
|
|
528
565
|
const main = anonymous0
|
|
@@ -1161,7 +1198,7 @@ function buildArray (context, location, input) {
|
|
|
1161
1198
|
const tmpRes = buildValue(context, itemLocation, value)
|
|
1162
1199
|
functionCode += `
|
|
1163
1200
|
if (${i} < arrayLength) {
|
|
1164
|
-
if (${
|
|
1201
|
+
if (${buildTupleItemCondition(item, value)}) {
|
|
1165
1202
|
if (${i}) {
|
|
1166
1203
|
json += JSON_STR_COMMA
|
|
1167
1204
|
}
|
|
@@ -1255,7 +1292,7 @@ function buildArray (context, location, input) {
|
|
|
1255
1292
|
const tmpRes = buildValue(context, itemLocation, value)
|
|
1256
1293
|
inlinedCode += `
|
|
1257
1294
|
if (${i} < arrayLength_${objVar}) {
|
|
1258
|
-
if (${
|
|
1295
|
+
if (${buildTupleItemCondition(item, value)}) {
|
|
1259
1296
|
!addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)
|
|
1260
1297
|
${tmpRes}
|
|
1261
1298
|
} else {
|
|
@@ -1342,6 +1379,13 @@ function buildArrayTypeCondition (type, accessor) {
|
|
|
1342
1379
|
return condition
|
|
1343
1380
|
}
|
|
1344
1381
|
|
|
1382
|
+
// A nullable tuple item accepts null on top of its declared type(s);
|
|
1383
|
+
// buildValue() then serializes it as `null`.
|
|
1384
|
+
function buildTupleItemCondition (item, accessor) {
|
|
1385
|
+
const condition = buildArrayTypeCondition(item.type, accessor)
|
|
1386
|
+
return item.nullable === true ? `${accessor} === null || ${condition}` : condition
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1345
1389
|
function generateFuncName (context) {
|
|
1346
1390
|
return 'anonymous' + context.functionsCounter++
|
|
1347
1391
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Projection strategy (objectMechanism: 'native').
|
|
4
|
+
//
|
|
5
|
+
// Instead of building the JSON string in generated JS, project the input into
|
|
6
|
+
// preallocated "scratch" objects whose keys are exactly the schema properties,
|
|
7
|
+
// in schema order, and hand the scratch to the native JSON.stringify. The
|
|
8
|
+
// scratch is clean by construction (no extra properties, no Date, no toJSON,
|
|
9
|
+
// standard prototypes), so V8's side-effect-free fast path (V8 13.8+) is
|
|
10
|
+
// always taken, even when the user's input would have bailed out of it.
|
|
11
|
+
//
|
|
12
|
+
// Projectable schemas (every node must be one of these):
|
|
13
|
+
// - object with `properties` only: no additionalProperties, patternProperties,
|
|
14
|
+
// nullable, combinators, $ref, dependencies; at least one property;
|
|
15
|
+
// - string / number / integer / boolean without nullable, const, enum,
|
|
16
|
+
// combinators, $ref; string may have format date-time / date / time;
|
|
17
|
+
// - the two-element forms ['string'|'number'|'boolean', 'null'] (any order),
|
|
18
|
+
// with the strict no-coercion dispatch of the multi-type codegen;
|
|
19
|
+
// ['integer', 'null'] is left to the codegen (its null handling depends
|
|
20
|
+
// on the type order);
|
|
21
|
+
// - array whose `items` is a single projectable schema (not a tuple); array
|
|
22
|
+
// elements get their own scratch tree from a per-element slot pool.
|
|
23
|
+
// Anything else makes buildProjection return null and the caller falls back to
|
|
24
|
+
// the string codegen, so the option never changes output for unsupported
|
|
25
|
+
// schemas.
|
|
26
|
+
//
|
|
27
|
+
// Semantics match the string codegen byte for byte: input.toJSON() honoured,
|
|
28
|
+
// `required` missing throws, `undefined` omitted, `default` applied, null
|
|
29
|
+
// string -> "", null number -> 0, null object -> {}, Date in a string -> ISO,
|
|
30
|
+
// numbers via the asNumber/asInteger rules, booleans via truthiness.
|
|
31
|
+
|
|
32
|
+
const PRIMITIVES = new Set(['string', 'number', 'integer', 'boolean'])
|
|
33
|
+
const STRING_FORMATS = new Set([undefined, 'date-time', 'date', 'time'])
|
|
34
|
+
|
|
35
|
+
// ['string'|'number'|'boolean', 'null'] in either order -> the non-null type;
|
|
36
|
+
// anything else -> null. Also requires the format rules of the base type.
|
|
37
|
+
function nullableMultiType (schema) {
|
|
38
|
+
const t = schema.type
|
|
39
|
+
if (!Array.isArray(t) || t.length !== 2 || !t.includes('null')) return null
|
|
40
|
+
const base = t[0] === 'null' ? t[1] : t[0]
|
|
41
|
+
if (base !== 'string' && base !== 'number' && base !== 'boolean') return null
|
|
42
|
+
if (base === 'string' ? !STRING_FORMATS.has(schema.format) : schema.format !== undefined) return null
|
|
43
|
+
return base
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasDispatchKeyword (s) {
|
|
47
|
+
return s.nullable === true || s.const !== undefined || s.enum !== undefined ||
|
|
48
|
+
s.allOf !== undefined || s.anyOf !== undefined || s.oneOf !== undefined ||
|
|
49
|
+
s.if !== undefined || s.$ref !== undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isProjectable (schema, depth = 0) {
|
|
53
|
+
if (typeof schema !== 'object' || schema === null || depth > 32) return false
|
|
54
|
+
if (hasDispatchKeyword(schema)) return false
|
|
55
|
+
if (Array.isArray(schema.type)) return nullableMultiType(schema) !== null
|
|
56
|
+
|
|
57
|
+
if (schema.type === 'object') {
|
|
58
|
+
if (schema.additionalProperties !== undefined || schema.patternProperties !== undefined ||
|
|
59
|
+
schema.dependencies !== undefined ||
|
|
60
|
+
typeof schema.properties !== 'object' || schema.properties === null) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
const keys = Object.keys(schema.properties)
|
|
64
|
+
if (keys.length === 0) return false
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
if (!isProjectable(schema.properties[key], depth + 1)) return false
|
|
67
|
+
}
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (schema.type === 'array') {
|
|
72
|
+
if (schema.items === undefined || Array.isArray(schema.items) || schema.additionalItems !== undefined) return false
|
|
73
|
+
return isProjectable(schema.items, depth + 1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!PRIMITIVES.has(schema.type)) return false
|
|
77
|
+
if (schema.type === 'string') return STRING_FORMATS.has(schema.format)
|
|
78
|
+
return schema.format === undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function primitiveExpr (schema, src, pointerIdx) {
|
|
82
|
+
const base = nullableMultiType(schema)
|
|
83
|
+
if (base !== null) {
|
|
84
|
+
// Same acceptance rules as the multi-type codegen (buildMultiTypeSerializer):
|
|
85
|
+
// null passes through; a matching primitive goes through the single-type
|
|
86
|
+
// conversion; anything else is a TypeError. No coercion across types.
|
|
87
|
+
const err = `typeMismatch(${pointerIdx})`
|
|
88
|
+
const single = primitiveExpr({ ...schema, type: base }, src, pointerIdx)
|
|
89
|
+
if (base === 'string') {
|
|
90
|
+
return `(${src} === null ? null : (typeof ${src} === 'string' || ${src} instanceof Date || ${src} instanceof RegExp || (typeof ${src} === 'object' && typeof ${src}.toString === 'function' && ${src}.toString !== Object.prototype.toString)) ? ${single} : ${err})`
|
|
91
|
+
}
|
|
92
|
+
if (base === 'number') {
|
|
93
|
+
return `(${src} === null ? null : typeof ${src} === 'number' ? ${single} : ${err})`
|
|
94
|
+
}
|
|
95
|
+
return `(${src} === null ? null : typeof ${src} === 'boolean' ? ${src} : ${err})`
|
|
96
|
+
}
|
|
97
|
+
switch (schema.type) {
|
|
98
|
+
case 'string':
|
|
99
|
+
if (schema.format === 'date-time') return `asDateTimeValue(${src})`
|
|
100
|
+
if (schema.format === 'date') return `asDateValue(${src})`
|
|
101
|
+
if (schema.format === 'time') return `asTimeValue(${src})`
|
|
102
|
+
return `asStringValue(${src})`
|
|
103
|
+
case 'number': return `asNumberValue(${src})`
|
|
104
|
+
case 'integer': return `asIntegerValue(${src})`
|
|
105
|
+
case 'boolean': return `(${src} ? true : false)`
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Same order as the string codegen: required properties first (stable sort).
|
|
110
|
+
function orderedKeys (schema) {
|
|
111
|
+
const required = schema.required || []
|
|
112
|
+
return Object.keys(schema.properties).sort((a, b) => {
|
|
113
|
+
const ra = required.includes(a); const rb = required.includes(b)
|
|
114
|
+
return ra === rb ? 0 : ra ? -1 : 1
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class Emitter {
|
|
119
|
+
constructor () {
|
|
120
|
+
this.decls = []
|
|
121
|
+
this.uid = 0
|
|
122
|
+
// JSON pointers for error messages. The generated code only ever refers
|
|
123
|
+
// to them by index into this table (emitted as a JSON literal), so no
|
|
124
|
+
// schema-derived text is interpolated into the code itself.
|
|
125
|
+
this.pointers = []
|
|
126
|
+
this.keys = []
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
id (prefix) { return `${prefix}_${this.uid++}` }
|
|
130
|
+
|
|
131
|
+
pointerIndex (pointer) {
|
|
132
|
+
let i = this.pointers.indexOf(pointer)
|
|
133
|
+
if (i === -1) { i = this.pointers.length; this.pointers.push(pointer) }
|
|
134
|
+
return i
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
keyIndex (key) {
|
|
138
|
+
let i = this.keys.indexOf(key)
|
|
139
|
+
if (i === -1) { i = this.keys.length; this.keys.push(key) }
|
|
140
|
+
return i
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
scratchLiteral (schema) {
|
|
144
|
+
return '{ ' + orderedKeys(schema).map((k) => `${JSON.stringify(k)}: undefined`).join(', ') + ' }'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Scratch storage for a schema node. Nodes reachable without crossing an
|
|
148
|
+
// array are module-level constants (one per serializer). Nodes inside array
|
|
149
|
+
// items live in a per-slot holder object created by a generated factory, so
|
|
150
|
+
// that each array element has its own scratch tree.
|
|
151
|
+
//
|
|
152
|
+
// `holder` is null at the top level (declare constants) or the expression of
|
|
153
|
+
// the current slot holder; `slotFields` collects the holder's fields.
|
|
154
|
+
allocScratch (schema, holder, slotFields) {
|
|
155
|
+
if (holder === null) {
|
|
156
|
+
const name = this.id('scratch')
|
|
157
|
+
this.decls.push(`const ${name} = ${this.scratchLiteral(schema)}`)
|
|
158
|
+
return name
|
|
159
|
+
}
|
|
160
|
+
const field = this.id('s')
|
|
161
|
+
slotFields.push([field, this.scratchLiteral(schema)])
|
|
162
|
+
return `${holder}.${field}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
allocArray (holder, slotFields) {
|
|
166
|
+
if (holder === null) {
|
|
167
|
+
const name = this.id('arr')
|
|
168
|
+
this.decls.push(`const ${name} = []`)
|
|
169
|
+
return name
|
|
170
|
+
}
|
|
171
|
+
const field = this.id('a')
|
|
172
|
+
slotFields.push([field, '[]'])
|
|
173
|
+
return `${holder}.${field}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fillObject (schema, src, dst, out, indent, pointer, holder, slotFields) {
|
|
177
|
+
const props = schema.properties
|
|
178
|
+
const requiredList = schema.required || []
|
|
179
|
+
const required = new Set(requiredList)
|
|
180
|
+
const keys = orderedKeys(schema)
|
|
181
|
+
const o = this.id('o')
|
|
182
|
+
out.push(`${indent}const ${o} = (${src} !== null && typeof ${src} === 'object' && typeof ${src}.toJSON === 'function') ? ${src}.toJSON() : ${src}`)
|
|
183
|
+
out.push(`${indent}if (${o} === null) {`)
|
|
184
|
+
for (const k of keys) out.push(`${indent} ${dst}[${JSON.stringify(k)}] = undefined`)
|
|
185
|
+
out.push(`${indent}} else {`)
|
|
186
|
+
for (const k of requiredList) {
|
|
187
|
+
if (props[k] === undefined) {
|
|
188
|
+
out.push(`${indent} if (${o}[${JSON.stringify(k)}] === undefined) requiredMissing(${this.keyIndex(k)})`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const k of keys) {
|
|
192
|
+
const p = props[k]
|
|
193
|
+
const ks = JSON.stringify(k)
|
|
194
|
+
const v = this.id('v')
|
|
195
|
+
out.push(`${indent} const ${v} = ${o}[${ks}]`)
|
|
196
|
+
out.push(`${indent} if (${v} === undefined) {`)
|
|
197
|
+
if (required.has(k)) {
|
|
198
|
+
out.push(`${indent} requiredMissing(${this.keyIndex(k)})`)
|
|
199
|
+
} else if (p.default !== undefined) {
|
|
200
|
+
out.push(`${indent} ${dst}[${ks}] = ${JSON.stringify(p.default)}`)
|
|
201
|
+
} else {
|
|
202
|
+
out.push(`${indent} ${dst}[${ks}] = undefined`)
|
|
203
|
+
}
|
|
204
|
+
out.push(`${indent} } else {`)
|
|
205
|
+
out.push(`${indent} ${dst}[${ks}] = ${this.valueExpr(p, v, out, indent + ' ', `${pointer}/properties/${k}`, holder, slotFields)}`)
|
|
206
|
+
out.push(`${indent} }`)
|
|
207
|
+
}
|
|
208
|
+
out.push(`${indent}}`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Returns an expression for the projected value of `src`; may push
|
|
212
|
+
// preparatory statements to `out` (filling a nested scratch) first.
|
|
213
|
+
valueExpr (schema, src, out, indent, pointer, holder, slotFields) {
|
|
214
|
+
if (schema.type === 'object') {
|
|
215
|
+
const scratch = this.allocScratch(schema, holder, slotFields)
|
|
216
|
+
this.fillObject(schema, src, scratch, out, indent, pointer, holder, slotFields)
|
|
217
|
+
return scratch
|
|
218
|
+
}
|
|
219
|
+
if (schema.type === 'array') {
|
|
220
|
+
const pool = this.allocArray(holder, slotFields)
|
|
221
|
+
this.fillArray(schema, src, pool, out, indent, pointer, holder, slotFields)
|
|
222
|
+
return pool
|
|
223
|
+
}
|
|
224
|
+
return primitiveExpr(schema, src, this.pointerIndex(pointer))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fillArray (schema, src, pool, out, indent, pointer, holder, slotFields) {
|
|
228
|
+
const items = schema.items
|
|
229
|
+
const n = this.id('n')
|
|
230
|
+
const i = this.id('i')
|
|
231
|
+
out.push(`${indent}if (${src} === null) {`)
|
|
232
|
+
out.push(`${indent} ${pool}.length = 0`)
|
|
233
|
+
out.push(`${indent}} else {`)
|
|
234
|
+
out.push(`${indent} if (!Array.isArray(${src})) typeMismatch(${this.pointerIndex(pointer)})`)
|
|
235
|
+
out.push(`${indent} const ${n} = ${src}.length`)
|
|
236
|
+
out.push(`${indent} ${pool}.length = ${n}`)
|
|
237
|
+
if (items.type === 'object' || items.type === 'array') {
|
|
238
|
+
// Pool of per-element slot holders; each holder owns the scratch tree of
|
|
239
|
+
// one element (nested objects and arrays included). Grows to the longest
|
|
240
|
+
// array seen, never shrinks. When this array is itself inside an array
|
|
241
|
+
// element, its slot pool lives in the enclosing holder, so sibling
|
|
242
|
+
// elements never share scratch objects.
|
|
243
|
+
const slots = this.allocArray(holder, slotFields)
|
|
244
|
+
const factory = this.id('makeSlot')
|
|
245
|
+
const el = this.id('el')
|
|
246
|
+
const innerHolder = this.id('slot')
|
|
247
|
+
const innerFields = []
|
|
248
|
+
const inner = []
|
|
249
|
+
const expr = this.valueExpr(items, el, inner, indent + ' ', `${pointer}/items`, innerHolder, innerFields)
|
|
250
|
+
this.decls.push(`function ${factory} () { return { ${innerFields.map(([f, lit]) => `${f}: ${lit}`).join(', ')} } }`)
|
|
251
|
+
out.push(`${indent} for (let ${i} = ${slots}.length; ${i} < ${n}; ${i}++) ${slots}[${i}] = ${factory}()`)
|
|
252
|
+
out.push(`${indent} for (let ${i} = 0; ${i} < ${n}; ${i}++) {`)
|
|
253
|
+
out.push(`${indent} const ${el} = ${src}[${i}]`)
|
|
254
|
+
out.push(`${indent} const ${innerHolder} = ${slots}[${i}]`)
|
|
255
|
+
out.push(...inner)
|
|
256
|
+
out.push(`${indent} ${pool}[${i}] = ${expr}`)
|
|
257
|
+
out.push(`${indent} }`)
|
|
258
|
+
} else {
|
|
259
|
+
out.push(`${indent} for (let ${i} = 0; ${i} < ${n}; ${i}++) {`)
|
|
260
|
+
out.push(`${indent} ${pool}[${i}] = ${primitiveExpr(items, `${src}[${i}]`, this.pointerIndex(`${pointer}/items`))}`)
|
|
261
|
+
out.push(`${indent} }`)
|
|
262
|
+
}
|
|
263
|
+
out.push(`${indent}}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Returns the full body of the context function (declarations + main) or null
|
|
268
|
+
// when the schema is not projectable.
|
|
269
|
+
function buildProjection (schema) {
|
|
270
|
+
if (!isProjectable(schema)) return null
|
|
271
|
+
if (schema.type !== 'object' && schema.type !== 'array') return null // primitive roots gain nothing
|
|
272
|
+
const em = new Emitter()
|
|
273
|
+
const body = []
|
|
274
|
+
const rootExpr = em.valueExpr(schema, 'input', body, ' ', '#', null, null)
|
|
275
|
+
// root object: a null input serializes as {} (same as the codegen)
|
|
276
|
+
body.push(` return JSON.stringify(${rootExpr})`)
|
|
277
|
+
return `
|
|
278
|
+
${projectionHelpers}
|
|
279
|
+
const POINTERS = ${JSON.stringify(em.pointers)}
|
|
280
|
+
const KEYS = ${JSON.stringify(em.keys)}
|
|
281
|
+
function typeMismatch (i) {
|
|
282
|
+
throw new TypeError("The value of '" + POINTERS[i] + "' does not match schema definition.")
|
|
283
|
+
}
|
|
284
|
+
function requiredMissing (i) {
|
|
285
|
+
throw new Error('"' + KEYS[i] + '" is required!')
|
|
286
|
+
}
|
|
287
|
+
${em.decls.join('\n ')}
|
|
288
|
+
function main (input) {
|
|
289
|
+
${body.join('\n')}
|
|
290
|
+
}
|
|
291
|
+
return main
|
|
292
|
+
`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Runtime helpers mirroring lib/serializer.js, returning values (not JSON text)
|
|
296
|
+
// to be placed into the scratch.
|
|
297
|
+
const projectionHelpers = `
|
|
298
|
+
const asStringValue = (v) => {
|
|
299
|
+
if (typeof v === 'string') return v
|
|
300
|
+
if (v === null) return ''
|
|
301
|
+
if (v instanceof Date) return v.toISOString()
|
|
302
|
+
if (v instanceof RegExp) return v.source
|
|
303
|
+
return v.toString()
|
|
304
|
+
}
|
|
305
|
+
const asDateTimeValue = (v) => {
|
|
306
|
+
if (v === null) return ''
|
|
307
|
+
if (v instanceof Date) return v.toISOString()
|
|
308
|
+
if (typeof v === 'string') return v
|
|
309
|
+
return v.toString()
|
|
310
|
+
}
|
|
311
|
+
const asDateValue = (v) => { const s = serializer.asDate(v); return s.slice(1, -1) }
|
|
312
|
+
const asTimeValue = (v) => { const s = serializer.asTime(v); return s.slice(1, -1) }
|
|
313
|
+
const asNumberValue = (v) => {
|
|
314
|
+
const num = typeof v === 'number' ? v : Number(v)
|
|
315
|
+
if (num !== num) throw new Error('The value "' + v + '" cannot be converted to a number.')
|
|
316
|
+
return (num === Infinity || num === -Infinity) ? null : num
|
|
317
|
+
}
|
|
318
|
+
const asIntegerValue = (v) => {
|
|
319
|
+
if (Number.isInteger(v)) return v
|
|
320
|
+
if (typeof v === 'bigint') return Number(v)
|
|
321
|
+
return Number(serializer.asInteger(v))
|
|
322
|
+
}
|
|
323
|
+
`
|
|
324
|
+
|
|
325
|
+
module.exports = { buildProjection, isProjectable }
|
package/package.json
CHANGED
package/test/array.test.js
CHANGED
|
@@ -411,6 +411,35 @@ test('array items is a schema and additionalItems is false', (t) => {
|
|
|
411
411
|
t.assert.equal(validate({ foo: ['foo', 'bar'] }), true)
|
|
412
412
|
})
|
|
413
413
|
|
|
414
|
+
test('array items is a list of schema with nullable items', (t) => {
|
|
415
|
+
t.plan(6)
|
|
416
|
+
|
|
417
|
+
const items = [
|
|
418
|
+
{ type: 'integer', nullable: true },
|
|
419
|
+
{ type: 'number', nullable: true },
|
|
420
|
+
{ type: 'boolean', nullable: true },
|
|
421
|
+
{ type: 'object', nullable: true, properties: { a: { type: 'string' } } },
|
|
422
|
+
{ type: 'array', nullable: true, items: { type: 'string' } },
|
|
423
|
+
{ type: 'string', nullable: true }
|
|
424
|
+
]
|
|
425
|
+
const input = [null, null, null, null, null, null]
|
|
426
|
+
const expected = '[null,null,null,null,null,null]'
|
|
427
|
+
|
|
428
|
+
const inlined = build({ type: 'array', items })
|
|
429
|
+
t.assert.equal(inlined(input), expected)
|
|
430
|
+
t.assert.equal(inlined([1, 1.5, true, { a: 'x' }, ['y'], 'z']), '[1,1.5,true,{"a":"x"},["y"],"z"]')
|
|
431
|
+
|
|
432
|
+
const withId = build({ $id: 'nullable-tuple', type: 'array', items })
|
|
433
|
+
t.assert.equal(withId(input), expected)
|
|
434
|
+
t.assert.equal(withId([1, 1.5, true, { a: 'x' }, ['y'], 'z']), '[1,1.5,true,{"a":"x"},["y"],"z"]')
|
|
435
|
+
|
|
436
|
+
const notNullable = build({ type: 'array', items: [{ type: 'integer' }] })
|
|
437
|
+
t.assert.throws(() => notNullable([null]), /Item at 0 does not match schema definition/)
|
|
438
|
+
|
|
439
|
+
const ajv = new Ajv({ allErrors: true, strict: false })
|
|
440
|
+
t.assert.equal(ajv.validate({ type: 'array', items }, JSON.parse(inlined(input))), true)
|
|
441
|
+
})
|
|
442
|
+
|
|
414
443
|
test('array items is a list of schema and additionalItems is a schema', (t) => {
|
|
415
444
|
t.plan(1)
|
|
416
445
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test')
|
|
4
|
+
|
|
5
|
+
// objectMechanism: 'native' is a no-op on runtimes without the V8 JSON.stringify
|
|
6
|
+
// fast path (Node < 25).
|
|
7
|
+
const build = require('..')
|
|
8
|
+
|
|
9
|
+
const schema = { type: 'object', properties: { a: { type: 'string' }, n: { type: 'number' } } }
|
|
10
|
+
const nodeMajor = Number.parseInt(process.versions.node, 10)
|
|
11
|
+
|
|
12
|
+
test('native mechanism is enabled only on Node 25+', t => {
|
|
13
|
+
t.plan(2)
|
|
14
|
+
const code = build(schema, { objectMechanism: 'native', mode: 'debug' }).code
|
|
15
|
+
t.assert.equal(code.includes('asStringValue'), nodeMajor >= 25)
|
|
16
|
+
// whatever the runtime, output is the same as the default mechanism
|
|
17
|
+
t.assert.equal(build(schema, { objectMechanism: 'native' })({ a: 'x', n: 1, extra: true }), build(schema)({ a: 'x', n: 1, extra: true }))
|
|
18
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The projection is only enabled on Node 25+ (V8 13.8+). Force it here so the
|
|
4
|
+
// semantics are tested on every runtime in CI; the gate itself is tested in
|
|
5
|
+
// object-mechanism-gate.test.js.
|
|
6
|
+
const { test } = require('node:test')
|
|
7
|
+
const fjs = require('..')
|
|
8
|
+
const build = (schema, options) => fjs(schema, options && options.objectMechanism === 'native' ? { ...options, forceProjection: true } : options)
|
|
9
|
+
|
|
10
|
+
// objectMechanism: 'native' must be byte-identical to the default codegen on
|
|
11
|
+
// every projectable schema (same output, or same error), and must fall back to
|
|
12
|
+
// the default codegen on every schema it does not cover.
|
|
13
|
+
|
|
14
|
+
const userSchema = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['id'],
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: 'integer' },
|
|
19
|
+
name: { type: 'string' },
|
|
20
|
+
email: { type: 'string', default: 'none@example.com' },
|
|
21
|
+
active: { type: 'boolean' },
|
|
22
|
+
score: { type: 'number' },
|
|
23
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
24
|
+
birthday: { type: 'string', format: 'date' },
|
|
25
|
+
at: { type: 'string', format: 'time' },
|
|
26
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
27
|
+
scores: { type: 'array', items: { type: 'number' } },
|
|
28
|
+
address: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
required: ['city'],
|
|
31
|
+
properties: { street: { type: 'string' }, city: { type: 'string' }, zip: { type: 'string' } }
|
|
32
|
+
},
|
|
33
|
+
friends: {
|
|
34
|
+
type: 'array',
|
|
35
|
+
items: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class User { constructor (o) { Object.assign(this, o) } }
|
|
41
|
+
|
|
42
|
+
const base = {
|
|
43
|
+
id: 1,
|
|
44
|
+
name: 'Mario "Il" Rossi\n\u0000',
|
|
45
|
+
email: 'm@x.it',
|
|
46
|
+
active: true,
|
|
47
|
+
score: 42.5,
|
|
48
|
+
createdAt: new Date(0),
|
|
49
|
+
birthday: new Date(86400000),
|
|
50
|
+
at: new Date(3600000),
|
|
51
|
+
tags: ['a', 'b"c', 'd'],
|
|
52
|
+
scores: [1, 2.5, -3],
|
|
53
|
+
address: { street: 'Via Roma 1', city: 'Milano', zip: '20100' },
|
|
54
|
+
friends: [{ id: 2, name: 'Luigi' }, { id: 3, name: 'Peach' }]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inputs = {
|
|
58
|
+
base,
|
|
59
|
+
'extra properties are dropped': { ...base, secret: 'x', internal: { a: 1 } },
|
|
60
|
+
'class instance': new User(base),
|
|
61
|
+
'undefined optional is omitted': { ...base, name: undefined, address: undefined, friends: undefined },
|
|
62
|
+
'missing optional, default applied': { id: 1 },
|
|
63
|
+
nulls: { id: 1, name: null, email: null, active: null, score: null, createdAt: null, birthday: null, at: null, tags: null, scores: null, address: null, friends: null },
|
|
64
|
+
'null inside arrays': { id: 1, tags: ['a', null, 'c'], scores: [1, null, 3], friends: [null, { id: 1 }] },
|
|
65
|
+
coercions: { id: 1.7, name: 123, active: 'yes', score: '3.5', tags: [1, 2], scores: ['4', '5'] },
|
|
66
|
+
'Date in a plain string': { id: 1, name: new Date(0), tags: [new Date(0)] },
|
|
67
|
+
'RegExp in a plain string': { id: 1, name: /a"b/, tags: [/c/g] },
|
|
68
|
+
'date-time given as string': { id: 1, createdAt: '2020-01-01T00:00:00.000Z' },
|
|
69
|
+
'toJSON on root': { id: 1, toJSON () { return { id: 7, name: 'json' } } },
|
|
70
|
+
'toJSON on nested object': { id: 1, address: { toJSON () { return { city: 'Roma' } } } },
|
|
71
|
+
'toJSON on array item': { id: 1, friends: [{ toJSON () { return { id: 9 } } }] },
|
|
72
|
+
'required missing on root throws': { name: 'no id' },
|
|
73
|
+
'required missing on nested throws': { id: 1, address: { street: 'x' } },
|
|
74
|
+
'NaN throws': { id: 1, score: NaN },
|
|
75
|
+
'Infinity becomes null': { id: 1, score: Infinity },
|
|
76
|
+
'bigint integer': { id: 10n },
|
|
77
|
+
'object items with missing fields': { id: 1, friends: [{ id: 1 }, { name: 'x' }, {}] },
|
|
78
|
+
'empty arrays': { id: 1, tags: [], scores: [], friends: [] },
|
|
79
|
+
'input key order differs from schema': { friends: [], address: { zip: '1', city: 'c', street: 's' }, score: 1, id: 1 },
|
|
80
|
+
'null root': null,
|
|
81
|
+
'non-array where array expected throws': { id: 1, tags: 'abc' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const run = (fn, v) => {
|
|
85
|
+
try { return { ok: fn(v) } } catch (e) { return { err: `${e.constructor.name}: ${e.message}` } }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('native mechanism is byte-identical to the default codegen', t => {
|
|
89
|
+
const def = build(userSchema)
|
|
90
|
+
const nat = build(userSchema, { objectMechanism: 'native' })
|
|
91
|
+
t.plan(Object.keys(inputs).length)
|
|
92
|
+
for (const [name, input] of Object.entries(inputs)) {
|
|
93
|
+
t.assert.deepStrictEqual(run(nat, input), run(def, input), name)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('native mechanism: array root of objects', t => {
|
|
98
|
+
const schema = { type: 'array', items: { type: 'object', required: ['id'], properties: { id: { type: 'integer' }, name: { type: 'string' } } } }
|
|
99
|
+
const def = build(schema)
|
|
100
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
101
|
+
const data = [
|
|
102
|
+
[],
|
|
103
|
+
[{ id: 1, name: 'a' }, { id: 2 }, { id: 3, name: null, extra: true }],
|
|
104
|
+
Array.from({ length: 50 }, (_, i) => ({ id: i, name: 'n' + i })),
|
|
105
|
+
[{ name: 'missing id' }],
|
|
106
|
+
null,
|
|
107
|
+
'not an array'
|
|
108
|
+
]
|
|
109
|
+
t.plan(data.length + 1)
|
|
110
|
+
for (const d of data) t.assert.deepStrictEqual(run(nat, d), run(def, d))
|
|
111
|
+
// pool grows and shrinks with the input length without leaking previous items
|
|
112
|
+
t.assert.equal(nat([{ id: 1 }]), '[{"id":1}]')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('native mechanism: array root of primitives', t => {
|
|
116
|
+
t.plan(3)
|
|
117
|
+
for (const items of [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }]) {
|
|
118
|
+
const schema = { type: 'array', items }
|
|
119
|
+
const d = ['a', 1, null, true, 2.5, 'b']
|
|
120
|
+
t.assert.deepStrictEqual(run(build(schema, { objectMechanism: 'native' }), d), run(build(schema), d))
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('native mechanism generates projection code only for projectable schemas', t => {
|
|
125
|
+
const projectable = [
|
|
126
|
+
userSchema,
|
|
127
|
+
{ type: 'object', properties: { a: { type: 'string' } } },
|
|
128
|
+
{ type: 'array', items: { type: 'object', properties: { a: { type: 'string' } } } },
|
|
129
|
+
{ type: 'object', properties: { a: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } },
|
|
130
|
+
{ type: 'object', properties: { a: { type: 'array', items: { type: 'object', properties: { b: { type: 'object', properties: { c: { type: 'string' } } } } } } } },
|
|
131
|
+
{ type: 'object', properties: { a: { type: ['string', 'null'] } } }
|
|
132
|
+
]
|
|
133
|
+
const notProjectable = [
|
|
134
|
+
{ type: 'string' },
|
|
135
|
+
{ type: 'object', properties: {} },
|
|
136
|
+
{ type: 'object', properties: { a: { type: 'string' } }, additionalProperties: true },
|
|
137
|
+
{ type: 'object', properties: { a: { type: 'string' } }, patternProperties: { '^x': { type: 'string' } } },
|
|
138
|
+
{ type: 'object', nullable: true, properties: { a: { type: 'string' } } },
|
|
139
|
+
{ type: 'object', properties: { a: { type: 'string', nullable: true } } },
|
|
140
|
+
{ type: 'object', properties: { a: { type: ['string', 'number'] } } },
|
|
141
|
+
{ type: 'object', properties: { a: { type: ['integer', 'null'] } } },
|
|
142
|
+
{ type: 'object', properties: { a: { const: 'x' } } },
|
|
143
|
+
{ type: 'object', properties: { a: { type: 'string', enum: ['x'] } } },
|
|
144
|
+
{ type: 'object', properties: { a: { anyOf: [{ type: 'string' }] } } },
|
|
145
|
+
{ type: 'object', properties: { a: { type: 'string', format: 'email' } } },
|
|
146
|
+
{ type: 'object', properties: { a: { $ref: '#/definitions/s' } }, definitions: { s: { type: 'string' } } },
|
|
147
|
+
{ type: 'object', properties: { a: { type: 'array', items: [{ type: 'string' }] } } },
|
|
148
|
+
{ type: 'object', properties: { a: { type: 'array' } } },
|
|
149
|
+
{ type: 'object', properties: { a: { type: 'null' } } }
|
|
150
|
+
]
|
|
151
|
+
t.plan(projectable.length + notProjectable.length)
|
|
152
|
+
for (const s of projectable) {
|
|
153
|
+
t.assert.ok(build(s, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), JSON.stringify(s))
|
|
154
|
+
}
|
|
155
|
+
for (const s of notProjectable) {
|
|
156
|
+
t.assert.ok(!build(s, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), JSON.stringify(s))
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('native mechanism: unsupported schemas keep the default output', t => {
|
|
161
|
+
const schema = {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: { a: { type: 'string' }, b: { type: 'string', nullable: true } },
|
|
164
|
+
additionalProperties: { type: 'number' }
|
|
165
|
+
}
|
|
166
|
+
const data = { a: 'x', b: null, c: 1, d: 2 }
|
|
167
|
+
t.plan(1)
|
|
168
|
+
t.assert.equal(build(schema, { objectMechanism: 'native' })(data), build(schema)(data))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('objectMechanism option validation', t => {
|
|
172
|
+
t.plan(3)
|
|
173
|
+
t.assert.doesNotThrow(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'default' }))
|
|
174
|
+
t.assert.doesNotThrow(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'native' }))
|
|
175
|
+
t.assert.throws(() => build({ type: 'object', properties: { a: { type: 'string' } } }, { objectMechanism: 'turbo' }), /Unsupported object mechanism turbo/)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('native mechanism: scratch does not leak state between calls', t => {
|
|
179
|
+
const schema = { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' }, nested: { type: 'object', properties: { c: { type: 'string' } } } } }
|
|
180
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
181
|
+
t.plan(3)
|
|
182
|
+
t.assert.equal(nat({ a: '1', b: '2', nested: { c: '3' } }), '{"a":"1","b":"2","nested":{"c":"3"}}')
|
|
183
|
+
t.assert.equal(nat({ a: '1' }), '{"a":"1"}')
|
|
184
|
+
t.assert.equal(nat({ nested: {} }), '{"nested":{}}')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('native mechanism works in standalone mode', t => {
|
|
188
|
+
const schema = { type: 'object', properties: { a: { type: 'string' }, n: { type: 'number' } } }
|
|
189
|
+
const code = build(schema, { objectMechanism: 'native', mode: 'standalone' })
|
|
190
|
+
const fs = require('node:fs'); const path = require('node:path')
|
|
191
|
+
// written under test/fixtures like the other standalone tests, so that the
|
|
192
|
+
// generated require('fast-json-stringify/...') resolves against this package
|
|
193
|
+
const file = path.resolve('test/fixtures', 'standalone-native.js')
|
|
194
|
+
fs.writeFileSync(file, code)
|
|
195
|
+
t.after(() => fs.rmSync(file, { force: true }))
|
|
196
|
+
const stringify = require(file)
|
|
197
|
+
t.plan(2)
|
|
198
|
+
t.assert.ok(code.includes('asStringValue'), 'projection code is emitted')
|
|
199
|
+
t.assert.equal(stringify({ a: 'x', n: 1.5, extra: true }), '{"a":"x","n":1.5}')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('native mechanism: required keys not declared in properties still throw', t => {
|
|
203
|
+
const schema = { type: 'object', required: ['id', 'token'], properties: { id: { type: 'integer' } } }
|
|
204
|
+
const def = build(schema)
|
|
205
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
206
|
+
t.plan(2)
|
|
207
|
+
t.assert.deepStrictEqual(run(nat, { id: 1 }), run(def, { id: 1 }))
|
|
208
|
+
t.assert.deepStrictEqual(run(nat, { id: 1, token: 'x' }), run(def, { id: 1, token: 'x' }))
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('native mechanism: deeply nested schema beyond the projection depth falls back', t => {
|
|
212
|
+
let schema = { type: 'string' }
|
|
213
|
+
for (let i = 0; i < 40; i++) schema = { type: 'object', properties: { p: schema } }
|
|
214
|
+
t.plan(1)
|
|
215
|
+
t.assert.ok(!build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('native mechanism: [primitive, null] multi-type properties keep the strict dispatch', t => {
|
|
219
|
+
const schema = {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
s: { type: ['string', 'null'] },
|
|
223
|
+
sr: { type: ['null', 'string'] },
|
|
224
|
+
sd: { type: ['string', 'null'], format: 'date-time' },
|
|
225
|
+
sdef: { type: ['string', 'null'], default: 'd' },
|
|
226
|
+
n: { type: ['number', 'null'] },
|
|
227
|
+
b: { type: ['boolean', 'null'] },
|
|
228
|
+
arr: { type: 'array', items: { type: ['string', 'null'] } }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const def = build(schema)
|
|
232
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
233
|
+
const cases = [
|
|
234
|
+
{ s: 'x', sr: 'y', sd: new Date(0), sdef: 'z', n: 1.5, b: true, arr: ['a', null] },
|
|
235
|
+
{ s: null, sr: null, sd: null, sdef: null, n: null, b: null, arr: [null] },
|
|
236
|
+
{},
|
|
237
|
+
{ s: new Date(0) },
|
|
238
|
+
{ s: /re/ },
|
|
239
|
+
{ s: { toString () { return 'custom' } } },
|
|
240
|
+
{ s: {} },
|
|
241
|
+
{ s: 5 },
|
|
242
|
+
{ s: true },
|
|
243
|
+
{ sd: '2020-01-01' },
|
|
244
|
+
{ sd: 5 },
|
|
245
|
+
{ n: Infinity },
|
|
246
|
+
{ n: NaN },
|
|
247
|
+
{ n: '3' },
|
|
248
|
+
{ n: 10n },
|
|
249
|
+
{ b: 'y' },
|
|
250
|
+
{ b: 0 },
|
|
251
|
+
{ arr: ['a', 5] },
|
|
252
|
+
{ arr: [new Date(0)] }
|
|
253
|
+
]
|
|
254
|
+
t.plan(cases.length + 2)
|
|
255
|
+
t.assert.ok(build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), 'schema is projected')
|
|
256
|
+
for (const c of cases) t.assert.deepStrictEqual(run(nat, c), run(def, c), JSON.stringify(c, (k, v) => typeof v === 'bigint' ? 'bigint' : v))
|
|
257
|
+
// ['integer', 'null'] and non-null multi-types are not projected
|
|
258
|
+
const notProjected = [{ type: ['integer', 'null'] }, { type: ['string', 'number'] }, { type: ['string', 'null', 'number'] }, { type: ['string', 'null'], format: 'email' }]
|
|
259
|
+
t.assert.ok(notProjected.every((p) => !build({ type: 'object', properties: { a: p } }, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue')))
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('native mechanism projects the realistic api-response fixtures byte for byte', t => {
|
|
263
|
+
const fixtures = require('../benchmark/fixtures/api-responses')
|
|
264
|
+
t.plan(fixtures.length * 2)
|
|
265
|
+
for (const { name, schema, input } of fixtures) {
|
|
266
|
+
const def = build(schema)
|
|
267
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
268
|
+
t.assert.ok(build(schema, { objectMechanism: 'native', mode: 'debug' }).code.includes('asStringValue'), `${name} is projected`)
|
|
269
|
+
t.assert.equal(nat(input), def(input), name)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('native mechanism: array elements never share scratch objects (no aliasing)', t => {
|
|
274
|
+
// items[].product.images[] : an array of objects inside an array of objects.
|
|
275
|
+
// Every element must get its own scratch tree, otherwise the last element
|
|
276
|
+
// written wins for all of them.
|
|
277
|
+
const schema = {
|
|
278
|
+
type: 'array',
|
|
279
|
+
items: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
id: { type: 'integer' },
|
|
283
|
+
images: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } } } },
|
|
284
|
+
meta: { type: 'object', properties: { k: { type: 'string' } } }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const def = build(schema)
|
|
289
|
+
const nat = build(schema, { objectMechanism: 'native' })
|
|
290
|
+
const a = [
|
|
291
|
+
{ id: 1, images: [{ url: 'a', tags: ['x'] }, { url: 'b', tags: [] }], meta: { k: '1' } },
|
|
292
|
+
{ id: 2, images: [{ url: 'c', tags: ['y', 'z'] }], meta: { k: '2' } },
|
|
293
|
+
{ id: 3, images: [], meta: null }
|
|
294
|
+
]
|
|
295
|
+
const b = [{ id: 9, images: [{ url: 'q', tags: ['k'] }] }]
|
|
296
|
+
t.plan(4)
|
|
297
|
+
t.assert.equal(nat(a), def(a))
|
|
298
|
+
t.assert.equal(nat(b), def(b))
|
|
299
|
+
t.assert.equal(nat(a), def(a), 'shrinking then growing again keeps elements independent')
|
|
300
|
+
t.assert.ok(nat(a).includes('"url":"a"') && nat(a).includes('"url":"c"'))
|
|
301
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -193,6 +193,15 @@ declare namespace build {
|
|
|
193
193
|
* @default 'default'
|
|
194
194
|
*/
|
|
195
195
|
largeArrayMechanism?: 'default' | 'json-stringify'
|
|
196
|
+
/**
|
|
197
|
+
* Specify how objects should be stringified. `'native'` projects the
|
|
198
|
+
* input into schema-shaped scratch objects and delegates to the native
|
|
199
|
+
* `JSON.stringify`. Effective on Node.js 25+ only (no-op on older
|
|
200
|
+
* runtimes); schemas it cannot project silently use the default mechanism.
|
|
201
|
+
*
|
|
202
|
+
* @default 'default'
|
|
203
|
+
*/
|
|
204
|
+
objectMechanism?: 'default' | 'native'
|
|
196
205
|
/**
|
|
197
206
|
* Eagerly compile the Ajv validators used by `anyOf`, `oneOf` and
|
|
198
207
|
* `if/then/else` at build time instead of on the first serialization
|
package/types/index.tst.ts
CHANGED
|
@@ -255,6 +255,10 @@ expect(stringify7).type.not.toBeCallableWith('a string')
|
|
|
255
255
|
// largeArrayMechanism
|
|
256
256
|
build({}, { largeArrayMechanism: 'json-stringify' })
|
|
257
257
|
build({}, { largeArrayMechanism: 'default' })
|
|
258
|
+
|
|
259
|
+
// objectMechanism
|
|
260
|
+
build({}, { objectMechanism: 'native' })
|
|
261
|
+
build({}, { objectMechanism: 'default' })
|
|
258
262
|
expect(build).type.not.toBeCallableWith({} as Schema, { largeArrayMechanism: 'invalid' })
|
|
259
263
|
|
|
260
264
|
build({}, { largeArraySize: 2000 })
|