fast-json-stringify 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.github/dependabot.yml +43 -2
  2. package/.github/workflows/ci.yml +1 -1
  3. package/.github/workflows/lock-threads.yml +1 -1
  4. package/.github/workflows/release.yml +24 -0
  5. package/README.md +25 -8
  6. package/benchmark/bench-thread.js +6 -2
  7. package/benchmark/bench.js +86 -0
  8. package/index.js +360 -94
  9. package/lib/location.js +3 -1
  10. package/lib/serializer.js +41 -37
  11. package/lib/standalone.js +22 -3
  12. package/lib/validator.js +22 -3
  13. package/package.json +6 -7
  14. package/test/additionalProperties.test.js +87 -0
  15. package/test/anyof.test.js +42 -0
  16. package/test/array.test.js +1 -1
  17. package/test/basic.test.js +1 -1
  18. package/test/code-generation-fallbacks.test.js +158 -0
  19. package/test/compile-validators.test.js +110 -0
  20. package/test/const.test.js +27 -6
  21. package/test/date.test.js +28 -1
  22. package/test/debug-mode.test.js +8 -0
  23. package/test/deopt.test.js +251 -0
  24. package/test/if-then-else.test.js +145 -0
  25. package/test/inferType.test.js +1 -1
  26. package/test/integer.test.js +1 -1
  27. package/test/oneof.test.js +242 -0
  28. package/test/optional-property-prefix.test.js +119 -0
  29. package/test/patternProperties.test.js +24 -0
  30. package/test/property-names.test.js +41 -0
  31. package/test/regex.test.js +1 -1
  32. package/test/schema-depth.test.js +227 -0
  33. package/test/standalone-mode.test.js +109 -2
  34. package/test/string.test.js +8 -0
  35. package/test/surrogate.test.js +1 -1
  36. package/test/typesArray.test.js +59 -0
  37. package/test/utils/validator.js +20 -0
  38. package/types/index.d.ts +20 -0
  39. package/types/index.tst.ts +13 -0
package/lib/serializer.js CHANGED
@@ -3,6 +3,43 @@
3
3
  // eslint-disable-next-line
4
4
  const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/
5
5
 
6
+ function asString (str) {
7
+ const len = str.length
8
+ if (len === 0) {
9
+ return '""'
10
+ } else if (len < 42) {
11
+ // magically escape strings for json
12
+ // relying on their charCodeAt
13
+ // everything below 32 needs JSON.stringify()
14
+ // every string that contain surrogate needs JSON.stringify()
15
+ // 34 and 92 happens all the time, so we
16
+ // have a fast case for them
17
+ let result = ''
18
+ let last = -1
19
+ let point = 255
20
+ for (let i = 0; i < len; i++) {
21
+ point = str.charCodeAt(i)
22
+ if (
23
+ point === 0x22 || // '"'
24
+ point === 0x5c // '\'
25
+ ) {
26
+ last === -1 && (last = 0)
27
+ result += str.slice(last, i) + '\\'
28
+ last = i
29
+ } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) {
30
+ // The current character is non-printable characters or a surrogate.
31
+ return JSON.stringify(str)
32
+ }
33
+ }
34
+ return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"')
35
+ } else if (len < 5000 && STR_ESCAPE.test(str) === false) {
36
+ // Only use the regular expression for shorter input. The overhead is otherwise too much.
37
+ return '"' + str + '"'
38
+ } else {
39
+ return JSON.stringify(str)
40
+ }
41
+ }
42
+
6
43
  module.exports = class Serializer {
7
44
  constructor (options) {
8
45
  switch (options && options.rounding) {
@@ -63,7 +100,7 @@ module.exports = class Serializer {
63
100
  return '"' + date.toISOString() + '"'
64
101
  }
65
102
  if (typeof date === 'string') {
66
- return '"' + date + '"'
103
+ return asString(date)
67
104
  }
68
105
  throw new Error(`The value "${date}" cannot be converted to a date-time.`)
69
106
  }
@@ -74,7 +111,7 @@ module.exports = class Serializer {
74
111
  return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"'
75
112
  }
76
113
  if (typeof date === 'string') {
77
- return '"' + date + '"'
114
+ return asString(date)
78
115
  }
79
116
  throw new Error(`The value "${date}" cannot be converted to a date.`)
80
117
  }
@@ -85,46 +122,13 @@ module.exports = class Serializer {
85
122
  return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"'
86
123
  }
87
124
  if (typeof date === 'string') {
88
- return '"' + date + '"'
125
+ return asString(date)
89
126
  }
90
127
  throw new Error(`The value "${date}" cannot be converted to a time.`)
91
128
  }
92
129
 
93
130
  asString (str) {
94
- const len = str.length
95
- if (len === 0) {
96
- return '""'
97
- } else if (len < 42) {
98
- // magically escape strings for json
99
- // relying on their charCodeAt
100
- // everything below 32 needs JSON.stringify()
101
- // every string that contain surrogate needs JSON.stringify()
102
- // 34 and 92 happens all the time, so we
103
- // have a fast case for them
104
- let result = ''
105
- let last = -1
106
- let point = 255
107
- for (let i = 0; i < len; i++) {
108
- point = str.charCodeAt(i)
109
- if (
110
- point === 0x22 || // '"'
111
- point === 0x5c // '\'
112
- ) {
113
- last === -1 && (last = 0)
114
- result += str.slice(last, i) + '\\'
115
- last = i
116
- } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) {
117
- // The current character is non-printable characters or a surrogate.
118
- return JSON.stringify(str)
119
- }
120
- }
121
- return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"')
122
- } else if (len < 5000 && STR_ESCAPE.test(str) === false) {
123
- // Only use the regular expression for shorter input. The overhead is otherwise too much.
124
- return '"' + str + '"'
125
- } else {
126
- return JSON.stringify(str)
127
- }
131
+ return asString(str)
128
132
  }
129
133
 
130
134
  asUnsafeString (str) {
package/lib/standalone.js CHANGED
@@ -3,9 +3,28 @@
3
3
  function buildStandaloneCode (contextFunc, context, serializer, validator) {
4
4
  let ajvDependencyCode = ''
5
5
  if (context.validatorSchemasIds.size > 0) {
6
- ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n'
7
- ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n`
8
- ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n'
6
+ if (context.options.inlineValidators) {
7
+ const standaloneCode = require('ajv/dist/standalone').default
8
+ const schemaRefs = Object.fromEntries(
9
+ [...context.validatorSchemaRefs].map(schemaRef => [schemaRef, schemaRef])
10
+ )
11
+
12
+ ajvDependencyCode += `const validator = (() => {
13
+ const validators = {}
14
+ const exports = validators
15
+ ${standaloneCode(validator.ajv, schemaRefs)}
16
+ return {
17
+ validate (schemaRef, data) {
18
+ const validate = validators[schemaRef]
19
+ return validate(data)
20
+ }
21
+ }
22
+ })()\n`
23
+ } else {
24
+ ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n'
25
+ ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n`
26
+ ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n'
27
+ }
9
28
  } else {
10
29
  ajvDependencyCode += 'const validator = null\n'
11
30
  }
package/lib/validator.js CHANGED
@@ -1,14 +1,20 @@
1
1
  'use strict'
2
2
 
3
3
  const Ajv = require('ajv')
4
+ const { _ } = Ajv
4
5
  const fastUri = require('fast-uri')
5
6
  const ajvFormats = require('ajv-formats')
6
7
  const clone = require('rfdc')({ proto: true })
7
8
 
8
9
  class Validator {
9
- constructor (ajvOptions) {
10
+ constructor (ajvOptions, inlineValidators) {
11
+ const codeOptions = inlineValidators
12
+ ? { code: { ...ajvOptions?.code, source: true, esm: false } }
13
+ : {}
14
+
10
15
  this.ajv = new Ajv({
11
16
  ...ajvOptions,
17
+ ...codeOptions,
12
18
  strictSchema: false,
13
19
  validateSchema: false,
14
20
  allowUnionTypes: true,
@@ -21,8 +27,9 @@ class Validator {
21
27
  keyword: 'fjs_type',
22
28
  type: 'object',
23
29
  errors: false,
24
- validate: (_type, data) => {
25
- return data && typeof data.toJSON === 'function'
30
+ code (context) {
31
+ const { data } = context
32
+ context.fail(_`!${data} || typeof ${data}.toJSON !== "function"`)
26
33
  }
27
34
  })
28
35
 
@@ -47,6 +54,18 @@ class Validator {
47
54
  }
48
55
  }
49
56
 
57
+ // Ajv compiles a schema lazily, on the first `validate` call for a given ref.
58
+ // That cost would otherwise be paid at serialization time, on the first
59
+ // document that exercises the union/if-then-else branch. Eagerly resolving
60
+ // every ref the generated code can validate against moves it to build time.
61
+ // It must run after every `addSchema` call: compiling resolves $refs, so
62
+ // doing it earlier breaks on forward references.
63
+ compileSchemas (schemaRefs) {
64
+ for (const schemaRef of schemaRefs) {
65
+ this.ajv.getSchema(schemaRef)
66
+ }
67
+ }
68
+
50
69
  validate (schemaRef, data) {
51
70
  return this.ajv.validate(schemaRef, data)
52
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -13,7 +13,7 @@
13
13
  "lint": "eslint",
14
14
  "lint:fix": "eslint --fix",
15
15
  "test:typescript": "tstyche",
16
- "test:unit": "c8 node --test",
16
+ "test:unit": "c8 --100 --all --include=index.js --include=lib/**/*.js --exclude=lib/schema-validator.js node --test",
17
17
  "test": "npm run test:unit && npm run test:typescript"
18
18
  },
19
19
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "contributors": [
31
31
  {
32
32
  "name": "Tomas Della Vedova",
33
- "url": "http://delved.org"
33
+ "url": "https://delvedor.dev"
34
34
  },
35
35
  {
36
36
  "name": "Aras Abbasi",
@@ -62,15 +62,14 @@
62
62
  }
63
63
  ],
64
64
  "devDependencies": {
65
- "c8": "^11.0.0",
65
+ "c8": "^12.0.0",
66
66
  "cli-select": "^1.1.2",
67
67
  "compile-json-stringify": "^0.1.2",
68
68
  "eslint": "^9.39.2",
69
69
  "fast-json-stringify": ".",
70
- "is-my-json-valid": "^2.20.6",
71
70
  "json-accelerator": "^0.1.7",
72
71
  "neostandard": "^0.13.0",
73
- "simple-git": "^3.30.0",
72
+ "simple-git": "^4.0.2",
74
73
  "tinybench": "^6.0.0",
75
74
  "tstyche": "^7.1.0",
76
75
  "typebox": "^1.0.81",
@@ -81,7 +80,7 @@
81
80
  "@fastify/merge-json-schemas": "^0.2.0",
82
81
  "ajv": "^8.12.0",
83
82
  "ajv-formats": "^3.0.1",
84
- "fast-uri": "^3.0.0",
83
+ "fast-uri": "^4.0.0",
85
84
  "json-schema-ref-resolver": "^3.0.0",
86
85
  "rfdc": "^1.2.0"
87
86
  },
@@ -330,3 +330,90 @@ test('function and symbol references are not serialized as undefined', (t) => {
330
330
  const obj = { str: 'x', test: 'test', meth: () => 'x', sym: Symbol('x') }
331
331
  t.assert.equal(stringify(obj), '{"str":"x","test":"test"}')
332
332
  })
333
+
334
+ test('required + additionalProperties without declared properties produces valid JSON', (t) => {
335
+ // Regression: when `required` is non-empty but `properties` is absent
336
+ // (e.g. a record-style schema with only `additionalProperties`), the
337
+ // serializer used to emit a stray leading comma:
338
+ // {"obj":{,"a":1,"b":2}}
339
+ // because the "skip first comma" optimization assumed a declared
340
+ // property would anchor it. With no `properties`, the additionalProperties
341
+ // branch would write the separator before its first entry.
342
+ t.plan(2)
343
+ const stringify = build({
344
+ type: 'object',
345
+ properties: {
346
+ obj: {
347
+ type: 'object',
348
+ propertyNames: { type: 'string', enum: ['a', 'b'] },
349
+ additionalProperties: { type: 'number' },
350
+ required: ['a', 'b']
351
+ }
352
+ }
353
+ })
354
+
355
+ const out = stringify({ obj: { a: 1, b: 2 } })
356
+ t.assert.equal(out, '{"obj":{"a":1,"b":2}}')
357
+ t.assert.deepStrictEqual(JSON.parse(out), { obj: { a: 1, b: 2 } })
358
+ })
359
+
360
+ test('required key not in properties + additionalProperties produces valid JSON', (t) => {
361
+ // Regression: declared `properties` is non-empty but `required` only lists
362
+ // keys that are not in `properties`. After sorting, propertiesKeys[0] is
363
+ // not required, so the "first declared property anchors the comma" premise
364
+ // does not hold. The serializer used to emit '{,"str":"x"}' for input
365
+ // missing the (non-required) declared `num`.
366
+ t.plan(2)
367
+ const stringify = build({
368
+ type: 'object',
369
+ properties: {
370
+ num: { type: 'number' }
371
+ },
372
+ additionalProperties: true,
373
+ required: ['str']
374
+ })
375
+
376
+ const out = stringify({ str: 'x' })
377
+ t.assert.equal(out, '{"str":"x"}')
378
+ t.assert.deepStrictEqual(JSON.parse(out), { str: 'x' })
379
+ })
380
+
381
+ test('additionalProperties set to false ignores properties not matched by patternProperties', (t) => {
382
+ // Regression: `additionalProperties: false` is documented to drop every
383
+ // property that is not listed in `properties` or matched by
384
+ // `patternProperties`. Combined with `patternProperties`, the generated
385
+ // code used to fall through to the additionalProperties branch with a
386
+ // boolean `false` schema, which serialized unmatched properties with
387
+ // `JSON.stringify(value)` and leaked them into the output:
388
+ // {"nickname":"nick","matchnum":3,"leaked":"secret"}
389
+ t.plan(2)
390
+ const stringify = build({
391
+ type: 'object',
392
+ properties: {
393
+ nickname: { type: 'string' }
394
+ },
395
+ patternProperties: {
396
+ num: { type: 'number' }
397
+ },
398
+ additionalProperties: false
399
+ })
400
+
401
+ const out = stringify({ nickname: 'nick', matchnum: 3, leaked: 'secret' })
402
+ t.assert.equal(out, '{"nickname":"nick","matchnum":3}')
403
+ t.assert.deepStrictEqual(JSON.parse(out), { nickname: 'nick', matchnum: 3 })
404
+ })
405
+
406
+ test('additionalProperties set to false without declared properties ignores unmatched properties', (t) => {
407
+ t.plan(2)
408
+ const stringify = build({
409
+ type: 'object',
410
+ patternProperties: {
411
+ '^str': { type: 'string' }
412
+ },
413
+ additionalProperties: false
414
+ })
415
+
416
+ const out = stringify({ str1: 'a', leaked: 'secret' })
417
+ t.assert.equal(out, '{"str1":"a"}')
418
+ t.assert.deepStrictEqual(JSON.parse(out), { str1: 'a' })
419
+ })
@@ -387,6 +387,48 @@ test('anyOf and $ref - multiple external $ref', (t) => {
387
387
  t.assert.equal(output, '{"obj":{"prop":{"prop2":"test"}}}')
388
388
  })
389
389
 
390
+ test('anyOf with registered $ref branches validates directly against the ref target', (t) => {
391
+ t.plan(3)
392
+
393
+ const schema = {
394
+ title: 'anyOf with registered $ref branches',
395
+ type: 'object',
396
+ properties: {
397
+ content: {
398
+ anyOf: [
399
+ { $ref: 'Foo#' },
400
+ { $ref: 'Bar#/definitions/Baz' }
401
+ ]
402
+ }
403
+ }
404
+ }
405
+ const externalSchema = {
406
+ Foo: {
407
+ type: 'object',
408
+ properties: { a: { type: 'string' } },
409
+ required: ['a'],
410
+ additionalProperties: false
411
+ },
412
+ Bar: {
413
+ definitions: {
414
+ Baz: {
415
+ type: 'object',
416
+ properties: { b: { type: 'string' } },
417
+ required: ['b'],
418
+ additionalProperties: false
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ const code = build(schema, { mode: 'standalone', schema: externalSchema })
425
+ t.assert.ok(code.includes('validator.validate("Foo#"'), 'validates directly against the bare registered id')
426
+ t.assert.ok(code.includes('validator.validate("Bar#/definitions/Baz"'), 'validates directly against the registered sub-path ref')
427
+
428
+ const stringify = build(schema, { schema: externalSchema })
429
+ t.assert.equal(stringify({ content: { b: 'yo' } }), '{"content":{"b":"yo"}}')
430
+ })
431
+
390
432
  test('anyOf looks for all of the array items', (t) => {
391
433
  t.plan(1)
392
434
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { test } = require('node:test')
4
- const validator = require('is-my-json-valid')
4
+ const validator = require('./utils/validator')
5
5
  const build = require('..')
6
6
  const Ajv = require('ajv')
7
7
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { test } = require('node:test')
4
- const validator = require('is-my-json-valid')
4
+ const validator = require('./utils/validator')
5
5
  const build = require('..')
6
6
 
7
7
  function buildTest (schema, toStringify) {
@@ -0,0 +1,158 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+
5
+ const OriginalLocation = require('../lib/location')
6
+
7
+ const buildPath = require.resolve('..')
8
+ const locationPath = require.resolve('../lib/location')
9
+
10
+ function loadBuildWithLocation (Location) {
11
+ require.cache[locationPath].exports = Location
12
+ delete require.cache[buildPath]
13
+
14
+ try {
15
+ return require(buildPath)
16
+ } finally {
17
+ require.cache[locationPath].exports = OriginalLocation
18
+ delete require.cache[buildPath]
19
+ }
20
+ }
21
+
22
+ class LocationWithoutSchemaId {
23
+ constructor (schema, _schemaId, jsonPointer = '#') {
24
+ this.schema = schema
25
+ this.schemaId = ''
26
+ this.jsonPointer = _schemaId ? _schemaId + jsonPointer : jsonPointer
27
+ }
28
+
29
+ getPropertyLocation (propertyName) {
30
+ return new LocationWithoutSchemaId(
31
+ this.schema[propertyName],
32
+ '',
33
+ this.jsonPointer + '/' + propertyName
34
+ )
35
+ }
36
+
37
+ getSchemaRef () {
38
+ return this.schemaId + this.jsonPointer
39
+ }
40
+ }
41
+
42
+ class LocationWithoutJsonPointer {
43
+ constructor (schema, schemaId) {
44
+ this.schema = schema
45
+ this.schemaId = schemaId
46
+ this.jsonPointer = ''
47
+ }
48
+
49
+ getPropertyLocation (propertyName) {
50
+ return new LocationWithoutJsonPointer(this.schema[propertyName], this.schemaId)
51
+ }
52
+
53
+ getSchemaRef () {
54
+ return this.schemaId
55
+ }
56
+ }
57
+
58
+ class LocationWithoutSchemaRef extends OriginalLocation {
59
+ getSchemaRef () {}
60
+ }
61
+
62
+ test('inline object generation without schema IDs', t => {
63
+ t.plan(3)
64
+
65
+ const build = loadBuildWithLocation(LocationWithoutSchemaId)
66
+ const stringify = build({
67
+ type: 'object',
68
+ properties: {
69
+ value: { type: 'string' }
70
+ }
71
+ })
72
+ const stringifyNullable = build({ type: 'object', nullable: true })
73
+
74
+ t.assert.equal(stringify({ value: 'test' }), '{"value":"test"}')
75
+ t.assert.equal(stringify(null), '{}')
76
+ t.assert.equal(stringifyNullable(null), 'null')
77
+ })
78
+
79
+ test('inline array generation without schema IDs', t => {
80
+ t.plan(6)
81
+
82
+ const build = loadBuildWithLocation(LocationWithoutSchemaId)
83
+ const stringify = build({ type: 'array', items: { type: 'string' } }, { largeArrayMechanism: 'default' })
84
+ const stringifyNullable = build({ type: 'array', nullable: true })
85
+ const stringifyTuple = build({
86
+ type: 'array',
87
+ items: [{ $ref: 'item' }, { type: 'number' }],
88
+ additionalItems: true
89
+ }, {
90
+ schema: {
91
+ item: { type: 'string' }
92
+ }
93
+ })
94
+ const stringifyFixedTuple = build({
95
+ type: 'array',
96
+ items: [{ type: 'string' }],
97
+ additionalItems: false
98
+ })
99
+ const stringifyLargeArray = build({ type: 'array', items: { type: 'number' } }, {
100
+ largeArrayMechanism: 'json-stringify',
101
+ largeArraySize: 1
102
+ })
103
+
104
+ t.assert.equal(stringify(['one', 'two']), '["one","two"]')
105
+ t.assert.equal(stringifyNullable(null), 'null')
106
+ t.assert.equal(stringifyTuple(['one', 2, true]), '["one",2,true]')
107
+ t.assert.throws(() => stringifyFixedTuple(['one', 'two']), /Item at 1/)
108
+ t.assert.equal(stringifyLargeArray([1, 2]), '[1,2]')
109
+ t.assert.throws(() => stringify('not-an-array'), /does not match schema definition/)
110
+ })
111
+
112
+ test('code generation reference fallbacks', t => {
113
+ t.plan(3)
114
+
115
+ const buildWithoutPointer = loadBuildWithLocation(LocationWithoutJsonPointer)
116
+ const stringifyObject = buildWithoutPointer({ type: 'object' })
117
+ const stringifyArray = buildWithoutPointer({ type: 'array' })
118
+
119
+ const buildWithoutRef = loadBuildWithLocation(LocationWithoutSchemaRef)
120
+ const stringifyWithoutRef = buildWithoutRef({ type: 'object' })
121
+
122
+ t.assert.equal(stringifyObject({}), '{}')
123
+ t.assert.equal(stringifyArray([]), '[]')
124
+ t.assert.equal(stringifyWithoutRef({}), '{}')
125
+ })
126
+
127
+ test('required-property fallback tolerates unexpected property ordering', t => {
128
+ t.plan(2)
129
+
130
+ const originalSort = Array.prototype.sort
131
+ // Deliberately fault-inject unexpected ordering to exercise the defensive branch.
132
+ // eslint-disable-next-line no-extend-native
133
+ Array.prototype.sort = function (compareFn) {
134
+ const result = originalSort.call(this, compareFn)
135
+ if (this.length === 2 && this.includes('optional') && this.includes('required')) {
136
+ result.reverse()
137
+ }
138
+ return result
139
+ }
140
+
141
+ try {
142
+ const build = loadBuildWithLocation(OriginalLocation)
143
+ const stringify = build({
144
+ type: 'object',
145
+ properties: {
146
+ optional: { type: 'string' },
147
+ required: { type: 'string' }
148
+ },
149
+ required: ['required']
150
+ })
151
+
152
+ t.assert.equal(stringify({ optional: 'one', required: 'two' }), '{"optional":"one","required":"two"}')
153
+ t.assert.throws(() => stringify({ optional: 'one' }), /"required" is required/)
154
+ } finally {
155
+ // eslint-disable-next-line no-extend-native
156
+ Array.prototype.sort = originalSort
157
+ }
158
+ })
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const fjs = require('..')
5
+
6
+ const schema = {
7
+ title: 'compile validators',
8
+ type: 'object',
9
+ properties: {
10
+ union: {
11
+ anyOf: [
12
+ { type: 'string' },
13
+ { type: 'number' }
14
+ ]
15
+ },
16
+ choice: {
17
+ oneOf: [
18
+ { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
19
+ { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }
20
+ ]
21
+ },
22
+ conditional: {
23
+ if: { type: 'string' },
24
+ then: { type: 'string' },
25
+ else: { type: 'number' }
26
+ }
27
+ }
28
+ }
29
+
30
+ // The refs the generated code validates against, straight out of the emitted
31
+ // source, so the assertions cannot drift from what fjs actually calls.
32
+ function getValidatorRefs (code) {
33
+ return [...code.matchAll(/validator\.validate\("([^"]+)"/g)].map(match => match[1])
34
+ }
35
+
36
+ // Ajv stores a compiled schema as a SchemaEnv with a `validate` function.
37
+ // `getSchema()` is not used here because it compiles on demand, which is the
38
+ // very thing under test.
39
+ function isCompiled (ajv, ref) {
40
+ const schemaEnv = ajv.refs[ref] || ajv.schemas[ref]
41
+ return schemaEnv !== undefined && typeof schemaEnv.validate === 'function'
42
+ }
43
+
44
+ test('validators are compiled lazily by default', t => {
45
+ t.plan(2)
46
+
47
+ const { code, ajv } = fjs(schema, { mode: 'debug' })
48
+ const refs = getValidatorRefs(code)
49
+
50
+ t.assert.ok(refs.length > 0)
51
+ t.assert.deepStrictEqual(refs.filter(ref => isCompiled(ajv, ref)), [])
52
+ })
53
+
54
+ test('compileValidators compiles every validator used by the generated code', t => {
55
+ t.plan(2)
56
+
57
+ const { code, ajv } = fjs(schema, { mode: 'debug', compileValidators: true })
58
+ const refs = getValidatorRefs(code)
59
+
60
+ t.assert.ok(refs.length > 0)
61
+ t.assert.deepStrictEqual(refs.filter(ref => !isCompiled(ajv, ref)), [])
62
+ })
63
+
64
+ test('compileValidators does not change the serialization output', t => {
65
+ t.plan(2)
66
+
67
+ const input = {
68
+ union: 'foo',
69
+ choice: { b: 42 },
70
+ conditional: 'bar'
71
+ }
72
+ const expected = JSON.stringify({ union: 'foo', choice: { b: 42 }, conditional: 'bar' })
73
+
74
+ t.assert.equal(fjs(schema)(input), expected)
75
+ t.assert.equal(fjs(schema, { compileValidators: true })(input), expected)
76
+ })
77
+
78
+ test('compileValidators works with external schemas resolved after registration', t => {
79
+ t.plan(3)
80
+
81
+ const externalSchema = {
82
+ branch: {
83
+ $id: 'branch',
84
+ definitions: {
85
+ str: { type: 'string' },
86
+ num: { type: 'number' }
87
+ },
88
+ type: 'object',
89
+ properties: {
90
+ value: {
91
+ anyOf: [
92
+ { $ref: 'branch#/definitions/str' },
93
+ { $ref: 'branch#/definitions/num' }
94
+ ]
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ const rootSchema = { $ref: 'branch#' }
101
+ const options = { schema: externalSchema, compileValidators: true }
102
+
103
+ const { code, ajv } = fjs(rootSchema, { ...options, mode: 'debug' })
104
+ const refs = getValidatorRefs(code)
105
+
106
+ t.assert.ok(refs.length > 0)
107
+ t.assert.deepStrictEqual(refs.filter(ref => !isCompiled(ajv, ref)), [])
108
+
109
+ t.assert.equal(fjs(rootSchema, options)({ value: 7 }), JSON.stringify({ value: 7 }))
110
+ })