From 4fbec288c4a4571a7c84f9a38b23aa9119309956 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 4 Nov 2013 22:53:02 -0800 Subject: [PATCH 1/2] Enable deferred type/relation resolutions --- lib/datasource.js | 46 +++++++++++++++++ lib/model-builder.js | 58 ++++++++++++++-------- lib/relations.js | 28 ++++++----- test/loopback-dl.test.js | 104 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 32 deletions(-) diff --git a/lib/datasource.js b/lib/datasource.js index ee8eb3d9..3972e9c9 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -364,6 +364,10 @@ DataSource.prototype.createModel = DataSource.prototype.define = function define var NewClass = ModelBuilder.prototype.define.call(this, className, properties, settings); + if(settings.unresolved) { + return NewClass; + } + // add data access objects this.mixin(NewClass); @@ -376,6 +380,48 @@ 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) { + targetModel.once('defined', function(model) { + // The target model is resolved + var params = { + foreignKey: relation.foreignKey, + as: name, + model: model + }; + NewClass[relation.type].call(NewClass, name, params); + }); + }; + + // Set up the relations + if (relations) { + for (var rn in relations) { + var r = relations[rn]; + if (!r.type) { + throw new Error('Relation type is required for ' + r); + } + var targetModel = 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}); + } + if(targetModel.settings.unresolved) { + // Create a listener to defer the relation set up + createListener(rn, r, targetModel); + } else { + // The target model is resolved + var params = { + foreignKey: r.foreignKey, + as: rn, + model: targetModel + }; + NewClass[r.type].call(NewClass, rn, params); + } + } + } + return NewClass; }; diff --git a/lib/model-builder.js b/lib/model-builder.js index c2d0674b..99a68f3b 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -104,33 +104,48 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett settings.strict = false; } - // every class can receive hash of data as optional param - var ModelClass = function ModelConstructor(data, dataSource) { - if(!(this instanceof ModelConstructor)) { - return new ModelConstructor(data, dataSource); - } - ModelBaseClass.apply(this, arguments); - if(dataSource) { - hiddenProperty(this, '__dataSource', dataSource); - } - }; + // Check if there is a unresolved model with the same name + var ModelClass = this.models[className]; - // 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); + if(!ModelClass) { + // every class can receive hash of data as optional param + ModelClass = function ModelConstructor(data, dataSource) { + if(!(this instanceof ModelConstructor)) { + 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 hiddenProperty(ModelClass, 'dataSource', dataSource); hiddenProperty(ModelClass, 'schema', dataSource); // For backward compatibility - hiddenProperty(ModelClass, 'modelName', className); hiddenProperty(ModelClass, 'pluralModelName', pluralName || inflection.pluralize(className)); hiddenProperty(ModelClass, 'relations', {}); - - util.inherits(ModelClass, ModelBaseClass); // inherit ModelBaseClass static methods for (var i in ModelBaseClass) { @@ -143,8 +158,7 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett ModelClass.setter = {}; var modelDefinition = new ModelDefinition(this, className, properties, settings); - // store class in model pool - this.models[className] = ModelClass; + this.definitions[className] = modelDefinition; // expose properties on the ModelClass @@ -343,6 +357,8 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett ModelClass.forEachProperty(ModelClass.registerProperty); + ModelClass.emit('defined', ModelClass); + return ModelClass; }; @@ -472,6 +488,8 @@ ModelBuilder.prototype.resolveType = function(type) { if (schemaType) { return schemaType; } else { + // The type cannot be resolved, let's create a place holder + type = this.define(type, {}, {unresolved: true}); return type; } } else if (type.constructor.name === 'Object') { diff --git a/lib/relations.js b/lib/relations.js index 97e46a4d..c437468f 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -44,16 +44,18 @@ Relation.hasMany = function hasMany(anotherClass, params) { var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true); var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); + var idName = this.dataSource.idName(this.modelName) || 'id'; + this.relations[methodName] = { type: 'hasMany', - keyFrom: 'id', + keyFrom: idName, keyTo: fk, modelTo: anotherClass, multiple: true }; // each instance of this class should have method named // 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 = { findById: find, destroy: destroy @@ -69,7 +71,7 @@ Relation.hasMany = function hasMany(anotherClass, params) { done = function() {}; } var self = this; - var id = this.id; + var id = this[idName]; anotherClass.create(data, function(err, ac) { if (err) return done(err, ac); var d = {}; @@ -89,16 +91,16 @@ Relation.hasMany = function hasMany(anotherClass, params) { scopeMethods.add = function(acInst, done) { var data = {}; var query = {}; - query[fk] = this.id; + query[fk] = this[idName]; data[params.through.relationNameFor(fk)] = this; - query[fk2] = acInst.id || acInst; + query[fk2] = acInst[idName] || acInst; data[params.through.relationNameFor(fk2)] = acInst; params.through.findOrCreate({where: query}, data, done); }; scopeMethods.remove = function(acInst, done) { var self = this; var q = {}; - q[fk2] = acInst.id || acInst; + q[fk2] = acInst[idName] || acInst; params.through.findOne({where: q}, function(err, d) { if (err) { return done(err); @@ -114,7 +116,7 @@ Relation.hasMany = function hasMany(anotherClass, params) { defineScope(this.prototype, params.through || anotherClass, methodName, function () { var filter = {}; filter.where = {}; - filter.where[fk] = this.id; + filter.where[fk] = this[idName]; if (params.through) { filter.collect = i8n.camelize(anotherClass.modelName, true); filter.include = filter.collect; @@ -131,7 +133,7 @@ Relation.hasMany = function hasMany(anotherClass, params) { anotherClass.findById(id, function (err, inst) { if (err) return cb(err); 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); } else { cb(new Error('Permission denied')); @@ -144,7 +146,7 @@ Relation.hasMany = function hasMany(anotherClass, params) { anotherClass.findById(id, function (err, inst) { if (err) return cb(err); 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); } else { 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 fk = params.foreignKey || methodName + 'Id'; this.relations[methodName] = { type: 'belongsTo', keyFrom: fk, - keyTo: 'id', + keyTo: idName, modelTo: anotherClass, multiple: false }; @@ -214,7 +218,7 @@ Relation.belongsTo = function (anotherClass, params) { anotherClass.findById(id, function (err,inst) { if (err) return cb(err); if (!inst) return cb(null, null); - if (inst.id === this[fk]) { + if (inst[idName] === this[fk]) { cb(null, inst); } else { cb(new Error('Permission denied')); @@ -235,7 +239,7 @@ Relation.belongsTo = function (anotherClass, params) { cachedValue = this.__cachedRelations[methodName]; } if (p instanceof ModelBaseClass) { // acts as setter - this[fk] = p.id; + this[fk] = p[idName]; this.__cachedRelations[methodName] = p; } else if (typeof p === 'function') { // acts as async getter if (typeof cachedValue === 'undefined') { diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index 1d3459b4..62cc4cd1 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -157,6 +157,38 @@ describe('ModelBuilder define model', function () { 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,78 @@ 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'}}}); + 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(); + } + + }); + +}); + describe('Load models from json', function () { it('should be able to define models from json', function () { var path = require('path'), From 68cf633795788084b198e1b348e7dff3e3065cd5 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 5 Nov 2013 09:29:24 -0800 Subject: [PATCH 2/2] Add support for hasMany-through and more tests --- lib/datasource.js | 72 +++++++++++++++++++++++++++++++--------- test/loopback-dl.test.js | 42 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/lib/datasource.js b/lib/datasource.js index 3972e9c9..8c4ada7c 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -12,6 +12,7 @@ var EventEmitter = require('events').EventEmitter; var util = require('util'); var path = require('path'); var fs = require('fs'); +var assert = require('assert'); var async = require('async'); 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 * @@ -383,33 +391,62 @@ 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) { - targetModel.once('defined', function(model) { - // The target model is resolved - var params = { - foreignKey: relation.foreignKey, - as: name, - model: model - }; - NewClass[relation.type].call(NewClass, name, params); - }); + 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]; - if (!r.type) { - throw new Error('Relation type is required for ' + r); - } - var targetModel = this.models[r.model]; + 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}); } - if(targetModel.settings.unresolved) { + 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); + createListener(rn, r, targetModel, throughModel); } else { // The target model is resolved var params = { @@ -417,6 +454,9 @@ DataSource.prototype.createModel = DataSource.prototype.define = function define as: rn, model: targetModel }; + if(throughModel) { + params.through = throughModel; + } NewClass[r.type].call(NewClass, rn, params); } } diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index 62cc4cd1..3421176d 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -444,6 +444,10 @@ describe('Load models with relations', function () { 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'}}}); @@ -489,6 +493,44 @@ describe('Load models with relations', function () { }); + 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 () {