Merge pull request #34 from strongloop/type-or-relation-resolution

Enable deferred type/relation resolutions
This commit is contained in:
Raymond Feng 2013-11-05 12:03:03 -08:00
commit 5d92bcb098
4 changed files with 286 additions and 32 deletions

View File

@ -12,6 +12,7 @@ var EventEmitter = require('events').EventEmitter;
var util = require('util'); var util = require('util');
var path = require('path'); var path = require('path');
var fs = require('fs'); var fs = require('fs');
var assert = require('assert');
var async = require('async'); var async = require('async');
var existsSync = fs.existsSync || path.existsSync; var existsSync = fs.existsSync || path.existsSync;
@ -302,6 +303,13 @@ DataSource.prototype.setup = function(name, settings) {
}; };
}; };
function isModelClass(cls) {
if(!cls) {
return false;
}
return cls.prototype instanceof ModelBaseClass;
}
/** /**
* Define a model class * Define a model class
* *
@ -364,6 +372,10 @@ DataSource.prototype.createModel = DataSource.prototype.define = function define
var NewClass = ModelBuilder.prototype.define.call(this, className, properties, settings); var NewClass = ModelBuilder.prototype.define.call(this, className, properties, settings);
if(settings.unresolved) {
return NewClass;
}
// add data access objects // add data access objects
this.mixin(NewClass); this.mixin(NewClass);
@ -376,6 +388,80 @@ DataSource.prototype.createModel = DataSource.prototype.define = function define
}); });
} }
var relations = settings.relationships || settings.relations;
// Create a function for the closure in the loop
var createListener = function (name, relation, targetModel, throughModel) {
if (targetModel && targetModel.settings.unresolved) {
targetModel.once('defined', function (model) {
// Check if the through model doesn't exist or resolved
if (!throughModel || !throughModel.settings.unresolved) {
// The target model is resolved
var params = {
foreignKey: relation.foreignKey,
as: name,
model: model
};
if (throughModel) {
params.through = throughModel;
}
NewClass[relation.type].call(NewClass, name, params);
}
});
}
if (throughModel && throughModel.settings.unresolved) {
// Set up a listener to the through model
throughModel.once('defined', function (model) {
if (!targetModel.settings.unresolved) {
// The target model is resolved
var params = {
foreignKey: relation.foreignKey,
as: name,
model: targetModel,
through: model
};
NewClass[relation.type].call(NewClass, name, params);
}
});
}
};
// Set up the relations
if (relations) {
for (var rn in relations) {
var r = relations[rn];
assert(['belongsTo', 'hasMany', 'hasAndBelongsToMany'].indexOf(r.type) !== -1, "Invalid relation type: " + r.type);
var targetModel = isModelClass(r.model) ? r.model : this.models[r.model];
if(!targetModel) {
// The target model doesn't exist, let create a place holder for it
targetModel = this.define(r.model, {}, {unresolved: true});
}
var throughModel = null;
if(r.through) {
throughModel = isModelClass(r.through) ? r.through : this.models[r.through];
if(!throughModel) {
// The through model doesn't exist, let create a place holder for it
throughModel = this.define(r.through, {}, {unresolved: true});
}
}
if(targetModel.settings.unresolved || (throughModel && throughModel.settings.unresolved)) {
// 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
};
if(throughModel) {
params.through = throughModel;
}
NewClass[r.type].call(NewClass, rn, params);
}
}
}
return NewClass; return NewClass;
}; };

View File

@ -104,34 +104,49 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
settings.strict = false; settings.strict = false;
} }
// every class can receive hash of data as optional param // Check if there is a unresolved model with the same name
var ModelClass = function ModelConstructor(data, dataSource) { var ModelClass = this.models[className];
if(!(this instanceof ModelConstructor)) {
return new ModelConstructor(data, dataSource);
}
ModelBaseClass.apply(this, arguments);
if(dataSource) {
hiddenProperty(this, '__dataSource', dataSource);
}
};
// mix in EventEmitter (don't inherit from) if(!ModelClass) {
var events = new EventEmitter(); // every class can receive hash of data as optional param
for (var f in EventEmitter.prototype) { ModelClass = function ModelConstructor(data, dataSource) {
if (typeof EventEmitter.prototype[f] === 'function') { if(!(this instanceof ModelConstructor)) {
ModelClass[f] = events[f].bind(events); return new ModelConstructor(data, dataSource);
}
if(ModelClass.settings.unresolved) {
throw new Error('Model ' + ModelClass.modelName + ' is not defined.');
}
ModelBaseClass.apply(this, arguments);
if(dataSource) {
hiddenProperty(this, '__dataSource', dataSource);
}
};
// mix in EventEmitter (don't inherit from)
var events = new EventEmitter();
for (var f in EventEmitter.prototype) {
if (typeof EventEmitter.prototype[f] === 'function') {
ModelClass[f] = events[f].bind(events);
}
} }
util.inherits(ModelClass, ModelBaseClass);
hiddenProperty(ModelClass, 'modelName', className);
}
// store class in model pool
this.models[className] = ModelClass;
// Return the unresolved model
if(settings.unresolved) {
ModelClass.settings = {unresolved: true};
return ModelClass;
} }
// Add metadata to the ModelClass // Add metadata to the ModelClass
hiddenProperty(ModelClass, 'dataSource', dataSource); hiddenProperty(ModelClass, 'dataSource', dataSource);
hiddenProperty(ModelClass, 'schema', dataSource); // For backward compatibility hiddenProperty(ModelClass, 'schema', dataSource); // For backward compatibility
hiddenProperty(ModelClass, 'modelName', className);
hiddenProperty(ModelClass, 'pluralModelName', pluralName || inflection.pluralize(className)); hiddenProperty(ModelClass, 'pluralModelName', pluralName || inflection.pluralize(className));
hiddenProperty(ModelClass, 'relations', {}); hiddenProperty(ModelClass, 'relations', {});
util.inherits(ModelClass, ModelBaseClass);
// inherit ModelBaseClass static methods // inherit ModelBaseClass static methods
for (var i in ModelBaseClass) { for (var i in ModelBaseClass) {
if(i !== '_mixins') { if(i !== '_mixins') {
@ -143,8 +158,7 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
ModelClass.setter = {}; ModelClass.setter = {};
var modelDefinition = new ModelDefinition(this, className, properties, settings); var modelDefinition = new ModelDefinition(this, className, properties, settings);
// store class in model pool
this.models[className] = ModelClass;
this.definitions[className] = modelDefinition; this.definitions[className] = modelDefinition;
// expose properties on the ModelClass // expose properties on the ModelClass
@ -343,6 +357,8 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
ModelClass.forEachProperty(ModelClass.registerProperty); ModelClass.forEachProperty(ModelClass.registerProperty);
ModelClass.emit('defined', ModelClass);
return ModelClass; return ModelClass;
}; };
@ -472,6 +488,8 @@ ModelBuilder.prototype.resolveType = function(type) {
if (schemaType) { if (schemaType) {
return schemaType; return schemaType;
} else { } else {
// The type cannot be resolved, let's create a place holder
type = this.define(type, {}, {unresolved: true});
return type; return type;
} }
} else if (type.constructor.name === 'Object') { } else if (type.constructor.name === 'Object') {

View File

@ -44,16 +44,18 @@ Relation.hasMany = function hasMany(anotherClass, params) {
var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true); var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true);
var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true);
var idName = this.dataSource.idName(this.modelName) || 'id';
this.relations[methodName] = { this.relations[methodName] = {
type: 'hasMany', type: 'hasMany',
keyFrom: 'id', keyFrom: idName,
keyTo: fk, keyTo: fk,
modelTo: anotherClass, modelTo: anotherClass,
multiple: true multiple: true
}; };
// 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[idName]}}, cb);
var scopeMethods = { var scopeMethods = {
findById: find, findById: find,
destroy: destroy destroy: destroy
@ -69,7 +71,7 @@ Relation.hasMany = function hasMany(anotherClass, params) {
done = function() {}; done = function() {};
} }
var self = this; var self = this;
var id = this.id; var id = this[idName];
anotherClass.create(data, function(err, ac) { anotherClass.create(data, function(err, ac) {
if (err) return done(err, ac); if (err) return done(err, ac);
var d = {}; var d = {};
@ -89,16 +91,16 @@ Relation.hasMany = function hasMany(anotherClass, params) {
scopeMethods.add = function(acInst, done) { scopeMethods.add = function(acInst, done) {
var data = {}; var data = {};
var query = {}; var query = {};
query[fk] = this.id; query[fk] = this[idName];
data[params.through.relationNameFor(fk)] = this; data[params.through.relationNameFor(fk)] = this;
query[fk2] = acInst.id || acInst; query[fk2] = acInst[idName] || acInst;
data[params.through.relationNameFor(fk2)] = acInst; data[params.through.relationNameFor(fk2)] = acInst;
params.through.findOrCreate({where: query}, data, done); params.through.findOrCreate({where: query}, data, done);
}; };
scopeMethods.remove = function(acInst, done) { scopeMethods.remove = function(acInst, done) {
var self = this; var self = this;
var q = {}; var q = {};
q[fk2] = acInst.id || acInst; q[fk2] = acInst[idName] || acInst;
params.through.findOne({where: q}, function(err, d) { params.through.findOne({where: q}, function(err, d) {
if (err) { if (err) {
return done(err); return done(err);
@ -114,7 +116,7 @@ Relation.hasMany = function hasMany(anotherClass, params) {
defineScope(this.prototype, params.through || anotherClass, methodName, function () { defineScope(this.prototype, params.through || anotherClass, methodName, function () {
var filter = {}; var filter = {};
filter.where = {}; filter.where = {};
filter.where[fk] = this.id; filter.where[fk] = this[idName];
if (params.through) { if (params.through) {
filter.collect = i8n.camelize(anotherClass.modelName, true); filter.collect = i8n.camelize(anotherClass.modelName, true);
filter.include = filter.collect; filter.include = filter.collect;
@ -131,7 +133,7 @@ Relation.hasMany = function hasMany(anotherClass, params) {
anotherClass.findById(id, function (err, inst) { anotherClass.findById(id, function (err, inst) {
if (err) return cb(err); if (err) return cb(err);
if (!inst) return cb(new Error('Not found')); if (!inst) return cb(new Error('Not found'));
if (inst[fk] && inst[fk].toString() == this.id.toString()) { if (inst[fk] && inst[fk].toString() == this[idName].toString()) {
cb(null, inst); cb(null, inst);
} else { } else {
cb(new Error('Permission denied')); cb(new Error('Permission denied'));
@ -144,7 +146,7 @@ Relation.hasMany = function hasMany(anotherClass, params) {
anotherClass.findById(id, function (err, inst) { anotherClass.findById(id, function (err, inst) {
if (err) return cb(err); if (err) return cb(err);
if (!inst) return cb(new Error('Not found')); if (!inst) return cb(new Error('Not found'));
if (inst[fk] && inst[fk].toString() == self.id.toString()) { if (inst[fk] && inst[fk].toString() == self[idName].toString()) {
inst.destroy(cb); inst.destroy(cb);
} else { } else {
cb(new Error('Permission denied')); cb(new Error('Permission denied'));
@ -192,13 +194,15 @@ Relation.belongsTo = function (anotherClass, params) {
} }
} }
} }
var idName = this.dataSource.idName(this.modelName) || 'id';
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[methodName] = { this.relations[methodName] = {
type: 'belongsTo', type: 'belongsTo',
keyFrom: fk, keyFrom: fk,
keyTo: 'id', keyTo: idName,
modelTo: anotherClass, modelTo: anotherClass,
multiple: false multiple: false
}; };
@ -214,7 +218,7 @@ Relation.belongsTo = function (anotherClass, params) {
anotherClass.findById(id, function (err,inst) { anotherClass.findById(id, function (err,inst) {
if (err) return cb(err); if (err) return cb(err);
if (!inst) return cb(null, null); if (!inst) return cb(null, null);
if (inst.id === this[fk]) { if (inst[idName] === this[fk]) {
cb(null, inst); cb(null, inst);
} else { } else {
cb(new Error('Permission denied')); cb(new Error('Permission denied'));
@ -235,7 +239,7 @@ Relation.belongsTo = function (anotherClass, params) {
cachedValue = this.__cachedRelations[methodName]; cachedValue = this.__cachedRelations[methodName];
} }
if (p instanceof ModelBaseClass) { // acts as setter if (p instanceof ModelBaseClass) { // acts as setter
this[fk] = p.id; this[fk] = p[idName];
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
if (typeof cachedValue === 'undefined') { if (typeof cachedValue === 'undefined') {

View File

@ -157,6 +157,38 @@ describe('ModelBuilder define model', function () {
done(null, User); done(null, User);
}); });
it('should be able to reference models by name before they are defined', function (done) {
var modelBuilder = new ModelBuilder();
var User = modelBuilder.define('User', {name: String, address: 'Address'});
var user;
try {
user = new User({name: 'Joe', address: {street: '123 Main St', 'city': 'San Jose', state: 'CA'}});
assert(false, 'An exception should have been thrown');
} catch (e) {
// Ignore
}
var Address = modelBuilder.define('Address', {
street: String,
city: String,
state: String,
zipCode: String,
country: String
});
user = new User({name: 'Joe', address: {street: '123 Main St', 'city': 'San Jose', state: 'CA'}});
User.modelName.should.equal('User');
User.definition.properties.address.should.have.property('type', Address);
user.should.be.a('object');
assert(user.name === 'Joe');
user.address.should.have.property('city', 'San Jose');
user.address.should.have.property('state', 'CA');
done(null, User);
});
}); });
@ -387,6 +419,120 @@ describe('DataSource define model', function () {
}); });
describe('Load models with relations', function () {
it('should set up relations', function (done) {
var ds = new DataSource('memory');
var Post = ds.define('Post', {userId: Number, content: String});
var User = ds.define('User', {name: String}, {relations: {posts: {type: 'hasMany', model: 'Post'}}});
assert(User.relations['posts']);
done();
});
it('should set up belongsTo relations', function (done) {
var ds = new DataSource('memory');
var User = ds.define('User', {name: String});
var Post = ds.define('Post', {userId: Number, content: String}, {relations: {user: {type: 'belongsTo', model: 'User'}}});
assert(Post.relations['user']);
done();
});
it('should set up hasMany and belongsTo relations', function (done) {
var ds = new DataSource('memory');
var User = ds.define('User', {name: String}, {relations: {posts: {type: 'hasMany', model: 'Post'}, accounts: {type: 'hasMany', model: 'Account'}}});
assert(!User.relations['posts']);
assert(!User.relations['accounts']);
var Post = ds.define('Post', {userId: Number, content: String}, {relations: {user: {type: 'belongsTo', model: 'User'}}});
var Account = ds.define('Account', {userId: Number, type: String}, {relations: {user: {type: 'belongsTo', model: 'User'}}});
assert(Post.relations['user']);
assert.deepEqual(Post.relations['user'], {
type: 'belongsTo',
keyFrom: 'userId',
keyTo: 'id',
modelTo: User,
multiple: false
});
assert(User.relations['posts']);
assert.deepEqual(User.relations['posts'], {
type: 'hasMany',
keyFrom: 'id',
keyTo: 'userId',
modelTo: Post,
multiple: true
});
assert(User.relations['accounts']);
assert.deepEqual(User.relations['accounts'], {
type: 'hasMany',
keyFrom: 'id',
keyTo: 'userId',
modelTo: Account,
multiple: true
});
done();
});
it('should throw if a relation is missing type', function (done) {
var ds = new DataSource('memory');
var Post = ds.define('Post', {userId: Number, content: String});
try {
var User = ds.define('User', {name: String}, {relations: {posts: {model: 'Post'}}});
} catch (e) {
done();
}
});
it('should throw if the relation type is invalid', function (done) {
var ds = new DataSource('memory');
var Post = ds.define('Post', {userId: Number, content: String});
try {
var User = ds.define('User', {name: String}, {relations: {posts: {type: 'hasXYZ', model: 'Post'}}});
} catch (e) {
done();
}
});
it('should handle hasMany through', function (done) {
var ds = new DataSource('memory');
var Physician = ds.createModel('Physician', {
name: String
}, {relations: {patients: {model: 'Patient', type: 'hasMany', through: 'Appointment'}}});
var Patient = ds.createModel('Patient', {
name: String
}, {relations: {physicians: {model: 'Physician', type: 'hasMany', through: 'Appointment'}}});
assert(!Physician.relations['patients']); // Appointment hasn't been resolved yet
assert(!Patient.relations['physicians']); // Appointment hasn't been resolved yet
var Appointment = ds.createModel('Appointment', {
physicianId: Number,
patientId: Number,
appointmentDate: Date
}, {relations: {patient: {type: 'belongsTo', model: 'Patient'}, physician: {type: 'belongsTo', model: 'Physician'}}});
assert(Physician.relations['patients']);
assert(Patient.relations['physicians']);
done();
});
});
describe('Load models from json', function () { describe('Load models from json', function () {
it('should be able to define models from json', function () { it('should be able to define models from json', function () {
var path = require('path'), var path = require('path'),