import utils from './utils' import Component from './Component' const DOMAIN = 'Schema' /** * TODO * * @name Schema.types * @type {Object} */ const types = { array: utils.isArray, boolean: utils.isBoolean, integer: utils.isInteger, 'null': utils.isNull, number: utils.isNumber, object: utils.isObject, string: utils.isString } /** * @ignore */ const segmentToString = function (segment, prev) { let str = '' if (segment) { if (utils.isNumber(segment)) { str += `[${segment}]` } else if (prev) { str += `.${segment}` } else { str += `${segment}` } } return str } /** * @ignore */ const makePath = function (opts) { opts || (opts = {}) let path = '' const segments = opts.path || [] segments.forEach(function (segment) { path += segmentToString(segment, path) }) path += segmentToString(opts.prop, path) return path } /** * @ignore */ const makeError = function (actual, expected, opts) { return { expected, actual: '' + actual, path: makePath(opts) } } /** * @ignore */ const addError = function (actual, expected, opts, errors) { errors.push(makeError(actual, expected, opts)) } /** * @ignore */ const maxLengthCommon = function (keyword, value, schema, opts) { const max = schema[keyword] if (value.length > max) { return makeError(value.length, `length no more than ${max}`, opts) } } /** * @ignore */ const minLengthCommon = function (keyword, value, schema, opts) { const min = schema[keyword] if (value.length < min) { return makeError(value.length, `length no less than ${min}`, opts) } } /** * TODO * * @name Schema.validationKeywords * @type {Object} */ const validationKeywords = { /** * http://json-schema.org/latest/json-schema-validation.html#anchor82 * * @name Schema.validationKeywords.allOf * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ allOf (value, schema, opts) { let allErrors = [] schema.allOf.forEach(function (_schema) { allErrors = allErrors.concat(validate(value, _schema, opts) || []) }) return allErrors.length ? undefined : allErrors }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor85 * * @name Schema.validationKeywords.anyOf * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ anyOf (value, schema, opts) { let validated = false let allErrors = [] schema.anyOf.forEach(function (_schema) { const errors = validate(value, _schema, opts) if (errors) { allErrors = allErrors.concat(errors) } else { validated = true } }) return validated ? undefined : allErrors }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor70 * * @name Schema.validationKeywords.dependencies * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ dependencies (value, schema, opts) { // TODO }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor76 * * @name Schema.validationKeywords.enum * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ enum (value, schema, opts) { const possibleValues = schema['enum'] if (possibleValues.indexOf(value) === -1) { return makeError(value, `one of (${possibleValues.join(', ')})`, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor37 * * @name Schema.validationKeywords.items * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ items (value, schema, opts) { opts || (opts = {}) // TODO: additionalItems let items = schema.items let errors = [] const checkingTuple = utils.isArray(items) const length = value.length for (var prop = 0; prop < length; prop++) { if (checkingTuple) { // Validating a tuple, instead of just checking each item against the // same schema items = schema.items[prop] } opts.prop = prop errors = errors.concat(validate(value[prop], items, opts) || []) } return errors.length ? errors : undefined }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor17 * * @name Schema.validationKeywords.maximum * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ maximum (value, schema, opts) { // Must be a number const maximum = schema.maximum // Must be a boolean // Depends on maximum // default: false const exclusiveMaximum = schema.exclusiveMaximum if (typeof value === typeof maximum && (exclusiveMaximum ? maximum < value : maximum <= value)) { // TODO: Account for value of exclusiveMaximum in messaging return makeError(value, `no more than ${maximum}`, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor42 * * @name Schema.validationKeywords.maxItems * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ maxItems (value, schema, opts) { return maxLengthCommon('maxItems', value, schema, opts) }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor26 * * @name Schema.validationKeywords.maxLength * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ maxLength (value, schema, opts) { return maxLengthCommon('maxLength', value, schema, opts) }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor54 * * @name Schema.validationKeywords.maxProperties * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ maxProperties (value, schema, opts) { const maxProperties = schema.maxProperties const length = Object.keys(value).length if (length > maxProperties) { return makeError(length, `no more than ${maxProperties} properties`, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor21 * * @name Schema.validationKeywords.minimum * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ minimum (value, schema, opts) { // Must be a number const minimum = schema.minimum // Must be a boolean // Depends on minimum // default: false const exclusiveMinimum = schema.exclusiveMinimum if (typeof value === typeof minimum && (exclusiveMinimum ? minimum > value : minimum >= value)) { // TODO: Account for value of exclusiveMinimum in messaging return makeError(value, `no less than ${minimum}`, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor42 * * @name Schema.validationKeywords.minItems * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ minItems (value, schema, opts) { return minLengthCommon('minItems', value, schema, opts) }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor29 * * @name Schema.validationKeywords.minLength * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ minLength (value, schema, opts) { return minLengthCommon('minLength', value, schema, opts) }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor57 * * @name Schema.validationKeywords.minProperties * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ minProperties (value, schema, opts) { const minProperties = schema.minProperties const length = Object.keys(value).length if (length < minProperties) { return makeError(length, `no more than ${minProperties} properties`, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor14 * * @name Schema.validationKeywords.multipleOf * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ multipleOf (value, schema, opts) { // TODO }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor91 * * @name Schema.validationKeywords.not * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ not (value, schema, opts) { if (!validate(value, schema.not, opts)) { // TODO: better messaging return makeError('succeeded', 'should have failed', opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor88 * * @name Schema.validationKeywords.oneOf * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ oneOf (value, schema, opts) { let validated = false let allErrors = [] schema.oneOf.forEach(function (_schema) { const errors = validate(value, _schema, opts) if (errors) { allErrors = allErrors.concat(errors) } else if (validated) { allErrors = [makeError('valid against more than one', 'valid against only one', opts)] validated = false return false } else { validated = true } }) return validated ? undefined : allErrors }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor33 * * @name Schema.validationKeywords.pattern * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ pattern (value, schema, opts) { const pattern = schema.pattern if (utils.isString(value) && !value.match(pattern)) { return makeError(value, pattern, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor64 * * @name Schema.validationKeywords.properties * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ properties (value, schema, opts) { opts || (opts = {}) // Can be a boolean or an object // Technically the default is an "empty schema", but here "true" is // functionally the same const additionalProperties = utils.isUndefined(schema.additionalProperties) ? true : schema.additionalProperties // "s": The property set of the instance to validate. const toValidate = {} // "p": The property set from "properties". // Default is an object const properties = schema.properties || {} // "pp": The property set from "patternProperties". // Default is an object const patternProperties = schema.patternProperties || {} let errors = [] // Collect set "s" utils.forOwn(value, function (_value, prop) { toValidate[prop] = undefined }) // Remove from "s" all elements of "p", if any. utils.forOwn(properties || {}, function (_schema, prop) { if (utils.isUndefined(value[prop]) && !utils.isUndefined(_schema['default'])) { value[prop] = utils.copy(_schema['default']) } opts.prop = prop errors = errors.concat(validate(value[prop], _schema, opts) || []) delete toValidate[prop] }) // For each regex in "pp", remove all elements of "s" which this regex // matches. utils.forOwn(patternProperties, function (_schema, pattern) { utils.forOwn(toValidate, function (undef, prop) { if (prop.match(pattern)) { opts.prop = prop errors = errors.concat(validate(value[prop], _schema, opts) || []) delete toValidate[prop] } }) }) const keys = Object.keys(toValidate) // If "s" is not empty, validation fails if (additionalProperties === false) { if (keys.length) { addError(`extra fields: ${keys.join(', ')}`, 'no extra fields', opts, errors) } } else if (utils.isObject(additionalProperties)) { // Otherwise, validate according to provided schema keys.forEach(function (prop) { opts.prop = prop errors = errors.concat(validate(value[prop], additionalProperties, opts) || []) }) } return errors.length ? errors : undefined }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor61 * * @name Schema.validationKeywords.required * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ required (value, schema, opts) { const required = schema.required let errors = [] if (!opts.existingOnly) { required.forEach(function (prop) { if (utils.isUndefined(utils.get(value, prop))) { const prevProp = opts.prop opts.prop = prop addError(undefined, 'a value', opts, errors) opts.prop = prevProp } }) } return errors.length ? errors : undefined }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor79 * * @name Schema.validationKeywords.type * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ type (value, schema, opts) { let type = schema.type let validType // Can be one of several types if (utils.isString(type)) { type = [type] } // Try to match the value against an expected type type.forEach(function (_type) { // TODO: throw an error if type is not defined if (types[_type](value, schema, opts)) { // Matched a type validType = _type return false } }) // Value did not match any expected type if (!validType) { return makeError(value ? typeof value : '' + value, `one of (${type.join(', ')})`, opts) } // Run keyword validators for matched type // http://json-schema.org/latest/json-schema-validation.html#anchor12 const validator = typeGroupValidators[validType] if (validator) { return validator(value, schema, opts) } }, /** * http://json-schema.org/latest/json-schema-validation.html#anchor49 * * @name Schema.validationKeywords.uniqueItems * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ uniqueItems (value, schema, opts) { if (value && value.length && schema.uniqueItems) { const length = value.length let item, i, j // Check n - 1 items for (i = length - 1; i > 0; i--) { item = value[i] // Only compare against unchecked items for (j = i - 1; j >= 0; j--) { // Found a duplicate if (item === value[j]) { return makeError(item, 'no duplicates', opts) } } } } } } /** * @ignore */ const validateKeyword = function (op, value, schema, opts) { return !utils.isUndefined(schema[op]) && validationKeywords[op](value, schema, opts) } /** * @ignore */ const runOps = function (ops, value, schema, opts) { let errors = [] ops.forEach(function (op) { errors = errors.concat(validateKeyword(op, value, schema, opts) || []) }) return errors.length ? errors : undefined } const ANY_OPS = ['enum', 'type', 'allOf', 'anyOf', 'oneOf', 'not'] const ARRAY_OPS = ['items', 'maxItems', 'minItems', 'uniqueItems'] const NUMERIC_OPS = ['multipleOf', 'maximum', 'minimum'] const OBJECT_OPS = ['maxProperties', 'minProperties', 'required', 'properties', 'dependencies'] const STRING_OPS = ['maxLength', 'minLength', 'pattern'] /** * http://json-schema.org/latest/json-schema-validation.html#anchor75 * @ignore */ const validateAny = function (value, schema, opts) { return runOps(ANY_OPS, value, schema, opts) } /** * TODO * * @name Schema.validate * @method * @param {*} value TODO * @param {Object} [schema] TODO * @param {Object} [opts] Configuration options. */ const validate = function (value, schema, opts) { let errors = [] opts || (opts = {}) let shouldPop let prevProp = opts.prop if (utils.isUndefined(schema)) { return } if (!utils.isObject(schema)) { throw utils.err(`${DOMAIN}#validate`)(500, `Invalid schema at path: "${opts.path}"`) } if (utils.isUndefined(opts.path)) { opts.path = [] } // Track our location as we recurse if (!utils.isUndefined(opts.prop)) { shouldPop = true opts.path.push(opts.prop) opts.prop = undefined } // Validate against parent schema if (schema['extends']) { // opts.path = path // opts.prop = prop if (utils.isFunction(schema['extends'].validate)) { errors = errors.concat(schema['extends'].validate(value, opts) || []) } else { errors = errors.concat(validate(value, schema['extends'], opts) || []) } } if (utils.isUndefined(value)) { // Check if property is required if (schema.required === true) { addError(value, 'a value', opts, errors) } if (shouldPop) { opts.path.pop() opts.prop = prevProp } return errors.length ? errors : undefined } errors = errors.concat(validateAny(value, schema, opts) || []) if (shouldPop) { opts.path.pop() opts.prop = prevProp } return errors.length ? errors : undefined } // These strings are cached for optimal performance of the change detection // boolean - Whether a Record is changing in the current execution frame const changingPath = 'changing' // string[] - Properties that have changed in the current execution frame const changedPath = 'changed' // boolean - Whether a Record is currently being instantiated const creatingPath = 'creating' // number - The setTimeout change event id of a Record, if any const eventIdPath = 'eventId' // boolean - Whether to skip validation for a Record's currently changing property const noValidatePath = 'noValidate' // boolean - Whether to skip change notification for a Record's currently // changing property const silentPath = 'silent' const validationFailureMsg = 'validation failed' /** * Assemble a property descriptor which will be added to the prototype of * {@link Mapper#recordClass}. This method is called when * {@link Mapper#applySchema} is set to `true`. * * TODO: Make this more configurable, i.e. not so tied to the Record class. * * @ignore */ const makeDescriptor = function (prop, schema, opts) { const descriptor = { // These properties are enumerable by default, but regardless of their // enumerability, they won't be "own" properties of individual records enumerable: utils.isUndefined(schema.enumerable) ? true : !!schema.enumerable } // Cache a few strings for optimal performance const keyPath = `props.${prop}` const previousPath = `previous.${prop}` const getter = opts.getter const setter = opts.setter const unsetter = opts.unsetter descriptor.get = function () { return this._get(keyPath) } descriptor.set = function (value) { const self = this // These are accessed a lot const _get = self[getter] const _set = self[setter] const _unset = self[unsetter] // Optionally check that the new value passes validation if (!_get(noValidatePath)) { const errors = schema.validate(value) if (errors) { // Immediately throw an error, preventing the record from getting into // an invalid state const error = new Error(validationFailureMsg) error.errors = errors throw error } } // TODO: Make it so tracking can be turned on for all properties instead of // only per-property if (schema.track && !_get(creatingPath)) { const previous = _get(previousPath) const current = _get(keyPath) let changing = _get(changingPath) let changed = _get(changedPath) if (!changing) { // Track properties that are changing in the current event loop changed = [] } // Add changing properties to this array once at most const index = changed.indexOf(prop) if (current !== value && index === -1) { changed.push(prop) } if (previous === value) { if (index >= 0) { changed.splice(index, 1) } } // No changes in current event loop if (!changed.length) { changing = false _unset(changingPath) _unset(changedPath) // Cancel pending change event if (_get(eventIdPath)) { clearTimeout(_get(eventIdPath)) _unset(eventIdPath) } } // Changes detected in current event loop if (!changing && changed.length) { _set(changedPath, changed) _set(changingPath, true) // Saving the timeout id allows us to batch all changes in the same // event loop into a single "change" // TODO: Optimize _set(eventIdPath, setTimeout(() => { // Previous event loop where changes were gathered has ended, so // notify any listeners of those changes and prepare for any new // changes _unset(changedPath) _unset(eventIdPath) _unset(changingPath) // TODO: Optimize if (!_get(silentPath)) { let i for (i = 0; i < changed.length; i++) { self.emit('change:' + changed[i], self, utils.get(self, changed[i])) } self.emit('change', self, self.changes()) } _unset(silentPath) }, 0)) } } _set(keyPath, value) return value } return descriptor } /** * TODO * * @name Schema.typeGroupValidators * @type {Object} */ const typeGroupValidators = { /** * TODO * * @name Schema.typeGroupValidators.array * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ array: function (value, schema, opts) { return runOps(ARRAY_OPS, value, schema, opts) }, /** * TODO * * @name Schema.typeGroupValidators.integer * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ integer: function (value, schema, opts) { // Additional validations for numerics are the same return typeGroupValidators.numeric(value, schema, opts) }, /** * TODO * * @name Schema.typeGroupValidators.number * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ number: function (value, schema, opts) { // Additional validations for numerics are the same return typeGroupValidators.numeric(value, schema, opts) }, /** * TODO * * See http://json-schema.org/latest/json-schema-validation.html#anchor13. * * @name Schema.typeGroupValidators.numeric * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ numeric: function (value, schema, opts) { return runOps(NUMERIC_OPS, value, schema, opts) }, /** * TODO * * See http://json-schema.org/latest/json-schema-validation.html#anchor53. * * @name Schema.typeGroupValidators.object * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ object: function (value, schema, opts) { return runOps(OBJECT_OPS, value, schema, opts) }, /** * TODO * * See http://json-schema.org/latest/json-schema-validation.html#anchor25. * * @name Schema.typeGroupValidators.string * @method * @param {*} value TODO * @param {Object} schema TODO * @param {Object} opts TODO */ string: function (value, schema, opts) { return runOps(STRING_OPS, value, schema, opts) } } /** * js-data's Schema class. * * ```javascript * import {Schema} from 'js-data' * ``` * * @class Schema * @extends Component * @param {Object} definition Schema definition according to json-schema.org */ export default Component.extend({ constructor: function Schema (definition) { // const self = this definition || (definition = {}) // TODO: schema validation utils.fillIn(this, definition) // TODO: rework this to make sure all possible keywords are converted if (definition.properties) { utils.forOwn(definition.properties, function (_definition, prop) { if (!(_definition instanceof Schema)) { definition.properties[prop] = new Schema(_definition) } }) } }, /** * This adds ES5 getters/setters to the target based on the "properties" in * this Schema, which makes possible change tracking and validation on * property assignment. * * @name Schema#validate * @method * @param {Object} target The prototype to which to apply this schema. */ apply (target, opts) { opts || (opts = {}) opts.getter = opts.getter || '_get' opts.setter = opts.setter || '_set' opts.unsetter = opts.unsetter || '_unset' const properties = this.properties || {} utils.forOwn(properties, function (schema, prop) { Object.defineProperty( target, prop, makeDescriptor(prop, schema, opts) ) }) }, /** * Validate the provided value against this schema. * * @name Schema#validate * @method * @param {*} value Value to validate. * @param {Object} [opts] Configuration options. * @return {(array|undefined)} Array of errors or `undefined` if valid. */ validate (value, opts) { return validate(value, this, opts) } }, { typeGroupValidators, types, validate, validationKeywords })