From 9b97e1ae778065a15ad0b2a508a60e9ce3f182dd Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sat, 26 Jul 2014 12:47:55 +0200 Subject: [PATCH] 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;