Skip to content

Commit 0f1cd4f

Browse files
committed
add support for basic findOne with ID
1 parent 3366d47 commit 0f1cd4f

6 files changed

Lines changed: 855 additions & 24 deletions

File tree

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,64 @@ context.prime(results);
3636

3737
await User.findById(2, {[EXPECTED_OPTIONS_KEY]: context}); // Cached, if was in results
3838
```
39+
40+
## `findOne` batching
41+
42+
`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:
43+
44+
```js
45+
import {createContext, EXPECTED_OPTIONS_KEY} from 'dataloader-sequelize';
46+
const context = createContext(sequelize);
47+
48+
// These execute in the same tick — batched into one query:
49+
User.findOne({ where: { id: 1 }, [EXPECTED_OPTIONS_KEY]: context });
50+
User.findOne({ where: { id: 2 }, [EXPECTED_OPTIONS_KEY]: context });
51+
// → SELECT * FROM users WHERE id IN (1, 2)
52+
```
53+
54+
Extra conditions alongside the primary key are supported. Calls with identical extra conditions are batched together; different extra conditions use separate loaders:
55+
56+
```js
57+
User.findOne({ where: { id: 1, status: 'active' }, [EXPECTED_OPTIONS_KEY]: context });
58+
User.findOne({ where: { id: 3, status: 'active' }, [EXPECTED_OPTIONS_KEY]: context });
59+
// → SELECT * FROM users WHERE id IN (1, 3) AND status = 'active'
60+
61+
User.findOne({ where: { id: 2, status: 'inactive' }, [EXPECTED_OPTIONS_KEY]: context });
62+
// → SELECT * FROM users WHERE id IN (2) AND status = 'inactive' (separate query)
63+
```
64+
65+
Sequelize `Op` symbols are also supported:
66+
67+
```js
68+
User.findOne({ where: { id: 1, [Op.and]: [{ deletedAt: null }] }, [EXPECTED_OPTIONS_KEY]: context });
69+
User.findOne({ where: { id: 2, [Op.and]: [{ deletedAt: null }] }, [EXPECTED_OPTIONS_KEY]: context });
70+
// → SELECT * FROM users WHERE id IN (1, 2) AND deletedAt IS NULL
71+
```
72+
73+
## `BATCH_BY_ATTRIBUTE` — batching by any field
74+
75+
For cases where you're not querying by primary key, use `BATCH_BY_ATTRIBUTE` to declare which field to batch by:
76+
77+
```js
78+
import {createContext, EXPECTED_OPTIONS_KEY, BATCH_BY_ATTRIBUTE} from 'dataloader-sequelize';
79+
const context = createContext(sequelize);
80+
81+
// Batch by a foreign key with a shared extra condition:
82+
Box.findOne({ where: { ancestorBoxId: 1, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context });
83+
Box.findOne({ where: { ancestorBoxId: 2, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context });
84+
Box.findOne({ where: { ancestorBoxId: 3, enabled: true }, [BATCH_BY_ATTRIBUTE]: 'ancestorBoxId', [EXPECTED_OPTIONS_KEY]: context });
85+
// → SELECT * FROM boxes WHERE ancestorBoxId IN (1, 2, 3) AND enabled = true
86+
```
87+
88+
`order`, `raw`, `paranoid`, and `rejectOnEmpty` are all respected. Calls with different extra conditions or different `order` values use separate loaders and are not mixed:
89+
90+
```js
91+
Box.findOne({
92+
[EXPECTED_OPTIONS_KEY]: context,
93+
[BATCH_BY_ATTRIBUTE]: 'ancestorBoxId',
94+
order: [['id', 'DESC']],
95+
rejectOnEmpty: true,
96+
where: { ancestorBoxId: achievementReward.ancestorBoxId, enabled: true },
97+
});
98+
```
99+

docker-compose.yml

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
dev:
2-
image: mhart/alpine-node:8.10
3-
links:
4-
- db
5-
working_dir: /src
6-
volumes:
7-
- .:/src
8-
environment:
9-
DB_HOST: db
10-
DB_DATABASE: dataloader_test
11-
DB_USER: dataloader_test
12-
DB_PASSWORD: dataloader_test
1+
version: '3.4'
132

14-
db:
15-
image: postgres:9.4
16-
environment:
17-
POSTGRES_USER: dataloader_test
18-
POSTGRES_PASSWORD: dataloader_test
3+
services:
4+
dev:
5+
image: mhart/alpine-node:8.10
6+
links:
7+
- db
8+
working_dir: /src
9+
volumes:
10+
- .:/src
11+
environment:
12+
DB_HOST: db
13+
DB_DATABASE: dataloader_test
14+
DB_USER: dataloader_test
15+
DB_PASSWORD: dataloader_test
16+
17+
db:
18+
image: postgres:9.4
19+
environment:
20+
POSTGRES_USER: dataloader_test
21+
POSTGRES_PASSWORD: dataloader_test

src/helper.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export function methods(version) {
88
return {
99
findByPk: /^[56]/.test(version) ? ['findByPk'] :
1010
/^[4]/.test(version) ? ['findByPk', 'findById'] :
11-
['findById', 'findByPrimary']
11+
['findById', 'findByPrimary'],
12+
findOne: ['findOne'],
1213
};
1314
}
1415

src/index.js

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import Sequelize from 'sequelize';
2-
import shimmer from 'shimmer';
1+
import assert from 'assert';
32
import DataLoader from 'dataloader';
4-
import {groupBy, property, values, clone, isEmpty, uniq} from 'lodash';
3+
import { clone, groupBy, isEmpty, property, uniq, values } from 'lodash';
54
import LRU from 'lru-cache';
6-
import assert from 'assert';
7-
import {methods} from './helper';
5+
import Sequelize from 'sequelize';
6+
import shimmer from 'shimmer';
7+
import { methods } from './helper';
88

99
const versionTestRegEx = /^[456]/;
1010

@@ -176,9 +176,22 @@ function loaderForModel(model, attribute, attributeField, options = {}) {
176176
});
177177
}
178178

179+
function loaderForFindOneWithConditions(model, attribute, otherWhere, options = {}) {
180+
return new DataLoader(keys => {
181+
const findOptions = Object.assign({}, options);
182+
delete findOptions.rejectOnEmpty;
183+
findOptions.where = Object.assign({ [attribute]: keys }, otherWhere);
184+
185+
return model.findAll(findOptions).then(mapResult.bind(null, attribute, keys, findOptions));
186+
}, {
187+
cache: options.cache
188+
});
189+
}
190+
179191
function shimModel(target) {
180192
if (target.findByPk ? target.findByPk.__wrapped : target.findById.__wrapped) return;
181193

194+
// findByPk
182195
shimmer.massWrap(target, methods(Sequelize.version).findByPk, original => {
183196
return function batchedFindById(id, options = {}) {
184197
if (options.transaction || options.include || activeClsTransaction() || !options[EXPECTED_OPTIONS_KEY]) {
@@ -204,6 +217,114 @@ function shimModel(target) {
204217
}).then(rejectOnEmpty.bind(null, options));
205218
};
206219
});
220+
221+
// findOne
222+
shimmer.massWrap(target, methods(Sequelize.version).findOne, () => {
223+
return function batchedFindOne(options = {}) {
224+
const keys = Object.keys(options.where || {});
225+
const symbolKeys = Object.getOwnPropertySymbols(options.where || {});
226+
const id = options.where && options.where[this.primaryKeyAttribute];
227+
const batchByAttribute = options[BATCH_BY_ATTRIBUTE];
228+
229+
if (options.transaction || options.include || activeClsTransaction() || !options[EXPECTED_OPTIONS_KEY]) {
230+
const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options);
231+
if (findOptions.limit === undefined) {
232+
const pkVal = findOptions.where && findOptions.where[this.primaryKeyAttribute];
233+
if (!findOptions.where || !(typeof pkVal === 'number' || typeof pkVal === 'string' || Buffer.isBuffer(pkVal))) {
234+
findOptions.limit = 1;
235+
}
236+
}
237+
return this.findAll(findOptions);
238+
}
239+
240+
// BATCH_BY_ATTRIBUTE path: user explicitly declares which field to batch by
241+
if (batchByAttribute) {
242+
const batchValue = options.where && options.where[batchByAttribute];
243+
if (!['string', 'number'].includes(typeof batchValue)) {
244+
const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options);
245+
if (findOptions.limit === undefined) findOptions.limit = 1;
246+
return this.findAll(findOptions);
247+
}
248+
249+
return Promise.resolve().then(() => {
250+
const loaders = options[EXPECTED_OPTIONS_KEY].loaders;
251+
const otherWhere = Object.assign({}, options.where);
252+
delete otherWhere[batchByAttribute];
253+
254+
const loaderOptions = { where: otherWhere, order: options.order, raw: options.raw, paranoid: options.paranoid };
255+
const cacheKey = getCacheKey(this, batchByAttribute, loaderOptions);
256+
let loader = loaders.autogenerated.get(cacheKey);
257+
if (!loader) {
258+
loader = loaderForFindOneWithConditions(this, batchByAttribute, otherWhere, {
259+
order: options.order,
260+
raw: options.raw,
261+
paranoid: options.paranoid,
262+
logging: options.logging,
263+
cache: true
264+
});
265+
loaders.autogenerated.set(cacheKey, loader);
266+
}
267+
return loader.load(batchValue);
268+
}).then(rejectOnEmpty.bind(null, options));
269+
}
270+
271+
if (!['string', 'number'].includes(typeof id)) {
272+
// Call this.findAll() directly rather than the original findOne, because
273+
// Sequelize v3's findOne internally calls Model.prototype.findAll (bypassing
274+
// instance-level overrides like sinon spies). Calling this.findAll() with the
275+
// same options findOne would use is semantically equivalent across all versions.
276+
const findOptions = Object.assign({ plain: true, rejectOnEmpty: false }, options);
277+
if (findOptions.limit === undefined) {
278+
const pkVal = findOptions.where && findOptions.where[this.primaryKeyAttribute];
279+
if (!findOptions.where || !(typeof pkVal === 'number' || typeof pkVal === 'string' || Buffer.isBuffer(pkVal))) {
280+
findOptions.limit = 1;
281+
}
282+
}
283+
return this.findAll(findOptions);
284+
}
285+
286+
return Promise.resolve().then(() => {
287+
if ([null, undefined].indexOf(id) !== -1) {
288+
return Promise.resolve(null);
289+
}
290+
291+
const loaders = options[EXPECTED_OPTIONS_KEY].loaders;
292+
293+
if (keys.length === 1 && symbolKeys.length === 0) {
294+
// Simple case: only PK in where — use byPrimaryKey loader
295+
let loader = loaders[this.name].byPrimaryKey;
296+
if (options.raw || options.paranoid === false) {
297+
const cacheKey = getCacheKey(this, this.primaryKeyAttribute, { raw: options.raw, paranoid: options.paranoid });
298+
loader = loaders.autogenerated.get(cacheKey);
299+
if (!loader) {
300+
loader = createModelAttributeLoader(this, this.primaryKeyAttribute, { raw: options.raw, paranoid: options.paranoid, logging: options.logging });
301+
loaders.autogenerated.set(cacheKey, loader);
302+
}
303+
}
304+
return loader.load(id);
305+
} else {
306+
// Extended case: PK + extra conditions — batch calls with identical extra conditions together
307+
const otherWhere = Object.assign({}, options.where);
308+
delete otherWhere[this.primaryKeyAttribute];
309+
310+
const loaderOptions = { where: otherWhere, order: options.order, raw: options.raw, paranoid: options.paranoid };
311+
const cacheKey = getCacheKey(this, this.primaryKeyAttribute, loaderOptions);
312+
let loader = loaders.autogenerated.get(cacheKey);
313+
if (!loader) {
314+
loader = loaderForFindOneWithConditions(this, this.primaryKeyAttribute, otherWhere, {
315+
order: options.order,
316+
raw: options.raw,
317+
paranoid: options.paranoid,
318+
logging: options.logging,
319+
cache: true
320+
});
321+
loaders.autogenerated.set(cacheKey, loader);
322+
}
323+
return loader.load(id);
324+
}
325+
}).then(rejectOnEmpty.bind(null, options));
326+
};
327+
});
207328
}
208329

209330
function shimBelongsTo(target) {
@@ -425,6 +546,7 @@ function activeClsTransaction() {
425546
}
426547

427548
export const EXPECTED_OPTIONS_KEY = 'dataloader_sequelize_context';
549+
export const BATCH_BY_ATTRIBUTE = 'dataloader_sequelize_batch_by_attribute';
428550
export function createContext(sequelize, options = {}) {
429551
const loaders = {};
430552

test/helper.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function createConnection() {
1919
process.env.DB_PASSWORD, {
2020
dialect: 'postgres',
2121
host: process.env.DB_HOST,
22-
logging: false
22+
logging: false,
23+
pool: { max: 1, min: 0, idle: 10000 }
2324
}
2425
);
2526

0 commit comments

Comments
 (0)