Skip to content

Commit

Permalink
Improve synchronous validation
Browse files Browse the repository at this point in the history
Provide intermediate result on returned promise
  • Loading branch information
jhnns committed May 11, 2016
1 parent 93c50ed commit bd4bf32
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 51 deletions.
19 changes: 15 additions & 4 deletions README.md
Expand Up @@ -272,13 +272,24 @@ _Note:_ validators will be called with `this` bound to `model`.

### Promises

Instead of using a callback it is possible to return a Promise instead.
The `validate()` method also returns a promise:

```javascript
PandaSchema.validate(panda)
.then(function (res) {
// ...
});
.then(function (validation) {
...
})
```

The promise provides a reference to the final validation result object. It contains the intermediate
result of all synchronous validators:

```javascript
var promise = PandaSchema.validate(panda);

if (promise.validation.result) {
console.log("Synchronous validation of " + Object.keys(validation.errors) + " failed");
}
```

__Important notice:__ You must bring your own ES6 Promise compatible polyfill!
Expand Down
104 changes: 61 additions & 43 deletions plugins/validation/index.js
Expand Up @@ -6,44 +6,53 @@ var defaultValidators = require("./validators.js");
/**
* Runs the given validators on a single field.
*
* Caution: callback will be called in synchronously in some situations. This behavior should usually be avoided in
* public APIs, but since runValidation() is internal, we know how to deal with it. It allows us to speed up
* validation and to return all synchronous validation results as soon as possible.
*
* The final callback, however, is guaranteed to be asynchronous.
*
* @param {Array} validators
* @param {*} field
* @param {Object} context
* @param {Function} callback
* @returns {Array}
*/
function runValidation(validators, field, context, callback) {
var result = [];
var pending = validators.length;
var fieldErrors = [];
var pending = 0;

// Return immediately if the field has no validator defined
if (pending === 0) {
setTimeout(function () {
return callback(result);
}, 0);
function saveResult(result) {
if (result !== true) {
fieldErrors.push(result);
}
}

function validationDone(res) {
pending--;

if (res !== true) {
result.push(res);
}
function doCallback() {
callback(fieldErrors);
}

if (pending === 0) {
callback(result);
return;
}
function asyncValidationDone(result) {
saveResult(result);
pending--;
pending === 0 && doCallback();
}

validators.forEach(function (validator) {
if (validator.length === 2) {
validator.call(context, field, validationDone);
pending++;
validator.call(context, field, asyncValidationDone);
} else {
setTimeout(function () {
validationDone(validator.call(context, field));
}, 0);
saveResult(validator.call(context, field));
}
});

if (pending === 0) {
// synchronous callback
doCallback();
}

return fieldErrors;
}

/**
Expand Down Expand Up @@ -111,7 +120,8 @@ function validationPlugin(Schema) {
};

/**
* Validate if given model matches schema-definition.
* Validate if given model matches schema definition. Returns a promise with the validation result object
* which contains the intermediate result of all synchronous validators.
*
* @param {Object} model
* @param {Function=} callback
Expand All @@ -127,6 +137,17 @@ function validationPlugin(Schema) {
};
var promise;

function handleFieldErrors(key, fieldErrors) {
if (fieldErrors.length > 0) {
result.result = false;
result.errors[key] = fieldErrors;
}
}

function doCallback() {
callback(result);
}

if (value(model).notTypeOf(Object)) {
throw new TypeError("Model must be an object");
}
Expand All @@ -136,38 +157,35 @@ function validationPlugin(Schema) {
}

promise = new Promise(function (resolve, reject) {
function done() {
if (typeof callback === "function") {
setTimeout(doCallback, 0);
}
result.result ? resolve(result) : reject(result);
}

if (self.keys.length === 0) {
setTimeout(function () {
resolve(result);
}, 0);
done();
return;
}

pending = self.keys.length;

self.keys.forEach(function (key) {
pending++;
runValidation(self.validators[key], model[key], model, function (errors) {
var fieldErrors = runValidation(self.validators[key], model[key], model, function onFieldValidation(fieldErrors) {
pending--;
handleFieldErrors(key, fieldErrors);

if (errors.length > 0) {
result.result = false;
result.errors[key] = errors;
}

// was final call
if (pending === 0) {
if (result.result === false) {
reject(result);
return;
}

resolve(result);
done();
}
});

handleFieldErrors(key, fieldErrors);
});
});

if (typeof callback === "function") {
promise.then(callback, callback);
}
// Attach intermediate result to promise
promise.validation = result;

return promise;
};
Expand Down
62 changes: 58 additions & 4 deletions test/validation.test.js
Expand Up @@ -286,12 +286,10 @@ describe("plugins/validation", function () {
});

it("should return a promise if no callback is given", function () {

expect(schema.validate({ age: 2 })).to.be.a("promise");
});

it("should resolve if validation succeeds", function () {

schema = new Schema({
age: {
type: Number,
Expand All @@ -306,7 +304,6 @@ describe("plugins/validation", function () {
});

it("should reject if validation fails", function () {

schema = new Schema({
age: {
type: Number,
Expand All @@ -322,6 +319,41 @@ describe("plugins/validation", function () {
expect(validation).to.eql({ result: false, model: { age: 1 }, errors: { age: ["min"] } });
});
});

it("should provide the intermediate result of all synchronous validators", function () {
var intermediateResult;

schema = new Schema({
age: {
type: Number,
min: 5,
validate: [
function syncTrue() {
return true;
},
function asyncTrue(value, callback) {
setTimeout(function () {
callback(true);
}, 0);
},
function syncFail() {
return "sync-fail";
},
function asyncFail(value, callback) {
setTimeout(function () {
callback("async-fail");
}, 0);
}
]
}
});

intermediateResult = schema.validate({ age: 1 }).validation;

expect(intermediateResult.result).to.equal(false);
expect(intermediateResult.errors.age).to.eql(["min", "sync-fail"]);
});

});

describe("mixed validators", function () {
Expand Down Expand Up @@ -400,9 +432,31 @@ describe("plugins/validation", function () {
});
});

it("should degrade gracefully with an false async validator", function (done) {
var falseAsyncSpy = chai.spy(function (age, callback) {
callback("fail-false-async"); // callback is called synchronously. This is a common error.
});

schema = new Schema({
age: {
type: Number,
validate: falseAsyncSpy
}
});

schema.validate({ age: 8 }, function (validation) {
expect(falseAsyncSpy).to.have.been.called.once();
expect(validation.result).to.equal(false);
expect(validation.errors.age).to.contain("fail-false-async");
done();
});
});

it("should fail if the sync validator fails & async passes", function (done) {
var asyncSpy = chai.spy(function (age, callback) {
callback(true);
setTimeout(function () {
callback("fail-async");
}, 0);
});
var syncSpy = chai.spy(function (age) {
return "fail-sync";
Expand Down

0 comments on commit bd4bf32

Please sign in to comment.