From 9b97e1ae778065a15ad0b2a508a60e9ce3f182dd Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 12:47:55 +0200 Subject: [PATCH 01/36] Implemented polymorphic hasMany --- lib/relation-definition.js | 97 ++++++++++++++++++++----- lib/relations.js | 4 ++ test/relations.test.js | 144 +++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 18 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 9279fc7e..5411f6a0 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -64,8 +64,11 @@ function RelationDefinition(definition) { assert(this.modelFrom, 'Source model is required'); this.keyFrom = definition.keyFrom; this.modelTo = definition.modelTo; - assert(this.modelTo, 'Target model is required'); this.keyTo = definition.keyTo; + this.typeTo = definition.typeTo; + if (!this.typeTo) { + assert(this.modelTo, 'Target model is required'); + } this.modelThrough = definition.modelThrough; this.keyThrough = definition.keyThrough; this.multiple = (this.type !== 'belongsTo' && this.type !== 'hasOne'); @@ -97,13 +100,17 @@ RelationDefinition.prototype.toJSON = function () { * @param {Object} filter (where, order, limit, fields, ...) */ RelationDefinition.prototype.applyScope = function(modelInstance, filter) { + filter.where = filter.where || {}; + if (this.type !== 'belongsTo' && typeof this.typeTo === 'string') { + filter.where[this.typeTo] = this.modelFrom.modelName; // polymorphic + } if (typeof this.scope === 'function') { var scope = this.scope.call(this, modelInstance, filter); - if (typeof scope === 'object') { - mergeQuery(filter, scope); - } - } else if (typeof this.scope === 'object') { - mergeQuery(filter, this.scope); + } else { + var scope = this.scope; + } + if (typeof scope === 'object') { + mergeQuery(filter, scope); } }; @@ -124,6 +131,9 @@ RelationDefinition.prototype.applyProperties = function(modelInstance, target) { target[key] = modelInstance[k]; } } + if (this.type !== 'belongsTo' && typeof this.typeTo === 'string') { + target[this.typeTo] = this.modelFrom.modelName; // polymorphic + } }; /** @@ -350,10 +360,20 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { 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 typeTo; + + if (typeof params.polymorphic === 'string') { + fk = i8n.camelize(params.polymorphic + '_id', true); + var typeTo = i8n.camelize(params.polymorphic + '_type', true); + if (!params.through) { + modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string' }); + } + } var definition = new RelationDefinition({ name: relationName, @@ -361,6 +381,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: idName, keyTo: fk, + typeTo: typeTo, modelTo: modelTo, multiple: true, properties: params.properties, @@ -378,6 +399,8 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { if (!params.through) { // obviously, modelTo should have attribute called `fk` + // for polymorphic relations, it is assumed to share the same fk type for all + // polymorphic models modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName); } @@ -797,8 +820,8 @@ HasManyThrough.prototype.remove = function (acInst, done) { * */ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { - params = params || {}; - if ('string' === typeof modelTo) { + var typeTo, params = params || {}; + if ('string' === typeof modelTo && !params.polymorphic) { params.as = modelTo; if (params.model) { modelTo = params.model; @@ -807,10 +830,30 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { 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'; + + if (params.polymorphic) { + var polymorphic = modelTo; + modelTo = null; // will lookup dynamically + + var idName = params.idName || 'id'; + var relationName = params.as || i8n.camelize(polymorphic, true); + var fk = i8n.camelize(polymorphic + '_id', true); + var typeTo = i8n.camelize(polymorphic + '_type', true); + + if (typeof params.idType === 'string') { // explicit key type + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: params.idType }); + } else { // try to use the same foreign key type as modelFrom + modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelFrom.modelName); + } + + modelFrom.dataSource.defineProperty(modelFrom.modelName, typeTo, { type: 'string' }); + } else { + var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; + var relationName = params.as || i8n.camelize(modelTo.modelName, true); + var fk = params.foreignKey || relationName + 'Id'; + + modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelTo.modelName); + } var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, @@ -818,14 +861,13 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: fk, keyTo: idName, + typeTo: typeTo, modelTo: modelTo, properties: params.properties, scope: params.scope, options: params.options }); - - 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, @@ -835,7 +877,9 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { var relationMethod = relation.related.bind(relation); relationMethod.create = relation.create.bind(relation); relationMethod.build = relation.build.bind(relation); - relationMethod._targetClass = relationDef.modelTo.modelName; + if (relationDef.modelTo) { + relationMethod._targetClass = relationDef.modelTo.modelName; + } return relationMethod; } }); @@ -863,7 +907,7 @@ BelongsTo.prototype.create = function(targetModelData, cb) { } this.definition.applyProperties(modelInstance, targetModelData || {}); - + modelTo.create(targetModelData, function(err, targetModel) { if(!err) { modelInstance[fk] = targetModel[pk]; @@ -894,7 +938,9 @@ BelongsTo.prototype.build = function(targetModelData) { */ BelongsTo.prototype.related = function (refresh, params) { var self = this; + var modelFrom = this.definition.modelFrom; var modelTo = this.definition.modelTo; + var typeTo = this.definition.typeTo; var pk = this.definition.keyTo; var fk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -905,15 +951,30 @@ BelongsTo.prototype.related = function (refresh, params) { } else if (arguments.length > 2) { throw new Error('Method can\'t be called with more than two arguments'); } - + var cachedValue; if (!refresh) { cachedValue = self.getCache(); } if (params instanceof ModelBaseClass) { // acts as setter + modelTo = params.constructor; modelInstance[fk] = params[pk]; + if (typeTo) modelInstance[typeTo] = params.constructor.modelName; self.resetCache(params); } else if (typeof params === 'function') { // acts as async getter + + if (typeTo && !modelTo) { + var modelToName = modelInstance[typeTo]; + if (typeof modelToName !== 'string') { + throw new Error('Polymorphic model not found: `' + typeTo + '` not set'); + } + modelToName = modelToName.toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + if (!modelTo) { + throw new Error('Polymorphic model not found: `' + modelToName + '`'); + } + } + var cb = params; if (cachedValue === undefined) { var query = {where: {}}; diff --git a/lib/relations.js b/lib/relations.js index c0985f2f..0e93d649 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -161,3 +161,7 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; + +RelationMixin.belongsToPolymorphic = function belongsToPolymorphic(polymorphic, params) { + RelationDefinition.belongsToPolymorphic(this, polymorphic, params); +}; diff --git a/test/relations.test.js b/test/relations.test.js index f091486d..1d1fcf7c 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -3,6 +3,7 @@ var should = require('./init.js'); var db, Book, Chapter, Author, Reader; var Category, Product; +var Picture; describe('relations', function () { @@ -526,6 +527,149 @@ describe('relations', function () { }); }); + + describe('polymorphic hasMany', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + db.automigrate(function () { + Picture.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasMany(Picture, { polymorphic: 'imageable' }); + Reader.hasMany(Picture, { polymorphic: 'imageable' }); + Picture.belongsTo('imageable', { polymorphic: true }); + db.automigrate(done); + }); + + it('should create polymorphic relation - author', function (done) { + Author.create({ name: 'Author 1' }, function (err, author) { + author.pictures.create({ name: 'Author Pic' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({ name: 'Reader 1' }, function (err, reader) { + reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findOne(function (err, author) { + author.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Author Pic'); + done(); + }); + }); + }); + + it('should find polymorphic items - reader', function (done) { + Reader.findOne(function (err, reader) { + reader.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Reader Pic'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Author Pic' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Author'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 1'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - reader', function (done) { + Picture.findOne({ where: { name: 'Reader Pic' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Reader'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Reader); + imageable.name.should.equal('Reader 1'); + done(); + }); + }); + }); + + it('should include the inverse of polymorphic relation', function (done) { + Picture.find({ include: 'imageable' }, function (err, pics) { + should.not.exist(err); + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic'); + pics[0].imageable().name.should.equal('Author 1'); + pics[1].name.should.equal('Reader Pic'); + pics[1].imageable().name.should.equal('Reader 1'); + done(); + }); + }); + + it('should assign a polymorphic relation', function(done) { + Author.create({ name: 'Author 2' }, function(err, author) { + var p = new Picture({ name: 'Sample' }); + p.imageable(author); // assign + p.imageableId.should.equal(author.id); + p.imageableType.should.equal('Author'); + p.save(done); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findOne({ where: { name: 'Author 2' } }, function (err, author) { + author.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Sample'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Sample' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Author'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 2'); + done(); + }); + }); + }); + + }); describe('belongsTo', function () { var List, Item, Fear, Mind; From 295e6fc1f113c70afe4135cfc1a492e1d155bac5 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 14:54:54 +0200 Subject: [PATCH 02/36] Implemented polymorphic hasAndBelongsToMany --- lib/relation-definition.js | 60 ++++++++++++++++--------- test/relations.test.js | 90 +++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 5411f6a0..2db36355 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -365,13 +365,14 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - var typeTo; + var polymorphic, typeTo; if (typeof params.polymorphic === 'string') { - fk = i8n.camelize(params.polymorphic + '_id', true); - var typeTo = i8n.camelize(params.polymorphic + '_type', true); + polymorphic = params.polymorphic; + fk = i8n.camelize(polymorphic + '_id', true); + typeTo = i8n.camelize(polymorphic + '_type', true); if (!params.through) { - modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string' }); + modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string', index: true }); } } @@ -389,12 +390,11 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { options: params.options }); - if (params.through) { - definition.modelThrough = params.through; - var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); - definition.keyThrough = keyThrough; - } - + definition.modelThrough = params.through; + + var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); + definition.keyThrough = keyThrough; + modelFrom.relations[relationName] = definition; if (!params.through) { @@ -447,6 +447,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { filter.collect = i8n.camelize(modelTo.modelName, true); filter.include = filter.collect; } + return filter; }, scopeMethods); @@ -645,7 +646,7 @@ 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 = {}; @@ -660,9 +661,16 @@ HasManyThrough.prototype.create = function create(data, done) { } // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + + if (definition.typeTo) { // polymorphic + var fk1 = definition.keyTo; + var fk2 = definition.keyThrough; + } else { + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + } + var d = {}; d[fk1] = modelInstance[definition.keyFrom]; d[fk2] = to[pk2]; @@ -841,12 +849,12 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { var typeTo = i8n.camelize(polymorphic + '_type', true); if (typeof params.idType === 'string') { // explicit key type - modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: params.idType }); + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: params.idType, index: true }); } else { // try to use the same foreign key type as modelFrom modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelFrom.modelName); } - modelFrom.dataSource.defineProperty(modelFrom.modelName, typeTo, { type: 'string' }); + modelFrom.dataSource.defineProperty(modelFrom.modelName, typeTo, { type: 'string', index: true }); } else { var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; var relationName = params.as || i8n.camelize(modelTo.modelName, true); @@ -1055,10 +1063,22 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, 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}); + + var options = {as: params.as, through: params.through}; + + if (typeof params.polymorphic === 'string') { + options.polymorphic = params.polymorphic; + var accessor = params.through.prototype[params.polymorphic]; + if (typeof accessor !== 'function') { // declare once + params.through.belongsTo(modelTo); + params.through.belongsTo(params.polymorphic, { polymorphic: true }); + } + } else { + params.through.belongsTo(modelFrom); + params.through.belongsTo(modelTo); + } + + this.hasMany(modelFrom, modelTo, options); }; diff --git a/test/relations.test.js b/test/relations.test.js index 1d1fcf7c..11ffb97a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -3,7 +3,7 @@ var should = require('./init.js'); var db, Book, Chapter, Author, Reader; var Category, Product; -var Picture; +var Picture, PictureLink; describe('relations', function () { @@ -670,6 +670,94 @@ describe('relations', function () { }); }); + + describe('polymorphic hasAndBelongsToMany through', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + PictureLink = db.define('PictureLink', {}); + + db.automigrate(function () { + Picture.destroyAll(function () { + PictureLink.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + Reader.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + db.automigrate(done); + }); + + it('should create polymorphic relation - author', function (done) { + Author.create({ id: 3, name: 'Author 1' }, function (err, author) { + author.pictures.create({ name: 'Author Pic 1' }, function (err, p) { + should.not.exist(err); + p.id.should.equal(1); + author.pictures.create({ name: 'Author Pic 2' }, function (err, p) { + should.not.exist(err); + p.id.should.equal(2); + done(); + }); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({ id: 4, name: 'Reader 1' }, function (err, reader) { + reader.pictures.create({ name: 'Reader Pic 1' }, function (err, p) { + should.not.exist(err); + p.id.should.equal(3); + done(); + }); + }); + }); + + it('should create polymorphic through model', function (done) { + PictureLink.findOne(function(err, link) { + should.not.exist(err); + link.pictureId.should.equal(1); + link.imageableId.should.equal(3); + link.imageableType.should.equal('Author'); + done(); + }); + }); + + it('should get polymorphic relation through model - author', function (done) { + Author.findById(3, function(err, author) { + should.not.exist(err); + author.name.should.equal('Author 1'); + author.pictures(function(err, pics) { + should.not.exist(err); + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + + it('should get polymorphic relation through model - reader', function (done) { + Reader.findById(4, function(err, reader) { + should.not.exist(err); + reader.name.should.equal('Reader 1'); + reader.pictures(function(err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Reader Pic 1'); + done(); + }); + }); + }); + + }); describe('belongsTo', function () { var List, Item, Fear, Mind; From 00dfe563eb2189ad774e69bab6a6ab774c4418aa Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 15:20:46 +0200 Subject: [PATCH 03/36] Implemented polymorphic hasOne Signed-off-by: Fabien Franzen --- lib/relation-definition.js | 33 +++++++++---- test/relations.test.js | 95 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 2db36355..92c23ab2 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -101,8 +101,9 @@ RelationDefinition.prototype.toJSON = function () { */ RelationDefinition.prototype.applyScope = function(modelInstance, filter) { filter.where = filter.where || {}; - if (this.type !== 'belongsTo' && typeof this.typeTo === 'string') { - filter.where[this.typeTo] = this.modelFrom.modelName; // polymorphic + if ((this.type !== 'belongsTo' || this.type === 'hasOne') + && typeof this.typeTo === 'string') { // polymorphic + filter.where[this.typeTo] = this.modelFrom.modelName; } if (typeof this.scope === 'function') { var scope = this.scope.call(this, modelInstance, filter); @@ -131,8 +132,9 @@ RelationDefinition.prototype.applyProperties = function(modelInstance, target) { target[key] = modelInstance[k]; } } - if (this.type !== 'belongsTo' && typeof this.typeTo === 'string') { - target[this.typeTo] = this.modelFrom.modelName; // polymorphic + if ((this.type !== 'belongsTo' || this.type === 'hasOne') + && typeof this.typeTo === 'string') { // polymorphic + target[this.typeTo] = this.modelFrom.modelName; } }; @@ -365,10 +367,10 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - var polymorphic, typeTo; + var typeTo; if (typeof params.polymorphic === 'string') { - polymorphic = params.polymorphic; + var polymorphic = params.polymorphic; fk = i8n.camelize(polymorphic + '_id', true); typeTo = i8n.camelize(polymorphic + '_type', true); if (!params.through) { @@ -1049,15 +1051,17 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, if (params.model) { modelTo = params.model; } else { - modelTo = lookupModel(models, i8n.singularize(modelTo)) || - modelTo; + modelTo = lookupModel(models, i8n.singularize(modelTo)) || modelTo; } if (typeof modelTo === 'string') { throw new Error('Could not find "' + modelTo + '" relation for ' + modelFrom.modelName); } } + var isPolymorphic = (typeof params.polymorphic === 'string'); + if (!params.through) { + if (isPolymorphic) throw new Error('Polymorphic relations need a through model'); var name1 = modelFrom.modelName + modelTo.modelName; var name2 = modelTo.modelName + modelFrom.modelName; params.through = lookupModel(models, name1) || lookupModel(models, name2) || @@ -1066,7 +1070,7 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, var options = {as: params.as, through: params.through}; - if (typeof params.polymorphic === 'string') { + if (isPolymorphic) { options.polymorphic = params.polymorphic; var accessor = params.through.prototype[params.polymorphic]; if (typeof accessor !== 'function') { // declare once @@ -1113,6 +1117,16 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { var relationName = params.as || i8n.camelize(modelTo.modelName, true); var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true); + var typeTo; + + if (typeof params.polymorphic === 'string') { + var polymorphic = params.polymorphic; + fk = i8n.camelize(polymorphic + '_id', true); + typeTo = i8n.camelize(polymorphic + '_type', true); + if (!params.through) { + modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string', index: true }); + } + } var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, @@ -1120,6 +1134,7 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: pk, keyTo: fk, + typeTo: typeTo, modelTo: modelTo, properties: params.properties, options: params.options diff --git a/test/relations.test.js b/test/relations.test.js index 11ffb97a..824d710f 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -528,6 +528,101 @@ describe('relations', function () { }); + describe('polymorphic hasOne', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + db.automigrate(function () { + Picture.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasOne(Picture, { as: 'avatar', polymorphic: 'imageable' }); + Reader.hasOne(Picture, { as: 'mugshot', polymorphic: 'imageable' }); + Picture.belongsTo('imageable', { polymorphic: true }); + db.automigrate(done); + }); + + it('should create polymorphic relation - author', function (done) { + Author.create({ id: 8, name: 'Author 1' }, function (err, author) { + author.avatar.create({ name: 'Avatar' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({ id: 6, name: 'Reader 1' }, function (err, reader) { + reader.mugshot.create({ name: 'Mugshot' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find polymorphic relation - author', function (done) { + Author.findOne(function (err, author) { + author.avatar(function (err, p) { + should.not.exist(err); + p.name.should.equal('Avatar'); + p.imageableId.should.equal(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should find polymorphic relation - reader', function (done) { + Reader.findOne(function (err, reader) { + reader.mugshot(function (err, p) { + should.not.exist(err); + p.name.should.equal('Mugshot'); + p.imageableId.should.equal(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find inverse polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Avatar' } }, function (err, p) { + p.imageable(function (err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 1'); + done(); + }); + }); + }); + + it('should find inverse polymorphic relation - reader', function (done) { + Picture.findOne({ where: { name: 'Mugshot' } }, function (err, p) { + p.imageable(function (err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Reader); + imageable.name.should.equal('Reader 1'); + done(); + }); + }); + }); + + }); + describe('polymorphic hasMany', function () { before(function (done) { db = getSchema(); From 9f94ec9bde589319af6c63e39aab5975755c636b Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 15:23:40 +0200 Subject: [PATCH 04/36] Minor cleanup --- lib/relation-definition.js | 4 ++-- lib/relations.js | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 92c23ab2..3f7d4477 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1074,14 +1074,14 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, options.polymorphic = params.polymorphic; var accessor = params.through.prototype[params.polymorphic]; if (typeof accessor !== 'function') { // declare once - params.through.belongsTo(modelTo); params.through.belongsTo(params.polymorphic, { polymorphic: true }); } } else { params.through.belongsTo(modelFrom); - params.through.belongsTo(modelTo); } + params.through.belongsTo(modelTo); + this.hasMany(modelFrom, modelTo, options); }; diff --git a/lib/relations.js b/lib/relations.js index 0e93d649..c0985f2f 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -161,7 +161,3 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; - -RelationMixin.belongsToPolymorphic = function belongsToPolymorphic(polymorphic, params) { - RelationDefinition.belongsToPolymorphic(this, polymorphic, params); -}; From d1896553fdd213e473b3a5a309fa336751fac230 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 17:20:25 +0200 Subject: [PATCH 05/36] More hasAndBelongsToMany tests --- lib/relation-definition.js | 60 ++++++++++++++++++--------------- test/relations.test.js | 68 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 29 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 3f7d4477..5ab62a55 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -574,6 +574,21 @@ HasMany.prototype.destroyById = function (fkId, cb) { }); }; +var throughKeys = function(definition) { + var modelThrough = definition.modelThrough; + var pk2 = definition.modelTo.definition.idName(); + + if (definition.typeTo) { // polymorphic + var fk1 = definition.keyTo; + var fk2 = definition.keyThrough; + } else { + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + } + return [fk1, fk2]; +} + /** * Find a related item by foreign key * @param {*} fkId The foreign key value @@ -664,14 +679,9 @@ HasManyThrough.prototype.create = function create(data, done) { // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - if (definition.typeTo) { // polymorphic - var fk1 = definition.keyTo; - var fk2 = definition.keyThrough; - } else { - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); - } + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; var d = {}; d[fk1] = modelInstance[definition.keyFrom]; @@ -694,6 +704,8 @@ HasManyThrough.prototype.create = function create(data, done) { }); }; + + /** * Add the target model instance to the 'hasMany' relation * @param {Object|ID} acInst The actual instance or id value @@ -706,14 +718,13 @@ HasManyThrough.prototype.add = function (acInst, done) { var data = {}; var query = {}; - - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - + // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -724,6 +735,7 @@ HasManyThrough.prototype.add = function (acInst, done) { data[fk1] = this.modelInstance[pk1]; data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + definition.applyProperties(this.modelInstance, data); // Create an instance of the through model @@ -749,15 +761,12 @@ HasManyThrough.prototype.exists = function (acInst, done) { var data = {}; var query = {}; - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); - - query[fk1] = this.modelInstance[pk1]; + + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -781,13 +790,12 @@ HasManyThrough.prototype.remove = function (acInst, done) { var query = {}; - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -795,7 +803,7 @@ HasManyThrough.prototype.remove = function (acInst, done) { var filter = { where: query }; definition.applyScope(this.modelInstance, filter); - + modelThrough.deleteAll(filter.where, function (err) { if (!err) { self.removeFromCache(query[fk2]); diff --git a/test/relations.test.js b/test/relations.test.js index 824d710f..fe2751d6 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -792,7 +792,7 @@ describe('relations', function () { }); it('should create polymorphic relation - author', function (done) { - Author.create({ id: 3, name: 'Author 1' }, function (err, author) { + Author.create({ id: 4, name: 'Author 1' }, function (err, author) { author.pictures.create({ name: 'Author Pic 1' }, function (err, p) { should.not.exist(err); p.id.should.equal(1); @@ -819,14 +819,14 @@ describe('relations', function () { PictureLink.findOne(function(err, link) { should.not.exist(err); link.pictureId.should.equal(1); - link.imageableId.should.equal(3); + link.imageableId.should.equal(4); link.imageableType.should.equal('Author'); done(); }); }); it('should get polymorphic relation through model - author', function (done) { - Author.findById(3, function(err, author) { + Author.findById(4, function(err, author) { should.not.exist(err); author.name.should.equal('Author 1'); author.pictures(function(err, pics) { @@ -851,6 +851,68 @@ describe('relations', function () { }); }); }); + + it('should include polymorphic items', function (done) { + Author.find({ include: 'pictures' }, function(err, authors) { + authors.should.have.length(1); + authors[0].pictures(function(err, pics) { + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + + it('should add to a polymorphic relation - author', function (done) { + Author.findById(4, function(err, author) { + Picture.create({ id: 7, name: 'Example' }, function(err, p) { + author.pictures.add(p, function(err, link) { + link.should.be.instanceof(PictureLink); + link.pictureId.should.equal(7); + link.imageableId.should.equal(4); + link.imageableType.should.equal('Author'); + done(); + }); + }); + }); + }); + + it('should create polymorphic through model', function (done) { + PictureLink.findOne({ where: { pictureId: 7 } }, function(err, link) { + should.not.exist(err); + link.pictureId.should.equal(7); + link.imageableId.should.equal(4); + link.imageableType.should.equal('Author'); + done(); + }); + }); + + it('should check if polymorphic relation exists - author', function (done) { + Author.findById(4, function(err, author) { + author.pictures.exists(7, function(err, exists) { + exists.should.be.true; + done(); + }); + }); + }); + + it('should remove from a polymorphic relation - author', function (done) { + Author.findById(4, function(err, author) { + author.pictures.remove(7, function(err) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should check if polymorphic relation exists - author', function (done) { + Author.findById(4, function(err, author) { + author.pictures.exists(7, function(err, exists) { + exists.should.be.false; + done(); + }); + }); + }); }); From 5e30ec863797e5b3e0ce09e60283e81bced47891 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 21:11:25 +0200 Subject: [PATCH 06/36] Implemented polymorphic hasMany through inverse Added option invert: true to enable inverse polymorphic hasMany relations. Fixed missing fk1 in query of HasManyThrough.prototype.exists. --- lib/relation-definition.js | 23 +++++++----- test/relations.test.js | 73 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 5ab62a55..d85f04a0 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -371,8 +371,10 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { if (typeof params.polymorphic === 'string') { var polymorphic = params.polymorphic; - fk = i8n.camelize(polymorphic + '_id', true); typeTo = i8n.camelize(polymorphic + '_type', true); + if (!params.invert) { + fk = i8n.camelize(polymorphic + '_id', true); + } if (!params.through) { modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string', index: true }); } @@ -445,7 +447,11 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { definition.applyScope(this, filter); - if (params.through) { + if (params.through && params.polymorphic && params.invert) { + filter.where[typeTo] = modelTo.modelName; // overwrite + filter.collect = params.polymorphic; + filter.include = filter.collect; + } else if (params.through) { filter.collect = i8n.camelize(modelTo.modelName, true); filter.include = filter.collect; } @@ -758,7 +764,6 @@ HasManyThrough.prototype.exists = function (acInst, done) { var modelThrough = definition.modelThrough; var pk1 = definition.keyFrom; - var data = {}; var query = {}; // The primary key for the target model @@ -768,12 +773,14 @@ HasManyThrough.prototype.exists = function (acInst, done) { var fk1 = keys[0]; var fk2 = keys[1]; + query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; - - data[fk1] = this.modelInstance[pk1]; - data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; - - modelThrough.count(query, function(err, ac) { + + var filter = { where: query }; + + definition.applyScope(this.modelInstance, filter); + + modelThrough.count(filter.where, function(err, ac) { done(err, ac > 0); }); }; diff --git a/test/relations.test.js b/test/relations.test.js index fe2751d6..8e66dfee 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -788,6 +788,8 @@ describe('relations', function () { it('can be declared', function (done) { Author.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); Reader.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + Picture.hasMany(Author, { through: PictureLink, polymorphic: 'imageable', invert: true }); + Picture.hasMany(Reader, { through: PictureLink, polymorphic: 'imageable', invert: true }); db.automigrate(done); }); @@ -821,7 +823,11 @@ describe('relations', function () { link.pictureId.should.equal(1); link.imageableId.should.equal(4); link.imageableType.should.equal('Author'); - done(); + link.imageable(function(err, imageable) { + imageable.should.be.instanceof(Author); + imageable.id.should.equal(4); + done(); + }); }); }); @@ -856,6 +862,7 @@ describe('relations', function () { Author.find({ include: 'pictures' }, function(err, authors) { authors.should.have.length(1); authors[0].pictures(function(err, pics) { + pics.should.have.length(2); pics[0].name.should.equal('Author Pic 1'); pics[1].name.should.equal('Author Pic 2'); done(); @@ -878,7 +885,7 @@ describe('relations', function () { }); it('should create polymorphic through model', function (done) { - PictureLink.findOne({ where: { pictureId: 7 } }, function(err, link) { + PictureLink.findOne({ where: { pictureId: 7, imageableType: 'Author' } }, function(err, link) { should.not.exist(err); link.pictureId.should.equal(7); link.imageableId.should.equal(4); @@ -887,6 +894,57 @@ describe('relations', function () { }); }); + it('should add to a polymorphic relation - author', function (done) { + Author.create({ id: 5, name: 'Author 2' }, function (err, author) { + author.pictures.add(7, function (err, p) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should add to a polymorphic relation - author', function (done) { + Reader.create({ id: 5, name: 'Reader 2' }, function (err, reader) { + reader.pictures.add(7, function (err, p) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should get the inverse polymorphic relation - author', function (done) { + Picture.findById(7, function(err, p) { + p.authors(function(err, authors) { + authors.should.have.length(2); + authors[0].name.should.equal('Author 1'); + authors[1].name.should.equal('Author 2'); + done(); + }); + }); + }); + + it('should get the inverse polymorphic relation - reader', function (done) { + Picture.findById(7, function(err, p) { + p.readers(function(err, readers) { + readers.should.have.length(1); + readers[0].name.should.equal('Reader 2'); + done(); + }); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findById(4, function(err, author) { + author.pictures(function(err, pics) { + pics.should.have.length(3); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + pics[2].name.should.equal('Example'); + done(); + }); + }); + }); + it('should check if polymorphic relation exists - author', function (done) { Author.findById(4, function(err, author) { author.pictures.exists(7, function(err, exists) { @@ -905,6 +963,17 @@ describe('relations', function () { }); }); + it('should find polymorphic items - author', function (done) { + Author.findById(4, function(err, author) { + author.pictures(function(err, pics) { + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + it('should check if polymorphic relation exists - author', function (done) { Author.findById(4, function(err, author) { author.pictures.exists(7, function(err, exists) { From 5a5aa3f14d19d376bff8f8dc9f0e3e98ad6e6776 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 21:32:24 +0200 Subject: [PATCH 07/36] Minor tweaks; pass-through properties/scope for hasAndBelongsToMany --- lib/relation-definition.js | 2 ++ test/relations.test.js | 1 + 2 files changed, 3 insertions(+) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index d85f04a0..856e252d 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1084,6 +1084,8 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, } var options = {as: params.as, through: params.through}; + options.properties = params.properties; + options.scope = params.scope; if (isPolymorphic) { options.polymorphic = params.polymorphic; diff --git a/test/relations.test.js b/test/relations.test.js index 8e66dfee..a6580649 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -788,6 +788,7 @@ describe('relations', function () { it('can be declared', function (done) { Author.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); Reader.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + // Optionally, define inverse relations: Picture.hasMany(Author, { through: PictureLink, polymorphic: 'imageable', invert: true }); Picture.hasMany(Reader, { through: PictureLink, polymorphic: 'imageable', invert: true }); db.automigrate(done); From 59a957b538be304dfac13f09960059680c730968 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:30:45 +0200 Subject: [PATCH 08/36] Implemented embedsMany relation --- lib/connectors/memory.js | 1 + lib/relation-definition.js | 307 ++++++++++++++++++++++++++++++++++++- lib/relations.js | 4 + lib/scope.js | 4 +- test/relations.test.js | 254 ++++++++++++++++++++++++++++++ 5 files changed, 567 insertions(+), 3 deletions(-) diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 829cca34..97687cf2 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -17,6 +17,7 @@ exports.initialize = function initializeDataSource(dataSource, callback) { }; exports.Memory = Memory; +exports.applyFilter = applyFilter; function Memory(m, settings) { if (m instanceof Memory) { diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 856e252d..6062a7c8 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -7,6 +7,8 @@ var i8n = require('inflection'); var defineScope = require('./scope.js').defineScope; var mergeQuery = require('./scope.js').mergeQuery; var ModelBaseClass = require('./model.js'); +var applyFilter = require('./connectors/memory').applyFilter; +var ValidationError = require('./validations.js').ValidationError; exports.Relation = Relation; exports.RelationDefinition = RelationDefinition; @@ -15,7 +17,8 @@ var RelationTypes = { belongsTo: 'belongsTo', hasMany: 'hasMany', hasOne: 'hasOne', - hasAndBelongsToMany: 'hasAndBelongsToMany' + hasAndBelongsToMany: 'hasAndBelongsToMany', + embedsMany: 'embedsMany' }; exports.RelationTypes = RelationTypes; @@ -24,13 +27,15 @@ exports.HasManyThrough = HasManyThrough; exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; +exports.EmbedsMany = EmbedsMany; var RelationClasses = { belongsTo: BelongsTo, hasMany: HasMany, hasManyThrough: HasManyThrough, hasOne: HasOne, - hasAndBelongsToMany: HasAndBelongsToMany + hasAndBelongsToMany: HasAndBelongsToMany, + embedsMany: EmbedsMany }; function normalizeType(type) { @@ -75,6 +80,7 @@ function RelationDefinition(definition) { this.properties = definition.properties || {}; this.options = definition.options || {}; this.scope = definition.scope; + this.embed = definition.embed === true; } RelationDefinition.prototype.toJSON = function () { @@ -290,6 +296,23 @@ function HasOne(definition, modelInstance) { util.inherits(HasOne, Relation); +/** + * EmbedsMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {EmbedsMany} + * @constructor + * @class EmbedsMany + */ +function EmbedsMany(definition, modelInstance) { + if (!(this instanceof EmbedsMany)) { + return new EmbedsMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.embedsMany); + Relation.apply(this, arguments); +} + +util.inherits(EmbedsMany, Relation); /*! * Find the relation by foreign key @@ -1342,3 +1365,283 @@ HasOne.prototype.related = function (refresh, params) { self.resetCache(); } }; + +RelationDefinition.embedsMany = 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); + } + } + + if (modelTo.dataSource.name !== 'memory') { + throw new Error('Invalid embedded model: `' + modelTo.modelName + '` (memory connector only)'); + } + + var accessorName = params.as || (i8n.camelize(modelTo.modelName, true) + 'List'); + var relationName = params.property || i8n.camelize(modelTo.pluralModelName, true); + var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; + + var definition = new RelationDefinition({ + name: relationName, + type: RelationTypes.embedsMany, + modelFrom: modelFrom, + keyFrom: idName, + keyTo: fk, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options, + embed: true + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, relationName, { + type: [modelTo], default: function() { return []; } + }); + + // require explicit/unique ids unless autoId === true + if (definition.options.autoId === false) { + modelTo.validatesPresenceOf(idName); + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var ids = embeddedList.map(function(m) { return m[idName]; }); + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + } + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists') + }; + + var findByIdFunc = scopeMethods.findById; + modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + + // Mix the property and scoped methods into the prototype class + var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition +}; + +EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var name = this.definition.name; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + var embeddedList = self[name] || []; + + var params = mergeQuery(actualCond, scopeParams); + if (params.where) { + embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList; + } + + process.nextTick(function() { cb(null, embeddedList); }); +}; + +EmbedsMany.prototype.findById = function (fkId, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + fkId = fkId.toString(); // in case of explicit id + + var find = function(id) { + for (var i = 0; i < embeddedList.length; i++) { + var item = embeddedList[i]; + if (item[pk].toString() === fkId) return item; + } + return null; + }; + + var item = find(fkId); + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + +EmbedsMany.prototype.exists = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var inst = this.findById(fkId, function (err, inst) { + if (cb) cb(err, inst instanceof modelTo); + }); + return inst instanceof modelTo; // sync +}; + +EmbedsMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.findById(fkId); + + if (inst instanceof modelTo) { + if (typeof data === 'object') { + for (var key in data) { + inst[key] = data[key]; + } + } + var err = inst.isValid() ? null : new ValidationError(inst); + if (err && typeof cb === 'function') { + return process.nextTick(function() { + cb(err, inst); + }); + } + + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err, inst); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, null); // not found + }); + } + return inst; // sync +}; + +EmbedsMany.prototype.destroyById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.findById(fkId); + + if (inst instanceof modelTo) { + var index = embeddedList.indexOf(inst); + if (index > -1) embeddedList.splice(index, 1); + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err, inst); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, null); // not found + }); + } + return inst; // sync +}; + +EmbedsMany.prototype.create = function (targetModelData, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var autoId = this.definition.options.autoId !== false; + + var embeddedList = modelInstance[relationName] || []; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + if (typeof targetModelData[pk] !== 'number' && autoId) { + var ids = embeddedList.map(function(m) { + return (typeof m[pk] === 'number' ? m[pk] : 0); + }); + targetModelData[pk] = (Math.max(ids) || 0) + 1; + } + + this.definition.applyProperties(this.modelInstance, targetModelData); + + var inst = new modelTo(targetModelData); + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + return process.nextTick(function() { + cb(err, embeddedList); + }); + } else if (this.definition.options.prepend) { + embeddedList.unshift(inst); + } else { + embeddedList.push(inst); + } + + modelInstance.updateAttribute(relationName, + embeddedList, function(err, modelInst) { + cb(err, modelInst[relationName]); + }); +}; + +EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + targetModelData = targetModelData || {}; + + this.definition.applyProperties(this.modelInstance, targetModelData); + + var embeddedList = modelInstance[relationName] || []; + + var inst = new modelTo(targetModelData); + + if (this.definition.options.prepend) { + embeddedList.unshift(inst); + } else { + embeddedList.push(inst); + } + + return inst; +}; diff --git a/lib/relations.js b/lib/relations.js index c0985f2f..493fdb92 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -161,3 +161,7 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; + +RelationMixin.embedsMany = function hasMany(modelTo, params) { + RelationDefinition.embedsMany(this, modelTo, params); +}; diff --git a/lib/scope.js b/lib/scope.js index c1c90eba..129e0b9f 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -18,7 +18,7 @@ function ScopeDefinition(definition) { ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { var name = this.name; var self = receiver; - + var actualCond = {}; var actualRefresh = false; var saveOnCache = true; @@ -224,6 +224,8 @@ function defineScope(cls, targetClass, name, params, methods) { var where = (this._scope && this._scope.where) || {}; targetClass.destroyAll(where, cb); } + + return definition; } /*! diff --git a/test/relations.test.js b/test/relations.test.js index a6580649..a455b41a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -4,6 +4,7 @@ var should = require('./init.js'); var db, Book, Chapter, Author, Reader; var Category, Product; var Picture, PictureLink; +var Person, Address; describe('relations', function () { @@ -1198,5 +1199,258 @@ describe('relations', function () { should.equal(Article.prototype.tags._targetClass, 'Tag'); }); }); + + describe('embedsMany', function () { + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address); + db.automigrate(done); + }); + + it('should have setup embedded accessor/scope', function() { + var p = new Person({ name: 'Fred' }); + p.addresses.should.be.an.array; + p.addresses.should.have.length(0); + p.addressList.should.be.a.function; + p.addressList.findById.should.be.a.function; + p.addressList.updateById.should.be.a.function; + p.addressList.destroy.should.be.a.function; + p.addressList.exists.should.be.a.function; + p.addressList.create.should.be.a.function; + p.addressList.build.should.be.a.function; + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ street: 'Street 1' }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(1); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + done(); + }); + }); + }); + + it('should create embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ street: 'Street 2' }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(2); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal(2); + addresses[1].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should return embedded items from scope', function(done) { + Person.findOne(function(err, p) { + p.addressList(function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(2); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal(2); + addresses[1].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should filter embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList({ where: { street: 'Street 2' } }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(1); + addresses[0].id.should.equal(2); + addresses[0].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should validate embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({}, function(err, addresses) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + addresses.should.have.length(2); + done(); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(2, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should check if item exists', function(done) { + Person.findOne(function(err, p) { + p.addressList.exists(2, function(err, exists) { + should.not.exist(err); + exists.should.be.true; + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(2, { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should validate the update of embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(2, { street: null }, function(err, address) { + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + done(); + }); + }); + }); + + it('should find embedded items by id - verify', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(2, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy(1, function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + }); + + describe('embedsMany - explicit ids', function () { + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {id: { type: String, id: true }, street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address, { options: { autoId: false } }); + db.automigrate(done); + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ id: 'home', street: 'Street 1' }, function(err, addresses) { + should.not.exist(err); + p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, addresses) { + addresses.should.have.length(2); + addresses[0].id.should.equal('home'); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal('work'); + addresses[1].street.should.equal('Work Street 2'); + done(); + }); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById('work', function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); + done(); + }); + }); + }); + + it('should check for duplicate ids', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ id: 'home', street: 'Invalid' }, function(err, addresses) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.addresses.should.eql(['uniqueness']); + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById('home', { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('home'); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy('home', function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + }); }); From 1487a592c13afb0e4b239fafe89cb388ab6413b0 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:54:01 +0200 Subject: [PATCH 09/36] Added validation for embedded items (optional) --- lib/relation-definition.js | 23 +++++++++++++++++++++++ test/relations.test.js | 15 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 6062a7c8..3d795bcf 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1422,6 +1422,29 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { }, { code: 'uniqueness' }) } + // validate all embedded items + if (definition.options.validate) { + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var hasErrors = false; + embeddedList.forEach(function(item) { + if (item instanceof modelTo) { + if (!item.isValid()) { + hasErrors = true; + var first = Object.keys(item.errors)[0]; + var msg = 'contains invalid item: `' + item[idName] + '`'; + msg += ' (' + first + ' ' + item.errors[first] + ')'; + this.errors.add(relationName, msg, 'invalid'); + } + } else { + hasErrors = true; + this.errors.add(relationName, 'Contains invalid item', 'invalid'); + } + }.bind(this)); + if (hasErrors) err(false); + }); + } + var scopeMethods = { findById: scopeMethod(definition, 'findById'), destroy: scopeMethod(definition, 'destroyById'), diff --git a/test/relations.test.js b/test/relations.test.js index a455b41a..5140dfbb 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1380,7 +1380,7 @@ describe('relations', function () { }); it('can be declared', function (done) { - Person.embedsMany(Address, { options: { autoId: false } }); + Person.embedsMany(Address, { options: { autoId: false, validate: true } }); db.automigrate(done); }); @@ -1451,6 +1451,19 @@ describe('relations', function () { }); }); + it('should validate all embedded items', function(done) { + var addresses = []; + addresses.push({ id: 'home', street: 'Home Street' }); + addresses.push({ id: 'work', street: '' }); + Person.create({ name: 'Wilma', addresses: addresses }, function(err, p) { + err.name.should.equal('ValidationError'); + var expected = 'The `Person` instance is not valid. '; + expected += 'Details: `addresses` contains invalid item: `work` (street can\'t be blank).'; + err.message.should.equal(expected); + done(); + }); + }); + }); }); From cd2cc68905f9431790543f604281dc9c47cd6330 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:56:30 +0200 Subject: [PATCH 10/36] Minor fix --- lib/relation-definition.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 3d795bcf..001075a9 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1431,8 +1431,9 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { if (item instanceof modelTo) { if (!item.isValid()) { hasErrors = true; + var id = item[idName] || '(blank)'; var first = Object.keys(item.errors)[0]; - var msg = 'contains invalid item: `' + item[idName] + '`'; + var msg = 'contains invalid item: `' + id + '`'; msg += ' (' + first + ' ' + item.errors[first] + ')'; this.errors.add(relationName, msg, 'invalid'); } From 43e11af942099f78cff44d2122d0b625b034989b Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 17:16:25 +0200 Subject: [PATCH 11/36] Test build of embedsMany --- lib/relation-definition.js | 61 +++++++++++++++++++++----------------- test/relations.test.js | 23 ++++++++++++++ 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 001075a9..b022072a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1614,6 +1614,39 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { var modelInstance = this.modelInstance; var autoId = this.definition.options.autoId !== false; + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.build(targetModelData); + + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + var index = embeddedList.indexOf(inst); + if (index > -1) embeddedList.splice(index, 1); + return process.nextTick(function() { + cb(err, embeddedList); + }); + } + + modelInstance.updateAttribute(relationName, + embeddedList, function(err, modelInst) { + cb(err, modelInst[relationName]); + }); +}; + +EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var autoId = this.definition.options.autoId !== false; + var embeddedList = modelInstance[relationName] || []; if (typeof targetModelData === 'function' && !cb) { @@ -1631,34 +1664,6 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { this.definition.applyProperties(this.modelInstance, targetModelData); - var inst = new modelTo(targetModelData); - var err = inst.isValid() ? null : new ValidationError(inst); - - if (err) { - return process.nextTick(function() { - cb(err, embeddedList); - }); - } else if (this.definition.options.prepend) { - embeddedList.unshift(inst); - } else { - embeddedList.push(inst); - } - - modelInstance.updateAttribute(relationName, - embeddedList, function(err, modelInst) { - cb(err, modelInst[relationName]); - }); -}; - -EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { - var modelTo = this.definition.modelTo; - var relationName = this.definition.name; - targetModelData = targetModelData || {}; - - this.definition.applyProperties(this.modelInstance, targetModelData); - - var embeddedList = modelInstance[relationName] || []; - var inst = new modelTo(targetModelData); if (this.definition.options.prepend) { diff --git a/test/relations.test.js b/test/relations.test.js index 5140dfbb..9807e6b4 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1464,6 +1464,29 @@ describe('relations', function () { }); }); + it('should build embedded items', function(done) { + Person.create({ name: 'Wilma' }, function(err, p) { + p.addressList.build({ id: 'home', street: 'Home' }); + p.addressList.build({ id: 'work', street: 'Work' }); + p.addresses.should.have.length(2); + p.save(function(err, p) { + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addresses[0].id.should.equal('home'); + p.addresses[0].street.should.equal('Home'); + p.addresses[1].id.should.equal('work'); + p.addresses[1].street.should.equal('Work'); + done(); + }); + }); + }); }); From e1ecb4b95f4c91da15dc6b57d39973a0ede11954 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 17:30:10 +0200 Subject: [PATCH 12/36] Require unique ids for embedded items --- lib/relation-definition.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b022072a..719bf637 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1406,21 +1406,19 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { type: [modelTo], default: function() { return []; } }); - // require explicit/unique ids unless autoId === true - if (definition.options.autoId === false) { - modelTo.validatesPresenceOf(idName); - modelFrom.validate(relationName, function(err) { - var embeddedList = this[relationName] || []; - var ids = embeddedList.map(function(m) { return m[idName]; }); - var uniqueIds = ids.filter(function(id, pos) { - return ids.indexOf(id) === pos; - }); - if (ids.length !== uniqueIds.length) { - this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); - err(false); - } - }, { code: 'uniqueness' }) - } + // unique id is required + modelTo.validatesPresenceOf(idName); + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var ids = embeddedList.map(function(m) { return m[idName]; }); + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) // validate all embedded items if (definition.options.validate) { From 4e76c2a77f487702534325106a51a391b170526c Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Mon, 28 Jul 2014 10:18:42 +0200 Subject: [PATCH 13/36] typeTo => discriminator --- lib/relation-definition.js | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 856e252d..c46b1c3a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -65,8 +65,8 @@ function RelationDefinition(definition) { this.keyFrom = definition.keyFrom; this.modelTo = definition.modelTo; this.keyTo = definition.keyTo; - this.typeTo = definition.typeTo; - if (!this.typeTo) { + this.discriminator = definition.discriminator; + if (!this.discriminator) { assert(this.modelTo, 'Target model is required'); } this.modelThrough = definition.modelThrough; @@ -102,8 +102,8 @@ RelationDefinition.prototype.toJSON = function () { RelationDefinition.prototype.applyScope = function(modelInstance, filter) { filter.where = filter.where || {}; if ((this.type !== 'belongsTo' || this.type === 'hasOne') - && typeof this.typeTo === 'string') { // polymorphic - filter.where[this.typeTo] = this.modelFrom.modelName; + && typeof this.discriminator === 'string') { // polymorphic + filter.where[this.discriminator] = this.modelFrom.modelName; } if (typeof this.scope === 'function') { var scope = this.scope.call(this, modelInstance, filter); @@ -133,8 +133,8 @@ RelationDefinition.prototype.applyProperties = function(modelInstance, target) { } } if ((this.type !== 'belongsTo' || this.type === 'hasOne') - && typeof this.typeTo === 'string') { // polymorphic - target[this.typeTo] = this.modelFrom.modelName; + && typeof this.discriminator === 'string') { // polymorphic + target[this.discriminator] = this.modelFrom.modelName; } }; @@ -367,16 +367,16 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - var typeTo; + var discriminator; if (typeof params.polymorphic === 'string') { var polymorphic = params.polymorphic; - typeTo = i8n.camelize(polymorphic + '_type', true); + discriminator = i8n.camelize(polymorphic + '_type', true); if (!params.invert) { fk = i8n.camelize(polymorphic + '_id', true); } if (!params.through) { - modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string', index: true }); + modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); } } @@ -386,7 +386,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: idName, keyTo: fk, - typeTo: typeTo, + discriminator: discriminator, modelTo: modelTo, multiple: true, properties: params.properties, @@ -448,7 +448,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { definition.applyScope(this, filter); if (params.through && params.polymorphic && params.invert) { - filter.where[typeTo] = modelTo.modelName; // overwrite + filter.where[discriminator] = modelTo.modelName; // overwrite filter.collect = params.polymorphic; filter.include = filter.collect; } else if (params.through) { @@ -584,7 +584,7 @@ var throughKeys = function(definition) { var modelThrough = definition.modelThrough; var pk2 = definition.modelTo.definition.idName(); - if (definition.typeTo) { // polymorphic + if (definition.discriminator) { // polymorphic var fk1 = definition.keyTo; var fk2 = definition.keyThrough; } else { @@ -845,7 +845,7 @@ HasManyThrough.prototype.remove = function (acInst, done) { * */ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { - var typeTo, params = params || {}; + var discriminator, params = params || {}; if ('string' === typeof modelTo && !params.polymorphic) { params.as = modelTo; if (params.model) { @@ -863,7 +863,7 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { var idName = params.idName || 'id'; var relationName = params.as || i8n.camelize(polymorphic, true); var fk = i8n.camelize(polymorphic + '_id', true); - var typeTo = i8n.camelize(polymorphic + '_type', true); + var discriminator = i8n.camelize(polymorphic + '_type', true); if (typeof params.idType === 'string') { // explicit key type modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: params.idType, index: true }); @@ -871,7 +871,7 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelFrom.modelName); } - modelFrom.dataSource.defineProperty(modelFrom.modelName, typeTo, { type: 'string', index: true }); + modelFrom.dataSource.defineProperty(modelFrom.modelName, discriminator, { type: 'string', index: true }); } else { var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; var relationName = params.as || i8n.camelize(modelTo.modelName, true); @@ -886,7 +886,7 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: fk, keyTo: idName, - typeTo: typeTo, + discriminator: discriminator, modelTo: modelTo, properties: params.properties, scope: params.scope, @@ -965,7 +965,7 @@ BelongsTo.prototype.related = function (refresh, params) { var self = this; var modelFrom = this.definition.modelFrom; var modelTo = this.definition.modelTo; - var typeTo = this.definition.typeTo; + var discriminator = this.definition.discriminator; var pk = this.definition.keyTo; var fk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -984,14 +984,14 @@ BelongsTo.prototype.related = function (refresh, params) { if (params instanceof ModelBaseClass) { // acts as setter modelTo = params.constructor; modelInstance[fk] = params[pk]; - if (typeTo) modelInstance[typeTo] = params.constructor.modelName; + if (discriminator) modelInstance[discriminator] = params.constructor.modelName; self.resetCache(params); } else if (typeof params === 'function') { // acts as async getter - if (typeTo && !modelTo) { - var modelToName = modelInstance[typeTo]; + if (discriminator && !modelTo) { + var modelToName = modelInstance[discriminator]; if (typeof modelToName !== 'string') { - throw new Error('Polymorphic model not found: `' + typeTo + '` not set'); + throw new Error('Polymorphic model not found: `' + discriminator + '` not set'); } modelToName = modelToName.toLowerCase(); modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); @@ -1134,14 +1134,14 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { var relationName = params.as || i8n.camelize(modelTo.modelName, true); var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true); - var typeTo; + var discriminator; if (typeof params.polymorphic === 'string') { var polymorphic = params.polymorphic; fk = i8n.camelize(polymorphic + '_id', true); - typeTo = i8n.camelize(polymorphic + '_type', true); + discriminator = i8n.camelize(polymorphic + '_type', true); if (!params.through) { - modelTo.dataSource.defineProperty(modelTo.modelName, typeTo, { type: 'string', index: true }); + modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); } } @@ -1151,7 +1151,7 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: pk, keyTo: fk, - typeTo: typeTo, + discriminator: discriminator, modelTo: modelTo, properties: params.properties, options: params.options From 7ddfbb64095cbd90eaf7e994951980bc74cdbf32 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Mon, 28 Jul 2014 10:44:26 +0200 Subject: [PATCH 14/36] polymorphics can now be declared using object --- lib/relation-definition.js | 39 ++++++++++++++++++++++++++++---------- test/relations.test.js | 5 ++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index c46b1c3a..bf7d3826 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -328,6 +328,19 @@ function lookupModel(models, modelName) { } } +/*! + * Normalize polymorphic parameters + * @param {Object|String} params Name of the polymorphic relation or params + * @returns {Object} The normalized parameters + */ +function polymorphicParams(params) { + if (typeof params === 'string') params = { as: params }; + if (typeof params.as !== 'string') params.as = 'reference'; // default + params.foreignKey = params.foreignKey || i8n.camelize(params.as + '_id', true); + params.discriminator = params.discriminator || i8n.camelize(params.as + '_type', true); + return params; +}; + /** * Define a "one to many" relationship by specifying the model name * @@ -369,11 +382,11 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; var discriminator; - if (typeof params.polymorphic === 'string') { - var polymorphic = params.polymorphic; - discriminator = i8n.camelize(polymorphic + '_type', true); + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + discriminator = polymorphic.discriminator; if (!params.invert) { - fk = i8n.camelize(polymorphic + '_id', true); + fk = polymorphic.foreignKey; } if (!params.through) { modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); @@ -857,16 +870,22 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { } if (params.polymorphic) { - var polymorphic = modelTo; + if (params.polymorphic === true) { + // modelTo will be the name of the polymorphic relation (string) + var polymorphic = polymorphicParams(modelTo); + } else { + var polymorphic = polymorphicParams(params.polymorphic); + } + modelTo = null; // will lookup dynamically var idName = params.idName || 'id'; - var relationName = params.as || i8n.camelize(polymorphic, true); - var fk = i8n.camelize(polymorphic + '_id', true); - var discriminator = i8n.camelize(polymorphic + '_type', true); + var relationName = params.as || polymorphic.as; + var fk = polymorphic.foreignKey; + var discriminator = polymorphic.discriminator; - if (typeof params.idType === 'string') { // explicit key type - modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: params.idType, index: true }); + if (typeof polymorphic.idType === 'string') { // explicit key type + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: polymorphic.idType, index: true }); } else { // try to use the same foreign key type as modelFrom modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelFrom.modelName); } diff --git a/test/relations.test.js b/test/relations.test.js index a6580649..6809e43b 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -641,7 +641,10 @@ describe('relations', function () { it('can be declared', function (done) { Author.hasMany(Picture, { polymorphic: 'imageable' }); - Reader.hasMany(Picture, { polymorphic: 'imageable' }); + Reader.hasMany(Picture, { polymorphic: { // alt syntax + as: 'imageable', foreignKey: 'imageableId', + discriminator: 'imageableType' + } }); Picture.belongsTo('imageable', { polymorphic: true }); db.automigrate(done); }); From 309105c4adcc594897cc69e39306fee3364992d2 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Mon, 28 Jul 2014 11:12:20 +0200 Subject: [PATCH 15/36] Updated remaining relations to use polymorphicParams --- lib/relation-definition.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index bf7d3826..eddcd91c 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -871,7 +871,7 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { if (params.polymorphic) { if (params.polymorphic === true) { - // modelTo will be the name of the polymorphic relation (string) + // modelTo arg will be the name of the polymorphic relation (string) var polymorphic = polymorphicParams(modelTo); } else { var polymorphic = polymorphicParams(params.polymorphic); @@ -1091,11 +1091,9 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, throw new Error('Could not find "' + modelTo + '" relation for ' + modelFrom.modelName); } } - - var isPolymorphic = (typeof params.polymorphic === 'string'); - + if (!params.through) { - if (isPolymorphic) throw new Error('Polymorphic relations need a through model'); + if (params.polymorphic) throw new Error('Polymorphic relations need a through model'); var name1 = modelFrom.modelName + modelTo.modelName; var name2 = modelTo.modelName + modelFrom.modelName; params.through = lookupModel(models, name1) || lookupModel(models, name2) || @@ -1106,11 +1104,13 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, options.properties = params.properties; options.scope = params.scope; - if (isPolymorphic) { - options.polymorphic = params.polymorphic; - var accessor = params.through.prototype[params.polymorphic]; + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + options.polymorphic = polymorphic; // pass through + var accessor = params.through.prototype[polymorphic.as]; if (typeof accessor !== 'function') { // declare once - params.through.belongsTo(params.polymorphic, { polymorphic: true }); + // use the name of the polymorphic rel, not modelTo + params.through.belongsTo(polymorphic.as, { polymorphic: true }); } } else { params.through.belongsTo(modelFrom); @@ -1155,10 +1155,10 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true); var discriminator; - if (typeof params.polymorphic === 'string') { - var polymorphic = params.polymorphic; - fk = i8n.camelize(polymorphic + '_id', true); - discriminator = i8n.camelize(polymorphic + '_type', true); + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + fk = polymorphic.foreignKey; + discriminator = polymorphic.discriminator; if (!params.through) { modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); } From 04a56e2ea6635a16e061b0aec1bd94b41728eb9e Mon Sep 17 00:00:00 2001 From: Laurent Chenay Date: Mon, 28 Jul 2014 11:36:32 +0200 Subject: [PATCH 16/36] Fix HEAD on relation hasMany --- lib/relation-definition.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 490d27c2..0a4dbc69 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -397,6 +397,9 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + var existsByIdFunc = scopeMethods.exists; + modelFrom.prototype['__exists__' + relationName] = existsByIdFunc; + if(definition.modelThrough) { scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.add = scopeMethod(definition, 'add'); From 7ad22939abd4275ca76c8f082bedc3d6ae8ec348 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jul 2014 09:00:05 -0700 Subject: [PATCH 17/36] Export GeoPoint class --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index e3eacac0..a1e5420e 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ exports.ModelBuilder = exports.LDL = require('./lib/model-builder.js').ModelBuilder; exports.DataSource = exports.Schema = require('./lib/datasource.js').DataSource; exports.ModelBaseClass = require('./lib/model.js'); +exports.GeoPoint = require('./lib/geo.js').GeoPoint; exports.ValidationError = require('./lib/validations.js').ValidationError; exports.__defineGetter__('version', function () { From 5221d12305d2274bb0812b080b8230b7cfd25379 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jul 2014 09:36:04 -0700 Subject: [PATCH 18/36] Remove unused data --- lib/relation-definition.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 0a4dbc69..032168e9 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -720,7 +720,6 @@ HasManyThrough.prototype.exists = function (acInst, done) { var modelThrough = definition.modelThrough; var pk1 = definition.keyFrom; - var data = {}; var query = {}; var fk1 = findBelongsTo(modelThrough, definition.modelFrom, @@ -732,12 +731,8 @@ HasManyThrough.prototype.exists = function (acInst, done) { var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); query[fk1] = this.modelInstance[pk1]; - query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; - data[fk1] = this.modelInstance[pk1]; - data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; - modelThrough.count(query, function(err, ac) { done(err, ac > 0); }); From 8bc11ca51e8e4d314e510116f6ec5c9882798c26 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jul 2014 13:02:00 -0700 Subject: [PATCH 19/36] Increase the max number of model listeners See https://github.com/strongloop/loopback/issues/404 --- lib/model-builder.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/model-builder.js b/lib/model-builder.js index 60758ddf..a688004d 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -161,6 +161,9 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett }; // mix in EventEmitter (don't inherit from) var events = new EventEmitter(); + // The model can have more than 10 listeners for lazy relationship setup + // See https://github.com/strongloop/loopback/issues/404 + events.setMaxListeners(32); for (var f in EventEmitter.prototype) { if (typeof EventEmitter.prototype[f] === 'function') { ModelClass[f] = EventEmitter.prototype[f].bind(events); From cb43114ab7d9795b401abbed2dbfd7c074fcbc01 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jul 2014 16:15:37 -0700 Subject: [PATCH 20/36] Fix test cases --- lib/relation-definition.js | 4 --- test/relations.test.js | 52 ++++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index d707a929..6198ce38 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1412,10 +1412,6 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { } } - if (modelTo.dataSource.name !== 'memory') { - throw new Error('Invalid embedded model: `' + modelTo.modelName + '` (memory connector only)'); - } - var accessorName = params.as || (i8n.camelize(modelTo.modelName, true) + 'List'); var relationName = params.property || i8n.camelize(modelTo.pluralModelName, true); var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; diff --git a/test/relations.test.js b/test/relations.test.js index 1a6b2ad5..99d4332d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -581,7 +581,7 @@ describe('relations', function () { author.avatar(function (err, p) { should.not.exist(err); p.name.should.equal('Avatar'); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -593,7 +593,7 @@ describe('relations', function () { reader.mugshot(function (err, p) { should.not.exist(err); p.name.should.equal('Mugshot'); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -655,7 +655,7 @@ describe('relations', function () { author.pictures.create({ name: 'Author Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -667,7 +667,7 @@ describe('relations', function () { reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -738,7 +738,7 @@ describe('relations', function () { Author.create({ name: 'Author 2' }, function(err, author) { var p = new Picture({ name: 'Sample' }); p.imageable(author); // assign - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); p.save(done); }); @@ -1090,7 +1090,7 @@ describe('relations', function () { it('should find record on scope', function (done) { Passport.findOne(function (err, p) { - p.personId.should.equal(personCreated.id); + p.personId.should.eql(personCreated.id); p.person(function(err, person) { person.name.should.equal('Fred'); person.should.not.have.property('age'); @@ -1248,13 +1248,15 @@ describe('relations', function () { p.addressList.create.should.be.a.function; p.addressList.build.should.be.a.function; }); - + + var address1, address2; it('should create embedded items on scope', function(done) { Person.create({ name: 'Fred' }, function(err, p) { p.addressList.create({ street: 'Street 1' }, function(err, addresses) { should.not.exist(err); addresses.should.have.length(1); - addresses[0].id.should.equal(1); + address1 = addresses[0]; + should.exist(address1.id); addresses[0].street.should.equal('Street 1'); done(); }); @@ -1266,10 +1268,12 @@ describe('relations', function () { p.addressList.create({ street: 'Street 2' }, function(err, addresses) { should.not.exist(err); addresses.should.have.length(2); - addresses[0].id.should.equal(1); - addresses[0].street.should.equal('Street 1'); - addresses[1].id.should.equal(2); - addresses[1].street.should.equal('Street 2'); + address1 = addresses[0]; + address2 = addresses[1]; + should.exist(address1.id); + address1.street.should.equal('Street 1'); + should.exist(address2.id); + address2.street.should.equal('Street 2'); done(); }); }); @@ -1280,9 +1284,9 @@ describe('relations', function () { p.addressList(function(err, addresses) { should.not.exist(err); addresses.should.have.length(2); - addresses[0].id.should.equal(1); + addresses[0].id.should.eql(address1.id); addresses[0].street.should.equal('Street 1'); - addresses[1].id.should.equal(2); + addresses[1].id.should.eql(address2.id); addresses[1].street.should.equal('Street 2'); done(); }); @@ -1294,7 +1298,7 @@ describe('relations', function () { p.addressList({ where: { street: 'Street 2' } }, function(err, addresses) { should.not.exist(err); addresses.should.have.length(1); - addresses[0].id.should.equal(2); + addresses[0].id.should.eql(address2.id); addresses[0].street.should.equal('Street 2'); done(); }); @@ -1315,9 +1319,9 @@ describe('relations', function () { it('should find embedded items by id', function(done) { Person.findOne(function(err, p) { - p.addressList.findById(2, function(err, address) { + p.addressList.findById(address2.id, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('Street 2'); done(); }); @@ -1326,7 +1330,7 @@ describe('relations', function () { it('should check if item exists', function(done) { Person.findOne(function(err, p) { - p.addressList.exists(2, function(err, exists) { + p.addressList.exists(address2.id, function(err, exists) { should.not.exist(err); exists.should.be.true; done(); @@ -1336,9 +1340,9 @@ describe('relations', function () { it('should update embedded items by id', function(done) { Person.findOne(function(err, p) { - p.addressList.updateById(2, { street: 'New Street' }, function(err, address) { + p.addressList.updateById(address2.id, { street: 'New Street' }, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('New Street'); done(); }); @@ -1347,7 +1351,7 @@ describe('relations', function () { it('should validate the update of embedded items', function(done) { Person.findOne(function(err, p) { - p.addressList.updateById(2, { street: null }, function(err, address) { + p.addressList.updateById(address2.id, { street: null }, function(err, address) { err.name.should.equal('ValidationError'); err.details.codes.street.should.eql(['presence']); done(); @@ -1357,9 +1361,9 @@ describe('relations', function () { it('should find embedded items by id - verify', function(done) { Person.findOne(function(err, p) { - p.addressList.findById(2, function(err, address) { + p.addressList.findById(address2.id, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('New Street'); done(); }); @@ -1369,7 +1373,7 @@ describe('relations', function () { it('should remove embedded items by id', function(done) { Person.findOne(function(err, p) { p.addresses.should.have.length(2); - p.addressList.destroy(1, function(err) { + p.addressList.destroy(address1.id, function(err) { should.not.exist(err); p.addresses.should.have.length(1); done(); From 6ed7a0a5f231bf6bed56a6b0e8e572fb6cbbe130 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 10:51:33 +0200 Subject: [PATCH 21/36] Convenience embedsMany accessors: at(idx), get(id), set(id, data) --- lib/relation-definition.js | 48 +++++++++++++++++++++++++++++++++++++- lib/relations.js | 4 ++++ test/relations.test.js | 29 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 6198ce38..d14582b8 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -18,6 +18,7 @@ var RelationTypes = { hasMany: 'hasMany', hasOne: 'hasOne', hasAndBelongsToMany: 'hasAndBelongsToMany', + referencesMany: 'referencesMany', embedsMany: 'embedsMany' }; @@ -27,6 +28,7 @@ exports.HasManyThrough = HasManyThrough; exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; +exports.ReferencesMany = ReferencesMany; exports.EmbedsMany = EmbedsMany; var RelationClasses = { @@ -35,6 +37,7 @@ var RelationClasses = { hasManyThrough: HasManyThrough, hasOne: HasOne, hasAndBelongsToMany: HasAndBelongsToMany, + referencesMany: ReferencesMany, embedsMany: EmbedsMany }; @@ -314,6 +317,24 @@ function EmbedsMany(definition, modelInstance) { util.inherits(EmbedsMany, Relation); +/** + * ReferencesMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasMany} + * @constructor + * @class HasMany + */ +function ReferencesMany(definition, modelInstance) { + if (!(this instanceof HasMany)) { + return new HasMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasMany); + Relation.apply(this, arguments); +} + +util.inherits(ReferencesMany, Relation); + /*! * Find the relation by foreign key * @param {*} foreignKey The foreign key @@ -1477,7 +1498,10 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { findById: scopeMethod(definition, 'findById'), destroy: scopeMethod(definition, 'destroyById'), updateById: scopeMethod(definition, 'updateById'), - exists: scopeMethod(definition, 'exists') + exists: scopeMethod(definition, 'exists'), + get: scopeMethod(definition, 'get'), + set: scopeMethod(definition, 'set'), + at: scopeMethod(definition, 'at') }; var findByIdFunc = scopeMethods.findById; @@ -1634,6 +1658,28 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { return inst; // sync }; +EmbedsMany.prototype.get = EmbedsMany.prototype.findById; +EmbedsMany.prototype.set = EmbedsMany.prototype.updateById; + +EmbedsMany.prototype.at = function (index, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var item = embeddedList[parseInt(index)]; + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + EmbedsMany.prototype.create = function (targetModelData, cb) { var pk = this.definition.keyFrom; var modelTo = this.definition.modelTo; diff --git a/lib/relations.js b/lib/relations.js index 493fdb92..d3e735ff 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -162,6 +162,10 @@ RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; +RelationMixin.referencesMany = function hasMany(modelTo, params) { + RelationDefinition.referencesMany(this, modelTo, params); +}; + RelationMixin.embedsMany = function hasMany(modelTo, params) { RelationDefinition.embedsMany(this, modelTo, params); }; diff --git a/test/relations.test.js b/test/relations.test.js index 99d4332d..2a9e5526 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -5,6 +5,7 @@ var db, Book, Chapter, Author, Reader; var Category, Product; var Picture, PictureLink; var Person, Address; +var Link; describe('relations', function () { @@ -1370,6 +1371,20 @@ describe('relations', function () { }); }); + it('should have accessors: at, get, set', function(done) { + Person.findOne(function(err, p) { + p.addressList.at(0).id.should.equal(address1.id); + p.addressList.get(address1.id).id.should.equal(address1.id); + p.addressList.set(address1.id, { street: 'Changed 1' }); + p.addresses[0].street.should.equal('Changed 1'); + p.addressList.at(1).id.should.equal(address2.id); + p.addressList.get(address2.id).id.should.equal(address2.id); + p.addressList.set(address2.id, { street: 'Changed 2' }); + p.addresses[1].street.should.equal('Changed 2'); + done(); + }); + }); + it('should remove embedded items by id', function(done) { Person.findOne(function(err, p) { p.addresses.should.have.length(2); @@ -1510,6 +1525,20 @@ describe('relations', function () { }); }); + it('should have accessors: at, get, set', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addressList.at(0).id.should.equal('home'); + p.addressList.get('home').id.should.equal('home'); + p.addressList.set('home', { id: 'den' }).id.should.equal('den'); + p.addressList.at(1).id.should.equal('work'); + p.addressList.get('work').id.should.equal('work'); + p.addressList.set('work', { id: 'factory' }).id.should.equal('factory'); + done(); + }); + }); + }); }); From da303b72a51d45094a9d4e6bf83a85ee1878b3fc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 10:54:28 +0200 Subject: [PATCH 22/36] Implemented more complex scenaro: embedsMany + relations The test case will denormalize data into the embedded object, and re-use the actual related object id as its own id. --- lib/dao.js | 37 ++++++++++-- lib/relation-definition.js | 32 ++++++++--- test/relations.test.js | 112 ++++++++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 12 deletions(-) diff --git a/lib/dao.js b/lib/dao.js index a1508eb1..2a03cc4f 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -1046,6 +1046,17 @@ DataAccessObject.prototype.remove = }.bind(this)); }, null, cb); }; + +/** + * Set a single attribute. + * Equivalent to `setAttributes({name: value})` + * + * @param {String} name Name of property + * @param {Mixed} value Value of property + */ +DataAccessObject.prototype.setAttribute = function setAttribute(name, value) { + this[name] = value; +}; /** * Update a single attribute. @@ -1062,7 +1073,27 @@ DataAccessObject.prototype.updateAttribute = function updateAttribute(name, valu }; /** - * Update saet of attributes. + * Update set of attributes. + * + * @trigger `change` hook + * @param {Object} data Data to update + */ +DataAccessObject.prototype.setAttributes = function setAttributes(data) { + if (typeof data !== 'object') return; + + var Model = this.constructor; + var inst = this; + + // update instance's properties + for (var key in data) { + inst.setAttribute(key, data[key]); + } + + Model.emit('set', inst); +}; + +/** + * Update set of attributes. * Performs validation before updating. * * @trigger `validation`, `save` and `update` hooks @@ -1086,9 +1117,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb } // update instance's properties - for (var key in data) { - inst[key] = data[key]; - } + inst.setAttributes(data); inst.isValid(function (valid) { if (!valid) { diff --git a/lib/relation-definition.js b/lib/relation-definition.js index d14582b8..0d34b473 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1057,6 +1057,11 @@ BelongsTo.prototype.related = function (refresh, params) { modelTo = params.constructor; modelInstance[fk] = params[pk]; if (discriminator) modelInstance[discriminator] = params.constructor.modelName; + + var data = {}; + this.definition.applyProperties(params, data); + modelInstance.setAttributes(data); + self.resetCache(params); } else if (typeof params === 'function') { // acts as async getter @@ -1525,7 +1530,9 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { }; EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { - var name = this.definition.name; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; var self = receiver; var actualCond = {}; @@ -1543,14 +1550,29 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb throw new Error('Method can be only called with one or two arguments'); } - var embeddedList = self[name] || []; + var embeddedList = self[relationName] || []; + + this.definition.applyScope(modelInstance, actualCond); var params = mergeQuery(actualCond, scopeParams); + if (params.where) { embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList; } - process.nextTick(function() { cb(null, embeddedList); }); + var returnRelated = function(list) { + if (params.include) { + modelTo.include(list, params.include, cb); + } else { + process.nextTick(function() { cb(null, list); }); + } + }; + + if (actualRefresh) { + + } + + returnRelated(embeddedList); }; EmbedsMany.prototype.findById = function (fkId, cb) { @@ -1722,10 +1744,6 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) var embeddedList = modelInstance[relationName] || []; - if (typeof targetModelData === 'function' && !cb) { - cb = targetModelData; - targetModelData = {}; - } targetModelData = targetModelData || {}; if (typeof targetModelData[pk] !== 'number' && autoId) { diff --git a/test/relations.test.js b/test/relations.test.js index 2a9e5526..e0c9c44d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1221,6 +1221,9 @@ describe('relations', function () { }); describe('embedsMany', function () { + + var address1, address2; + before(function (done) { db = getSchema(); Person = db.define('Person', {name: String}); @@ -1250,7 +1253,6 @@ describe('relations', function () { p.addressList.build.should.be.a.function; }); - var address1, address2; it('should create embedded items on scope', function(done) { Person.create({ name: 'Fred' }, function(err, p) { p.addressList.create({ street: 'Street 1' }, function(err, addresses) { @@ -1540,5 +1542,113 @@ describe('relations', function () { }); }); + + describe('embedsMany - relations, scope and properties', function () { + + var product1, product2; + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + Link = db.define('Link'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Category.embedsMany(Link, { + as: 'items', // rename + scope: { include: 'product' } // always include + }); + Link.belongsTo(Product, { + foreignKey: 'id', // re-use the actual product id + properties: { id: 'id', name: 'name' }, // denormalize, transfer id + }); + db.automigrate(done); + }); + + it('should setup related items', function(done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 2' }, function(err, p) { + product2 = p; + done(); + }); + }); + }); + + it('should create item on scope', function(done) { + Category.create({ name: 'Category A' }, function(err, cat) { + var link = cat.items.build(); + link.product(product1); + var link = cat.items.build(); + link.product(product2); + cat.save(function(err, cat) { + var product = cat.items.at(0); + product.id.should.equal(product1.id); + product.name.should.equal(product1.name); + var product = cat.items.at(1); + product.id.should.equal(product2.id); + product.name.should.equal(product2.name); + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + // denormalized properties: + cat.items.at(0).id.should.equal(product1.id); + cat.items.at(0).name.should.equal(product1.name); + cat.items.at(1).id.should.equal(product2.id); + cat.items.at(1).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + should.not.exist(cat.items.at(1).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(1).product().should.be.instanceof(Product); + cat.items.at(1).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.destroy(product1.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.at(0).id.should.equal(product2.id); + cat.items.at(0).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(0).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + }); }); From 13cee9502cb6dc3c33ae8164140bbb85f06d1bd2 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 13:57:49 +0200 Subject: [PATCH 23/36] Tests for polymorphic embedsMany --- lib/include.js | 8 +-- lib/relation-definition.js | 31 ++++----- test/relations.test.js | 137 +++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/lib/include.js b/lib/include.js index 342a2bde..a72824b0 100644 --- a/lib/include.js +++ b/lib/include.js @@ -94,7 +94,7 @@ Inclusion.include = function (objects, include, cb) { subInclude = null; } var relation = relations[relationName]; - + if (!relation) { cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); @@ -106,7 +106,7 @@ Inclusion.include = function (objects, include, cb) { cb(); return; } - + // Calling the relation method for each object async.each(objs, function (obj, callback) { if(relation.type === 'belongsTo') { @@ -133,11 +133,11 @@ Inclusion.include = function (objects, include, cb) { obj.__cachedRelations[relationName] = result; if(obj === inst) { obj.__data[relationName] = result; - obj.strict = false; + obj.__strict = false; } else { obj[relationName] = result; } - + if (subInclude && result) { var subItems = relation.multiple ? result : [result]; // Recursively include the related models diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 0d34b473..0a3952b1 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1443,7 +1443,7 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - var definition = new RelationDefinition({ + var definition = modelFrom.relations[accessorName] = new RelationDefinition({ name: relationName, type: RelationTypes.embedsMany, modelFrom: modelFrom, @@ -1463,17 +1463,20 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { // unique id is required modelTo.validatesPresenceOf(idName); - modelFrom.validate(relationName, function(err) { - var embeddedList = this[relationName] || []; - var ids = embeddedList.map(function(m) { return m[idName]; }); - var uniqueIds = ids.filter(function(id, pos) { - return ids.indexOf(id) === pos; - }); - if (ids.length !== uniqueIds.length) { - this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); - err(false); - } - }, { code: 'uniqueness' }) + + if (!params.polymorphic) { + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var ids = embeddedList.map(function(m) { return m[idName]; }); + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + } // validate all embedded items if (definition.options.validate) { @@ -1568,10 +1571,6 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb } }; - if (actualRefresh) { - - } - returnRelated(embeddedList); }; diff --git a/test/relations.test.js b/test/relations.test.js index e0c9c44d..b417112b 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1580,7 +1580,7 @@ describe('relations', function () { }); }); - it('should create item on scope', function(done) { + it('should create items on scope', function(done) { Category.create({ name: 'Category A' }, function(err, cat) { var link = cat.items.build(); link.product(product1); @@ -1588,10 +1588,10 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); - product.id.should.equal(product1.id); + product.id.should.eql(product1.id); product.name.should.equal(product1.name); var product = cat.items.at(1); - product.id.should.equal(product2.id); + product.id.should.eql(product2.id); product.name.should.equal(product2.name); done(); }); @@ -1603,9 +1603,9 @@ describe('relations', function () { cat.links.should.have.length(2); // denormalized properties: - cat.items.at(0).id.should.equal(product1.id); + cat.items.at(0).id.should.eql(product1.id); cat.items.at(0).name.should.equal(product1.name); - cat.items.at(1).id.should.equal(product2.id); + cat.items.at(1).id.should.eql(product2.id); cat.items.at(1).name.should.equal(product2.name); // lazy-loaded relations @@ -1635,7 +1635,7 @@ describe('relations', function () { it('should find items on scope', function(done) { Category.findOne(function(err, cat) { cat.links.should.have.length(1); - cat.items.at(0).id.should.equal(product2.id); + cat.items.at(0).id.should.eql(product2.id); cat.items.at(0).name.should.equal(product2.name); // lazy-loaded relations @@ -1650,5 +1650,130 @@ describe('relations', function () { }); }); + + describe('embedsMany - polymorphic relations', function () { + + var person1, person2; + + before(function (done) { + db = getSchema(); + Book = db.define('Book', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + Link = db.define('Link'); // generic model + Link.validatesPresenceOf('linkedId'); + Link.validatesPresenceOf('linkedType'); + + db.automigrate(function () { + Book.destroyAll(function() { + Author.destroyAll(function() { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Book.embedsMany(Link, { as: 'people', + polymorphic: 'linked', + scope: { include: 'linked' } + }); + Link.belongsTo('linked', { + polymorphic: true, + properties: { name: 'name' } // denormalized + }); + db.automigrate(done); + }); + + it('should setup related items', function(done) { + Author.create({ name: 'Author 1' }, function(err, p) { + person1 = p; + Reader.create({ name: 'Reader 1' }, function(err, p) { + person2 = p; + done(); + }); + }); + }); + + it('should create items on scope', function(done) { + Book.create({ name: 'Book' }, function(err, book) { + var link = book.people.build({ notes: 'Something ...' }); + link.linked(person1); + var link = book.people.build(); + link.linked(person2); + book.save(function(err, book) { + should.not.exist(err); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.name.should.equal('Author 1'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + link.name.should.equal('Reader 1'); + + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Book.findOne(function(err, book) { + book.links.should.have.length(2); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.notes.should.equal('Something ...'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + + // lazy-loaded relations + should.not.exist(book.people.at(0).linked()); + should.not.exist(book.people.at(1).linked()); + + book.people(function(err, people) { + people[0].linked().should.be.instanceof(Author); + people[0].linked().name.should.equal('Author 1'); + people[1].linked().should.be.instanceof(Reader); + people[1].linked().name.should.equal('Reader 1'); + done(); + }); + }); + }); + + it('should include nested related items on scope', function(done) { + Book.find({ include: 'people' }, function(err, books) { + var obj = books[0].toObject(); + + obj.should.have.property('links'); + obj.should.have.property('people'); + + obj.links.should.have.length(2); + obj.links[0].name.should.equal('Author 1'); + obj.links[1].name.should.equal('Reader 1'); + + obj.people.should.have.length(2); + obj.people[0].linked.name.should.equal('Author 1'); + obj.people[1].linked.name.should.equal('Reader 1'); + + done(); + }); + }); + + }); }); From 296bb0d73ed0ad5b18ebce89be40b50323e8bda2 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 14:05:18 +0200 Subject: [PATCH 24/36] Minor touchups --- test/relations.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/relations.test.js b/test/relations.test.js index b417112b..3339bb94 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1588,6 +1588,7 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); + product.should.not.have.property('productId'); product.id.should.eql(product1.id); product.name.should.equal(product1.name); var product = cat.items.at(1); @@ -1756,6 +1757,12 @@ describe('relations', function () { }); it('should include nested related items on scope', function(done) { + + // There's some date duplication going on, so it might + // make sense to override toObject on a case-by-case basis + // to sort this out (delete links, keep people). + // In loopback, an afterRemote filter could do this as well. + Book.find({ include: 'people' }, function(err, books) { var obj = books[0].toObject(); @@ -1767,6 +1774,10 @@ describe('relations', function () { obj.links[1].name.should.equal('Reader 1'); obj.people.should.have.length(2); + + obj.people[0].name.should.equal('Author 1'); + obj.people[0].notes.should.equal('Something ...'); + obj.people[0].linked.name.should.equal('Author 1'); obj.people[1].linked.name.should.equal('Reader 1'); From b18384459a0fb565f1911617a2075b4e2378a1dc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 15:01:47 +0200 Subject: [PATCH 25/36] Implemented findByIds --- lib/dao.js | 42 +++++++++++++++++++++++++++++++++++++ lib/relation-definition.js | 7 ++++++- test/basic-querying.test.js | 39 ++++++++++++++++++++++++++++++++++ test/relations.test.js | 23 +++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/lib/dao.js b/lib/dao.js index 2a03cc4f..7f51ab90 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -14,6 +14,7 @@ var Relation = require('./relations.js'); var Inclusion = require('./include.js'); var List = require('./list.js'); var geo = require('./geo'); +var mergeQuery = require('./scope.js').mergeQuery; var Memory = require('./connectors/memory').Memory; var utils = require('./utils'); var fieldsToArray = utils.fieldsToArray; @@ -324,6 +325,47 @@ DataAccessObject.findById = function find(id, cb) { }.bind(this)); }; +DataAccessObject.findByIds = function(ids, cond, cb) { + if (typeof cond === 'function') { + cb = cond; + cond = {}; + } + + var pk = this.dataSource.idName(this.modelName) || 'id'; + if (ids.length === 0) { + process.nextTick(function() { cb(null, []); }); + return; + } + + var filter = { where: {} }; + filter.where[pk] = { inq: ids }; + mergeQuery(filter, cond || {}); + this.find(filter, function(err, results) { + cb(err, err ? results : this.sortByIds(ids, results)); + }.bind(this)); +}; + +DataAccessObject.sortByIds = function(ids, results) { + var pk = this.dataSource.idName(this.modelName) || 'id'; + ids = ids.map(function(id) { + return (typeof id === 'object') ? id.toString() : id; + }); + + results.sort(function(x, y) { + var idA = (typeof x[pk] === 'object') ? x[pk].toString() : x[pk]; + var idB = (typeof y[pk] === 'object') ? y[pk].toString() : y[pk]; + var a = ids.indexOf(idA); + var b = ids.indexOf(idB); + if (a === -1 || b === -1) return 1; // last + if (a !== b) { + if (a > b) return 1; + if (a < b) return -1; + } + }); + + return results; +}; + function convertNullToNotFoundError(ctx, cb) { if (ctx.result !== null) return cb(); diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 0a3952b1..59e8425a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1425,7 +1425,7 @@ HasOne.prototype.related = function (refresh, params) { } }; -RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { +RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { var thisClassName = modelFrom.modelName; params = params || {}; if (typeof modelTo === 'string') { @@ -1764,3 +1764,8 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) return inst; }; + +RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, params) { + +}; + diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index 2a235def..dd26928a 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -50,6 +50,45 @@ describe('basic-querying', function () { }); }); + + describe('findById', function () { + + before(function(done) { + var people = [ + { id: 1, name: 'a', vip: true }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + { id: 4, name: 'd', vip: true }, + { id: 5, name: 'e' }, + { id: 6, name: 'f' } + ]; + User.destroyAll(function() { + User.create(people, done); + }); + }); + + it('should query by ids', function (done) { + User.findByIds([3, 2, 1], function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['c', 'b', 'a']); + done(); + }); + }); + + it('should query by ids and condition', function (done) { + User.findByIds([4, 3, 2, 1], + { where: { vip: true } }, function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['d', 'a']); + done(); + }); + }); + + }); describe('find', function () { diff --git a/test/relations.test.js b/test/relations.test.js index 3339bb94..c37b082b 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1681,7 +1681,7 @@ describe('relations', function () { scope: { include: 'linked' } }); Link.belongsTo('linked', { - polymorphic: true, + polymorphic: true, // needs unique auto-id properties: { name: 'name' } // denormalized }); db.automigrate(done); @@ -1786,5 +1786,26 @@ describe('relations', function () { }); }); + + describe('referencesMany', function () { + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + + db.automigrate(function () { + Category.destroyAll(function() { + Product.destroyAll(done); + }); + }); + }); + + it('can be declared', function (done) { + Category.referencesMany(Product); + db.automigrate(done); + }); + + }); }); From 60fd39d31113bea59b55aa767a00dbc3391dd21e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 17:43:30 +0200 Subject: [PATCH 26/36] Added option: reference to enable embedsMany add/remove --- lib/include.js | 2 +- lib/relation-definition.js | 98 +++++++++++++++++++++++++++++++++++++- test/relations.test.js | 64 +++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/lib/include.js b/lib/include.js index a72824b0..5fe6548c 100644 --- a/lib/include.js +++ b/lib/include.js @@ -133,7 +133,7 @@ Inclusion.include = function (objects, include, cb) { obj.__cachedRelations[relationName] = result; if(obj === inst) { obj.__data[relationName] = result; - obj.__strict = false; + obj.setStrict(false); } else { obj[relationName] = result; } diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 59e8425a..fc2bd48c 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -65,6 +65,7 @@ function RelationDefinition(definition) { } definition = definition || {}; this.name = definition.name; + this.accessor = definition.accessor || this.name; assert(this.name, 'Relation name is missing'); this.type = normalizeType(definition.type); assert(this.type, 'Invalid relation type: ' + definition.type); @@ -1444,6 +1445,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; var definition = modelFrom.relations[accessorName] = new RelationDefinition({ + accessor: accessorName, name: relationName, type: RelationTypes.embedsMany, modelFrom: modelFrom, @@ -1507,8 +1509,11 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) destroy: scopeMethod(definition, 'destroyById'), updateById: scopeMethod(definition, 'updateById'), exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), get: scopeMethod(definition, 'get'), set: scopeMethod(definition, 'set'), + unset: scopeMethod(definition, 'unset'), at: scopeMethod(definition, 'at') }; @@ -1520,6 +1525,12 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + relationName] = removeFunc; scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -1660,7 +1671,7 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { var embeddedList = modelInstance[relationName] || []; - var inst = this.findById(fkId); + var inst = (fkId instanceof modelTo) ? fkId : this.findById(fkId); if (inst instanceof modelTo) { var index = embeddedList.indexOf(inst); @@ -1681,6 +1692,7 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { EmbedsMany.prototype.get = EmbedsMany.prototype.findById; EmbedsMany.prototype.set = EmbedsMany.prototype.updateById; +EmbedsMany.prototype.unset = EmbedsMany.prototype.destroyById; EmbedsMany.prototype.at = function (index, cb) { var modelTo = this.definition.modelTo; @@ -1734,7 +1746,7 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { }); }; -EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { +EmbedsMany.prototype.build = function(targetModelData) { var pk = this.definition.keyFrom; var modelTo = this.definition.modelTo; var relationName = this.definition.name; @@ -1765,6 +1777,88 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) return inst; }; +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +EmbedsMany.prototype.add = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var referenceDef = options.reference && modelTo.relations[options.reference]; + + if (!referenceDef) { + throw new Error('Invalid reference: ' + options.reference || '(none)'); + } + + var fk2 = referenceDef.keyTo; + var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + referenceDef.applyScope(modelInstance, filter); + + referenceDef.modelTo.findOne(filter, function(err, ref) { + if (ref instanceof referenceDef.modelTo) { + var inst = self.build(); + inst[options.reference](ref); + modelInstance.save(function(err) { + cb(err, err ? null : inst); + }); + } else { + cb(null, null); + } + }); +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +EmbedsMany.prototype.remove = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var referenceDef = options.reference && modelTo.relations[options.reference]; + + if (!referenceDef) { + throw new Error('Invalid reference: ' + options.reference || '(none)'); + } + + var fk2 = referenceDef.keyTo; + var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + referenceDef.applyScope(modelInstance, filter); + + modelInstance[definition.accessor](filter, function(err, items) { + if (err) return cb(err); + + items.forEach(function(item) { + self.unset(item); + }); + + modelInstance.save(function(err) { + cb(err, err ? [] : items); + }); + }); +}; + RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, params) { }; diff --git a/test/relations.test.js b/test/relations.test.js index c37b082b..a3d04a7d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1545,13 +1545,13 @@ describe('relations', function () { describe('embedsMany - relations, scope and properties', function () { - var product1, product2; + var product1, product2, product3; before(function (done) { db = getSchema(); Category = db.define('Category', {name: String}); Product = db.define('Product', {name: String}); - Link = db.define('Link'); + Link = db.define('Link', {name: String}); db.automigrate(function () { Person.destroyAll(done); @@ -1561,13 +1561,16 @@ describe('relations', function () { it('can be declared', function (done) { Category.embedsMany(Link, { as: 'items', // rename - scope: { include: 'product' } // always include + scope: { include: 'product' }, // always include + options: { reference: 'product' } // optional, for add()/remove() }); Link.belongsTo(Product, { foreignKey: 'id', // re-use the actual product id properties: { id: 'id', name: 'name' }, // denormalize, transfer id }); - db.automigrate(done); + db.automigrate(function() { + Product.create({ name: 'Product 0' }, done); // offset ids for tests + }); }); it('should setup related items', function(done) { @@ -1575,7 +1578,10 @@ describe('relations', function () { product1 = p; Product.create({ name: 'Product 2' }, function(err, p) { product2 = p; - done(); + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); }); }); }); @@ -1588,6 +1594,7 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); + product.should.be.instanceof(Link); product.should.not.have.property('productId'); product.id.should.eql(product1.id); product.name.should.equal(product1.name); @@ -1604,6 +1611,7 @@ describe('relations', function () { cat.links.should.have.length(2); // denormalized properties: + cat.items.at(0).should.be.instanceof(Link); cat.items.at(0).id.should.eql(product1.id); cat.items.at(0).name.should.equal(product1.name); cat.items.at(1).id.should.eql(product2.id); @@ -1650,6 +1658,52 @@ describe('relations', function () { }); }); + it('should add related items to scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.add(product3, function(err, link) { + link.should.be.instanceof(Link); + link.id.should.equal(product3.id); + link.name.should.equal('Product 3'); + + cat.links.should.have.length(2); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + cat.items.at(0).should.be.instanceof(Link); + cat.items.at(0).id.should.eql(product2.id); + cat.items.at(0).name.should.equal(product2.name); + cat.items.at(1).id.should.eql(product3.id); + cat.items.at(1).name.should.equal(product3.name); + + done(); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.remove(product2.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + done(); + }); + }); + }); describe('embedsMany - polymorphic relations', function () { From 1782b439f173c8a0f0237b1c2e16b16a50a8084e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 21:46:12 +0200 Subject: [PATCH 27/36] Implemented referencesMany --- lib/relation-definition.js | 336 ++++++++++++++++++++++++++++++++++++- test/relations.test.js | 215 ++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 9 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index fc2bd48c..96c18c32 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -322,15 +322,15 @@ util.inherits(EmbedsMany, Relation); * ReferencesMany subclass * @param {RelationDefinition|Object} definition * @param {Object} modelInstance - * @returns {HasMany} + * @returns {ReferencesMany} * @constructor - * @class HasMany + * @class ReferencesMany */ function ReferencesMany(definition, modelInstance) { - if (!(this instanceof HasMany)) { - return new HasMany(definition, modelInstance); + if (!(this instanceof ReferencesMany)) { + return new ReferencesMany(definition, modelInstance); } - assert(definition.type === RelationTypes.hasMany); + assert(definition.type === RelationTypes.referencesMany); Relation.apply(this, arguments); } @@ -550,6 +550,7 @@ function scopeMethod(definition, methodName) { */ HasMany.prototype.findById = function (fkId, cb) { var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -579,7 +580,7 @@ HasMany.prototype.findById = function (fkId, cb) { if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { cb(null, inst); } else { - err = new Error('Key mismatch: ' + this.definition.modelFrom.modelName + '.' + pk + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + pk + ': ' + modelInstance[pk] + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); err.statusCode = 400; @@ -1781,7 +1782,12 @@ EmbedsMany.prototype.build = function(targetModelData) { * Add the target model instance to the 'embedsMany' relation * @param {Object|ID} acInst The actual instance or id value */ -EmbedsMany.prototype.add = function (acInst, cb) { +EmbedsMany.prototype.add = function (acInst, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + var self = this; var definition = this.definition; var modelTo = this.definition.modelTo; @@ -1807,7 +1813,7 @@ EmbedsMany.prototype.add = function (acInst, cb) { referenceDef.modelTo.findOne(filter, function(err, ref) { if (ref instanceof referenceDef.modelTo) { - var inst = self.build(); + var inst = self.build(data || {}); inst[options.reference](ref); modelInstance.save(function(err) { cb(err, err ? null : inst); @@ -1860,6 +1866,318 @@ EmbedsMany.prototype.remove = function (acInst, cb) { }; RelationDefinition.referencesMany = function referencesMany(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(modelTo.modelName + '_ids', true); + var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idType = modelTo.getPropertyType(idName); + + var definition = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.referencesMany, + modelFrom: modelFrom, + keyFrom: fk, + keyTo: idName, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { + type: [idType], default: function() { return []; } + }); + + modelFrom.validate(relationName, function(err) { + var ids = this[fk] || []; + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + var msg = 'Contains duplicate `' + modelTo.modelName + '` instance'; + this.errors.add(relationName, msg, 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), + at: scopeMethod(definition, 'at') + }; + + var findByIdFunc = scopeMethods.findById; + modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + + // Mix the property and scoped methods into the prototype class + var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition }; +ReferencesMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var fk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + var ids = self[fk] || []; + + this.definition.applyScope(modelInstance, actualCond); + + var params = mergeQuery(actualCond, scopeParams); + + modelTo.findByIds(ids, params, cb); +}; + +ReferencesMany.prototype.findById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var modelTo = this.definition.modelTo; + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof fkId === 'object') { + fkId = fkId.toString(); // mongodb + } + + var ids = [fkId]; + + var filter = {}; + + this.definition.applyScope(modelInstance, filter); + + modelTo.findByIds(ids, filter, function (err, instances) { + if (err) { + return cb(err); + } + + var inst = instances[0]; + if (!inst) { + err = new Error('No instance with id ' + fkId + ' found for ' + modelTo.modelName); + err.statusCode = 404; + return cb(err); + } + + var currentIds = ids.map(function(id) { return id.toString(); }); + var id = (inst[pk] || '').toString(); // mongodb + + // Check if the foreign key is amongst the ids + if (currentIds.indexOf(id) > -1) { + cb(null, inst); + } else { + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + fk + + ': ' + modelInstance[fk] + + ', ' + modelTo.modelName + '.' + pk + ': ' + inst[pk]); + err.statusCode = 400; + cb(err); + } + }); +}; + +ReferencesMany.prototype.exists = function (fkId, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + var currentIds = ids.map(function(id) { return id.toString(); }); + var fkId = (fkId || '').toString(); // mongodb + process.nextTick(function() { cb(null, currentIds.indexOf(fkId) > -1) }); +}; + +ReferencesMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + inst.updateAttributes(data, cb); + }); +}; + +ReferencesMany.prototype.destroyById = function (fkId, cb) { + var self = this; + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + self.remove(inst, function(err, ids) { + inst.destroy(cb); + }); + }); +}; + +ReferencesMany.prototype.at = function (index, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + this.findById(ids[index], cb); +}; + +ReferencesMany.prototype.create = function (targetModelData, cb) { + var definition = this.definition; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var ids = modelInstance[fk] || []; + + var inst = this.build(targetModelData); + + inst.save(function(err, inst) { + if (err) return cb(err, inst); + + var id = inst[pk]; + + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, + ids, function(err, modelInst) { + cb(err, inst); + }); + }); +}; + +ReferencesMany.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + targetModelData = targetModelData || {}; + + this.definition.applyProperties(this.modelInstance, targetModelData); + + return new modelTo(targetModelData); +}; + +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +ReferencesMany.prototype.add = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var insertId = function(id, done) { + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + var ids = modelInstance[fk] || []; + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, ids, function(err, inst) { + done(err, inst[fk] || []); + }); + }; + + if (acInst instanceof modelTo) { + insertId(acInst[pk], cb); + } else { + var filter = { where: {} }; + filter.where[pk] = acInst; + + definition.applyScope(modelInstance, filter); + + modelTo.findOne(filter, function (err, inst) { + if (err || !inst) return cb(err, modelInstance[fk]); + insertId(inst[pk], cb); + }); + } +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +ReferencesMany.prototype.remove = function (acInst, cb) { + var definition = this.definition; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var ids = modelInstance[fk] || []; + + var currentIds = ids.map(function(id) { return id.toString(); }); + + var id = (acInst instanceof definition.modelTo) ? acInst[pk] : acInst; + id = id.toString(); + + var index = currentIds.indexOf(id); + if (index > -1) { + ids.splice(index, 1); + modelInstance.updateAttribute(fk, ids, function(err, inst) { + cb(err, inst[fk] || []); + }); + } else { + process.nextTick(function() { cb(null, ids); }); + } +}; \ No newline at end of file diff --git a/test/relations.test.js b/test/relations.test.js index a3d04a7d..fb26a40c 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1843,6 +1843,8 @@ describe('relations', function () { describe('referencesMany', function () { + var product1, product2, product3; + before(function (done) { db = getSchema(); Category = db.define('Category', {name: String}); @@ -1859,6 +1861,219 @@ describe('relations', function () { Category.referencesMany(Product); db.automigrate(done); }); + + it('should setup test records', function (done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); + }); + }); + + it('should create record on scope', function (done) { + Category.create({ name: 'Category A' }, function(err, cat) { + cat.productIds.should.be.an.array; + cat.productIds.should.have.length(0); + cat.products.create({ name: 'Product 2' }, function(err, p) { + should.not.exist(err); + cat.productIds.should.have.length(1); + cat.productIds.should.eql([p.id]); + p.name.should.equal('Product 2'); + product2 = p; + done(); + }); + }); + }); + + it('should not create duplicate record on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds = [product2.id, product2.id]; + cat.save(function(err, p) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.products.should.eql(['uniqueness']); + var expected = 'The `Category` instance is not valid. '; + expected += 'Details: `products` Contains duplicate `Product` instance.'; + err.message.should.equal(expected); + done(); + }); + }); + }); + + it('should find items on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products(function(err, products) { + should.not.exist(err); + var p = products[0]; + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products.findById(product2.id, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should check if a record exists on scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + + it('should update a record on scope', function (done) { + Category.findOne(function(err, cat) { + var attrs = { name: 'Product 2 - edit' }; + cat.products.updateById(product2.id, attrs, function(err, p) { + should.not.exist(err); + p.name.should.equal(attrs.name); + done(); + }); + }); + }); + + it('should get a record by index - at', function (done) { + Category.findOne(function(err, cat) { + cat.products.at(0, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2 - edit'); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product1, function(err, ids) { + should.not.exist(err); + cat.productIds.should.eql([product2.id, product1.id]); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product3.id, function(err, ids) { + should.not.exist(err); + var expected = [product2.id, product1.id, product3.id]; + cat.productIds.should.eql(expected); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.products.findById(product3.id, function(err, p) { + should.not.exist(err); + p.id.should.eql(product3.id); + p.name.should.equal('Product 3'); + done(); + }); + }); + }); + + it('should find items on scope - filter', function (done) { + Category.findOne(function(err, cat) { + var filter = { where: { name: 'Product 1' } }; + cat.products(filter, function(err, products) { + should.not.exist(err); + products.should.have.length(1); + var p = products[0]; + p.id.should.eql(product1.id); + p.name.should.equal('Product 1'); + done(); + }); + }); + }); + + it('should remove items from scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.remove(product1.id, function(err, ids) { + should.not.exist(err); + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(2); + products[0].id.should.eql(product2.id); + products[1].id.should.eql(product3.id); + done(); + }); + }); + }); + + it('should include related items from scope', function(done) { + Category.find({ include: 'products' }, function(err, categories) { + categories.should.have.length(1); + var cat = categories[0].toObject(); + cat.name.should.equal('Category A'); + cat.products.should.have.length(2); + cat.products[0].id.should.eql(product2.id); + cat.products[1].id.should.eql(product3.id); + done(); + }); + }); + + it('should destroy items from scope - destroyById', function (done) { + Category.findOne(function(err, cat) { + cat.products.destroy(product2.id, function(err) { + should.not.exist(err); + var expected = [product3.id]; + cat.productIds.should.eql(expected); + Product.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(1); + products[0].id.should.eql(product3.id); + done(); + }); + }); + }); }); From 06f2b32c21c669cf93bb70194edba1c8945873e4 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 21:56:59 +0200 Subject: [PATCH 28/36] Renamed EmbedsMany 'reference' option to 'belongsTo' --- lib/relation-definition.js | 34 +++++++++++++++++----------------- test/relations.test.js | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 96c18c32..bbafb8d0 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1794,27 +1794,27 @@ EmbedsMany.prototype.add = function (acInst, data, cb) { var modelInstance = this.modelInstance; var options = definition.options; - var referenceDef = options.reference && modelTo.relations[options.reference]; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; - if (!referenceDef) { - throw new Error('Invalid reference: ' + options.reference || '(none)'); + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); } - var fk2 = referenceDef.keyTo; - var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; var query = {}; - query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; var filter = { where: query }; - referenceDef.applyScope(modelInstance, filter); + belongsTo.applyScope(modelInstance, filter); - referenceDef.modelTo.findOne(filter, function(err, ref) { - if (ref instanceof referenceDef.modelTo) { + belongsTo.modelTo.findOne(filter, function(err, ref) { + if (ref instanceof belongsTo.modelTo) { var inst = self.build(data || {}); - inst[options.reference](ref); + inst[options.belongsTo](ref); modelInstance.save(function(err) { cb(err, err ? null : inst); }); @@ -1835,22 +1835,22 @@ EmbedsMany.prototype.remove = function (acInst, cb) { var modelInstance = this.modelInstance; var options = definition.options; - var referenceDef = options.reference && modelTo.relations[options.reference]; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; - if (!referenceDef) { - throw new Error('Invalid reference: ' + options.reference || '(none)'); + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); } - var fk2 = referenceDef.keyTo; - var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; var query = {}; - query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; var filter = { where: query }; - referenceDef.applyScope(modelInstance, filter); + belongsTo.applyScope(modelInstance, filter); modelInstance[definition.accessor](filter, function(err, items) { if (err) return cb(err); diff --git a/test/relations.test.js b/test/relations.test.js index fb26a40c..9319a2f6 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1562,7 +1562,7 @@ describe('relations', function () { Category.embedsMany(Link, { as: 'items', // rename scope: { include: 'product' }, // always include - options: { reference: 'product' } // optional, for add()/remove() + options: { belongsTo: 'product' } // optional, for add()/remove() }); Link.belongsTo(Product, { foreignKey: 'id', // re-use the actual product id From e888b8cff9ebc3e21a0e630bd439036850b405e3 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 22:59:44 +0200 Subject: [PATCH 29/36] Allow custom scopeMethods option (obj/fn) for relation scopes --- lib/relation-definition.js | 34 ++++++++++++++++++++++++++++------ test/relations.test.js | 28 ++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index bbafb8d0..b763c06e 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -52,7 +52,25 @@ function normalizeType(type) { } } return null; -} +}; + +function extendScopeMethods(definition, scopeMethods, ext) { + var relationClass = RelationClasses[definition.type]; + if (definition.type === RelationTypes.hasMany && definition.modelThrough) { + relationClass = RelationClasses.hasManyThrough; + } + if (typeof ext === 'function') { + ext.call(definition, scopeMethods, relationClass); + } else if (typeof ext === 'object') { + for (var key in ext) { + scopeMethods[key] = function () { + var relation = new relationClass(definition, this); + return ext[key].apply(relation, arguments); + }; + } + } + return scopeMethods; +}; /** * Relation definition class. Use to define relationships between models. @@ -518,7 +536,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { } return filter; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); }; @@ -1536,12 +1554,14 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); + scopeMethods.related = scopeMethod(definition, 'related'); // bound to definition + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { return {}; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); - scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition + scopeDefinition.related = scopeMethods.related; }; EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { @@ -1934,12 +1954,14 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); + scopeMethods.related = scopeMethod(definition, 'related'); + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { return {}; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); - scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition + scopeDefinition.related = scopeMethods.related; // bound to definition }; ReferencesMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { diff --git a/test/relations.test.js b/test/relations.test.js index 9319a2f6..bcedc425 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1858,7 +1858,16 @@ describe('relations', function () { }); it('can be declared', function (done) { - Category.referencesMany(Product); + Category.referencesMany(Product, { scopeMethods: { + reverse: function(cb) { + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + var ids = modelInstance[fk] || []; + modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { + cb(err, inst[fk] || []); + }); + } + } }); db.automigrate(done); }); @@ -1887,7 +1896,7 @@ describe('relations', function () { }); }); - it('should not create duplicate record on scope', function (done) { + it('should not allow duplicate record on scope', function (done) { Category.findOne(function(err, cat) { cat.productIds = [product2.id, product2.id]; cat.save(function(err, p) { @@ -2035,14 +2044,25 @@ describe('relations', function () { }); }); + it('should allow custom scope methods - reverse', function(done) { + Category.findOne(function(err, cat) { + cat.products.reverse(function(err, ids) { + var expected = [product3.id, product2.id]; + ids.should.eql(expected); + cat.productIds.should.eql(expected); + done(); + }); + }) + }); + it('should include related items from scope', function(done) { Category.find({ include: 'products' }, function(err, categories) { categories.should.have.length(1); var cat = categories[0].toObject(); cat.name.should.equal('Category A'); cat.products.should.have.length(2); - cat.products[0].id.should.eql(product2.id); - cat.products[1].id.should.eql(product3.id); + cat.products[0].id.should.eql(product3.id); + cat.products[1].id.should.eql(product2.id); done(); }); }); From 7a9b64f1bf1f2d377d3e9f9da0929d7acb4ff71d Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 29 Jul 2014 22:19:52 -0700 Subject: [PATCH 30/36] Fix the test failure for mongodb --- lib/relation-definition.js | 2 +- test/relations.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b763c06e..69dc2117 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1901,7 +1901,7 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); var fk = params.foreignKey || i8n.camelize(modelTo.modelName + '_ids', true); var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; - var idType = modelTo.getPropertyType(idName); + var idType = modelTo.definition.properties[idName].type; var definition = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, diff --git a/test/relations.test.js b/test/relations.test.js index bcedc425..245b9bec 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1663,7 +1663,7 @@ describe('relations', function () { cat.links.should.have.length(1); cat.items.add(product3, function(err, link) { link.should.be.instanceof(Link); - link.id.should.equal(product3.id); + link.id.should.eql(product3.id); link.name.should.equal('Product 3'); cat.links.should.have.length(2); From 5cee6a4b794e6a14abd3776e1bf010b6b02d026b Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 13:22:20 +0200 Subject: [PATCH 31/36] Fixed embedsMany after LB integration --- lib/relation-definition.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 69dc2117..2167ecb5 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -128,6 +128,7 @@ RelationDefinition.prototype.toJSON = function () { * @param {Object} filter (where, order, limit, fields, ...) */ RelationDefinition.prototype.applyScope = function(modelInstance, filter) { + filter = filter || {}; filter.where = filter.where || {}; if ((this.type !== 'belongsTo' || this.type === 'hasOne') && typeof this.discriminator === 'string') { // polymorphic @@ -1537,19 +1538,19 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) }; var findByIdFunc = scopeMethods.findById; - modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + modelFrom.prototype['__findById__' + accessorName] = findByIdFunc; var destroyByIdFunc = scopeMethods.destroy; - modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + modelFrom.prototype['__destroyById__' + accessorName] = destroyByIdFunc; var updateByIdFunc = scopeMethods.updateById; - modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + modelFrom.prototype['__updateById__' + accessorName] = updateByIdFunc; var addFunc = scopeMethods.add; - modelFrom.prototype['__link__' + relationName] = addFunc; + modelFrom.prototype['__link__' + accessorName] = addFunc; var removeFunc = scopeMethods.remove; - modelFrom.prototype['__unlink__' + relationName] = removeFunc; + modelFrom.prototype['__unlink__' + accessorName] = removeFunc; scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -1614,17 +1615,15 @@ EmbedsMany.prototype.findById = function (fkId, cb) { var embeddedList = modelInstance[relationName] || []; - fkId = fkId.toString(); // in case of explicit id - var find = function(id) { for (var i = 0; i < embeddedList.length; i++) { var item = embeddedList[i]; - if (item[pk].toString() === fkId) return item; + if (item[pk].toString() === id) return item; } return null; }; - var item = find(fkId); + var item = find(fkId.toString()); // in case of explicit id item = (item instanceof modelTo) ? item : null; if (typeof cb === 'function') { @@ -1700,13 +1699,11 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { if (typeof cb === 'function') { modelInstance.updateAttribute(relationName, embeddedList, function(err) { - cb(err, inst); + cb(err); }); } } else if (typeof cb === 'function') { - process.nextTick(function() { - cb(null, null); // not found - }); + process.nextTick(cb); // not found } return inst; // sync }; @@ -1759,7 +1756,7 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { return process.nextTick(function() { cb(err, embeddedList); }); - } + } modelInstance.updateAttribute(relationName, embeddedList, function(err, modelInst) { @@ -1782,7 +1779,11 @@ EmbedsMany.prototype.build = function(targetModelData) { var ids = embeddedList.map(function(m) { return (typeof m[pk] === 'number' ? m[pk] : 0); }); - targetModelData[pk] = (Math.max(ids) || 0) + 1; + if (ids.length > 0) { + targetModelData[pk] = Math.max.apply(null, ids) + 1; + } else { + targetModelData[pk] = 1; + } } this.definition.applyProperties(this.modelInstance, targetModelData); @@ -1880,7 +1881,7 @@ EmbedsMany.prototype.remove = function (acInst, cb) { }); modelInstance.save(function(err) { - cb(err, err ? [] : items); + cb(err); }); }); }; From e38c92af8787acd8cc996b4c6e77101c74f61e8e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 15:01:55 +0200 Subject: [PATCH 32/36] ReferencesMany fixes after LB integration tests --- lib/relation-definition.js | 37 +++++++++++++++++++++--------------- test/relations.test.js | 39 +++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 2167ecb5..12669318 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1503,6 +1503,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) // validate all embedded items if (definition.options.validate) { modelFrom.validate(relationName, function(err) { + var self = this; var embeddedList = this[relationName] || []; var hasErrors = false; embeddedList.forEach(function(item) { @@ -1513,13 +1514,13 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var first = Object.keys(item.errors)[0]; var msg = 'contains invalid item: `' + id + '`'; msg += ' (' + first + ' ' + item.errors[first] + ')'; - this.errors.add(relationName, msg, 'invalid'); + self.errors.add(relationName, msg, 'invalid'); } } else { hasErrors = true; - this.errors.add(relationName, 'Contains invalid item', 'invalid'); + self.errors.add(relationName, 'Contains invalid item', 'invalid'); } - }.bind(this)); + }); if (hasErrors) err(false); }); } @@ -1751,16 +1752,14 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { var err = inst.isValid() ? null : new ValidationError(inst); if (err) { - var index = embeddedList.indexOf(inst); - if (index > -1) embeddedList.splice(index, 1); - return process.nextTick(function() { - cb(err, embeddedList); + return process.nextTick(function() { + cb(err); }); } - modelInstance.updateAttribute(relationName, + modelInstance.updateAttribute(relationName, embeddedList, function(err, modelInst) { - cb(err, modelInst[relationName]); + cb(err, err ? null : inst); }); }; @@ -1952,6 +1951,12 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + relationName] = removeFunc; + scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -2143,7 +2148,9 @@ ReferencesMany.prototype.add = function (acInst, cb) { var pk = this.definition.keyTo; var fk = this.definition.keyFrom; - var insertId = function(id, done) { + var insert = function(inst, done) { + var id = inst[pk]; + if (typeof id === 'object') { id = id.toString(); // mongodb } @@ -2156,13 +2163,13 @@ ReferencesMany.prototype.add = function (acInst, cb) { ids.push(id); } - modelInstance.updateAttribute(fk, ids, function(err, inst) { - done(err, inst[fk] || []); + modelInstance.updateAttribute(fk, ids, function(err) { + done(err, err ? null : inst); }); }; if (acInst instanceof modelTo) { - insertId(acInst[pk], cb); + insert(acInst, cb); } else { var filter = { where: {} }; filter.where[pk] = acInst; @@ -2170,8 +2177,8 @@ ReferencesMany.prototype.add = function (acInst, cb) { definition.applyScope(modelInstance, filter); modelTo.findOne(filter, function (err, inst) { - if (err || !inst) return cb(err, modelInstance[fk]); - insertId(inst[pk], cb); + if (err || !inst) return cb(err, null); + insert(inst, cb); }); } }; diff --git a/test/relations.test.js b/test/relations.test.js index 245b9bec..b6030dac 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1255,12 +1255,11 @@ describe('relations', function () { it('should create embedded items on scope', function(done) { Person.create({ name: 'Fred' }, function(err, p) { - p.addressList.create({ street: 'Street 1' }, function(err, addresses) { + p.addressList.create({ street: 'Street 1' }, function(err, address) { should.not.exist(err); - addresses.should.have.length(1); - address1 = addresses[0]; + address1 = address; should.exist(address1.id); - addresses[0].street.should.equal('Street 1'); + address1.street.should.equal('Street 1'); done(); }); }); @@ -1268,13 +1267,9 @@ describe('relations', function () { it('should create embedded items on scope', function(done) { Person.findOne(function(err, p) { - p.addressList.create({ street: 'Street 2' }, function(err, addresses) { + p.addressList.create({ street: 'Street 2' }, function(err, address) { should.not.exist(err); - addresses.should.have.length(2); - address1 = addresses[0]; - address2 = addresses[1]; - should.exist(address1.id); - address1.street.should.equal('Street 1'); + address2 = address; should.exist(address2.id); address2.street.should.equal('Street 2'); done(); @@ -1310,11 +1305,11 @@ describe('relations', function () { it('should validate embedded items', function(done) { Person.findOne(function(err, p) { - p.addressList.create({}, function(err, addresses) { + p.addressList.create({}, function(err, address) { should.exist(err); + should.not.exist(address); err.name.should.equal('ValidationError'); err.details.codes.street.should.eql(['presence']); - addresses.should.have.length(2); done(); }); }); @@ -1428,12 +1423,10 @@ describe('relations', function () { Person.create({ name: 'Fred' }, function(err, p) { p.addressList.create({ id: 'home', street: 'Street 1' }, function(err, addresses) { should.not.exist(err); - p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, addresses) { - addresses.should.have.length(2); - addresses[0].id.should.equal('home'); - addresses[0].street.should.equal('Street 1'); - addresses[1].id.should.equal('work'); - addresses[1].street.should.equal('Work Street 2'); + p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, address) { + should.not.exist(err); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); done(); }); }); @@ -1972,10 +1965,11 @@ describe('relations', function () { it('should add a record to scope - object', function (done) { Category.findOne(function(err, cat) { - cat.products.add(product1, function(err, ids) { + cat.products.add(product1, function(err, prod) { should.not.exist(err); cat.productIds.should.eql([product2.id, product1.id]); - ids.should.eql(cat.productIds); + prod.id.should.eql(product1.id); + prod.should.have.property('name'); done(); }); }); @@ -1983,11 +1977,12 @@ describe('relations', function () { it('should add a record to scope - object', function (done) { Category.findOne(function(err, cat) { - cat.products.add(product3.id, function(err, ids) { + cat.products.add(product3.id, function(err, prod) { should.not.exist(err); var expected = [product2.id, product1.id, product3.id]; cat.productIds.should.eql(expected); - ids.should.eql(cat.productIds); + prod.id.should.eql(product3.id); + prod.should.have.property('name'); done(); }); }); From af0ca5b1086e2141c2b523e91b46e6061c9125e1 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 16:46:05 +0200 Subject: [PATCH 33/36] Handle remoting of custom scope methods --- lib/relation-definition.js | 43 +++++++++++++++++++++++++++++++++----- test/relations.test.js | 23 +++++++++++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 12669318..4185b22f 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -55,21 +55,24 @@ function normalizeType(type) { }; function extendScopeMethods(definition, scopeMethods, ext) { + var customMethods = []; var relationClass = RelationClasses[definition.type]; if (definition.type === RelationTypes.hasMany && definition.modelThrough) { relationClass = RelationClasses.hasManyThrough; } if (typeof ext === 'function') { - ext.call(definition, scopeMethods, relationClass); + customMethods = ext.call(definition, scopeMethods, relationClass); } else if (typeof ext === 'object') { for (var key in ext) { scopeMethods[key] = function () { var relation = new relationClass(definition, this); return ext[key].apply(relation, arguments); }; + if (ext[key].shared === true) scopeMethods[key].shared = true; + customMethods.push(key); } } - return scopeMethods; + return [].concat(customMethods || []); }; /** @@ -519,6 +522,16 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { scopeMethods.build = scopeMethod(definition, 'build'); } + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + // Mix the property and scoped methods into the prototype class defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { var filter = {}; @@ -537,7 +550,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { } return filter; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); }; @@ -1558,10 +1571,20 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) scopeMethods.related = scopeMethod(definition, 'related'); // bound to definition + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + accessorName] = method; + } + }; + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { return {}; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); scopeDefinition.related = scopeMethods.related; }; @@ -1962,10 +1985,20 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, scopeMethods.related = scopeMethod(definition, 'related'); + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { return {}; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); scopeDefinition.related = scopeMethods.related; // bound to definition }; diff --git a/test/relations.test.js b/test/relations.test.js index b6030dac..77a198f9 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1851,16 +1851,23 @@ describe('relations', function () { }); it('can be declared', function (done) { + var reverse = function(cb) { + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + var ids = modelInstance[fk] || []; + modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { + cb(err, inst[fk] || []); + }); + }; + + reverse.shared = true; // remoting + Category.referencesMany(Product, { scopeMethods: { - reverse: function(cb) { - var modelInstance = this.modelInstance; - var fk = this.definition.keyFrom; - var ids = modelInstance[fk] || []; - modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { - cb(err, inst[fk] || []); - }); - } + reverse: reverse } }); + + Category.prototype['__reverse__products'].should.be.a.function; + db.automigrate(done); }); From 090c738bb5b1a1a6b77fe02b32c7f3724d2c47db Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 17:30:21 +0200 Subject: [PATCH 34/36] Correctly handle remoting of scope methods --- lib/relation-definition.js | 13 ++++++++++--- test/relations.test.js | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 4185b22f..44532273 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -64,11 +64,18 @@ function extendScopeMethods(definition, scopeMethods, ext) { customMethods = ext.call(definition, scopeMethods, relationClass); } else if (typeof ext === 'object') { for (var key in ext) { - scopeMethods[key] = function () { + var relationMethod = ext[key]; + var method = scopeMethods[key] = function () { var relation = new relationClass(definition, this); - return ext[key].apply(relation, arguments); + return relationMethod.apply(relation, arguments); }; - if (ext[key].shared === true) scopeMethods[key].shared = true; + if (relationMethod.shared) { + method.shared = true; + method.accepts = relationMethod.accepts; + method.returns = relationMethod.returns; + method.http = relationMethod.http; + method.description = relationMethod.description; + } customMethods.push(key); } } diff --git a/test/relations.test.js b/test/relations.test.js index 77a198f9..bb054a7a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1861,12 +1861,15 @@ describe('relations', function () { }; reverse.shared = true; // remoting + reverse.http = { verb: 'put', path: '/products/reverse' }; Category.referencesMany(Product, { scopeMethods: { reverse: reverse } }); Category.prototype['__reverse__products'].should.be.a.function; + should.exist(Category.prototype['__reverse__products'].shared); + Category.prototype['__reverse__products'].http.should.eql(reverse.http); db.automigrate(done); }); From 79af219618f33478a2b065f2a88af1e6e7df9bac Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 30 Jul 2014 09:19:40 -0700 Subject: [PATCH 35/36] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 30921e06..bcdbda64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-datasource-juggler", - "version": "2.1.1", + "version": "2.2.0", "description": "LoopBack DataSoure Juggler", "keywords": [ "StrongLoop", From d896a0982eebbab5e677db4f6e3a2a55a1d66c35 Mon Sep 17 00:00:00 2001 From: Jaka Hudoklin Date: Thu, 31 Jul 2014 01:06:52 +0200 Subject: [PATCH 36/36] fix datasources to support new model parameters Signed-off-by: Jaka Hudoklin --- lib/datasource.js | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/lib/datasource.js b/lib/datasource.js index 34f40e0b..1f3846c5 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -12,6 +12,7 @@ var EventEmitter = require('events').EventEmitter; var util = require('util'); var assert = require('assert'); var async = require('async'); +var traverse = require('traverse'); if (process.env.DEBUG === 'loopback') { // For back-compatibility @@ -396,12 +397,9 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { // Check if the through model doesn't exist or resolved if (!throughModel || isModelDataSourceAttached(throughModel)) { // The target model is resolved - var params = { - foreignKey: relation.foreignKey, - as: name, - model: model, - options: relation.options - }; + var params = traverse(relation).clone(); + params.as = name; + params.model = model; if (throughModel) { params.through = throughModel; } @@ -415,13 +413,10 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { throughModel.once('dataSourceAttached', function (model) { if (isModelDataSourceAttached(targetModel)) { // The target model is resolved - var params = { - foreignKey: relation.foreignKey, - as: name, - model: targetModel, - through: model, - options: relation.options - }; + var params = traverse(relations).clone(); + params.as = name; + params.model = targetModel; + params.through = model; modelClass[relation.type].call(modelClass, name, params); } }); @@ -438,17 +433,14 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { if (r.through) { throughModel = isModelClass(r.through) ? r.through : this.getModel(r.through, true); } - if (!isModelDataSourceAttached(targetModel) || (throughModel && !isModelDataSourceAttached(throughModel))) { + if ((!r.polymorphic && !isModelDataSourceAttached(targetModel)) || (throughModel && !isModelDataSourceAttached(throughModel))) { // Create a listener to defer the relation set up createListener(rn, r, targetModel, throughModel); } else { // The target model is resolved - var params = { - foreignKey: r.foreignKey, - as: rn, - model: targetModel, - options: r.options - }; + var params = traverse(r).clone(); + params.as = rn; + params.model = targetModel; if (throughModel) { params.through = throughModel; }