Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
```

37 changes: 20 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};
}

Expand Down
132 changes: 127 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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]/;

Expand Down Expand Up @@ -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]) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 = {};

Expand Down
3 changes: 2 additions & 1 deletion test/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
);

Expand Down
Loading
Loading