diff --git a/index.js b/index.js index 29a54ca2..be933442 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ -exports.Schema = require('./lib/schema'); -exports.AbstractClass = require('./lib/abstract-class'); -exports.Validatable = require('./lib/validatable'); +exports.Schema = require('./lib/schema').Schema; +exports.AbstractClass = require('./lib/abstract-class').AbstractClass; +exports.Validatable = require('./lib/validatable').Validatable; diff --git a/lib/abstract-class.js b/lib/abstract-class.js index 6df0c02d..62bb9135 100644 --- a/lib/abstract-class.js +++ b/lib/abstract-class.js @@ -1,3 +1,14 @@ +/** + * Module deps + */ +var Validatable = require('./validatable').Validatable; +var util = require('util'); +var jutil = require('./jutil'); + +exports.AbstractClass = AbstractClass; + +jutil.inherits(AbstractClass, Validatable); + /** * Abstract class constructor */ @@ -73,13 +84,20 @@ AbstractClass.create = function (data) { } var obj = null; + // if we come from save if (data instanceof AbstractClass && !data.id) { obj = data; data = obj.toObject(); + } else { + obj = new this(data); + + // validation required + if (!obj.isValid()) { + return callback(new Error('Validation error'), obj); + } } this.schema.adapter.create(modelName, data, function (err, id) { - obj = obj || new this(data); if (id) { defineReadonlyProp(obj, 'id', id); this.cache[id] = obj; @@ -195,6 +213,10 @@ AbstractClass.prototype.save = function (options, callback) { } }; +AbstractClass.prototype.isNewRecord = function () { + return !this.id; +}; + AbstractClass.prototype._adapter = function () { return this.constructor.schema.adapter; }; @@ -227,6 +249,13 @@ AbstractClass.prototype.updateAttribute = function (name, value, cb) { AbstractClass.prototype.updateAttributes = function updateAttributes(data, cb) { var model = this.constructor.modelName; + Object.keys(data).forEach(function (key) { + this[key] = data[key]; + }.bind(this)); + if (!this.isValid()) { + var err = new Error('Validation error'); + return cb && cb(err); + } this._adapter().updateAttributes(model, this.id, data, function (err) { if (!err) { Object.keys(data).forEach(function (key) { @@ -333,15 +362,6 @@ function merge(base, update) { return base; } -function hiddenProperty(where, property, value) { - Object.defineProperty(where, property, { - writable: false, - enumerable: false, - configurable: false, - value: value - }); -} - function defineReadonlyProp(obj, key, value) { Object.defineProperty(obj, key, { writable: false, diff --git a/lib/jutil.js b/lib/jutil.js new file mode 100644 index 00000000..61585dee --- /dev/null +++ b/lib/jutil.js @@ -0,0 +1,9 @@ +exports.inherits = function (newClass, baseClass) { + Object.keys(baseClass).forEach(function (classMethod) { + newClass[classMethod] = baseClass[classMethod]; + }); + Object.keys(baseClass.prototype).forEach(function (instanceMethod) { + newClass.prototype[instanceMethod] = baseClass.prototype[instanceMethod]; + }); +}; + diff --git a/lib/schema.js b/lib/schema.js index e06e5810..f3f5cf59 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -102,7 +102,7 @@ Schema.prototype.define = function defineClass(className, properties, settings) // setup inheritance newClass.__proto__ = AbstractClass; - require('util').inherits(newClass, AbstractClass); + util.inherits(newClass, AbstractClass); // store class in model pool this.models[className] = newClass; @@ -150,3 +150,12 @@ Schema.prototype.defineForeignKey = function defineForeignKey(className, key) { }; +function hiddenProperty(where, property, value) { + Object.defineProperty(where, property, { + writable: false, + enumerable: false, + configurable: false, + value: value + }); +} + diff --git a/lib/validatable.js b/lib/validatable.js index e69de29b..686507c7 100644 --- a/lib/validatable.js +++ b/lib/validatable.js @@ -0,0 +1,192 @@ +exports.Validatable = Validatable; + +function Validatable() { + // validatable class +}; + +Validatable.validatesPresenceOf = getConfigurator('presence'); +Validatable.validatesLengthOf = getConfigurator('length'); +Validatable.validatesNumericalityOf = getConfigurator('numericality'); +Validatable.validatesInclusionOf = getConfigurator('inclusion'); +Validatable.validatesExclusionOf = getConfigurator('exclusion'); + +function getConfigurator(name) { + return function () { + configure(this, name, arguments); + }; +} + +Validatable.prototype.isValid = function () { + var valid = true, inst = this; + if (!this.constructor._validations) { + Object.defineProperty(this, 'errors', { + enumerable: false, + configurable: true, + value: false + }); + return valid; + } + Object.defineProperty(this, 'errors', { + enumerable: false, + configurable: true, + value: new Errors + }); + this.constructor._validations.forEach(function (v) { + if (validationFailed(inst, v)) { + valid = false; + } + }); + if (valid) { + Object.defineProperty(this, 'errors', { + enumerable: false, + configurable: true, + value: false + }); + } + return valid; +}; + +function validationFailed(inst, v) { + var attr = v[0]; + var conf = v[1]; + // here we should check skip validation conditions (if, unless) + // that can be specified in conf + var fail = false; + validators[conf.validation].call(inst, attr, conf, function onerror(kind) { + var message; + if (conf.message) { + message = conf.message; + } + if (!message && defaultMessages[conf.validation]) { + message = defaultMessages[conf.validation]; + } + if (!message) { + message = 'is invalid'; + } + if (kind) { + if (message[kind]) { + // get deeper + message = message[kind]; + } else if (defaultMessages.common[kind]) { + message = defaultMessages.common[kind]; + } + } + inst.errors.add(attr, message); + fail = true; + }); + return fail; +} + +var defaultMessages = { + presence: 'can\'t be blank', + length: { + min: 'too short', + max: 'too long', + is: 'length is wrong' + }, + common: { + blank: 'is blank', + 'null': 'is null' + }, + numericality: { + 'int': 'is not an integer', + 'number': 'is not a number' + }, + inclusion: 'is not included in the list', + exclusion: 'is reserved' +}; + +var validators = { + presence: function (attr, conf, err) { + if (blank(this[attr])) { + err(); + } + }, + length: function (attr, conf, err) { + var isNull = this[attr] === null || !(attr in this); + if (isNull) { + if (conf.allowNull) { + return true; + } else { + return err('null'); + } + } else { + if (blank(this[attr])) { + if (conf.allowBlank) { + return true; + } else { + return err('blank'); + } + } + } + var len = this[attr].length; + if (conf.min && len < conf.min) { + err('min'); + } + if (conf.max && len > conf.max) { + err('max'); + } + if (conf.is && len !== conf.is) { + err('is'); + } + }, + numericality: function (attr, conf, err) { + if (typeof this[attr] !== 'number') { + return err('number'); + } + if (conf.int && this[attr] !== Math.round(this[attr])) { + return err('int'); + } + }, + inclusion: function (attr, conf, err) { + if (!~conf.in.indexOf(this[attr])) { + err() + } + }, + exclusion: function (attr, conf, err) { + if (~conf.in.indexOf(this[attr])) { + err() + } + } +}; + +function blank(v) { + if (typeof v === 'undefined') return true; + if (v instanceof Array && v.length === 0) return true; + if (v === null) return true; + if (typeof v == 'string' && v === '') return true; + return false; +} + +function configure(class, validation, args) { + if (!class._validations) { + Object.defineProperty(class, '_validations', { + writable: true, + configurable: true, + enumerable: false, + value: [] + }); + } + args = [].slice.call(args); + var conf; + if (typeof args[args.length - 1] === 'object') { + conf = args.pop(); + } else { + conf = {}; + } + conf.validation = validation; + args.forEach(function (attr) { + class._validations.push([attr, conf]); + }); +} + +function Errors() { +} + +Errors.prototype.add = function (field, message) { + if (!this[field]) { + this[field] = [message]; + } else { + this[field].push(message); + } +}; diff --git a/package.json b/package.json index 44e83bd9..98dca16c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "Anatoliy Chakkaev", - "name": "yadm", - "description": "Yet another data mapper", + "name": "jugglingdb", + "description": "ORM for every database: redis, mysql, neo4j, mongodb", "version": "0.0.1", "repository": { "url": "" diff --git a/test/validations_test.coffee b/test/validations_test.coffee new file mode 100644 index 00000000..bba9af2b --- /dev/null +++ b/test/validations_test.coffee @@ -0,0 +1,172 @@ +juggling = require('../index') +Schema = juggling.Schema +AbstractClass = juggling.AbstractClass +Validatable = juggling.Validatable + +require('./spec_helper').init module.exports + +schema = new Schema 'memory' +User = schema.define 'User', + email: String + name: String + password: String + state: String + age: Number + gender: String + domain: String + +validAttributes = + name: 'Anatoliy' + email: 'email@example.com' + state: '' + age: 26 + gender: 'male' + domain: '1602' + +User.validatesPresenceOf 'email', 'name' + + +it 'should validate presence', (test) -> + user = new User + test.ok not user.isValid(), 'User is not valid' + test.ok user.errors.email, 'Attr email in errors' + test.ok user.errors.name, 'Attr name in errors' + + user.name = 'Anatoliy' + test.ok not user.isValid(), 'User is still not valid' + test.ok user.errors.email, 'Attr email still in errors' + test.ok not user.errors.name, 'Attr name valid' + + user.email = 'anatoliy@localhost' + test.ok user.isValid(), 'User is valid' + test.ok not user.errors, 'No errors' + test.ok not user.errors.email, 'Attr email valid' + test.ok not user.errors.name, 'Attr name valid' + test.done() + + +it 'should throw error on save if required', (test) -> + user = new User + + test.throws () -> + user.save throws: true + + test.done() + + +it 'should allow to skip validation on save', (test) -> + user = new User + test.ok user.isNewRecord(), 'User not saved yet' + test.ok not user.isValid(), 'User not valid' + + user.save validate: false + + test.ok not user.isNewRecord(), 'User saved' + test.ok not user.isValid(), 'User data still not valid' + test.done() + +it 'should perform validation on updateAttributes', (test) -> + User.create email: 'anatoliy@localhost', name: 'anatoliy', (err, user) -> + user.updateAttributes name: null, (err, name) -> + test.ok(err) + test.ok user.errors + test.ok user.errors.name + test.done() + +it 'should perform validation on create', (test) -> + User.create (err, user) -> + test.ok err, 'We have an error' + # we got an user, + test.ok user, 'We got an user' + # but it's not saved + test.ok user.isNewRecord(), 'User not saved' + # and we have errors + test.ok user.errors, 'User have errors' + # explaining what happens + test.ok user.errors.name, 'Errors contain name' + test.ok user.errors.email, 'Errors contain email' + + test.done() + +it 'should validate length', (test) -> + User.validatesLengthOf 'password', min: 3, max: 10, allowNull: true + User.validatesLengthOf 'state', is: 2, allowBlank: true + user = new User validAttributes + + user.password = 'qw' + test.ok not user.isValid(), 'Invalid: too short' + test.equal user.errors.password[0], 'too short' + + user.password = '12345678901' + test.ok not user.isValid(), 'Invalid: too long' + test.equal user.errors.password[0], 'too long' + + user.password = 'hello' + test.ok user.isValid(), 'Valid with value' + test.ok not user.errors + + user.password = null + test.ok user.isValid(), 'Valid without value' + test.ok not user.errors + + user.state = 'Texas' + test.ok not user.isValid(), 'Invalid state' + test.equal user.errors.state[0], 'length is wrong' + + user.state = 'TX' + test.ok user.isValid(), 'Valid with value of state' + test.ok not user.errors + + test.done() + +it 'should validate numericality', (test) -> + User.validatesNumericalityOf 'age', int: true + user = new User validAttributes + + user.age = '26' + test.ok not user.isValid(), 'User is not valid: not a number' + test.equal user.errors.age[0], 'is not a number' + + user.age = 26.1 + test.ok not user.isValid(), 'User is not valid: not integer' + test.equal user.errors.age[0], 'is not an integer' + + user.age = 26 + test.ok user.isValid(), 'User valid: integer age' + test.ok not user.errors + + test.done() + +it 'should validate inclusion', (test) -> + User.validatesInclusionOf 'gender', in: ['male', 'female'] + user = new User validAttributes + + user.gender = 'any' + test.ok not user.isValid() + test.equal user.errors.gender[0], 'is not included in the list' + + user.gender = 'female' + test.ok user.isValid() + + user.gender = 'male' + test.ok user.isValid() + + user.gender = '' + test.ok not user.isValid() + test.equal user.errors.gender[0], 'is not included in the list' + + test.done() + +it 'should validate exclusion', (test) -> + User.validatesExclusionOf 'domain', in: ['www', 'admin'] + user = new User validAttributes + + user.domain = 'www' + test.ok not user.isValid() + test.equal user.errors.domain[0], 'is reserved' + + user.domain = 'my' + test.ok user.isValid() + + test.done() +