diff --git a/lib/abstract-class.js b/lib/abstract-class.js index 4882a902..12e6bf4f 100644 --- a/lib/abstract-class.js +++ b/lib/abstract-class.js @@ -37,6 +37,13 @@ AbstractClass.prototype._initProperties = function (data, applySetters) { var properties = ds.properties; data = data || {}; + Object.defineProperty(this, '__cachedRelations', { + writable: true, + enumerable: false, + configurable: true, + value: {} + }); + Object.defineProperty(this, '__data', { writable: true, enumerable: false, @@ -704,6 +711,23 @@ AbstractClass.hasMany = function hasMany(anotherClass, params) { * * @param {Class} anotherClass - class to belong * @param {Object} params - configuration {as: 'propertyName', foreignKey: 'keyName'} + * + * **Usage examples** + * Suppose model Post have a *belongsTo* relationship with User (the author of the post). You could declare it this way: + * Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + * + * When a post is loaded, you can load the related author with: + * post.author(function(err, user) { + * // the user variable is your user object + * }); + * + * The related object is cached, so if later you try to get again the author, no additional request will be made. + * But there is an optional boolean parameter in first position that set whether or not you want to reload the cache: + * post.author(true, function(err, user) { + * // The user is reloaded, even if it was already cached. + * }); + * + * This optional parameter default value is false, so the related object will be loaded from cache if available. */ AbstractClass.belongsTo = function (anotherClass, params) { var methodName = params.as; @@ -724,16 +748,39 @@ AbstractClass.belongsTo = function (anotherClass, params) { }.bind(this)); }; - this.prototype[methodName] = function (p) { + this.prototype[methodName] = function (refresh, p) { + if (arguments.length === 1) { + p = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + var self = this; + var cachedValue; + if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { + cachedValue = this.__cachedRelations[methodName]; + } if (p instanceof AbstractClass) { // acts as setter this[fk] = p.id; + this.__cachedRelations[methodName] = p; } else if (typeof p === 'function') { // acts as async getter - this.__finders__[methodName](this[fk], p); - return this[fk]; + if (typeof cachedValue === 'undefined') { + this.__finders__[methodName](this[fk], function(err, inst) { + if (!err) { + self.__cachedRelations[methodName] = inst; + } + p(err, inst); + }); + return this[fk]; + } else { + p(null, cachedValue); + return cachedValue; + } } else if (typeof p === 'undefined') { // acts as sync getter return this[fk]; } else { // setter this[fk] = p; + delete this.__cachedRelations[methodName]; } }; @@ -768,18 +815,35 @@ function defineScope(cls, targetClass, name, params, methods) { enumerable: false, configurable: true, get: function () { - var f = function caller(cond, cb) { - var actualCond; + var f = function caller(condOrRefresh, cb) { + var actualCond = {}; + var actualRefresh = false; + var saveOnCache = true; if (arguments.length === 1) { - actualCond = {}; - cb = cond; + cb = condOrRefresh; } else if (arguments.length === 2) { - actualCond = cond; + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + saveOnCache = false; + } } else { - throw new Error('Method only can be called with one or two arguments'); + throw new Error('Method can be only called with one or two arguments'); } - return targetClass.all(mergeParams(actualCond, caller._scope), cb); + if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { + var self = this; + return targetClass.all(mergeParams(actualCond, caller._scope), function(err, data) { + if (!err && saveOnCache) { + self.__cachedRelations[name] = data; + } + cb(err, data); + }); + } else { + cb(null, this.__cachedRelations[name]); + } }; f._scope = typeof params === 'function' ? params.call(this) : params; f.build = build; diff --git a/test/common_test.js b/test/common_test.js index 053ea3be..a47c231f 100644 --- a/test/common_test.js +++ b/test/common_test.js @@ -26,6 +26,7 @@ var schemas = { var specificTest = getSpecificTests(); var testPerformed = false; +var nbSchemaRequests = 0; Object.keys(schemas).forEach(function (schemaName) { if (process.env.ONLY && process.env.ONLY !== schemaName) return; @@ -48,7 +49,8 @@ function performTestFor(schemaName) { }); schema.log = function (a) { - console.log(a); + console.log(a); + nbSchemaRequests++; }; testOrm(schema); @@ -446,6 +448,58 @@ function testOrm(schema) { }); }); + + it('hasMany should be cached', function (test) { + + User.find(1, function(err, user) { + User.create(function(err, voidUser) { + Post.create({userId: user.id}, function() { + + // There can't be any concurrency because we are counting requests + // We are first testing cases when user has posts + user.posts(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + user.posts(function(err, data2) { + test.equal(data.length, 2, 'There should be 2 posts.'); + test.equal(data.length, data2.length, 'Posts should be the same, since we are loading on the same object.'); + test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + user.posts({where: {id: 12}}, function(err, data) { + test.equal(data.length, 1, 'There should be only one post.'); + test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we added conditions.'); + + user.posts(function(err, data) { + test.equal(data.length, 2, 'Previous get shouldn\'t have changed cached value though, since there was additional conditions.'); + test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // We are now testing cases when user doesn't have any post + voidUser.posts(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + voidUser.posts(function(err, data2) { + test.equal(data.length, 0, 'There shouldn\'t be any posts (1/2).'); + test.equal(data2.length, 0, 'There shouldn\'t be any posts (2/2).'); + test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + voidUser.posts(true, function(err, data3) { + test.equal(data3.length, 0, 'There shouldn\'t be any posts.'); + test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we forced refresh.'); + + test.done(); + }); + }); + }); + + }); + }); + }); + }); + + }); + }); + }); + + }); + // it('should handle hasOne relationship', function (test) { // User.create(function (err, u) { // if (err) return console.log(err); @@ -882,6 +936,48 @@ function testOrm(schema) { }); }); + it('belongsTo should be cached', function (test) { + User.findOne(function(err, user) { + + var passport = new Passport({ownerId: user.id}); + var passport2 = new Passport({ownerId: null}); + + // There can't be any concurrency because we are counting requests + // We are first testing cases when passport has an owner + passport.owner(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + passport.owner(function(err, data2) { + test.equal(data.id, data2.id, 'The value should remain the same'); + test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // We are now testing cases when passport has not an owner + passport2.owner(function(err, data) { + var nbInitialRequests2 = nbSchemaRequests; + passport2.owner(function(err, data2) { + test.equal(data, null, 'The value should be null since there is no owner'); + test.equal(data, data2, 'The value should remain the same (null)'); + test.equal(nbInitialRequests2, nbSchemaRequests, 'There should not be any request because value is cached.'); + + passport2.owner(user.id); + passport2.owner(function(err, data3) { + test.equal(data3.id, user.id, 'Owner should now be the user.'); + test.equal(nbInitialRequests2 + 1, nbSchemaRequests, 'If we changed owner id, there should be one more request.'); + + passport2.owner(true, function(err, data4) { + test.equal(data3.id, data3.id, 'The value should remain the same'); + test.equal(nbInitialRequests2 + 2, nbSchemaRequests, 'If we forced refreshing, there should be one more request.'); + test.done(); + }); + }); + }); + }); + + }); + }); + }); + + }); + if (schema.name !== 'mongoose' && schema.name !== 'neo4j') it('should update or create record', function (test) { var newData = {