Skip to content

Commit 74c1959

Browse files
feat(list): Optimize limit/offset queries by pre-fetching the primary keys that should be returned.
1 parent 27f2188 commit 74c1959

File tree

8 files changed

+170
-40
lines changed

8 files changed

+170
-40
lines changed

lib/associations/inject.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
1616
exports.__esModule = true;
1717
var debug_1 = __importDefault(require("debug"));
1818
var graphql_sequelize_1 = require("graphql-sequelize");
19-
var createResolver_1 = __importDefault(require("../createResolver"));
19+
var createListResolver_1 = __importDefault(require("../createListResolver"));
2020
var field_1 = __importDefault(require("./field"));
2121
var debug = (0, debug_1["default"])('gsg');
2222
function injectAssociations(modelGraphQLType, graphqlSchemaDeclaration, outputTypes, models, globalPreCallback, proxyModelName) {
@@ -37,7 +37,7 @@ function injectAssociations(modelGraphQLType, graphqlSchemaDeclaration, outputTy
3737
debug("Cannot generate the association for model [".concat(associations[associationName].target.name, "] as it wasn't declared in the schema declaration. Skipping it."));
3838
continue;
3939
}
40-
associationsFields[associationName] = (0, field_1["default"])(associations[associationName], outputTypes, graphqlSchemaDeclaration, models, globalPreCallback, (0, createResolver_1["default"])(graphqlSchemaDeclaration[associations[associationName].target.name
40+
associationsFields[associationName] = (0, field_1["default"])(associations[associationName], outputTypes, graphqlSchemaDeclaration, models, globalPreCallback, (0, createListResolver_1["default"])(graphqlSchemaDeclaration[associations[associationName].target.name
4141
// Models MUST be ModelDeclarationType. GraphQLFieldConfig are for custom endpoints.
4242
], models, globalPreCallback, associations[associationName]));
4343
}

lib/createResolver.js lib/createListResolver.js

+60-12
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,51 @@ var argsAdvancedProcessing = function (findOptions, args, context, info, model,
163163
}
164164
return findOptions;
165165
};
166-
function createResolver(graphqlTypeDeclaration, models, globalPreCallback, relation) {
166+
function trimAndOptimizeFindOptions(_a) {
167+
var findOptions = _a.findOptions, graphqlTypeDeclaration = _a.graphqlTypeDeclaration, info = _a.info, models = _a.models;
168+
return __awaiter(this, void 0, void 0, function () {
169+
var trimedFindOptions, fetchIdsFindOptions, result;
170+
var _b;
171+
return __generator(this, function (_c) {
172+
switch (_c.label) {
173+
case 0:
174+
trimedFindOptions = graphqlTypeDeclaration.list &&
175+
graphqlTypeDeclaration.list.removeUnusedAttributes === false
176+
? findOptions
177+
: (0, removeUnusedAttributes_1["default"])(findOptions, info, graphqlTypeDeclaration.model, models);
178+
if (!
179+
// If we have a list with a limit and an offset
180+
(trimedFindOptions.limit &&
181+
trimedFindOptions.offset &&
182+
// And no explicit instructions to not optimize it.
183+
// In the majority of the case, doubling the number of queries should be either
184+
// faster OR not significantly slower.
185+
// As GSG is made to be "easy-to-use", we optimize by default.
186+
// We expect limit to be small enough to not cause performance issues.
187+
// If you are in a case where you need to fetch a big offset, you should disable the optimization.
188+
(!graphqlTypeDeclaration.list ||
189+
typeof graphqlTypeDeclaration.list.disableOptimizationForLimitOffset ===
190+
'undefined' ||
191+
graphqlTypeDeclaration.list.disableOptimizationForLimitOffset !== true)))
192+
// If we have a list with a limit and an offset
193+
return [3 /*break*/, 2];
194+
fetchIdsFindOptions = __assign(__assign({}, trimedFindOptions), {
195+
// We only fetch the primary attribute
196+
attributes: [graphqlTypeDeclaration.model.primaryKeyAttribute] });
197+
return [4 /*yield*/, graphqlTypeDeclaration.model.findAll(fetchIdsFindOptions)];
198+
case 1:
199+
result = _c.sent();
200+
return [2 /*return*/, __assign(__assign({}, trimedFindOptions), { offset: undefined, limit: undefined,
201+
// We override the where to only fetch the rows we want.
202+
where: (_b = {},
203+
_b[graphqlTypeDeclaration.model.primaryKeyAttribute] = result.map(function (r) { return r[graphqlTypeDeclaration.model.primaryKeyAttribute]; }),
204+
_b) })];
205+
case 2: return [2 /*return*/, trimedFindOptions];
206+
}
207+
});
208+
});
209+
}
210+
function createListResolver(graphqlTypeDeclaration, models, globalPreCallback, relation) {
167211
var _this = this;
168212
var _a;
169213
if (relation === void 0) { relation = null; }
@@ -199,7 +243,7 @@ function createResolver(graphqlTypeDeclaration, models, globalPreCallback, relat
199243
? graphqlTypeDeclaration.list.contextToOptions
200244
: undefined,
201245
before: function (findOptions, args, context, info) { return __awaiter(_this, void 0, void 0, function () {
202-
var processedFindOptions, beforeList, beforeList_1, beforeList_1_1, before, handle, e_1_1, handle, result;
246+
var processedFindOptions, beforeList, beforeList_1, beforeList_1_1, before, handle, e_1_1, handle, resultBefore;
203247
var e_1, _a;
204248
return __generator(this, function (_b) {
205249
switch (_b.label) {
@@ -261,18 +305,22 @@ function createResolver(graphqlTypeDeclaration, models, globalPreCallback, relat
261305
handle = globalPreCallback('listBefore');
262306
return [4 /*yield*/, listBefore(processedFindOptions, args, context, info)];
263307
case 9:
264-
result = _b.sent();
308+
resultBefore = _b.sent();
309+
if (!resultBefore) {
310+
throw new Error('The before hook of the list endpoint must return a value.');
311+
}
312+
// The list overwrite the findOptions
313+
processedFindOptions = resultBefore;
265314
if (handle) {
266315
handle();
267316
}
268-
return [2 /*return*/, graphqlTypeDeclaration.list &&
269-
graphqlTypeDeclaration.list.removeUnusedAttributes === false
270-
? result
271-
: (0, removeUnusedAttributes_1["default"])(result, info, graphqlTypeDeclaration.model, models)];
272-
case 10: return [2 /*return*/, graphqlTypeDeclaration.list &&
273-
graphqlTypeDeclaration.list.removeUnusedAttributes === false
274-
? processedFindOptions
275-
: (0, removeUnusedAttributes_1["default"])(processedFindOptions, info, graphqlTypeDeclaration.model, models)];
317+
_b.label = 10;
318+
case 10: return [2 /*return*/, trimAndOptimizeFindOptions({
319+
findOptions: processedFindOptions,
320+
graphqlTypeDeclaration: graphqlTypeDeclaration,
321+
info: info,
322+
models: models
323+
})];
276324
}
277325
});
278326
}); },
@@ -296,4 +344,4 @@ function createResolver(graphqlTypeDeclaration, models, globalPreCallback, relat
296344
}); }
297345
});
298346
}
299-
exports["default"] = createResolver;
347+
exports["default"] = createListResolver;

lib/queryResolvers/list.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
1616
exports.__esModule = true;
1717
var graphql_1 = require("graphql");
1818
var graphql_sequelize_1 = require("graphql-sequelize");
19-
var createResolver_1 = __importDefault(require("../createResolver"));
2019
var inject_1 = __importDefault(require("../associations/inject"));
20+
var createListResolver_1 = __importDefault(require("../createListResolver"));
2121
/**
2222
* Returns a root `GraphQLObjectType` used as query for `GraphQLSchema`.
2323
*
@@ -35,7 +35,7 @@ function generateListResolver(modelType, allSchemaDeclarations, outputTypes, mod
3535
args: __assign(__assign(__assign({}, (0, graphql_sequelize_1.defaultArgs)(schemaDeclaration.model)), (0, graphql_sequelize_1.defaultListArgs)()), (schemaDeclaration.list && schemaDeclaration.list.extraArg
3636
? schemaDeclaration.list.extraArg
3737
: {})),
38-
resolve: (0, createResolver_1["default"])(schemaDeclaration, models, globalPreCallback)
38+
resolve: (0, createListResolver_1["default"])(schemaDeclaration, models, globalPreCallback)
3939
};
4040
}
4141
exports["default"] = generateListResolver;

src/associations/inject.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import _debug from 'debug'
22
import { GraphQLObjectType } from 'graphql'
33
import { attributeFields } from 'graphql-sequelize'
44

5-
import createResolver from '../createResolver'
5+
import createResolver from '../createListResolver'
66
import {
77
GlobalPreCallback,
88
GraphqlSchemaDeclarationType,

src/createResolver.ts src/createListResolver.ts

+84-21
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,70 @@ const argsAdvancedProcessing = (
129129
return findOptions
130130
}
131131

132-
export default function createResolver(
132+
async function trimAndOptimizeFindOptions({
133+
findOptions,
134+
graphqlTypeDeclaration,
135+
info,
136+
models,
137+
}: {
138+
findOptions: any
139+
graphqlTypeDeclaration: any
140+
info: any
141+
models: any
142+
}) {
143+
const trimedFindOptions =
144+
graphqlTypeDeclaration.list &&
145+
graphqlTypeDeclaration.list.removeUnusedAttributes === false
146+
? findOptions
147+
: removeUnusedAttributes(
148+
findOptions,
149+
info,
150+
graphqlTypeDeclaration.model,
151+
models
152+
)
153+
154+
if (
155+
// If we have a list with a limit and an offset
156+
trimedFindOptions.limit &&
157+
trimedFindOptions.offset &&
158+
// And no explicit instructions to not optimize it.
159+
// In the majority of the case, doubling the number of queries should be either
160+
// faster OR not significantly slower.
161+
// As GSG is made to be "easy-to-use", we optimize by default.
162+
// We expect limit to be small enough to not cause performance issues.
163+
// If you are in a case where you need to fetch a big offset, you should disable the optimization.
164+
(!graphqlTypeDeclaration.list ||
165+
typeof graphqlTypeDeclaration.list.disableOptimizationForLimitOffset ===
166+
'undefined' ||
167+
graphqlTypeDeclaration.list.disableOptimizationForLimitOffset !== true)
168+
) {
169+
// then we pre-fetch the ids to avoid slowness problems for big offsets.
170+
const fetchIdsFindOptions = {
171+
...trimedFindOptions,
172+
// We only fetch the primary attribute
173+
attributes: [graphqlTypeDeclaration.model.primaryKeyAttribute],
174+
}
175+
const result = await graphqlTypeDeclaration.model.findAll(
176+
fetchIdsFindOptions
177+
)
178+
179+
return {
180+
...trimedFindOptions,
181+
offset: undefined,
182+
limit: undefined,
183+
// We override the where to only fetch the rows we want.
184+
where: {
185+
[graphqlTypeDeclaration.model.primaryKeyAttribute]: result.map(
186+
(r: any) => r[graphqlTypeDeclaration.model.primaryKeyAttribute]
187+
),
188+
},
189+
}
190+
}
191+
192+
return trimedFindOptions
193+
}
194+
195+
export default function createListResolver(
133196
graphqlTypeDeclaration: ModelDeclarationType<any>,
134197
models: any,
135198
globalPreCallback: any,
@@ -167,7 +230,7 @@ export default function createResolver(
167230
? graphqlTypeDeclaration.list.contextToOptions
168231
: undefined,
169232
before: async (findOptions: any, args: any, context: any, info: any) => {
170-
const processedFindOptions = argsAdvancedProcessing(
233+
let processedFindOptions = argsAdvancedProcessing(
171234
findOptions,
172235
args,
173236
context,
@@ -194,6 +257,8 @@ export default function createResolver(
194257
findOptions.limit = graphqlTypeDeclaration.list.enforceMaxLimit
195258
}
196259
}
260+
261+
// Global hooks, cannot impact the findOptions
197262
if (graphqlTypeDeclaration.before) {
198263
const beforeList: GlobalBeforeHook[] =
199264
typeof graphqlTypeDeclaration.before.length !== 'undefined'
@@ -211,37 +276,35 @@ export default function createResolver(
211276
}
212277
}
213278

279+
// before hook, can mutate the findOptions
214280
if (listBefore) {
215281
const handle = globalPreCallback('listBefore')
216-
const result = await listBefore(
282+
const resultBefore = await listBefore(
217283
processedFindOptions,
218284
args,
219285
context,
220286
info
221287
)
288+
if (!resultBefore) {
289+
throw new Error(
290+
'The before hook of the list endpoint must return a value.'
291+
)
292+
}
293+
294+
// The list overwrite the findOptions
295+
processedFindOptions = resultBefore
296+
222297
if (handle) {
223298
handle()
224299
}
225-
return graphqlTypeDeclaration.list &&
226-
graphqlTypeDeclaration.list.removeUnusedAttributes === false
227-
? result
228-
: removeUnusedAttributes(
229-
result,
230-
info,
231-
graphqlTypeDeclaration.model,
232-
models
233-
)
234300
}
235301

236-
return graphqlTypeDeclaration.list &&
237-
graphqlTypeDeclaration.list.removeUnusedAttributes === false
238-
? processedFindOptions
239-
: removeUnusedAttributes(
240-
processedFindOptions,
241-
info,
242-
graphqlTypeDeclaration.model,
243-
models
244-
)
302+
return trimAndOptimizeFindOptions({
303+
findOptions: processedFindOptions,
304+
graphqlTypeDeclaration,
305+
info,
306+
models,
307+
})
245308
},
246309
after: async (
247310
result: Model<any> | Model<any>[],

src/queryResolvers/list.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { GraphQLList, GraphQLType } from 'graphql'
22
import { defaultArgs, defaultListArgs } from 'graphql-sequelize'
33

4-
import createResolver from '../createResolver'
54
import injectAssociations from '../associations/inject'
5+
import createListResolver from '../createListResolver'
66
/**
77
* Returns a root `GraphQLObjectType` used as query for `GraphQLSchema`.
88
*
@@ -46,6 +46,6 @@ export default function generateListResolver(
4646
? schemaDeclaration.list.extraArg
4747
: {}),
4848
},
49-
resolve: createResolver(schemaDeclaration, models, globalPreCallback),
49+
resolve: createListResolver(schemaDeclaration, models, globalPreCallback),
5050
}
5151
}

src/tests/basic.spec.js

+18
Original file line numberDiff line numberDiff line change
@@ -282,5 +282,23 @@ describe('Test the API queries', () => {
282282
.set('userId', 1)
283283
expect(responseNull.body.errors).toBeUndefined()
284284
expect(responseNull.body.data.locations.length).toBe(2)
285+
286+
const responseWorksWithLimitAndOffsetOptimization = await request(server)
287+
.get(
288+
`/graphql?query=
289+
query getLocations {
290+
locations: location(limit: 10, offset:10) {
291+
id
292+
}
293+
}
294+
&operationName=getLocations`
295+
)
296+
.set('userId', 1)
297+
expect(
298+
responseWorksWithLimitAndOffsetOptimization.body.errors
299+
).toBeUndefined()
300+
expect(
301+
responseWorksWithLimitAndOffsetOptimization.body.data.locations.length
302+
).toBe(2)
285303
})
286304
})

src/types/types.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export type DeleteFieldDeclarationType<M extends Model<M>> = {
190190

191191
export type ListDeclarationType<M extends Model<any>> = {
192192
removeUnusedAttributes?: boolean
193+
disableOptimizationForLimitOffset?: boolean
193194
extraArg?: ExtraArg
194195
before?: QueryBeforeHook<M>
195196
after?: ListAfterHook<M>

0 commit comments

Comments
 (0)