diff --git a/package/lib/SimpleSchema.js b/package/lib/SimpleSchema.js index dde74ce..a38860e 100644 --- a/package/lib/SimpleSchema.js +++ b/package/lib/SimpleSchema.js @@ -372,6 +372,52 @@ class SimpleSchema { return result; } + // while potentially not very performant - this is only called once per validation for the total schema + // it is called per "key" of the original schema if that key is a oneOf, limited to the schema of the key. + // this is equivalent to at most once for the entire root schema. + recursiveKeys(startPrefix) { + const keys = new Set(Object.values(this._schemaKeys)); + const recurse = (prefix) => { + keys.add(prefix); + this.objectKeys(prefix).forEach((suffix) => recurse(`${prefix}.${suffix}`)); + }; + Object.values(this._schemaKeys).filter((k) => k.endsWith('$')).forEach(recurse); + this.objectKeys(startPrefix).forEach(recurse); + return Array.from(keys); + } + + // returns all keys that are internal to a oneOf - does not include the key that IS the oneOf. + // returns a map of the key => [{ prefix: String, suffix: String, schema: SimpleSchema }] + oneOfKeys() { + const oneOfKeys = new Map(); + this._oneOfKeys.forEach((key) => { + if (this._schema[key].type.definitions.length > 1) { + this._schema[key].type.definitions.forEach((typeDef) => { + if (!(SimpleSchema.isSimpleSchema(typeDef.type))) return; + typeDef.type.recursiveKeys().forEach((oneOfKey) => { + const fullKey = `${key}.${oneOfKey}`; + if (oneOfKeys.has(fullKey)) { + oneOfKeys.get(fullKey).push({ + schema: typeDef.type, + prefix: key, + suffix: oneOfKey, + }); + } else { + oneOfKeys.set(fullKey, [{ + schema: typeDef.type, + prefix: key, + suffix: oneOfKey, + }]); + } + }); + }); + } + }); + + // most places we're going to use this, a set is better than an array + return oneOfKeys; + } + // Returns an array of all the blackbox keys, including those in subschemas blackboxKeys() { const blackboxKeys = new Set(this._blackboxKeys); @@ -535,6 +581,7 @@ class SimpleSchema { this._schemaKeys = Object.keys(this._schema); this._autoValues = []; this._blackboxKeys = new Set(); + this._oneOfKeys = new Set(); this._firstLevelSchemaKeys = []; this._objectKeys = {}; @@ -551,6 +598,10 @@ class SimpleSchema { // Keep list of all top level keys if (fieldName.indexOf('.') === -1) this._firstLevelSchemaKeys.push(fieldName); + if (definition.type.definitions.length > 1) { + this._oneOfKeys.add(fieldName); + } + // Keep list of all blackbox keys for passing to MongoObject constructor // XXX For now if any oneOf type is blackbox, then the whole field is. /* eslint-disable no-restricted-syntax */ diff --git a/package/lib/SimpleSchema_oneOf.tests.js b/package/lib/SimpleSchema_oneOf.tests.js index 0d10311..bf70188 100644 --- a/package/lib/SimpleSchema_oneOf.tests.js +++ b/package/lib/SimpleSchema_oneOf.tests.js @@ -35,7 +35,7 @@ describe('SimpleSchema', function () { expect(test4.foo).toBeA('boolean'); }); - it.skip('allows either type including schemas', function () { + it('allows either type including schemas (first)', function () { const schemaOne = new SimpleSchema({ itemRef: String, partNo: String, @@ -72,6 +72,607 @@ describe('SimpleSchema', function () { expect(isValid).toBe(true); }); + it('allows either type including schemas (second)', function () { + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(String, schemaOne, schemaTwo), + }); + + let isValid = combinedSchema.namedContext().validate({ + item: 'foo', + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + anotherIdentifier: 'hhh', + partNo: 'ttt', + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'hhh', + partNo: 'ttt', + }, + }); + expect(isValid).toBe(true); + }); + + it('allows either type including schemas (nested)', function () { + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + obj: Object, + 'obj.inner': String, + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + obj2: Object, + 'obj2.inner': String, + }); + + const schemaA = new SimpleSchema({ + item1: SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + + const schemaB = new SimpleSchema({ + item2: SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaA, schemaB), + }); + + let isValid = combinedSchema.namedContext().validate({ + item: { + item1: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + item2: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + item1: { + anotherIdentifier: 'test', + partNo: 'test', + obj2: { + inner: 'test', + }, + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + item2: { + anotherIdentifier: 'test', + partNo: 'test', + obj2: { + inner: 'test', + }, + }, + }, + }); + expect(isValid).toBe(true); + isValid = combinedSchema.namedContext().validate({ + item: { + item2: { + badKey: 'test', + partNo: 'test', + }, + }, + }); + expect(isValid).toBe(false); + isValid = combinedSchema.namedContext().validate({ + item: { + item2: { + }, + }, + }); + expect(isValid).toBe(false); + }); + + it('allows either type including schemas (nested differing types)', function () { + // this test case is to ensure we correctly use a new "root" schema for nested objects + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + obj: Object, + 'obj.inner': String, + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + obj: Object, + 'obj.inner': Number, + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + let isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: 2, + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 2, + }, + }, + }); + expect(isValid).toBe(false); + + isValid = combinedSchema.namedContext().validate({ + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + }); + expect(isValid).toBe(false); + }); + + it('allows either type including schemas (nested arrays)', function () { + // this test case is to ensure we correctly use a new "root" schema for nested objects + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + obj: Object, + 'obj.inner': [String], + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + obj: Object, + 'obj.inner': [Number], + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + let isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: ['test'], + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: [2], + }, + }, + }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: [2, 'test'], + }, + }, + }); + expect(isValid).toBe(false); + + isValid = combinedSchema.namedContext().validate({ + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: ['test', 2], + }, + }, + }); + expect(isValid).toBe(false); + }); + + it('allows either type including schemas (mixed arrays)', function () { + const schemaTwo = new SimpleSchema({ + itemRef: String, + }); + + const schemaOne = new SimpleSchema({ + itemRef: Number, + }); + + const combinedSchema = new SimpleSchema({ + item: Array, + 'item.$': SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + const isValid = combinedSchema.namedContext().validate({ + item: [{ itemRef: 'test' }, { itemRef: 2 }], + }); + expect(isValid).toBe(true); + }); + + it('allows either type including schemas (maybe arrays)', function () { + const schemaOne = new SimpleSchema({ + itemRef: Number, + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaOne, Array), + 'item.$': schemaOne, + }); + let isValid = combinedSchema.namedContext().validate({ + item: [{ itemRef: 2 }], + }); + expect(isValid).toBe(true); + isValid = combinedSchema.namedContext().validate({ + item: { itemRef: 2 }, + }); + expect(isValid).toBe(true); + }); + + it('allows either type including schemas (maybe mixed arrays)', function () { + const schemaOne = new SimpleSchema({ + itemRef: Object, + 'itemRef.inner': Number, + }); + const schemaTwo = new SimpleSchema({ + itemRef: Object, + 'itemRef.inner': String, + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaOne, Array), + 'item.$': schemaTwo, + }); + let isValid = combinedSchema.namedContext().validate({ + item: [{ itemRef: { inner: 'test' } }], + }); + expect(isValid).toBe(true); + isValid = combinedSchema.namedContext().validate({ + item: { itemRef: { inner: 2 } }, + }); + expect(isValid).toBe(true); + }); + + it('oneOfKeys returns the correct set of keys', () => { + let schema = new SimpleSchema({ + str: String, + obj: Object, + 'obj.inner': String, + }); + + expect(schema.oneOfKeys().size).toBe(0); + schema = new SimpleSchema({ + str: String, + obj: Object, + 'obj.inner': new SimpleSchema({ + thing: String, + }), + }); + + expect(schema.oneOfKeys().size).toBe(0); + schema = new SimpleSchema({ + str: String, + obj: Object, + 'obj.inner': SimpleSchema.oneOf( + new SimpleSchema({ + thing: String, + }), + new SimpleSchema({ + thing2: String, + }), + ), + }); + + expect(Array.from(schema.oneOfKeys().keys())).toEqual(['obj.inner.thing', 'obj.inner.thing2']); + schema = new SimpleSchema({ + str: String, + obj: Object, + 'obj.inner': SimpleSchema.oneOf( + new SimpleSchema({ + thing: Object, + 'thing.inner': String, + }), + new SimpleSchema({ + thing2: Object, + 'thing2.inner': String, + }), + ), + }); + + expect(Array.from(schema.oneOfKeys().keys())).toEqual(['obj.inner.thing', 'obj.inner.thing.inner', 'obj.inner.thing2', 'obj.inner.thing2.inner']); + schema = new SimpleSchema({ + str: String, + obj: Array, + 'obj.$': SimpleSchema.oneOf( + new SimpleSchema({ + thing: Object, + 'thing.inner': String, + }), + new SimpleSchema({ + thing2: Object, + 'thing2.inner': String, + }), + ), + }); + + expect(Array.from(schema.oneOfKeys().keys())).toEqual(['obj.$.thing', 'obj.$.thing.inner', 'obj.$.thing2', 'obj.$.thing2.inner']); + }); + + it('allows simple types (modifier)', function () { + const schema = new SimpleSchema({ + field: SimpleSchema.oneOf(String, Number), + }); + + let isValid = schema.namedContext().validate({ + $set: { + field: 'test', + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = schema.namedContext().validate({ + $set: { + field: 3, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = schema.namedContext().validate({ + $set: { + field: false, + }, + }, { modifier: true }); + expect(isValid).toBe(false); + }); + + it('allows either type including schemas (array nested differing types - modifier)', function () { + // this test case is to ensure we correctly use a new "root" schema for nested objects + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + obj: Object, + 'obj.inner': String, + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + obj: Object, + 'obj.inner': Number, + }); + + const combinedSchema = new SimpleSchema({ + item: Array, + 'item.$': SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + let isValid = combinedSchema.namedContext().validate({ + $push: { + item: { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $push: { + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: 3, + }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $push: { + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: false, + }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(false); + + isValid = combinedSchema.namedContext().validate({ + $push: { + item: { + $each: [ + { + anotherIdentifier: 'test', + partNo: 'test', + obj: { + inner: 3, + }, + }, + { + itemRef: 'test', + partNo: 'test', + obj: { + inner: 'test', + }, + }, + ], + }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.0.obj.inner': 'test', + }, + }, { modifier: true }); + expect(isValid).toBe(true); + }); + + it('allows either type including schemas (nested differing types - modifier)', function () { + // this test case is to ensure we correctly use a new "root" schema for nested objects + const schemaTwo = new SimpleSchema({ + itemRef: String, + partNo: String, + obj: Object, + 'obj.inner': String, + }); + + const schemaOne = new SimpleSchema({ + anotherIdentifier: String, + partNo: String, + obj: Object, + 'obj.inner': Number, + }); + + const combinedSchema = new SimpleSchema({ + item: SimpleSchema.oneOf(schemaOne, schemaTwo), + }); + let isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj.inner': 'test', + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj.inner': 3, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj.inner': false, + }, + }, { modifier: true }); + expect(isValid).toBe(false); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj': { inner: 'test' }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj': { inner: 3 }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + 'item.obj': { inner: false }, + }, + }, { modifier: true }); + expect(isValid).toBe(false); + + isValid = combinedSchema.namedContext().validate({ + $set: { + item: { + itemRef: 'test', + partNo: 'test', + obj: { inner: 'test' }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { inner: 3 }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(true); + + isValid = combinedSchema.namedContext().validate({ + $set: { + item: { + anotherIdentifier: 'test', + partNo: 'test', + obj: { inner: 'test' }, + }, + }, + }, { modifier: true }); + expect(isValid).toBe(false); + }); + it('is valid as long as one min value is met', function () { const schema = new SimpleSchema({ foo: SimpleSchema.oneOf({ diff --git a/package/lib/doValidation.js b/package/lib/doValidation.js index 069aa48..44a5cf3 100644 --- a/package/lib/doValidation.js +++ b/package/lib/doValidation.js @@ -47,31 +47,107 @@ function doValidation({ }; } - let validationErrors = []; + const oneOfKeys = schema.oneOfKeys(); + + function recurse({ + val, + affectedKey, + affectedKeyGeneric, + def, + operator, + isInArrayItemObject, + subSchema, + subSchemaAffectedKey, + subSchemaAffectedKeyGeneric, + }) { + // If affectedKeyGeneric is undefined due to this being the first run of this + // function, objectKeys will return the top-level keys. + const childKeys = (subSchema || schema).objectKeys(subSchema ? subSchemaAffectedKeyGeneric : affectedKeyGeneric); + + // Temporarily convert missing objects to empty objects + // so that the looping code will be called and required + // descendent keys can be validated. + if ((val === undefined || val === null) && (!def || (!def.optional && childKeys && childKeys.length > 0))) { + val = {}; + } + const allErrors = []; + + // Loop through arrays + if (Array.isArray(val)) { + val.forEach((v, i) => { + const ret = checkObj({ + val: v, + affectedKey: `${affectedKey}.${i}`, + operator, + subSchema, + // wrap in quotes to protect against SimpleSchema.oneOf(schema, [schema]) + subSchemaAffectedKey: subSchemaAffectedKey === undefined ? undefined : appendAffectedKey(subSchemaAffectedKey, `${i}`), + }); + if (Array.isArray(ret)) { + allErrors.push(...ret); + } + }); + } else if (isObjectWeShouldTraverse(val) && (!def || !schema._blackboxKeys.has(affectedKey))) { + // Loop through object keys + + // Get list of present keys + const presentKeys = Object.keys(val); + + // If this object is within an array, make sure we check for + // required as if it's not a modifier + isInArrayItemObject = (affectedKeyGeneric && affectedKeyGeneric.slice(-2) === '.$'); + + const checkedKeys = []; + + // Check all present keys plus all keys defined by the schema. + // This allows us to detect extra keys not allowed by the schema plus + // any missing required keys, and to run any custom functions for other keys. + /* eslint-disable no-restricted-syntax */ + for (const key of [...presentKeys, ...childKeys]) { + // `childKeys` and `presentKeys` may contain the same keys, so make + // sure we run only once per unique key + if (checkedKeys.indexOf(key) !== -1) continue; + checkedKeys.push(key); + + const ret = checkObj({ + val: val[key], + affectedKey: appendAffectedKey(affectedKey, key), + operator, + isInArrayItemObject, + isInSubObject: true, + subSchema, + subSchemaAffectedKey: subSchemaAffectedKey === undefined ? undefined : appendAffectedKey(subSchemaAffectedKey, key), + }); + if (Array.isArray(ret)) { + allErrors.push(...ret); + } + } + /* eslint-enable no-restricted-syntax */ + } + return allErrors; + } // Validation function called for each affected key - function validate(val, affectedKey, affectedKeyGeneric, def, op, isInArrayItemObject, isInSubObject) { + function validate(val, affectedKey, affectedKeyGeneric, def, op, isInArrayItemObject, isInSubObject, subSchema, subSchemaAffectedKey, subSchemaAffectedKeyGeneric) { // Get the schema for this key, marking invalid if there isn't one. if (!def) { // We don't need KEY_NOT_IN_SCHEMA error for $unset and we also don't need to continue - if (op === '$unset' || (op === '$currentDate' && affectedKey.endsWith('.$type'))) return; + if (op === '$unset' || (op === '$currentDate' && affectedKey.endsWith('.$type'))) return []; - validationErrors.push({ + return [{ name: affectedKey, type: SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA, value: val, - }); - return; + }]; } // For $rename, make sure that the new name is allowed by the schema if (op === '$rename' && !schema.allowsKey(val)) { - validationErrors.push({ + return [{ name: val, type: SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA, value: null, - }); - return; + }]; } // Prepare the context object for the validator functions @@ -123,7 +199,10 @@ function doValidation({ // Loop through each of the definitions in the SimpleSchemaGroup. // If any return true, we're valid. + const fieldTypeValidationErrors = []; const fieldIsValid = def.type.some((typeDef) => { + const subFieldValidationErrors = []; + fieldTypeValidationErrors.push(subFieldValidationErrors); // If the type is SimpleSchema.Any, then it is valid: if (typeDef === SimpleSchema.Any) return true; @@ -149,13 +228,13 @@ function doValidation({ // We use _.every just so that we don't continue running more validator // functions after the first one returns false or an error string. - return fieldValidators.every((validator) => { + const simpleResult = fieldValidators.every((validator) => { const result = validator.call(finalValidatorContext); // If the validator returns a string, assume it is the // error type. if (typeof result === 'string') { - fieldValidationErrors.push({ + subFieldValidationErrors.push({ name: affectedKey, type: result, value: val, @@ -166,7 +245,7 @@ function doValidation({ // If the validator returns an object, assume it is an // error object. if (typeof result === 'object' && result !== null) { - fieldValidationErrors.push({ + subFieldValidationErrors.push({ name: affectedKey, value: val, ...result, @@ -181,10 +260,38 @@ function doValidation({ // Any other return value we assume means it was valid return true; }); + const singleType = typeDef.type; + if (singleType && (SimpleSchema.isSimpleSchema(singleType) || singleType === Array || singleType === Object)) { + let innerSubSchema = subSchema; + let innerSubSchemaAffectedKey = subSchemaAffectedKey; + let innerSubSchemaAffectedKeyGeneric = subSchemaAffectedKeyGeneric; + if (SimpleSchema.isSimpleSchema(singleType)) { + innerSubSchema = singleType; + innerSubSchemaAffectedKey = ''; + innerSubSchemaAffectedKeyGeneric = ''; + } + const ret = recurse({ + val, + affectedKey, + affectedKeyGeneric, + def, + operator: op, + isInArrayItemObject, + subSchema: innerSubSchema, + subSchemaAffectedKey: innerSubSchemaAffectedKey, + subSchemaAffectedKeyGeneric: innerSubSchemaAffectedKeyGeneric, + }); + + if (Array.isArray(ret)) { + subFieldValidationErrors.push(...ret); + return simpleResult && ret.length === 0; + } + } + return simpleResult; }); if (!fieldIsValid) { - validationErrors = validationErrors.concat(fieldValidationErrors); + return [].concat(...fieldTypeValidationErrors); } } @@ -195,17 +302,21 @@ function doValidation({ operator, isInArrayItemObject = false, isInSubObject = false, + subSchema, + subSchemaAffectedKey, }) { let affectedKeyGeneric; + let subSchemaAffectedKeyGeneric; let def; if (affectedKey) { // When we hit a blackbox key, we don't progress any further - if (schema.keyIsInBlackBox(affectedKey)) return; + if (schema.keyIsInBlackBox(affectedKey)) return []; // Make a generic version of the affected key, and use that // to get the schema for this key. affectedKeyGeneric = MongoObject.makeKeyGeneric(affectedKey); + subSchemaAffectedKeyGeneric = subSchemaAffectedKey && MongoObject.makeKeyGeneric(subSchemaAffectedKey); const shouldValidateKey = !keysToValidate || keysToValidate.some((keyToValidate) => ( keyToValidate === affectedKey @@ -242,68 +353,42 @@ function doValidation({ }; // Perform validation for this key - def = schema.getDefinition(affectedKey, null, functionsContext); + if (subSchema) { + def = subSchemaAffectedKey ? subSchema.getDefinition(subSchemaAffectedKey, null, functionsContext) : subSchema; + } else { + def = schema.getDefinition(affectedKey, null, functionsContext); + } if (shouldValidateKey) { - validate(val, affectedKey, affectedKeyGeneric, def, operator, isInArrayItemObject, isInSubObject); + return validate(val, affectedKey, affectedKeyGeneric, def, operator, isInArrayItemObject, isInSubObject, subSchema, subSchemaAffectedKey, subSchemaAffectedKeyGeneric); } - } - - // If affectedKeyGeneric is undefined due to this being the first run of this - // function, objectKeys will return the top-level keys. - const childKeys = schema.objectKeys(affectedKeyGeneric); - - // Temporarily convert missing objects to empty objects - // so that the looping code will be called and required - // descendent keys can be validated. - if ((val === undefined || val === null) && (!def || (!def.optional && childKeys && childKeys.length > 0))) { - val = {}; - } - - // Loop through arrays - if (Array.isArray(val)) { - val.forEach((v, i) => { - checkObj({ - val: v, - affectedKey: `${affectedKey}.${i}`, - operator, - }); + return recurse({ + val, + affectedKey, + affectedKeyGeneric, + def, + operator, + isInArrayItemObject, + subSchema, + subSchemaAffectedKey, + subSchemaAffectedKeyGeneric, }); - } else if (isObjectWeShouldTraverse(val) && (!def || !schema._blackboxKeys.has(affectedKey))) { - // Loop through object keys - - // Get list of present keys - const presentKeys = Object.keys(val); - - // If this object is within an array, make sure we check for - // required as if it's not a modifier - isInArrayItemObject = (affectedKeyGeneric && affectedKeyGeneric.slice(-2) === '.$'); - - const checkedKeys = []; - - // Check all present keys plus all keys defined by the schema. - // This allows us to detect extra keys not allowed by the schema plus - // any missing required keys, and to run any custom functions for other keys. - /* eslint-disable no-restricted-syntax */ - for (const key of [...presentKeys, ...childKeys]) { - // `childKeys` and `presentKeys` may contain the same keys, so make - // sure we run only once per unique key - if (checkedKeys.indexOf(key) !== -1) continue; - checkedKeys.push(key); - - checkObj({ - val: val[key], - affectedKey: appendAffectedKey(affectedKey, key), - operator, - isInArrayItemObject, - isInSubObject: true, - }); - } - /* eslint-enable no-restricted-syntax */ } + return recurse({ + val, + affectedKey, + affectedKeyGeneric, + def, + operator, + isInArrayItemObject, + subSchema, + subSchemaAffectedKey, + subSchemaAffectedKeyGeneric, + }); } function checkModifier(mod) { // Loop through operators + const allErrors = []; Object.keys(mod).forEach((op) => { const opObj = mod[op]; // If non-operators are mixed in, throw error @@ -317,11 +402,14 @@ function doValidation({ const presentKeys = Object.keys(opObj); schema.objectKeys().forEach((schemaKey) => { if (!presentKeys.includes(schemaKey)) { - checkObj({ + const ret = checkObj({ val: undefined, affectedKey: schemaKey, operator: op, }); + if (Array.isArray(ret)) { + allErrors.push(...ret); + } } }); } @@ -336,21 +424,55 @@ function doValidation({ k = `${k}.0`; } } - checkObj({ - val: v, - affectedKey: k, - operator: op, - }); + + // if this key is in a oneOf - we need to fork and recurse to find the relevant key + const genericKey = MongoObject.makeKeyGeneric(k); + const isOneOf = oneOfKeys.has(genericKey); + if (isOneOf) { + const subSchemaReferences = oneOfKeys.get(genericKey); + const perSchemaErrors = []; + const isValid = subSchemaReferences.some(({ schema: subSchema, suffix }) => { + const ret = checkObj({ + val: v, + affectedKey: k, + operator: op, + subSchema, + subSchemaAffectedKey: suffix, + }); + if (Array.isArray(ret)) { + perSchemaErrors.push(ret); + return ret.length === 0; + } + return true; + }); + if (!isValid) { + perSchemaErrors.forEach((errors) => { + allErrors.push(...errors); + }); + } + } else { + const ret = checkObj({ + val: v, + affectedKey: k, + operator: op, + }); + + if (Array.isArray(ret)) { + allErrors.push(...ret); + } + } }); } }); + return allErrors; } + let validationErrors; // Kick off the validation if (isModifier) { - checkModifier(obj); + validationErrors = checkModifier(obj) || []; } else { - checkObj({ val: obj }); + validationErrors = checkObj({ val: obj }) || []; } // Custom whole-doc validators