fast-json-stringify 7.1.1 → 7.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -5
- package/benchmark/bench-fixtures.js +54 -0
- package/benchmark/bench-large-array.js +64 -0
- package/benchmark/bench-utils.js +41 -0
- package/index.js +241 -48
- package/lib/location.js +2 -1
- package/lib/serializer.js +33 -0
- package/package.json +1 -1
- package/test/allof.test.js +51 -0
- package/test/array.test.js +122 -0
- package/test/schema-depth.test.js +14 -2
package/README.md
CHANGED
|
@@ -623,11 +623,15 @@ In order to improve that the user can set the `largeArrayMechanism` and
|
|
|
623
623
|
|
|
624
624
|
`largeArrayMechanism`'s default value is `default`. Valid values for it are:
|
|
625
625
|
|
|
626
|
-
- `default` - This option
|
|
627
|
-
still
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
626
|
+
- `default` - This option keeps the full feature set of this lib: every element
|
|
627
|
+
is still coerced to the type declared in `items`, and type errors are still
|
|
628
|
+
thrown. Arrays whose `items` schema is a plain `{ type: 'string' }` (no
|
|
629
|
+
`format`, `nullable`, `const`, or combinator keyword) are serialized at or
|
|
630
|
+
above `largeArraySize` by copying the elements and calling the native
|
|
631
|
+
`JSON.stringify` on the copy, which is several times faster than appending one
|
|
632
|
+
element at a time. If an element is not a string, that element and the ones
|
|
633
|
+
after it go through the regular per-element coercion, so the output is the
|
|
634
|
+
same as before.
|
|
631
635
|
- `json-stringify` - This option will remove support for schema validation
|
|
632
636
|
within **large arrays** completely. By doing so the overhead previously
|
|
633
637
|
mentioned is nulled, greatly improving execution time. Mind there's no change
|
|
@@ -642,6 +646,32 @@ integer-like values, such as:
|
|
|
642
646
|
- `'2e4'` - _note this will be converted to `2`, not `20000`_
|
|
643
647
|
- `1.5` - _note this will be converted to `1`_
|
|
644
648
|
|
|
649
|
+
##### Number and integer arrays
|
|
650
|
+
|
|
651
|
+
With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain
|
|
652
|
+
`{ type: 'number' }` or `{ type: 'integer' }` (no `nullable`, `const`, `$ref`,
|
|
653
|
+
or combinator keyword) use the same copy-then-`JSON.stringify` strategy, but
|
|
654
|
+
with their own length cutoffs that do not depend on `largeArraySize`. The
|
|
655
|
+
cutoffs are the array lengths above which the copy beats the per-element loop,
|
|
656
|
+
and they depend on the V8 version:
|
|
657
|
+
|
|
658
|
+
| Node.js | `number` | `integer` |
|
|
659
|
+
|---|---:|---:|
|
|
660
|
+
| 22 and later | 128 elements | 512 elements |
|
|
661
|
+
| 20 | 300000 elements | 300000 elements |
|
|
662
|
+
|
|
663
|
+
Below the cutoff the array is serialized element by element as before. Above
|
|
664
|
+
it, the elements are copied until one is found that is not already a `number`
|
|
665
|
+
(or not already an integer, for `integer`); that element and the ones after it
|
|
666
|
+
go through the regular per-element coercion and validation (`null` still
|
|
667
|
+
becomes `0`, `rounding` still applies, `NaN` still throws). The output is
|
|
668
|
+
byte-identical either way. Arrays of `boolean`, `null`, objects, or any other
|
|
669
|
+
`items` schema are not affected.
|
|
670
|
+
|
|
671
|
+
In [standalone mode](#standalone) the cutoffs are baked into the generated
|
|
672
|
+
code, so the generated module inherits the cutoffs of the Node.js version that
|
|
673
|
+
ran `build()`, not of the one that runs the module.
|
|
674
|
+
|
|
645
675
|
<a name="unsafe"></a>
|
|
646
676
|
#### Unsafe string
|
|
647
677
|
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.
|
|
@@ -748,6 +778,12 @@ may require Ajv runtime helpers used by the generated validation code. Custom
|
|
|
748
778
|
Ajv formats used by these schemas must support Ajv's standalone code
|
|
749
779
|
generation.
|
|
750
780
|
|
|
781
|
+
The generated code also bakes in the length cutoffs used for
|
|
782
|
+
[number and integer arrays](#largearrays). Those cutoffs are chosen for the
|
|
783
|
+
Node.js version that ran `build()`; a module built on Node.js 20 and run on
|
|
784
|
+
Node.js 22 or later keeps the Node.js 20 cutoffs, and vice versa. The output is
|
|
785
|
+
the same either way, only the speed differs.
|
|
786
|
+
|
|
751
787
|
<a name="acknowledgments"></a>
|
|
752
788
|
## Acknowledgments
|
|
753
789
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const arrayKinds = ['integer', 'number', 'short-string', 'long-string', 'flat-object', 'nested-object', 'nullable-number', 'Float64Array', 'Int32Array']
|
|
4
|
+
const arraySizes = [1e3, 2e4, 1e5, 1e6]
|
|
5
|
+
const mechanisms = ['default', 'json-stringify', 'native']
|
|
6
|
+
const flatSchema = { type: 'object', properties: { s: { type: 'string' }, n: { type: 'number' }, b: { type: 'boolean' } } }
|
|
7
|
+
const longString = 'a"quoted" string\\with\ncontrols '.repeat(4)
|
|
8
|
+
|
|
9
|
+
function arrayFixture (kind, size) {
|
|
10
|
+
let items
|
|
11
|
+
let value
|
|
12
|
+
switch (kind) {
|
|
13
|
+
case 'integer':
|
|
14
|
+
case 'Int32Array':
|
|
15
|
+
items = { type: 'integer' }
|
|
16
|
+
value = i => (i % 2001) - 1000
|
|
17
|
+
break
|
|
18
|
+
case 'number':
|
|
19
|
+
case 'Float64Array':
|
|
20
|
+
items = { type: 'number' }
|
|
21
|
+
value = i => ((i % 2001) - 1000) / 10
|
|
22
|
+
break
|
|
23
|
+
case 'short-string':
|
|
24
|
+
items = { type: 'string' }
|
|
25
|
+
value = i => `hello ${i % 100}`
|
|
26
|
+
break
|
|
27
|
+
case 'long-string':
|
|
28
|
+
items = { type: 'string' }
|
|
29
|
+
value = () => longString
|
|
30
|
+
break
|
|
31
|
+
case 'flat-object':
|
|
32
|
+
items = flatSchema
|
|
33
|
+
value = i => ({ s: `hello ${i % 100}`, n: i % 1000, b: i % 2 === 0 })
|
|
34
|
+
break
|
|
35
|
+
case 'nested-object':
|
|
36
|
+
items = { type: 'object', properties: { id: { type: 'integer' }, inner: { type: 'array', items: flatSchema } } }
|
|
37
|
+
value = i => ({ id: i, inner: [{ s: 'hello', n: i % 1000, b: true }, { s: 'world', n: i / 10, b: false }] })
|
|
38
|
+
break
|
|
39
|
+
case 'nullable-number':
|
|
40
|
+
items = { type: 'number', nullable: true }
|
|
41
|
+
value = i => i % 4 === 0 ? null : i / 10
|
|
42
|
+
break
|
|
43
|
+
default:
|
|
44
|
+
throw new Error(`Unknown array kind: ${kind}`)
|
|
45
|
+
}
|
|
46
|
+
const input = kind === 'Float64Array'
|
|
47
|
+
? Float64Array.from({ length: size }, (_, i) => value(i))
|
|
48
|
+
: kind === 'Int32Array'
|
|
49
|
+
? Int32Array.from({ length: size }, (_, i) => value(i))
|
|
50
|
+
: Array.from({ length: size }, (_, i) => value(i))
|
|
51
|
+
return { schema: { type: 'array', items }, input, typed: ArrayBuffer.isView(input) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { parseArgs } = require('node:util')
|
|
5
|
+
const { isMainThread, parentPort, workerData } = require('node:worker_threads')
|
|
6
|
+
const { Bench } = require('tinybench')
|
|
7
|
+
const build = require('..')
|
|
8
|
+
const { arrayKinds, arraySizes, mechanisms, arrayFixture } = require('./bench-fixtures')
|
|
9
|
+
const { positiveNumber, runWorker, saveResults } = require('./bench-utils')
|
|
10
|
+
|
|
11
|
+
async function measure ({ kind, size, mechanism, time, iterations }) {
|
|
12
|
+
const { schema, input, typed } = arrayFixture(kind, size)
|
|
13
|
+
const stringify = mechanism === 'native' ? JSON.stringify : build(schema, { largeArrayMechanism: mechanism })
|
|
14
|
+
if (typed && mechanism !== 'native') {
|
|
15
|
+
// Rejection is existing behavior; do not time exceptions as serialization.
|
|
16
|
+
assert.throws(() => stringify(input), TypeError)
|
|
17
|
+
return { kind, size, mechanism, status: 'unsupported (TypeError)' }
|
|
18
|
+
}
|
|
19
|
+
assert.equal(stringify(input), JSON.stringify(input))
|
|
20
|
+
const bytes = Buffer.byteLength(stringify(input))
|
|
21
|
+
const bench = new Bench({ time, iterations, warmupTime: 100, warmupIterations: 2, throws: true })
|
|
22
|
+
bench.add(kind, () => {
|
|
23
|
+
Buffer.byteLength(stringify(input))
|
|
24
|
+
})
|
|
25
|
+
await bench.run()
|
|
26
|
+
const { latency, throughput } = bench.tasks[0].result
|
|
27
|
+
return {
|
|
28
|
+
kind,
|
|
29
|
+
size,
|
|
30
|
+
mechanism,
|
|
31
|
+
status: 'ok',
|
|
32
|
+
bytes,
|
|
33
|
+
ops: throughput.p50,
|
|
34
|
+
meanOps: throughput.mean,
|
|
35
|
+
medianMs: latency.p50,
|
|
36
|
+
rme: latency.rme,
|
|
37
|
+
samples: latency.samplesCount
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main () {
|
|
42
|
+
const { values } = parseArgs({ options: { kind: { type: 'string' }, size: { type: 'string' }, time: { type: 'string', default: '300' }, iterations: { type: 'string', default: '8' }, json: { type: 'string' } } })
|
|
43
|
+
const kinds = values.kind ? [values.kind] : arrayKinds
|
|
44
|
+
if (kinds.some(kind => !arrayKinds.includes(kind))) throw new Error(`kind must be one of ${arrayKinds.join(', ')}`)
|
|
45
|
+
const sizes = values.size ? [positiveNumber(values.size, 'size', true)] : arraySizes
|
|
46
|
+
const config = { time: positiveNumber(values.time, 'time'), iterations: positiveNumber(values.iterations, 'iterations', true) }
|
|
47
|
+
const rows = []
|
|
48
|
+
console.log('| Kind | Size | Mechanism | Median ops/sec | Latency RME % | Samples |')
|
|
49
|
+
console.log('| --- | ---: | --- | ---: | ---: | ---: |')
|
|
50
|
+
for (const size of sizes) {
|
|
51
|
+
for (const kind of kinds) {
|
|
52
|
+
for (const mechanism of mechanisms) {
|
|
53
|
+
const row = await runWorker(__filename, { kind, size, mechanism, ...config })
|
|
54
|
+
rows.push(row)
|
|
55
|
+
console.log(`| ${kind} | ${size} | ${mechanism} | ${row.ops?.toFixed(2) ?? row.status} | ${row.rme?.toFixed(2) ?? '—'} | ${row.samples ?? '—'} |`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
console.log('\nTyped arrays are rejected by FJS; native serializes indexed objects. Each measured call includes Buffer.byteLength().')
|
|
60
|
+
saveResults(values.json, { ...config, kinds, sizes }, rows)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isMainThread) main().catch(err => { console.error(err); process.exitCode = 1 })
|
|
64
|
+
else measure(workerData).then(result => parentPort.postMessage(result)).catch(err => { throw err })
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Worker } = require('node:worker_threads')
|
|
4
|
+
const { writeFileSync } = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
|
|
7
|
+
function positiveNumber (value, name, integer = false) {
|
|
8
|
+
const result = Number(value)
|
|
9
|
+
if (!Number.isFinite(result) || result <= 0 || (integer && !Number.isInteger(result))) {
|
|
10
|
+
throw new Error(`${name} must be a positive ${integer ? 'integer' : 'number'}`)
|
|
11
|
+
}
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runWorker (file, workerData) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const worker = new Worker(file, { workerData })
|
|
18
|
+
let result
|
|
19
|
+
worker.on('message', value => { result = value })
|
|
20
|
+
worker.on('error', reject)
|
|
21
|
+
worker.on('exit', code => {
|
|
22
|
+
if (code !== 0 || result === undefined) reject(new Error(`Benchmark worker exited ${code} without a valid result`))
|
|
23
|
+
else resolve(result)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveResults (file, config, rows) {
|
|
29
|
+
if (!file) return
|
|
30
|
+
writeFileSync(file, JSON.stringify({
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
node: process.version,
|
|
33
|
+
platform: `${process.platform}/${process.arch}`,
|
|
34
|
+
cpu: os.cpus()[0].model,
|
|
35
|
+
cpus: os.cpus().length,
|
|
36
|
+
config,
|
|
37
|
+
rows
|
|
38
|
+
}, null, 2) + '\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { positiveNumber, runWorker, saveResults }
|
package/index.js
CHANGED
|
@@ -13,6 +13,29 @@ const mergeSchemas = require('./lib/merge-schemas')
|
|
|
13
13
|
const DEFAULT_MAX_DEPTH = 100
|
|
14
14
|
const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
|
|
15
15
|
|
|
16
|
+
// Minimum array lengths at which copying into a null-prototype array and
|
|
17
|
+
// calling native JSON.stringify beats the generated per-item loop. Measured
|
|
18
|
+
// against main with tinybench (alternating rounds, median ops/s, output
|
|
19
|
+
// consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64.
|
|
20
|
+
//
|
|
21
|
+
// Node 22+: floats win from 32 elements, but integer-valued elements under a
|
|
22
|
+
// `number` schema only break even around 100 on Node 26 (-25% at 32), and a
|
|
23
|
+
// `number` schema cannot tell the two apart at build time, so numbers wait
|
|
24
|
+
// for 128. Integers win from 512 because small-int ToString is cached, so
|
|
25
|
+
// the loop is already cheap below that.
|
|
26
|
+
//
|
|
27
|
+
// Node 20: JSON.stringify only beats the loop for every element kind from
|
|
28
|
+
// ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there.
|
|
29
|
+
//
|
|
30
|
+
// Booleans never win and keep the loop. The array lookup instead of a
|
|
31
|
+
// ternary keeps both branches covered on every Node.
|
|
32
|
+
// TODO: drop the Node 20 tier (and this version check) once Node 20 is no
|
|
33
|
+
// longer supported.
|
|
34
|
+
const nodeMajor = Number.parseInt(process.versions.node, 10)
|
|
35
|
+
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)]
|
|
36
|
+
const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
|
|
37
|
+
const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number }
|
|
38
|
+
|
|
16
39
|
const singleSchemaKeywords = [
|
|
17
40
|
'additionalItems',
|
|
18
41
|
'additionalProperties',
|
|
@@ -44,7 +67,8 @@ const {
|
|
|
44
67
|
asDateTime,
|
|
45
68
|
asDate,
|
|
46
69
|
asTime,
|
|
47
|
-
asUnsafeString
|
|
70
|
+
asUnsafeString,
|
|
71
|
+
asPlainStringArray
|
|
48
72
|
} = serializer
|
|
49
73
|
|
|
50
74
|
const asInteger = serializer.asInteger.bind(serializer)
|
|
@@ -81,6 +105,7 @@ function schemaDepthError (maxDepth, name) {
|
|
|
81
105
|
function assertObjectDepth (schema, maxDepth, name) {
|
|
82
106
|
const stack = [{ value: schema, depth: 0, exit: false }]
|
|
83
107
|
const path = new WeakSet()
|
|
108
|
+
let hasRef = false
|
|
84
109
|
|
|
85
110
|
while (stack.length > 0) {
|
|
86
111
|
const entry = stack.pop()
|
|
@@ -107,9 +132,19 @@ function assertObjectDepth (schema, maxDepth, name) {
|
|
|
107
132
|
stack.push({ value, depth: entry.depth, exit: true })
|
|
108
133
|
|
|
109
134
|
for (const key in value) {
|
|
110
|
-
|
|
135
|
+
const child = value[key]
|
|
136
|
+
if (key === '$ref' && typeof child === 'string') {
|
|
137
|
+
hasRef = true
|
|
138
|
+
}
|
|
139
|
+
// Primitive values cannot contain cycles or contribute object depth.
|
|
140
|
+
// Avoid allocating stack entries that the next iteration would discard.
|
|
141
|
+
if (typeof child === 'object' && child !== null) {
|
|
142
|
+
stack.push({ value: child, depth: entry.depth + 1, exit: false })
|
|
143
|
+
}
|
|
111
144
|
}
|
|
112
145
|
}
|
|
146
|
+
|
|
147
|
+
return hasRef
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
function assertSchemaDepth (schema, maxDepth, name) {
|
|
@@ -324,7 +359,9 @@ function build (schema, options) {
|
|
|
324
359
|
options = options || {}
|
|
325
360
|
|
|
326
361
|
const maxDepth = getMaxDepth(options)
|
|
327
|
-
assertObjectDepth
|
|
362
|
+
// assertObjectDepth also reports whether any $ref appears; recursive-schema
|
|
363
|
+
// detection is only needed when $ref can form cycles.
|
|
364
|
+
let hasRef = assertObjectDepth(schema, maxDepth)
|
|
328
365
|
assertSchemaDepth(schema, maxDepth)
|
|
329
366
|
isValidSchema(schema)
|
|
330
367
|
|
|
@@ -357,7 +394,9 @@ function build (schema, options) {
|
|
|
357
394
|
const schema = options.schema[key]
|
|
358
395
|
const schemaId = getSchemaId(schema, key)
|
|
359
396
|
if (!context.refResolver.hasSchema(schemaId)) {
|
|
360
|
-
assertObjectDepth(schema, maxDepth, key)
|
|
397
|
+
if (assertObjectDepth(schema, maxDepth, key)) {
|
|
398
|
+
hasRef = true
|
|
399
|
+
}
|
|
361
400
|
assertSchemaDepth(schema, maxDepth, key)
|
|
362
401
|
isValidSchema(schema, key)
|
|
363
402
|
context.refResolver.addSchema(schema, key)
|
|
@@ -395,7 +434,11 @@ function build (schema, options) {
|
|
|
395
434
|
}
|
|
396
435
|
|
|
397
436
|
const location = new Location(schema, context.rootSchemaId)
|
|
398
|
-
|
|
437
|
+
// Without $ref, json-pointer paths are unique in a tree walk, so no cycle
|
|
438
|
+
// can appear in recursivePaths. Skip the third full-schema traversal.
|
|
439
|
+
if (hasRef) {
|
|
440
|
+
detectRecursiveSchemas(context, location)
|
|
441
|
+
}
|
|
399
442
|
const code = buildValue(context, location, 'input')
|
|
400
443
|
|
|
401
444
|
let contextFunctionCode = `
|
|
@@ -437,23 +480,35 @@ function build (schema, options) {
|
|
|
437
480
|
}
|
|
438
481
|
|
|
439
482
|
const serializer = new Serializer(options)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
483
|
+
// Ajv construction dominates tiny build() cost. Only create a Validator when
|
|
484
|
+
// generated code needs validator.validate (anyOf/oneOf/if-then-else), or when
|
|
485
|
+
// debug mode exposes the instance. Standalone already emits `validator = null`
|
|
486
|
+
// when validatorSchemasIds is empty.
|
|
487
|
+
const needsValidator =
|
|
488
|
+
context.validatorSchemasIds.size > 0 ||
|
|
489
|
+
options.mode === 'debug'
|
|
490
|
+
|
|
491
|
+
const validator = needsValidator
|
|
492
|
+
? new Validator(
|
|
493
|
+
options.ajv,
|
|
494
|
+
options.mode === 'standalone' && options.inlineValidators
|
|
495
|
+
)
|
|
496
|
+
: null
|
|
448
497
|
|
|
449
|
-
|
|
450
|
-
for (const
|
|
498
|
+
if (validator !== null) {
|
|
499
|
+
for (const schemaId of context.validatorSchemasIds) {
|
|
500
|
+
const schema = context.refResolver.getSchema(schemaId)
|
|
451
501
|
validator.addSchema(schema, schemaId)
|
|
502
|
+
|
|
503
|
+
const dependencies = getSchemaDependencies(context.refResolver, schemaId)
|
|
504
|
+
for (const [schemaId, schema] of Object.entries(dependencies)) {
|
|
505
|
+
validator.addSchema(schema, schemaId)
|
|
506
|
+
}
|
|
452
507
|
}
|
|
453
|
-
}
|
|
454
508
|
|
|
455
|
-
|
|
456
|
-
|
|
509
|
+
if (options.compileValidators) {
|
|
510
|
+
validator.compileSchemas(context.validatorSchemaRefs)
|
|
511
|
+
}
|
|
457
512
|
}
|
|
458
513
|
|
|
459
514
|
if (options.mode === 'debug') {
|
|
@@ -592,7 +647,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
|
|
|
592
647
|
return code
|
|
593
648
|
}
|
|
594
649
|
|
|
595
|
-
function buildInnerObject (context, location, objVar) {
|
|
650
|
+
function buildInnerObject (context, location, objVar, isReturnForm) {
|
|
596
651
|
const schema = location.schema
|
|
597
652
|
|
|
598
653
|
const propertiesLocation = location.getPropertyLocation('properties')
|
|
@@ -616,17 +671,16 @@ function buildInnerObject (context, location, objVar) {
|
|
|
616
671
|
}
|
|
617
672
|
}
|
|
618
673
|
|
|
619
|
-
code += 'json += JSON_STR_BEGIN_OBJECT\n'
|
|
620
|
-
|
|
621
674
|
const localUid = context.uid++
|
|
622
675
|
let addComma = ''
|
|
676
|
+
let emptyObjectGuard = null
|
|
623
677
|
|
|
624
678
|
// propertiesKeys is sorted required-first; the guard checks [0] is required because
|
|
625
679
|
// otherwise additionalProperties/patternProperties would emit a stray `{ ,"k":v }`.
|
|
626
680
|
if (propertiesKeys.length > 0 && requiredProperties.includes(propertiesKeys[0])) {
|
|
627
|
-
// The first property is required, so
|
|
628
|
-
//
|
|
629
|
-
|
|
681
|
+
// The first property is required, so it always emits (or throws): fold the
|
|
682
|
+
// object-open brace into its key prefix. Subsequent properties fold the
|
|
683
|
+
// comma into their key prefix the same way, cutting one append per property.
|
|
630
684
|
for (let i = 0; i < propertiesKeys.length; i++) {
|
|
631
685
|
const key = propertiesKeys[i]
|
|
632
686
|
const propertyLocation = propertiesLocation.getPropertyLocation(key)
|
|
@@ -640,23 +694,18 @@ function buildInnerObject (context, location, objVar) {
|
|
|
640
694
|
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
|
|
641
695
|
const defaultValue = resolvedLocation.schema.default
|
|
642
696
|
const isRequired = requiredProperties.includes(key)
|
|
643
|
-
|
|
644
|
-
// i === 0 means it's the first property, and it IS required (due to sort). No comma.
|
|
645
|
-
// i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma.
|
|
646
|
-
const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA'
|
|
697
|
+
const keyPrefix = (i === 0 ? '{' : ',') + sanitizedKey + ':'
|
|
647
698
|
|
|
648
699
|
code += `
|
|
649
700
|
const ${value} = ${objVar}[${sanitizedKey}]
|
|
650
701
|
if (${value} !== undefined) {
|
|
651
|
-
${
|
|
652
|
-
json += ${JSON.stringify(sanitizedKey + ':')}
|
|
702
|
+
json += ${JSON.stringify(keyPrefix)}
|
|
653
703
|
${buildValue(context, resolvedLocation, `${value}`)}
|
|
654
704
|
}`
|
|
655
705
|
|
|
656
706
|
if (defaultValue !== undefined) {
|
|
657
707
|
code += ` else {
|
|
658
|
-
${
|
|
659
|
-
json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
|
|
708
|
+
json += ${JSON.stringify(keyPrefix + JSON.stringify(defaultValue))}
|
|
660
709
|
}
|
|
661
710
|
`
|
|
662
711
|
} else if (isRequired) {
|
|
@@ -671,11 +720,16 @@ function buildInnerObject (context, location, objVar) {
|
|
|
671
720
|
|
|
672
721
|
addComma = 'json += JSON_STR_COMMA'
|
|
673
722
|
} else {
|
|
723
|
+
// No property is guaranteed to emit: fold `{` into whichever property
|
|
724
|
+
// emits first, and only emit a bare `{` when none does.
|
|
674
725
|
const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false)
|
|
675
726
|
|
|
676
727
|
if (needsRuntimeComma) {
|
|
677
728
|
code += `let addComma_${localUid} = false\n`
|
|
678
|
-
addComma =
|
|
729
|
+
addComma = `json += addComma_${localUid} ? JSON_STR_COMMA : ((addComma_${localUid} = true), JSON_STR_BEGIN_OBJECT)`
|
|
730
|
+
emptyObjectGuard = `if (!addComma_${localUid}) json += JSON_STR_BEGIN_OBJECT\n`
|
|
731
|
+
} else if (propertiesKeys.length === 0) {
|
|
732
|
+
code += 'json += JSON_STR_BEGIN_OBJECT\n'
|
|
679
733
|
}
|
|
680
734
|
|
|
681
735
|
for (const key of propertiesKeys) {
|
|
@@ -688,12 +742,10 @@ function buildInnerObject (context, location, objVar) {
|
|
|
688
742
|
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
|
|
689
743
|
const defaultValue = propertyLocation.schema.default
|
|
690
744
|
const isRequired = requiredProperties.includes(key) // Should be false here but good to keep
|
|
691
|
-
|
|
692
|
-
const propertyPrefix = sanitizedKey + ':'
|
|
745
|
+
const keyColon = sanitizedKey + ':'
|
|
693
746
|
const addProperty = needsRuntimeComma
|
|
694
|
-
? `json += addComma_${localUid} ? ${JSON.stringify(',' +
|
|
695
|
-
|
|
696
|
-
: `json += ${JSON.stringify(propertyPrefix)}`
|
|
747
|
+
? `json += addComma_${localUid} ? ${JSON.stringify(',' + keyColon)} : ((addComma_${localUid} = true), ${JSON.stringify('{' + keyColon)})`
|
|
748
|
+
: `json += ${JSON.stringify('{' + keyColon)}`
|
|
697
749
|
|
|
698
750
|
code += `
|
|
699
751
|
const ${value} = ${objVar}[${sanitizedKey}]
|
|
@@ -703,17 +755,34 @@ function buildInnerObject (context, location, objVar) {
|
|
|
703
755
|
}`
|
|
704
756
|
|
|
705
757
|
if (defaultValue !== undefined) {
|
|
706
|
-
|
|
758
|
+
const defaultValueLiteral = JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))
|
|
759
|
+
if (needsRuntimeComma) {
|
|
760
|
+
code += ` else {
|
|
707
761
|
${addComma}
|
|
708
|
-
json += ${
|
|
762
|
+
json += ${defaultValueLiteral}
|
|
763
|
+
}
|
|
764
|
+
`
|
|
765
|
+
} else {
|
|
766
|
+
// Single optional property with default: `{` is folded into the
|
|
767
|
+
// default literal so the absent branch still opens the object.
|
|
768
|
+
code += ` else {
|
|
769
|
+
json += ${JSON.stringify('{' + sanitizedKey + ':' + JSON.stringify(defaultValue))}
|
|
709
770
|
}
|
|
710
771
|
`
|
|
772
|
+
}
|
|
711
773
|
} else if (isRequired) {
|
|
712
774
|
// Should not happen if requiredProperties.length === 0 but safety
|
|
713
775
|
code += ` else {
|
|
714
776
|
throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
|
|
715
777
|
}
|
|
716
778
|
`
|
|
779
|
+
} else if (!needsRuntimeComma) {
|
|
780
|
+
// Single optional property: `{` is folded into its key, so emit `{`
|
|
781
|
+
// when the property is absent.
|
|
782
|
+
code += ` else {
|
|
783
|
+
json += JSON_STR_BEGIN_OBJECT
|
|
784
|
+
}
|
|
785
|
+
`
|
|
717
786
|
} else {
|
|
718
787
|
code += '\n'
|
|
719
788
|
}
|
|
@@ -724,9 +793,15 @@ function buildInnerObject (context, location, objVar) {
|
|
|
724
793
|
code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar)
|
|
725
794
|
}
|
|
726
795
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
796
|
+
if (emptyObjectGuard !== null) {
|
|
797
|
+
code += emptyObjectGuard
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Named object serializers can return `json + '}'` directly, saving one
|
|
801
|
+
// append + intermediate assignment in the hot per-item path.
|
|
802
|
+
code += isReturnForm
|
|
803
|
+
? '\nreturn json + JSON_STR_END_OBJECT\n'
|
|
804
|
+
: '\njson += JSON_STR_END_OBJECT\n'
|
|
730
805
|
return code
|
|
731
806
|
}
|
|
732
807
|
|
|
@@ -820,8 +895,7 @@ function buildObject (context, location, input) {
|
|
|
820
895
|
if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
|
|
821
896
|
let json = ''
|
|
822
897
|
|
|
823
|
-
${buildInnerObject(context, location, 'obj')}
|
|
824
|
-
return json
|
|
898
|
+
${buildInnerObject(context, location, 'obj', true)}
|
|
825
899
|
}
|
|
826
900
|
`
|
|
827
901
|
|
|
@@ -843,6 +917,73 @@ function buildObject (context, location, input) {
|
|
|
843
917
|
return code
|
|
844
918
|
}
|
|
845
919
|
|
|
920
|
+
// Per-element condition under which a native JSON.stringify(array) is
|
|
921
|
+
// byte-identical to the generated per-item serializer for primitive item
|
|
922
|
+
// schemas, plus the minimum array length at which copying beats the existing
|
|
923
|
+
// loop. Returns null when the item schema uses any keyword that changes
|
|
924
|
+
// serialization dispatch, or when the kind never wins (boolean: 'true'/'false'
|
|
925
|
+
// concat is already cheap; the copy loses 25-44% at every size on Node 24).
|
|
926
|
+
function getPrimitiveArrayFastPathCondition (itemsSchema) {
|
|
927
|
+
if (
|
|
928
|
+
Array.isArray(itemsSchema) ||
|
|
929
|
+
itemsSchema.$ref !== undefined ||
|
|
930
|
+
typeof itemsSchema.type !== 'string' ||
|
|
931
|
+
itemsSchema.const !== undefined ||
|
|
932
|
+
itemsSchema.nullable === true ||
|
|
933
|
+
itemsSchema.allOf !== undefined ||
|
|
934
|
+
itemsSchema.anyOf !== undefined ||
|
|
935
|
+
itemsSchema.oneOf !== undefined ||
|
|
936
|
+
itemsSchema.if !== undefined
|
|
937
|
+
) {
|
|
938
|
+
return null
|
|
939
|
+
}
|
|
940
|
+
switch (itemsSchema.type) {
|
|
941
|
+
case 'number':
|
|
942
|
+
// asNumber(): NaN throws, Infinity serializes as null, every other
|
|
943
|
+
// number renders via ToString, exactly like JSON.stringify. The cutoff
|
|
944
|
+
// is in NUMBER_ARRAY_FAST_PATH.
|
|
945
|
+
return NUMBER_ARRAY_FAST_PATH
|
|
946
|
+
case 'integer':
|
|
947
|
+
// asInteger(): only integer numbers skip the (rounding-dependent)
|
|
948
|
+
// parseInteger path; those render via ToString like JSON.stringify.
|
|
949
|
+
// Small-int ToString is cached, so the loop stays cheap for longer;
|
|
950
|
+
// the cutoff is in INTEGER_ARRAY_FAST_PATH.
|
|
951
|
+
return INTEGER_ARRAY_FAST_PATH
|
|
952
|
+
default:
|
|
953
|
+
return null
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Item schema { type: 'string' } with no format, nullable, const, or combinator.
|
|
958
|
+
// Other keywords (enum, pattern, minLength) are not applied while serializing.
|
|
959
|
+
function isPlainStringItemsSchema (schema) {
|
|
960
|
+
if (schema.type !== 'string') {
|
|
961
|
+
return false
|
|
962
|
+
}
|
|
963
|
+
if (schema.format !== undefined) {
|
|
964
|
+
return false
|
|
965
|
+
}
|
|
966
|
+
if (schema.nullable === true) {
|
|
967
|
+
return false
|
|
968
|
+
}
|
|
969
|
+
if (schema.const !== undefined) {
|
|
970
|
+
return false
|
|
971
|
+
}
|
|
972
|
+
if (schema.allOf !== undefined) {
|
|
973
|
+
return false
|
|
974
|
+
}
|
|
975
|
+
if (schema.anyOf !== undefined) {
|
|
976
|
+
return false
|
|
977
|
+
}
|
|
978
|
+
if (schema.oneOf !== undefined) {
|
|
979
|
+
return false
|
|
980
|
+
}
|
|
981
|
+
if (schema.if !== undefined) {
|
|
982
|
+
return false
|
|
983
|
+
}
|
|
984
|
+
return true
|
|
985
|
+
}
|
|
986
|
+
|
|
846
987
|
function buildArray (context, location, input) {
|
|
847
988
|
const schema = location.schema
|
|
848
989
|
|
|
@@ -854,6 +995,9 @@ function buildArray (context, location, input) {
|
|
|
854
995
|
}
|
|
855
996
|
|
|
856
997
|
const itemsSchema = itemsLocation.schema
|
|
998
|
+
const plainStringFastPath = context.largeArrayMechanism !== 'json-stringify' &&
|
|
999
|
+
!Array.isArray(itemsSchema) &&
|
|
1000
|
+
isPlainStringItemsSchema(itemsSchema)
|
|
857
1001
|
|
|
858
1002
|
if (context.functionsNamesBySchema.has(schema)) {
|
|
859
1003
|
const funcName = context.functionsNamesBySchema.get(schema)
|
|
@@ -896,6 +1040,44 @@ function buildArray (context, location, input) {
|
|
|
896
1040
|
|
|
897
1041
|
if (context.largeArrayMechanism === 'json-stringify') {
|
|
898
1042
|
functionCode += `if (arrayLength >= ${context.largeArraySize}) return JSON.stringify(obj)\n`
|
|
1043
|
+
} else if (plainStringFastPath) {
|
|
1044
|
+
// Copy strings once; the helper resumes ordered coercion on the first miss.
|
|
1045
|
+
functionCode += `
|
|
1046
|
+
if (typeof arrayLength === 'number' && arrayLength >= ${context.largeArraySize} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
|
|
1047
|
+
return asPlainStringArray(obj, arrayLength)
|
|
1048
|
+
}
|
|
1049
|
+
`
|
|
1050
|
+
} else {
|
|
1051
|
+
const fastPath = getPrimitiveArrayFastPathCondition(itemsSchema)
|
|
1052
|
+
if (fastPath) {
|
|
1053
|
+
const valueCode = buildValue(context, itemsLocation, 'v')
|
|
1054
|
+
functionCode += `
|
|
1055
|
+
// Copying small arrays costs more than the existing loop.
|
|
1056
|
+
if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
|
|
1057
|
+
const copy = new Array(arrayLength)
|
|
1058
|
+
Object.setPrototypeOf(copy, null)
|
|
1059
|
+
for (let i = 0; i < arrayLength; i++) {
|
|
1060
|
+
const v = obj[i]
|
|
1061
|
+
if (!(${fastPath.condition})) {
|
|
1062
|
+
// Serialize the captured prefix, then resume at the first miss.
|
|
1063
|
+
// Never reread getters or read ahead of an item's coercion.
|
|
1064
|
+
copy.length = i
|
|
1065
|
+
json = JSON.stringify(copy).slice(0, -1)
|
|
1066
|
+
if (i > 0) json += JSON_STR_COMMA
|
|
1067
|
+
${valueCode}
|
|
1068
|
+
for (i++; i < arrayLength; i++) {
|
|
1069
|
+
json += JSON_STR_COMMA
|
|
1070
|
+
const v = obj[i]
|
|
1071
|
+
${valueCode}
|
|
1072
|
+
}
|
|
1073
|
+
return json + JSON_STR_END_ARRAY
|
|
1074
|
+
}
|
|
1075
|
+
copy[i] = v
|
|
1076
|
+
}
|
|
1077
|
+
return JSON.stringify(copy)
|
|
1078
|
+
}
|
|
1079
|
+
`
|
|
1080
|
+
}
|
|
899
1081
|
}
|
|
900
1082
|
|
|
901
1083
|
functionCode += `
|
|
@@ -981,6 +1163,13 @@ function buildArray (context, location, input) {
|
|
|
981
1163
|
|
|
982
1164
|
if (context.largeArrayMechanism === 'json-stringify') {
|
|
983
1165
|
inlinedCode += `if (arrayLength_${objVar} >= ${context.largeArraySize}) json += JSON.stringify(${objVar})\n else {`
|
|
1166
|
+
} else if (plainStringFastPath) {
|
|
1167
|
+
inlinedCode += `
|
|
1168
|
+
let fjsPlain_${objVar}
|
|
1169
|
+
if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
|
|
1170
|
+
if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
|
|
1171
|
+
else {
|
|
1172
|
+
`
|
|
984
1173
|
}
|
|
985
1174
|
|
|
986
1175
|
inlinedCode += `
|
|
@@ -1037,7 +1226,7 @@ function buildArray (context, location, input) {
|
|
|
1037
1226
|
json += JSON_STR_END_ARRAY
|
|
1038
1227
|
`
|
|
1039
1228
|
|
|
1040
|
-
if (context.largeArrayMechanism === 'json-stringify') {
|
|
1229
|
+
if (context.largeArrayMechanism === 'json-stringify' || plainStringFastPath) {
|
|
1041
1230
|
inlinedCode += '}'
|
|
1042
1231
|
}
|
|
1043
1232
|
|
|
@@ -1236,8 +1425,8 @@ function detectRecursiveSchemas (context, location) {
|
|
|
1236
1425
|
throw schemaDepthError(context.maxDepth)
|
|
1237
1426
|
}
|
|
1238
1427
|
|
|
1239
|
-
const schemaId = location.schemaId
|
|
1240
|
-
const jsonPointer = location.jsonPointer
|
|
1428
|
+
const schemaId = location.schemaId
|
|
1429
|
+
const jsonPointer = location.jsonPointer
|
|
1241
1430
|
const fullPath = `${schemaId}#${jsonPointer}`
|
|
1242
1431
|
|
|
1243
1432
|
if (pathStack.has(fullPath)) {
|
|
@@ -1357,6 +1546,9 @@ function buildAllOf (context, location, input) {
|
|
|
1357
1546
|
context.mergedSchemasIds.set(schema, mergedSchemaId)
|
|
1358
1547
|
|
|
1359
1548
|
const { allOf, ...schemaWithoutAllOf } = location.schema
|
|
1549
|
+
const dropAdditionalProperties = schemaWithoutAllOf.additionalProperties === false
|
|
1550
|
+
if (dropAdditionalProperties) delete schemaWithoutAllOf.additionalProperties
|
|
1551
|
+
|
|
1360
1552
|
const locations = [
|
|
1361
1553
|
new Location(
|
|
1362
1554
|
schemaWithoutAllOf,
|
|
@@ -1371,6 +1563,7 @@ function buildAllOf (context, location, input) {
|
|
|
1371
1563
|
}
|
|
1372
1564
|
|
|
1373
1565
|
const mergedLocation = mergeLocations(context, mergedSchemaId, locations)
|
|
1566
|
+
if (dropAdditionalProperties) mergedLocation.schema.additionalProperties = false
|
|
1374
1567
|
return buildValue(context, mergedLocation, input)
|
|
1375
1568
|
}
|
|
1376
1569
|
|
package/lib/location.js
CHANGED
|
@@ -9,7 +9,8 @@ class Location {
|
|
|
9
9
|
|
|
10
10
|
getPropertyLocation (propertyName) {
|
|
11
11
|
// a JSON pointer segment, so a property name with a / or a ~ still points at the property
|
|
12
|
-
const
|
|
12
|
+
const name = String(propertyName)
|
|
13
|
+
const segment = /[~/]/.test(name) ? name.replace(/~/g, '~0').replace(/\//g, '~1') : name
|
|
13
14
|
const propertyLocation = new Location(
|
|
14
15
|
this.schema[propertyName],
|
|
15
16
|
this.schemaId,
|
package/lib/serializer.js
CHANGED
|
@@ -135,6 +135,39 @@ module.exports = class Serializer {
|
|
|
135
135
|
return '"' + str + '"'
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Copy primitive strings once, ignoring array/prototype toJSON hooks. On a
|
|
139
|
+
// miss, serialize the captured prefix and coerce the remaining items in order,
|
|
140
|
+
// matching buildSingleTypeSerializer without rereading getters.
|
|
141
|
+
asPlainStringArray (arr, len = arr.length) {
|
|
142
|
+
const copy = new Array(len)
|
|
143
|
+
Object.setPrototypeOf(copy, null)
|
|
144
|
+
for (let i = 0; i < len; i++) {
|
|
145
|
+
const value = arr[i]
|
|
146
|
+
if (typeof value !== 'string') {
|
|
147
|
+
copy.length = i
|
|
148
|
+
let json = JSON.stringify(copy).slice(0, -1)
|
|
149
|
+
for (let j = i; j < len; j++) {
|
|
150
|
+
const item = j === i ? value : arr[j]
|
|
151
|
+
if (j > 0) json += ','
|
|
152
|
+
if (typeof item === 'string') {
|
|
153
|
+
json += asString(item)
|
|
154
|
+
} else if (item === null) {
|
|
155
|
+
json += '""'
|
|
156
|
+
} else if (item instanceof Date) {
|
|
157
|
+
json += '"' + item.toISOString() + '"'
|
|
158
|
+
} else if (item instanceof RegExp) {
|
|
159
|
+
json += asString(item.source)
|
|
160
|
+
} else {
|
|
161
|
+
json += asString(item.toString())
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return json + ']'
|
|
165
|
+
}
|
|
166
|
+
copy[i] = value
|
|
167
|
+
}
|
|
168
|
+
return JSON.stringify(copy)
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
getState () {
|
|
139
172
|
return this._options
|
|
140
173
|
}
|
package/package.json
CHANGED
package/test/allof.test.js
CHANGED
|
@@ -749,3 +749,54 @@ test('do not crash with $ref prop', (t) => {
|
|
|
749
749
|
})
|
|
750
750
|
t.assert.equal(value, '{"outside":{"$ref":"true"}}')
|
|
751
751
|
})
|
|
752
|
+
|
|
753
|
+
test('allOf keeps property constraints when additional properties are disabled', (t) => {
|
|
754
|
+
const schema = {
|
|
755
|
+
additionalProperties: false,
|
|
756
|
+
allOf: [{
|
|
757
|
+
type: 'object',
|
|
758
|
+
properties: {
|
|
759
|
+
entries: {
|
|
760
|
+
type: 'array',
|
|
761
|
+
items: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
properties: { enabled: { type: 'boolean' }, domain: { type: 'string' } },
|
|
764
|
+
required: ['domain']
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}]
|
|
769
|
+
}
|
|
770
|
+
const stringify = build(schema)
|
|
771
|
+
t.assert.deepEqual(JSON.parse(stringify({ entries: [{ enabled: 1, domain: 'example.com' }], extra: true })),
|
|
772
|
+
{ entries: [{ enabled: true, domain: 'example.com' }] })
|
|
773
|
+
t.assert.throws(() => stringify({ entries: [{ enabled: 1 }] }), /"domain" is required/)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
test('allOf keeps root and referenced property constraints with additionalProperties false', (t) => {
|
|
777
|
+
const schema = {
|
|
778
|
+
type: 'object',
|
|
779
|
+
additionalProperties: false,
|
|
780
|
+
properties: { name: { type: 'string' } },
|
|
781
|
+
required: ['name'],
|
|
782
|
+
allOf: [{ $ref: 'entry' }]
|
|
783
|
+
}
|
|
784
|
+
const stringify = build(schema, {
|
|
785
|
+
schema: { entry: { type: 'object', properties: { enabled: { type: 'boolean' } }, required: ['enabled'] } }
|
|
786
|
+
})
|
|
787
|
+
t.assert.deepEqual(JSON.parse(stringify({ name: 'one', enabled: 1, extra: 'drop' })), { name: 'one', enabled: true })
|
|
788
|
+
t.assert.throws(() => stringify({ name: 'one' }), /"enabled" is required/)
|
|
789
|
+
t.assert.throws(() => stringify({ enabled: true }), /"name" is required/)
|
|
790
|
+
t.assert.equal(schema.additionalProperties, false)
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
test('allOf preserves explicit additional property schemas', (t) => {
|
|
794
|
+
for (const additionalProperties of [true, { type: 'boolean' }]) {
|
|
795
|
+
const stringify = build({
|
|
796
|
+
type: 'object',
|
|
797
|
+
additionalProperties,
|
|
798
|
+
allOf: [{ type: 'object', properties: { enabled: { type: 'boolean' } } }]
|
|
799
|
+
})
|
|
800
|
+
t.assert.deepEqual(JSON.parse(stringify({ enabled: 1, extra: true })), { enabled: true, extra: true })
|
|
801
|
+
}
|
|
802
|
+
})
|
package/test/array.test.js
CHANGED
|
@@ -711,3 +711,125 @@ buildTest({
|
|
|
711
711
|
largeArraySize: '10000',
|
|
712
712
|
largeArrayMechanism: 'default'
|
|
713
713
|
})
|
|
714
|
+
|
|
715
|
+
buildTest({
|
|
716
|
+
title: 'string array whose items declare const',
|
|
717
|
+
type: 'array',
|
|
718
|
+
items: { type: 'string', const: 'a' }
|
|
719
|
+
}, ['a', 'a'])
|
|
720
|
+
|
|
721
|
+
buildTest({
|
|
722
|
+
title: 'string array whose items declare allOf',
|
|
723
|
+
type: 'array',
|
|
724
|
+
items: { type: 'string', allOf: [{ type: 'string' }] }
|
|
725
|
+
}, ['a', 'b'])
|
|
726
|
+
|
|
727
|
+
buildTest({
|
|
728
|
+
title: 'string array whose items declare anyOf',
|
|
729
|
+
type: 'array',
|
|
730
|
+
items: { type: 'string', anyOf: [{ type: 'string' }] }
|
|
731
|
+
}, ['a', 'b'])
|
|
732
|
+
|
|
733
|
+
buildTest({
|
|
734
|
+
title: 'string array whose items declare oneOf',
|
|
735
|
+
type: 'array',
|
|
736
|
+
items: { type: 'string', oneOf: [{ type: 'string' }] }
|
|
737
|
+
}, ['a', 'b'])
|
|
738
|
+
|
|
739
|
+
buildTest({
|
|
740
|
+
title: 'string array whose items declare if',
|
|
741
|
+
type: 'array',
|
|
742
|
+
items: { type: 'string', if: { type: 'string' }, then: { type: 'string' } }
|
|
743
|
+
}, ['a', 'b'])
|
|
744
|
+
|
|
745
|
+
test('string array coerces a non-string item', (t) => {
|
|
746
|
+
const stringify = build({
|
|
747
|
+
type: 'array',
|
|
748
|
+
items: { type: 'string' }
|
|
749
|
+
}, { largeArraySize: 2 })
|
|
750
|
+
const date = new Date('2020-01-02T03:04:05.006Z')
|
|
751
|
+
|
|
752
|
+
t.assert.equal(
|
|
753
|
+
stringify([1, 'a', null, date, /re/]),
|
|
754
|
+
'["1","a","","2020-01-02T03:04:05.006Z","re"]'
|
|
755
|
+
)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
// Large number/integer arrays are copied and handed to JSON.stringify. The
|
|
759
|
+
// first element that is not already the right type switches to the per-item
|
|
760
|
+
// loop from that index on. 3e5 elements is the largest fast-path cutoff on
|
|
761
|
+
// any supported Node release (see PRIMITIVE_FAST_PATH_MIN_LENGTH in index.js),
|
|
762
|
+
// so these always exercise the resume path.
|
|
763
|
+
const FAST_PATH_LENGTH = 3e5
|
|
764
|
+
|
|
765
|
+
for (const [type, bad, expected] of [
|
|
766
|
+
['integer', 1.9, '1'],
|
|
767
|
+
['integer', '7', '7'],
|
|
768
|
+
['integer', null, '0'],
|
|
769
|
+
['number', '7.5', '7.5'],
|
|
770
|
+
['number', null, '0'],
|
|
771
|
+
['number', true, '1']
|
|
772
|
+
]) {
|
|
773
|
+
for (const [position, index] of [
|
|
774
|
+
['start', 0],
|
|
775
|
+
['middle', FAST_PATH_LENGTH >> 1],
|
|
776
|
+
['end', FAST_PATH_LENGTH - 1]
|
|
777
|
+
]) {
|
|
778
|
+
test(`large ${type} array falls back to the loop on ${JSON.stringify(bad)} at the ${position}`, (t) => {
|
|
779
|
+
const stringify = build({ type: 'array', items: { type } })
|
|
780
|
+
const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i - 1000)
|
|
781
|
+
input[index] = bad
|
|
782
|
+
|
|
783
|
+
const reference = Array.from(input, (v, i) => i === index ? expected : String(v))
|
|
784
|
+
t.assert.equal(stringify(input), '[' + reference.join(',') + ']')
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
test('large integer array applies the rounding option after the fallback', (t) => {
|
|
790
|
+
const stringify = build({ type: 'array', items: { type: 'integer' } }, { rounding: 'ceil' })
|
|
791
|
+
const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
|
|
792
|
+
input[10] = 1.1
|
|
793
|
+
input[FAST_PATH_LENGTH - 1] = 2.1
|
|
794
|
+
|
|
795
|
+
const output = stringify(input)
|
|
796
|
+
t.assert.equal(output.slice(0, 23), '[0,1,2,3,4,5,6,7,8,9,2,')
|
|
797
|
+
t.assert.equal(output.slice(-3), ',3]')
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
test('large number array still throws on a non-numeric string after the fallback', (t) => {
|
|
801
|
+
const stringify = build({ type: 'array', items: { type: 'number' } })
|
|
802
|
+
const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
|
|
803
|
+
input[FAST_PATH_LENGTH >> 1] = 'not a number'
|
|
804
|
+
|
|
805
|
+
t.assert.throws(() => stringify(input), new Error('The value "not a number" cannot be converted to a number.'))
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
// A real NaN is typeof 'number', so only the `v === v` half of the fast-path
|
|
809
|
+
// condition rejects it. Check it at every position the copy loop can stop at.
|
|
810
|
+
for (const [position, index] of [['start', 0], ['middle', FAST_PATH_LENGTH >> 1], ['end', FAST_PATH_LENGTH - 1]]) {
|
|
811
|
+
test(`large number array still throws on NaN at the ${position} after the fallback`, (t) => {
|
|
812
|
+
const stringify = build({ type: 'array', items: { type: 'number' } })
|
|
813
|
+
const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
|
|
814
|
+
input[index] = NaN
|
|
815
|
+
|
|
816
|
+
t.assert.throws(() => stringify(input), new Error('The value "NaN" cannot be converted to a number.'))
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
test('large number array reads each element once across the fallback', (t) => {
|
|
821
|
+
const stringify = build({ type: 'array', items: { type: 'number' } })
|
|
822
|
+
const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
|
|
823
|
+
const reads = []
|
|
824
|
+
for (const index of [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1]) {
|
|
825
|
+
Object.defineProperty(input, index, {
|
|
826
|
+
get () {
|
|
827
|
+
reads.push(index)
|
|
828
|
+
return index === 1 ? '1' : index
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
t.assert.equal(stringify(input), JSON.stringify(Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)))
|
|
834
|
+
t.assert.deepEqual(reads, [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1])
|
|
835
|
+
})
|
|
@@ -110,11 +110,23 @@ test('rejects invalid maximum schema depths', (t) => {
|
|
|
110
110
|
test('checks schema dependencies without treating property lists as schemas', (t) => {
|
|
111
111
|
t.assert.doesNotThrow(() => build({
|
|
112
112
|
type: 'object',
|
|
113
|
+
definitions: {
|
|
114
|
+
valueProp: { type: 'string' },
|
|
115
|
+
any: true
|
|
116
|
+
},
|
|
117
|
+
// Include a $ref so recursive-schema detection runs and walks dependency
|
|
118
|
+
// schema forms (property-list dependencies must still be ignored). Boolean
|
|
119
|
+
// schemas must also be visited so traverse's non-object early-return stays covered.
|
|
120
|
+
properties: {
|
|
121
|
+
link: { $ref: '#/definitions/valueProp' },
|
|
122
|
+
flag: { $ref: '#/definitions/any' },
|
|
123
|
+
open: true
|
|
124
|
+
},
|
|
113
125
|
dependencies: {
|
|
114
|
-
enabled: { properties: { value: {
|
|
126
|
+
enabled: { properties: { value: { $ref: '#/definitions/valueProp' } } },
|
|
115
127
|
value: ['enabled']
|
|
116
128
|
}
|
|
117
|
-
}, { maxDepth:
|
|
129
|
+
}, { maxDepth: 3 }))
|
|
118
130
|
})
|
|
119
131
|
|
|
120
132
|
test('checks external schemas before validating them recursively', (t) => {
|