From 1406c22a646a2e10255b039d3fbc6a042476b8a1 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 15 Jun 2014 15:53:58 -0700 Subject: [PATCH 1/8] Refactor relation into classes --- lib/relation-definition.js | 641 +++++++++++++++++++++++++++++++++++++ lib/relations.js | 384 +--------------------- test/loopback-dl.test.js | 18 +- 3 files changed, 668 insertions(+), 375 deletions(-) create mode 100644 lib/relation-definition.js diff --git a/lib/relation-definition.js b/lib/relation-definition.js new file mode 100644 index 00000000..b3dfe835 --- /dev/null +++ b/lib/relation-definition.js @@ -0,0 +1,641 @@ +/*! + * 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(model, foreignKey) { + var relations = model.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.keyFrom === foreignKey) { + return keys[k]; + } + } + 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 modelTo = this.definition.modelTo; + var modelThrough = this.definition.modelThrough; + var fk = this.definition.keyTo; + var fk2 = this.definition.keyThrough; + + if (typeof data === 'function' && !done) { + done = data; + data = {}; + } + + var self = this.modelInstance; + // First create the target model + modelTo.create(data, function (err, ac) { + if (err) { + return done && done(err, ac); + } + var d = {}; + d[findBelongsTo(modelThrough, fk)] = self; + d[findBelongsTo(modelThrough, fk2)] = ac; + // 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 modelThrough = this.definition.modelThrough; + var fk = this.definition.keyTo; + var fk2 = this.definition.keyThrough; + var pk = this.definition.keyFrom; + + var data = {}; + var query = {}; + query[fk] = this[pk]; + data[findBelongsTo(modelThrough, fk)] = this.modelInstance; + query[fk2] = acInst[pk] || acInst; + data[findBelongsTo(modelThrough, fk2)] = 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); + return relationMethod; + } + }); +}; + +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. + * 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 + */ +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}); + +}; diff --git a/lib/relations.js b/lib/relations.js index c07b87d2..8b740359 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. + * @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); }; /** @@ -304,151 +113,14 @@ 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); - }; - - fn.shared = true; - fn.http = {verb: 'get', path: '/' + methodName}; - fn.accepts = {arg: 'refresh', type: 'boolean', http: {source: 'query'}}; - fn.description = 'Fetches belongsTo relation ' + methodName; - fn.returns = {arg: methodName, type: 'object', root: true}; - - this.prototype['__get__' + methodName] = fn; +RelationMixin.belongsTo = function (modelTo, params) { + RelationDefinition.belongsTo(this, modelTo, params); }; /** @@ -475,39 +147,13 @@ 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); }; diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index d1d2b0a8..2d840fca 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 }); From 34c1998f0406ec488edfded89b9796e77d56670e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 16 Jun 2014 00:36:12 -0700 Subject: [PATCH 2/8] Fix the hasMany through connection --- lib/relation-definition.js | 55 +++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b3dfe835..09849d12 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -203,14 +203,15 @@ util.inherits(HasOne, Relation); * @param {*} foreignKey The foreign key * @returns {Object} The relation object */ -function findBelongsTo(model, foreignKey) { - var relations = model.relations; +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.keyFrom === foreignKey) { - return keys[k]; + rel.modelTo === modelTo && + rel.keyTo === keyTo) { + return rel.keyFrom; } } return null; @@ -378,25 +379,30 @@ HasMany.prototype.destroyById = function (id, cb) { // 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 modelTo = this.definition.modelTo; - var modelThrough = this.definition.modelThrough; - var fk = this.definition.keyTo; - var fk2 = this.definition.keyThrough; + var definition = this.definition; + var modelTo = definition.modelTo; + var modelThrough = definition.modelThrough; if (typeof data === 'function' && !done) { done = data; data = {}; } - var self = this.modelInstance; + 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[findBelongsTo(modelThrough, fk)] = self; - d[findBelongsTo(modelThrough, fk2)] = ac; + d[fk1] = modelInstance[definition.keyFrom]; + d[fk2] = ac[pk2]; // Then create the through model modelThrough.create(d, function (e) { if (e) { @@ -416,17 +422,28 @@ HasManyThrough.prototype.create = function create(data, done) { * @param {Object|ID} acInst The actual instance or id value */ HasManyThrough.prototype.add = function (acInst, done) { - var modelThrough = this.definition.modelThrough; - var fk = this.definition.keyTo; - var fk2 = this.definition.keyThrough; - var pk = this.definition.keyFrom; + var definition = this.definition; + var modelThrough = definition.modelThrough; + var modelTo = definition.modelTo; + var pk1 = definition.keyFrom; var data = {}; var query = {}; - query[fk] = this[pk]; - data[findBelongsTo(modelThrough, fk)] = this.modelInstance; - query[fk2] = acInst[pk] || acInst; - data[findBelongsTo(modelThrough, fk2)] = acInst; + + 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); }; From 2db43c58e5b0cb4cc3a70b9a13eacebb90139987 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 16 Jun 2014 01:17:37 -0700 Subject: [PATCH 3/8] Add support for hasOne --- lib/relation-definition.js | 139 +++++++++++++++++++++++++++++++++++++ lib/relations.js | 4 ++ test/relations.test.js | 39 ++++++++++- 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 09849d12..ae030143 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -656,3 +656,142 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, this.hasMany(modelFrom, modelTo, {as: params.as, through: params.through}); }; + +/** + * HasOne + * @param modelFrom + * @param modelTo + * @param params + */ +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; + } + }); +}; + +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); + } + }); +}; + +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 refresh + * @param params + * @returns {*} + */ +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 8b740359..068bce7f 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -157,3 +157,7 @@ RelationMixin.belongsTo = function (modelTo, params) { 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/test/relations.test.js b/test/relations.test.js index 7bc3a1b3..d0cd0ea9 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,43 @@ 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) { From 046816191df587e686ebd5e6c0c7e330159c643e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 16 Jun 2014 10:50:42 -0700 Subject: [PATCH 4/8] Clean up scope implementation --- lib/scope.js | 208 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 132 insertions(+), 76 deletions(-) diff --git a/lib/scope.js b/lib/scope.js index ac14efc7..185ad0d3 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 = mergeParams(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,11 +122,14 @@ 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, @@ -105,7 +138,7 @@ function defineScope(cls, targetClass, name, params, methods) { return f; } }); - }.bind(this)); + }.bind(self)); return f; } }); @@ -152,7 +185,13 @@ function defineScope(cls, targetClass, name, params, methods) { // 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 || {}; + var params = mergeParams(this._scope, {where: data}).where; + delete params['and']; + delete params['or']; + delete params['nor']; + + return new targetClass(params); } function create(data, cb) { @@ -187,53 +226,70 @@ function defineScope(cls, targetClass, name, params, methods) { } }); } - - 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; - - } } -/** +/*! * 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` + * @private */ -function merge(base, update) { +function mergeWhere(base, update) { base = base || {}; if (update) { - Object.keys(update).forEach(function (key) { + var keys = Object.keys(update); + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; base[key] = update[key]; - }); + } + } + return base; +} + +/*! + * Merge query parameters + * @param base + * @param update + * @returns {*|{}} + * @private + */ +function mergeParams(base, update) { + if (!update) { + return; + } + base = base || {}; + if (update.where) { + base.where = mergeWhere(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; } From e2ab9ccc931aaa7d2512c054afefe46d1a44eb6b Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 16 Jun 2014 13:46:17 -0700 Subject: [PATCH 5/8] Adding back the remoting metadata --- lib/relation-definition.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index ae030143..38c52c93 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -520,12 +520,30 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { configurable: true, get: function() { var relation = new BelongsTo(relationDef, this); - var relationMethod = relation.related.bind(relation) + 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) { From 177752e144a6f53febc5342588bb20ad7caa4d29 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 19 Jun 2014 11:13:24 -0700 Subject: [PATCH 6/8] Add more jsdocs --- lib/relation-definition.js | 49 ++++++++++++++++++++++++++++---------- test/relations.test.js | 1 - 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 38c52c93..13366d7a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -631,17 +631,19 @@ BelongsTo.prototype.related = function (refresh, params) { }; /** - * 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: + * 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. - * the relation + * @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} 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 */ @@ -676,10 +678,19 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, }; /** - * HasOne - * @param modelFrom - * @param modelTo - * @param params + * 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 || {}; @@ -723,6 +734,13 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { }); }; +/** + * 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; @@ -740,6 +758,11 @@ HasOne.prototype.create = function(targetModelData, cb) { }); }; +/** + * 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; @@ -756,9 +779,9 @@ HasOne.prototype.build = function(targetModelData) { * - order.customer(customer): Synchronous setter of the target model instance * - order.customer(): Synchronous getter of the target model instance * - * @param refresh - * @param params - * @returns {*} + * @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; diff --git a/test/relations.test.js b/test/relations.test.js index d0cd0ea9..4e1f5873 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -193,7 +193,6 @@ describe('relations', 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) { From 18333522381c0dfab35f241421489900ee95a402 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 19 Jun 2014 12:00:49 -0700 Subject: [PATCH 7/8] More clean up for the scope processing --- lib/scope.js | 93 ++++++++++++++++++++++------------------------ test/scope.test.js | 2 +- 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/lib/scope.js b/lib/scope.js index 185ad0d3..8a4485df 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -38,7 +38,7 @@ ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefres if (!self.__cachedRelations || self.__cachedRelations[name] === undefined || actualRefresh) { // It either doesn't hit the cache or refresh is required - var params = mergeParams(actualCond, scopeParams); + var params = mergeQuery(actualCond, scopeParams); return this.targetModel.find(params, function (err, data) { if (!err && saveOnCache) { defineCachedRelations(self); @@ -134,7 +134,7 @@ function defineScope(cls, targetClass, name, params, methods) { Object.defineProperty(f, name, { enumerable: false, get: function () { - mergeParams(f._scope, targetClass._scopeMeta[name]); + mergeQuery(f._scope, targetClass._scopeMeta[name]); return f; } }); @@ -183,15 +183,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) { data = data || {}; - var params = mergeParams(this._scope, {where: data}).where; - delete params['and']; - delete params['or']; - delete params['nor']; - - return new targetClass(params); + // 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) { @@ -209,58 +234,28 @@ 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)); - } - }); + 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` - * @private - */ -function mergeWhere(base, update) { - base = base || {}; - if (update) { - var keys = Object.keys(update); - for (var k = 0; k < keys.length; k++) { - var key = keys[k]; - base[key] = update[key]; - } - } - return base; -} - /*! * Merge query parameters - * @param base - * @param update - * @returns {*|{}} + * @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 mergeParams(base, update) { +function mergeQuery(base, update) { if (!update) { return; } base = base || {}; - if (update.where) { - base.where = mergeWhere(base.where, update.where); + 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 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(); From 27f4c1b7b437f1d15efd18876f3ed1a9fd4d39ad Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 19 Jun 2014 15:09:19 -0700 Subject: [PATCH 8/8] Fix the error msg --- lib/list.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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()); - */