const { isPlainObject, hasOwnProperty } = require('./helpers');
const typeValidator = (type) => (value) => type === 'any' || typeof value === type;
const instanceValidator = (instance) => (value) => value instanceof instance;
const customValidator = (predicate) => (value) => predicate(value);
const arrayValidator = (validator) => (value) => Array.isArray(value) && value.every((element) => validator(element));
/**
* @typedef SchemaTypes
* @property {'any'} Any
* @property {'any'} Mixed
* @property {'string'} String
* @property {'number'} Number
* @property {'boolean'} Boolean
* @property {'date'} Date
*/
class Schema {
/**
* Creates a Schema instance.
*
* @param {object} schema Plain object to parse as a schema.
*/
constructor(schema) {
this._reservedProperties = [
'__value',
'__optional',
'__validate',
'__skip',
];
this._schema = this._parseSchema(schema);
this._template = schema;
// TODO: Add validation errors to this log.
this._validationErrors = [];
}
/**
* Validate a document.
*
* @param {object} document Document to validate.
* @return {boolean} Whether the document is valid.
*/
validate(document) {
if (!isPlainObject(document)) {
throw new TypeError('Expected parameter `document` to be a plain object');
}
this._validationErrors.length = 0;
return this._applySchema(this._schema, document);
}
/**
* Get the available schema types.
*
* @returns {SchemaTypes} Available schema types.
*/
static get Types() {
return {
Any: 'any',
Mixed: 'any',
String: 'string',
Number: 'number',
Boolean: 'boolean',
Date: 'date',
};
}
/**
* Get the validation errors (if present).
*
* @returns {string[]} Validation errors.
*/
get validationErrors() {
return this._validationErrors;
}
/**
* Parse an object into a validation schema.
*
* @private
* @param {object} schema Plain object to parse as a schema.
* @return {object} Parsed validation schema.
*/
_parseSchema(schema) {
if (!isPlainObject(schema)) {
throw new TypeError('Expected parameter `schema` to be a plain object');
}
return Object.entries(schema).reduce((acc, [key, value]) => {
let isArray = false;
let _schema = {};
if (Array.isArray(value)) {
isArray = true;
value = value.length > 0 ? value[0] : Schema.Types.Any;
}
if (isPlainObject(value) && Object.keys(value).some(
(key) => this._reservedProperties.includes(key))) {
if (hasOwnProperty(value, '__optional')) {
_schema.__optional = !!value.__optional;
}
value = hasOwnProperty(value, '__value') ? value.__value : Schema.Types.Any;
if (Array.isArray(value)) {
isArray = true;
value = value.length > 0 ? value[0] : Schema.Types.Any;
}
}
if (isPlainObject(value)) {
Object.assign(_schema, this._parseSchema(value));
}
_schema.__validate = this._chooseValidator(value, isArray);
if (value === Schema.Types.Any) {
_schema.__skip = true;
}
acc[key] = _schema;
return acc;
}, {});
}
/**
* Internal validation wrapper for recursive calls.
*
* @private
* @param {object} schema Schema used for validation.
* @param {object} obj Plain object to validate.
* @return {boolean} Whether the plain object is valid.
*/
_applySchema(schema, obj) {
const isValid = Object.entries(obj).every(([key, value]) => {
if (key === '__id') {
return true;
}
if (!hasOwnProperty(schema, key)) {
this._addLog(`Property '${key}' does not exist in schema`);
return false;
}
if (!schema[key].__validate(value)) {
this._addLog(`Mismatched type for property '${key}'`);
return false;
}
if (hasOwnProperty(schema[key], '__skip')) {
return true;
}
if (isPlainObject(value)) {
return this._applySchema(schema[key], value);
}
if (Array.isArray(value)) {
// TODO: Find better way to check if some but not all elements are plain objects.
const allPlainObjects = value.every(isPlainObject);
const somePlainObjects = value.some(isPlainObject);
if (somePlainObjects && !allPlainObjects) {
this._addLog(`Some - but not all - elements of property '${key}' are plain objects`);
return false;
}
if (allPlainObjects) {
return value.every((element) => this._applySchema(schema[key], element));
}
}
return true;
});
return isValid && !this._checkDeviations(schema, obj);
}
/**
* Choose a fitting validator based on the value and other parameters.
*
* @private
* @param {string|object} value Value to build the validator for.
* @param {boolean} [isArray=false] Whether `value` is inside an array.
* @return {function} Validation function matched to the parameters.
*/
_chooseValidator(value, isArray = false) {
let validator = () => true;
if (value === Schema.Types.Date) {
validator = instanceValidator(Date);
} else if (isPlainObject(value)) {
validator = customValidator(isPlainObject);
} else {
validator = typeValidator(value);
}
return isArray
? arrayValidator(validator)
: validator;
}
/**
* Check if the schema and an object differ from each other.
*
* @private
* @param {object} schema Schema used for validation.
* @param {object} obj Plain object to validate.
* @returns {boolean} Whether `schema` and `obj` differ from each other.
*/
_checkDeviations(schema, obj) {
return Object.entries(schema).some(([key, value]) => {
if (this._reservedProperties.includes(key)) {
return false;
}
if (hasOwnProperty(value, '__optional') && value.__optional === true) {
return false;
}
if (Array.isArray(obj)) {
if (obj.some((element) => !hasOwnProperty(element, key))) {
this._addLog(`Elements in document are missing property '${key}'`);
return true;
}
return false;
}
if (!hasOwnProperty(obj, key)) {
this._addLog(`Document is missing property '${key}'`);
return true;
}
if (isPlainObject(value)) {
return this._checkDeviations(value, obj[key]);
}
return false;
});
}
_addLog(message) {
this._validationErrors.push(message);
}
/**
* Return the necessary properties for the export.
*
* @private
* @return {object} Necessary schema properties.
*/
_export() {
return this._template;
}
}
module.exports = Schema;