diff --git a/lib/bundler.rb b/lib/bundler.rb index 154d762..0397139 100644 --- a/lib/bundler.rb +++ b/lib/bundler.rb @@ -37,6 +37,7 @@ def files model_log model_rest model_uid + model_validations model_version ) end diff --git a/src/model.js b/src/model.js index c0db8d6..3fbf0e0 100644 --- a/src/model.js +++ b/src/model.js @@ -11,9 +11,11 @@ var Model = function(name, class_methods, instance_methods) { if (jQuery.isFunction(this.initialize)) this.initialize() }; - // Persistence is special, remove it from class_methods. + // Persistence & validations are special, remove them from class_methods. var persistence = class_methods.persistence + var validation_rules = class_methods.validates; delete class_methods.persistence + delete class_methods.validates; // Apply class methods and extend with any custom class methods. Make sure // vitals are added last so they can't be overridden. @@ -30,8 +32,13 @@ var Model = function(name, class_methods, instance_methods) { // Initialise persistence with a reference to the class. if (persistence) model.persistence = persistence(model) + // Initialise a validator object for this class. + if (validation_rules) { + model.validator = Model.Validator(validation_rules); + }; + // Add default and custom instance methods. - jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, + jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, Model.Validations, instance_methods); return model; diff --git a/src/model_class_methods.js b/src/model_class_methods.js index 633b0a4..aef9024 100644 --- a/src/model_class_methods.js +++ b/src/model_class_methods.js @@ -24,6 +24,10 @@ Model.ClassMethods = { return this.collection; }, + any: function () { + return this.count() > 0; + }, + count: function() { return this.collection.length; }, diff --git a/src/model_instance_methods.js b/src/model_instance_methods.js index 409ad36..79c40cf 100644 --- a/src/model_instance_methods.js +++ b/src/model_instance_methods.js @@ -110,6 +110,9 @@ Model.InstanceMethods = { valid: function() { this.errors.clear(); this.validate(); + if (this.constructor.validator) { + this.constructor.validator.run.call(this); + }; return this.errors.size() === 0; }, diff --git a/src/model_validations.js b/src/model_validations.js new file mode 100644 index 0000000..7b422f2 --- /dev/null +++ b/src/model_validations.js @@ -0,0 +1,123 @@ +Model.Validator = function (rules) { + var rules = rules; + + // http://dl.dropbox.com/u/35146/js/tests/isNumber.html + var isNumeric = function (n) { + return !isNaN(parseFloat(n)) && isFinite(n); + }; + + var defaultOptions = { + "condition": function () { return true; } + }; + + var validationMethods = { + + exclusionOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (options['in'].indexOf(attrValue) !== -1) { + this.errors.add(attrName, options.message || "should not be one of " + options['in'].join(', ')) + }; + }; + }, + + inclusionOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (options['in'].indexOf(attrValue) === -1) { + this.errors.add(attrName, options.message || "should be one of " + options['in'].join(', ')) + }; + }; + }, + + presenceOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { + this.errors.add(attrName, options.message || "should not be blank"); + }; + }; + }, + + // numeric strings will pass validation by default, i.e. "1" + numericalityOf: function (attrName, attrValue, options) { + var self = this; + var addError = function () { self.errors.add(attrName, options.message || "should be numeric"); }; + + if (options.condition.call(this)) { + if (options.allowNumericStrings) { + if (!isNumeric(attrValue)) { + addError(); + }; + } else if (typeof(attrValue) != "number" || isNaN(attrValue)) { + addError(); + }; + }; + }, + + lengthOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (attrValue.length < options.min || attrValue.length > options.max) { + this.errors.add(attrName, options.message || "is too short or too long"); + }; + }; + }, + + formatOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (!options["with"].test(attrValue)) { + this.errors.add(attrName, options.message || "is the wrong format"); + }; + }; + }, + + uniquenessOf: function (attrName, attrValue, options) { + var instanceToValidat = this + if (options.condition.call(this)) { + if (this.constructor + .select(function () { + return this !== instanceToValidat; + }) + .select(function () { + return this.attr(attrName) == attrValue; + }) + .any()) { + this.errors.add(attrName, options.message || "should be unique"); + }; + }; + } + }; + + var ruleWithoutOptions = function (ruleName, ruleValue) { + for (var i=0; i < ruleValue.length; i++) { + validationMethods[ruleName].call( + this, + ruleValue[i], + this.attr(ruleValue[i]), + defaultOptions + ); + }; + }; + + var ruleWithOptions = function (ruleName, ruleValue) { + for (attributeName in ruleValue) { + validationMethods[ruleName].call( + this, + attributeName, + this.attr(attributeName), + $.extend({}, defaultOptions, ruleValue[attributeName]) + ); + }; + }; + + var run = function () { + for (rule in rules) { + if ($.isArray(rules[rule])) { + ruleWithoutOptions.call(this, rule, rules[rule]); + } else { + ruleWithOptions.call(this, rule, rules[rule]); + }; + }; + }; + + return { + run: run + }; +}; \ No newline at end of file diff --git a/test/tests/model_class_methods.js b/test/tests/model_class_methods.js index b8c3b9b..2732b6e 100644 --- a/test/tests/model_class_methods.js +++ b/test/tests/model_class_methods.js @@ -66,15 +66,19 @@ test("maintaining a collection of unique models by object, id and uid", function equals(Post.count(), 2) }) -test("detect, select, first, last, count (with chaining)", function() { +test("detect, select, first, last, count, any (with chaining)", function() { var Post = Model('post'); + ok(!Post.any(), "there shouldn't be any posts when none have been added to the colleciton") + var post1 = new Post({ id: 1, title: "Foo" }); var post2 = new Post({ id: 2, title: "Bar" }); var post3 = new Post({ id: 3, title: "Bar" }); Post.add(post1, post2, post3); + ok(Post.any(), "there should be some posts once the instances have been added to the collection") + var indexes = []; equals(Post.detect(function(i) { diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js new file mode 100644 index 0000000..fd8d32e --- /dev/null +++ b/test/tests/model_validations.js @@ -0,0 +1,195 @@ +module("Model.Validations"); + +test("validatesPresenceOf", function () { + + var Post = Model("post", { + validates: { + presenceOf: ['title'] + } + }); + + var validPost = new Post ({ title: "Foo", body: "..." }); + var invalidPost_noTitle = new Post ({body: "..."}); + var invalidPost_blankTitle = new Post ({title: "", body: "..."}); + + ok(validPost.valid(), "should be valid with a title"); + ok(!invalidPost_noTitle.valid(), "should be invalid without a title attribute"); + ok(!invalidPost_blankTitle.valid(), "should be invalid with a blank title attribute"); +}); + +test("validatesNumericalityOf without options", function () { + var Post = Model("post", { + validates: { + numericalityOf: ['views'] + } + }); + + var validPost = new Post({ views: 10 }); + var validPost_stringReprOfNumber = new Post ({ views: "10"}); + var invalidPost_notNumeric = new Post({ views: 'not numeric' }); + var invalidPost_missingAttribute = new Post({ notViews: 1}); + + ok(validPost.valid(), "should be valid with a numeric attribute"); + ok(!validPost_stringReprOfNumber.valid(), "should not be valid with a string representation of a number, by default"); + ok(!invalidPost_notNumeric.valid(), "should not be valid with a string value"); + ok(!invalidPost_missingAttribute.valid(), "should not be valid if the attr is missing, by default"); +}); + +test("validatesNumericalityOf with options", function () { + + var Post = Model("post", { + validates: { + numericalityOf: { + 'views': { + allowNumericStrings: true, + message: "custom message" + } + } + } + }); + + var validPost_stringReprOfNumber = new Post ({ views: "10"}); + var invalidPost = new Post({views: "blah"}) + + ok(validPost_stringReprOfNumber.valid(), "should be valid with a string representation of a number"); + ok(!invalidPost.valid()) + equal(invalidPost.errors.errors.views[0], "custom message", "should use a custom validation message"); +}); + +test("validatesLengthOf", function () { + var Post = Model("post", { + validates: { + lengthOf: { + 'title': { + min: 5, + max: 10, + message: "custom message" + } + } + } + }); + + var validPost = new Post ({ title: "just right", body: "..." }); + var invalidPost_tooShort = new Post ({ title: "1", body: "..." }); + var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); + + ok(validPost.valid(), "should be valid with the right length title"); + ok(!invalidPost_tooShort.valid(), "should be invalid with a title that is too short"); + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long"); + + equal(invalidPost_tooLong.errors.errors.title[0], "custom message", "should use a custom validation message"); +}); + +test("validatesLengthOf with default options", function () { + var Post = Model("post", { + validates: { + lengthOf: { + 'title': { max: 10 } + } + } + }); + + var validPost = new Post ({ title: "just right", body: "..." }); + var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); + + ok(validPost.valid(), "should be valid with the right length title"); + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long"); +}); + +test("validatesUniqunessOf", function () { + var Post = Model("post", { + validates: { + uniquenessOf: ['title'] + } + }); + + Post.add(new Post({title: "foo"})); + + var validPost = new Post({title: "bar"}); + var invalidPost = new Post({title: "foo"}); + + ok(validPost.valid(), "should be valid with a unique title"); + ok(!invalidPost.valid(), "should be invalid with a duplicate title"); + + Post.add(validPost) + + ok(validPost.valid(), "should still be valid when added to the colleciton") +}); + +test("validatesUniquenessOf with options", function () { + var Post = Model("post", { + validates: { + uniquenessOf: { + 'title': { + message: "custom message", + condition: function () { + return this.attr('doValidation'); + } + } + } + } + }); + + Post.add(new Post({title: "foo"})); + + var invalidPost = new Post({title: "foo", doValidation: true}); + var validPost = new Post({title: "foo", doValidation: false}); + + ok(!invalidPost.valid()); + ok(validPost.valid(), "should only validate if the if condition is true"); + equal(invalidPost.errors.errors.title[0], "custom message", "should use a custom validation message"); +}); + +test("validatesFormatOf", function () { + var Post = Model("post", { + validates: { + formatOf: { + 'title': { + 'with': /^f/ + } + } + } + }); + + var validPost = new Post({ title: "foo" }); + var invalidPost = new Post({ title: "boo" }); + + ok(validPost.valid(), "should be valid when the regex matches the string"); + ok(!invalidPost.valid(), "should be invalid when the regex doesn't match the string"); +}) + +test("validatesInclusionOf", function () { + var Post = Model("post", { + validates: { + inclusionOf: { + 'tags': { + 'in': ['awesome', 'life changing'] + } + } + } + }); + + var validPost = new Post({ tags: 'awesome' }); + var invalidPost = new Post({ tags: 'boring' }); + + ok(validPost.valid(), "should be valid when the attribute value is in the supplied list"); + ok(!invalidPost.valid(), "should be invalid when the attribute value is not in the supplied list"); +}) + +test("validatesExclusionOf", function () { + var Post = Model("post", { + validates: { + exclusionOf: { + 'name': { + 'in': ['bob', 'tom'] + } + } + } + }); + + var validPost = new Post({ name: 'xavier' }); + var invalidPost = new Post({ name: 'bob' }); + + ok(validPost.valid(), "should be valid when the attribute value is not in the supplied list"); + ok(!invalidPost.valid(), "should be invalid when the attribute value is in the supplied list"); +}) \ No newline at end of file diff --git a/test/views/index.erb b/test/views/index.erb index b19f27c..45f2c91 100644 --- a/test/views/index.erb +++ b/test/views/index.erb @@ -24,6 +24,7 @@ +