diff --git a/index.js b/index.js index 19529050..940ca841 100644 --- a/index.js +++ b/index.js @@ -52,6 +52,8 @@ function mergeLocation (source, dest) { const arrayItemsReferenceSerializersMap = new Map() const objectReferenceSerializersMap = new Map() const schemaReferenceMap = new Map() +const dedupRefsSet = new Set() +let dedupTrack = [] let ajvInstance = null let contextFunctions = null @@ -60,6 +62,8 @@ function build (schema, options) { arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() schemaReferenceMap.clear() + dedupRefsSet.clear() + dedupTrack = [] contextFunctions = [] options = options || {} @@ -150,6 +154,8 @@ function build (schema, options) { arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() schemaReferenceMap.clear() + dedupRefsSet.clear() + dedupTrack = [] return stringifyFunc } @@ -253,6 +259,7 @@ function addPatternProperties (location) { } ` }) + if (schema.additionalProperties) { code += additionalProperty(location) } @@ -326,7 +333,7 @@ function refFinder (ref, location) { if (externalSchema && externalSchema[ref]) { return { - schema: externalSchema[ref], + schema: dedupIdsRefs(ref, externalSchema[ref]), root: externalSchema[ref], externalSchema } @@ -447,8 +454,9 @@ function buildCode (location, locationPath) { Object.keys(schema.properties || {}).forEach((key) => { let propertyLocation = mergeLocation(location, { schema: schema.properties[key] }) - if (schema.properties[key].$ref) { - propertyLocation = refFinder(schema.properties[key].$ref, location) + const ref = schema.properties[key].$ref + if (ref) { + propertyLocation = refFinder(ref, location) schema.properties[key] = propertyLocation.schema } @@ -595,6 +603,8 @@ function buildInnerObject (location, locationPath) { } function addIfThenElse (location, locationPath) { + dedupTrack.push('ifThenElse') + let code = '' const schema = location.schema @@ -640,6 +650,9 @@ function addIfThenElse (location, locationPath) { code += ` } ` + + dedupTrack.pop() + return code } @@ -884,6 +897,33 @@ function dereferenceOfRefs (location, type) { return locations } +function _dedupIdsRefs (refs, schema) { + // we dedup the same $ref only and clear the $id after the first one. + // so, it can keep track the duplicate $id when it trying to resolve + // difference schema. + // however, it will not works for $id in nested properties. + if (dedupRefsSet.has(refs)) { + // we need to check if we face the recursive schema + if (!objectReferenceSerializersMap.has(schema) && !arrayItemsReferenceSerializersMap.has(schema)) { + schema = clone(schema) + delete schema.$id + } + } + + dedupRefsSet.add(refs) + + return schema +} + +function _dummyDedupIdsRefs (_, schema) { + return schema +} + +function dedupIdsRefs (refs, schema) { + // we should ignore the $refs in root schema + return dedupTrack.join(',') === '' ? _dummyDedupIdsRefs(refs, schema) : _dedupIdsRefs(refs, schema) +} + let genFuncNameCounter = 0 function generateFuncName () { return 'anonymous' + genFuncNameCounter++ @@ -949,6 +989,7 @@ function buildValue (locationPath, input, location) { break case undefined: if (schema.anyOf || schema.oneOf) { + dedupTrack.push(schema.anyOf ? 'anyOf' : 'oneOf') // beware: dereferenceOfRefs has side effects and changes schema.anyOf const locations = dereferenceOfRefs(location, schema.anyOf ? 'anyOf' : 'oneOf') locations.forEach((location, index) => { @@ -976,6 +1017,8 @@ function buildValue (locationPath, input, location) { ` }) + dedupTrack.pop() + code += ` else throw new Error(\`The value $\{JSON.stringify(${input})} does not match schema definition.\`) ` diff --git a/test/ref.test.js b/test/ref.test.js index fbf9d117..a5f3fe6c 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -1249,3 +1249,277 @@ test('Regression 2.5.2', t => { t.equal(output, '[{"field":"parent","sub":{"field":"joined"}}]') }) + +test('ref with same id in properties', (t) => { + t.plan(2) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + File: { + $id: 'File', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + name: { type: 'string' }, + owner: { $ref: 'ObjectId' } + } + } + } + + t.test('anyOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) + + t.test('oneOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + oneOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) +}) + +test('dedup refs id', (t) => { + t.plan(5) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + String: { + $id: 'String', + type: 'string' + } + } + + t.test('should treat same $id but not reference by $ref as different schema', (t) => { + t.plan(1) + + try { + const schema = { + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { $ref: 'ObjectId' }, + { + $id: 'ObjectId', + type: 'number' + } + ] + } + } + } + + build(schema, { schema: externalSchema }) + t.fail('should not be here') + } catch (err) { + t.pass('should fail when $id reference to multiple schema') + } + }) + + t.test('same id refs cross anyOf', (t) => { + t.plan(4) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + String: { + $id: 'String', + type: 'string' + } + } + + const schema = { + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { type: 'object', properties: { foo: { $ref: 'String' } }, required: ['foo'] }, + { type: 'object', properties: { bar: { $ref: 'String' } }, required: ['bar'] }, + { type: 'object', properties: { baz: { $ref: 'String' } }, required: ['baz'] }, + { $ref: 'ObjectId' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + + { + const output = stringify({ _id: 'foo', image: { foo: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"foo":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: { bar: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"bar":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: { baz: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"baz":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: 'hello' }) + t.equal(output, '{"_id":"foo","image":"hello"}') + } + }) + + t.test('same id refs cross oneOf', (t) => { + t.plan(4) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + String: { + $id: 'String', + type: 'string' + } + } + + const schema = { + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + oneOf: [ + { type: 'object', properties: { foo: { $ref: 'String' } }, required: ['foo'] }, + { type: 'object', properties: { bar: { $ref: 'String' } }, required: ['bar'] }, + { type: 'object', properties: { baz: { $ref: 'String' } }, required: ['baz'] }, + { $ref: 'ObjectId' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + + { + const output = stringify({ _id: 'foo', image: { foo: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"foo":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: { bar: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"bar":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: { baz: 'hello' } }) + t.equal(output, '{"_id":"foo","image":{"baz":"hello"}}') + } + { + const output = stringify({ _id: 'foo', image: 'hello' }) + t.equal(output, '{"_id":"foo","image":"hello"}') + } + }) + + t.test('same id refs cross multiple anyOf', (t) => { + t.plan(1) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + } + } + + const schema = { + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { $ref: 'ObjectId' } + ] + }, + owner: { + anyOf: [ + { $ref: 'ObjectId' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + { + const output = stringify({ _id: 'foo', image: 'hello', owner: 'world' }) + t.equal(output, '{"_id":"foo","image":"hello","owner":"world"}') + } + }) + + t.test('same id refs cross multiple oneOf', (t) => { + t.plan(1) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + } + } + + const schema = { + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + oneOf: [ + { $ref: 'ObjectId' } + ] + }, + owner: { + oneOf: [ + { $ref: 'ObjectId' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + { + const output = stringify({ _id: 'foo', image: 'hello', owner: 'world' }) + t.equal(output, '{"_id":"foo","image":"hello","owner":"world"}') + } + }) +})