// Copyright IBM Corp. 2013,2015. All Rights Reserved. // Node module: loopback-datasource-juggler // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT // This test written in mocha+should.js var should = require('./init.js'); var async = require('async'); var assert = require('assert'); var DataSource = require('../').DataSource; var db, User, Profile, AccessToken, Post, Passport, City, Street, Building, Assembly, Part; describe('include', function() { before(setup); it('should fetch belongsTo relation', function(done) { Passport.find({ include: 'owner' }, function(err, passports) { passports.length.should.be.ok; passports.forEach(function(p) { p.__cachedRelations.should.have.property('owner'); // The relation should be promoted as the 'owner' property p.should.have.property('owner'); // The __cachedRelations should be removed from json output p.toJSON().should.not.have.property('__cachedRelations'); var owner = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(owner); } else { should.exist(owner); owner.id.should.eql(p.ownerId); } }); done(); }); }); it('should fetch hasMany relation', function(done) { User.find({ include: 'posts' }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(u) { // The relation should be promoted as the 'owner' property u.should.have.property('posts'); // The __cachedRelations should be removed from json output u.toJSON().should.not.have.property('__cachedRelations'); u.__cachedRelations.should.have.property('posts'); u.__cachedRelations.posts.forEach(function(p) { p.userId.should.eql(u.id); }); }); done(); }); }); it('should fetch Passport - Owner - Posts', function(done) { Passport.find({ include: { owner: 'posts' }}, function(err, passports) { should.not.exist(err); should.exist(passports); passports.length.should.be.ok; passports.forEach(function(p) { p.__cachedRelations.should.have.property('owner'); // The relation should be promoted as the 'owner' property p.should.have.property('owner'); // The __cachedRelations should be removed from json output p.toJSON().should.not.have.property('__cachedRelations'); var user = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(user); } else { should.exist(user); user.id.should.eql(p.ownerId); user.__cachedRelations.should.have.property('posts'); user.should.have.property('posts'); user.toJSON().should.have.property('posts').and.be.an.Array; user.__cachedRelations.posts.forEach(function(pp) { pp.userId.should.eql(user.id); }); } }); done(); }); }); it('should fetch Passport - Owner - empty Posts', function(done) { Passport.findOne({ where: { number: '4' }, include: { owner: 'posts' }}, function(err, passport) { should.not.exist(err); should.exist(passport); passport.__cachedRelations.should.have.property('owner'); // The relation should be promoted as the 'owner' property passport.should.have.property('owner'); // The __cachedRelations should be removed from json output passport.toJSON().should.not.have.property('__cachedRelations'); var user = passport.__cachedRelations.owner; should.exist(user); user.id.should.eql(passport.ownerId); user.__cachedRelations.should.have.property('posts'); user.should.have.property('posts'); user.toJSON().should.have.property('posts').and.be.an.Array().with .length(0); 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({ include: { owner: { posts: 'author' }}, }, function(err, passports) { should.not.exist(err); should.exist(passports); passports.length.should.be.ok; passports.forEach(function(p) { p.__cachedRelations.should.have.property('owner'); var user = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(user); } else { should.exist(user); user.id.should.eql(p.ownerId); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.posts.forEach(function(pp) { pp.should.have.property('id'); pp.userId.should.eql(user.id); pp.should.have.property('author'); pp.__cachedRelations.should.have.property('author'); var author = pp.__cachedRelations.author; author.id.should.eql(user.id); }); } }); 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(4); 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.have.property('id', undefined); // 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 support limit', function(done) { Passport.find({ include: { owner: { relation: 'posts', scope: { fields: ['title'], include: ['author'], order: 'title DESC', limit: 2, }, }, }, limit: 1, }, function(err, passports) { if (err) return done(err); passports.length.should.equal(1); passports[0].toJSON().owner.posts.length.should.equal(2); done(); }); }); it('should fetch Users with include scope on Posts - belongsTo', function(done) { Post.find({ include: { relation: 'author', scope: { fields: ['name'] }}, }, function(err, posts) { should.not.exist(err); should.exist(posts); posts.length.should.equal(5); var author = posts[0].author(); author.name.should.equal('User A'); author.should.have.property('id'); author.should.have.property('age', undefined); done(); }); }); it('should fetch Users with include scope on Posts - hasMany', 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 - hasMany', 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) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(user) { // The relation should be promoted as the 'owner' property user.should.have.property('posts'); user.should.have.property('passports'); var userObj = user.toJSON(); userObj.should.have.property('posts'); userObj.should.have.property('passports'); userObj.posts.should.be.an.instanceOf(Array); userObj.passports.should.be.an.instanceOf(Array); // The __cachedRelations should be removed from json output userObj.should.not.have.property('__cachedRelations'); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.should.have.property('passports'); user.__cachedRelations.posts.forEach(function(p) { p.userId.should.eql(user.id); }); user.__cachedRelations.passports.forEach(function(pp) { pp.ownerId.should.eql(user.id); }); }); done(); }); }); it('should fetch User - Posts AND Passports in relation syntax', function(done) { User.find({ include: [ { relation: 'posts', scope: { where: { title: 'Post A' }, }}, 'passports', ] }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(user) { // The relation should be promoted as the 'owner' property user.should.have.property('posts'); user.should.have.property('passports'); var userObj = user.toJSON(); userObj.should.have.property('posts'); userObj.should.have.property('passports'); userObj.posts.should.be.an.instanceOf(Array); userObj.passports.should.be.an.instanceOf(Array); // The __cachedRelations should be removed from json output userObj.should.not.have.property('__cachedRelations'); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.should.have.property('passports'); user.__cachedRelations.posts.forEach(function(p) { p.userId.should.eql(user.id); p.title.should.be.equal('Post A'); }); user.__cachedRelations.passports.forEach(function(pp) { pp.ownerId.should.eql(user.id); }); }); done(); }); }); it('should not fetch User - AccessTokens', function(done) { User.find({ include: ['accesstokens'] }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(user) { var userObj = user.toJSON(); userObj.should.not.have.property('accesstokens'); }); done(); }); }); it('should support hasAndBelongsToMany', function(done) { Assembly.create({ name: 'car' }, function(err, assembly) { Part.create({ partNumber: 'engine' }, function(err, part) { assembly.parts.add(part, function(err, data) { assembly.parts(function(err, parts) { should.not.exist(err); should.exists(parts); parts.length.should.equal(1); parts[0].partNumber.should.equal('engine'); // Create a part assembly.parts.create({ partNumber: 'door' }, function(err, part4) { Assembly.find({ include: 'parts' }, function(err, assemblies) { assemblies.length.should.equal(1); assemblies[0].parts().length.should.equal(2); done(); }); }); }); }); }); }); }); it('should fetch User - Profile (HasOne)', function(done) { User.find({ include: ['profile'] }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; var usersWithProfile = 0; users.forEach(function(user) { // The relation should be promoted as the 'owner' property user.should.have.property('profile'); var userObj = user.toJSON(); var profile = user.profile(); if (profile) { profile.should.be.an.instanceOf(Profile); usersWithProfile++; } else { (profile === null).should.be.true; } // The __cachedRelations should be removed from json output userObj.should.not.have.property('__cachedRelations'); user.__cachedRelations.should.have.property('profile'); if (user.__cachedRelations.profile) { user.__cachedRelations.profile.userId.should.eql(user.id); usersWithProfile++; } }); usersWithProfile.should.equal(2 * 2); done(); }); }); // Not implemented correctly, see: loopback-datasource-juggler/issues/166 // fixed by DB optimization it('should support include scope on hasAndBelongsToMany', function(done) { Assembly.find({ include: { relation: 'parts', scope: { where: { partNumber: 'engine' }, }}}, function(err, assemblies) { assemblies.length.should.equal(1); var parts = assemblies[0].parts(); parts.should.have.length(1); parts[0].partNumber.should.equal('engine'); done(); }); }); it('should save related items separately', function(done) { User.find({ include: 'posts', }) .then(function(users) { var posts = users[0].posts(); posts.should.have.length(3); return users[0].save(); }) .then(function(updatedUser) { return User.findById(updatedUser.id, { include: 'posts', }); }) .then(function(user) { var posts = user.posts(); posts.should.have.length(3); }) .then(done) .catch(done); }); describe('performance', function() { var all; beforeEach(function() { this.called = 0; var self = this; all = db.connector.all; db.connector.all = function(model, filter, options, cb) { self.called++; return all.apply(db.connector, arguments); }; }); afterEach(function() { db.connector.all = all; }); it('including belongsTo should make only 2 db calls', function(done) { var self = this; Passport.find({ include: 'owner' }, function(err, passports) { passports.length.should.be.ok; passports.forEach(function(p) { p.__cachedRelations.should.have.property('owner'); // The relation should be promoted as the 'owner' property p.should.have.property('owner'); // The __cachedRelations should be removed from json output p.toJSON().should.not.have.property('__cachedRelations'); var owner = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(owner); } else { should.exist(owner); owner.id.should.eql(p.ownerId); } }); self.called.should.eql(2); done(); }); }); it('including hasManyThrough should make only 3 db calls', function(done) { var self = this; Assembly.create([{ name: 'sedan' }, { name: 'hatchback' }, { name: 'SUV' }], function(err, assemblies) { Part.create([{ partNumber: 'engine' }, { partNumber: 'bootspace' }, { partNumber: 'silencer' }], function(err, parts) { async.each(parts, function(part, next) { async.each(assemblies, function(assembly, next) { if (assembly.name === 'SUV') { return next(); } if (assembly.name === 'hatchback' && part.partNumber === 'bootspace') { return next(); } assembly.parts.add(part, function(err, data) { next(); }); }, next); }, function(err) { self.called = 0; Assembly.find({ where: { name: { inq: ['sedan', 'hatchback', 'SUV'], }, }, include: 'parts', }, function(err, result) { should.not.exist(err); should.exists(result); result.length.should.equal(3); // Please note the order of assemblies is random var assemblies = {}; result.forEach(function(r) { assemblies[r.name] = r; }); //sedan assemblies.sedan.parts().should.have.length(3); //hatchback assemblies.hatchback.parts().should.have.length(2); //SUV assemblies.SUV.parts().should.have.length(0); self.called.should.eql(3); done(); }); }); }); }); }); it('including hasMany should make only 2 db calls', function(done) { var self = this; User.find({ include: ['posts', 'passports'] }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(user) { // The relation should be promoted as the 'owner' property user.should.have.property('posts'); user.should.have.property('passports'); var userObj = user.toJSON(); userObj.should.have.property('posts'); userObj.should.have.property('passports'); userObj.posts.should.be.an.instanceOf(Array); userObj.passports.should.be.an.instanceOf(Array); // The __cachedRelations should be removed from json output userObj.should.not.have.property('__cachedRelations'); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.should.have.property('passports'); user.__cachedRelations.posts.forEach(function(p) { p.userId.should.eql(user.id); }); user.__cachedRelations.passports.forEach(function(pp) { pp.ownerId.should.eql(user.id); }); }); self.called.should.eql(3); done(); }); }); it('should not make n+1 db calls in relation syntax', function(done) { var self = this; User.find({ include: [{ relation: 'posts', scope: { where: { title: 'Post A' }, }}, 'passports'] }, function(err, users) { should.not.exist(err); should.exist(users); users.length.should.be.ok; users.forEach(function(user) { // The relation should be promoted as the 'owner' property user.should.have.property('posts'); user.should.have.property('passports'); var userObj = user.toJSON(); userObj.should.have.property('posts'); userObj.should.have.property('passports'); userObj.posts.should.be.an.instanceOf(Array); userObj.passports.should.be.an.instanceOf(Array); // The __cachedRelations should be removed from json output userObj.should.not.have.property('__cachedRelations'); user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.should.have.property('passports'); user.__cachedRelations.posts.forEach(function(p) { p.userId.should.eql(user.id); p.title.should.be.equal('Post A'); }); user.__cachedRelations.passports.forEach(function(pp) { pp.ownerId.should.eql(user.id); }); }); self.called.should.eql(3); done(); }); }); }); }); function setup(done) { db = getSchema(); City = db.define('City'); Street = db.define('Street'); Building = db.define('Building'); User = db.define('User', { name: String, age: Number, }); Profile = db.define('Profile', { profileName: String, }); AccessToken = db.define('AccessToken', { token: String, }); Passport = db.define('Passport', { number: String, }); Post = db.define('Post', { title: String, }); Passport.belongsTo('owner', { model: User }); User.hasMany('passports', { foreignKey: 'ownerId' }); User.hasMany('posts', { foreignKey: 'userId' }); User.hasMany('accesstokens', { foreignKey: 'userId', options: { disableInclude: true }, }); Profile.belongsTo('user', { model: User }); User.hasOne('profile', { foreignKey: 'userId' }); Post.belongsTo('author', { model: User, foreignKey: 'userId' }); Assembly = db.define('Assembly', { name: String, }); Part = db.define('Part', { partNumber: String, }); Assembly.hasAndBelongsToMany(Part); Part.hasAndBelongsToMany(Assembly); db.automigrate(function() { var createdUsers = []; var createdPassports = []; var createdProfiles = []; var createdPosts = []; 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(); createAccessTokens(); } ); } function createAccessTokens() { clearAndCreate( AccessToken, [ { token: '1', userId: createdUsers[0].id }, { token: '2', userId: createdUsers[1].id }, ], function(items) {} ); } function createPassports() { clearAndCreate( Passport, [ { number: '1', ownerId: createdUsers[0].id }, { number: '2', ownerId: createdUsers[1].id }, { number: '3' }, { number: '4', ownerId: createdUsers[2].id }, ], function(items) { createdPassports = items; createPosts(); } ); } function createProfiles() { clearAndCreate( Profile, [ { profileName: 'Profile A', userId: createdUsers[0].id }, { profileName: 'Profile B', userId: createdUsers[1].id }, { profileName: 'Profile Z' }, ], function(items) { createdProfiles = items; done(); } ); } 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; createProfiles(); } ); } }); } 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++; } } describe('Model instance with included relation .toJSON()', function() { var db, ChallengerModel, GameParticipationModel, ResultModel; before(function(done) { db = new DataSource({ connector: 'memory' }); ChallengerModel = db.createModel('Challenger', { name: String, }, { relations: { gameParticipations: { type: 'hasMany', model: 'GameParticipation', foreignKey: '', }, }, } ); GameParticipationModel = db.createModel('GameParticipation', { date: Date, }, { relations: { challenger: { type: 'belongsTo', model: 'Challenger', foreignKey: '', }, results: { type: 'hasMany', model: 'Result', foreignKey: '', }, }, } ); ResultModel = db.createModel('Result', { points: Number, }, { relations: { gameParticipation: { type: 'belongsTo', model: 'GameParticipation', foreignKey: '', }, }, }); async.waterfall([ createChallengers, createGameParticipations, createResults], function(err) { done(err); }); }); function createChallengers(callback) { ChallengerModel.create([{ name: 'challenger1' }, { name: 'challenger2' }], callback); } function createGameParticipations(challengers, callback) { GameParticipationModel.create([ { challengerId: challengers[0].id, date: Date.now() }, { challengerId: challengers[0].id, date: Date.now() }, ], callback); } function createResults(gameParticipations, callback) { ResultModel.create([ { gameParticipationId: gameParticipations[0].id, points: 10 }, { gameParticipationId: gameParticipations[0].id, points: 20 }, ], callback); } it('should recursively serialize objects', function(done) { var filter = { include: { gameParticipations: 'results' }}; ChallengerModel.find(filter, function(err, challengers) { var levelOneInclusion = challengers[0].toJSON().gameParticipations[0]; assert(levelOneInclusion.__data === undefined, '.__data of a level 1 inclusion is undefined.'); var levelTwoInclusion = challengers[0].toJSON().gameParticipations[0].results[0]; assert(levelTwoInclusion.__data === undefined, '__data of a level 2 inclusion is undefined.'); done(); }); }); });