diff --git a/lib/dao.js b/lib/dao.js index cff2e52c..2a9b9734 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -803,19 +803,19 @@ DataAccessObject.find = function find(query, cb) { // This handles the case to return parent items including the related // models. For example, Article.find({include: 'tags'}, ...); // Try to normalize the include - var includes = query.include || []; - if (typeof includes === 'string') { - includes = [includes]; - } else if (!Array.isArray(includes) && typeof includes === 'object') { - includes = Object.keys(includes); - } + var includes = Inclusion.normalizeInclude(query.include || []); includes.forEach(function (inc) { + var relationName = inc; + if (utils.isPlainObject(inc)) { + relationName = Object.keys(inc)[0]; + } + // Promote the included model as a direct property - var data = obj.__cachedRelations[inc]; + var data = obj.__cachedRelations[relationName]; if(Array.isArray(data)) { data = new List(data, null, obj); } - obj.__data[inc] = data; + if (data) obj.__data[relationName] = data; }); delete obj.__data.__cachedRelations; } diff --git a/lib/include.js b/lib/include.js index dacf78af..eddf7a39 100644 --- a/lib/include.js +++ b/lib/include.js @@ -3,6 +3,53 @@ var utils = require('./utils'); var isPlainObject = utils.isPlainObject; var defineCachedRelations = utils.defineCachedRelations; +/*! + * Normalize the include to be an array + * @param include + * @returns {*} + */ +function normalizeInclude(include) { + if (typeof include === 'string') { + return [include]; + } else if (isPlainObject(include)) { + // Build an array of key/value pairs + var newInclude = []; + var rel = include.rel || include.relation; + if (typeof rel === 'string') { + var obj = {}; + obj[rel] = new IncludeScope(include.scope); + newInclude.push(obj); + } else { + for (var key in include) { + var obj = {}; + obj[key] = include[key]; + newInclude.push(obj); + } + } + return newInclude; + } else { + return include; + } +} + +function IncludeScope(scope) { + this._scope = utils.deepMerge({}, scope || {}); + if (this._scope.include) { + this._include = normalizeInclude(this._scope.include); + delete this._scope.include; + } else { + this._include = null; + } +}; + +IncludeScope.prototype.conditions = function() { + return utils.deepMerge({}, this._scope); +}; + +IncludeScope.prototype.include = function() { + return this._include; +}; + /*! * Include mixin for ./model.js */ @@ -17,6 +64,13 @@ module.exports = Inclusion; function Inclusion() { } +/** + * Normalize includes - used in DataAccessObject + * + */ + +Inclusion.normalizeInclude = normalizeInclude; + /** * Enables you to load relations of several objects and optimize numbers of requests. * @@ -52,49 +106,33 @@ Inclusion.include = function (objects, include, cb) { } include = normalizeInclude(include); - + async.each(include, function(item, callback) { processIncludeItem(objects, item, callback); }, function(err) { cb && cb(err, objects); }); - - /*! - * Normalize the include to be an array - * @param include - * @returns {*} - */ - function normalizeInclude(include) { - if (typeof include === 'string') { - return [include]; - } else if (isPlainObject(include)) { - // Build an array of key/value pairs - var newInclude = []; - for (var key in include) { - var obj = {}; - obj[key] = include[key]; - newInclude.push(obj); - } - return newInclude; - } else { - return include; - } - } - function processIncludeItem(objs, include, cb) { var relations = self.relations; - - var relationName, subInclude; + + var relationName; + var subInclude = null, scope = null; + if (isPlainObject(include)) { relationName = Object.keys(include)[0]; - subInclude = include[relationName]; + if (include[relationName] instanceof IncludeScope) { + scope = include[relationName]; + subInclude = scope.include(); + } else { + subInclude = include[relationName]; + } } else { relationName = include; subInclude = null; } - var relation = relations[relationName]; + var relation = relations[relationName]; if (!relation) { cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); @@ -126,10 +164,35 @@ Inclusion.include = function (objects, include, cb) { var inst = (obj instanceof self) ? obj : new self(obj); // Calling the relation method on the instance - inst[relationName](function (err, result) { + + var related; // relation accessor function + + if (relation.multiple && scope) { + var includeScope = {}; + var filter = scope.conditions(); + + // make sure not to miss any fields for sub includes + if (filter.fields && Array.isArray(subInclude) && relation.modelTo.relations) { + includeScope.fields = []; + subInclude.forEach(function(name) { + var rel = relation.modelTo.relations[name]; + if (rel && rel.type === 'belongsTo') { + includeScope.fields.push(rel.keyFrom); + } + }); + } + + utils.mergeQuery(filter, includeScope, {fields: false}); + related = inst[relationName].bind(inst, filter); + } else { + related = inst[relationName].bind(inst); + } + + related(function (err, result) { if (err) { return callback(err); } else { + defineCachedRelations(obj); obj.__cachedRelations[relationName] = result; diff --git a/lib/utils.js b/lib/utils.js index 4e317058..c3fbf841 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -3,7 +3,7 @@ exports.fieldsToArray = fieldsToArray; exports.selectFields = selectFields; exports.removeUndefined = removeUndefined; exports.parseSettings = parseSettings; -exports.mergeSettings = mergeSettings; +exports.mergeSettings = exports.deepMerge = mergeSettings; exports.isPlainObject = isPlainObject; exports.defineCachedRelations = defineCachedRelations; exports.sortObjectsByIds = sortObjectsByIds; @@ -93,6 +93,8 @@ function mergeQuery(base, update, spec) { // Overwrite fields if (spec.fields !== false && update.fields !== undefined) { base.fields = update.fields; + } else if (update.fields !== undefined) { + base.fields = [].concat(base.fields).concat(update.fields); } // set order diff --git a/test/include.test.js b/test/include.test.js index 394862a2..1773eb2b 100644 --- a/test/include.test.js +++ b/test/include.test.js @@ -79,6 +79,17 @@ describe('include', function () { done(); }); }); + + it('should fetch Passport - Owner - Posts - alternate syntax', function (done) { + Passport.find({include: {owner: {relation: 'posts'}}}, function (err, passports) { + should.not.exist(err); + should.exist(passports); + passports.length.should.be.ok; + var posts = passports[0].owner().posts(); + posts.should.have.length(3); + done(); + }); + }); it('should fetch Passports - User - Posts - User', function (done) { Passport.find({ @@ -97,6 +108,7 @@ describe('include', function () { user.id.should.equal(p.ownerId); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.posts.forEach(function (pp) { + pp.should.have.property('id'); pp.userId.should.equal(user.id); pp.should.have.property('author'); pp.__cachedRelations.should.have.property('author'); @@ -108,6 +120,92 @@ describe('include', function () { done(); }); }); + + it('should fetch Passports with include scope on Posts', function (done) { + Passport.find({ + include: {owner: {relation: 'posts', scope:{ + fields: ['title'], include: ['author'], + order: 'title DESC' + }}} + }, function (err, passports) { + should.not.exist(err); + should.exist(passports); + passports.length.should.equal(3); + + var passport = passports[0]; + passport.number.should.equal('1'); + passport.owner().name.should.equal('User A'); + var owner = passport.owner().toObject(); + + var posts = passport.owner().posts(); + posts.should.be.an.array; + posts.should.have.length(3); + + posts[0].title.should.equal('Post C'); + posts[0].should.not.have.property('id'); // omitted + posts[0].author().should.be.instanceOf(User); + posts[0].author().name.should.equal('User A'); + + posts[1].title.should.equal('Post B'); + posts[1].author().name.should.equal('User A'); + + posts[2].title.should.equal('Post A'); + posts[2].author().name.should.equal('User A'); + + done(); + }); + }); + + it('should fetch Users with include scope on Posts', function (done) { + User.find({ + include: {relation: 'posts', scope:{ + order: 'title DESC' + }} + }, function (err, users) { + should.not.exist(err); + should.exist(users); + users.length.should.equal(5); + + users[0].name.should.equal('User A'); + users[1].name.should.equal('User B'); + + var posts = users[0].posts(); + posts.should.be.an.array; + posts.should.have.length(3); + + posts[0].title.should.equal('Post C'); + posts[1].title.should.equal('Post B'); + posts[2].title.should.equal('Post A'); + + var posts = users[1].posts(); + posts.should.be.an.array; + posts.should.have.length(1); + posts[0].title.should.equal('Post D'); + + done(); + }); + }); + + it('should fetch Users with include scope on Passports', function (done) { + User.find({ + include: {relation: 'passports', scope:{ + where: { number: '2' } + }} + }, function (err, users) { + should.not.exist(err); + should.exist(users); + users.length.should.equal(5); + + users[0].name.should.equal('User A'); + users[0].passports().should.be.empty; + + users[1].name.should.equal('User B'); + var passports = users[1].passports(); + passports[0].number.should.equal('2'); + + done(); + }); + }); it('should fetch User - Posts AND Passports', function (done) { User.find({include: ['posts', 'passports']}, function (err, users) {