diff --git a/README.md b/README.md index 7bf4884..47f7f66 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,64 @@ context.prime(results); await User.findById(2, {[EXPECTED_OPTIONS_KEY]: context}); // Cached, if was in results ``` + +## `findOne` batching + +`findOne` calls are automatically batched when a primary key is present in `where`. Multiple calls in the same tick are rewritten to a single `WHERE pk IN (...)` query: + +```js +import {createContext, EXPECTED_OPTIONS_KEY} from 'dataloader-sequelize'; +const context = createContext(sequelize); + +// These execute in the same tick — batched into one query: +User.findOne({ where: { id: 1 }, [EXPECTED_OPTIONS_KEY]: context }); +User.findOne({ where: { id: 2 }, [EXPECTED_OPTIONS_KEY]: context }); +// → SELECT * FROM users WHERE id IN (1, 2) +``` + +Extra conditions alongside the primary key are supported. Calls with identical extra conditions are batched together; different extra conditions use separate loaders: + +```js +User.findOne({ where: { id: 1, status: 'active' }, [EXPECTED_OPTIONS_KEY]: context }); +User.findOne({ where: { id: 3, status: 'active' }, [EXPECTED_OPTIONS_KEY]: context }); +// → SELECT * FROM users WHERE id IN (1, 3) AND status = 'active' + +User.findOne({ where: { id: 2, status: 'inactive' }, [EXPECTED_OPTIONS_KEY]: context }); +// → SELECT * FROM users WHERE id IN (2) AND status = 'inactive' (separate query) +``` + +Sequelize `Op` symbols are also supported: + +```js +User.findOne({ where: { id: 1, [Op.and]: [{ deletedAt: null }] }, [EXPECTED_OPTIONS_KEY]: context }); +User.findOne({ where: { id: 2, [Op.and]: [{ deletedAt: null }] }, [EXPECTED_OPTIONS_KEY]: context }); +// → SELECT * FROM users WHERE id IN (1, 2) AND deletedAt IS NULL +``` + +## `BATCH_BY_ATTRIBUTE` — batching by any field + +For cases where you're not querying by primary key, use `BATCH_BY_ATTRIBUTE` to declare which field to batch by: + +```js +import {createContext, EXPECTED_OPTIONS_KEY, BATCH_BY_ATTRIBUTE} from 'dataloader-sequelize'; +const context = createContext(sequelize); + +// Batch by a foreign key with a shared extra condition: +Box.findOne({ where: { ancestorBoxId: 1, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context }); +Box.findOne({ where: { ancestorBoxId: 2, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context }); +Box.findOne({ where: { ancestorBoxId: 3, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context }); +// → SELECT * FROM boxes WHERE ancestorBoxId IN (1, 2, 3) AND enabled = true +``` + +`order`, `raw`, `paranoid`, and `rejectOnEmpty` are all respected. Calls with different extra conditions or different `order` values use separate loaders and are not mixed: + +```js +Box.findOne({ + [EXPECTED_OPTIONS_KEY]: context, + [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', + order: [['id', 'DESC']], + rejectOnEmpty: true, + where: { ancestorBoxId: achievementReward.ancestorBoxId, enabled: true }, +}); +``` + diff --git a/docker-compose.yml b/docker-compose.yml index c30055b..89b5f4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,21 @@ -dev: - image: mhart/alpine-node:8.10 - links: - - db - working_dir: /src - volumes: - - .:/src - environment: - DB_HOST: db - DB_DATABASE: dataloader_test - DB_USER: dataloader_test - DB_PASSWORD: dataloader_test +version: '3.4' -db: - image: postgres:9.4 - environment: - POSTGRES_USER: dataloader_test - POSTGRES_PASSWORD: dataloader_test +services: + dev: + image: mhart/alpine-node:8.10 + links: + - db + working_dir: /src + volumes: + - .:/src + environment: + DB_HOST: db + DB_DATABASE: dataloader_test + DB_USER: dataloader_test + DB_PASSWORD: dataloader_test + + db: + image: postgres:9.4 + environment: + POSTGRES_USER: dataloader_test + POSTGRES_PASSWORD: dataloader_test diff --git a/src/helper.js b/src/helper.js index 6877c8f..775a17c 100644 --- a/src/helper.js +++ b/src/helper.js @@ -8,7 +8,8 @@ export function methods(version) { return { findByPk: /^[56]/.test(version) ? ['findByPk'] : /^[4]/.test(version) ? ['findByPk', 'findById'] : - ['findById', 'findByPrimary'] + ['findById', 'findByPrimary'], + findOne: ['findOne'], }; } diff --git a/src/index.js b/src/index.js index 857c3da..cd66855 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,10 @@ -import Sequelize from 'sequelize'; -import shimmer from 'shimmer'; +import assert from 'assert'; import DataLoader from 'dataloader'; -import {groupBy, property, values, clone, isEmpty, uniq} from 'lodash'; +import { clone, groupBy, isEmpty, property, uniq, values } from 'lodash'; import LRU from 'lru-cache'; -import assert from 'assert'; -import {methods} from './helper'; +import Sequelize from 'sequelize'; +import shimmer from 'shimmer'; +import { methods } from './helper'; const versionTestRegEx = /^[456]/; @@ -176,9 +176,22 @@ function loaderForModel(model, attribute, attributeField, options = {}) { }); } +function loaderForFindOneWithConditions(model, attribute, otherWhere, options = {}) { + return new DataLoader(keys => { + const findOptions = Object.assign({}, options); + delete findOptions.rejectOnEmpty; + findOptions.where = Object.assign({ [attribute]: keys }, otherWhere); + + return model.findAll(findOptions).then(mapResult.bind(null, attribute, keys, findOptions)); + }, { + cache: options.cache + }); +} + function shimModel(target) { if (target.findByPk ? target.findByPk.__wrapped : target.findById.__wrapped) return; + // findByPk shimmer.massWrap(target, methods(Sequelize.version).findByPk, original => { return function batchedFindById(id, options = {}) { if (options.transaction || options.include || activeClsTransaction() || !options[EXPECTED_OPTIONS_KEY]) { @@ -204,6 +217,114 @@ function shimModel(target) { }).then(rejectOnEmpty.bind(null, options)); }; }); + + // findOne + shimmer.massWrap(target, methods(Sequelize.version).findOne, () => { + return function batchedFindOne(options = {}) { + const keys = Object.keys(options.where || {}); + const symbolKeys = Object.getOwnPropertySymbols(options.where || {}); + const id = options.where && options.where[this.primaryKeyAttribute]; + const batchByAttribute = options[BATCH_BY_ATTRIBUTE]; + + if (options.transaction || options.include || activeClsTransaction() || !options[EXPECTED_OPTIONS_KEY]) { + const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options); + if (findOptions.limit === undefined) { + const pkVal = findOptions.where && findOptions.where[this.primaryKeyAttribute]; + if (!findOptions.where || !(typeof pkVal === 'number' || typeof pkVal === 'string' || Buffer.isBuffer(pkVal))) { + findOptions.limit = 1; + } + } + return this.findAll(findOptions); + } + + // BATCH_BY_ATTRIBUTE path: user explicitly declares which field to batch by + if (batchByAttribute) { + const batchValue = options.where && options.where[batchByAttribute]; + if (!['string', 'number'].includes(typeof batchValue)) { + const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options); + if (findOptions.limit === undefined) findOptions.limit = 1; + return this.findAll(findOptions); + } + + return Promise.resolve().then(() => { + const loaders = options[EXPECTED_OPTIONS_KEY].loaders; + const otherWhere = Object.assign({}, options.where); + delete otherWhere[batchByAttribute]; + + const loaderOptions = { where: otherWhere, order: options.order, raw: options.raw, paranoid: options.paranoid }; + const cacheKey = getCacheKey(this, batchByAttribute, loaderOptions); + let loader = loaders.autogenerated.get(cacheKey); + if (!loader) { + loader = loaderForFindOneWithConditions(this, batchByAttribute, otherWhere, { + order: options.order, + raw: options.raw, + paranoid: options.paranoid, + logging: options.logging, + cache: true + }); + loaders.autogenerated.set(cacheKey, loader); + } + return loader.load(batchValue); + }).then(rejectOnEmpty.bind(null, options)); + } + + if (!['string', 'number'].includes(typeof id)) { + // Call this.findAll() directly rather than the original findOne, because + // Sequelize v3's findOne internally calls Model.prototype.findAll (bypassing + // instance-level overrides like sinon spies). Calling this.findAll() with the + // same options findOne would use is semantically equivalent across all versions. + const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options); + if (findOptions.limit === undefined) { + const pkVal = findOptions.where && findOptions.where[this.primaryKeyAttribute]; + if (!findOptions.where || !(typeof pkVal === 'number' || typeof pkVal === 'string' || Buffer.isBuffer(pkVal))) { + findOptions.limit = 1; + } + } + return this.findAll(findOptions); + } + + return Promise.resolve().then(() => { + if ([null, undefined].indexOf(id) !== -1) { + return Promise.resolve(null); + } + + const loaders = options[EXPECTED_OPTIONS_KEY].loaders; + + if (keys.length === 1 && symbolKeys.length === 0) { + // Simple case: only PK in where — use byPrimaryKey loader + let loader = loaders[this.name].byPrimaryKey; + if (options.raw || options.paranoid === false) { + const cacheKey = getCacheKey(this, this.primaryKeyAttribute, { raw: options.raw, paranoid: options.paranoid }); + loader = loaders.autogenerated.get(cacheKey); + if (!loader) { + loader = createModelAttributeLoader(this, this.primaryKeyAttribute, { raw: options.raw, paranoid: options.paranoid, logging: options.logging }); + loaders.autogenerated.set(cacheKey, loader); + } + } + return loader.load(id); + } else { + // Extended case: PK + extra conditions — batch calls with identical extra conditions together + const otherWhere = Object.assign({}, options.where); + delete otherWhere[this.primaryKeyAttribute]; + + const loaderOptions = { where: otherWhere, order: options.order, raw: options.raw, paranoid: options.paranoid }; + const cacheKey = getCacheKey(this, this.primaryKeyAttribute, loaderOptions); + let loader = loaders.autogenerated.get(cacheKey); + if (!loader) { + loader = loaderForFindOneWithConditions(this, this.primaryKeyAttribute, otherWhere, { + order: options.order, + raw: options.raw, + paranoid: options.paranoid, + logging: options.logging, + cache: true + }); + loaders.autogenerated.set(cacheKey, loader); + } + return loader.load(id); + } + }).then(rejectOnEmpty.bind(null, options)); + }; + }); } function shimBelongsTo(target) { @@ -425,6 +546,7 @@ function activeClsTransaction() { } export const EXPECTED_OPTIONS_KEY = 'dataloader_sequelize_context'; +export const BATCH_BY_ATTRIBUTE = 'dataloader_sequelize_batch_by_attribute'; export function createContext(sequelize, options = {}) { const loaders = {}; diff --git a/test/helper.js b/test/helper.js index b044ebf..0ad8b75 100644 --- a/test/helper.js +++ b/test/helper.js @@ -19,7 +19,8 @@ export function createConnection() { process.env.DB_PASSWORD, { dialect: 'postgres', host: process.env.DB_HOST, - logging: false + logging: false, + pool: { max: 1, min: 0, idle: 10000 } } ); diff --git a/test/integration/findOne.test.js b/test/integration/findOne.test.js new file mode 100644 index 0000000..dda59ca --- /dev/null +++ b/test/integration/findOne.test.js @@ -0,0 +1,643 @@ +import Promise from 'bluebird'; +import Sequelize from 'sequelize'; +import sinon from 'sinon'; +import expect from 'unexpected'; +import { EXPECTED_OPTIONS_KEY, BATCH_BY_ATTRIBUTE, createContext, removeContext } from '../../src'; +import { method } from '../../src/helper'; +import { createConnection, randint } from '../helper'; + +describe('findOne', function () { + afterEach(function () { + if (this.connection) { + return this.connection.close(); + } + }); + + describe('id primary key', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user'); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ + force: true + }); + + [this.user0, this.user1, this.user2, this.user3] = await this.User.bulkCreate([ + { id: '0' }, + { id: randint() }, + { id: randint() }, + { id: randint() } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + + afterEach(function () { + this.sandbox.restore(); + }); + + it('works with null', async function () { + const user0 = await this.User[this.method]({ [EXPECTED_OPTIONS_KEY]: this.context }); + + expect(user0.get('id'), 'to equal', 0); + expect(this.User.findAll, 'was called once'); + }); + + it('works with id of 0', async function () { + const user0 = await this.User[this.method]({ where: { id: 0 }, [EXPECTED_OPTIONS_KEY]: this.context }); + + expect(user0.get('id'), 'to equal', 0); + expect(this.User.findAll, 'was called once'); + }); + + it('batches and caches to a single findAll call (createContext)', async function () { + let user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user2.get('id')] + } + }]); + }); + + it('supports rejectOnEmpty', async function () { + const user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, rejectOnEmpty: true, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { id: 42 }, rejectOnEmpty: true, [EXPECTED_OPTIONS_KEY]: this.context }) + , user3 = this.User[this.method]({ where: { id: 42 }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be rejected'); + await expect(user3, 'to be fulfilled with', null); + }); + + it('supports raw/attributes', async function () { + await Promise.all([ + this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }), + this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, raw: true}), + this.User[this.method]({ where: { id: this.user3.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, raw: true}) + ]); + + expect(this.User.findAll, 'was called twice'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id')] + } + }]); + expect(this.User.findAll, 'to have a call satisfying', [{ + raw: true, + where: { + id: [this.user2.get('id'), this.user3.get('id')] + } + }]); + }); + + it('works if model method is shimmed', async function () { + removeContext(this.connection); + + const original = this.User[this.method]; + this.User[this.method] = function (...args) { + return original.call(this, ...args); + }; + + this.context = createContext(this.connection); + + let user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user2.get('id')] + } + }]); + }); + }); + + describe('other primary key', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user', { + identifier: { + primaryKey: true, + type: Sequelize.INTEGER + } + }); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ + force: true + }); + + [this.user1, this.user2, this.user3] = await this.User.bulkCreate([ + { identifier: randint() }, + { identifier: randint() }, + { identifier: randint() } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + + afterEach(function () { + this.sandbox.restore(); + }); + + it('batches to a single findAll call', async function () { + const user1 = this.User[this.method]({ where: { identifier: this.user1.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { identifier: this.user2.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + identifier: [this.user1.get('identifier'), this.user2.get('identifier')] + } + }]); + }); + + it('batches and caches to a single findAll call (createContext)', async function () { + let user1 = this.User[this.method]({ where: { identifier: this.user1.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { identifier: this.user2.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + user1 = this.User[this.method]({ where: { identifier: this.user1.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }); + user2 = this.User[this.method]({ where: { identifier: this.user2.get('identifier') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + identifier: [this.user1.get('identifier'), this.user2.get('identifier')] + } + }]); + }); + }); + + describe('other fields', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user', { + id: { + primaryKey: true, + type: Sequelize.INTEGER + }, + status: { + type: Sequelize.STRING + }, + }); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ + force: true + }); + + [this.user1, this.user2, this.user3] = await this.User.bulkCreate([ + { id: randint(), status: 'active' }, + { id: randint(), status: 'inactive' }, + { id: randint(), status: 'active' } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + + afterEach(function () { + this.sandbox.restore(); + }); + + it('works with other where clauses', async function () { + const user1 = await this.User[this.method]({ where: { id: this.user1.get('id'), status: this.user1.get('status') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + expect(user1.get('id'), 'to equal', this.user1.get('id')); + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id')], + status: this.user1.get('status'), + } + }]); + }); + + it('batches to a single findAll call with same extra conditions', async function () { + const user1 = this.User[this.method]({ where: { id: this.user1.get('id'), status: 'active' }, [EXPECTED_OPTIONS_KEY]: this.context }); + const user3 = this.User[this.method]({ where: { id: this.user3.get('id'), status: 'active' }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user3, 'to be fulfilled with', this.user3); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user3.get('id')], + status: 'active', + } + }]); + }); + + it('does not batch with different extra conditions', async function () { + const user1 = this.User[this.method]({ where: { id: this.user1.get('id'), status: this.user1.get('status') }, [EXPECTED_OPTIONS_KEY]: this.context }); + const user2 = this.User[this.method]({ where: { id: this.user2.get('id'), status: this.user2.get('status') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called twice'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id')], + status: this.user1.get('status'), + } + }]); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user2.get('id')], + status: this.user2.get('status'), + } + }]); + }); + + it('falls through without batching when where contains Op symbols', async function () { + const Op = Sequelize.Op; + if (!Op) return; // Op not available in Sequelize v3 - skip + + const user1 = this.User[this.method]({ + where: { id: this.user1.get('id'), [Op.and]: [{ status: 'active' }] }, + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user3 = this.User[this.method]({ + where: { id: this.user3.get('id'), [Op.and]: [{ status: 'active' }] }, + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user3, 'to be fulfilled with', this.user3); + + // Both calls share the same Op conditions — should batch into a single findAll + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user3.get('id')], + [Op.and]: [{ status: 'active' }] + } + }]); + }); + + it('does not batch with different Op symbol conditions', async function () { + const Op = Sequelize.Op; + if (!Op) return; // Op not available in Sequelize v3 - skip + + const user1 = this.User[this.method]({ + where: { id: this.user1.get('id'), [Op.and]: [{ status: 'active' }] }, + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user2 = this.User[this.method]({ + where: { id: this.user2.get('id'), [Op.and]: [{ status: 'inactive' }] }, + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + // Different Op conditions — should use separate loaders + expect(this.User.findAll, 'was called twice'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { id: [this.user1.get('id')], [Op.and]: [{ status: 'active' }] } + }]); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { id: [this.user2.get('id')], [Op.and]: [{ status: 'inactive' }] } + }]); + }); + + it('batches calls with the same order', async function () { + const user1 = this.User[this.method]({ + where: { id: this.user1.get('id'), status: 'active' }, + order: [['id', 'DESC']], + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user3 = this.User[this.method]({ + where: { id: this.user3.get('id'), status: 'active' }, + order: [['id', 'DESC']], + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user3, 'to be fulfilled with', this.user3); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { id: [this.user1.get('id'), this.user3.get('id')], status: 'active' }, + order: [['id', 'DESC']] + }]); + }); + + it('does not batch calls with different order', async function () { + const user1 = this.User[this.method]({ + where: { id: this.user1.get('id'), status: 'active' }, + order: [['id', 'ASC']], + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user3 = this.User[this.method]({ + where: { id: this.user3.get('id'), status: 'active' }, + order: [['id', 'DESC']], + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user3, 'to be fulfilled with', this.user3); + + expect(this.User.findAll, 'was called twice'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { id: [this.user1.get('id')], status: 'active' }, + order: [['id', 'ASC']] + }]); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { id: [this.user3.get('id')], status: 'active' }, + order: [['id', 'DESC']] + }]); + }); + }); + + describe('primary key with field', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user', { + id: { + primaryKey: true, + type: Sequelize.INTEGER, + field: 'identifier' + } + }); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ + force: true + }); + + [this.user1, this.user2, this.user3] = await this.User.bulkCreate([ + { id: randint() }, + { id: randint() }, + { id: randint() } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + + afterEach(function () { + this.sandbox.restore(); + }); + + it('batches to a single findAll call', async function () { + const user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + const user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user2.get('id')] + } + }]); + }); + }); + + describe('paranoid', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user', {}, { paranoid: true }); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ + force: true + }); + + [this.user1, this.user2] = await this.User.bulkCreate([ + { id: randint(), deletedAt: new Date() }, + { id: randint() } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + afterEach(function () { + this.sandbox.restore(); + }); + + it('batches and caches to a single findAll call (paranoid)', async function () { + let user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }) + , user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', null); + await expect(user2, 'to be fulfilled with', this.user2); + + user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context }); + + await expect(user1, 'to be fulfilled with', null); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + id: [this.user1.get('id'), this.user2.get('id')] + } + }]); + }); + + it('batches and caches to a single findAll call (not paranoid)', async function () { + let user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, paranoid: false}) + , user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, paranoid: false}); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + user1 = this.User[this.method]({ where: { id: this.user1.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, paranoid: false}); + user2 = this.User[this.method]({ where: { id: this.user2.get('id') }, [EXPECTED_OPTIONS_KEY]: this.context, paranoid: false}); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + paranoid: false, + where: { + id: [this.user1.get('id'), this.user2.get('id')] + } + }]); + }); + }); + + describe('BATCH_BY_ATTRIBUTE', function () { + beforeEach(createConnection); + beforeEach(async function () { + this.sandbox = sinon.sandbox.create(); + + this.User = this.connection.define('user', { + id: { + primaryKey: true, + type: Sequelize.INTEGER + }, + parentId: { + type: Sequelize.INTEGER + }, + status: { + type: Sequelize.STRING + } + }); + + this.sandbox.spy(this.User, 'findAll'); + + await this.User.sync({ force: true }); + + [this.user1, this.user2, this.user3] = await this.User.bulkCreate([ + { id: randint(), parentId: randint(), status: 'active' }, + { id: randint(), parentId: randint(), status: 'active' }, + { id: randint(), parentId: randint(), status: 'inactive' } + ], { returning: true }); + + this.context = createContext(this.connection); + this.method = method(this.User, 'findOne'); + }); + + afterEach(function () { + this.sandbox.restore(); + }); + + it('batches by the specified attribute', async function () { + const user1 = this.User[this.method]({ + where: { parentId: this.user1.get('parentId'), status: 'active' }, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user2 = this.User[this.method]({ + where: { parentId: this.user2.get('parentId'), status: 'active' }, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + parentId: [this.user1.get('parentId'), this.user2.get('parentId')], + status: 'active' + } + }]); + }); + + it('does not batch when extra conditions differ', async function () { + const user1 = this.User[this.method]({ + where: { parentId: this.user1.get('parentId'), status: 'active' }, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user3 = this.User[this.method]({ + where: { parentId: this.user3.get('parentId'), status: 'inactive' }, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user3, 'to be fulfilled with', this.user3); + + expect(this.User.findAll, 'was called twice'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { parentId: [this.user1.get('parentId')], status: 'active' } + }]); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { parentId: [this.user3.get('parentId')], status: 'inactive' } + }]); + }); + + it('respects order option', async function () { + const user1 = this.User[this.method]({ + where: { parentId: this.user1.get('parentId'), status: 'active' }, + order: [['id', 'DESC']], + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + const user2 = this.User[this.method]({ + where: { parentId: this.user2.get('parentId'), status: 'active' }, + order: [['id', 'DESC']], + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(user1, 'to be fulfilled with', this.user1); + await expect(user2, 'to be fulfilled with', this.user2); + + expect(this.User.findAll, 'was called once'); + expect(this.User.findAll, 'to have a call satisfying', [{ + where: { + parentId: [this.user1.get('parentId'), this.user2.get('parentId')], + status: 'active' + }, + order: [['id', 'DESC']] + }]); + }); + + it('supports rejectOnEmpty', async function () { + const missing = this.User[this.method]({ + where: { parentId: 999999, status: 'active' }, + rejectOnEmpty: true, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + const found = this.User[this.method]({ + where: { parentId: this.user1.get('parentId'), status: 'active' }, + [BATCH_BY_ATTRIBUTE]: 'parentId', + [EXPECTED_OPTIONS_KEY]: this.context + }); + + await expect(missing, 'to be rejected'); + await expect(found, 'to be fulfilled with', this.user1); + }); + }); +});