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/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
- stack.push({ value: value[key], depth: entry.depth + 1, exit: false })
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(schema, maxDepth)
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
- detectRecursiveSchemas(context, location)
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
- const validator = new Validator(
442
- options.ajv,
443
- options.mode === 'standalone' && options.inlineValidators
444
- )
445
-
446
- for (const schemaId of context.validatorSchemasIds) {
447
- const schema = context.refResolver.getSchema(schemaId)
448
- validator.addSchema(schema, schemaId)
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
- const dependencies = getSchemaDependencies(context.refResolver, schemaId)
451
- for (const [schemaId, schema] of Object.entries(dependencies)) {
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
- if (options.compileValidators) {
457
- validator.compileSchemas(context.validatorSchemaRefs)
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 we don't need a comma.
629
- // For the subsequent properties, we can blindly add a comma.
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
- ${currentAddComma}
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
- ${currentAddComma}
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 = `!addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)`
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
- // Select a complete prefix so the comma does not need a separate concatenation.
693
- const propertyPrefix = sanitizedKey + ':'
745
+ const keyColon = sanitizedKey + ':'
694
746
  const addProperty = needsRuntimeComma
695
- ? `json += addComma_${localUid} ? ${JSON.stringify(',' + propertyPrefix)} : ${JSON.stringify(propertyPrefix)}
696
- addComma_${localUid} = true`
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
- code += ` else {
758
+ const defaultValueLiteral = JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))
759
+ if (needsRuntimeComma) {
760
+ code += ` else {
708
761
  ${addComma}
709
- json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
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
- code += `
729
- json += JSON_STR_END_OBJECT
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 segment = String(propertyName).replace(/~/g, '~0').replace(/\//g, '~1')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.1.0",
3
+ "version": "7.1.2",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -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
+ })