From 6ee7de0716d0b6f5f5a537ea549cb55cfbc6fc57 Mon Sep 17 00:00:00 2001 From: Anatoliy Chakkaev Date: Tue, 27 Mar 2012 18:22:24 +0400 Subject: [PATCH] Document --- index.js | 9 +- lib/abstract-class.js | 211 +++++++++++++++++++++++---- lib/adapters/mysql.js | 3 + lib/adapters/redis.js | 2 +- lib/railway.js | 199 +++++++++++++++++++++++++ lib/schema.js | 198 +++++++++++++++++-------- lib/sql.js | 6 +- lib/validatable.js | 327 +++++++++++++++++++++++++++++++++--------- 8 files changed, 800 insertions(+), 155 deletions(-) create mode 100644 lib/railway.js diff --git a/index.js b/index.js index dd9f57f7..d078c192 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,18 @@ var fs = require('fs'); +var path = require('path'); exports.Schema = require('./lib/schema').Schema; exports.AbstractClass = require('./lib/abstract-class').AbstractClass; exports.Validatable = require('./lib/validatable').Validatable; +exports.init = function () { + if (!global.railway) return; + railway.orm = exports; + require('./lib/railway'); +}; + try { - if (process.versions.node < '0.6' || true) { + if (process.versions.node < '0.6') { exports.version = JSON.parse(fs.readFileSync(__dirname + '/package.json')).version; } else { exports.version = require('../package').version; diff --git a/lib/abstract-class.js b/lib/abstract-class.js index 459e7e63..914dbb12 100644 --- a/lib/abstract-class.js +++ b/lib/abstract-class.js @@ -1,10 +1,10 @@ /** - * Module deps + * Module dependencies */ -var Validatable = require('./validatable').Validatable; -var Hookable = require('./hookable').Hookable; var util = require('util'); var jutil = require('./jutil'); +var Validatable = require('./validatable').Validatable; +var Hookable = require('./hookable').Hookable; var DEFAULT_CACHE_LIMIT = 1000; exports.AbstractClass = AbstractClass; @@ -13,7 +13,15 @@ jutil.inherits(AbstractClass, Validatable); jutil.inherits(AbstractClass, Hookable); /** - * Abstract class constructor + * Abstract class - base class for all persist objects + * provides **common API** to access any database adapter. + * This class describes only abstract behavior layer, refer to `lib/adapters/*.js` + * to learn more about specific adapter implementations + * + * `AbstractClass` mixes `Validatable` and `Hookable` classes methods + * + * @constructor + * @param {Object} data - initial object data */ function AbstractClass(data) { var self = this; @@ -99,6 +107,10 @@ function AbstractClass(data) { AbstractClass.setter = {}; AbstractClass.getter = {}; +/** + * @param {String} prop - property name + * @param {Object} params - various property configuration + */ AbstractClass.defineProperty = function (prop, params) { this.schema.defineProperty(this.modelName, prop, params); }; @@ -113,8 +125,14 @@ AbstractClass.prototype.whatTypeName = function (propName) { }; /** + * Create new instance of Model class, saved in database + * * @param data [optional] * @param callback(err, obj) + * callback called with arguments: + * + * - err (null or Error) + * - instance (null or Model) */ AbstractClass.create = function (data, callback) { if (stillConnecting(this.schema, this, arguments)) return; @@ -178,6 +196,9 @@ function stillConnecting(schema, obj, args) { return true; }; +/** + * Update or insert + */ AbstractClass.upsert = AbstractClass.updateOrCreate = function upsert(data, callback) { if (stillConnecting(this.schema, this, arguments)) return; @@ -204,6 +225,12 @@ AbstractClass.upsert = AbstractClass.updateOrCreate = function upsert(data, call } }; +/** + * Check whether object exitst in database + * + * @param {id} id - identifier of object (primary key value) + * @param {Function} cb - callbacl called with (err, exists: Bool) + */ AbstractClass.exists = function exists(id, cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -214,6 +241,12 @@ AbstractClass.exists = function exists(id, cb) { } }; +/** + * Find object by id + * + * @param {id} id - primary key value + * @param {Function} cb - callback called with (err, instance) + */ AbstractClass.find = function find(id, cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -236,9 +269,20 @@ AbstractClass.find = function find(id, cb) { }; /** - * Query collection of objects - * @param params {where: {}, order: '', limit: 1, offset: 0,...} - * @param cb (err, array of AbstractClass) + * Find all instances of Model, matched by query + * make sure you have marked as `index: true` fields for filter or sort + * + * @param {Object} params (optional) + * + * - where: Object `{ key: val, key2: {gt: 'val2'}}` + * - order: String + * - limit: Number + * - skip: Number + * + * @param {Function} callback (required) called with arguments: + * + * - err (null or Error) + * - Array of instances */ AbstractClass.all = function all(params, cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -271,6 +315,12 @@ AbstractClass.all = function all(params, cb) { }); }; +/** + * Find one record, same as `all`, limited by 1 and return object, not collection + * + * @param {Object} params - search conditions + * @param {Function} cb - callback called with (err, instance) + */ AbstractClass.findOne = function findOne(params, cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -293,6 +343,10 @@ function substractDirtyAttributes(object, data) { }); } +/** + * Destroy all records + * @param {Function} cb - callback called with (err) + */ AbstractClass.destroyAll = function destroyAll(cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -302,6 +356,12 @@ AbstractClass.destroyAll = function destroyAll(cb) { }.bind(this)); }; +/** + * Return count of matched records + * + * @param {Object} where - search conditions (optional) + * @param {Function} cb - callback, called with (err, count) + */ AbstractClass.count = function (where, cb) { if (stillConnecting(this.schema, this, arguments)) return; @@ -312,6 +372,11 @@ AbstractClass.count = function (where, cb) { this.schema.adapter.count(this.modelName, cb, where); }; +/** + * Return string representation of class + * + * @override default toString method + */ AbstractClass.toString = function () { return '[Model ' + this.modelName + ']'; } @@ -393,14 +458,22 @@ AbstractClass.prototype.isNewRecord = function () { return !this.id; }; +/** + * Return adapter of current record + * @private + */ AbstractClass.prototype._adapter = function () { return this.constructor.schema.adapter; }; -AbstractClass.prototype.propertyChanged = function (name) { - return this[name + '_was'] !== this['_' + name]; -}; - +/** + * Convert instance to Object + * + * @param {Boolean} onlySchema - restrict properties to schema only, default false + * when onlySchema == true, only properties defined in schema returned, + * otherwise all enumerable properties returned + * @returns {Object} - canonical object representation (no getters and setters) + */ AbstractClass.prototype.toObject = function (onlySchema) { var data = {}; var ds = this.constructor.schema.definitions[this.constructor.modelName]; @@ -412,6 +485,11 @@ AbstractClass.prototype.toObject = function (onlySchema) { return data; }; +/** + * Delete object from persistence + * + * @triggers `destroy` hook (async) before and after destroying object + */ AbstractClass.prototype.destroy = function (cb) { if (stillConnecting(this.constructor.schema, this, arguments)) return; @@ -425,14 +503,30 @@ AbstractClass.prototype.destroy = function (cb) { }); }; -AbstractClass.prototype.updateAttribute = function (name, value, cb) { - if (stillConnecting(this.constructor.schema, this, arguments)) return; - +/** + * Update single attribute + * + * equals to `updateAttributes({name: value}, cb) + * + * @param {String} name - name of property + * @param {Mixed} value - value of property + * @param {Function} callback - callback called with (err, instance) + */ +AbstractClass.prototype.updateAttribute = function updateAttribute(name, value, callback) { data = {}; data[name] = value; - this.updateAttributes(data, cb); + this.updateAttributes(data, callback); }; +/** + * Update set of attributes + * + * this method performs validation before updating + * + * @trigger `validation`, `save` and `update` hooks + * @param {Object} data - data to update + * @param {Function} callback - callback called with (err, instance) + */ AbstractClass.prototype.updateAttributes = function updateAttributes(data, cb) { if (stillConnecting(this.constructor.schema, this, arguments)) return; @@ -490,23 +584,34 @@ AbstractClass.prototype.updateAttributes = function updateAttributes(data, cb) { /** * Checks is property changed based on current property and initial value - * @param {attr} String - property name + * + * @param {String} attr - property name * @return Boolean */ -AbstractClass.prototype.propertyChanged = function (attr) { +AbstractClass.prototype.propertyChanged = function propertyChanged(attr) { return this['_' + attr] !== this[attr + '_was']; }; -AbstractClass.prototype.reload = function (cb) { +/** + * Reload object from persistence + * + * @requires `id` member of `object` to be able to call `find` + * @param {Function} callback - called with (err, instance) arguments + */ +AbstractClass.prototype.reload = function reload(callback) { if (stillConnecting(this.constructor.schema, this, arguments)) return; var obj = getCached(this.constructor, this.id); - if (obj) { - obj.reset(); - } - this.constructor.find(this.id, cb); + if (obj) obj.reset(); + this.constructor.find(this.id, callback); }; +/** + * Reset dirty attributes + * + * this method does not perform any database operation it just reset object to it's + * initial state + */ AbstractClass.prototype.reset = function () { var obj = this; Object.keys(obj).forEach(function (k) { @@ -519,8 +624,14 @@ AbstractClass.prototype.reset = function () { }); }; -// relations -AbstractClass.hasMany = function (anotherClass, params) { +/** + * Declare hasMany relation + * + * @param {Class} anotherClass - class to has many + * @param {Object} params - configuration {as:, foreignKey:} + * @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});` + */ +AbstractClass.hasMany = function hasMany(anotherClass, params) { var methodName = params.as; // or pluralize(anotherClass.modelName) var fk = params.foreignKey; // each instance of this class should have method named @@ -562,6 +673,12 @@ AbstractClass.hasMany = function (anotherClass, params) { }; +/** + * Declare belongsTo relation + * + * @param {Class} anotherClass - class to belong + * @param {Object} params - configuration {as: 'propertyName', foreignKey: 'keyName'} + */ AbstractClass.belongsTo = function (anotherClass, params) { var methodName = params.as; var fk = params.foreignKey; @@ -596,6 +713,10 @@ AbstractClass.belongsTo = function (anotherClass, params) { }; +/** + * Define scope + * TODO: describe behavior and usage examples + */ AbstractClass.scope = function (name, params) { defineScope(this, this, name, params); }; @@ -689,14 +810,23 @@ function defineScope(cls, targetClass, name, params, methods) { } } -// helper methods -// +/** + * Check whether `s` is not undefined + * @param {Mixed} s + * @return {Boolean} s is undefined + */ function isdef(s) { var undef; return s !== undef; } +/** + * Merge `base` and `update` params + * @param {Object} base - base object (updating this object) + * @param {Object} update - object with new data to update base + * @returns {Object} `base` + */ function merge(base, update) { base = base || {}; if (update) { @@ -707,6 +837,13 @@ function merge(base, update) { return base; } +/** + * Define readonly property on object + * + * @param {Object} obj + * @param {String} key + * @param {Mixed} value + */ function defineReadonlyProp(obj, key, value) { Object.defineProperty(obj, key, { writable: false, @@ -716,11 +853,17 @@ function defineReadonlyProp(obj, key, value) { }); } +/** + * Add object to cache + */ function addToCache(constr, obj) { touchCache(constr, obj.id); constr.cache[obj.id] = obj; } +/** + * Renew object position in LRU cache index + */ function touchCache(constr, id) { var cacheLimit = constr.CACHE_LIMIT || DEFAULT_CACHE_LIMIT; @@ -734,16 +877,32 @@ function touchCache(constr, id) { } } +/** + * Retrieve cached object + */ function getCached(constr, id) { if (id) touchCache(constr, id); return id && constr.cache[id]; } +/** + * Clear cache (fully) + * + * removes both cache and LRU index + * + * @param {Class} constr - class constructor + */ function clearCache(constr) { constr.cache = {}; constr.mru = []; } +/** + * Remove object from cache + * + * @param {Class} constr + * @param {id} id + */ function removeFromCache(constr, id) { var ind = constr.mru.indexOf(id); if (!~ind) constr.mru.splice(ind, 1); diff --git a/lib/adapters/mysql.js b/lib/adapters/mysql.js index ae0da123..4dde4d78 100644 --- a/lib/adapters/mysql.js +++ b/lib/adapters/mysql.js @@ -35,6 +35,9 @@ exports.initialize = function initializeSchema(schema, callback) { }); }; +/** + * MySQL adapter + */ function MySQL(client) { this._models = {}; this.client = client; diff --git a/lib/adapters/redis.js b/lib/adapters/redis.js index cb636ee1..b9bcc935 100644 --- a/lib/adapters/redis.js +++ b/lib/adapters/redis.js @@ -54,7 +54,7 @@ BridgeToRedis.prototype.save = function (model, data, callback) { BridgeToRedis.prototype.updateIndexes = function (model, id, data, callback) { var i = this.indexes[model]; - var schedule = []; + var schedule = [['sadd', 's:' + model, id]]; Object.keys(data).forEach(function (key) { if (i[key]) { schedule.push([ diff --git a/lib/railway.js b/lib/railway.js new file mode 100644 index 00000000..e15f192c --- /dev/null +++ b/lib/railway.js @@ -0,0 +1,199 @@ +var fs = require('fs'); +var path = require('path'); +var Schema = railway.orm.Schema; + +railway.orm._schemas = []; + +try { + var config = JSON.parse(fs.readFileSync(app.root + '/config/database.json', 'utf-8'))[app.set('env')]; +} catch (e) { + console.log('Could not parse config/database.json'); + throw e; +} + +var schema = new Schema(config && config.driver || 'memory', config); +schema.log = log; +railway.orm._schemas.push(schema); + +context = prepareContext(schema); + +// run schema first +var schemaFile = app.root + '/db/schema.'; +if (path.existsSync(schemaFile + 'js')) { + schemaFile += 'js'; +} else { + schemaFile += 'coffee'; +} +runCode(schemaFile, context); + +// and freeze schemas +railway.orm._schemas.forEach(function (schema) { + schema.freeze(); +}); + +function log(str, startTime) { + var $ = utils.stylize.$; + var m = Date.now() - startTime; + utils.debug(str + $(' [' + (m < 10 ? m : $(m).red) + ' ms]').bold); + app.emit('app-event', { + type: 'query', + param: str, + time: m + }); +} + +function runCode(filename, context) { + var isCoffee = filename.match(/coffee$/); + + context = context || {}; + + var dirname = path.dirname(filename); + + // extend context + context.require = context.require || function (apath) { + var isRelative = apath.match(/^\.\.?\//); + return require(isRelative ? path.resolve(dirname, apath) : apath); + }; + context.app = app; + context.railway = railway; + context.console = console; + context.setTimeout = setTimeout; + context.setInterval = setInterval; + context.clearTimeout = clearTimeout; + context.clearInterval = clearInterval; + context.__filename = filename; + context.__dirname = dirname; + context.process = process; + context.t = context.t || t; + context.Buffer = Buffer; + + var code = path.existsSync(filename) && require('fs').readFileSync(filename); + if (!code) return; + if (isCoffee) { + try { + var cs = require('coffee-script'); + } catch (e) { + throw new Error('Please install coffee-script npm package: `npm install coffee-script`'); + } + try { + code = require('coffee-script').compile(code); + } catch (e) { + console.log('Error in coffee code compilation in file ' + filename); + throw e; + } + } + + try { + var m = require('vm').createScript(code.toString('utf8'), filename); + m.runInNewContext(context); + } catch (e) { + console.log('Error while executing ' + filename); + throw e; + } + +} + +function prepareContext(defSchema, done) { + var ctx = {app: app}, + models = {}, + settings = {}, + cname, + schema, + wait = connected = 0, + nonJugglingSchema = false; + + done = done || function () {}; + + /** + * Multiple schemas support + * example: + * schema('redis', {url:'...'}, function () { + * describe models using redis connection + * ... + * }); + * schema(function () { + * describe models stored in memory + * ... + * }); + */ + ctx.schema = function () { + var name = argument('string'); + var opts = argument('object') || {}; + var def = argument('function') || function () {}; + schema = new Schema(name || opts.driver || 'memory', opts); + railway.orm._schemas.push(schema); + wait += 1; + ctx.gotSchema = true; + schema.on('log', log); + schema.on('connected', function () { + if (wait === ++connected) done(); + }); + def(); + schema = false; + }; + + /** + * Use custom schema driver + */ + ctx.customSchema = function () { + var def = argument('function') || function () {}; + nonJugglingSchema = true; + def(); + Object.keys(ctx.exports).forEach(function (m) { + ctx.define(m, ctx.exports[m]); + }); + nonJugglingSchema = false; + }; + ctx.exports = {}; + ctx.module = { exports: ctx.exports }; + + /** + * Define a class in current schema + */ + ctx.describe = ctx.define = function (className, callback) { + var m; + cname = className; + models[cname] = {}; + settings[cname] = {}; + if (nonJugglingSchema) { + m = callback; + } else { + callback && callback(); + m = (schema || defSchema).define(className, models[cname], settings[cname]); + } + return global[cname] = app.models[cname] = ctx[cname] = m; + }; + + /** + * Define a property in current class + */ + ctx.property = function (name, type, params) { + if (!params) params = {}; + if (typeof type !== 'function' && typeof type === 'object') { + params = type; + type = String; + } + params.type = type || String; + models[cname][name] = params; + }; + + /** + * Set custom table name for current class + * @param name - name of table + */ + ctx.setTableName = function (name) { + if (cname) settings[cname].table = name; + }; + + ctx.Text = Schema.Text; + + return ctx; + + function argument(type) { + var r; + [].forEach.call(arguments.callee.caller.arguments, function (a) { + if (!r && typeof a === type) r = a; + }); + return r; + } +} diff --git a/lib/schema.js b/lib/schema.js index d7a09059..de820905 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -17,10 +17,30 @@ exports.Schema = Schema; var slice = Array.prototype.slice; /** - * Shema - classes factory + * Schema - adapter-specific classes factory. + * + * All classes in single schema shares same adapter type and + * one database connection + * * @param name - type of schema adapter (mysql, mongoose, sequelize, redis) * @param settings - any database-specific settings which we need to * establish connection (of course it depends on specific adapter) + * + * - host + * - port + * - username + * - password + * - database + * - debug {Boolean} = false + * + * @example Schema creation, waiting for connection callback + * ``` + * var schema = new Schema('mysql', { database: 'myapp_test' }); + * schema.define(...); + * schema.on('connected', function () { + * // work with database + * }); + * ``` */ function Schema(name, settings) { var schema = this; @@ -73,65 +93,40 @@ function Schema(name, settings) { util.inherits(Schema, require('events').EventEmitter); -function Text() { -} +function Text() {} Schema.Text = Text; -Schema.prototype.defineProperty = function (model, prop, params) { - this.definitions[model].properties[prop] = params; - if (this.adapter.defineProperty) { - this.adapter.defineProperty(model, prop, params); - } -}; - -Schema.prototype.automigrate = function (cb) { - this.freeze(); - if (this.adapter.automigrate) { - this.adapter.automigrate(cb); - } else if (cb) { - cb(); - } -}; - -Schema.prototype.autoupdate = function (cb) { - this.freeze(); - if (this.adapter.autoupdate) { - this.adapter.autoupdate(cb); - } else if (cb) { - cb(); - } -}; - -/** - * Check whether migrations needed - */ -Schema.prototype.isActual = function (cb) { - this.freeze(); - if (this.adapter.isActual) { - this.adapter.isActual(cb); - } else if (cb) { - cb(null, true); - } -}; - -Schema.prototype.log = function (sql, t) { - this.emit('log', sql, t); -}; - -Schema.prototype.freeze = function freeze() { - if (this.adapter.freezeSchema) { - this.adapter.freezeSchema(); - } -} - /** * Define class - * @param className - * @param properties - hash of class properties in format - * {property: Type, property2: Type2, ...} - * or - * {property: {type: Type}, property2: {type: Type2}, ...} - * @param settings - other configuration of class + * + * @param {String} className + * @param {Object} properties - hash of class properties in format + * `{property: Type, property2: Type2, ...}` + * or + * `{property: {type: Type}, property2: {type: Type2}, ...}` + * @param {Object} settings - other configuration of class + * @return newly created class + * + * @example simple case + * ``` + * var User = schema.defind('User', { + * email: String, + * password: String, + * birthDate: Date, + * activated: Boolean + * }); + * ``` + * + * @example more advanced case + * ``` + * var User = schema.defind('User', { + * email: { type: String, limit: 150, index: true }, + * password: { type: String, limit: 50 }, + * birthDate: Date, + * registrationDate: {type: Date, default: function () { return new Date }}, + * activated: { type: Boolean, default: false } + * }); + * ``` */ Schema.prototype.define = function defineClass(className, properties, settings) { var schema = this; @@ -191,10 +186,94 @@ Schema.prototype.define = function defineClass(className, properties, settings) }; + +/** + * Define single property named `prop` on `model` + * + * @param {String} model - name of model + * @param {String} prop - name of propery + * @param {Object} params - property settings + */ +Schema.prototype.defineProperty = function (model, prop, params) { + this.definitions[model].properties[prop] = params; + if (this.adapter.defineProperty) { + this.adapter.defineProperty(model, prop, params); + } +}; + +/** + * Drop each model table and re-create. + * This method make sense only for sql adapters. + * + * @warning All data will be lost! Use autoupdate if you need your data. + */ +Schema.prototype.automigrate = function (cb) { + this.freeze(); + if (this.adapter.automigrate) { + this.adapter.automigrate(cb); + } else if (cb) { + cb(); + } +}; + +/** + * Update existing database tables. + * This method make sense only for sql adapters. + */ +Schema.prototype.autoupdate = function (cb) { + this.freeze(); + if (this.adapter.autoupdate) { + this.adapter.autoupdate(cb); + } else if (cb) { + cb(); + } +}; + +/** + * Check whether migrations needed + * This method make sense only for sql adapters. + */ +Schema.prototype.isActual = function (cb) { + this.freeze(); + if (this.adapter.isActual) { + this.adapter.isActual(cb); + } else if (cb) { + cb(null, true); + } +}; + +/** + * Log benchmarked message. Do not redefine this method, if you need to grab + * chema logs, use `schema.on('log', ...)` emitter event + * + * @private used by adapters + */ +Schema.prototype.log = function (sql, t) { + this.emit('log', sql, t); +}; + +/** + * Freeze schema. Behavior depends on adapter + */ +Schema.prototype.freeze = function freeze() { + if (this.adapter.freezeSchema) { + this.adapter.freezeSchema(); + } +} + +/** + * Return table name for specified `modelName` + * @param {String} modelName + */ Schema.prototype.tableName = function (modelName) { return this.definitions[modelName].settings.table = this.definitions[modelName].settings.table || modelName }; +/** + * Define foreign key + * @param {String} className + * @param {String} key - name of key field + */ Schema.prototype.defineForeignKey = function defineForeignKey(className, key) { // return if already defined if (this.definitions[className].properties[key]) return; @@ -209,13 +288,18 @@ Schema.prototype.defineForeignKey = function defineForeignKey(className, key) { } }; +/** + * Close database connection + */ Schema.prototype.disconnect = function disconnect() { if (typeof this.adapter.disconnect === 'function') { this.adapter.disconnect(); } }; - +/** + * Define hidden property + */ function hiddenProperty(where, property, value) { Object.defineProperty(where, property, { writable: false, diff --git a/lib/sql.js b/lib/sql.js index d25239cc..6eea5842 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -1,6 +1,10 @@ module.exports = BaseSQL; -function BaseSQL() {} +/** + * Base SQL class + */ +function BaseSQL() { +} BaseSQL.prototype.query = function () { throw new Error('query method should be declared in adapter'); diff --git a/lib/validatable.js b/lib/validatable.js index 0d8a0be1..831da7f0 100644 --- a/lib/validatable.js +++ b/lib/validatable.js @@ -1,92 +1,255 @@ exports.Validatable = Validatable; +/** + * Validation encapsulated in this abstract class. + * + * Basically validation configurators is just class methods, which adds validations + * configs to AbstractClass._validations. Each of this validations run when + * `obj.isValid()` method called. + * + * Each configurator can accept n params (n-1 field names and one config). Config + * is {Object} depends on specific validation, but all of them has one common part: + * `message` member. It can be just string, when only one situation possible, + * e.g. `Post.validatesPresenceOf('title', { message: 'can not be blank' });` + * + * In more complicated cases it can be {Hash} of messages (for each case): + * `User.validatesLengthOf('password', { min: 6, max: 20, message: {min: 'too short', max: 'too long'}});` + */ function Validatable() { // validatable class }; +/** + * Validate presence. This validation fails when validated field is blank. + * + * Default error message "can't be blank" + * + * @example `Post.validatesPresenceOf('title')` + * @example `Post.validatesPresenceOf('title', {message: 'Can not be blank'})` + * @sync + * + * @nocode + * @see helper/validatePresence + */ Validatable.validatesPresenceOf = getConfigurator('presence'); + +/** + * Validate length. Three kinds of validations: min, max, is. + * + * Default error messages: + * + * - min: too short + * - max: too long + * - is: length is wrong + * + * @example `User.validatesLengthOf('password', {min: 7});` + * @example `User.validatesLengthOf('email', {max: 100});` + * @example `User.validatesLengthOf('state', {is: 2});` + * @example `User.validatesLengthOf('nick', {min: 3, max: 15}); + * @sync + * + * @nocode + * @see helper/validateLength + */ Validatable.validatesLengthOf = getConfigurator('length'); + +/** + * Validate numericality. + * + * @example `User.validatesNumericalityOf('age', { message: { number: '...' }});` + * @example `User.validatesNumericalityOf('age', {int: true, message: { int: '...' }});` + * + * Default error messages: + * + * - number: is not a number + * - int: is not an integer + * + * @sync + * + * @nocode + * @see helper/validateNumericality + */ Validatable.validatesNumericalityOf = getConfigurator('numericality'); + +/** + * Validate inclusion in set + * + * @example `User.validatesInclusionOf('gender', {in: ['male', 'female']});` + * + * Default error message: is not included in the list + * + * @nocode + * @see helper/validateInclusion + */ Validatable.validatesInclusionOf = getConfigurator('inclusion'); + +/** + * Validate exclusion + * + * @example `Company.validatesExclusionOf('domain', {in: ['www', 'admin']});` + * + * Default error message: is reserved + * + * @nocode + * @see helper/validateExclusion + */ Validatable.validatesExclusionOf = getConfigurator('exclusion'); + +/** + * Validate format + * + * Default error message: is invalid + * + * @nocode + * @see helper/validateFormat + */ Validatable.validatesFormatOf = getConfigurator('format'); + +/** + * Validate using custom validator + * + * Default error message: is invalid + * + * @nocode + * @see helper/validateCustom + */ Validatable.validate = getConfigurator('custom'); + +/** + * Validate using custom async validator + * + * Default error message: is invalid + * + * @async + * @nocode + * @see helper/validateCustom + */ Validatable.validateAsync = getConfigurator('custom', {async: true}); + +/** + * Validate uniqueness + * + * Default error message: is not unique + * + * @async + * @nocode + * @see helper/validateUniqueness + */ Validatable.validatesUniquenessOf = getConfigurator('uniqueness', {async: true}); // implementation of validators -var validators = { - presence: function (attr, conf, err) { - if (blank(this[attr])) { - err(); - } - }, - length: function (attr, conf, err) { - if (nullCheck.call(this, attr, conf, err)) return; - 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 (nullCheck.call(this, attr, conf, err)) return; - - 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 (nullCheck.call(this, attr, conf, err)) return; - - if (!~conf.in.indexOf(this[attr])) { - err() - } - }, - exclusion: function (attr, conf, err) { - if (nullCheck.call(this, attr, conf, err)) return; - - if (~conf.in.indexOf(this[attr])) { - err() - } - }, - format: function (attr, conf, err) { - if (nullCheck.call(this, attr, conf, err)) return; - - if (typeof this[attr] === 'string') { - if (!this[attr].match(conf['with'])) { - err(); - } - } else { - err(); - } - }, - custom: function (attr, conf, err, done) { - conf.customValidator.call(this, err, done); - }, - uniqueness: function (attr, conf, err, done) { - var cond = {where: {}}; - cond.where[attr] = this[attr]; - this.constructor.all(cond, function (error, found) { - if (found.length > 1) { - err(); - } else if (found.length === 1 && found[0].id !== this.id) { - err(); - } - done(); - }.bind(this)); +/** + * Presence validator + */ +function validatePresence(attr, conf, err) { + if (blank(this[attr])) { + err(); } -}; +} +/** + * Length validator + */ +function validateLength(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + 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 validator + */ +function validateNumericality(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (typeof this[attr] !== 'number') { + return err('number'); + } + if (conf.int && this[attr] !== Math.round(this[attr])) { + return err('int'); + } +} + +/** + * Inclusion validator + */ +function validateInclusion(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (!~conf.in.indexOf(this[attr])) { + err() + } +} + +/** + * Exclusion validator + */ +function validateExclusion(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (~conf.in.indexOf(this[attr])) { + err() + } +} + +/** + * Format validator + */ +function validateFormat(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (typeof this[attr] === 'string') { + if (!this[attr].match(conf['with'])) { + err(); + } + } else { + err(); + } +} + +/** + * Custom validator + */ +function validateCustom(attr, conf, err, done) { + conf.customValidator.call(this, err, done); +} + +/** + * Uniqueness validator + */ +function validateUniqueness(attr, conf, err, done) { + var cond = {where: {}}; + cond.where[attr] = this[attr]; + this.constructor.all(cond, function (error, found) { + if (found.length > 1) { + err(); + } else if (found.length === 1 && found[0].id !== this.id) { + err(); + } + done(); + }.bind(this)); +} + +var validators = { + presence: validatePresence, + length: validateLength, + numericality: validateNumericality, + inclusion: validateInclusion, + exclusion: validateExclusion, + format: validateFormat, + custom: validateCustom, + uniqueness: validateUniqueness +}; function getConfigurator(name, opts) { return function () { @@ -94,6 +257,25 @@ function getConfigurator(name, opts) { }; } +/** + * This method performs validation, triggers validation hooks. + * Before validation `obj.errors` collection cleaned. + * Each validation can add errors to `obj.errors` collection. + * If collection is not blank, validation failed. + * + * @warning This method can be called as sync only when no async validation configured. It's strongly recommended to run all validations as asyncronous. + * + * @param {Function} callback called with (valid) + * @return {Boolean} true if no async validation configured and all passed + * + * @example ExpressJS controller: render user if valid, show flash otherwise + * ``` + * user.isValid(function (valid) { + * if (valid) res.render({user: user}); + * else res.flash('error', 'User is not valid'), console.log(user.errors), res.redirect('/users'); + * }); + * ``` + */ Validatable.prototype.isValid = function (callback) { var valid = true, inst = this, wait = 0, async = false; @@ -269,6 +451,13 @@ function nullCheck(attr, conf, err) { return false; } +/** + * Return true when v is undefined, blank array, null or empty string + * otherwise returns false + * + * @param {Mix} v + * @returns {Boolean} whether `v` blank or not + */ function blank(v) { if (typeof v === 'undefined') return true; if (v instanceof Array && v.length === 0) return true;