From e8f91dcd5016b3998cbec425fa77f337b398fc96 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Sun, 11 Dec 2022 10:08:35 -0600 Subject: [PATCH] feat: improved oneOf support Thanks to @znewsham for initial work on this fixes #428 fixes #410 fixes #112 fixes #68 --- src/SimpleSchema.ts | 73 +++- src/ValidationContext.ts | 12 + src/doValidation.ts | 473 ++++--------------------- src/utility/index.ts | 2 +- src/validation/validateDocument.ts | 58 +++ src/validation/validateField.ts | 348 ++++++++++++++++++ test/SimpleSchema_oneOf.tests.ts | 545 ++++++++++++++++++++++++++++- 7 files changed, 1096 insertions(+), 415 deletions(-) create mode 100644 src/validation/validateDocument.ts create mode 100644 src/validation/validateField.ts diff --git a/src/SimpleSchema.ts b/src/SimpleSchema.ts index 49a5de9..30cf07d 100644 --- a/src/SimpleSchema.ts +++ b/src/SimpleSchema.ts @@ -289,6 +289,33 @@ class SimpleSchema { return keySchema } + /** + * @param key One specific or generic key for which to get all possible schemas. + * @returns An potentially empty array of possible definitions for one key + * + * Note that this returns the raw, unevaluated definition object. Use `getDefinition` + * if you want the evaluated definition, where any properties that are functions + * have been run to produce a result. + */ + schemas (key: string): StandardSchemaKeyDefinition[] { + const schemas: StandardSchemaKeyDefinition[] = [] + + const genericKey = MongoObject.makeKeyGeneric(key) + const keySchema = genericKey == null ? null : this._schema[genericKey] + if (keySchema != null) schemas.push(keySchema) + + // See if it's defined in any subschema + this.forEachAncestorSimpleSchema( + key, + (simpleSchema, ancestor, subSchemaKey) => { + const keyDef = simpleSchema.schema(subSchemaKey) + if (keyDef != null) schemas.push(keyDef) + } + ) + + return schemas + } + /** * @returns {Object} The entire schema object with subschemas merged. This is the * equivalent of what schema() returned in SimpleSchema < 2.0 @@ -329,9 +356,45 @@ class SimpleSchema { propList?: string[] | null, functionContext: Record = {} ): StandardSchemaKeyDefinitionWithSimpleTypes | undefined { - const defs = this.schema(key) - if (defs == null) return + const schemaKeyDefinition = this.schema(key) + if (schemaKeyDefinition == null) return + return this.resolveDefinitionForSchema(key, schemaKeyDefinition, propList, functionContext) + } + + /** + * Returns the evaluated definition for one key in the schema + * + * @param key Generic or specific schema key + * @param [propList] Array of schema properties you need; performance optimization + * @param [functionContext] The context to use when evaluating schema options that are functions + * @returns The schema definition for the requested key + */ + getDefinitions ( + key: string, + propList?: string[] | null, + functionContext: Record = {} + ): StandardSchemaKeyDefinitionWithSimpleTypes[] { + const schemaKeyDefinitions = this.schemas(key) + return schemaKeyDefinitions.map((def) => { + return this.resolveDefinitionForSchema(key, def, propList, functionContext) + }) + } + /** + * Resolves the definition for one key in the schema + * + * @param key Generic or specific schema key + * @param schemaKeyDefinition Unresolved definition as returned from simpleSchema.schema() + * @param [propList] Array of schema properties you need; performance optimization + * @param [functionContext] The context to use when evaluating schema options that are functions + * @returns The schema definition for the requested key + */ + resolveDefinitionForSchema ( + key: string, + schemaKeyDefinition: StandardSchemaKeyDefinition, + propList?: string[] | null, + functionContext: Record = {} + ): StandardSchemaKeyDefinitionWithSimpleTypes { const getPropIterator = (obj: Record, newObj: Record) => { return (prop: string): void => { if (Array.isArray(propList) && !propList.includes(prop)) return @@ -362,11 +425,11 @@ class SimpleSchema { type: [] } - Object.keys(defs).forEach(getPropIterator(defs, result)) + Object.keys(schemaKeyDefinition).forEach(getPropIterator(schemaKeyDefinition, result)) // Resolve all the types and convert to a normal array to make it easier to use. - if (Array.isArray(defs.type?.definitions)) { - result.type = defs.type.definitions.map((typeDef) => { + if (Array.isArray(schemaKeyDefinition.type?.definitions)) { + result.type = schemaKeyDefinition.type.definitions.map((typeDef) => { const newTypeDef: SchemaKeyDefinitionWithOneType = { type: String // will be overwritten } diff --git a/src/ValidationContext.ts b/src/ValidationContext.ts index a3c0f9a..52a84a0 100644 --- a/src/ValidationContext.ts +++ b/src/ValidationContext.ts @@ -3,6 +3,7 @@ import MongoObject from 'mongo-object' import doValidation from './doValidation.js' import { SimpleSchema } from './SimpleSchema.js' import { CleanOptions, ObjectToValidate, ValidationError, ValidationOptions } from './types.js' +import { looksLikeModifier } from './utility/index.js' export default class ValidationContext { public name?: string @@ -88,6 +89,17 @@ export default class ValidationContext { upsert: isUpsert = false }: ValidationOptions = {} ): boolean { + // First do some basic checks of the object, and throw errors if necessary + if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function')) { + throw new Error('The first argument of validate() must be an object') + } + + if (!isModifier && looksLikeModifier(obj)) { + throw new Error( + 'When the validation object contains mongo operators, you must set the modifier option to true' + ) + } + const validationErrors = doValidation({ extendedCustomContext, ignoreTypes, diff --git a/src/doValidation.ts b/src/doValidation.ts index 4bbcf81..a3df886 100644 --- a/src/doValidation.ts +++ b/src/doValidation.ts @@ -1,30 +1,14 @@ import MongoObject from 'mongo-object' import { SimpleSchema } from './SimpleSchema.js' -import { - DocValidatorContext, - FieldInfo, - FunctionPropContext, - SchemaKeyDefinitionWithOneType, - StandardSchemaKeyDefinitionWithSimpleTypes, - ValidationError, - ValidatorContext, - ValidatorFunction -} from './types.js' -import { - appendAffectedKey, - getParentOfKey, - isObjectWeShouldTraverse, - looksLikeModifier -} from './utility/index.js' -import allowedValuesValidator from './validation/allowedValuesValidator.js' -import requiredValidator from './validation/requiredValidator.js' -import typeValidator from './validation/typeValidator/index.js' +import { ValidationError } from './types.js' +import validateDocument from './validation/validateDocument.js' +import validateField from './validation/validateField.js' import ValidationContext from './ValidationContext.js' -function shouldCheck (key: string): boolean { - if (key === '$pushAll') { throw new Error('$pushAll is not supported; use $push + $each') } - return !['$pull', '$pullAll', '$pop', '$slice'].includes(key) +function shouldCheck (operator: string): boolean { + if (operator === '$pushAll') { throw new Error('$pushAll is not supported; use $push + $each') } + return !['$pull', '$pullAll', '$pop', '$slice'].includes(operator) } interface DoValidationProps { @@ -39,14 +23,6 @@ interface DoValidationProps { validationContext: ValidationContext } -interface CheckObjProps { - affectedKey?: string | null - isInArrayItemObject?: boolean - isInSubObject?: boolean - operator?: string | null - val: any -} - function doValidation ({ extendedCustomContext, ignoreTypes, @@ -58,382 +34,71 @@ function doValidation ({ schema, validationContext }: DoValidationProps): ValidationError[] { - // First do some basic checks of the object, and throw errors if necessary - if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function')) { - throw new Error('The first argument of validate() must be an object') - } - - if (!isModifier && looksLikeModifier(obj)) { - throw new Error( - 'When the validation object contains mongo operators, you must set the modifier option to true' - ) - } - - function getFieldInfo (key: string): FieldInfo { - // Create mongoObject if necessary, cache for speed - if (mongoObject == null) mongoObject = new MongoObject(obj, schema.blackboxKeys()) - - const keyInfo = mongoObject.getInfoForKey(key) ?? { - operator: null, - value: undefined - } - - return { - ...keyInfo, - isSet: keyInfo.value !== undefined - } - } - - let validationErrors: ValidationError[] = [] - - // Validation function called for each affected key - function validate ( - val: any, - affectedKey: string, - affectedKeyGeneric: string | null, - def: StandardSchemaKeyDefinitionWithSimpleTypes | null | undefined, - op: string | null, - isInArrayItemObject: boolean, - isInSubObject: boolean - ): void { - // Get the schema for this key, marking invalid if there isn't one. - if (def == null) { - // 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 } - - validationErrors.push({ - 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({ - name: val, - type: SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA, - value: null - }) - return - } - - // Prepare the context object for the validator functions - const fieldParentNameWithEndDot = getParentOfKey(affectedKey, true) - const fieldParentName = fieldParentNameWithEndDot.slice(0, -1) - - const fieldValidationErrors: ValidationError[] = [] - - const validatorContext: Omit = { - addValidationErrors (errors: ValidationError[]) { - errors.forEach((error) => fieldValidationErrors.push(error)) - }, - field (fName: string) { - return getFieldInfo(fName) - }, - genericKey: affectedKeyGeneric, - isInArrayItemObject, - isInSubObject, - isModifier, - isSet: val !== undefined, - key: affectedKey, - obj, - operator: op, - parentField () { - return getFieldInfo(fieldParentName) - }, - siblingField (fName: string) { - return getFieldInfo(fieldParentNameWithEndDot + fName) - }, - validationContext, - value: val, - // Value checks are not necessary for null or undefined values, except - // for non-optional null array items, or for $unset or $rename values - valueShouldBeChecked: - op !== '$unset' && - op !== '$rename' && - ((val !== undefined && val !== null) || - (affectedKeyGeneric?.slice(-2) === '.$' && - val === null && - def.optional !== true)), - ...(extendedCustomContext ?? {}) - } - - const builtInValidators: ValidatorFunction[] = [ - requiredValidator, - typeValidator, - allowedValuesValidator - ] - const validators = builtInValidators - // @ts-expect-error - .concat(schema._validators) - // @ts-expect-error - .concat(SimpleSchema._validators) - - // Loop through each of the definitions in the SimpleSchemaGroup. - // If any return true, we're valid. - const fieldIsValid = def.type.some((typeDef) => { - // If the type is SimpleSchema.Any, then it is valid - if (typeDef === SimpleSchema.Any) return true - - const { type, ...definitionWithoutType } = def // eslint-disable-line no-unused-vars - - // @ts-expect-error - const finalValidatorContext: ValidatorContext = { - ...validatorContext, - - // Take outer definition props like "optional" and "label" - // and add them to inner props like "type" and "min" - definition: { - ...definitionWithoutType, - ...typeDef as SchemaKeyDefinitionWithOneType - } - } - - // Add custom field validators to the list after the built-in - // validators but before the schema and global validators. - const fieldValidators = validators.slice(0) - const customFn = (typeDef as SchemaKeyDefinitionWithOneType).custom - if (customFn != null) fieldValidators.splice(builtInValidators.length, 0, customFn) - - // 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 result = validator.call(finalValidatorContext) - - // If the validator returns a string, assume it is the - // error type. - if (typeof result === 'string') { - fieldValidationErrors.push({ - name: affectedKey, - type: result, - value: val - }) - return false - } - - // If the validator returns an object, assume it is an - // error object. - if (typeof result === 'object' && result !== null) { - fieldValidationErrors.push({ - name: affectedKey, - value: val, - ...result - }) - return false - } - - // If the validator returns false, assume they already - // called this.addValidationErrors within the function - if (result === false) return false - - // Any other return value we assume means it was valid - return true - }) - }) - - if (!fieldIsValid) { - validationErrors = validationErrors.concat(fieldValidationErrors) - } - } - - // The recursive function - function checkObj ({ - val, - affectedKey, - operator = null, - isInArrayItemObject = false, - isInSubObject = false - }: CheckObjProps): void { - let affectedKeyGeneric: string | null | undefined - let def - - if (affectedKey != null) { - // When we hit a blackbox key, we don't progress any further - if (schema.keyIsInBlackBox(affectedKey)) return + const validationErrors: ValidationError[] = [] - // Make a generic version of the affected key, and use that - // to get the schema for this key. - affectedKeyGeneric = MongoObject.makeKeyGeneric(affectedKey) - if (affectedKeyGeneric === null) throw new Error(`Failed to get generic key for affected key "${affectedKey}"`) - - const shouldValidateKey = - (keysToValidate == null) || - keysToValidate.some( - (keyToValidate) => - keyToValidate === affectedKey || - keyToValidate === affectedKeyGeneric || - affectedKey.startsWith(`${keyToValidate}.`) || - affectedKeyGeneric?.startsWith(`${keyToValidate}.`) - ) - - // Prepare the context object for the rule functions - const fieldParentNameWithEndDot = getParentOfKey(affectedKey, true) - const fieldParentName = fieldParentNameWithEndDot.slice(0, -1) - - const functionsContext: FunctionPropContext = { - field (fName: string) { - return getFieldInfo(fName) - }, - genericKey: affectedKeyGeneric, - isInArrayItemObject, - isInSubObject, - isModifier, - isSet: val !== undefined, - key: affectedKey, - obj, - operator, - parentField () { - return getFieldInfo(fieldParentName) - }, - siblingField (fName: string) { - return getFieldInfo(fieldParentNameWithEndDot + fName) - }, - validationContext, - value: val, - ...(extendedCustomContext ?? {}) - } - - // Perform validation for this key - def = schema.getDefinition(affectedKey, null, functionsContext) - if (shouldValidateKey) { - validate( - val, - affectedKey, - affectedKeyGeneric, - def, - operator, - isInArrayItemObject, - isInSubObject - ) - } - } - - // 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 as string | undefined) - - // 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 == null) || (def.optional !== true && childKeys.length > 0)) - ) { - val = {} - } - - // Loop through arrays - if (Array.isArray(val)) { - val.forEach((v, i) => { - checkObj({ - val: v, - affectedKey: `${affectedKey as string}.${i}`, - operator - }) - }) - } else if ( - isObjectWeShouldTraverse(val) && - // @ts-expect-error - ((def == null) || !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?.slice(-2) === '.$' - - const checkedKeys: string[] = [] - - // 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.includes(key)) continue - checkedKeys.push(key) - - checkObj({ - val: val[key], - affectedKey: appendAffectedKey(affectedKey, key), - operator, - isInArrayItemObject, - isInSubObject: true - }) - } - /* eslint-enable no-restricted-syntax */ - } - } - - function checkModifier (mod: Record): void { + // Kick off the validation + if (isModifier) { // Loop through operators - Object.keys(mod).forEach((op) => { - const opObj = mod[op] + for (const [op, opObj] of Object.entries>(obj)) { // If non-operators are mixed in, throw error if (op.slice(0, 1) !== '$') { throw new Error( - `Expected '${op}' to be a modifier operator like '$set'` + `Expected '${op}' to be a modifier operator like '$set'` ) } - if (shouldCheck(op)) { - // For an upsert, missing props would not be set if an insert is performed, - // so we check them all with undefined value to force any 'required' checks to fail - if (isUpsert && (op === '$set' || op === '$setOnInsert')) { - const presentKeys = Object.keys(opObj) - schema.objectKeys().forEach((schemaKey) => { - if (!presentKeys.includes(schemaKey)) { - checkObj({ - val: undefined, - affectedKey: schemaKey, - operator: op - }) - } - }) + if (!shouldCheck(op)) continue + + const presentKeys = Object.keys(opObj) + const fields = presentKeys.map((opKey) => { + let value = opObj[opKey] + if (op === '$push' || op === '$addToSet') { + if (typeof value === 'object' && '$each' in value) { + value = value.$each + } else { + opKey = `${opKey}.0` + } } - // Don't use forEach here because it will not properly handle an - // object that has a property named `length` - Object.keys(opObj).forEach((k) => { - let v = opObj[k] - if (op === '$push' || op === '$addToSet') { - if (typeof v === 'object' && '$each' in v) { - v = v.$each - } else { - k = `${k}.0` - } + return { key: opKey, value } + }) + + // For an upsert, missing props would not be set if an insert is performed, + // so we check them all with undefined value to force any 'required' checks to fail + if (isUpsert && (op === '$set' || op === '$setOnInsert')) { + for (const key of schema.objectKeys()) { + if (!presentKeys.includes(key)) { + fields.push({ key, value: undefined }) } - checkObj({ - val: v, - affectedKey: k, - operator: op - }) - }) + } } - }) - } - // Kick off the validation - if (isModifier) { - checkModifier(obj) + for (const field of fields) { + const fieldErrors = validateField({ + affectedKey: field.key, + obj, + op, + schema, + val: field.value, + validationContext + }) + if (fieldErrors.length > 0) { + validationErrors.push(...fieldErrors) + } + } + } } else { - checkObj({ val: obj }) + const fieldErrors = validateField({ + obj, + schema, + val: obj, + validationContext + }) + if (fieldErrors.length > 0) { + validationErrors.push(...fieldErrors) + } } - // Custom whole-doc validators - // @ts-expect-error - const docValidators = schema._docValidators.concat( - // @ts-expect-error - SimpleSchema._docValidators - ) - const docValidatorContext: DocValidatorContext = { + const wholeDocumentErrors = validateDocument({ + extendedCustomContext, ignoreTypes, isModifier, isUpsert, @@ -441,30 +106,22 @@ function doValidation ({ mongoObject, obj, schema, - validationContext, - ...(extendedCustomContext ?? {}) - } - docValidators.forEach((func) => { - const errors = func.call(docValidatorContext, obj) - if (!Array.isArray(errors)) { - throw new Error( - 'Custom doc validator must return an array of error objects' - ) - } - if (errors.length > 0) validationErrors = validationErrors.concat(errors) + validationContext }) + if (wholeDocumentErrors.length > 0) { + validationErrors.push(...wholeDocumentErrors) + } - const addedFieldNames: string[] = [] - validationErrors = validationErrors.filter((errObj) => { + const addedFieldNames = new Set() + return validationErrors.filter((errObj) => { // Remove error types the user doesn't care about if (ignoreTypes?.includes(errObj.type) === true) return false // Make sure there is only one error per fieldName - if (addedFieldNames.includes(errObj.name)) return false + if (addedFieldNames.has(errObj.name)) return false - addedFieldNames.push(errObj.name) + addedFieldNames.add(errObj.name) return true }) - return validationErrors } export default doValidation diff --git a/src/utility/index.ts b/src/utility/index.ts index 0b1e216..11f352f 100644 --- a/src/utility/index.ts +++ b/src/utility/index.ts @@ -1,6 +1,6 @@ import { ObjectToValidate } from '../types.js' -export function appendAffectedKey (affectedKey: string | null | undefined, key: string): string | null | undefined { +export function appendAffectedKey (affectedKey: string | undefined, key: string): string | undefined { if (key === '$each') return affectedKey return affectedKey == null ? key : `${affectedKey}.${key}` } diff --git a/src/validation/validateDocument.ts b/src/validation/validateDocument.ts new file mode 100644 index 0000000..f942115 --- /dev/null +++ b/src/validation/validateDocument.ts @@ -0,0 +1,58 @@ +import MongoObject from 'mongo-object' + +import { SimpleSchema, ValidationContext } from '../SimpleSchema.js' +import { DocValidatorContext, ValidationError } from '../types.js' + +interface ValidateDocumentProps { + extendedCustomContext?: Record + ignoreTypes?: string[] + isModifier: boolean + isUpsert: boolean + keysToValidate?: string[] | undefined + mongoObject?: MongoObject + obj: any + schema: SimpleSchema + validationContext: ValidationContext +} + +export default function validateDocument ({ + extendedCustomContext, + ignoreTypes, + isModifier, + isUpsert, + keysToValidate, + mongoObject, + obj, + schema, + validationContext +}: ValidateDocumentProps): ValidationError[] { + // @ts-expect-error + const docValidators = schema._docValidators.concat( + // @ts-expect-error + SimpleSchema._docValidators + ) + const docValidatorContext: DocValidatorContext = { + ignoreTypes, + isModifier, + isUpsert, + keysToValidate, + mongoObject, + obj, + schema, + validationContext, + ...(extendedCustomContext ?? {}) + } + const validationErrors: ValidationError[] = [] + for (const docValidator of docValidators) { + const errors = docValidator.call(docValidatorContext, obj) + if (!Array.isArray(errors)) { + throw new Error( + 'Custom doc validator must return an array of error objects' + ) + } + if (errors.length > 0) { + validationErrors.push(...errors) + } + } + return validationErrors +} diff --git a/src/validation/validateField.ts b/src/validation/validateField.ts new file mode 100644 index 0000000..42e6631 --- /dev/null +++ b/src/validation/validateField.ts @@ -0,0 +1,348 @@ +import MongoObject from 'mongo-object' + +import { SimpleSchema, ValidationContext } from '../SimpleSchema.js' +import { FieldInfo, FunctionPropContext, SchemaKeyDefinitionWithOneType, StandardSchemaKeyDefinitionWithSimpleTypes, ValidationError, ValidatorContext } from '../types.js' +import { appendAffectedKey, getParentOfKey, isObjectWeShouldTraverse } from '../utility/index.js' +import allowedValuesValidator from './allowedValuesValidator.js' +import requiredValidator from './requiredValidator.js' +import typeValidator from './typeValidator/index.js' + +interface ValidateFieldProps { + affectedKey?: string | undefined + extendedCustomContext?: Record + isInArrayItemObject?: boolean + isInSubObject?: boolean + keysToValidate?: string[] | undefined + obj: any + op?: string | null + schema: SimpleSchema + val: any + validationContext: ValidationContext +} + +interface ShouldValidateKeyProps { + affectedKey?: string | undefined + affectedKeyGeneric?: string | undefined + keysToValidate?: string[] | undefined +} + +interface ShouldCheckValueProps { + affectedKeyGeneric?: string | undefined + isOptional?: boolean + op: string | null + val: any +} + +function shouldValidateKey ({ + affectedKey, + affectedKeyGeneric, + keysToValidate +}: ShouldValidateKeyProps): boolean { + if (keysToValidate == null) return true + return keysToValidate.some( + (keyToValidate) => + keyToValidate === affectedKey || + keyToValidate === affectedKeyGeneric || + (affectedKey?.startsWith(`${keyToValidate}.`) ?? false) || + (affectedKeyGeneric?.startsWith(`${keyToValidate}.`) ?? false) + ) +} + +function shouldCheckValue ({ + affectedKeyGeneric, + isOptional, + op, + val +}: ShouldCheckValueProps): boolean { + if (op === '$unset') return false + if (op === '$rename') return false + if (val === undefined || val === null) { + return affectedKeyGeneric?.slice(-2) === '.$' && + val === null && + isOptional !== true + } + return true +} + +function makeGenericKeyOrThrow (key: string): string { + const genericKey = MongoObject.makeKeyGeneric(key) + if (genericKey == null) throw new Error(`Failed to get generic key for key "${key}"`) + return genericKey +} + +/** + * Validate a single field within an object being validated + * @returns Array of all validation errors + */ +export default function validateField (props: ValidateFieldProps): ValidationError[] { + const { + affectedKey, + extendedCustomContext, + isInArrayItemObject = false, + isInSubObject = false, + keysToValidate, + obj, + op = null, + schema, + validationContext + } = props + let { val } = props + + let affectedKeyGeneric: string | undefined + let def: StandardSchemaKeyDefinitionWithSimpleTypes | undefined + const fieldValidationErrors: ValidationError[] = [] + + let mongoObject: MongoObject + function getFieldInfo (key: string): FieldInfo { + // Create mongoObject if necessary, cache for speed + if (mongoObject === undefined) mongoObject = new MongoObject(obj, schema.blackboxKeys()) + + const keyInfo = mongoObject.getInfoForKey(key) ?? { + operator: null, + value: undefined + } + + return { + ...keyInfo, + isSet: keyInfo.value !== undefined + } + } + + if (affectedKey !== undefined) { + // When we hit a blackbox key, we don't progress any further + if (schema.keyIsInBlackBox(affectedKey)) return [] + + affectedKeyGeneric = makeGenericKeyOrThrow(affectedKey) + + // Prepare the context object for the rule functions + const fieldParentNameWithEndDot = getParentOfKey(affectedKey, true) + const fieldParentName = fieldParentNameWithEndDot.slice(0, -1) + + const functionsContext: FunctionPropContext = { + field (fName: string) { + return getFieldInfo(fName) + }, + genericKey: affectedKeyGeneric, + isInArrayItemObject, + isInSubObject, + isModifier: op != null, + isSet: val !== undefined, + key: affectedKey, + obj, + operator: op, + parentField () { + return getFieldInfo(fieldParentName) + }, + siblingField (fName: string) { + return getFieldInfo(fieldParentNameWithEndDot + fName) + }, + validationContext, + value: val, + ...(extendedCustomContext ?? {}) + } + + if (!shouldValidateKey({ + affectedKey, + affectedKeyGeneric: affectedKeyGeneric ?? undefined, + keysToValidate + })) return [] + + // Perform validation for this key + for (const currentDef of schema.getDefinitions(affectedKey, null, functionsContext)) { + def = currentDef + + // Whenever we try a new possible schema, clear any field errors from the previous tried schema + fieldValidationErrors.length = 0 + + const validatorContext: Omit = { + ...functionsContext, + addValidationErrors (errors: ValidationError[]) { + errors.forEach((error) => fieldValidationErrors.push(error)) + }, + // Value checks are not necessary for null or undefined values, except + // for non-optional null array items, or for $unset or $rename values + valueShouldBeChecked: shouldCheckValue({ + affectedKeyGeneric: affectedKeyGeneric ?? undefined, + isOptional: currentDef.optional as boolean, + op, + val + }) + } + + // Loop through each of the definitions in the SimpleSchemaGroup. + // If the value matches any, we are valid and can stop checking the rest. + for (const [typeIndex, typeDef] of currentDef.type.entries()) { + // If the type is SimpleSchema.Any, then it is valid + if (typeDef === SimpleSchema.Any) break + + const nonAnyTypeDefinition = typeDef as SchemaKeyDefinitionWithOneType + const { type, ...definitionWithoutType } = currentDef + + // @ts-expect-error + const finalValidatorContext: ValidatorContext = { + ...validatorContext, + + // Take outer definition props like "optional" and "label" + // and add them to inner props like "type" and "min" + definition: { + ...definitionWithoutType, + ...nonAnyTypeDefinition + } + } + + // Order of these validators is important + const customFieldValidator = nonAnyTypeDefinition.custom + const fieldValidators = [ + requiredValidator, + typeValidator, + allowedValuesValidator, + ...(customFieldValidator == null ? [] : [customFieldValidator]), + // @ts-expect-error It's fine to access private method from here + ...schema._validators, + // @ts-expect-error It's fine to access private method from here + ...SimpleSchema._validators + ] + + const fieldValidationErrorsForThisType = [] + for (const fieldValidator of fieldValidators) { + const result = fieldValidator.call(finalValidatorContext) + + // If the validator returns a string, assume it is the error type. + if (typeof result === 'string') { + fieldValidationErrorsForThisType.push({ + name: affectedKey, + type: result, + value: val + }) + } + + // If the validator returns an object, assume it is an error object. + if (typeof result === 'object' && result !== null) { + fieldValidationErrorsForThisType.push({ + name: affectedKey, + value: val, + ...result + }) + } + } + + if (SimpleSchema.isSimpleSchema(nonAnyTypeDefinition.type)) { + const itemErrors = validateField({ + extendedCustomContext, + keysToValidate, + obj: val, + op, + schema: nonAnyTypeDefinition.type as SimpleSchema, + val, + validationContext + }) + if (itemErrors.length > 0) { + fieldValidationErrorsForThisType.push(...itemErrors.map((error) => ({ ...error, name: `${affectedKey}.${error.name}` }))) + } + } + + // As soon as we find a type for which the value is valid, stop checking more + if (fieldValidationErrorsForThisType.length === 0) { + // One we have chosen a valid schema, there is no need to validate the + // properties of this object because we validated all the way down + if (SimpleSchema.isSimpleSchema(nonAnyTypeDefinition.type)) { + return fieldValidationErrors + } + break + } + + if (typeIndex === currentDef.type.length - 1) { + fieldValidationErrors.push(...fieldValidationErrorsForThisType) + } + } + + // If it's valid with this schema, we don't need to try any more + if (fieldValidationErrors.length === 0) break + } + + // Mark invalid if not found in schema + if (def == null) { + // 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 [] + } + + return [ + { + name: affectedKey, + type: SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA, + value: val + } + ] + } + + // For $rename, make sure that the new name is allowed by the schema + if (op === '$rename' && !schema.allowsKey(val)) { + return [ + { + name: val, + type: SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA, + value: null + } + ] + } + + // Loop through arrays + if (Array.isArray(val)) { + for (const [index, itemValue] of val.entries()) { + const itemErrors = validateField({ + ...props, + affectedKey: `${affectedKey}.${index}`, + val: itemValue + }) + if (itemErrors.length > 0) { + fieldValidationErrors.push(...itemErrors) + } + } + return fieldValidationErrors + } + } + + // 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 ?? undefined) + + // 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 == null) || (def.optional !== true && childKeys.length > 0)) + ) { + val = {} + } + + // Loop through object keys + if ( + isObjectWeShouldTraverse(val) && + // @ts-expect-error + ((def == null) || !schema._blackboxKeys.has(affectedKey ?? '')) + ) { + // 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. + for (const key of new Set([...Object.keys(val), ...childKeys])) { + const childFieldErrors = validateField({ + ...props, + affectedKey: appendAffectedKey(affectedKey, key) as string, + // If this object is within an array, make sure we check for required as if it's not a modifier + isInArrayItemObject: affectedKeyGeneric?.slice(-2) === '.$', + isInSubObject: true, + val: val[key] + }) + if (childFieldErrors.length > 0) { + fieldValidationErrors.push(...childFieldErrors) + } + } + } + + return fieldValidationErrors +} diff --git a/test/SimpleSchema_oneOf.tests.ts b/test/SimpleSchema_oneOf.tests.ts index 5fc0a38..13a57c2 100644 --- a/test/SimpleSchema_oneOf.tests.ts +++ b/test/SimpleSchema_oneOf.tests.ts @@ -36,7 +36,7 @@ describe('SimpleSchema', function () { expect(typeof test4.foo).toBe('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 @@ -71,6 +71,549 @@ describe('SimpleSchema', function () { } }) expect(isValid).toBe(true) + + isValid = combinedSchema.namedContext().validate({ + item: { + itemRef: 'hhh' + } + }) + expect(isValid).toBe(false) + }) + + 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('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 () {