diff --git a/lib/abstract-class.js b/lib/abstract-class.js index 61c9a6ee..1c0b7a77 100644 --- a/lib/abstract-class.js +++ b/lib/abstract-class.js @@ -58,6 +58,10 @@ AbstractClass.prototype._initProperties = function (data, applySetters) { value: {} }); + if (data['__cachedRelations']) { + this.__cachedRelations = data['__cachedRelations']; + } + for (var i in data) this.__data[i] = this.__dataWas[i] = data[i]; if (applySetters && ctor.setter) { @@ -387,6 +391,200 @@ AbstractClass.count = function (where, cb) { this.schema.adapter.count(this.modelName, cb, where); }; +/** + * + * + * @param objects + * @param include + */ +AbstractClass.include = function (objects, include, callback) { + var self = this; + + if ((include.constructor.name == 'Array' && include.length == 0) || (include.constructor.name == 'Object' && Object.keys(include).length == 0)) { + callback(null, objects); + return; + } + + include = processIncludeJoin(include); + + var keyVals = {}; + var objsByKeys = {}; + + var nbCallbacks = 0; + for (var i = 0; i < include.length; i++) { + var cb = processIncludeItem(objects, include[i], keyVals, objsByKeys); + if (cb !== null) { + nbCallbacks++; + cb(function() { + nbCallbacks--; + if (nbCallbacks == 0) { + callback(null, objects); + } + }); + } + } + + + + /* + async.parallel(callbacks, function(err, results) { + callback(null, objs); + }); + */ + + function processIncludeJoin(ij) { + if (typeof ij === 'string') { + ij = [ij]; + } + if (ij.constructor.name === 'Object') { + var newIj = []; + for (var key in ij) { + var obj = {}; + obj[key] = ij[key]; + newIj.push(obj); + } + return newIj; + } + return ij; + } + + function processIncludeItem(objs, include, keyVals, objsByKeys) { + var relations = self.relations; + + if (include.constructor.name === 'Object') { + var relationName = Object.keys(include)[0]; + var subInclude = include[relationName]; + } else { + var relationName = include; + var subInclude = []; + } + var relation = relations[relationName]; + + var req = {'where': {}}; + + if (!keyVals[relation.keyFrom]) { + objsByKeys[relation.keyFrom] = {}; + for (var j = 0; j < objs.length; j++) { + if (!objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]]) { + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]] = []; + } + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]].push(objs[j]); + } + keyVals[relation.keyFrom] = Object.keys(objsByKeys[relation.keyFrom]); + } + + if (keyVals[relation.keyFrom].length > 0) { + // deep clone is necessary since inq seems to change the processed array + var keysToBeProcessed = {}; + var inValues = []; + for (var j = 0; j < keyVals[relation.keyFrom].length; j++) { + keysToBeProcessed[keyVals[relation.keyFrom][j]] = true; + if (keyVals[relation.keyFrom][j] !== 'null') { + inValues.push(keyVals[relation.keyFrom][j]); + } + } + + req['where'][relation.keyTo] = {inq: inValues}; + req['include'] = subInclude; + + return function(cb) { + relation.modelTo.all(req, function(err, objsIncluded) { + for (var i = 0; i < objsIncluded.length; i++) { + delete keysToBeProcessed[objsIncluded[i][relation.keyTo]]; + var objectsFrom = objsByKeys[relation.keyFrom][objsIncluded[i][relation.keyTo]]; + for (var j = 0; j < objectsFrom.length; j++) { + if (!objectsFrom[j].__cachedRelations) { + objectsFrom[j].__cachedRelations = {}; + } + if (relation.multiple) { + if (!objectsFrom[j].__cachedRelations[relationName]) { + objectsFrom[j].__cachedRelations[relationName] = []; + } + objectsFrom[j].__cachedRelations[relationName].push(objsIncluded[i]); + } else { + objectsFrom[j].__cachedRelations[relationName] = objsIncluded[i]; + } + } + } + + // No relation have been found for these keys + for (var key in keysToBeProcessed) { + var objectsFrom = objsByKeys[relation.keyFrom][key]; + for (var j = 0; j < objectsFrom.length; j++) { + if (!objectsFrom[j].__cachedRelations) { + objectsFrom[j].__cachedRelations = {}; + } + objectsFrom[j].__cachedRelations[relationName] = relation.multiple ? [] : null; + } + } + cb(err, objsIncluded); + }); + }; + } + + + return null; + } + + + + /* + function processIncludeItem(model, objs, include, keyVals, objsByKeys) { + var relations = model.relations; + + if (include.constructor.name === 'Object') { + var relationName = Object.keys(include)[0]; + var relation = relations[relationName]; + var subInclude = include[relationName]; + } else { + var relationName = include; + var relation = relations[relationName]; + var subInclude = []; + } + + var req = {'where': {}}; + if (!keyVals[relation.keyFrom]) { + objsByKeys[relation.keyFrom] = {}; + for (var j = 0; j < objs.length; j++) { + if (!objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]]) { + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]] = []; + } + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]].push(objs[j]); + } + keyVals[relation.keyFrom] = Object.keys(objsByKeys[relation.keyFrom]); + } + req['where'][relation.keyTo] = {inq: keyVals[relation.keyFrom]}; + req['include'] = subInclude; + return function(cb) { + relation.model.all(req, function(err, dataIncluded) { + + var objsIncluded = dataIncluded.map(function (obj) { + return self.fromDatabase(relation.model.modelName, obj); + }); + + for (var i = 0; i < objsIncluded.length; i++) { + var objectsFrom = objsByKeys[relation.keyFrom][objsIncluded[i][relation.keyTo]]; + for (var j = 0; j < objectsFrom.length; j++) { + if (!objectsFrom[j].__cache) { + objectsFrom[j].__cache = {}; + } + if (relation.type == 'hasMany') { + if (!objectsFrom[j].__cache[relationName]) { + objectsFrom[j].__cache[relationName] = []; + } + objectsFrom[j].__cache[relationName].push(objsIncluded[i]); + } else { + objectsFrom[j].__cache[relationName] = objsIncluded[i]; + } + } + } + cb(err, dataIncluded); + }); + }; + } + */ +} + /** * Return string representation of class * @@ -669,6 +867,14 @@ AbstractClass.prototype.reset = function () { AbstractClass.hasMany = function hasMany(anotherClass, params) { var methodName = params.as; // or pluralize(anotherClass.modelName) var fk = params.foreignKey; + + this.relations[params['as']] = { + type: 'hasMany', + keyFrom: 'id', + keyTo: params['foreignKey'], + 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); @@ -736,10 +942,22 @@ AbstractClass.belongsTo = function (anotherClass, params) { var methodName = params.as; var fk = params.foreignKey; + this.relations[params['as']] = { + type: 'belongsTo', + keyFrom: params['foreignKey'], + keyTo: 'id', + modelTo: anotherClass, + multiple: false + }; + this.schema.defineForeignKey(this.modelName, fk); this.prototype['__finders__'] = this.prototype['__finders__'] || {}; this.prototype['__finders__'][methodName] = function (id, cb) { + if (id === null) { + cb(null, null); + return; + } anotherClass.find(id, function (err,inst) { if (err) return cb(err); if (!inst) return cb(null, null); diff --git a/lib/schema.js b/lib/schema.js index 1957513c..040eaf9f 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -162,6 +162,7 @@ Schema.prototype.define = function defineClass(className, properties, settings) hiddenProperty(NewClass, 'modelName', className); hiddenProperty(NewClass, 'cache', {}); hiddenProperty(NewClass, 'mru', []); + hiddenProperty(NewClass, 'relations', {}); // inherit AbstractClass methods for (var i in AbstractClass) { diff --git a/test/common_test.js b/test/common_test.js index d2591a79..825151ac 100644 --- a/test/common_test.js +++ b/test/common_test.js @@ -33,6 +33,7 @@ function it(name, cases) { batch[schemaName][name] = cases; } + module.exports = function testSchema(exportCasesHere, schema) { batch = exportCasesHere; @@ -77,6 +78,27 @@ Object.defineProperty(module.exports, 'it', { value: it }); + +function clearAndCreate(model, data, callback) { + var createdItems = []; + model.destroyAll(function () { + nextItem(null, null); + }); + + var itemIndex = 0; + function nextItem(err, lastItem) { + if (lastItem !== null) { + createdItems.push(lastItem); + } + if (itemIndex >= data.length) { + callback(createdItems); + return; + } + model.create(data[itemIndex], nextItem); + itemIndex++; + } +} + function testOrm(schema) { var requestsAreCounted = schema.name !== 'mongodb'; @@ -141,6 +163,7 @@ function testOrm(schema) { }); Passport.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'}); + User.hasMany(Passport, {as: 'passports', foreignKey: 'ownerId'}); var user = new User; @@ -453,6 +476,30 @@ function testOrm(schema) { }); }); + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' + ) + it('relations key is working', function (test) { + test.ok(User.relations, 'Relations key should be defined'); + test.ok(User.relations.posts, 'posts relation should exist on User'); + test.equal(User.relations.posts.type, 'hasMany', 'Type of hasMany relation is hasMany'); + test.equal(User.relations.posts.multiple, true, 'hasMany relations are multiple'); + test.equal(User.relations.posts.keyFrom, 'id', 'keyFrom is primary key of model table'); + test.equal(User.relations.posts.keyTo, 'userId', 'keyTo is foreign key of related model table'); + + test.ok(Post.relations, 'Relations key should be defined'); + test.ok(Post.relations.author, 'author relation should exist on Post'); + test.equal(Post.relations.author.type, 'belongsTo', 'Type of belongsTo relation is belongsTo'); + test.equal(Post.relations.author.multiple, false, 'belongsTo relations are not multiple'); + test.equal(Post.relations.author.keyFrom, 'userId', 'keyFrom is foreign key of model table'); + test.equal(Post.relations.author.keyTo, 'id', 'keyTo is primary key of related model table'); + test.done(); + }); + + it('should handle hasMany relationship', function (test) { User.create(function (err, u) { if (err) return console.log(err); @@ -470,7 +517,6 @@ function testOrm(schema) { }); }); - it('hasMany should support additional conditions', function (test) { User.create(function (e, u) { @@ -589,6 +635,236 @@ function testOrm(schema) { }; }); + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' + ) + it('should handle include function', function (test) { + var createdUsers = []; + var createdPassports = []; + var createdPosts = []; + var context = null; + + createUsers(); + function createUsers() { + clearAndCreate( + User, + [ + {name: 'User A', age: 21}, + {name: 'User B', age: 22}, + {name: 'User C', age: 23}, + {name: 'User D', age: 24}, + {name: 'User E', age: 25} + ], + function(items) { + createdUsers = items; + createPassports(); + } + ); + } + + function createPassports() { + clearAndCreate( + Passport, + [ + {number: '1', ownerId: createdUsers[0].id}, + {number: '2', ownerId: createdUsers[1].id}, + {number: '3'} + ], + function(items) { + createdPassports = items; + createPosts(); + } + ); + } + + function createPosts() { + clearAndCreate( + Post, + [ + {title: 'Post A', userId: createdUsers[0].id}, + {title: 'Post B', userId: createdUsers[0].id}, + {title: 'Post C', userId: createdUsers[0].id}, + {title: 'Post D', userId: createdUsers[1].id}, + {title: 'Post E'} + ], + function(items) { + createdPosts = items; + makeTests(); + } + ); + } + + function makeTests() { + var unitTests = [ + function() { + context = ' (belongsTo simple string from passports to users)'; + Passport.all({include: 'owner'}, testPassportsUser); + }, + function() { + context = ' (belongsTo simple string from posts to users)'; + Post.all({include: 'author'}, testPostsUser); + }, + function() { + context = ' (belongsTo simple array)'; + Passport.all({include: ['owner']}, testPassportsUser); + }, + function() { + context = ' (hasMany simple string from users to posts)'; + User.all({include: 'posts'}, testUsersPosts); + }, + function() { + context = ' (hasMany simple string from users to passports)'; + User.all({include: 'passports'}, testUsersPassports); + }, + function() { + context = ' (hasMany simple array)'; + User.all({include: ['posts']}, testUsersPosts); + }, + function() { + context = ' (Passports - User - Posts in object)'; + Passport.all({include: {'owner': 'posts'}}, testPassportsUserPosts); + }, + function() { + context = ' (Passports - User - Posts in array)'; + Passport.all({include: [{'owner': 'posts'}]}, testPassportsUserPosts); + }, + function() { + context = ' (Passports - User - Posts - User)'; + Passport.all({include: {'owner': {'posts': 'author'}}}, testPassportsUserPosts); + }, + function() { + context = ' (User - Posts AND Passports)'; + User.all({include: ['posts', 'passports']}, testUsersPostsAndPassports); + } + ]; + + function testPassportsUser(err, passports, callback) { + testBelongsTo(passports, 'owner', callback); + } + + function testPostsUser(err, posts, callback) { + testBelongsTo(posts, 'author', callback); + } + + function testBelongsTo(items, relationName, callback) { + if (typeof callback === 'undefined') { + callback = nextUnitTest; + } + var nbInitialRequests = nbSchemaRequests; + var nbItemsRemaining = items.length; + + for (var i = 0; i < items.length; i++) { + testItem(items[i]); + } + + function testItem(item) { + var relation = item.constructor.relations[relationName]; + var modelNameFrom = item.constructor.modelName; + var modelNameTo = relation.modelTo.modelName; + item[relationName](function(err, relatedItem) { + if (relatedItem !== null) { + test.equal(relatedItem[relation.keyTo], item[relation.keyFrom], modelNameTo + '\'s instance match ' + modelNameFrom + '\'s instance' + context); + } else { + test.ok(item[relation.keyFrom] == null, 'User match passport even when user is null.' + context); + } + nbItemsRemaining--; + if (nbItemsRemaining == 0) { + requestsAreCounted && test.equal(nbSchemaRequests, nbInitialRequests, 'No more request have been executed for loading ' + relationName + ' relation' + context) + callback(); + } + }); + } + } + + function testUsersPosts(err, users, expectedUserNumber, callback) { + if (typeof expectedUserNumber === 'undefined') { + expectedUserNumber = 5; + } + test.equal(users.length, expectedUserNumber, 'Exactly ' + expectedUserNumber + ' users returned by query' + context); + testHasMany(users, 'posts', callback); + } + + function testUsersPassports(err, users, callback) { + testHasMany(users, 'passports', callback); + } + + function testHasMany(items, relationName, callback) { + if (typeof callback === 'undefined') { + callback = nextUnitTest; + } + var nbInitialRequests = nbSchemaRequests; + var nbItemRemaining = items.length; + for (var i = 0; i < items.length; i++) { + testItem(items[i]); + } + + function testItem(item) { + var relation = item.constructor.relations[relationName]; + var modelNameFrom = item.constructor.modelName; + var modelNameTo = relation.modelTo.modelName; + item[relationName](function(err, relatedItems) { + for (var j = 0; j < relatedItems.length; j++) { + test.equal(relatedItems[j][relation.keyTo], item[relation.keyFrom], modelNameTo + '\'s instances match ' + modelNameFrom + '\'s instance' + context); + } + nbItemRemaining--; + if (nbItemRemaining == 0) { + requestsAreCounted && test.equal(nbSchemaRequests, nbInitialRequests, 'No more request have been executed for loading ' + relationName + ' relation' + context) + callback(); + } + }); + } + } + + function testPassportsUserPosts(err, passports) { + testPassportsUser(err, passports, function() { + var nbPassportsRemaining = passports.length; + for (var i = 0; i < passports.length; i++) { + if (passports[i].ownerId !== null) { + passports[i].owner(function(err, user) { + testUsersPosts(null, [user], 1, function() { + nextPassport(); + }); + }); + } else { + nextPassport(); + } + } + function nextPassport() { + nbPassportsRemaining-- + if (nbPassportsRemaining == 0) { + nextUnitTest(); + } + } + }); + } + + function testUsersPostsAndPassports(err, users) { + testUsersPosts(err, users, 5, function() { + testUsersPassports(err, users, function() { + nextUnitTest(); + }); + }); + } + + var testNum = 0; + function nextUnitTest() { + if (testNum >= unitTests.length) { + test.done(); + return; + } + unitTests[testNum](); + testNum++; + + } + + nextUnitTest(); + } + + }); + it('should destroy all records', function (test) { Post.destroyAll(function (err) { if (err) {