fast-json-stringify 7.1.0 → 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-cmp-lib.js +63 -38
- package/benchmark/bench-fixtures.js +54 -0
- package/benchmark/bench-large-array.js +64 -0
- package/benchmark/bench-thread.js +4 -2
- package/benchmark/bench-utils.js +41 -0
- package/benchmark/fixtures/api-responses.js +266 -0
- package/index.js +251 -59
- 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 +147 -0
- package/test/schema-depth.test.js +14 -2
package/index.js
CHANGED
|
@@ -10,12 +10,32 @@ const Location = require('./lib/location')
|
|
|
10
10
|
const validate = require('./lib/schema-validator')
|
|
11
11
|
const mergeSchemas = require('./lib/merge-schemas')
|
|
12
12
|
|
|
13
|
-
let largeArraySize = 2e4
|
|
14
|
-
let largeArrayMechanism = 'default'
|
|
15
|
-
|
|
16
13
|
const DEFAULT_MAX_DEPTH = 100
|
|
17
14
|
const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
|
|
18
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
|
+
|
|
19
39
|
const singleSchemaKeywords = [
|
|
20
40
|
'additionalItems',
|
|
21
41
|
'additionalProperties',
|
|
@@ -47,7 +67,8 @@ const {
|
|
|
47
67
|
asDateTime,
|
|
48
68
|
asDate,
|
|
49
69
|
asTime,
|
|
50
|
-
asUnsafeString
|
|
70
|
+
asUnsafeString,
|
|
71
|
+
asPlainStringArray
|
|
51
72
|
} = serializer
|
|
52
73
|
|
|
53
74
|
const asInteger = serializer.asInteger.bind(serializer)
|
|
@@ -84,6 +105,7 @@ function schemaDepthError (maxDepth, name) {
|
|
|
84
105
|
function assertObjectDepth (schema, maxDepth, name) {
|
|
85
106
|
const stack = [{ value: schema, depth: 0, exit: false }]
|
|
86
107
|
const path = new WeakSet()
|
|
108
|
+
let hasRef = false
|
|
87
109
|
|
|
88
110
|
while (stack.length > 0) {
|
|
89
111
|
const entry = stack.pop()
|
|
@@ -110,9 +132,19 @@ function assertObjectDepth (schema, maxDepth, name) {
|
|
|
110
132
|
stack.push({ value, depth: entry.depth, exit: true })
|
|
111
133
|
|
|
112
134
|
for (const key in value) {
|
|
113
|
-
|
|
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
|
+
}
|
|
114
144
|
}
|
|
115
145
|
}
|
|
146
|
+
|
|
147
|
+
return hasRef
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
function assertSchemaDepth (schema, maxDepth, name) {
|
|
@@ -327,7 +359,9 @@ function build (schema, options) {
|
|
|
327
359
|
options = options || {}
|
|
328
360
|
|
|
329
361
|
const maxDepth = getMaxDepth(options)
|
|
330
|
-
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)
|
|
331
365
|
assertSchemaDepth(schema, maxDepth)
|
|
332
366
|
isValidSchema(schema)
|
|
333
367
|
|
|
@@ -345,6 +379,8 @@ function build (schema, options) {
|
|
|
345
379
|
recursiveSchemas: new Set(),
|
|
346
380
|
recursivePaths: new Set(),
|
|
347
381
|
buildingSet: new Set(),
|
|
382
|
+
largeArraySize: 2e4,
|
|
383
|
+
largeArrayMechanism: 'default',
|
|
348
384
|
uid: 0
|
|
349
385
|
}
|
|
350
386
|
|
|
@@ -358,7 +394,9 @@ function build (schema, options) {
|
|
|
358
394
|
const schema = options.schema[key]
|
|
359
395
|
const schemaId = getSchemaId(schema, key)
|
|
360
396
|
if (!context.refResolver.hasSchema(schemaId)) {
|
|
361
|
-
assertObjectDepth(schema, maxDepth, key)
|
|
397
|
+
if (assertObjectDepth(schema, maxDepth, key)) {
|
|
398
|
+
hasRef = true
|
|
399
|
+
}
|
|
362
400
|
assertSchemaDepth(schema, maxDepth, key)
|
|
363
401
|
isValidSchema(schema, key)
|
|
364
402
|
context.refResolver.addSchema(schema, key)
|
|
@@ -374,7 +412,7 @@ function build (schema, options) {
|
|
|
374
412
|
|
|
375
413
|
if (options.largeArrayMechanism) {
|
|
376
414
|
if (validLargeArrayMechanisms.has(options.largeArrayMechanism)) {
|
|
377
|
-
largeArrayMechanism = options.largeArrayMechanism
|
|
415
|
+
context.largeArrayMechanism = options.largeArrayMechanism
|
|
378
416
|
} else {
|
|
379
417
|
throw new Error(`Unsupported large array mechanism ${options.largeArrayMechanism}`)
|
|
380
418
|
}
|
|
@@ -385,18 +423,22 @@ function build (schema, options) {
|
|
|
385
423
|
let parsedNumber
|
|
386
424
|
|
|
387
425
|
if (largeArraySizeType === 'string' && Number.isFinite((parsedNumber = Number.parseInt(options.largeArraySize, 10)))) {
|
|
388
|
-
largeArraySize = parsedNumber
|
|
426
|
+
context.largeArraySize = parsedNumber
|
|
389
427
|
} else if (largeArraySizeType === 'number' && Number.isInteger(options.largeArraySize)) {
|
|
390
|
-
largeArraySize = options.largeArraySize
|
|
428
|
+
context.largeArraySize = options.largeArraySize
|
|
391
429
|
} else if (largeArraySizeType === 'bigint') {
|
|
392
|
-
largeArraySize = Number(options.largeArraySize)
|
|
430
|
+
context.largeArraySize = Number(options.largeArraySize)
|
|
393
431
|
} else {
|
|
394
432
|
throw new Error(`Unsupported large array size. Expected integer-like, got ${typeof options.largeArraySize} with value ${options.largeArraySize}`)
|
|
395
433
|
}
|
|
396
434
|
}
|
|
397
435
|
|
|
398
436
|
const location = new Location(schema, context.rootSchemaId)
|
|
399
|
-
|
|
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
|
+
}
|
|
400
442
|
const code = buildValue(context, location, 'input')
|
|
401
443
|
|
|
402
444
|
let contextFunctionCode = `
|
|
@@ -438,23 +480,35 @@ function build (schema, options) {
|
|
|
438
480
|
}
|
|
439
481
|
|
|
440
482
|
const serializer = new Serializer(options)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
449
497
|
|
|
450
|
-
|
|
451
|
-
for (const
|
|
498
|
+
if (validator !== null) {
|
|
499
|
+
for (const schemaId of context.validatorSchemasIds) {
|
|
500
|
+
const schema = context.refResolver.getSchema(schemaId)
|
|
452
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
|
+
}
|
|
453
507
|
}
|
|
454
|
-
}
|
|
455
508
|
|
|
456
|
-
|
|
457
|
-
|
|
509
|
+
if (options.compileValidators) {
|
|
510
|
+
validator.compileSchemas(context.validatorSchemaRefs)
|
|
511
|
+
}
|
|
458
512
|
}
|
|
459
513
|
|
|
460
514
|
if (options.mode === 'debug') {
|
|
@@ -593,7 +647,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
|
|
|
593
647
|
return code
|
|
594
648
|
}
|
|
595
649
|
|
|
596
|
-
function buildInnerObject (context, location, objVar) {
|
|
650
|
+
function buildInnerObject (context, location, objVar, isReturnForm) {
|
|
597
651
|
const schema = location.schema
|
|
598
652
|
|
|
599
653
|
const propertiesLocation = location.getPropertyLocation('properties')
|
|
@@ -617,17 +671,16 @@ function buildInnerObject (context, location, objVar) {
|
|
|
617
671
|
}
|
|
618
672
|
}
|
|
619
673
|
|
|
620
|
-
code += 'json += JSON_STR_BEGIN_OBJECT\n'
|
|
621
|
-
|
|
622
674
|
const localUid = context.uid++
|
|
623
675
|
let addComma = ''
|
|
676
|
+
let emptyObjectGuard = null
|
|
624
677
|
|
|
625
678
|
// propertiesKeys is sorted required-first; the guard checks [0] is required because
|
|
626
679
|
// otherwise additionalProperties/patternProperties would emit a stray `{ ,"k":v }`.
|
|
627
680
|
if (propertiesKeys.length > 0 && requiredProperties.includes(propertiesKeys[0])) {
|
|
628
|
-
// The first property is required, so
|
|
629
|
-
//
|
|
630
|
-
|
|
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.
|
|
631
684
|
for (let i = 0; i < propertiesKeys.length; i++) {
|
|
632
685
|
const key = propertiesKeys[i]
|
|
633
686
|
const propertyLocation = propertiesLocation.getPropertyLocation(key)
|
|
@@ -641,23 +694,18 @@ function buildInnerObject (context, location, objVar) {
|
|
|
641
694
|
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
|
|
642
695
|
const defaultValue = resolvedLocation.schema.default
|
|
643
696
|
const isRequired = requiredProperties.includes(key)
|
|
644
|
-
|
|
645
|
-
// i === 0 means it's the first property, and it IS required (due to sort). No comma.
|
|
646
|
-
// i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma.
|
|
647
|
-
const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA'
|
|
697
|
+
const keyPrefix = (i === 0 ? '{' : ',') + sanitizedKey + ':'
|
|
648
698
|
|
|
649
699
|
code += `
|
|
650
700
|
const ${value} = ${objVar}[${sanitizedKey}]
|
|
651
701
|
if (${value} !== undefined) {
|
|
652
|
-
${
|
|
653
|
-
json += ${JSON.stringify(sanitizedKey + ':')}
|
|
702
|
+
json += ${JSON.stringify(keyPrefix)}
|
|
654
703
|
${buildValue(context, resolvedLocation, `${value}`)}
|
|
655
704
|
}`
|
|
656
705
|
|
|
657
706
|
if (defaultValue !== undefined) {
|
|
658
707
|
code += ` else {
|
|
659
|
-
${
|
|
660
|
-
json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
|
|
708
|
+
json += ${JSON.stringify(keyPrefix + JSON.stringify(defaultValue))}
|
|
661
709
|
}
|
|
662
710
|
`
|
|
663
711
|
} else if (isRequired) {
|
|
@@ -672,11 +720,16 @@ function buildInnerObject (context, location, objVar) {
|
|
|
672
720
|
|
|
673
721
|
addComma = 'json += JSON_STR_COMMA'
|
|
674
722
|
} else {
|
|
723
|
+
// No property is guaranteed to emit: fold `{` into whichever property
|
|
724
|
+
// emits first, and only emit a bare `{` when none does.
|
|
675
725
|
const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false)
|
|
676
726
|
|
|
677
727
|
if (needsRuntimeComma) {
|
|
678
728
|
code += `let addComma_${localUid} = false\n`
|
|
679
|
-
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'
|
|
680
733
|
}
|
|
681
734
|
|
|
682
735
|
for (const key of propertiesKeys) {
|
|
@@ -689,12 +742,10 @@ function buildInnerObject (context, location, objVar) {
|
|
|
689
742
|
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
|
|
690
743
|
const defaultValue = propertyLocation.schema.default
|
|
691
744
|
const isRequired = requiredProperties.includes(key) // Should be false here but good to keep
|
|
692
|
-
|
|
693
|
-
const propertyPrefix = sanitizedKey + ':'
|
|
745
|
+
const keyColon = sanitizedKey + ':'
|
|
694
746
|
const addProperty = needsRuntimeComma
|
|
695
|
-
? `json += addComma_${localUid} ? ${JSON.stringify(',' +
|
|
696
|
-
|
|
697
|
-
: `json += ${JSON.stringify(propertyPrefix)}`
|
|
747
|
+
? `json += addComma_${localUid} ? ${JSON.stringify(',' + keyColon)} : ((addComma_${localUid} = true), ${JSON.stringify('{' + keyColon)})`
|
|
748
|
+
: `json += ${JSON.stringify('{' + keyColon)}`
|
|
698
749
|
|
|
699
750
|
code += `
|
|
700
751
|
const ${value} = ${objVar}[${sanitizedKey}]
|
|
@@ -704,17 +755,34 @@ function buildInnerObject (context, location, objVar) {
|
|
|
704
755
|
}`
|
|
705
756
|
|
|
706
757
|
if (defaultValue !== undefined) {
|
|
707
|
-
|
|
758
|
+
const defaultValueLiteral = JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))
|
|
759
|
+
if (needsRuntimeComma) {
|
|
760
|
+
code += ` else {
|
|
708
761
|
${addComma}
|
|
709
|
-
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))}
|
|
710
770
|
}
|
|
711
771
|
`
|
|
772
|
+
}
|
|
712
773
|
} else if (isRequired) {
|
|
713
774
|
// Should not happen if requiredProperties.length === 0 but safety
|
|
714
775
|
code += ` else {
|
|
715
776
|
throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
|
|
716
777
|
}
|
|
717
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
|
+
`
|
|
718
786
|
} else {
|
|
719
787
|
code += '\n'
|
|
720
788
|
}
|
|
@@ -725,9 +793,15 @@ function buildInnerObject (context, location, objVar) {
|
|
|
725
793
|
code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar)
|
|
726
794
|
}
|
|
727
795
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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'
|
|
731
805
|
return code
|
|
732
806
|
}
|
|
733
807
|
|
|
@@ -821,8 +895,7 @@ function buildObject (context, location, input) {
|
|
|
821
895
|
if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
|
|
822
896
|
let json = ''
|
|
823
897
|
|
|
824
|
-
${buildInnerObject(context, location, 'obj')}
|
|
825
|
-
return json
|
|
898
|
+
${buildInnerObject(context, location, 'obj', true)}
|
|
826
899
|
}
|
|
827
900
|
`
|
|
828
901
|
|
|
@@ -844,6 +917,73 @@ function buildObject (context, location, input) {
|
|
|
844
917
|
return code
|
|
845
918
|
}
|
|
846
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
|
+
|
|
847
987
|
function buildArray (context, location, input) {
|
|
848
988
|
const schema = location.schema
|
|
849
989
|
|
|
@@ -855,6 +995,9 @@ function buildArray (context, location, input) {
|
|
|
855
995
|
}
|
|
856
996
|
|
|
857
997
|
const itemsSchema = itemsLocation.schema
|
|
998
|
+
const plainStringFastPath = context.largeArrayMechanism !== 'json-stringify' &&
|
|
999
|
+
!Array.isArray(itemsSchema) &&
|
|
1000
|
+
isPlainStringItemsSchema(itemsSchema)
|
|
858
1001
|
|
|
859
1002
|
if (context.functionsNamesBySchema.has(schema)) {
|
|
860
1003
|
const funcName = context.functionsNamesBySchema.get(schema)
|
|
@@ -895,8 +1038,46 @@ function buildArray (context, location, input) {
|
|
|
895
1038
|
`
|
|
896
1039
|
}
|
|
897
1040
|
|
|
898
|
-
if (largeArrayMechanism === 'json-stringify') {
|
|
899
|
-
functionCode += `if (arrayLength >= ${largeArraySize}) return JSON.stringify(obj)\n`
|
|
1041
|
+
if (context.largeArrayMechanism === 'json-stringify') {
|
|
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
|
+
}
|
|
900
1081
|
}
|
|
901
1082
|
|
|
902
1083
|
functionCode += `
|
|
@@ -980,8 +1161,15 @@ function buildArray (context, location, input) {
|
|
|
980
1161
|
`
|
|
981
1162
|
}
|
|
982
1163
|
|
|
983
|
-
if (largeArrayMechanism === 'json-stringify') {
|
|
984
|
-
inlinedCode += `if (arrayLength_${objVar} >= ${largeArraySize}) json += JSON.stringify(${objVar})\n else {`
|
|
1164
|
+
if (context.largeArrayMechanism === 'json-stringify') {
|
|
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
|
+
`
|
|
985
1173
|
}
|
|
986
1174
|
|
|
987
1175
|
inlinedCode += `
|
|
@@ -1038,7 +1226,7 @@ function buildArray (context, location, input) {
|
|
|
1038
1226
|
json += JSON_STR_END_ARRAY
|
|
1039
1227
|
`
|
|
1040
1228
|
|
|
1041
|
-
if (largeArrayMechanism === 'json-stringify') {
|
|
1229
|
+
if (context.largeArrayMechanism === 'json-stringify' || plainStringFastPath) {
|
|
1042
1230
|
inlinedCode += '}'
|
|
1043
1231
|
}
|
|
1044
1232
|
|
|
@@ -1237,8 +1425,8 @@ function detectRecursiveSchemas (context, location) {
|
|
|
1237
1425
|
throw schemaDepthError(context.maxDepth)
|
|
1238
1426
|
}
|
|
1239
1427
|
|
|
1240
|
-
const schemaId = location.schemaId
|
|
1241
|
-
const jsonPointer = location.jsonPointer
|
|
1428
|
+
const schemaId = location.schemaId
|
|
1429
|
+
const jsonPointer = location.jsonPointer
|
|
1242
1430
|
const fullPath = `${schemaId}#${jsonPointer}`
|
|
1243
1431
|
|
|
1244
1432
|
if (pathStack.has(fullPath)) {
|
|
@@ -1358,6 +1546,9 @@ function buildAllOf (context, location, input) {
|
|
|
1358
1546
|
context.mergedSchemasIds.set(schema, mergedSchemaId)
|
|
1359
1547
|
|
|
1360
1548
|
const { allOf, ...schemaWithoutAllOf } = location.schema
|
|
1549
|
+
const dropAdditionalProperties = schemaWithoutAllOf.additionalProperties === false
|
|
1550
|
+
if (dropAdditionalProperties) delete schemaWithoutAllOf.additionalProperties
|
|
1551
|
+
|
|
1361
1552
|
const locations = [
|
|
1362
1553
|
new Location(
|
|
1363
1554
|
schemaWithoutAllOf,
|
|
@@ -1372,6 +1563,7 @@ function buildAllOf (context, location, input) {
|
|
|
1372
1563
|
}
|
|
1373
1564
|
|
|
1374
1565
|
const mergedLocation = mergeLocations(context, mergedSchemaId, locations)
|
|
1566
|
+
if (dropAdditionalProperties) mergedLocation.schema.additionalProperties = false
|
|
1375
1567
|
return buildValue(context, mergedLocation, input)
|
|
1376
1568
|
}
|
|
1377
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
|
+
})
|