Last active
August 29, 2015 14:13
-
-
Save alloy-d/d929c5023b82660fc9e1 to your computer and use it in GitHub Desktop.
a less-awful validation framework.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ## Goals | |
// | |
// We want a validation framework with the following attributes: | |
// | |
// - Data validations can be specified alongside the data model. | |
// - Validations match the specificity of the data they validate | |
// (i.e., if there's a problem with `model.items[1]`, the validation | |
// fails for `model.items[1]`, *not* `model.items`). | |
// - Validations can be serialized with the data they validate. | |
// - Validations can be applied piecemeal: validating `model.thingy` | |
// can be done without doing a full validation of `model`. | |
// - Validations compose easily: `model` is valid if it passes its | |
// own validations *and* `model.thingy` passes its validations. | |
// - Validations don't need to be aware of their enclosing scope: | |
// `model.thingy` can be validated without knowing about `model`. | |
// ## Validator functions | |
// Let's define some quick validator functions. | |
// | |
// A validator function takes a value to be validated | |
// and returns either `VALID` or an error describing | |
// why the value is invalid. | |
var VALID = false; | |
var Validators = { | |
lowercaseWithDashes: function (value) { | |
if (value.match(/[^-a-z]/)) { | |
return new Error("should contain only dashes and lowercase letters"); | |
} | |
return VALID; | |
}, | |
lengthLessThan: function (cap) { | |
return function (value) { | |
if (value.length >= cap) { | |
return new Error("should have fewer than " + cap + " things"); | |
} | |
return VALID; | |
}; | |
}, | |
present: function (value) { | |
if (!value) { | |
return new Error("must be present"); | |
} | |
return VALID; | |
} | |
}; | |
// ## Motivation | |
// We have a model: | |
var Identifier = function(identifier) { | |
return identifier; | |
}; | |
// Let's say we want an Identifier to be non-blank and | |
// composed of lowercase words separated by dashes. | |
// That sounds like an array of validation functions: | |
var IdentifierSpec = [Validators.present, Validators.lowercaseWithDashes]; | |
// Ideally we then have an `applyValidation` function | |
// that takes a model and its validation spec and returns | |
// something useful: | |
// | |
// applyValidation(Identifier("this-is-valid"), IdentifierSpec) | |
// // => VALID | |
// applyValidation(Identifier("This is invalid."), IdentifierSpec) | |
// // => [Error("identifier does not match spec.")] | |
// --- | |
// Now let's say we have a more complex model. | |
var Pair = function(first, second) { | |
return { | |
first: first, | |
second: second | |
}; | |
}; | |
// We want to validate only the second item in a Pair. It would be fantastic | |
// to represent that as an Object that mirrors the structure of a Pair: | |
var PairSpec = { | |
second: [Validators.present] | |
}; | |
// The validation function, then, would ideally do something like | |
// | |
// applyValidation(Pair("this is", "valid"), PairSpec) | |
// // => {second: VALID} | |
// applyValidation(Pair("this is bad", null), PairSpec) | |
// // => {second: [Error("isn't present")]} | |
// | |
// This is super handy, because | |
// | |
// applyValidation(Pair("bad", null), PairSpec).second | |
// | |
// is the same as | |
// | |
// applyValidation(Pair("bad", null).second, PairSpec.second) | |
// | |
// and, even more handily, assuming we have an `isValid` function | |
// that returns `true` if the result of `applyValidation` represents | |
// a totally valid result: | |
// | |
// isValid(applyValidation(Pair("bad", null), PairSpec).second) | |
// | |
// is the same as | |
// | |
// isValid(applyValidation(Pair("bad", null).second, PairSpec.second)) | |
// --- | |
// This composes really nicely. | |
var composed = { | |
name: Identifier("this-is-a-name"), | |
pair: Pair("part one", "part two") | |
}; | |
var composedSpec = { | |
name: IdentifierSpec, | |
pair: PairSpec | |
}; | |
// But let's say we need to have an array of Pairs, | |
// and we need to validate both the whole array *and* | |
// each Pair within it. | |
// Maybe, for example, we want to make sure that | |
// our model has at most 5 pairs. | |
var model = { | |
name: Identifier("here-are-some-pairs"), | |
pairs: [ | |
Pair("I'm", "Adam."), | |
Pair(null, "this pair has no first."), | |
Pair("This pair has no second:", null) | |
] | |
}; | |
// This gets a little bit uglier. | |
// | |
// `modelSpec.pairs` now has some fake keys: | |
// - `$collection`, which is a spec for the `pairs` field itself, and | |
// - `$each`, which is a spec for each thing within `pairs`. | |
var modelSpec = { | |
name: IdentifierSpec, | |
pairs: { | |
$collection: [Validators.lengthLessThan(5)], | |
$each: PairSpec | |
} | |
}; | |
// In order to preserve symmetry between the model | |
// and the validation's result, we'd need something | |
// like this: | |
// | |
// applyValidation(model, modelSpec).pairs | |
// // => { | |
// // $collection: VALID, | |
// // 0: {second: VALID}, | |
// // 1: {second: VALID}, | |
// // 2: {second: [Error("isn't present")]} | |
// // } | |
// | |
// This isn't *exactly* the same as the input, in that | |
// the result isn't an Array. But it *can* be accessed | |
// in the same way. | |
// | |
// This *does* kill the symmetry between the spec and its | |
// data, since | |
// | |
// model.pairs[1] | |
// | |
// has a value, but | |
// | |
// modelSpec.pairs[1] | |
// | |
// does not. | |
// | |
// However, we can still define `isValid` such that: | |
// | |
// model.pairs[1] | |
// | |
// is valid if | |
// | |
// isValid(applyValidation(model, modelSpec).pairs[1]) | |
// ## Validation results | |
// Now, let's define `isValid`. | |
// For convenience, we'll start with a helper | |
// that returns either `VALID` or `!VALID` | |
// for a given result. | |
function check(validated) { | |
// Each level of the result will be one of the following: | |
// - an object with validated components. | |
// | |
// { | |
// name: [VALID], | |
// pairs: { | |
// $collection: [VALID], | |
// 0: [Error("something was wrong")], | |
// // ... | |
// } | |
// } | |
// | |
// - an array of results from validator functions. | |
// | |
// [VALID, Error("something was wrong")] | |
// | |
var each = Util.map(validated, function (value) { | |
// The trivial case: we're looking at a single | |
// validation result. It passes the check if it's valid. | |
if (value === VALID) { | |
return VALID; | |
// We might also be looking at an array of validation results. | |
// If any of those is invalid, then this whole component is invalid. | |
// Otherwise, this component is valid. | |
} else if (Array.isArray(value)) { | |
if (Util.any(value, function (val) { return val !== VALID; })) { | |
return !VALID; | |
} else { | |
return VALID; | |
} | |
// Finally, we might be looking at another object. | |
// This value is valid if that object is valid. | |
} else if (typeof(value) === "object") { | |
return check(value); | |
} | |
// If none of the above apply, then this isn't valid. | |
return !VALID; | |
}); | |
// And now, if any of this value's components are invalid, | |
// this whole value is invalid. | |
if (Util.any(each, function (val) { return val !== VALID; })) { | |
return !VALID; | |
} | |
// Otherwise, it's valid. | |
return VALID; | |
} | |
// `isValid` is now just a simple wrapper around `check`. | |
function isValid() { | |
return check.apply(null, arguments) === VALID; | |
} | |
// ## Applying validations | |
// `applyValidation` is the function we described above | |
// that takes a model and a spec for that model and returns | |
// a similarly structured object that represents the | |
// validity of the model according to the spec. | |
function applyValidation(model, spec, name) { | |
var composite; | |
name = name || "root"; | |
log("validating " + name); | |
// In the simplest case, there is no spec. | |
// | |
// Anything is valid according to no spec. | |
if (!spec) { | |
return VALID; | |
// If the spec is an array, then it's just an array | |
// of validation functions. We'll return the result | |
// of applying them each to the model. | |
} else if (Array.isArray(spec)) { | |
return spec.map(function (validator) { | |
return validator(model); | |
}); | |
// If the spec is a collection validation spec, | |
// then we need to apply the `$collection` validator to | |
// the collection as a whole *and* the `$each` validator | |
// to each item in the collection. | |
} else if (isCollectionValidationSpec(spec)) { | |
composite = {}; | |
Util.each(model, function (item, key) { | |
composite[key] = applyValidation(item, spec.$each, name + "$each"); | |
}); | |
composite.$collection = applyValidation(model, spec.$collection, name + "$collection"); | |
return composite; | |
// If none of the above apply, then we're looking at | |
// another complex validator object. We just need to recurse. | |
} else { | |
composite = {}; | |
Util.each(model, function (item, key) { | |
composite[key] = applyValidation(item, spec[key], name + "." + key); | |
}); | |
return composite; | |
} | |
} | |
// A collection validation spec has a `$collection` spec | |
// and a spec for each `$item`. | |
function isCollectionValidationSpec(spec) { | |
return spec.hasOwnProperty("$each") && spec.hasOwnProperty("$collection"); | |
} | |
// --- | |
// ## Helpers | |
// `log` is a logging helper for easier debugging in node. | |
var log = function () { | |
var util; | |
if (typeof(require) === "function") { | |
util = require("util"); | |
util.debug.apply(util, arguments); | |
} else { | |
console.log.apply(console, arguments); | |
} | |
}; | |
// `Util` is a fake Underscore. | |
var Util = (function () { | |
var map = function (thingy, func) { | |
if (thingy.map) { | |
return thingy.map(func); | |
} else { | |
return Object.keys(thingy).map(function (key) { | |
return func(thingy[key], key); | |
}); | |
} | |
}; | |
var each = function (thingy, func) { | |
if (thingy.forEach) { | |
thingy.forEach(func); | |
} else { | |
Object.keys(thingy).forEach(function (key) { | |
func(thingy[key], key); | |
}); | |
} | |
}; | |
var any = function (collection, test) { | |
var result = false; | |
each(collection, function (val) { | |
result = result || test(val); | |
}); | |
return result; | |
}; | |
return { | |
map: map, | |
each: each, | |
any: any | |
}; | |
}()); | |
Much of what's already there would allow this: an "array validator" will already work on an object.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Problem: this doesn't allow validations on objects. For instance, there's no way for me to validate that a
thing
has at least one offirst
orsecond
defined.It probably makes sense to do something for objects similar to what is is done with
$item
for arrays.