From d29bec72a8c7aa457f2c5f83acf3334272f9e248 Mon Sep 17 00:00:00 2001 From: Samarpan Bhattacharya Date: Fri, 9 Sep 2022 13:43:18 +0900 Subject: [PATCH] feat: add capability for insert multiple rows in single query Signed-off-by: Samarpan Bhattacharya --- lib/connectors/memory.js | 25 + lib/dao.js | 236 ++++++ package.json | 3 +- test/basic-querying.test.js | 1135 +++++++++++++++++++++++++- test/common_test.js | 60 +- test/crud-with-options.test.js | 15 +- test/helpers/context-test-helpers.js | 18 +- test/persistence-hooks.suite.js | 304 +++++++ types/persisted-model.d.ts | 21 +- 9 files changed, 1776 insertions(+), 41 deletions(-) diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 6236c6fc..5c6be09c 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -47,6 +47,8 @@ function Memory(m, settings) { util.inherits(Memory, Connector); +Memory.prototype.multiInsertSupported = true; + Memory.prototype.getDefaultIdType = function() { return Number; }; @@ -277,6 +279,29 @@ Memory.prototype.create = function create(model, data, options, callback) { }); }; +Memory.prototype.createAll = function create(model, dataArray, options, callback) { + const returnArr = []; + async.eachSeries( + dataArray, + (data, cb) => { + this._createSync(model, data, (err, id) => { + if (err) { + return process.nextTick(function() { + cb(err); + }); + } + const returnData = Object.assign({}, data); + this.setIdValue(model, returnData, id); + returnArr.push(returnData); + this.saveToFile(id, cb); + }); + }, + (err) => { + callback(err, returnArr); + }, + ); +}; + Memory.prototype.updateOrCreate = function(model, data, options, callback) { const self = this; this.exists(model, self.getIdValue(model, data), options, function(err, exists) { diff --git a/lib/dao.js b/lib/dao.js index 275a0008..6134cb2e 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -457,6 +457,242 @@ DataAccessObject.create = function(data, options, cb) { return cb.promise; }; +/** + * Create an instances of Model with given data array and save to the attached data source. Callback is optional. + * Example: + *```js + * User.createAll([{first: 'Joe', last: 'Bob'},{first: 'Tom', last: 'Cat'}], function(err, users) { + * console.log(users[0] instanceof User); // true + * }); + * ``` + * Note: You must include a callback and use the created models provided in the callback if your code depends on your model being + * saved or having an ID. + * + * @param {Object} [dataArray] Optional data object with array of records + * @param {Object} [options] Options for create + * @param {Function} [cb] Callback function called with these arguments: + * - err (null or Error) + * - instance (null or Models) + */ +DataAccessObject.createAll = function(dataArray, options, cb) { + const connectionPromise = stillConnecting(this.getDataSource(), this, arguments); + if (connectionPromise) { + return connectionPromise; + } + + let Model = this; + const connector = Model.getConnector(); + if (!connector.multiInsertSupported) { + // If multi insert is not supported, then, revert to create method + // Array is handled in create method already in legacy code + // This ensures backwards compatibility + return this.create(dataArray, options, cb); + } + assert( + typeof connector.createAll === 'function', + 'createAll() must be implemented by the connector', + ); + + if (options === undefined && cb === undefined) { + if (typeof dataArray === 'function') { + // create(cb) + cb = dataArray; + dataArray = []; + } + } else if (cb === undefined) { + if (typeof options === 'function') { + // create(data, cb); + cb = options; + options = {}; + } + } + + dataArray = dataArray || []; + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + assert(typeof dataArray === 'object' && dataArray.length, + 'The data argument must be an array with length > 0'); + assert(typeof options === 'object', 'The options argument must be an object'); + assert(typeof cb === 'function', 'The cb argument must be a function'); + + const validationPromises = []; + for (let index = 0; index < dataArray.length; index++) { + const data = dataArray[index]; + const hookState = {}; + + const enforced = {}; + let obj; + + try { + obj = new Model(data); + + this.applyProperties(enforced, obj); + obj.setAttributes(enforced); + } catch (err) { + process.nextTick(function() { + cb(err); + }); + return cb.promise; + } + + Model = this.lookupModel(data); // data-specific + if (Model !== obj.constructor) obj = new Model(data); + + const context = { + Model: Model, + instance: obj, + isNewInstance: true, + hookState: hookState, + options: options, + }; + + const promise = new Promise((resolve, reject) => { + Model.notifyObserversOf('before save', context, function(err) { + if (err) return reject({ + error: err, + data: obj, + }); + + const d = obj.toObject(true); + + // options has precedence on model-setting + if (options.validate === false) { + return resolve(obj); + } + + // only when options.validate is not set, take model-setting into consideration + if ( + options.validate === undefined && + Model.settings.automaticValidation === false + ) { + return resolve(obj); + } + + // validation required + obj.isValid( + function(valid) { + if (valid) { + resolve(obj); + } else { + reject({ + error: new ValidationError(obj), + data: obj, + }); + } + }, + d, + options, + ); + }); + }); + validationPromises.push(promise); + } + + Promise.all(validationPromises).then((objArray) => { + const values = []; + const valMap = new Map(); + objArray.forEach((obj) => { + const val = Model._sanitizeData(obj.toObject(true), options); + values.push(val); + valMap.set(obj, applyDefaultsOnWrites(val, Model.definition)); + }); + + function createCallback(err, savedArray) { + if (err) { + return cb(err, objArray); + } + + const context = values.map((val) => { + return { + Model: Model, + data: val, + isNewInstance: true, + hookState: {}, + options: options, + }; + }); + Model.notifyObserversOf('loaded', context, function(err) { + if (err) return cb(err); + + const afterSavePromises = []; + savedArray.map((obj) => { + const dataModel = new Model(obj); + + let afterSavePromise; + if (options.notify !== false) { + const context = { + Model: Model, + instance: dataModel, + isNewInstance: true, + hookState: {}, + options: options, + }; + + afterSavePromise = new Promise((resolve, reject) => { + Model.notifyObserversOf('after save', context, function(err) { + if (err) { + reject(err); + } else { + resolve(dataModel); + } + }); + }); + afterSavePromises.push(afterSavePromise); + } else { + afterSavePromises.push(Promise.resolve(dataModel)); + } + }); + + Promise.all(afterSavePromises).then(saved => { + cb(null, saved); + }).catch(err => { + cb(err, objArray); + }); + }); + } + + context = objArray.map(obj => { + return { + Model: Model, + data: valMap.get(obj), + isNewInstance: true, + currentInstance: obj, + hookState: {}, + options: options, + }; + }); + const persistPromise = new Promise((resolve, reject) => { + Model.notifyObserversOf('persist', context, function(err, ctx) { + if (err) return reject(err); + + const objDataArray = ctx + .map((obj) => { + return obj.currentInstance.constructor._forDB(obj.data); + }) + .filter((objData) => !!objData); + resolve(objDataArray); + }); + }); + persistPromise.then((objDataArray) => { + invokeConnectorMethod( + connector, + 'createAll', + Model, + [objDataArray], + options, + createCallback, + ); + }).catch((err) => { + err && cb(err); + }); + }).catch((err) => { + err && cb(err.error, err.data); + }); + + return cb.promise; +}; + // Implementation of applyDefaultOnWrites property function applyDefaultsOnWrites(obj, modelDefinition) { for (const key in modelDefinition.properties) { diff --git a/package.json b/package.json index 39e36768..740f94fe 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "lint": "eslint .", "build": "npm run build-ts-types", "build-ts-types": "tsc -p tsconfig.json --outDir dist", + "pretest": "npm run build", "test": "nyc mocha", - "posttest": "npm run tsc && npm run lint" + "posttest": "npm run lint" }, "devDependencies": { "@commitlint/cli": "^17.2.0", diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index 5c440682..2577c85b 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -984,6 +984,974 @@ describe('basic-querying', function() { }); }); + describe('find after createAll', function() { + before(function seedData(done) { + seed(done, true); + }); + + before(function setupDelayingLoadedHook() { + User.observe('loaded', nextAfterDelay); + }); + + after(function removeDelayingLoadHook() { + User.removeObserver('loaded', nextAfterDelay); + }); + + it('should query collection', function(done) { + User.find(function(err, users) { + should.exists(users); + should.not.exists(err); + users.should.have.lengthOf(6); + done(); + }); + }); + + it('should query limited collection', function(done) { + User.find({limit: 3}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.should.have.lengthOf(3); + done(); + }); + }); + + bdd.itIf( + connectorCapabilities.supportPagination !== false, + 'should query collection with skip & ' + 'limit', + function(done) { + User.find({skip: 1, limit: 4, order: 'seq'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users[0].seq.should.be.eql(1); + users.should.have.lengthOf(4); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.supportPagination !== false, + 'should query collection with offset & ' + 'limit', + function(done) { + User.find({offset: 2, limit: 3, order: 'seq'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users[0].seq.should.be.eql(2); + users.should.have.lengthOf(3); + done(); + }); + }, + ); + + it('should query filtered collection', function(done) { + User.find({where: {role: 'lead'}}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.should.have.lengthOf(2); + done(); + }); + }); + + bdd.itIf( + connectorCapabilities.adhocSort !== false, + 'should query collection sorted by numeric ' + 'field', + function(done) { + User.find({order: 'order'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.forEach(function(u, i) { + u.order.should.eql(i + 1); + }); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort !== false, + 'should query collection desc sorted by ' + 'numeric field', + function(done) { + User.find({order: 'order DESC'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.forEach(function(u, i) { + u.order.should.eql(users.length - i); + }); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort !== false, + 'should query collection sorted by string ' + 'field', + function(done) { + User.find({order: 'name'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.shift().name.should.equal('George Harrison'); + users.shift().name.should.equal('John Lennon'); + users.pop().name.should.equal('Stuart Sutcliffe'); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort !== false, + 'should query collection desc sorted by ' + 'string field', + function(done) { + User.find({order: 'name DESC'}, function(err, users) { + should.exists(users); + should.not.exists(err); + users.pop().name.should.equal('George Harrison'); + users.pop().name.should.equal('John Lennon'); + users.shift().name.should.equal('Stuart Sutcliffe'); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort !== false, + 'should query sorted desc by order integer field' + + ' even though there is an async model loaded hook', + function(done) { + User.find({order: 'order DESC'}, function(err, users) { + if (err) return done(err); + should.exists(users); + const order = users.map(function(u) { + return u.order; + }); + order.should.eql([6, 5, 4, 3, 2, 1]); + done(); + }); + }, + ); + + it('should support "and" operator that is satisfied', function(done) { + User.find( + {where: {and: [{name: 'John Lennon'}, {role: 'lead'}]}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + done(); + }, + ); + }); + + it('should support "and" operator that is not satisfied', function(done) { + User.find( + {where: {and: [{name: 'John Lennon'}, {role: 'member'}]}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }, + ); + }); + + bdd.itIf( + connectorCapabilities.supportOrOperator !== false, + 'should support "or" that is ' + 'satisfied', + function(done) { + User.find( + {where: {or: [{name: 'John Lennon'}, {role: 'lead'}]}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 2); + done(); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.supportOrOperator !== false, + 'should support "or" operator that is ' + 'not satisfied', + function(done) { + User.find( + {where: {or: [{name: 'XYZ'}, {role: 'Hello1'}]}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.nullDataValueExists !== false, + 'should support where date "neq" null', + function(done) { + User.find({where: {birthday: {neq: null}}}, function(err, users) { + should.not.exist(err); + should.exist(users); + users.should.have.property('length', 2); + should(users[0].name).be.oneOf('John Lennon', 'Paul McCartney'); + should(users[1].name).be.oneOf('John Lennon', 'Paul McCartney'); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.nullDataValueExists !== false, + 'should support where date is null', + function(done) { + User.find({where: {birthday: null}}, function(err, users) { + should.not.exist(err); + should.exist(users); + users.should.have.property('length', 4); + done(); + }); + }, + ); + + it('should support date "gte" that is satisfied', function(done) { + User.find( + {where: {birthday: {gte: new Date('1980-12-08')}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('John Lennon'); + done(); + }, + ); + }); + + it('should support date "gt" that is not satisfied', function(done) { + User.find( + {where: {birthday: {gt: new Date('1980-12-08')}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }, + ); + }); + + it('should support date "gt" that is satisfied', function(done) { + User.find( + {where: {birthday: {gt: new Date('1980-12-07')}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('John Lennon'); + done(); + }, + ); + }); + + bdd.itIf( + connectorCapabilities.cloudantCompatible !== false, + 'should support date "lt" that is satisfied', + function(done) { + User.find( + {where: {birthday: {lt: new Date('1980-12-07')}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Paul McCartney'); + done(); + }, + ); + }, + ); + + it('should support number "gte" that is satisfied', function(done) { + User.find({where: {order: {gte: 3}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 4); + users + .map((u) => u.name) + .should.containDeep([ + 'George Harrison', + 'Ringo Starr', + 'Pete Best', + 'Stuart Sutcliffe', + ]); + done(); + }); + }); + + it('should support number "gt" that is not satisfied', function(done) { + User.find({where: {order: {gt: 6}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support number "gt" that is satisfied', function(done) { + User.find({where: {order: {gt: 5}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Ringo Starr'); + done(); + }); + }); + + it('should support number "lt" that is satisfied', function(done) { + User.find({where: {order: {lt: 2}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 1); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + + bdd.itIf( + connectorCapabilities.ignoreUndefinedConditionValue !== false, + 'should support number "gt" ' + 'that is satisfied by null value', + function(done) { + User.find( + {order: 'seq', where: {order: {gt: null}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.ignoreUndefinedConditionValue !== false, + 'should support number "lt" ' + 'that is not satisfied by null value', + function(done) { + User.find({where: {order: {lt: null}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.ignoreUndefinedConditionValue !== false, + 'should support string "gte" ' + 'that is satisfied by null value', + function(done) { + User.find( + {order: 'seq', where: {name: {gte: null}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.cloudantCompatible !== false, + 'should support string "gte" that is satisfied', + function(done) { + User.find( + {where: {name: {gte: 'Paul McCartney'}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 4); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.greaterThanOrEqual('Paul McCartney'); + } + done(); + }, + ); + }, + ); + + it('should support string "gt" that is not satisfied', function(done) { + User.find({where: {name: {gt: 'xyz'}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + bdd.itIf( + connectorCapabilities.cloudantCompatible !== false, + 'should support string "gt" that is satisfied', + function(done) { + User.find( + {where: {name: {gt: 'Paul McCartney'}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.greaterThan('Paul McCartney'); + } + done(); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.cloudantCompatible !== false, + 'should support string "lt" that is satisfied', + function(done) { + User.find( + {where: {name: {lt: 'Paul McCartney'}}}, + function(err, users) { + should.not.exist(err); + users.should.have.property('length', 2); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.lessThan('Paul McCartney'); + } + done(); + }, + ); + }, + ); + + it('should support boolean "gte" that is satisfied', function(done) { + User.find({where: {vip: {gte: true}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.oneOf([ + 'John Lennon', + 'Stuart Sutcliffe', + 'Paul McCartney', + ]); + users[ix].vip.should.be.true(); + } + done(); + }); + }); + + it('should support boolean "gt" that is not satisfied', function(done) { + User.find({where: {vip: {gt: true}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 0); + done(); + }); + }); + + it('should support boolean "gt" that is satisfied', function(done) { + User.find({where: {vip: {gt: false}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 3); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.oneOf([ + 'John Lennon', + 'Stuart Sutcliffe', + 'Paul McCartney', + ]); + users[ix].vip.should.be.true(users[ix].name + ' should be VIP'); + } + done(); + }); + }); + + it('should support boolean "lt" that is satisfied', function(done) { + User.find({where: {vip: {lt: true}}}, function(err, users) { + should.not.exist(err); + users.should.have.property('length', 2); + for (let ix = 0; ix < users.length; ix++) { + users[ix].name.should.be.oneOf(['Ringo Starr', 'George Harrison']); + users[ix].vip.should.be.false( + users[ix].name + ' should not be VIP', + ); + } + done(); + }); + }); + + bdd.itIf( + connectorCapabilities.supportInq, + 'supports non-empty inq', + function() { + // note there is no record with seq=100 + return User.find({where: {seq: {inq: [0, 1, 100]}}}).then( + (result) => { + const seqsFound = result.map((r) => r.seq); + should(seqsFound).eql([0, 1]); + }, + ); + }, + ); + + bdd.itIf( + connectorCapabilities.supportInq, + 'supports empty inq', + function() { + return User.find({where: {seq: {inq: []}}}).then((result) => { + const seqsFound = result.map((r) => r.seq); + should(seqsFound).eql([]); + }); + }, + ); + + const itWhenIlikeSupported = connectorCapabilities.ilike; + bdd.describeIf(itWhenIlikeSupported, 'ilike', function() { + it('should support "like" that is satisfied', function(done) { + User.find({where: {name: {like: 'John'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should sanitize invalid usage of like', async () => { + const users = await User.find({where: {tag: {like: '['}}}); + users.should.have.length(1); + users[0].should.have.property('name', 'John Lennon'); + }); + + it('should support "like" that is not satisfied', function(done) { + User.find({where: {name: {like: 'Bob'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(0); + done(); + }); + }); + it('should support "ilike" that is satisfied', function(done) { + User.find({where: {name: {ilike: 'john'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + it('should support "ilike" that is not satisfied', function(done) { + User.find({where: {name: {ilike: 'bob'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(0); + done(); + }); + }); + + it('should properly sanitize invalid ilike filter', async () => { + const users = await User.find({where: {name: {ilike: '['}}}); + users.should.be.empty(); + }); + }); + + const itWhenNilikeSupported = connectorCapabilities.nilike !== false; + bdd.describeIf(itWhenNilikeSupported, 'nilike', function() { + it('should support "nlike" that is satisfied', function(done) { + User.find({where: {name: {nlike: 'John'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(5); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + + it('should support "nilike" that is satisfied', function(done) { + User.find({where: {name: {nilike: 'john'}}}, function(err, users) { + if (err) return done(err); + users.length.should.equal(5); + users[0].name.should.equal('Paul McCartney'); + done(); + }); + }); + }); + + describe('geo queries', function() { + describe('near filter', function() { + it('supports a basic "near" query', function(done) { + User.find( + { + where: { + addressLoc: { + near: {lat: 29.9, lng: -90.07}, + }, + }, + }, + function(err, users) { + if (err) done(err); + users.should.have.property('length', 3); + users[0].name.should.equal('John Lennon'); + users[0].should.be.instanceOf(User); + users[0].addressLoc.should.not.be.null(); + done(); + }, + ); + }); + + it('supports "near" inside a coumpound query with "and"', function(done) { + User.find( + { + where: { + and: [ + { + addressLoc: { + near: {lat: 29.9, lng: -90.07}, + }, + }, + { + vip: true, + }, + ], + }, + }, + function(err, users) { + if (err) done(err); + users.should.have.property('length', 2); + users[0].name.should.equal('John Lennon'); + users[0].should.be.instanceOf(User); + users[0].addressLoc.should.not.be.null(); + users[0].vip.should.be.true(); + done(); + }, + ); + }); + + it('supports "near" inside a complex coumpound query with multiple "and"', function(done) { + User.find( + { + where: { + and: [ + { + and: [ + { + addressLoc: { + near: {lat: 29.9, lng: -90.07}, + }, + }, + { + order: 2, + }, + ], + }, + { + vip: true, + }, + ], + }, + }, + function(err, users) { + if (err) done(err); + users.should.have.property('length', 1); + users[0].name.should.equal('John Lennon'); + users[0].should.be.instanceOf(User); + users[0].addressLoc.should.not.be.null(); + users[0].vip.should.be.true(); + users[0].order.should.equal(2); + done(); + }, + ); + }); + + it('supports multiple "near" queries with "or"', function(done) { + User.find( + { + where: { + or: [ + { + addressLoc: { + near: {lat: 29.9, lng: -90.04}, + maxDistance: 300, + }, + }, + { + addressLoc: { + near: {lat: 22.97, lng: -88.03}, + maxDistance: 50, + }, + }, + ], + }, + }, + function(err, users) { + if (err) done(err); + users.should.have.property('length', 2); + users[0].addressLoc.should.not.be.null(); + users[0].name.should.equal('Paul McCartney'); + users[0].should.be.instanceOf(User); + users[1].addressLoc.should.not.equal(null); + users[1].name.should.equal('John Lennon'); + done(); + }, + ); + }); + + it( + 'supports multiple "near" queries with "or" ' + + 'inside a coumpound query with "and"', + function(done) { + User.find( + { + where: { + and: [ + { + or: [ + { + addressLoc: { + near: {lat: 29.9, lng: -90.04}, + maxDistance: 300, + }, + }, + { + addressLoc: { + near: {lat: 22.7, lng: -89.03}, + maxDistance: 50, + }, + }, + ], + }, + { + vip: true, + }, + ], + }, + }, + function(err, users) { + if (err) done(err); + users.should.have.property('length', 1); + users[0].addressLoc.should.not.be.null(); + users[0].name.should.equal('John Lennon'); + users[0].should.be.instanceOf(User); + users[0].vip.should.be.true(); + done(); + }, + ); + }, + ); + }); + }); + + it('should only include fields as specified', function(done) { + let remaining = 0; + + function sample(fields) { + return { + expect: function(arr) { + remaining++; + User.find({fields: fields}, function(err, users) { + remaining--; + if (err) return done(err); + + should.exists(users); + + if (remaining === 0) { + done(); + } + + users.forEach(function(user) { + const obj = user.toObject(); + + Object.keys(obj).forEach(function(key) { + // if the obj has an unexpected value + if (obj[key] !== undefined && arr.indexOf(key) === -1) { + console.log('Given fields:', fields); + console.log('Got:', key, obj[key]); + console.log('Expected:', arr); + throw new Error( + 'should not include data for key: ' + key, + ); + } + }); + }); + }); + }, + }; + } + + sample({name: true}).expect(['name']); + sample({name: false}).expect([ + 'id', + 'seq', + 'email', + 'role', + 'order', + 'birthday', + 'vip', + 'address', + 'friends', + 'addressLoc', + 'tag', + ]); + sample({name: false, id: true}).expect(['id']); + sample({id: true}).expect(['id']); + sample('id').expect(['id']); + sample(['id']).expect(['id']); + sample(['email']).expect(['email']); + }); + + it('should ignore non existing properties when excluding', function(done) { + return User.find({fields: {notExist: false}}, (err, users) => { + if (err) return done(err); + users.forEach((user) => { + switch ( + user.seq // all fields depending on each document + ) { + case 0: + case 1: + Object.keys(user.__data).should.containDeep([ + 'id', + 'seq', + 'name', + 'order', + 'role', + 'birthday', + 'vip', + 'address', + 'friends', + ]); + break; + case 4: // seq 4 + Object.keys(user.__data).should.containDeep([ + 'id', + 'seq', + 'name', + 'order', + ]); + break; + default: // Other records, seq 2, 3, 5 + Object.keys(user.__data).should.containDeep([ + 'id', + 'seq', + 'name', + 'order', + 'vip', + ]); + } + }); + done(); + }); + }); + + const describeWhenNestedSupported = connectorCapabilities.nestedProperty; + bdd.describeIf( + describeWhenNestedSupported, + 'query with nested property', + function() { + it('should support nested property in query', function(done) { + User.find( + {where: {'address.city': 'San Jose'}}, + function(err, users) { + if (err) return done(err); + users.length.should.be.equal(1); + for (let i = 0; i < users.length; i++) { + users[i].address.city.should.be.eql('San Jose'); + } + done(); + }, + ); + }); + + it('should support nested property with regex over arrays in query', function(done) { + User.find( + {where: {'friends.name': {regexp: /^Ringo/}}}, + function(err, users) { + if (err) return done(err); + users.length.should.be.equal(2); + const expectedUsers = ['John Lennon', 'Paul McCartney']; + expectedUsers.indexOf(users[0].name).should.not.equal(-1); + expectedUsers.indexOf(users[1].name).should.not.equal(-1); + done(); + }, + ); + }); + + it('should support nested property with gt in query', function(done) { + User.find( + {where: {'address.city': {gt: 'San'}}}, + function(err, users) { + if (err) return done(err); + users.length.should.be.equal(2); + for (let i = 0; i < users.length; i++) { + users[i].address.state.should.be.eql('CA'); + } + done(); + }, + ); + }); + + bdd.itIf( + connectorCapabilities.adhocSort, + 'should support nested property for order in query', + function(done) { + User.find( + {where: {'address.state': 'CA'}, order: 'address.city DESC'}, + function(err, users) { + if (err) return done(err); + users.length.should.be.equal(2); + users[0].address.city.should.be.eql('San Mateo'); + users[1].address.city.should.be.eql('San Jose'); + done(); + }, + ); + }, + ); + + it('should support multi-level nested array property in query', function(done) { + User.find( + {where: {'address.tags.tag': 'business'}}, + function(err, users) { + if (err) return done(err); + users.length.should.be.equal(1); + users[0].address.tags[0].tag.should.be.equal('business'); + users[0].address.tags[1].tag.should.be.equal('rent'); + done(); + }, + ); + }); + + it('should fail when querying with an invalid value for a type', function(done) { + User.find({where: {birthday: 'notadate'}}, function(err, users) { + should.exist(err); + err.message.should.equal('Invalid date: notadate'); + done(); + }); + }); + }, + ); + + it('preserves empty values from the database', async () => { + // https://github.com/strongloop/loopback-datasource-juggler/issues/1692 + + // Initially, all Players were always active, no property was needed + const Player = db.define('Player', {name: String}); + + await db.automigrate('Player'); + const created = await Player.create({name: 'Pen'}); + + // Later on, we decide to introduce `active` property + Player.defineProperty('active', { + type: Boolean, + default: false, + }); + await db.autoupdate('Player'); + + // And query existing data + const found = await Player.findOne(); + should(found.toObject().active).be.oneOf([ + undefined, // databases supporting `undefined` value + null, // databases representing `undefined` as `null` (e.g. SQL) + ]); + }); + + describe('check __parent relationship in embedded models', () => { + createTestSetupForParentRef(() => User.modelBuilder); + it('should fill the parent in embedded model', async () => { + const user = await User.findOne({where: {name: 'John Lennon'}}); + user.should.have.property('address'); + should(user.address).have.property('__parent'); + should(user.address.__parent).be.instanceof(User).and.equal(user); + }); + it('should assign the container model as parent in list property', async () => { + const user = await User.findOne({where: {name: 'John Lennon'}}); + user.should.have.property('friends'); + should(user.friends).have.property('parent'); + should(user.friends.parent).be.instanceof(User).and.equal(user); + }); + it('should have the complete chain of parents available in embedded list element', async () => { + const user = await User.findOne({where: {name: 'John Lennon'}}); + user.friends.forEach((userFriend) => { + userFriend.should.have.property('__parent'); + should(userFriend.__parent).equal(user); + }); + }); + }); + }); + describe('count', function() { before(seed); @@ -1006,6 +1974,30 @@ describe('basic-querying', function() { }); }); + describe('count after createAll', function() { + before(function seedData(done) { + seed(done, true); + }); + + it('should query total count', function(done) { + User.count(function(err, n) { + should.not.exist(err); + should.exist(n); + n.should.equal(6); + done(); + }); + }); + + it('should query filtered count', function(done) { + User.count({role: 'lead'}, function(err, n) { + should.not.exist(err); + should.exist(n); + n.should.equal(2); + done(); + }); + }); + }); + describe('findOne', function() { before(seed); @@ -1065,6 +2057,85 @@ describe('basic-querying', function() { }); }); + describe('findOne after createAll', function() { + before(function seedData(done) { + seed(done, true); + }); + + bdd.itIf( + connectorCapabilities.cloudantCompatible !== false, + 'should find first record (default sort by id)', + function(done) { + User.all({order: 'id'}, function(err, users) { + User.findOne(function(e, u) { + should.not.exist(e); + should.exist(u); + u.id.toString().should.equal(users[0].id.toString()); + done(); + }); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort, + 'should find first record', + function(done) { + User.findOne({order: 'order'}, function(e, u) { + should.not.exist(e); + should.exist(u); + u.order.should.equal(1); + u.name.should.equal('Paul McCartney'); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort, + 'should find last record', + function(done) { + User.findOne({order: 'order DESC'}, function(e, u) { + should.not.exist(e); + should.exist(u); + u.order.should.equal(6); + u.name.should.equal('Ringo Starr'); + done(); + }); + }, + ); + + bdd.itIf( + connectorCapabilities.adhocSort, + 'should find last record in filtered set', + function(done) { + User.findOne( + { + where: {role: 'lead'}, + order: 'order DESC', + }, + function(e, u) { + should.not.exist(e); + should.exist(u); + u.order.should.equal(2); + u.name.should.equal('John Lennon'); + done(); + }, + ); + }, + ); + + it('should work even when find by id', function(done) { + User.findOne(function(e, u) { + User.findOne({where: {id: u.id}}, function(err, user) { + should.not.exist(err); + should.exist(user); + done(); + }); + }); + }); + }); + describe('exists', function() { before(seed); @@ -1091,6 +2162,34 @@ describe('basic-querying', function() { }); }); + describe('exists after createAll', function() { + before(function seedData(done) { + seed(done, true); + }); + + it('should check whether record exist', function(done) { + User.findOne(function(e, u) { + User.exists(u.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + exists.should.be.ok; + done(); + }); + }); + }); + + it('should check whether record not exist', function(done) { + const unknownId = uid.fromConnector(db) || 42; + User.destroyAll(function() { + User.exists(unknownId, function(err, exists) { + should.not.exist(err); + exists.should.not.be.ok; + done(); + }); + }); + }); + }); + describe('updateAll', function() { let numAndDateModel, numAndDateArrayModel; @@ -1337,7 +2436,7 @@ describe.skip('queries', function() { }); }); -function seed(done) { +function seed(done, useCreateAll = false) { const beatles = [ { seq: 0, @@ -1398,13 +2497,35 @@ function seed(done) { {seq: 4, name: 'Pete Best', order: 4, birthday: null}, {seq: 5, name: 'Stuart Sutcliffe', order: 3, birthday: null, vip: true}, ]; + if (useCreateAll) { + seedUsingCreateAll(beatles, done); + } else { + seedUsingCreate(beatles, done); + } +} - async.series([ - User.destroyAll.bind(User), - function(cb) { - async.each(beatles, User.create.bind(User), cb); - }, - ], done); +function seedUsingCreate(beatles, done) { + async.series( + [ + User.destroyAll.bind(User), + function(cb) { + async.each(beatles, User.create.bind(User), cb); + }, + ], + done, + ); +} + +function seedUsingCreateAll(beatles, done) { + async.series( + [ + User.destroyAll.bind(User), + function(cb) { + User.createAll(beatles, cb); + }, + ], + done, + ); } function nextAfterDelay(ctx, next) { diff --git a/test/common_test.js b/test/common_test.js index 957fad8d..9a81e756 100644 --- a/test/common_test.js +++ b/test/common_test.js @@ -69,27 +69,6 @@ Object.defineProperty(module.exports, 'skip', { value: skip, }); -function clearAndCreate(model, data, callback) { - const createdItems = []; - model.destroyAll(function() { - nextItem(null, null); - }); - - let 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++; - } -} - /* eslint-disable mocha/handle-done-callback */ function testOrm(dataSource) { const requestsAreCounted = dataSource.name !== 'mongodb'; @@ -251,6 +230,45 @@ function testOrm(dataSource) { }); }); + it('should save objects when createAll is invoked', function(test) { + const title = 'Initial title', + title2 = 'Hello world', + date = new Date(); + + Post.createAll( + [{ + title: title, + date: date, + }], + function(err, objs) { + const obj = objs[0]; + test.ok(obj.id, 'Object id should present'); + test.equals(obj.title, title); + // test.equals(obj.date, date); + obj.title = title2; + test.ok(obj.propertyChanged('title'), 'Title changed'); + obj.save(function(err, obj) { + test.equal(obj.title, title2); + test.ok(!obj.propertyChanged('title')); + + const p = new Post({title: 1}); + p.title = 2; + p.save(function(err, obj) { + test.ok(!p.propertyChanged('title')); + p.title = 3; + test.ok(p.propertyChanged('title')); + test.equal(p.title_was, 2); + p.save(function() { + test.equal(p.title_was, 3); + test.ok(!p.propertyChanged('title')); + test.done(); + }); + }); + }); + }, + ); + }); + it('should create object with initial data', function(test) { const title = 'Initial title', date = new Date; diff --git a/test/crud-with-options.test.js b/test/crud-with-options.test.js index f258afbf..c1a9bd5c 100644 --- a/test/crud-with-options.test.js +++ b/test/crud-with-options.test.js @@ -632,10 +632,13 @@ function seed(done) { {id: 5, seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true}, ]; - async.series([ - User.destroyAll.bind(User), - function(cb) { - async.each(beatles, User.create.bind(User), cb); - }, - ], done); + async.series( + [ + User.destroyAll.bind(User), + function(cb) { + User.createAll(beatles, cb); + }, + ], + done, + ); } diff --git a/test/helpers/context-test-helpers.js b/test/helpers/context-test-helpers.js index fdc87346..41f48f60 100644 --- a/test/helpers/context-test-helpers.js +++ b/test/helpers/context-test-helpers.js @@ -25,8 +25,16 @@ ContextRecorder.prototype.recordAndNext = function(transformFm) { transformFm(context); } - context = deepCloneToObject(context); - context.hookState.test = true; + if (Array.isArray(context)) { + context = context.map(ctx => { + const ctxCopy = deepCloneToObject(ctx); + ctxCopy.hookState.test = true; + return ctxCopy; + }); + } else { + context = deepCloneToObject(context); + context.hookState.test = true; + } if (typeof self.records === 'string') { self.records = context; @@ -37,7 +45,11 @@ ContextRecorder.prototype.recordAndNext = function(transformFm) { self.records = [self.records]; } - self.records.push(context); + if (Array.isArray(context)) { + self.records.push(...context); + } else { + self.records.push(context); + } next(); }; }; diff --git a/test/persistence-hooks.suite.js b/test/persistence-hooks.suite.js index 57be987f..860dfc2e 100644 --- a/test/persistence-hooks.suite.js +++ b/test/persistence-hooks.suite.js @@ -671,6 +671,310 @@ module.exports = function(dataSource, should, connectorCapabilities) { }); }); + describe('PersistedModel.createAll', function() { + it('triggers hooks in the correct order', function(done) { + monitorHookExecution(); + + TestModel.createAll( + [{name: '1'}, {name: '2'}], + function(err) { + if (err) return done(err); + + hookMonitor.names.should.eql([ + 'before save', + 'before save', + 'persist', + 'loaded', + 'after save', + 'after save', + ]); + done(); + }, + ); + }); + + it('aborts when `after save` fires when option to notify is false', function(done) { + monitorHookExecution(); + + TestModel.create( + [{name: '1'}, {name: '2'}], + {notify: false}, + function(err) { + if (err) return done(err); + + hookMonitor.names.should.not.containEql('after save'); + done(); + }, + ); + }); + + it('triggers `before save` hook for each item in the array', function(done) { + TestModel.observe('before save', ctxRecorder.recordAndNext()); + + TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) { + if (err) return done(err); + // Creation of multiple instances is executed in parallel + ctxRecorder.records.sort(function(c1, c2) { + return c1.instance.name - c2.instance.name; + }); + ctxRecorder.records.should.eql([ + aCtxForModel(TestModel, { + instance: {id: list[0].id, name: '1', extra: undefined}, + isNewInstance: true, + }), + aCtxForModel(TestModel, { + instance: {id: list[1].id, name: '2', extra: undefined}, + isNewInstance: true, + }), + ]); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + TestModel.createAll([{name: '1'}, {name: '2'}], function(err) { + err.should.eql(expectedError); + done(); + }); + }); + + it('applies updates from `before save` hook to each item in the array', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + TestModel.createAll( + [{id: uid.next(), name: 'a-name'}, {id: uid.next(), name: 'b-name'}], + function(err, instances) { + if (err) return done(err); + instances.forEach(instance => { + instance.should.have.property('extra', 'hook data'); + }); + done(); + }, + ); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.createAll([{name: 'created1'}, {name: 'created2'}], function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({name: ['presence']}); + done(); + }); + }); + + it('triggers `persist` hook', function(done) { + TestModel.observe('persist', ctxRecorder.recordAndNext()); + + TestModel.createAll( + [{id: 'new-id-1', name: 'a name'}, {id: 'new-id-2', name: 'b name'}], + function(err, instances) { + if (err) return done(err); + + ctxRecorder.records.should.eql([ + aCtxForModel(TestModel, { + data: {id: 'new-id-1', name: 'a name'}, + isNewInstance: true, + currentInstance: {extra: null, id: 'new-id-1', name: 'a name'}, + }), + aCtxForModel(TestModel, { + data: {id: 'new-id-2', name: 'b name'}, + isNewInstance: true, + currentInstance: {extra: null, id: 'new-id-2', name: 'b name'}, + }), + ]); + + done(); + }, + ); + }); + + it('applies updates from `persist` hook', function(done) { + TestModel.observe( + 'persist', + ctxRecorder.recordAndNext(function(ctxArr) { + // It's crucial to change `ctx.data` reference, not only data props + ctxArr.forEach(ctx => { + ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'}); + }); + }), + ); + + // By default, the instance passed to create callback is NOT updated + // with the changes made through persist/loaded hooks. To preserve + // backwards compatibility, we introduced a new setting updateOnLoad, + // which if set, will apply these changes to the model instance too. + TestModel.settings.updateOnLoad = true; + TestModel.createAll( + [{id: 'new-id', name: 'a name'}], + function(err, instances) { + if (err) return done(err); + + instances.forEach(instance => { + instance.should.have.property('extra', 'hook data'); + }); + + // Also query the database here to verify that, on `create` + // updates from `persist` hook are reflected into database + TestModel.findById('new-id', function(err, dbInstance) { + if (err) return done(err); + should.exists(dbInstance); + dbInstance.toObject(true).should.eql({ + id: 'new-id', + name: 'a name', + extra: 'hook data', + }); + done(); + }); + }, + ); + }); + + it('triggers `loaded` hook', function(done) { + TestModel.observe('loaded', ctxRecorder.recordAndNext()); + + // By default, the instance passed to create callback is NOT updated + // with the changes made through persist/loaded hooks. To preserve + // backwards compatibility, we introduced a new setting updateOnLoad, + // which if set, will apply these changes to the model instance too. + TestModel.settings.updateOnLoad = true; + TestModel.createAll( + [ + {id: 'new-id-1', name: 'a name'}, + {id: 'new-id-2', name: 'b name'}, + ], + function(err) { + if (err) return done(err); + + ctxRecorder.records.sort(function(c1, c2) { + return c1.data.name - c2.data.name; + }); + ctxRecorder.records.should.eql([ + aCtxForModel(TestModel, { + data: {id: 'new-id-1', name: 'a name'}, + isNewInstance: true, + }), + aCtxForModel(TestModel, { + data: {id: 'new-id-2', name: 'b name'}, + isNewInstance: true, + }), + ]); + + done(); + }, + ); + }); + + it('emits error when `loaded` hook fails', function(done) { + TestModel.observe('loaded', nextWithError(expectedError)); + TestModel.createAll( + [{id: 'new-id', name: 'a name'}], + function(err) { + err.should.eql(expectedError); + done(); + }, + ); + }); + + it('applies updates from `loaded` hook', function(done) { + TestModel.observe( + 'loaded', + ctxRecorder.recordAndNext(function(ctx) { + // It's crucial to change `ctx.data` reference, not only data props + ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'}); + }), + ); + + // By default, the instance passed to create callback is NOT updated + // with the changes made through persist/loaded hooks. To preserve + // backwards compatibility, we introduced a new setting updateOnLoad, + // which if set, will apply these changes to the model instance too. + TestModel.settings.updateOnLoad = true; + TestModel.create( + [{id: 'new-id', name: 'a name'}], + function(err, instances) { + if (err) return done(err); + + instances.forEach((instance) => { + instance.should.have.property('extra', 'hook data'); + }); + done(); + }, + ); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', ctxRecorder.recordAndNext()); + + TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) { + if (err) return done(err); + + ctxRecorder.records.sort(function(c1, c2) { + return c1.instance.name - c2.instance.name; + }); + ctxRecorder.records.should.eql([ + aCtxForModel(TestModel, { + instance: {id: list[0].id, name: '1', extra: undefined}, + isNewInstance: true, + }), + aCtxForModel(TestModel, { + instance: {id: list[1].id, name: '2', extra: undefined}, + isNewInstance: true, + }), + ]); + done(); + }); + }); + + it('aborts when `after save` hook fails', function(done) { + TestModel.observe('after save', nextWithError(expectedError)); + + TestModel.createAll([{name: 'created'}], function(err) { + err.should.eql(expectedError); + done(); + }); + }); + + it('applies updates from `after save` hook', function(done) { + TestModel.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + TestModel.createAll([ + {name: 'a-name'}, + {name: 'b-name'}, + ], function(err, instances) { + if (err) return done(err); + instances.forEach((instance) => { + instance.should.have.property('extra', 'hook data'); + }); + done(); + }); + }); + + it('do not emit `after save` when before save fails for even one', function(done) { + TestModel.observe('before save', function(ctx, next) { + if (ctx.instance.name === 'fail') next(expectedError); + else next(); + }); + + TestModel.observe('after save', ctxRecorder.recordAndNext()); + + TestModel.createAll([{name: 'ok'}, {name: 'fail'}], function(err, list) { + err.should.eql(expectedError); + done(); + }); + }); + }); + describe('PersistedModel.findOrCreate', function() { it('triggers `access` hook', function(done) { TestModel.observe('access', ctxRecorder.recordAndNext()); diff --git a/types/persisted-model.d.ts b/types/persisted-model.d.ts index a5e1b5cb..4a80e219 100644 --- a/types/persisted-model.d.ts +++ b/types/persisted-model.d.ts @@ -3,9 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Callback, Options, PromiseOrVoid} from './common'; -import {ModelBase, ModelData} from './model'; -import {Filter, Where} from './query'; +import { Callback, Options, PromiseOrVoid } from './common'; +import { ModelBase, ModelData } from './model'; +import { Filter, Where } from './query'; /** * Data object for persisted models @@ -47,6 +47,21 @@ export declare class PersistedModel extends ModelBase { callback?: Callback, ): PromiseOrVoid; + /** + * Creates an array of new instances of Model, and save to database in one DB query. + * + * @param {Object[]} [data] Optional data argument. An array of instances. + * + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://loopback.io/doc/en/lb2/Error-object.html). + * @param {Object} models Model instances or null. + */ + static createAll( + data: PersistedData[], + options?: Options, + callback?: Callback, + ): PromiseOrVoid; + /** * Update or insert a model instance * @param {Object} data The model instance data to insert.