diff --git a/docs.json b/docs.json index 8864fc54..0cc0d640 100644 --- a/docs.json +++ b/docs.json @@ -2,13 +2,11 @@ "content": [ "lib/datasource.js", "lib/geo.js", - "lib/dao.js", - "lib/model.js", - "lib/model-builder.js", + "lib/hooks.js", "lib/include.js", + "lib/model-builder.js", "lib/relations.js", - "lib/validations.js", - "lib/sql.js" + "lib/validations.js" ], "codeSectionDepth": 4, "assets": { diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index b36d9d31..4b868601 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -306,9 +306,9 @@ Memory.prototype.all = function all(model, filter, callback) { } // limit/skip - filter.skip = filter.skip || 0; - filter.limit = filter.limit || nodes.length; - nodes = nodes.slice(filter.skip, filter.skip + filter.limit); + var skip = filter.skip || filter.offset || 0; + var limit = filter.limit || nodes.length; + nodes = nodes.slice(skip, skip + limit); } process.nextTick(function () { @@ -363,8 +363,40 @@ function applyFilter(filter) { return pass; } + function toRegExp(pattern) { + if (pattern instanceof RegExp) { + return pattern; + } + var regex = ''; + // Escaping user input to be treated as a literal string within a regular expression + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Writing_a_Regular_Expression_Pattern + pattern = pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); + for (var i = 0, n = pattern.length; i < n; i++) { + var char = pattern.charAt(i); + if (char === '\\') { + i++; // Skip to next char + if (i < n) { + regex += pattern.charAt(i); + } + continue; + } else if (char === '%') { + regex += '.*'; + } else if (char === '_') { + regex += '.'; + } else if (char === '.') { + regex += '\\.'; + } else if (char === '*') { + regex += '\\*'; + } + else { + regex += char; + } + } + return regex; + } + function test(example, value) { - if (typeof value === 'string' && example && example.constructor.name === 'RegExp') { + if (typeof value === 'string' && (example instanceof RegExp)) { return value.match(example); } if (example === undefined || value === undefined) { @@ -377,24 +409,81 @@ function applyFilter(filter) { } if (example.inq) { - if (!value) return false; - for (var i = 0; i < example.inq.length; i += 1) { - if (example.inq[i] == value) return true; + // if (!value) return false; + for (var i = 0; i < example.inq.length; i++) { + if (example.inq[i] == value) { + return true; + } } return false; } - if (isNum(example.gt) && example.gt < value) return true; - if (isNum(example.gte) && example.gte <= value) return true; - if (isNum(example.lt) && example.lt > value) return true; - if (isNum(example.lte) && example.lte >= value) return true; + if (example.like || example.nlike) { + + var like = example.like || example.nlike; + if (typeof like === 'string') { + like = toRegExp(like); + } + if (example.like) { + return !!new RegExp(like).test(value); + } + + if (example.nlike) { + return !new RegExp(like).test(value); + } + } + + if (testInEquality(example, value)) { + return true; + } } // not strict equality return (example !== null ? example.toString() : example) == (value !== null ? value.toString() : value); } - function isNum(n) { - return typeof n === 'number'; + /** + * Compare two values + * @param {*} val1 The 1st value + * @param {*} val2 The 2nd value + * @returns {number} 0: =, positive: >, negative < + * @private + */ + function compare(val1, val2) { + if(val1 == null || val2 == null) { + // Either val1 or val2 is null or undefined + return val1 == val2 ? 0 : NaN; + } + if (typeof val1 === 'number') { + return val1 - val2; + } + if (typeof val1 === 'string') { + return (val1 > val2) ? 1 : ((val1 < val2) ? -1 : (val1 == val2) ? 0 : NaN); + } + if (typeof val1 === 'boolean') { + return val1 - val2; + } + if (val1 instanceof Date) { + var result = val1 - val2; + return result; + } + // Return NaN if we don't know how to compare + return (val1 == val2) ? 0 : NaN; + } + + function testInEquality(example, val) { + if ('gt' in example) { + return compare(val, example.gt) > 0; + } + if ('gte' in example) { + return compare(val, example.gte) >= 0; + } + if ('lt' in example) { + return compare(val, example.lt) < 0; + } + if ('lte' in example) { + return compare(val, example.lte) <= 0; + } + return false; } } @@ -434,6 +523,29 @@ Memory.prototype.count = function count(model, callback, where) { }); }; +Memory.prototype.update = + Memory.prototype.updateAll = function updateAll(model, where, data, cb) { + var self = this; + var cache = this.cache[model]; + var filter = null; + where = where || {}; + filter = applyFilter({where: where}); + + var ids = Object.keys(cache); + async.each(ids, function (id, done) { + var inst = self.fromDb(model, cache[id]); + if (!filter || filter(inst)) { + self.updateAttributes(model, id, data, done); + } else { + process.nextTick(done); + } + }, function (err) { + if (!err) { + self.saveToFile(null, cb); + } + }); + }; + Memory.prototype.updateAttributes = function updateAttributes(model, id, data, cb) { if (!id) { var err = new Error('You must provide an id when updating attributes!'); diff --git a/lib/dao.js b/lib/dao.js index e75ddd48..f16cbeeb 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -18,6 +18,8 @@ var Memory = require('./connectors/memory').Memory; var utils = require('./utils'); var fieldsToArray = utils.fieldsToArray; var removeUndefined = utils.removeUndefined; +var util = require('util'); +var assert = require('assert'); /** * Base class for all persistent objects. @@ -79,7 +81,7 @@ DataAccessObject._forDB = function (data) { * }); * ``` * Note: You must include a callback and use the created model provided in the callback if your code depends on your model being - * saved or having an ID. + * saved or having an ID. * * @param {Object} data Optional data object * @param {Function} callback Callback function called with these arguments: @@ -163,7 +165,6 @@ DataAccessObject.create = function (data, callback) { this._adapter().create(modelName, this.constructor._forDB(obj.toObject(true)), function (err, id, rev) { if (id) { obj.__data[_idName] = id; - obj.__dataWas[_idName] = id; defineReadonlyProp(obj, _idName, id); } if (rev) { @@ -194,7 +195,7 @@ function stillConnecting(dataSource, obj, args) { /** * Update or insert a model instance: update exiting record if one is found, such that parameter `data.id` matches `id` of model instance; * otherwise, insert a new record. - * + * * NOTE: No setters, validations, or hooks are applied when using upsert. * `updateOrCreate` is an alias * @param {Object} data The model instance data @@ -294,7 +295,7 @@ DataAccessObject.exists = function exists(id, cb) { /** * Find model instance by ID. - * + * * Example: * ```js * User.findById(23, function(err, user) { @@ -314,8 +315,7 @@ DataAccessObject.findById = function find(id, cb) { if (!getIdValue(this, data)) { setIdValue(this, data, id); } - obj = new this(); - obj._initProperties(data); + obj = new this(data, {applySetters: false}); } cb(err, obj); }.bind(this)); @@ -350,13 +350,88 @@ var operators = { nlike: 'NOT LIKE' }; +/* + * Normalize the filter object and throw errors if invalid values are detected + * @param {Object} filter The query filter object + * @returns {Object} The normalized filter object + * @private + */ +DataAccessObject._normalize = function (filter) { + if (!filter) { + return undefined; + } + var err = null; + if ((typeof filter !== 'object') || Array.isArray(filter)) { + err = new Error(util.format('The query filter %j is not an object', filter)); + err.statusCode = 400; + throw err; + } + if (filter.limit || filter.skip || filter.offset) { + var limit = Number(filter.limit || 100); + var offset = Number(filter.skip || filter.offset || 0); + if (isNaN(limit) || limit <= 0 || Math.ceil(limit) !== limit) { + err = new Error(util.format('The limit parameter %j is not valid', + filter.limit)); + err.statusCode = 400; + throw err; + } + if (isNaN(offset) || offset < 0 || Math.ceil(offset) !== offset) { + err = new Error(util.format('The offset/skip parameter %j is not valid', + filter.skip || filter.offset)); + err.statusCode = 400; + throw err; + } + filter.limit = limit; + filter.offset = offset; + filter.skip = offset; + } + + // normalize fields as array of included property names + if (filter.fields) { + filter.fields = fieldsToArray(filter.fields, + Object.keys(this.definition.properties)); + } + + filter = removeUndefined(filter); + this._coerce(filter.where); + return filter; +}; + +/* + * Coerce values based the property types + * @param {Object} where The where clause + * @returns {Object} The coerced where clause + * @private + */ DataAccessObject._coerce = function (where) { + var self = this; if (!where) { return where; } - var props = this.getDataSource().getModelDefinition(this.modelName).properties; + var err; + if (typeof where !== 'object' || Array.isArray(where)) { + err = new Error(util.format('The where clause %j is not an object', where)); + err.statusCode = 400; + throw err; + } + + var props = self.definition.properties; for (var p in where) { + // Handle logical operators + if (p === 'and' || p === 'or' || p === 'nor') { + var clauses = where[p]; + if (Array.isArray(clauses)) { + for (var i = 0; i < clauses.length; i++) { + self._coerce(clauses[i]); + } + } else { + err = new Error(util.format('The %p operator has invalid clauses %j', p, clauses)); + err.statusCode = 400; + throw err; + } + return where; + } var DataType = props[p] && props[p].type; if (!DataType) { continue; @@ -427,10 +502,14 @@ DataAccessObject._coerce = function (where) { // Coerce the array items if (Array.isArray(val)) { for (var i = 0; i < val.length; i++) { - val[i] = DataType(val[i]); + if (val[i] !== null && val[i] !== undefined) { + val[i] = DataType(val[i]); + } } } else { - val = DataType(val); + if (val !== null && val !== undefined) { + val = DataType(val); + } } // Rebuild {property: {operator: value}} if (operator) { @@ -447,7 +526,7 @@ DataAccessObject._coerce = function (where) { * Find all instances of Model that match the specified query. * Fields used for filter and sort should be declared with `{index: true}` in model definition. * See [Querying models](http://docs.strongloop.com/display/DOC/Querying+models) for more information. - * + * * For example, find the second page of ten users over age 21 in descending order exluding the password property. * * ```js @@ -464,7 +543,7 @@ DataAccessObject._coerce = function (where) { * ``` * * @options {Object} [query] Optional JSON object that specifies query criteria and parameters. - * @property {Object} where Search criteria in JSON format `{ key: val, key2: {gt: 'val2'}}`. + * @property {Object} where Search criteria in JSON format `{ key: val, key2: {gt: 'val2'}}`. * Operations: * - gt: > * - gte: >= @@ -476,7 +555,7 @@ DataAccessObject._coerce = function (where) { * - neq: != * - like: LIKE * - nlike: NOT LIKE - * + * * You can also use `and` and `or` operations. See [Querying models](http://docs.strongloop.com/display/DOC/Querying+models) for more information. * @property {String|Object|Array} include Allows you to load relations of several objects and optimize numbers of requests. * Format examples; @@ -495,7 +574,7 @@ DataAccessObject._coerce = function (where) { * - `['foo', 'bar']` - include the foo and bar properties. Format: * - `{foo: true}` - include only foo * - `{bat: false}` - include all properties, exclude bat - * + * * @param {Function} callback Required callback function. Call this function with two arguments: `err` (null or Error) and an array of instances. */ @@ -506,24 +585,21 @@ DataAccessObject.find = function find(query, cb) { cb = query; query = null; } - var constr = this; + var self = this; query = query || {}; - if (query.where) { - query.where = this._coerce(query.where); + try { + this._normalize(query); + } catch (err) { + return process.nextTick(function () { + cb && cb(err); + }); } - var fields = query.fields; var near = query && geo.nearFilter(query.where); var supportsGeo = !!this.getDataSource().connector.buildNearFilter; - // normalize fields as array of included property names - if (fields) { - query.fields = fieldsToArray(fields, Object.keys(this.definition.properties)); - } - - query = removeUndefined(query); if (near) { if (supportsGeo) { // convert it @@ -533,15 +609,15 @@ DataAccessObject.find = function find(query, cb) { // using all documents this.getDataSource().connector.all(this.modelName, {}, function (err, data) { var memory = new Memory(); - var modelName = constr.modelName; + var modelName = self.modelName; if (err) { cb(err); } else if (Array.isArray(data)) { memory.define({ - properties: constr.dataSource.definitions[constr.modelName].properties, - settings: constr.dataSource.definitions[constr.modelName].settings, - model: constr + properties: self.dataSource.definitions[self.modelName].properties, + settings: self.dataSource.definitions[self.modelName].settings, + model: self }); data.forEach(function (obj) { @@ -564,9 +640,7 @@ DataAccessObject.find = function find(query, cb) { this.getDataSource().connector.all(this.modelName, query, function (err, data) { if (data && data.forEach) { data.forEach(function (d, i) { - var obj = new constr(); - - obj._initProperties(d, {fields: query.fields}); + var obj = new self(d, {fields: query.fields, applySetters: false}); if (query && query.include) { if (query.collect) { @@ -643,7 +717,7 @@ DataAccessObject.findOne = function findOne(query, cb) { // removed matching products * }); * ```` - * + * * @param {Object} [where] Optional object that defines the criteria. This is a "where" object. Do NOT pass a filter object. * @param {Function} [cb] Callback called with (err) */ @@ -661,9 +735,15 @@ DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyA if(!err) Model.emit('deletedAll'); }.bind(this)); } else { - // Support an optional where object - where = removeUndefined(where); - where = this._coerce(where); + try { + // Support an optional where object + where = removeUndefined(where); + where = this._coerce(where); + } catch (err) { + return process.nextTick(function() { + cb && cb(err); + }); + } this.getDataSource().connector.destroyAll(this.modelName, where, function (err, data) { cb && cb(err, data); if(!err) Model.emit('deletedAll', where); @@ -672,7 +752,7 @@ DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyA }; /** - * Delete the record with the specified ID. + * Delete the record with the specified ID. * Aliases are `destroyById` and `deleteById`. * @param {*} id The id value * @param {Function} cb Callback called with (err) @@ -693,7 +773,7 @@ DataAccessObject.removeById = DataAccessObject.deleteById = DataAccessObject.des /** * Return count of matched records. Optional query parameter allows you to count filtered set of model instances. * Example: - * + * *```js * User.count({approved: true}, function(err, count) { * console.log(count); // 2081 @@ -710,8 +790,14 @@ DataAccessObject.count = function (where, cb) { cb = where; where = null; } - where = removeUndefined(where); - where = this._coerce(where); + try { + where = removeUndefined(where); + where = this._coerce(where); + } catch (err) { + return process.nextTick(function () { + cb && cb(err); + }); + } this.getDataSource().connector.count(this.modelName, cb, where); }; @@ -793,6 +879,58 @@ DataAccessObject.prototype.save = function (options, callback) { } }; +/** + * Update multiple instances that match the where clause + * + * Example: + * + *```js + * Employee.update({managerId: 'x001'}, {managerId: 'x002'}, function(err) { + * ... + * }); + * ``` + * + * @param {Object} [where] Search conditions (optional) + * @param {Object} data Changes to be made + * @param {Function} cb Callback, called with (err, count) + */ +DataAccessObject.update = +DataAccessObject.updateAll = function (where, data, cb) { + if (stillConnecting(this.getDataSource(), this, arguments)) return; + + if (arguments.length === 1) { + // update(data) is being called + data = where; + where = null; + cb = null; + } else if (arguments.length === 2) { + if (typeof data === 'function') { + // update(data, cb) is being called + cb = data; + data = where; + where = null; + } else { + // update(where, data) is being called + cb = null; + } + } + + assert(typeof where === 'object', 'The where argument should be an object'); + assert(typeof data === 'object', 'The data argument should be an object'); + assert(cb === null || typeof cb === 'function', 'The cb argument should be a function'); + + try { + where = removeUndefined(where); + where = this._coerce(where); + } catch (err) { + return process.nextTick(function () { + cb && cb(err); + }); + } + var connector = this.getDataSource().connector; + connector.update(this.modelName, where, data, cb); +}; + DataAccessObject.prototype.isNewRecord = function () { return !getIdValue(this.constructor, this); }; @@ -891,12 +1029,6 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb } inst._adapter().updateAttributes(model, getIdValue(inst.constructor, inst), inst.constructor._forDB(typedData), function (err) { - if (!err) { - // update $was attrs - for (var key in data) { - inst.__dataWas[key] = inst.__data[key]; - } - } done.call(inst, function () { saveDone.call(inst, function () { if(cb) cb(err, inst); @@ -914,6 +1046,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb * Reload object from persistence * Requires `id` member of `object` to be able to call `find` * @param {Function} callback Called with (err, instance) arguments + * @private */ DataAccessObject.prototype.reload = function reload(callback) { if (stillConnecting(this.getDataSource(), this, arguments)) { @@ -924,12 +1057,13 @@ DataAccessObject.prototype.reload = function reload(callback) { }; -/*! +/* * Define readonly property on object * * @param {Object} obj * @param {String} key * @param {Mixed} value + * @private */ function defineReadonlyProp(obj, key, value) { Object.defineProperty(obj, key, { @@ -955,12 +1089,12 @@ DataAccessObject.scope = function (name, query, targetClass) { defineScope(this, targetClass || this, name, query); }; -/*! +/* * Add 'include' */ jutil.mixin(DataAccessObject, Inclusion); -/*! +/* * Add 'relation' */ jutil.mixin(DataAccessObject, Relation); diff --git a/lib/datasource.js b/lib/datasource.js index e20d481e..7c8b4190 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -494,7 +494,11 @@ DataSource.prototype.setupDataAccess = function (modelClass, settings) { }; /** - * Define a model class. + * Define a model class. Returns newly created model object. + * The first (String) argument specifying the model name is required. + * You can provide one or two JSON object arguments, to provide configuration options. + * See [Model definition reference](http://docs.strongloop.com/display/DOC/Model+definition+reference) for details. + * * Simple example: * ``` * var User = dataSource.createModel('User', { @@ -533,9 +537,8 @@ DataSource.prototype.setupDataAccess = function (modelClass, settings) { * ``` * * @param {String} className Name of the model to create. - * @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 settings. - * @returns newly created class + * @param {Object} properties Hash of model properties in format `{property: Type, property2: Type2, ...}` or `{property: {type: Type}, property2: {type: Type2}, ...}` + * @options {Object} properties Other configuration options. This corresponds to the options key in the config object. * */ @@ -584,6 +587,7 @@ DataSource.prototype.createModel = DataSource.prototype.define = function define * Mixin DataAccessObject methods. * * @param {Function} ModelCtor The model constructor + * @private */ DataSource.prototype.mixin = function (ModelCtor) { @@ -716,12 +720,14 @@ DataSource.prototype.defineProperty = function (model, prop, params) { /** * Drop each model table and re-create. - * This method applies only to SQL connectors. - * + * This method applies only to database connectors. For MongoDB, it drops and creates indexes. + * + * **WARNING**: Calling this function deletes all data! Use `autoupdate()` to preserve data. + * * @param {String} model Model to migrate. If not present, apply to all models. Can also be an array of Strings. - * @param {Function} cb Callback function. Optional. + * @param {Function} [callback] Callback function. Optional. * - * WARNING: Calling this function will cause all data to be lost! Use autoupdate if you need to preserve data. + */ DataSource.prototype.automigrate = function (models, cb) { this.freeze(); @@ -738,7 +744,7 @@ DataSource.prototype.automigrate = function (models, cb) { /** * Update existing database tables. - * This method make sense only for sql connectors. + * This method applies only to database connectors. * * @param {String} model Model to migrate. If not present, apply to all models. Can also be an array of Strings. * @param {Function} [cb] The callback function @@ -762,14 +768,11 @@ DataSource.prototype.autoupdate = function (models, cb) { * * @param {Object} options The options * @param {Function} Callback function. Optional. - * @options {Object} options Discovery options. - * - * Keys in options object: - * - * @property all {Boolean} If true, discover all models; if false, discover only models owned by the current user. - * @property views {Boolean} If true, nclude views; if false, only tables. - * @property limit {Number} Page size - * @property offset {Number} Starting index + * @options {Object} options Discovery options. See below. + * @property {Boolean} all If true, discover all models; if false, discover only models owned by the current user. + * @property {Boolean} views If true, nclude views; if false, only tables. + * @property {Number} limit Page size + * @property {Number} offset Starting index * */ DataSource.prototype.discoverModelDefinitions = function (options, cb) { @@ -782,8 +785,12 @@ DataSource.prototype.discoverModelDefinitions = function (options, cb) { }; /** - * The synchronous version of discoverModelDefinitions - * @param {Object} options The options + * The synchronous version of discoverModelDefinitions. + * @options {Object} options The options + * @property {Boolean} all If true, discover all models; if false, discover only models owned by the current user. + * @property {Boolean} views If true, nclude views; if false, only tables. + * @property {Number} limit Page size + * @property {Number} offset Starting index * @returns {*} */ DataSource.prototype.discoverModelDefinitionsSync = function (options) { @@ -797,24 +804,22 @@ DataSource.prototype.discoverModelDefinitionsSync = function (options) { /** * Discover properties for a given model. * - * property description: + * Callback function return value is an object that can have the following properties: * -*| Key | Type | Description | -*|-----|------|-------------| -*|owner | String | Database owner or schema| -*|tableName | String | Table/view name| -*|columnName | String | Column name| -*|dataType | String | Data type| -*|dataLength | Number | Data length| -*|dataPrecision | Number | Numeric data precision| -*|dataScale |Number | Numeric data scale| -*|nullable |Boolean | If true, then the data can be null| - * - * Options: - * - owner/schema The database owner/schema + *| Key | Type | Description | + *|-----|------|-------------| + *|owner | String | Database owner or schema| + *|tableName | String | Table/view name| + *|columnName | String | Column name| + *|dataType | String | Data type| + *|dataLength | Number | Data length| + *|dataPrecision | Number | Numeric data precision| + *|dataScale |Number | Numeric data scale| + *|nullable |Boolean | If true, then the data can be null| * * @param {String} modelName The table/view name - * @param {Object} options The options + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema * @param {Function} cb Callback function. Optional * */ @@ -843,22 +848,19 @@ DataSource.prototype.discoverModelPropertiesSync = function (modelName, options) /** * Discover primary keys for a given owner/modelName. + * Callback function return value is an object that can have the following properties: * - * Each primary key column description has the following columns: - * - * - owner {String} => table schema (may be null) - * - tableName {String} => table name - * - columnName {String} => column name - * - keySeq {Number} => sequence number within primary key( a value of 1 represents the first column of the primary key, a value of 2 would represent the second column within the primary key). - * - pkName {String} => primary key name (may be null) - * - * The owner defaults to current user. - * - * Options: - * - owner/schema The database owner/schema + *| Key | Type | Description | + *|-----|------|-------------| + *| owner |String | Table schema or owner (may be null). Owner defaults to current user. + *| tableName |String| Table name + *| columnName |String| Column name + *| keySeq |Number| Sequence number within primary key (1 indicates the first column in the primary key; 2 indicates the second column in the primary key). + *| pkName |String| Primary key name (may be null) * * @param {String} modelName The model name - * @param {Object} options The options + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema * @param {Function} [cb] The callback function */ DataSource.prototype.discoverPrimaryKeys = function (modelName, options, cb) { @@ -873,7 +875,8 @@ DataSource.prototype.discoverPrimaryKeys = function (modelName, options, cb) { /** * The synchronous version of discoverPrimaryKeys * @param {String} modelName The model name - * @param {Object} options The options + * @options {Object} options The options + * @property {String} owner|schema The database owner orschema * @returns {*} */ DataSource.prototype.discoverPrimaryKeysSync = function (modelName, options) { @@ -887,24 +890,23 @@ DataSource.prototype.discoverPrimaryKeysSync = function (modelName, options) { /** * Discover foreign keys for a given owner/modelName * - * `foreign key description` + * Callback function return value is an object that can have the following properties: * - * fkOwner String => foreign key table schema (may be null) - * fkName String => foreign key name (may be null) - * fkTableName String => foreign key table name - * fkColumnName String => foreign key column name - * keySeq Number => sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). - * pkOwner String => primary key table schema being imported (may be null) - * pkName String => primary key name (may be null) - * pkTableName String => primary key table name being imported - * pkColumnName String => primary key column name being imported - * - * `options` - * - * owner/schema The database owner/schema + *| Key | Type | Description | + *|-----|------|-------------| + *|fkOwner |String | Foreign key table schema (may be null) + *|fkName |String | Foreign key name (may be null) + *|fkTableName |String | Foreign key table name + *|fkColumnName |String | Foreign key column name + *|keySeq |Number | Sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). + *|pkOwner |String | Primary key table schema being imported (may be null) + *|pkName |String | Primary key name (may be null) + *|pkTableName |String | Primary key table name being imported + *|pkColumnName |String | Primary key column name being imported * * @param {String} modelName The model name - * @param {Object} options The options + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema * @param {Function} [cb] The callback function * */ @@ -933,27 +935,26 @@ DataSource.prototype.discoverForeignKeysSync = function (modelName, options) { }; /** - * Retrieves a description of the foreign key columns that reference the given table's primary key columns (the foreign keys exported by a table). - * They are ordered by fkTableOwner, fkTableName, and keySeq. + * Retrieves a description of the foreign key columns that reference the given table's primary key columns + * (the foreign keys exported by a table), ordered by fkTableOwner, fkTableName, and keySeq. + * + * Callback function return value is an object that can have the following properties: * - * `foreign key description` - * - * fkOwner {String} => foreign key table schema (may be null) - * fkName {String} => foreign key name (may be null) - * fkTableName {String} => foreign key table name - * fkColumnName {String} => foreign key column name - * keySeq {Number} => sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). - * pkOwner {String} => primary key table schema being imported (may be null) - * pkName {String} => primary key name (may be null) - * pkTableName {String} => primary key table name being imported - * pkColumnName {String} => primary key column name being imported - * - * `options` - * - * owner/schema The database owner/schema + *| Key | Type | Description | + *|-----|------|-------------| + *|fkOwner |String | Foreign key table schema (may be null) + *|fkName |String | Foreign key name (may be null) + *|fkTableName |String | Foreign key table name + *|fkColumnName |String | Foreign key column name + *|keySeq |Number | Sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). + *|pkOwner |String | Primary key table schema being imported (may be null) + *|pkName |String | Primary key name (may be null) + *|pkTableName |String | Primary key table name being imported + *|pkColumnName |String | Primary key column name being imported * * @param {String} modelName The model name - * @param {Object} options The options + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema * @param {Function} [cb] The callback function */ DataSource.prototype.discoverExportedForeignKeys = function (modelName, options, cb) { @@ -1080,17 +1081,14 @@ DataSource.prototype.discoverSchema = function (modelName, options, cb) { }; /** - * Discover schema from a given modelName/view + * Discover schema from a given modelName/view. * - * `options` - * - * {String} owner/schema - The database owner/schema name - * {Boolean} relations - If relations (primary key/foreign key) are navigated - * {Boolean} all - If all owners are included - * {Boolean} views - If views are included - * - * @param {String} modelName The model name - * @param {Object} [options] The options + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. * @param {Function} [cb] The callback function */ DataSource.prototype.discoverSchemas = function (modelName, options, cb) { @@ -1252,15 +1250,12 @@ DataSource.prototype.discoverSchemas = function (modelName, options, cb) { /** * Discover schema from a given table/view synchronously * - * `options` - * - * {String} owner/schema - The database owner/schema name - * {Boolean} relations - If relations (primary key/foreign key) are navigated - * {Boolean} all - If all owners are included - * {Boolean} views - If views are included - * * @param {String} modelName The model name - * @param {Object} [options] The options + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. */ DataSource.prototype.discoverSchemasSync = function (modelName, options) { var self = this; @@ -1392,17 +1387,14 @@ DataSource.prototype.discoverSchemasSync = function (modelName, options) { }; /** - * Discover and build models from the given owner/modelName + * Discover and build models from the specified owner/modelName. * - * `options` - * - * {String} owner/schema - The database owner/schema name - * {Boolean} relations - If relations (primary key/foreign key) are navigated - * {Boolean} all - If all owners are included - * {Boolean} views - If views are included - * - * @param {String} modelName The model name - * @param {Object} [options] The options + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. * @param {Function} [cb] The callback function */ DataSource.prototype.discoverAndBuildModels = function (modelName, options, cb) { @@ -1429,15 +1421,15 @@ DataSource.prototype.discoverAndBuildModels = function (modelName, options, cb) }; /** - * Discover and build models from the given owner/modelName synchronously - * - * `options` - * - * {String} owner/schema - The database owner/schema name - * {Boolean} relations - If relations (primary key/foreign key) are navigated - * {Boolean} all - If all owners are included - * {Boolean} views - If views are included + * Discover and build models from the given owner/modelName synchronously. * + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. + * @param {String} modelName The model name * @param {Object} [options] The options */ @@ -1456,8 +1448,8 @@ DataSource.prototype.discoverAndBuildModelsSync = function (modelName, options) /** * Check whether migrations needed - * This method make sense only for sql connectors. - * @param {String[]} [models] A model name or an array of model names. If not present, apply to all models + * This method applies only to SQL connectors. + * @param {String|String[]} [models] A model name or an array of model names. If not present, apply to all models. */ DataSource.prototype.isActual = function (models, cb) { this.freeze(); @@ -1513,8 +1505,8 @@ DataSource.prototype.tableName = function (modelName) { /** * Return column name for specified modelName and propertyName * @param {String} modelName The model name - * @param propertyName The property name - * @returns {String} columnName + * @param {String} propertyName The property name + * @returns {String} columnName The column name. */ DataSource.prototype.columnName = function (modelName, propertyName) { return this.getModelDefinition(modelName).columnName(this.connector.name, propertyName); @@ -1523,7 +1515,7 @@ DataSource.prototype.columnName = function (modelName, propertyName) { /** * Return column metadata for specified modelName and propertyName * @param {String} modelName The model name - * @param propertyName The property name + * @param {String} propertyName The property name * @returns {Object} column metadata */ DataSource.prototype.columnMetadata = function (modelName, propertyName) { @@ -1622,7 +1614,7 @@ DataSource.prototype.defineForeignKey = function defineForeignKey(className, key /** * Close database connection - * @param {Fucntion} cb The callback function. Optional. + * @param {Function} [cb] The callback function. Optional. */ DataSource.prototype.disconnect = function disconnect(cb) { var self = this; @@ -1738,7 +1730,6 @@ DataSource.prototype.enableRemote = function (operation) { * and disabled operations. To list the operations, call `dataSource.operations()`. * *```js - * * var oracle = loopback.createDataSource({ * connector: require('loopback-connector-oracle'), * host: '...', @@ -1749,8 +1740,8 @@ DataSource.prototype.enableRemote = function (operation) { * **Notes:** * * - Disabled operations will not be added to attached models. - * - Disabling the remoting for a method only affects client access (it will still be available from server models). - * - Data sources must enable / disable operations before attaching or creating models. + * - Disabling the remoting for a method only affects client access (it will still be available from server models). + * - Data sources must enable / disable operations before attaching or creating models. * @param {String} operation The operation name */ @@ -1828,11 +1819,11 @@ DataSource.prototype.isRelational = function () { return this.connector && this.connector.relational; }; -/** +/*! * Check if the data source is ready. * Returns a Boolean value. - * @param obj {Object} ? - * @param args {Object} ? + * @param {Object} obj ? + * @param {Object} args ? */ DataSource.prototype.ready = function (obj, args) { var self = this; diff --git a/lib/geo.js b/lib/geo.js index 1dac1abd..b5b8cd3b 100644 --- a/lib/geo.js +++ b/lib/geo.js @@ -106,9 +106,12 @@ exports.GeoPoint = GeoPoint; * }); * ``` * @class GeoPoint - * @param {Object} latlong Object with two Number properties: lat and long. - * @prop {Number} lat - * @prop {Number} lng + * @property {Number} lat The latitude in degrees. + * @property {Number} lng The longitude in degrees. + * + * @options {Object} Options Object with two Number properties: lat and long. + * @property {Number} lat The latitude point in degrees. Range: -90 to 90. + * @property {Number} lng The longitude point in degrees. Range: -90 to 90. */ function GeoPoint(data) { @@ -138,20 +141,18 @@ function GeoPoint(data) { assert(data.lat <= 90, 'lat must be <= 90'); assert(data.lat >= -90, 'lat must be >= -90'); -/** - * @property {Number} lat The latitude point in degrees. Range: -90 to 90. -*/ - this.lat = data.lat; - -/** - * @property {Number} lng The longitude point in degrees. Range: -90 to 90. -*/ + this.lat = data.lat; this.lng = data.lng; } /** * Determine the spherical distance between two GeoPoints. - * Specify units of measurement with the 'type' property in options object. Type can be: + * + * @param {GeoPoint} pointA Point A + * @param {GeoPoint} pointB Point B + * @options {Object} options Options object with one key, 'type'. See below. + * @property {String} type Unit of measurement, one of: + * * - `miles` (default) * - `radians` * - `kilometers` @@ -159,9 +160,6 @@ function GeoPoint(data) { * - `miles` * - `feet` * - `degrees` - * @param {GeoPoint} pointA Point A - * @param {GeoPoint} pointB Point B - * @param {Object} options Options object; has one key, 'type', to specify the units of measurment (see above). Default is miles. */ GeoPoint.distanceBetween = function distanceBetween(a, b, options) { @@ -190,7 +188,16 @@ GeoPoint.distanceBetween = function distanceBetween(a, b, options) { * GeoPoint.distanceBetween(here, there, {type: 'miles'}) // 438 * ``` * @param {Object} point GeoPoint object to which to measure distance. - * @param {Object} options Use type key to specify units of measurment (default is miles). + * @options {Object} options Options object with one key, 'type'. See below. + * @property {String} type Unit of measurement, one of: + * + * - `miles` (default) + * - `radians` + * - `kilometers` + * - `meters` + * - `miles` + * - `feet` + * - `degrees` */ GeoPoint.prototype.distanceTo = function (point, options) { diff --git a/lib/hooks.js b/lib/hooks.js index 815d1aa2..9288f55b 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -1,10 +1,11 @@ -/** +/*! * Module exports */ module.exports = Hookable; -/** - * Hooks mixins +/* + * Hooks object. + * @class Hookable */ function Hookable() { diff --git a/lib/include.js b/lib/include.js index c459451b..f276b3d9 100644 --- a/lib/include.js +++ b/lib/include.js @@ -36,7 +36,7 @@ function Inclusion() { *``` Passport.include(passports, {owner: [{posts: 'images'}, 'passports']}); * * @param {Array} objects Array of instances - * @param {String}, {Object} or {Array} include Which relations to load. + * @param {String|Object|Array} include Which relations to load. * @param {Function} cb Callback called when relations are loaded * */ diff --git a/lib/list.js b/lib/list.js index 3e6faa1e..4c4158eb 100644 --- a/lib/list.js +++ b/lib/list.js @@ -13,7 +13,7 @@ function List(items, itemType, parent) { try { items = JSON.parse(items); } catch (e) { - throw new Error('could not create List from JSON string: ', items); + throw new Error(util.format('could not create List from JSON string: %j', items)); } } @@ -22,7 +22,7 @@ function List(items, itemType, parent) { items = items || []; if (!Array.isArray(items)) { - throw new Error('Items must be an array: ' + items); + throw new Error(util.format('Items must be an array: %j', items)); } if(!itemType) { @@ -92,12 +92,3 @@ List.prototype.toString = function () { return JSON.stringify(this.toJSON()); }; -/* - var strArray = new List(['1', 2], String); - strArray.push(3); - console.log(strArray); - console.log(strArray.length); - - console.log(strArray.toJSON()); - console.log(strArray.toString()); - */ diff --git a/lib/model-builder.js b/lib/model-builder.js index 90601ccf..ecbbe9a4 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -28,18 +28,14 @@ var slice = Array.prototype.slice; /** * ModelBuilder - A builder to define data models. - * + * + * @property {Object} definitions Definitions of the models. + * @property {Object} models Model constructors * @class */ function ModelBuilder() { // create blank models pool - /** - * @property {Object} models Model constructors - */ this.models = {}; - /** - * @property {Object} definitions Definitions of the models - */ this.definitions = {}; } @@ -61,7 +57,7 @@ function isModelClass(cls) { * * @param {String} name The model name * @param {Boolean} forceCreate Whether the create a stub for the given name if a model doesn't exist. - * Returns {*} The model class + * @returns {*} The model class */ ModelBuilder.prototype.getModel = function (name, forceCreate) { var model = this.models[name]; @@ -268,7 +264,11 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett // A function to loop through the properties ModelClass.forEachProperty = function (cb) { - Object.keys(ModelClass.definition.properties).forEach(cb); + var props = ModelClass.definition.properties; + var keys = Object.keys(props); + for (var i = 0, n = keys.length; i < n; i++) { + cb(keys[i], props[keys[i]]); + } }; // A function to attach the model class to a data source @@ -276,7 +276,26 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett dataSource.attach(this); }; - // A function to extend the model + /** Extend the model with the specified model, properties, and other settings. + * For example, to extend an existing model, for example, a built-in model: + * + * ```js + * var Customer = User.extend('customer', { + * accountId: String, + * vip: Boolean + * }); + * ``` + * + * To extend the base model, essentially creating a new model: + * ```js + * var user = loopback.Model.extend('user', properties, options); + * ``` + * + * @param {String} className Name of the new model being defined. + * @options {Object} properties Properties to define for the model, added to properties of model being extended. + * @options {Object} settings Model settings, such as relations and acls. + * + */ ModelClass.extend = function (className, subclassProperties, subclassSettings) { var properties = ModelClass.definition.properties; var settings = ModelClass.definition.settings; @@ -294,27 +313,22 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett } // Merging the properties - Object.keys(properties).forEach(function (key) { + var keys = Object.keys(properties); + for (var i = 0, n = keys.length; i < n; i++) { + var key = keys[i]; + if (idFound && properties[key].id) { // don't inherit id properties - return; + continue; } if (subclassProperties[key] === undefined) { subclassProperties[key] = properties[key]; } - }); + } // Merge the settings subclassSettings = mergeSettings(settings, subclassSettings); - /* - Object.keys(settings).forEach(function (key) { - if(subclassSettings[key] === undefined) { - subclassSettings[key] = settings[key]; - } - }); - */ - // Define the subclass var subClass = modelBuilder.define(className, subclassProperties, subclassSettings, ModelClass); @@ -355,20 +369,15 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett var DataType = ModelClass.definition.properties[propertyName].type; if (Array.isArray(DataType) || DataType === Array) { DataType = List; - } else if (DataType.name === 'Date') { - var OrigDate = Date; - DataType = function Date(arg) { - return new OrigDate(arg); - }; + } else if (DataType === Date) { + DataType = DateType; } else if (typeof DataType === 'string') { DataType = modelBuilder.resolveType(DataType); } if (ModelClass.setter[propertyName]) { ModelClass.setter[propertyName].call(this, value); // Try setter first } else { - if (!this.__data) { - this.__data = {}; - } + this.__data = this.__data || {}; if (value === null || value === undefined) { this.__data[propertyName] = value; } else { @@ -386,15 +395,6 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett enumerable: true }); - // $was --> __dataWas. - Object.defineProperty(ModelClass.prototype, propertyName + '$was', { - get: function () { - return this.__dataWas && this.__dataWas[propertyName]; - }, - configurable: true, - enumerable: false - }); - // FIXME: [rfeng] Do we need to keep the raw data? // Use $ as the prefix to avoid conflicts with properties such as _id Object.defineProperty(ModelClass.prototype, '$' + propertyName, { @@ -412,7 +412,13 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett }); }; - ModelClass.forEachProperty(ModelClass.registerProperty); + var props = ModelClass.definition.properties; + var keys = Object.keys(props); + var size = keys.length; + for (i = 0; i < size; i++) { + var propertyName = keys[i]; + ModelClass.registerProperty(propertyName); + } ModelClass.emit('defined', ModelClass); @@ -420,6 +426,11 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett }; +// DataType for Date +function DateType(arg) { + return new Date(arg); +} + /** * Define single property named `propertyName` on `model` * @@ -442,17 +453,20 @@ ModelBuilder.prototype.defineValueType = function(type, aliases) { }; /** - * Extend existing model with bunch of properties + * Extend existing model with specified properties * * Example: - * Instead of doing amending the content model with competition attributes like this: + * Instead of extending a model with attributes like this (for example): * * ```js - * db.defineProperty('Content', 'competitionType', { type: String }); - * db.defineProperty('Content', 'expiryDate', { type: Date, index: true }); - * db.defineProperty('Content', 'isExpired', { type: Boolean, index: true }); + * db.defineProperty('Content', 'competitionType', + * { type: String }); + * db.defineProperty('Content', 'expiryDate', + * { type: Date, index: true }); + * db.defineProperty('Content', 'isExpired', + * { type: Boolean, index: true }); *``` - * The extendModel() method enables you to extend the content model with competition attributes. + * This method enables you to extend a model as follows (for example): * ```js * db.extendModel('Content', { * competitionType: String, @@ -462,14 +476,19 @@ ModelBuilder.prototype.defineValueType = function(type, aliases) { *``` * * @param {String} model Name of model - * @param {Object} props Hash of properties + * @options {Object} properties JSON object specifying properties. Each property is a key whos value is + * either the [type](http://docs.strongloop.com/display/DOC/LDL+data+types) or `propertyName: {options}` + * where the options are described below. + * @property {String} type Datatype of property: Must be an [LDL type](http://docs.strongloop.com/display/DOC/LDL+data+types). + * @property {Boolean} index True if the property is an index; false otherwise. */ ModelBuilder.prototype.extendModel = function (model, props) { var t = this; - Object.keys(props).forEach(function (propName) { - var definition = props[propName]; - t.defineProperty(model, propName, definition); - }); + var keys = Object.keys(props); + for (var i = 0; i < keys.length; i++) { + var definition = props[keys[i]]; + t.defineProperty(model, keys[i], definition); + } }; ModelBuilder.prototype.copyModel = function copyModel(Master) { diff --git a/lib/model.js b/lib/model.js index 508c5b6d..aaf5e29f 100644 --- a/lib/model.js +++ b/lib/model.js @@ -8,13 +8,20 @@ module.exports = ModelBaseClass; */ var util = require('util'); -var traverse = require('traverse'); var jutil = require('./jutil'); var List = require('./list'); var Hookable = require('./hooks'); var validations = require('./validations.js'); -var BASE_TYPES = ['String', 'Boolean', 'Number', 'Date', 'Text', 'ObjectID']; +// Set up an object for quick lookup +var BASE_TYPES = { + 'String': true, + 'Boolean': true, + 'Number': true, + 'Date': true, + 'Text': true, + 'ObjectID': true +}; /** * Model class: base class for all persistent objects. @@ -33,18 +40,6 @@ function ModelBaseClass(data, options) { this._initProperties(data, options); } -// FIXME: [rfeng] We need to make sure the input data should not be mutated. Disabled cloning for now to get tests passing -function clone(data) { - /* - if(!(data instanceof ModelBaseClass)) { - if(data && (Array.isArray(data) || 'object' === typeof data)) { - return traverse(data).clone(); - } - } - */ - return data; -} - /** * Initialize the model instance with a list of properties * @param {Object} data The data object @@ -58,10 +53,10 @@ ModelBaseClass.prototype._initProperties = function (data, options) { var ctor = this.constructor; if(data instanceof ctor) { - // Convert the data to be plain object to avoid polutions + // Convert the data to be plain object to avoid pollutions data = data.toObject(false); } - var properties = ctor.definition.build(); + var properties = ctor.definition.properties; data = data || {}; options = options || {}; @@ -71,133 +66,124 @@ ModelBaseClass.prototype._initProperties = function (data, options) { if(strict === undefined) { strict = ctor.definition.settings.strict; } - Object.defineProperty(this, '__cachedRelations', { - writable: true, - enumerable: false, - configurable: true, - value: {} - }); - Object.defineProperty(this, '__data', { - writable: true, - enumerable: false, - configurable: true, - value: {} - }); + if (ctor.hideInternalProperties) { + // Object.defineProperty() is expensive. We only try to make the internal + // properties hidden (non-enumerable) if the model class has the + // `hideInternalProperties` set to true + Object.defineProperties(this, { + __cachedRelations: { + writable: true, + enumerable: false, + configurable: true, + value: {} + }, - Object.defineProperty(this, '__dataWas', { - writable: true, - enumerable: false, - configurable: true, - value: {} - }); + __data: { + writable: true, + enumerable: false, + configurable: true, + value: {} + }, - /** - * Instance level data source - */ - Object.defineProperty(this, '__dataSource', { - writable: true, - enumerable: false, - configurable: true, - value: options.dataSource - }); + // Instance level data source + __dataSource: { + writable: true, + enumerable: false, + configurable: true, + value: options.dataSource + }, - /** - * Instance level strict mode - */ - Object.defineProperty(this, '__strict', { - writable: true, - enumerable: false, - configurable: true, - value: strict - }); + // Instance level strict mode + __strict: { + writable: true, + enumerable: false, + configurable: true, + value: strict + } + }); + } else { + this.__cachedRelations = {}; + this.__data = {}; + this.__dataSource = options.dataSource; + this.__strict = strict; + } if (data.__cachedRelations) { this.__cachedRelations = data.__cachedRelations; } - for (var i in data) { - if (i in properties && typeof data[i] !== 'function') { - this.__data[i] = this.__dataWas[i] = clone(data[i]); - } else if (i in ctor.relations) { - if (ctor.relations[i].type === 'belongsTo' && data[i] !== null && data[i] !== undefined) { + var keys = Object.keys(data); + var size = keys.length; + var p, propVal; + for (var k = 0; k < size; k++) { + p = keys[k]; + propVal = data[p]; + if (typeof propVal === 'function') { + continue; + } + if (properties[p]) { + // Managed property + if (applySetters) { + self[p] = propVal; + } else { + self.__data[p] = propVal; + } + } else if (ctor.relations[p]) { + // Relation + if (ctor.relations[p].type === 'belongsTo' && propVal != null) { // If the related model is populated - this.__data[ctor.relations[i].keyFrom] = this.__dataWas[i] = data[i][ctor.relations[i].keyTo]; + self.__data[ctor.relations[p].keyFrom] = propVal[ctor.relations[p].keyTo]; } - this.__cachedRelations[i] = data[i]; + self.__cachedRelations[p] = propVal; } else { + // Un-managed property if (strict === false) { - this.__data[i] = this.__dataWas[i] = clone(data[i]); + self[p] = self.__data[p] = propVal; } else if (strict === 'throw') { - throw new Error('Unknown property: ' + i); + throw new Error('Unknown property: ' + p); } } } - var propertyName; - if (applySetters === true) { - for (propertyName in data) { - if (typeof data[propertyName] !== 'function' && ((propertyName in properties) || (propertyName in ctor.relations))) { - self[propertyName] = self.__data[propertyName] || data[propertyName]; + keys = Object.keys(properties); + size = keys.length; + + for (k = 0; k < size; k++) { + p = keys[k]; + propVal = self.__data[p]; + + // Set default values + if (propVal === undefined) { + var def = properties[p]['default']; + if (def !== undefined) { + if (typeof def === 'function') { + self.__data[p] = def(); + } else { + self.__data[p] = def; + } } } - } - // Set the unknown properties as properties to the object - if (strict === false) { - for (propertyName in data) { - if (typeof data[propertyName] !== 'function' && !(propertyName in properties)) { - self[propertyName] = self.__data[propertyName] || data[propertyName]; - } - } - } - - ctor.forEachProperty(function (propertyName) { - - if (undefined === self.__data[propertyName]) { - self.__data[propertyName] = self.__dataWas[propertyName] = getDefault(propertyName); - } else { - self.__dataWas[propertyName] = self.__data[propertyName]; - } - - }); - - ctor.forEachProperty(function (propertyName) { - - var type = properties[propertyName].type; - - if (BASE_TYPES.indexOf(type.name) === -1) { - if (typeof self.__data[propertyName] !== 'object' && self.__data[propertyName]) { + // Handle complex types (JSON/Object) + var type = properties[p].type; + if (! BASE_TYPES[type.name]) { + if (typeof self.__data[p] !== 'object' && self.__data[p]) { try { - self.__data[propertyName] = JSON.parse(self.__data[propertyName] + ''); + self.__data[p] = JSON.parse(self.__data[p] + ''); } catch (e) { - self.__data[propertyName] = String(self.__data[propertyName]); + self.__data[p] = String(self.__data[p]); } } if (type.name === 'Array' || Array.isArray(type)) { - if (!(self.__data[propertyName] instanceof List) - && self.__data[propertyName] !== undefined - && self.__data[propertyName] !== null ) { - self.__data[propertyName] = List(self.__data[propertyName], type, self); + if (!(self.__data[p] instanceof List) + && self.__data[p] !== undefined + && self.__data[p] !== null ) { + self.__data[p] = List(self.__data[p], type, self); } } } - - }); - - function getDefault(propertyName) { - var def = properties[propertyName]['default']; - if (def !== undefined) { - if (typeof def === 'function') { - return def(); - } else { - return def; - } - } else { - return undefined; - } } - this.trigger('initialize'); }; @@ -242,7 +228,7 @@ ModelBaseClass.toString = function () { * @param {Boolean} onlySchema Restrict properties to dataSource only. Default is false. If true, the function returns only properties defined in the schema; Otherwise it returns all enumerable properties. */ ModelBaseClass.prototype.toObject = function (onlySchema, removeHidden) { - if(onlySchema === undefined) { + if (onlySchema === undefined) { onlySchema = true; } var data = {}; @@ -250,47 +236,63 @@ ModelBaseClass.prototype.toObject = function (onlySchema, removeHidden) { var Model = this.constructor; // if it is already an Object - if(Model === Object) return self; + if (Model === Object) { + return self; + } var strict = this.__strict; var schemaLess = (strict === false) || !onlySchema; - this.constructor.forEachProperty(function (propertyName) { - if (removeHidden && Model.isHiddenProperty(propertyName)) { - return; - } - if (typeof self[propertyName] === 'function') { - return; - } - - if (self[propertyName] instanceof List) { - data[propertyName] = self[propertyName].toObject(!schemaLess, removeHidden); - } else if (self.__data.hasOwnProperty(propertyName)) { - if (self[propertyName] !== undefined && self[propertyName] !== null && self[propertyName].toObject) { - data[propertyName] = self[propertyName].toObject(!schemaLess, removeHidden); - } else { - data[propertyName] = self[propertyName]; - } - } else { - data[propertyName] = null; - } - }); + var props = Model.definition.properties; + var keys = Object.keys(props); + var propertyName, val; + for (var i = 0; i < keys.length; i++) { + propertyName = keys[i]; + val = self[propertyName]; + + // Exclude functions + if (typeof val === 'function') { + continue; + } + // Exclude hidden properties + if (removeHidden && Model.isHiddenProperty(propertyName)) { + continue; + } + + if (val instanceof List) { + data[propertyName] = val.toObject(!schemaLess, removeHidden); + } else { + if (val !== undefined && val !== null && val.toObject) { + data[propertyName] = val.toObject(!schemaLess, removeHidden); + } else { + data[propertyName] = val; + } + } + } - var val = null; if (schemaLess) { // Find its own properties which can be set via myModel.myProperty = 'myValue'. // If the property is not declared in the model definition, no setter will be // triggered to add it to __data - for (var propertyName in self) { - if(removeHidden && Model.isHiddenProperty(propertyName)) { + keys = Object.keys(self); + var size = keys.length; + for (i = 0; i < size; i++) { + propertyName = keys[i]; + if (props[propertyName]) { continue; } - if(self.hasOwnProperty(propertyName) && (!data.hasOwnProperty(propertyName))) { - val = self[propertyName]; + if (propertyName.indexOf('__') === 0) { + continue; + } + if (removeHidden && Model.isHiddenProperty(propertyName)) { + continue; + } + val = self[propertyName]; + if (val !== undefined && data[propertyName] === undefined) { if (typeof val === 'function') { continue; } - if (val !== undefined && val !== null && val.toObject) { + if (val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess, removeHidden); } else { data[propertyName] = val; @@ -298,15 +300,25 @@ ModelBaseClass.prototype.toObject = function (onlySchema, removeHidden) { } } // Now continue to check __data - for (propertyName in self.__data) { - if (!data.hasOwnProperty(propertyName)) { - if(removeHidden && Model.isHiddenProperty(propertyName)) { + keys = Object.keys(self.__data); + size = keys.length; + for (i = 0; i < size; i++) { + propertyName = keys[i]; + if (propertyName.indexOf('__') === 0) { + continue; + } + if (data[propertyName] === undefined) { + if (removeHidden && Model.isHiddenProperty(propertyName)) { continue; } - val = self.hasOwnProperty(propertyName) ? self[propertyName] : self.__data[propertyName]; + var ownVal = self[propertyName]; + // The ownVal can be a relation function + val = (ownVal !== undefined && (typeof ownVal !== 'function')) + ? ownVal : self.__data[propertyName]; if (typeof val === 'function') { continue; } + if (val !== undefined && val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess, removeHidden); } else { @@ -319,12 +331,20 @@ ModelBaseClass.prototype.toObject = function (onlySchema, removeHidden) { return data; }; -ModelBaseClass.isHiddenProperty = function(propertyName) { +ModelBaseClass.isHiddenProperty = function (propertyName) { var Model = this; var settings = Model.definition && Model.definition.settings; - var hiddenProperties = settings && settings.hidden; - if(hiddenProperties) { - return ~hiddenProperties.indexOf(propertyName); + var hiddenProperties = settings && (settings.hiddenProperties || settings.hidden); + if (Array.isArray(hiddenProperties)) { + // Cache the hidden properties as an object for quick lookup + settings.hiddenProperties = {}; + for (var i = 0; i < hiddenProperties.length; i++) { + settings.hiddenProperties[hiddenProperties[i]] = true; + } + hiddenProperties = settings.hiddenProperties; + } + if (hiddenProperties) { + return hiddenProperties[propertyName]; } else { return false; } @@ -340,16 +360,6 @@ ModelBaseClass.prototype.fromObject = function (obj) { } }; -/** - * Checks is property changed based on current property and initial value - * - * @param {String} propertyName Property name - * @return Boolean - */ -ModelBaseClass.prototype.propertyChanged = function propertyChanged(propertyName) { - return this.__data[propertyName] !== this.__dataWas[propertyName]; -}; - /** * Reset dirty attributes. * This method does not perform any database operations; it just resets the object to its @@ -361,9 +371,6 @@ ModelBaseClass.prototype.reset = function () { if (k !== 'id' && !obj.constructor.dataSource.definitions[obj.constructor.modelName].properties[k]) { delete obj[k]; } - if (obj.propertyChanged(k)) { - obj[k] = obj[k + '$was']; - } } }; diff --git a/lib/relation-definition.js b/lib/relation-definition.js new file mode 100644 index 00000000..13366d7a --- /dev/null +++ b/lib/relation-definition.js @@ -0,0 +1,838 @@ +/*! + * Dependencies + */ +var assert = require('assert'); +var util = require('util'); +var i8n = require('inflection'); +var defineScope = require('./scope.js').defineScope; +var ModelBaseClass = require('./model.js'); + +exports.Relation = Relation; +exports.RelationDefinition = RelationDefinition; + +var RelationTypes = { + belongsTo: 'belongsTo', + hasMany: 'hasMany', + hasOne: 'hasOne', + hasAndBelongsToMany: 'hasAndBelongsToMany' +}; + +exports.RelationTypes = RelationTypes; +exports.HasMany = HasMany; +exports.HasManyThrough = HasManyThrough; +exports.HasOne = HasOne; +exports.HasAndBelongsToMany = HasAndBelongsToMany; +exports.BelongsTo = BelongsTo; + +var RelationClasses = { + belongsTo: BelongsTo, + hasMany: HasMany, + hasManyThrough: HasManyThrough, + hasOne: HasOne, + hasAndBelongsToMany: HasAndBelongsToMany +}; + +function normalizeType(type) { + if (!type) { + return type; + } + var t1 = type.toLowerCase(); + for (var t2 in RelationTypes) { + if (t2.toLowerCase() === t1) { + return t2; + } + } + return null; +} + +/** + * Relation definition class. Use to define relationships between models. + * @param {Object} definition + * @class RelationDefinition + */ +function RelationDefinition(definition) { + if (!(this instanceof RelationDefinition)) { + return new RelationDefinition(definition); + } + definition = definition || {}; + this.name = definition.name; + assert(this.name, 'Relation name is missing'); + this.type = normalizeType(definition.type); + assert(this.type, 'Invalid relation type: ' + definition.type); + this.modelFrom = definition.modelFrom; + assert(this.modelFrom); + this.keyFrom = definition.keyFrom; + this.modelTo = definition.modelTo; + assert(this.modelTo); + this.keyTo = definition.keyTo; + this.modelThrough = definition.modelThrough; + this.keyThrough = definition.keyThrough; + this.multiple = (this.type !== 'belongsTo' && this.type !== 'hasOne'); +} + +RelationDefinition.prototype.toJSON = function () { + var json = { + name: this.name, + type: this.type, + modelFrom: this.modelFrom.modelName, + keyFrom: this.keyFrom, + modelTo: this.modelTo.modelName, + keyTo: this.keyTo, + multiple: this.multiple + }; + if (this.modelThrough) { + json.modelThrough = this.modelThrough.modelName; + json.keyThrough = this.keyThrough; + } + return json; +}; + +/** + * A relation attaching to a given model instance + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {Relation} + * @constructor + * @class Relation + */ +function Relation(definition, modelInstance) { + if (!(this instanceof Relation)) { + return new Relation(definition, modelInstance); + } + if (!(definition instanceof RelationDefinition)) { + definition = new RelationDefinition(definition); + } + this.definition = definition; + this.modelInstance = modelInstance; +} + +/** + * HasMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasMany} + * @constructor + * @class HasMany + */ +function HasMany(definition, modelInstance) { + if (!(this instanceof HasMany)) { + return new HasMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasMany); + Relation.apply(this, arguments); +} + +util.inherits(HasMany, Relation); + +/** + * HasManyThrough subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasManyThrough} + * @constructor + * @class HasManyThrough + */ +function HasManyThrough(definition, modelInstance) { + if (!(this instanceof HasManyThrough)) { + return new HasManyThrough(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasMany); + assert(definition.modelThrough); + HasMany.apply(this, arguments); +} + +util.inherits(HasManyThrough, HasMany); + +/** + * BelongsTo subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {BelongsTo} + * @constructor + * @class BelongsTo + */ +function BelongsTo(definition, modelInstance) { + if (!(this instanceof BelongsTo)) { + return new BelongsTo(definition, modelInstance); + } + assert(definition.type === RelationTypes.belongsTo); + Relation.apply(this, arguments); +} + +util.inherits(BelongsTo, Relation); + +/** + * HasAndBelongsToMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasAndBelongsToMany} + * @constructor + * @class HasAndBelongsToMany + */ +function HasAndBelongsToMany(definition, modelInstance) { + if (!(this instanceof HasAndBelongsToMany)) { + return new HasAndBelongsToMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasAndBelongsToMany); + Relation.apply(this, arguments); +} + +util.inherits(HasAndBelongsToMany, Relation); + +/** + * HasOne subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasOne} + * @constructor + * @class HasOne + */ +function HasOne(definition, modelInstance) { + if (!(this instanceof HasOne)) { + return new HasOne(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasOne); + Relation.apply(this, arguments); +} + +util.inherits(HasOne, Relation); + + +/*! + * Find the relation by foreign key + * @param {*} foreignKey The foreign key + * @returns {Object} The relation object + */ +function findBelongsTo(modelFrom, modelTo, keyTo) { + var relations = modelFrom.relations; + var keys = Object.keys(relations); + for (var k = 0; k < keys.length; k++) { + var rel = relations[keys[k]]; + if (rel.type === RelationTypes.belongsTo && + rel.modelTo === modelTo && + rel.keyTo === keyTo) { + return rel.keyFrom; + } + } + return null; +} + +/*! + * Look up a model by name from the list of given models + * @param {Object} models Models keyed by name + * @param {String} modelName The model name + * @returns {*} The matching model class + */ +function lookupModel(models, modelName) { + if(models[modelName]) { + return models[modelName]; + } + var lookupClassName = modelName.toLowerCase(); + for (var name in models) { + if (name.toLowerCase() === lookupClassName) { + return models[name]; + } + } +} + +/** + * Define a "one to many" relationship by specifying the model name + * + * Examples: + * ``` + * User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'}); + * ``` + * + * ``` + * Book.hasMany(Chapter); + * ``` + * Or, equivalently: + * ``` + * Book.hasMany('chapters', {model: Chapter}); + * ``` + * @param {Model} modelFrom Source model class + * @param {Object|String} modelTo Model object (or String name of model) to which you are creating the relationship. + * @options {Object} params Configuration parameters; see below. + * @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model. + * @property {String} foreignKey Property name of foreign key field. + * @property {Object} model Model object + */ +RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { + var thisClassName = modelFrom.modelName; + params = params || {}; + if (typeof modelTo === 'string') { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = i8n.singularize(modelTo).toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); + var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); + + var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; + + var definition = new RelationDefinition({ + name: relationName, + type: RelationTypes.hasMany, + modelFrom: modelFrom, + keyFrom: idName, + keyTo: fk, + modelTo: modelTo, + multiple: true + }); + + if (params.through) { + definition.modelThrough = params.through; + var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); + definition.keyThrough = keyThrough; + } + + modelFrom.relations[relationName] = definition; + + if (!params.through) { + // obviously, modelTo should have attribute called `fk` + modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, this.modelName); + } + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById') + } + + if(definition.modelThrough) { + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.add = scopeMethod(definition, 'add'); + scopeMethods.remove = scopeMethod(definition, 'remove'); + } + + // Mix the property and scoped methods into the prototype class + defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { + var filter = {}; + filter.where = {}; + filter.where[fk] = this[idName]; + if (params.through) { + filter.collect = i8n.camelize(modelTo.modelName, true); + filter.include = filter.collect; + } + return filter; + }, scopeMethods); + +}; + +function scopeMethod(definition, methodName) { + var method = function () { + var relationClass = RelationClasses[definition.type]; + if (definition.type === RelationTypes.hasMany && definition.modelThrough) { + relationClass = RelationClasses.hasManyThrough; + } + var relation = new relationClass(definition, this); + return relation[methodName].apply(relation, arguments); + }; + return method; +} + +HasMany.prototype.findById = function (id, cb) { + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + modelTo.findById(id, function (err, inst) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(new Error('Not found')); + } + // Check if the foreign key matches the primary key + if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { + cb(null, inst); + } else { + cb(new Error('Permission denied: foreign key does not match the primary key')); + } + }); +}; + +HasMany.prototype.destroyById = function (id, cb) { + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + modelTo.findById(id, function (err, inst) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(new Error('Not found')); + } + // Check if the foreign key matches the primary key + if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { + inst.destroy(cb); + } else { + cb(new Error('Permission denied: foreign key does not match the primary key')); + } + }); +}; + +// Create an instance of the target model and connect it to the instance of +// the source model by creating an instance of the through model +HasManyThrough.prototype.create = function create(data, done) { + var definition = this.definition; + var modelTo = definition.modelTo; + var modelThrough = definition.modelThrough; + + if (typeof data === 'function' && !done) { + done = data; + data = {}; + } + + var modelInstance = this.modelInstance; + + // First create the target model + modelTo.create(data, function (err, ac) { + if (err) { + return done && done(err, ac); + } + // The primary key for the target model + var pk2 = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + var d = {}; + d[fk1] = modelInstance[definition.keyFrom]; + d[fk2] = ac[pk2]; + // Then create the through model + modelThrough.create(d, function (e) { + if (e) { + // Undo creation of the target model + ac.destroy(function () { + done && done(e); + }); + } else { + done && done(err, ac); + } + }); + }); +}; + +/** + * Add the target model instance to the 'hasMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +HasManyThrough.prototype.add = function (acInst, done) { + var definition = this.definition; + var modelThrough = definition.modelThrough; + var modelTo = definition.modelTo; + var pk1 = definition.keyFrom; + + var data = {}; + var query = {}; + + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + + // The primary key for the target model + var pk2 = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + + query[fk1] = this.modelInstance[pk1]; + query[fk2] = acInst[pk2] || acInst; + + data[fk1] = this.modelInstance[pk1]; + data[fk2] = acInst[pk2] || acInst; + + // Create an instance of the through model + modelThrough.findOrCreate({where: query}, data, done); +}; + +/** + * Remove the target model instance from the 'hasMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +HasManyThrough.prototype.remove = function (acInst, done) { + var modelThrough = this.definition.modelThrough; + var fk2 = this.definition.keyThrough; + var pk = this.definition.keyFrom; + + var q = {}; + q[fk2] = acInst[pk] || acInst; + modelThrough.deleteAll(q, done ); +}; + + +/** + * Declare "belongsTo" relation that sets up a one-to-one connection with + * another model, such that each instance of the declaring model "belongs to" + * one instance of the other model. + * + * For example, if an application includes users and posts, and each post can + * be written by exactly one user. The following code specifies that `Post` has + * a reference called `author` to the `User` model via the `userId` property of + * `Post` as the foreign key. + * ``` + * Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + * ``` + * + * This optional parameter default value is false, so the related object will + * be loaded from cache if available. + * + * @param {Class|String} modelTo Model object (or String name of model) to + * which you are creating the relationship. + * @options {Object} params Configuration parameters; see below. + * @property {String} as Name of the property in the referring model that + * corresponds to the foreign key field in the related model. + * @property {String} foreignKey Name of foreign key property. + * + */ +RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { + params = params || {}; + if ('string' === typeof modelTo) { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = modelTo.toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + + var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; + var relationName = params.as || i8n.camelize(modelTo.modelName, true); + var fk = params.foreignKey || relationName + 'Id'; + + var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.belongsTo, + modelFrom: modelFrom, + keyFrom: fk, + keyTo: idName, + modelTo: modelTo + }); + + modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelTo.modelName); + + // Define a property for the scope so that we have 'this' for the scoped methods + Object.defineProperty(modelFrom.prototype, relationName, { + enumerable: true, + configurable: true, + get: function() { + var relation = new BelongsTo(relationDef, this); + var relationMethod = relation.related.bind(relation); + relationMethod.create = relation.create.bind(relation); + relationMethod.build = relation.build.bind(relation); + relationMethod._targetClass = relationDef.modelTo.modelName; + return relationMethod; + } + }); + + // Wrap the property into a function for remoting + // so that it can be accessed as /api/// + // For example, /api/orders/1/customer + var fn = function() { + var f = this[relationName]; + f.apply(this, arguments); + }; + + fn.shared = true; + fn.http = {verb: 'get', path: '/' + relationName}; + fn.accepts = {arg: 'refresh', type: 'boolean', http: {source: 'query'}}; + fn.description = 'Fetches belongsTo relation ' + relationName; + fn.returns = {arg: relationName, type: 'object', root: true}; + + modelFrom.prototype['__get__' + relationName] = fn; + +}; + +BelongsTo.prototype.create = function(targetModelData, cb) { + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + + modelTo.create(targetModelData, function(err, targetModel) { + if(!err) { + modelInstance[fk] = targetModel[pk]; + cb && cb(err, targetModel); + } else { + cb && cb(err); + } + }); +}; + +BelongsTo.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + return new modelTo(targetModelData); +}; + +/** + * Define the method for the belongsTo relation itself + * It will support one of the following styles: + * - order.customer(refresh, callback): Load the target model instance asynchronously + * - order.customer(customer): Synchronous setter of the target model instance + * - order.customer(): Synchronous getter of the target model instance + * + * @param refresh + * @param params + * @returns {*} + */ +BelongsTo.prototype.related = function (refresh, params) { + var modelTo = this.definition.modelTo; + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + var relationName = this.definition.name; + + if (arguments.length === 1) { + params = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + + var cachedValue; + if (!refresh && modelInstance.__cachedRelations + && (modelInstance.__cachedRelations[relationName] !== undefined)) { + cachedValue = modelInstance.__cachedRelations[relationName]; + } + if (params instanceof ModelBaseClass) { // acts as setter + modelInstance[fk] = params[pk]; + modelInstance.__cachedRelations[relationName] = params; + } else if (typeof params === 'function') { // acts as async getter + var cb = params; + if (cachedValue === undefined) { + modelTo.findById(modelInstance[fk], function (err, inst) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(null, null); + } + // Check if the foreign key matches the primary key + if (inst[pk] === modelInstance[fk]) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }); + return modelInstance[fk]; + } else { + cb(null, cachedValue); + return cachedValue; + } + } else if (params === undefined) { // acts as sync getter + return modelInstance[fk]; + } else { // setter + modelInstance[fk] = params; + delete modelInstance.__cachedRelations[relationName]; + } +}; + +/** + * A hasAndBelongsToMany relation creates a direct many-to-many connection with + * another model, with no intervening model. For example, if your application + * includes users and groups, with each group having many users and each user + * appearing in many groups, you could declare the models this way: + * ``` + * User.hasAndBelongsToMany('groups', {model: Group, foreignKey: 'groupId'}); + * ``` + * + * @param {String|Object} modelTo Model object (or String name of model) to + * which you are creating the relationship. + * @options {Object} params Configuration parameters; see below. + * @property {String} as Name of the property in the referring model that + * corresponds to the foreign key field in the related model. + * @property {String} foreignKey Property name of foreign key field. + * @property {Object} model Model object + */ +RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, modelTo, params) { + params = params || {}; + var models = modelFrom.dataSource.modelBuilder.models; + + if ('string' === typeof modelTo) { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + modelTo = lookupModel(models, i8n.singularize(modelTo)) || + modelTo; + } + if (typeof modelTo === 'string') { + throw new Error('Could not find "' + modelTo + '" relation for ' + modelFrom.modelName); + } + } + + if (!params.through) { + var name1 = modelFrom.modelName + modelTo.modelName; + var name2 = modelTo.modelName + modelFrom.modelName; + params.through = lookupModel(models, name1) || lookupModel(models, name2) || + modelFrom.dataSource.define(name1); + } + params.through.belongsTo(modelFrom); + params.through.belongsTo(modelTo); + + this.hasMany(modelFrom, modelTo, {as: params.as, through: params.through}); + +}; + +/** + * A HasOne relation creates a one-to-one connection from modelFrom to modelTo. + * This relation indicates that each instance of a model contains or possesses + * one instance of another model. For example, each supplier in your application + * has only one account. + * + * @param {Function} modelFrom The declaring model class + * @param {String|Function} modelTo Model object (or String name of model) to + * which you are creating the relationship. + * @options {Object} params Configuration parameters; see below. + * @property {String} as Name of the property in the referring model that + * corresponds to the foreign key field in the related model. + * @property {String} foreignKey Property name of foreign key field. + * @property {Object} model Model object + */ +RelationDefinition.hasOne = function (modelFrom, modelTo, params) { + params = params || {}; + if ('string' === typeof modelTo) { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = modelTo.toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + + var pk = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; + var relationName = params.as || i8n.camelize(modelTo.modelName, true); + + var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true); + + var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.hasOne, + modelFrom: modelFrom, + keyFrom: pk, + keyTo: fk, + modelTo: modelTo + }); + + modelFrom.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName); + + // Define a property for the scope so that we have 'this' for the scoped methods + Object.defineProperty(modelFrom.prototype, relationName, { + enumerable: true, + configurable: true, + get: function() { + var relation = new HasOne(relationDef, this); + var relationMethod = relation.related.bind(relation) + relationMethod.create = relation.create.bind(relation); + relationMethod.build = relation.build.bind(relation); + return relationMethod; + } + }); +}; + +/** + * Create a target model instance + * @param {Object} targetModelData The target model data + * @callback {Function} [cb] Callback function + * @param {String|Object} err Error string or object + * @param {Object} The newly created target model instance + */ +HasOne.prototype.create = function(targetModelData, cb) { + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + + targetModelData = targetModelData || {}; + targetModelData[fk] = modelInstance[pk]; + modelTo.create(targetModelData, function(err, targetModel) { + if(!err) { + cb && cb(err, targetModel); + } else { + cb && cb(err); + } + }); +}; + +/** + * Build a target model instance + * @param {Object} targetModelData The target model data + * @returns {Object} The newly built target model instance + */ +HasOne.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + var pk = this.definition.keyFrom; + var fk = this.definition.keyTo; + targetModelData = targetModelData || {}; + targetModelData[fk] = this.modelInstance[pk]; + return new modelTo(targetModelData); +}; + +/** + * Define the method for the hasOne relation itself + * It will support one of the following styles: + * - order.customer(refresh, callback): Load the target model instance asynchronously + * - order.customer(customer): Synchronous setter of the target model instance + * - order.customer(): Synchronous getter of the target model instance + * + * @param {Boolean} refresh Reload from the data source + * @param {Object|Function} params Query parameters + * @returns {Object} + */ +HasOne.prototype.related = function (refresh, params) { + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + var relationName = this.definition.name; + + if (arguments.length === 1) { + params = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + + var cachedValue; + if (!refresh && modelInstance.__cachedRelations + && (modelInstance.__cachedRelations[relationName] !== undefined)) { + cachedValue = modelInstance.__cachedRelations[relationName]; + } + if (params instanceof ModelBaseClass) { // acts as setter + params[fk] = modelInstance[pk]; + modelInstance.__cachedRelations[relationName] = params; + } else if (typeof params === 'function') { // acts as async getter + var cb = params; + if (cachedValue === undefined) { + var query = {where: {}}; + query.where[fk] = modelInstance[pk]; + modelTo.findOne(query, function (err, inst) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(null, null); + } + // Check if the foreign key matches the primary key + if (inst[fk] === modelInstance[pk]) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }); + return modelInstance[pk]; + } else { + cb(null, cachedValue); + return cachedValue; + } + } else if (params === undefined) { // acts as sync getter + return modelInstance[pk]; + } else { // setter + params[fk] = modelInstance[pk]; + delete modelInstance.__cachedRelations[relationName]; + } +}; diff --git a/lib/relations.js b/lib/relations.js index a938df09..c0985f2f 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -1,49 +1,17 @@ /*! * Dependencies */ -var i8n = require('inflection'); -var defineScope = require('./scope.js').defineScope; -var ModelBaseClass = require('./model.js'); +var relation = require('./relation-definition'); +var RelationDefinition = relation.RelationDefinition; -module.exports = Relation; +module.exports = RelationMixin; /** - * Relations class. Use to define relationships between models. + * RelationMixin class. Use to define relationships between models. * - * @class Relation + * @class RelationMixin */ -function Relation() { -} - -/** - * Find the relation by foreign key - * @param {*} foreignKey The foreign key - * @returns {Object} The relation object - */ -Relation.relationNameFor = function relationNameFor(foreignKey) { - for (var rel in this.relations) { - if (this.relations[rel].type === 'belongsTo' && this.relations[rel].keyFrom === foreignKey) { - return rel; - } - } -}; - -/*! - * Look up a model by name from the list of given models - * @param {Object} models Models keyed by name - * @param {String} modelName The model name - * @returns {*} The matching model class - */ -function lookupModel(models, modelName) { - if(models[modelName]) { - return models[modelName]; - } - var lookupClassName = modelName.toLowerCase(); - for (var name in models) { - if (name.toLowerCase() === lookupClassName) { - return models[name]; - } - } +function RelationMixin() { } /** @@ -90,173 +58,14 @@ function lookupModel(models, modelName) { * }); * }); *``` - * @param {Object|String} anotherClass Model object (or String name of model) to which you are creating the relationship. - * @options {Object} parameters Configuration parameters; see below. + * @param {Object|String} modelTo Model object (or String name of model) to which you are creating the relationship. + * @options {Object} parameters Configuration parameters; see below. * @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model. * @property {String} foreignKey Property name of foreign key field. * @property {Object} model Model object */ -Relation.hasMany = function hasMany(anotherClass, params) { - var thisClassName = this.modelName; - params = params || {}; - if (typeof anotherClass === 'string') { - params.as = anotherClass; - if (params.model) { - anotherClass = params.model; - } else { - var anotherClassName = i8n.singularize(anotherClass).toLowerCase(); - anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName); - } - } - var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true); - var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); - - var idName = this.dataSource.idName(this.modelName) || 'id'; - - this.relations[methodName] = { - type: 'hasMany', - keyFrom: idName, - keyTo: fk, - modelTo: anotherClass, - multiple: true - }; - - if (params.through) { - this.relations[methodName].modelThrough = params.through; - } - // each instance of this class should have method named - // pluralize(anotherClass.modelName) - // which is actually just anotherClass.find({where: {thisModelNameId: this[idName]}}, cb); - var scopeMethods = { - findById: findById, - destroy: destroyById - }; - if (params.through) { - var fk2 = i8n.camelize(anotherClass.modelName + '_id', true); - - // Create an instance of the target model and connect it to the instance of - // the source model by creating an instance of the through model - scopeMethods.create = function create(data, done) { - if (typeof data !== 'object') { - done = data; - data = {}; - } - if ('function' !== typeof done) { - done = function () { - }; - } - var self = this; - // First create the target model - anotherClass.create(data, function (err, ac) { - if (err) return done(err, ac); - var d = {}; - d[params.through.relationNameFor(fk)] = self; - d[params.through.relationNameFor(fk2)] = ac; - // Then create the through model - params.through.create(d, function (e) { - if (e) { - // Undo creation of the target model - ac.destroy(function () { - done(e); - }); - } else { - done(err, ac); - } - }); - }); - }; - - /*! - * Add the target model instance to the 'hasMany' relation - * @param {Object|ID} acInst The actual instance or id value - */ - scopeMethods.add = function (acInst, done) { - var data = {}; - var query = {}; - query[fk] = this[idName]; - data[params.through.relationNameFor(fk)] = this; - query[fk2] = acInst[idName] || acInst; - data[params.through.relationNameFor(fk2)] = acInst; - // Create an instance of the through model - params.through.findOrCreate({where: query}, data, done); - }; - - /*! - * Remove the target model instance from the 'hasMany' relation - * @param {Object|ID) acInst The actual instance or id value - */ - scopeMethods.remove = function (acInst, done) { - var q = {}; - q[fk2] = acInst[idName] || acInst; - params.through.findOne({where: q}, function (err, d) { - if (err) { - return done(err); - } - if (!d) { - return done(); - } - d.destroy(done); - }); - }; - - // No destroy method will be injected - delete scopeMethods.destroy; - } - - // Mix the property and scoped methods into the prototype class - defineScope(this.prototype, params.through || anotherClass, methodName, function () { - var filter = {}; - filter.where = {}; - filter.where[fk] = this[idName]; - if (params.through) { - filter.collect = i8n.camelize(anotherClass.modelName, true); - filter.include = filter.collect; - } - return filter; - }, scopeMethods); - - if (!params.through) { - // obviously, anotherClass should have attribute called `fk` - anotherClass.dataSource.defineForeignKey(anotherClass.modelName, fk, this.modelName); - } - - // Find the target model instance by id - function findById(id, cb) { - anotherClass.findById(id, function (err, inst) { - if (err) { - return cb(err); - } - if (!inst) { - return cb(new Error('Not found')); - } - // Check if the foreign key matches the primary key - if (inst[fk] && inst[fk].toString() === this[idName].toString()) { - cb(null, inst); - } else { - cb(new Error('Permission denied')); - } - }.bind(this)); - } - - // Destroy the target model instance by id - function destroyById(id, cb) { - var self = this; - anotherClass.findById(id, function (err, inst) { - if (err) { - return cb(err); - } - if (!inst) { - return cb(new Error('Not found')); - } - // Check if the foreign key matches the primary key - if (inst[fk] && inst[fk].toString() === self[idName].toString()) { - inst.destroy(cb); - } else { - cb(new Error('Permission denied')); - } - }); - } - +RelationMixin.hasMany = function hasMany(modelTo, params) { + RelationDefinition.hasMany(this, modelTo, params); }; /** @@ -303,145 +112,15 @@ Relation.hasMany = function hasMany(anotherClass, params) { * }); * ``` * This optional parameter default value is false, so the related object will be loaded from cache if available. - * - * @param {Class|String} anotherClass Model object (or String name of model) to which you are creating the relationship. + * + * @param {Class|String} modelTo Model object (or String name of model) to which you are creating the relationship. * @options {Object} params Configuration parameters; see below. * @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model. * @property {String} foreignKey Name of foreign key property. * */ -Relation.belongsTo = function (anotherClass, params) { - params = params || {}; - if ('string' === typeof anotherClass) { - params.as = anotherClass; - if (params.model) { - anotherClass = params.model; - } else { - var anotherClassName = anotherClass.toLowerCase(); - anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName); - } - } - - var idName = this.dataSource.idName(anotherClass.modelName) || 'id'; - var methodName = params.as || i8n.camelize(anotherClass.modelName, true); - var fk = params.foreignKey || methodName + 'Id'; - - this.relations[methodName] = { - type: 'belongsTo', - keyFrom: fk, - keyTo: idName, - modelTo: anotherClass, - multiple: false - }; - - this.dataSource.defineForeignKey(this.modelName, fk, anotherClass.modelName); - this.prototype.__finders__ = this.prototype.__finders__ || {}; - - // Set up a finder to find by id and make sure the foreign key of the declaring - // model matches the primary key of the target model - this.prototype.__finders__[methodName] = function (id, cb) { - if (id === null) { - cb(null, null); - return; - } - anotherClass.findById(id, function (err, inst) { - if (err) { - return cb(err); - } - if (!inst) { - return cb(null, null); - } - // Check if the foreign key matches the primary key - if (inst[idName] === this[fk]) { - cb(null, inst); - } else { - cb(new Error('Permission denied')); - } - }.bind(this)); - }; - - // Define the method for the belongsTo relation itself - // It will support one of the following styles: - // - order.customer(refresh, callback): Load the target model instance asynchronously - // - order.customer(customer): Synchronous setter of the target model instance - // - order.customer(): Synchronous getter of the target model instance - var relationMethod = function (refresh, p) { - if (arguments.length === 1) { - p = refresh; - refresh = false; - } else if (arguments.length > 2) { - throw new Error('Method can\'t be called with more than two arguments'); - } - var self = this; - var cachedValue; - if (!refresh && this.__cachedRelations && (this.__cachedRelations[methodName] !== undefined)) { - cachedValue = this.__cachedRelations[methodName]; - } - if (p instanceof ModelBaseClass) { // acts as setter - this[fk] = p[idName]; - this.__cachedRelations[methodName] = p; - } else if (typeof p === 'function') { // acts as async getter - if (typeof cachedValue === 'undefined') { - this.__finders__[methodName].apply(self, [this[fk], function (err, inst) { - if (!err) { - self.__cachedRelations[methodName] = inst; - } - p(err, inst); - }]); - return this[fk]; - } else { - p(null, cachedValue); - return cachedValue; - } - } else if (typeof p === 'undefined') { // acts as sync getter - return this[fk]; - } else { // setter - this[fk] = p; - delete this.__cachedRelations[methodName]; - } - }; - - // Define a property for the scope so that we have 'this' for the scoped methods - Object.defineProperty(this.prototype, methodName, { - enumerable: true, - configurable: true, - get: function () { - var fn = function() { - // Call the relation method on the declaring model instance - return relationMethod.apply(this, arguments); - } - // Create an instance of the target model and set the foreign key of the - // declaring model instance to the id of the target instance - fn.create = function(targetModelData, cb) { - var self = this; - anotherClass.create(targetModelData, function(err, targetModel) { - if(!err) { - self[fk] = targetModel[idName]; - cb && cb(err, targetModel); - } else { - cb && cb(err); - } - }); - }.bind(this); - - // Build an instance of the target model - fn.build = function(targetModelData) { - return new anotherClass(targetModelData); - }.bind(this); - - fn._targetClass = anotherClass.modelName; - - return fn; - }}); - - // Wrap the property into a function for remoting - // so that it can be accessed as /api/// - // For example, /api/orders/1/customer - var fn = function() { - var f = this[methodName]; - f.apply(this, arguments); - }; - this.prototype['__get__' + methodName] = fn; +RelationMixin.belongsTo = function (modelTo, params) { + RelationDefinition.belongsTo(this, modelTo, params); }; /** @@ -468,39 +147,17 @@ Relation.belongsTo = function (anotherClass, params) { * user.groups.remove(group, callback); * ``` * - * @param {String|Object} anotherClass Model object (or String name of model) to which you are creating the relationship. + * @param {String|Object} modelTo Model object (or String name of model) to which you are creating the relationship. * the relation * @options {Object} params Configuration parameters; see below. * @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model. * @property {String} foreignKey Property name of foreign key field. * @property {Object} model Model object */ -Relation.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params) { - params = params || {}; - var models = this.dataSource.modelBuilder.models; - - if ('string' === typeof anotherClass) { - params.as = anotherClass; - if (params.model) { - anotherClass = params.model; - } else { - anotherClass = lookupModel(models, i8n.singularize(anotherClass).toLowerCase()) || - anotherClass; - } - if (typeof anotherClass === 'string') { - throw new Error('Could not find "' + anotherClass + '" relation for ' + this.modelName); - } - } - - if (!params.through) { - var name1 = this.modelName + anotherClass.modelName; - var name2 = anotherClass.modelName + this.modelName; - params.through = lookupModel(models, name1) || lookupModel(models, name2) || - this.dataSource.define(name1); - } - params.through.belongsTo(this); - params.through.belongsTo(anotherClass); - - this.hasMany(anotherClass, {as: params.as, through: params.through}); - +RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params) { + RelationDefinition.hasAndBelongsToMany(this, modelTo, params); +}; + +RelationMixin.hasOne = function hasMany(modelTo, params) { + RelationDefinition.hasOne(this, modelTo, params); }; diff --git a/lib/scope.js b/lib/scope.js index 17ee0e47..7d8d8e75 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -6,6 +6,52 @@ var defineCachedRelations = utils.defineCachedRelations; */ exports.defineScope = defineScope; +function ScopeDefinition(definition) { + this.sourceModel = definition.sourceModel; + this.targetModel = definition.targetModel || definition.sourceModel; + this.name = definition.name; + this.params = definition.params; + this.methods = definition.methods; +} + +ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var name = this.name; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + var saveOnCache = true; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + saveOnCache = false; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + if (!self.__cachedRelations || self.__cachedRelations[name] === undefined + || actualRefresh) { + // It either doesn't hit the cache or refresh is required + var params = mergeQuery(actualCond, scopeParams); + return this.targetModel.find(params, function (err, data) { + if (!err && saveOnCache) { + defineCachedRelations(self); + self.__cachedRelations[name] = data; + } + cb(err, data); + }); + } else { + // Return from cache + cb(null, self.__cachedRelations[name]); + } +} + /** * Define a scope to the class * @param {Model} cls The class where the scope method is added @@ -22,7 +68,7 @@ function defineScope(cls, targetClass, name, params, methods) { cls._scopeMeta = {}; } - // only makes sence to add scope in meta if base and target classes + // only makes sense to add scope in meta if base and target classes // are same if (cls === targetClass) { cls._scopeMeta[name] = params; @@ -32,6 +78,14 @@ function defineScope(cls, targetClass, name, params, methods) { } } + var definition = new ScopeDefinition({ + sourceModel: cls, + targetModel: targetClass, + name: name, + params: params, + methods: methods + }); + // Define a property for the scope Object.defineProperty(cls, name, { enumerable: false, @@ -49,42 +103,18 @@ function defineScope(cls, targetClass, name, params, methods) { */ get: function () { var self = this; - var f = function caller(condOrRefresh, cb) { - var actualCond = {}; - var actualRefresh = false; - var saveOnCache = true; - if (arguments.length === 1) { - cb = condOrRefresh; - } else if (arguments.length === 2) { - if (typeof condOrRefresh === 'boolean') { - actualRefresh = condOrRefresh; - } else { - actualCond = condOrRefresh; - actualRefresh = true; - saveOnCache = false; - } + var f = function(condOrRefresh, cb) { + if(arguments.length === 1) { + definition.related(self, f._scope, condOrRefresh); } else { - throw new Error('Method can be only called with one or two arguments'); - } - - if (!self.__cachedRelations || self.__cachedRelations[name] === undefined - || actualRefresh) { - // It either doesn't hit the cache or reresh is required - var params = mergeParams(actualCond, caller._scope); - return targetClass.find(params, function (err, data) { - if (!err && saveOnCache) { - defineCachedRelations(self); - self.__cachedRelations[name] = data; - } - cb(err, data); - }); - } else { - // Return from cache - cb(null, self.__cachedRelations[name]); + definition.related(self, f._scope, condOrRefresh, cb); } }; - f._scope = typeof params === 'function' ? params.call(this) : params; - f._targetClass = targetClass.modelName; + + f._scope = typeof definition.params === 'function' ? + definition.params.call(self) : definition.params; + + f._targetClass = definition.targetModel.modelName; if (f._scope.collect) { f._targetClass = i8n.capitalize(f._scope.collect); } @@ -92,20 +122,23 @@ function defineScope(cls, targetClass, name, params, methods) { f.build = build; f.create = create; f.destroyAll = destroyAll; - for (var i in methods) { - f[i] = methods[i].bind(this); + for (var i in definition.methods) { + f[i] = definition.methods[i].bind(self); } - // define sub-scopes + // Define scope-chaining, such as + // Station.scope('active', {where: {isActive: true}}); + // Station.scope('subway', {where: {isUndeground: true}}); + // Station.active.subway(cb); Object.keys(targetClass._scopeMeta).forEach(function (name) { Object.defineProperty(f, name, { enumerable: false, get: function () { - mergeParams(f._scope, targetClass._scopeMeta[name]); + mergeQuery(f._scope, targetClass._scopeMeta[name]); return f; } }); - }.bind(this)); + }.bind(self)); return f; } }); @@ -134,9 +167,40 @@ function defineScope(cls, targetClass, name, params, methods) { cls['__delete__' + name] = fn_delete; + /* + * Extracting fixed property values for the scope from the where clause into + * the data object + * + * @param {Object} The data object + * @param {Object} The where clause + */ + function setScopeValuesFromWhere(data, where) { + for (var i in where) { + if (i === 'and') { + // Find fixed property values from each subclauses + for (var w = 0, n = where[i].length; w < n; w++) { + setScopeValuesFromWhere(data, where[i][w]); + } + continue; + } + var prop = targetClass.definition.properties[i]; + if (prop) { + var val = where[i]; + if (typeof val !== 'object' || val instanceof prop.type) { + // Only pick the {propertyName: propertyValue} + data[i] = where[i]; + } + } + } + } + // and it should have create/build methods with binded thisModelNameId param function build(data) { - return new targetClass(mergeParams(this._scope, {where: data || {}}).where); + data = data || {}; + // Find all fixed property values for the scope + var where = (this._scope && this._scope.where) || {}; + setScopeValuesFromWhere(data, where); + return new targetClass(data); } function create(data, cb) { @@ -154,70 +218,57 @@ function defineScope(cls, targetClass, name, params, methods) { - If fetching the Elements on which destroyAll is called results in an error */ function destroyAll(cb) { - targetClass.find(this._scope, function (err, data) { - if (err) { - cb(err); - } else { - (function loopOfDestruction(data) { - if (data.length > 0) { - data.shift().destroy(function (err) { - if (err && cb) cb(err); - loopOfDestruction(data); - }); - } else { - if (cb) cb(); - } - }(data)); - } - }); - } - - function mergeParams(base, update) { - base = base || {}; - if (update.where) { - base.where = merge(base.where, update.where); - } - if (update.include) { - base.include = update.include; - } - if (update.collect) { - base.collect = update.collect; - } - - // overwrite order - if (update.order) { - base.order = update.order; - } - - if(update.limit !== undefined) { - base.limit = update.limit; - } - if(update.skip !== undefined) { - base.skip = update.skip; - } - if(update.offset !== undefined) { - base.offset = update.offset; - } - if(update.fields !== undefined) { - base.fields = update.fields; - } - return base; - + targetClass.destroyAll(this._scope, cb); } } -/** - * 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` +/*! + * Merge query parameters + * @param {Object} base The base object to contain the merged results + * @param {Object} update The object containing updates to be merged + * @returns {*|Object} The base object + * @private */ -function merge(base, update) { +function mergeQuery(base, update) { + if (!update) { + return; + } base = base || {}; - if (update) { - Object.keys(update).forEach(function (key) { - base[key] = update[key]; - }); + if (update.where && Object.keys(update.where).length > 0) { + if (base.where && Object.keys(base.where).length > 0) { + base.where = {and: [base.where, update.where]}; + } else { + base.where = update.where; + } + } + + // Overwrite inclusion + if (update.include) { + base.include = update.include; + } + if (update.collect) { + base.collect = update.collect; + } + + // overwrite order + if (update.order) { + base.order = update.order; + } + + // overwrite pagination + if (update.limit !== undefined) { + base.limit = update.limit; + } + if (update.skip !== undefined) { + base.skip = update.skip; + } + if (update.offset !== undefined) { + base.offset = update.offset; + } + + // Overwrite fields + if (update.fields !== undefined) { + base.fields = update.fields; } return base; } diff --git a/package.json b/package.json index f359c56c..8babe3e6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "mocha": "~1.18.2" }, "dependencies": { - "async": "~0.8.0", + "async": "~0.9.0", "inflection": "~1.3.5", "loopback-connector": "1.x", "traverse": "~0.6.6", diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index ae72ca45..2a235def 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -1,5 +1,6 @@ // This test written in mocha+should.js var should = require('./init.js'); +var async = require('async'); var db, User; describe('basic-querying', function () { @@ -8,10 +9,13 @@ describe('basic-querying', function () { db = getSchema(); User = db.define('User', { + seq: {type: Number, index: true}, name: {type: String, index: true, sort: true}, email: {type: String, index: true}, + birthday: {type: Date, index: true}, role: {type: String, index: true}, - order: {type: Number, index: true, sort: true} + order: {type: Number, index: true, sort: true}, + vip: {type: Boolean} }); db.automigrate(done); @@ -69,15 +73,26 @@ describe('basic-querying', function () { }); }); - it('should query offset collection with limit', function (done) { - User.find({skip: 1, limit: 4}, function (err, users) { + it('should query collection with skip & limit', function (done) { + User.find({skip: 1, limit: 4, order: 'seq'}, function (err, users) { should.exists(users); should.not.exists(err); + users[0].seq.should.be.eql(1); users.should.have.lengthOf(4); done(); }); }); + it('should query collection with offset & limit', function (done) { + User.find({offset: 2, limit: 3, order: 'seq'}, function (err, users) { + should.exists(users); + should.not.exists(err); + users[0].seq.should.be.eql(2); + users.should.have.lengthOf(3); + done(); + }); + }); + it('should query filtered collection', function (done) { User.find({where: {role: 'lead'}}, function (err, users) { should.exists(users); @@ -175,6 +190,190 @@ describe('basic-querying', function () { }); }); + it('should support date "gte" that is satisfied', function (done) { + User.find({order: 'seq', where: { birthday: { "gte": new Date('1980-12-08') } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support date "gt" that is not satisfied', function (done) { + User.find({order: 'seq', where: { birthday: { "gt": new Date('1980-12-08') } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support date "gt" that is satisfied', function (done) { + User.find({order: 'seq', where: { birthday: { "gt": new Date('1980-12-07') } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support date "lt" that is satisfied', function (done) { + User.find({order: 'seq', where: { birthday: { "lt": new Date('1980-12-07') } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + + it('should support number "gte" that is satisfied', function (done) { + User.find({order: 'seq', where: { order: { "gte": 3} + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 4); + users[0].name.should.equal('George Harrison'); + done(); + }); + }); + + it('should support number "gt" that is not satisfied', function (done) { + User.find({order: 'seq', where: { order: { "gt": 6 } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support number "gt" that is satisfied', function (done) { + User.find({order: 'seq', where: { order: { "gt": 5 } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Ringo Starr'); + done(); + }); + }); + + it('should support number "lt" that is satisfied', function (done) { + User.find({order: 'seq', where: { order: { "lt": 2 } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + + it('should support number "gt" that is satisfied by null value', function (done) { + User.find({order: 'seq', where: { order: { "gt": null } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support number "lt" that is not satisfied by null value', function (done) { + User.find({order: 'seq', where: { order: { "lt": null } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support string "gte" that is satisfied by null value', function (done) { + User.find({order: 'seq', where: { name: { "gte": null} + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support string "gte" that is satisfied', function (done) { + User.find({order: 'seq', where: { name: { "gte": 'Paul McCartney'} + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 4); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + + it('should support string "gt" that is not satisfied', function (done) { + User.find({order: 'seq', where: { name: { "gt": 'xyz' } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support string "gt" that is satisfied', function (done) { + User.find({order: 'seq', where: { name: { "gt": 'Paul McCartney' } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + users[0].name.should.equal('Ringo Starr'); + done(); + }); + }); + + it('should support string "lt" that is satisfied', function (done) { + User.find({order: 'seq', where: { name: { "lt": 'Paul McCartney' } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 2); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support boolean "gte" that is satisfied', function (done) { + User.find({order: 'seq', where: { vip: { "gte": true} + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support boolean "gt" that is not satisfied', function (done) { + User.find({order: 'seq', where: { vip: { "gt": true } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support boolean "gt" that is satisfied', function (done) { + User.find({order: 'seq', where: { vip: { "gt": false } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support boolean "lt" that is satisfied', function (done) { + User.find({order: 'seq', where: { vip: { "lt": true } + }}, function (err, users) { + should.not.exist(err); + users.should.have.property('length', 2); + users[0].name.should.equal('George Harrison'); + done(); + }); + }); + + it('should only include fields as specified', function (done) { var remaining = 0; @@ -214,7 +413,7 @@ describe('basic-querying', function () { } sample({name: true}).expect(['name']); - sample({name: false}).expect(['id', 'email', 'role', 'order']); + sample({name: false}).expect(['id', 'seq', 'email', 'role', 'order', 'birthday', 'vip']); sample({name: false, id: true}).expect(['id']); sample({id: true}).expect(['id']); sample('id').expect(['id']); @@ -354,37 +553,72 @@ describe('basic-querying', function () { }); + describe('updateAll ', function () { + + beforeEach(seed); + + it('should only update instances that satisfy the where condition', function (done) { + User.update({name: 'John Lennon'}, {name: 'John Smith'}, function () { + User.find({where: {name: 'John Lennon'}}, function (err, data) { + should.not.exist(err); + data.length.should.equal(0); + User.find({where: {name: 'John Smith'}}, function (err, data) { + should.not.exist(err); + data.length.should.equal(1); + done(); + }); + }); + }); + }); + + it('should update all instances without where', function (done) { + User.update({name: 'John Smith'}, function () { + User.find({where: {name: 'John Lennon'}}, function (err, data) { + should.not.exist(err); + data.length.should.equal(0); + User.find({where: {name: 'John Smith'}}, function (err, data) { + should.not.exist(err); + data.length.should.equal(6); + done(); + }); + }); + }); + }); + + }); + }); function seed(done) { - var count = 0; var beatles = [ { + seq: 0, name: 'John Lennon', email: 'john@b3atl3s.co.uk', role: 'lead', - order: 2 + birthday: new Date('1980-12-08'), + order: 2, + vip: true }, { + seq: 1, name: 'Paul McCartney', email: 'paul@b3atl3s.co.uk', role: 'lead', - order: 1 + birthday: new Date('1942-06-18'), + order: 1, + vip: true }, - {name: 'George Harrison', order: 5}, - {name: 'Ringo Starr', order: 6}, - {name: 'Pete Best', order: 4}, - {name: 'Stuart Sutcliffe', order: 3} + {seq: 2, name: 'George Harrison', order: 5, vip: false}, + {seq: 3, name: 'Ringo Starr', order: 6, vip: false}, + {seq: 4, name: 'Pete Best', order: 4}, + {seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true} ]; - User.destroyAll(function () { - beatles.forEach(function (beatle) { - User.create(beatle, ok); - }); - }); - function ok() { - if (++count === beatles.length) { - done(); + async.series([ + User.destroyAll.bind(User), + function(cb) { + async.each(beatles, User.create.bind(User), cb); } - } + ], done); } diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index 167519b1..095afb6c 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -872,27 +872,33 @@ describe('Load models with relations', function () { var Account = ds.define('Account', {userId: Number, type: String}, {relations: {user: {type: 'belongsTo', model: 'User'}}}); assert(Post.relations['user']); - assert.deepEqual(Post.relations['user'], { + assert.deepEqual(Post.relations['user'].toJSON(), { + name: 'user', type: 'belongsTo', + modelFrom: 'Post', keyFrom: 'userId', + modelTo: 'User', keyTo: 'id', - modelTo: User, multiple: false }); assert(User.relations['posts']); - assert.deepEqual(User.relations['posts'], { + assert.deepEqual(User.relations['posts'].toJSON(), { + name: 'posts', type: 'hasMany', + modelFrom: 'User', keyFrom: 'id', + modelTo: 'Post', keyTo: 'userId', - modelTo: Post, multiple: true }); assert(User.relations['accounts']); - assert.deepEqual(User.relations['accounts'], { + assert.deepEqual(User.relations['accounts'].toJSON(), { + name: 'accounts', type: 'hasMany', + modelFrom: 'User', keyFrom: 'id', + modelTo: 'Account', keyTo: 'userId', - modelTo: Account, multiple: true }); @@ -993,7 +999,7 @@ describe('Model with scopes', function () { }); describe('DataAccessObject', function () { - var ds, model, where; + var ds, model, where, error; before(function () { ds = new DataSource('memory'); @@ -1007,6 +1013,10 @@ describe('DataAccessObject', function () { }); }); + beforeEach(function () { + error = null; + }); + it('should be able to coerce where clause for string types', function () { where = model._coerce({id: 1}); assert.deepEqual(where, {id: '1'}); @@ -1069,6 +1079,139 @@ describe('DataAccessObject', function () { }); + it('should be able to coerce where clause with and operators', function () { + where = model._coerce({and: [{age: '10'}, {vip: 'true'}]}); + assert.deepEqual(where, {and: [{age: 10}, {vip: true}]}); + }); + + it('should be able to coerce where clause with or operators', function () { + where = model._coerce({or: [{age: '10'}, {vip: 'true'}]}); + assert.deepEqual(where, {or: [{age: 10}, {vip: true}]}); + }); + + it('should throw if the where property is not an object', function () { + try { + // The where clause has to be an object + model._coerce('abc'); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if the where property is an array', function () { + try { + // The where clause cannot be an array + model._coerce([ + {vip: true} + ]); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if the and operator does not take an array', function () { + try { + // The and operator only takes an array of objects + model._coerce({and: {x: 1}}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if the or operator does not take an array', function () { + try { + // The or operator only takes an array of objects + model._coerce({or: {x: 1}}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if the or operator does not take an array of objects', function () { + try { + // The or operator only takes an array of objects + model._coerce({or: ['x']}); + } catch(err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter property is not an object', function () { + var filter = null; + try { + // The filter clause has to be an object + filter = model._normalize('abc'); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter.limit property is not a number', function () { + try { + // The limit param must be a valid number + filter = model._normalize({limit: 'x'}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter.limit property is nagative', function () { + try { + // The limit param must be a valid number + filter = model._normalize({limit: -1}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter.limit property is not an integer', function () { + try { + // The limit param must be a valid number + filter = model._normalize({limit: 5.8}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter.offset property is not a number', function () { + try { + // The limit param must be a valid number + filter = model._normalize({offset: 'x'}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should throw if filter.skip property is not a number', function () { + try { + // The limit param must be a valid number + filter = model._normalize({skip: '_'}); + } catch (err) { + error = err; + } + assert(error, 'An error should have been thrown'); + }); + + it('should normalize limit/offset/skip', function () { + filter = model._normalize({limit: '10', skip: 5}); + assert.deepEqual(filter, {limit: 10, offset: 5, skip: 5}); + }); + + it('should set the default value for limit', function () { + filter = model._normalize({skip: 5}); + assert.deepEqual(filter, {limit: 100, offset: 5, skip: 5}); + }); + it('should skip GeoPoint', function () { where = model._coerce({location: {near: {lng: 10, lat: 20}, maxDistance: 20}}); assert.deepEqual(where, {location: {near: {lng: 10, lat: 20}, maxDistance: 20}}); @@ -1162,15 +1305,24 @@ describe('Load models from json', function () { customer.should.not.have.property('bio'); // The properties are defined at prototype level - assert.equal(Object.keys(customer).length, 0); + assert.equal(Object.keys(customer).filter(function (k) { + // Remove internal properties + return k.indexOf('__') === -1; + }).length, 0); var count = 0; for (var p in customer) { + if (p.indexOf('__') === 0) { + continue; + } if (typeof customer[p] !== 'function') { count++; } } assert.equal(count, 7); // Please note there is an injected id from User prototype - assert.equal(Object.keys(customer.toObject()).length, 6); + assert.equal(Object.keys(customer.toObject()).filter(function (k) { + // Remove internal properties + return k.indexOf('__') === -1; + }).length, 6); done(null, customer); }); diff --git a/test/manipulation.test.js b/test/manipulation.test.js index 5fd6ce84..d31b3b9d 100644 --- a/test/manipulation.test.js +++ b/test/manipulation.test.js @@ -133,14 +133,11 @@ describe('manipulation', function () { Person.findOne(function (err, p) { should.not.exist(err); p.name = 'Hans'; - p.propertyChanged('name').should.be.true; p.save(function (err) { should.not.exist(err); - p.propertyChanged('name').should.be.false; Person.findOne(function (err, p) { should.not.exist(err); p.name.should.equal('Hans'); - p.propertyChanged('name').should.be.false; done(); }); }); @@ -157,10 +154,8 @@ describe('manipulation', function () { p.name = 'Nana'; p.save(function (err) { should.exist(err); - p.propertyChanged('name').should.be.true; p.save({validate: false}, function (err) { should.not.exist(err); - p.propertyChanged('name').should.be.false; done(); }); }); @@ -244,10 +239,7 @@ describe('manipulation', function () { person = new Person({name: hw}); person.name.should.equal(hw); - person.propertyChanged('name').should.be.false; person.name = 'Goodbye, Lenin'; - person.name$was.should.equal(hw); - person.propertyChanged('name').should.be.true; (person.createdAt >= now).should.be.true; person.isNewRecord().should.be.true; }); diff --git a/test/memory.test.js b/test/memory.test.js index 4c613270..4016209e 100644 --- a/test/memory.test.js +++ b/test/memory.test.js @@ -4,6 +4,7 @@ var path = require('path'); var fs = require('fs'); var assert = require('assert'); var async = require('async'); +var should = require('./init.js'); describe('Memory connector', function () { var file = path.join(__dirname, 'memory.json'); @@ -91,5 +92,92 @@ describe('Memory connector', function () { }); }); + + describe('Query for memory connector', function () { + var ds = new DataSource({ + connector: 'memory' + }); + + var User = ds.define('User', { + seq: {type: Number, index: true}, + name: {type: String, index: true, sort: true}, + email: {type: String, index: true}, + birthday: {type: Date, index: true}, + role: {type: String, index: true}, + order: {type: Number, index: true, sort: true}, + vip: {type: Boolean} + }); + + before(seed); + it('should allow to find using like', function (done) { + User.find({where: {name: {like: '%St%'}}}, function (err, posts) { + should.not.exist(err); + posts.should.have.property('length', 2); + done(); + }); + }); + + it('should support like for no match', function (done) { + User.find({where: {name: {like: 'M%XY'}}}, function (err, posts) { + should.not.exist(err); + posts.should.have.property('length', 0); + done(); + }); + }); + + it('should allow to find using nlike', function (done) { + User.find({where: {name: {nlike: '%St%'}}}, function (err, posts) { + should.not.exist(err); + posts.should.have.property('length', 4); + done(); + }); + }); + + it('should support nlike for no match', function (done) { + User.find({where: {name: {nlike: 'M%XY'}}}, function (err, posts) { + should.not.exist(err); + posts.should.have.property('length', 6); + done(); + }); + }); + + function seed(done) { + var beatles = [ + { + seq: 0, + name: 'John Lennon', + email: 'john@b3atl3s.co.uk', + role: 'lead', + birthday: new Date('1980-12-08'), + order: 2, + vip: true + }, + { + seq: 1, + name: 'Paul McCartney', + email: 'paul@b3atl3s.co.uk', + role: 'lead', + birthday: new Date('1942-06-18'), + order: 1, + vip: true + }, + {seq: 2, name: 'George Harrison', order: 5, vip: false}, + {seq: 3, name: 'Ringo Starr', order: 6, vip: false}, + {seq: 4, name: 'Pete Best', order: 4}, + {seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true} + ]; + + async.series([ + User.destroyAll.bind(User), + function(cb) { + async.each(beatles, User.create.bind(User), cb); + } + ], done); + } + + }); + }); + + diff --git a/test/relations.test.js b/test/relations.test.js index 7bc3a1b3..4e1f5873 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1,7 +1,7 @@ // This test written in mocha+should.js var should = require('./init.js'); -var db, Book, Chapter, Author, Reader; +var db, Book, Chapter, Author, Reader, Publisher; describe('relations', function () { before(function (done) { @@ -180,6 +180,42 @@ describe('relations', function () { }); + describe('hasOne', function () { + var Supplier, Account; + + before(function () { + db = getSchema(); + Supplier = db.define('Supplier', {name: String}); + Account = db.define('Account', {accountNo: String}); + }); + + it('can be declared using hasOne method', function () { + Supplier.hasOne(Account); + Object.keys((new Account()).toObject()).should.include('supplierId'); + (new Supplier()).account.should.be.an.instanceOf(Function); + }); + + it('can be used to query data', function (done) { + // Supplier.hasOne(Account); + db.automigrate(function () { + Supplier.create({name: 'Supplier 1'}, function (e, supplier) { + should.not.exist(e); + should.exist(supplier); + supplier.account.create({accountNo: 'a01'}, function (err, account) { + supplier.account(function (e, act) { + should.not.exist(e); + should.exist(act); + act.should.be.an.instanceOf(Account); + supplier.account().should.equal(act.id); + done(); + }); + }); + }); + }); + }); + + }); + describe('hasAndBelongsToMany', function () { var Article, Tag, ArticleTag; it('can be declared', function (done) { diff --git a/test/scope.test.js b/test/scope.test.js index d927bcc7..ecaa8727 100644 --- a/test/scope.test.js +++ b/test/scope.test.js @@ -3,7 +3,7 @@ var should = require('./init.js'); var db, Railway, Station; -describe('sc0pe', function () { +describe('scope', function () { before(function () { db = getSchema();