Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested Schema Validation #47

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
53 changes: 31 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,35 @@ Anchor.prototype.rules = require('./lib/match/rules');
*/

Anchor.prototype.to = function (ruleset, context) {
"use strict";

var errors = [];
var errors = [], self = this;

// If ruleset doesn't contain any explicit rule keys,
// assume that this is a type

if (util.isPlainObject(ruleset) || util.isArray(ruleset)) {

if (util.has(ruleset, 'type') && util.keys(ruleset).length == 1) {
// backward compatible for anchor(data).to({ type: typeDef })
errors = errors.concat(Anchor.match.type.call(context, self.data, ruleset['type']));
} else if (util.difference(util.keys(ruleset), util.keys(this.rules)).length === 0) {
// backward compatible for anchor(data).to({ ruleName: ruleArgs })
// Look for explicit rules
util.forOwn(ruleset, function (args, ruleName) {
// Validate a non-type rule
errors = errors.concat(Anchor.match.rule.call(context, self.data, ruleName, args));
});
} else {
// Use deep match to descend into the collection and verify each item and/or key
// Stop at default maxDepth (50) to prevent infinite loops in self-associations
errors = errors.concat(Anchor.match.schema.call(context, self.data, ruleset));
}
} else {
errors = errors.concat(Anchor.match.rule.call(context, self.data, ruleset.toString(), ruleset));
}

// Look for explicit rules
for (var rule in ruleset) {

if (rule === 'type') {

// Use deep match to descend into the collection and verify each item and/or key
// Stop at default maxDepth (50) to prevent infinite loops in self-associations
errors = errors.concat(Anchor.match.type.call(context, this.data, ruleset['type']));
}

// Validate a non-type rule
else {
errors = errors.concat(Anchor.match.rule.call(context, this.data, rule, ruleset[rule]));
}
}

// If errors exist, return the list of them
if (errors.length) {
Expand Down Expand Up @@ -186,19 +193,21 @@ Anchor.prototype.defaults = function (ruleset) {
*/

Anchor.prototype.define = function (name, definition) {
"use strict";

// check to see if we have an dictionary
if ( util.isObject(name) ) {
if ( util.isPlainObject(name) && definition === undefined ) {
definition = name;

// if so all the attributes should be validation functions
for (var attr in name){
if(!util.isFunction(name[attr])){
throw new Error('Definition error: \"' + attr + '\" does not have a definition');
}
}
util.forOwn(definition, function (value, attr) {
if(!util.isFunction(value)) {
throw new Error('Definition error: \"' + attr + '\" does not have a definition');
}
});

// add the new custom data types
util.extend(Anchor.prototype.rules, name);
util.extend(Anchor.prototype.rules, definition);

return this;

Expand Down
156 changes: 156 additions & 0 deletions lib/match/deepMatchType.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Module dependencies
*/

var util = require('util');
var _ = require('lodash');
var rules = require('./rules');
var errorFactory = require('./errorFactory');
var matchType = require('./matchType');

// var JSValidationError

// Exposes `matchType` as `deepMatchType`.
module.exports = deepMatchType;


var RESERVED_KEYS = {
$validate: '$validate',
$message: '$message'
};

// Max depth value
var MAX_DEPTH = 50;



/**
* Match a complex collection or model against a schema
*
* @param {?} data
* @param {?} ruleset
* @param {Number} depth
* @param {String} keyName
* @param {String} customMessage
* (optional)
*
* @returns {[]} a list of errors (or an empty list if no errors were found)
*/

function deepMatchType(data, ruleset, depth, keyName, customMessage) {

var self = this;

// Prevent infinite recursion
depth = depth || 0;
if (depth > MAX_DEPTH) {
return [
new Error({ message: 'Exceeded MAX_DEPTH when validating object. Maybe it\'s recursively referencing itself?'})
];
}

// (1) Base case - primitive
// ----------------------------------------------------
// If ruleset is not an object or array, use the provided function to validate
if (!_.isObject(ruleset)) {
return matchType.call(self, data, ruleset, keyName, customMessage);
}


// (2) Recursive case - Array
// ----------------------------------------------------
// If this is a schema rule, check each item in the data collection
else if (_.isArray(ruleset)) {
if (ruleset.length !== 0) {
if (ruleset.length > 1) {
return [
new Error({ message: '[] (or schema) rules must contain exactly one item.'})
];
}

// Handle plurals (arrays with a schema rule)
// Match each object in data array against ruleset until error is detected
return _.reduce(data, function getErrors(errors, datum) {
errors = errors.concat(deepMatchType.call(self, datum, ruleset[0], depth + 1, keyName, customMessage));
return errors;
}, []);
}
// Leaf rules land here and execute the iterator fn
else return matchType.call(self, data, ruleset, keyName, customMessage);
}

// (3) Recursive case - POJO
// ----------------------------------------------------
// If the current rule is an object, check each key
else {

// Note:
//
// We take advantage of a couple of preconditions at this point:
// (a) ruleset must be an Object
// (b) ruleset must NOT be an Array


// *** Check for special reserved keys ***

// { $message: '...' } specified as data type
// uses supplied message instead of the default
var _customMessage = ruleset[RESERVED_KEYS.$message];

// { $validate: {...} } specified as data type
// runs a sub-validation (recursive)
var subValidation = ruleset[RESERVED_KEYS.$validate];

// Don't allow a `$message` without a `$validate`
if (_customMessage) {
if (!subValidation) {
return [{
code: 'E_USAGE',
status: 500,
$message: _customMessage,
property: keyName,
message: 'Custom messages ($message) require a subvalidation - please specify a `$validate` option on `'+keyName+'`'
}];
}
else {
// Use the specified message as the `customMessage`
customMessage = _customMessage;
}
}

// Execute subvalidation rules
if (subValidation) {
if (!subValidation.type) {
return [
new Error({message: 'Sub-validation rules (i.e. using $validate) other than `type` are not currently supported'})
];
}

return deepMatchType.call(self, data, subValidation.type, depth+1, keyName, customMessage);
}





// Don't treat empty object as a ruleset
// Instead, treat it as 'object'
if (_.keys(ruleset).length === 0) {
return matchType.call(self, data, ruleset, keyName, customMessage);
} else {
// Iterate through rules in dictionary until error is detected
return _.reduce(ruleset, function(errors, subRule, key) {

// Prevent throwing when encountering unexpectedly "shallow" data
// (instead- this should be pushed as an error where "undefined" is
// not of the expected type: "object")
if (!_.isObject(data)) {
return errors.concat(errorFactory(data, 'object', key, customMessage));
} else {
return errors.concat(deepMatchType.call(self, data[key], ruleset[key], depth + 1, key, customMessage));
}
}, []);
}
}
}

5 changes: 3 additions & 2 deletions lib/match/errorFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ module.exports = function errorFactory(value, ruleName, keyName, customMessage)
// errMsg += keyName ? '(' + keyName + ') ' : '';
// errMsg += 'is not of type "' + ruleName + '"';

var expectedType = _.isString(ruleName) ? ruleName : Object.prototype.toString.call(ruleName);
errMsg = util.format(
'`%s` should be a %s (instead of "%s", which is a %s)',
keyName, ruleName, value, typeof value
keyName, expectedType, value, typeof value
);
}

Expand All @@ -48,6 +49,6 @@ module.exports = function errorFactory(value, ruleName, keyName, customMessage)
message: errMsg,
rule: ruleName,
actualType: typeof value,
expectedType: ruleName
expectedType: expectedType
}];
};
3 changes: 2 additions & 1 deletion lib/match/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
type: require('./matchType'),
schema: require('./matchSchema'),
type: require('./deepMatchType'),
rule: require('./matchRule')
};
5 changes: 3 additions & 2 deletions lib/match/matchRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var rules = require('./rules');
* or a list of errors if things go wrong
*/

module.exports = function matchRule (data, ruleName, args) {
module.exports = function matchRule (data, ruleName, args, keyName) {
var self = this,
errors = [];

Expand Down Expand Up @@ -45,9 +45,10 @@ module.exports = function matchRule (data, ruleName, args) {
// If outcome is false, an error occurred
if (!outcome) {
return [{
property: keyName,
rule: ruleName,
data: data,
message: util.format('"%s" validation rule failed for input: %s', ruleName, util.inspect(data))
message: util.format('"%s" validation rule failed for value of `%s`: %s', ruleName, keyName, util.inspect(data))
}];
}
else {
Expand Down
55 changes: 55 additions & 0 deletions lib/match/matchRuleset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Module dependencies
*/

var _ = require('lodash')
, util = require('util')
, matchType = require('./matchType')
, matchRule = require('./matchRule')
;


/**
* Match a value against a ruleset.
*
* @param {*} data
* @param {{}} ruleset
* @param {string} keyName
*
* @returns {[]} a list of errors, or an empty list in the absence of them
*/
module.exports = function matchRuleset (data, ruleset, keyName) {
"use strict";

var errors = [], self = this;

if (!_.isPlainObject(ruleset)) {
return [{
property: keyName,
message: util.format('Ruleset definition for property `%s` should be a plain object.', keyName)
}];
}

if (!_.has(ruleset, 'type')) {
ruleset['type'] = {};
}

// if no explicit `required` rule, then treat as optional TODO: What about `null` and empty string same?
if (data === undefined && !ruleset.required) {
return errors;
}

_.forOwn(ruleset, function (value, key) {
"use strict";

if (key === 'type') {
// Validate a type rule
errors = errors.concat(matchType.call(self, data, value, keyName));
} else {
// Validate a non-type rule
errors = errors.concat(matchRule.call(self, data, key, value, keyName));
}
});

return errors;
};
Loading