Many-to-many relation

- hasMany {through: Class}
- hasAndBelongsToMany
- some specs in relations.test.js
This commit is contained in:
Anatoliy Chakkaev 2013-04-13 01:35:06 +04:00 committed by Raymond Feng
parent 9facf369b1
commit c9e97744dd
5 changed files with 188 additions and 19 deletions

View File

@ -388,11 +388,15 @@ AbstractClass.all = function all(params, cb) {
} }
var constr = this; var constr = this;
this.schema.adapter.all(this.modelName, params, function (err, data) { this.schema.adapter.all(this.modelName, params, function (err, data) {
if (data && data.map) { if (data && data.forEach) {
data.forEach(function (d, i) { data.forEach(function (d, i) {
var obj = new constr; var obj = new constr;
obj._initProperties(d, false); obj._initProperties(d, false);
if (params && params.include && params.collect) {
data[i] = obj.__cachedRelations[params.collect];
} else {
data[i] = obj; data[i] = obj;
}
}); });
if (data && data.countBeforeLimit) { if (data && data.countBeforeLimit) {
data.countBeforeLimit = data.countBeforeLimit; data.countBeforeLimit = data.countBeforeLimit;

View File

@ -7,16 +7,16 @@ var defineScope = require('./scope.js').defineScope;
/** /**
* Relations mixins for ./model.js * Relations mixins for ./model.js
*/ */
var AbstractClass = require('./model.js'); var Model = require('./model.js');
/** /**
* Declare hasMany relation * Declare hasMany relation
* *
* @param {Class} anotherClass - class to has many * @param {Model} anotherClass - class to has many
* @param {Object} params - configuration {as:, foreignKey:} * @param {Object} params - configuration {as:, foreignKey:}
* @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});` * @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});`
*/ */
AbstractClass.hasMany = function hasMany(anotherClass, params) { Model.hasMany = function hasMany(anotherClass, params) {
var thisClass = this, thisClassName = this.modelName; var thisClass = this, thisClassName = this.modelName;
params = params || {}; params = params || {};
if (typeof anotherClass === 'string') { if (typeof anotherClass === 'string') {
@ -46,14 +46,65 @@ AbstractClass.hasMany = function hasMany(anotherClass, params) {
// each instance of this class should have method named // each instance of this class should have method named
// pluralize(anotherClass.modelName) // pluralize(anotherClass.modelName)
// which is actually just anotherClass.all({where: {thisModelNameId: this.id}}, cb); // which is actually just anotherClass.all({where: {thisModelNameId: this.id}}, cb);
defineScope(this.prototype, anotherClass, methodName, function () { var scopeMethods = {
var x = {};
x[fk] = this.id;
return {where: x};
}, {
find: find, find: find,
destroy: destroy destroy: destroy
};
if (params.through) {
var fk2 = i8n.camelize(anotherClass.modelName + '_id', true);
scopeMethods.create = function create(data, done) {
if (typeof data !== 'object') {
done = data;
data = {};
}
var id = this.id;
anotherClass.create(data, function(err, ac) {
if (err) return done(err, ac);
var d = {};
d[fk] = id;
d[fk2] = ac.id;
params.through.create(d, function(e) {
if (e) {
ac.destroy(function() {
done(e);
}); });
} else {
done(err, ac);
}
});
});
};
scopeMethods.add = function(acInst, done) {
var data = {};
data[fk] = this.id;
data[fk2] = acInst.id || acInst;
params.through.findOrCreate({where: data}, done);
};
scopeMethods.remove = function(acInst, done) {
var self = this;
var q = {};
q[fk2] = acInst.id || acInst;
params.through.findOne({where: q}, function(err, d) {
if (err) {
return done(err);
}
if (!d) {
return done();
}
d.destroy(done);
});
};
}
defineScope(this.prototype, params.through || anotherClass, methodName, function () {
var filter = {};
filter.where = {};
filter.where[fk] = this.id;
if (params.through) {
filter.collect = i8n.camelize(anotherClass.modelName, true);
filter.include = filter.collect;
}
return filter;
}, scopeMethods);
// obviously, anotherClass should have attribute called `fk` // obviously, anotherClass should have attribute called `fk`
anotherClass.schema.defineForeignKey(anotherClass.modelName, fk); anotherClass.schema.defineForeignKey(anotherClass.modelName, fk);
@ -106,7 +157,7 @@ AbstractClass.hasMany = function hasMany(anotherClass, params) {
* *
* This optional parameter default value is false, so the related object will be loaded from cache if available. * This optional parameter default value is false, so the related object will be loaded from cache if available.
*/ */
AbstractClass.belongsTo = function (anotherClass, params) { Model.belongsTo = function (anotherClass, params) {
params = params || {}; params = params || {};
if ('string' === typeof anotherClass) { if ('string' === typeof anotherClass) {
params.as = anotherClass; params.as = anotherClass;
@ -124,7 +175,7 @@ AbstractClass.belongsTo = function (anotherClass, params) {
var methodName = params.as || i8n.camelize(anotherClass.modelName, true); var methodName = params.as || i8n.camelize(anotherClass.modelName, true);
var fk = params.foreignKey || methodName + 'Id'; var fk = params.foreignKey || methodName + 'Id';
this.relations[params['as']] = { this.relations[methodName] = {
type: 'belongsTo', type: 'belongsTo',
keyFrom: fk, keyFrom: fk,
keyTo: 'id', keyTo: 'id',
@ -163,7 +214,7 @@ AbstractClass.belongsTo = function (anotherClass, params) {
if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) {
cachedValue = this.__cachedRelations[methodName]; cachedValue = this.__cachedRelations[methodName];
} }
if (p instanceof AbstractClass) { // acts as setter if (p instanceof Model) { // acts as setter
this[fk] = p.id; this[fk] = p.id;
this.__cachedRelations[methodName] = p; this.__cachedRelations[methodName] = p;
} else if (typeof p === 'function') { // acts as async getter } else if (typeof p === 'function') { // acts as async getter
@ -189,3 +240,46 @@ AbstractClass.belongsTo = function (anotherClass, params) {
}; };
/**
* Many-to-many relation
*
* Post.hasAndBelongsToMany('tags'); creates connection model 'PostTag'
*/
Model.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params) {
params = params || {};
var models = this.schema.models;
if ('string' === typeof anotherClass) {
params.as = anotherClass;
if (params.model) {
anotherClass = params.model;
} else {
anotherClass = lookupModel(i8n.singularize(anotherClass)) ||
anotherClass;
}
if (typeof anotherClass === 'string') {
throw new Error('Could not find "' + anotherClass + '" relation for ' + this.modelName);
}
}
if (!params.through) {
var name1 = this.modelName + anotherClass.modelName;
var name2 = anotherClass.modelName + this.modelName;
params.through = lookupModel(name1) || lookupModel(name2) ||
this.schema.define(name1);
}
params.through.belongsTo(this);
params.through.belongsTo(anotherClass);
this.hasMany(anotherClass, {as: params.as, through: params.through});
function lookupModel(modelName) {
var lookupClassName = modelName.toLowerCase();
for (var name in models) {
if (name.toLowerCase() === lookupClassName) {
return models[name];
}
}
}
};

View File

@ -57,7 +57,8 @@ function defineScope(cls, targetClass, name, params, methods) {
if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) {
var self = this; var self = this;
return targetClass.all(mergeParams(actualCond, caller._scope), function(err, data) { var params = mergeParams(actualCond, caller._scope);
return targetClass.all(params, function(err, data) {
if (!err && saveOnCache) { if (!err && saveOnCache) {
if (!self.__cachedRelations) { if (!self.__cachedRelations) {
self.__cachedRelations = {}; self.__cachedRelations = {};
@ -134,6 +135,12 @@ function defineScope(cls, targetClass, name, params, methods) {
if (update.where) { if (update.where) {
base.where = merge(base.where, update.where); base.where = merge(base.where, update.where);
} }
if (update.include) {
base.include = update.include;
}
if (update.collect) {
base.collect = update.collect;
}
// overwrite order // overwrite order
if (update.order) { if (update.order) {

View File

@ -159,13 +159,78 @@ describe('relations', function() {
}); });
}); });
describe.skip('hasAndBelongsToMany', function() { describe('hasAndBelongsToMany', function() {
var Article, Tag; var Article, Tag, ArticleTag;
it('can be declared', function(done) { it('can be declared', function(done) {
Article = db.define('Article', {title: String}); Article = db.define('Article', {title: String});
Tag = db.define('Tag', {name: String}); Tag = db.define('Tag', {name: String});
Article.hasAndBelongsToMany('tags'); Article.hasAndBelongsToMany('tags');
ArticleTag = db.models.ArticleTag;
db.automigrate(function() {
Article.destroyAll(function() {
Tag.destroyAll(function() {
ArticleTag.destroyAll(done)
});
});
}); });
}); });
it('should allow to create instances on scope', function(done) {
Article.create(function(e, article) {
article.tags.create({name: 'popular'}, function(e, t) {
t.should.be.an.instanceOf(Tag);
ArticleTag.findOne(function(e, at) {
should.exist(at);
at.tagId.should.equal(t.id);
at.articleId.should.equal(article.id);
done();
});
});
});
});
it('should allow to fetch scoped instances', function(done) {
Article.findOne(function(e, article) {
article.tags(function(e, tags) {
should.not.exist(e);
should.exist(tags);
done();
});
});
});
it('should allow to add connection with instance', function(done) {
Article.findOne(function(e, article) {
Tag.create({name: 'awesome'}, function(e, tag) {
article.tags.add(tag, function(e, at) {
should.not.exist(e);
should.exist(at);
at.should.be.an.instanceOf(ArticleTag);
at.tagId.should.equal(tag.id);
at.articleId.should.equal(article.id);
done();
});
});
});
});
it('should allow to remove connection with instance', function(done) {
Article.findOne(function(e, article) {
article.tags(function(e, tags) {
var len = tags.length;
article.tags.remove(tags[0], function(e, at) {
should.not.exist(e);
article.tags(true, function(e, tags) {
tags.should.have.lengthOf(len - 1);
done();
});
});
});
});
});
it('should allow to destroy instance and connection');
});
}); });

View File

@ -4,6 +4,7 @@ var should = require('./init.js');
var db, Railway, Station; var db, Railway, Station;
describe('sc0pe', function() { describe('sc0pe', function() {
before(function() { before(function() {
db = getSchema(); db = getSchema();
Railway = db.define('Railway', { Railway = db.define('Railway', {
@ -16,7 +17,6 @@ describe('sc0pe', function() {
isActive: {type: Boolean, index: true}, isActive: {type: Boolean, index: true},
isUndeground: {type: Boolean, index: true} isUndeground: {type: Boolean, index: true}
}); });
}); });
beforeEach(function(done) { beforeEach(function(done) {
@ -27,7 +27,6 @@ describe('sc0pe', function() {
it('should define scope with query', function(done) { it('should define scope with query', function(done) {
Station.scope('active', {where: {isActive: true}}); Station.scope('active', {where: {isActive: true}});
Station.active.create(function(err, station) { Station.active.create(function(err, station) {
should.not.exist(err); should.not.exist(err);
should.exist(station); should.exist(station);