nodester 0.7.12 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/middlewares/ql/sequelize/interpreter/QueryLexer.js +5 -3
- package/lib/models/mixins.js +87 -38
- package/lib/query/traverse/index.js +3 -1
- package/lib/structures/Filter.js +12 -16
- package/package.json +1 -1
- package/tests/aggregates.test.js +2 -8
- package/tests/clauses.test.js +1 -4
- package/tests/nql.test.js +26 -0
- package/tests/traverse.test.js +468 -0
|
@@ -160,10 +160,12 @@ module.exports = class QueryLexer {
|
|
|
160
160
|
// If end of OP token:
|
|
161
161
|
if (!!tree.node.op) {
|
|
162
162
|
|
|
163
|
-
// If token is empty,
|
|
163
|
+
// If token is empty, treat as a no-op.
|
|
164
|
+
// e.g. `name=like()` means "no filter on this field"
|
|
165
|
+
// (common when a frontend search field is cleared).
|
|
164
166
|
if (token === '') {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
tree.node.resetOP();
|
|
168
|
+
continue;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
// Structure of a value depends on OP:
|
package/lib/models/mixins.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* nodester
|
|
3
3
|
* MIT Licensed
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* CRUD mixins for any model:
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
// Utils:
|
|
13
13
|
const {
|
|
14
14
|
modelHasAssociations,
|
|
@@ -59,7 +59,7 @@ function _implementsCRUD(modelDefinition) {
|
|
|
59
59
|
|
|
60
60
|
/** Main mixinis: */
|
|
61
61
|
async function _createWithIncludes(
|
|
62
|
-
data={}
|
|
62
|
+
data = {}
|
|
63
63
|
) {
|
|
64
64
|
try {
|
|
65
65
|
const instance = await this.create(data);
|
|
@@ -77,7 +77,7 @@ async function _createWithIncludes(
|
|
|
77
77
|
if (modelHasAssociations(this)) {
|
|
78
78
|
const allModelAssociations = Object.entries(this.associations);
|
|
79
79
|
|
|
80
|
-
for (const [
|
|
80
|
+
for (const [associationName, associationDefinition] of allModelAssociations) {
|
|
81
81
|
|
|
82
82
|
// If data of this association is present:
|
|
83
83
|
if (!!data[associationName]) {
|
|
@@ -85,10 +85,11 @@ async function _createWithIncludes(
|
|
|
85
85
|
const {
|
|
86
86
|
associatedModel,
|
|
87
87
|
foreignKey,
|
|
88
|
-
associationType
|
|
88
|
+
associationType,
|
|
89
|
+
accessors
|
|
89
90
|
} = getModelAssociationProps(associationDefinition);
|
|
90
91
|
|
|
91
|
-
// If association type is HasMany or HasOne
|
|
92
|
+
// If association type is HasMany or HasOne:
|
|
92
93
|
if (associationType === 'HasMany' || associationType === 'HasOne') {
|
|
93
94
|
|
|
94
95
|
// Process current instance.
|
|
@@ -99,11 +100,35 @@ async function _createWithIncludes(
|
|
|
99
100
|
parentForeignKey: foreignKey,
|
|
100
101
|
});
|
|
101
102
|
|
|
102
|
-
fullInstanceData[associationName] =_unwrapUpdateOrCreateOrDeleteOperationsResults(
|
|
103
|
+
fullInstanceData[associationName] = _unwrapUpdateOrCreateOrDeleteOperationsResults(
|
|
103
104
|
operationsResults,
|
|
104
105
|
associationType
|
|
105
106
|
);
|
|
106
107
|
}
|
|
108
|
+
// BelongsToMany: link existing records via junction table.
|
|
109
|
+
else if (associationType === 'BelongsToMany') {
|
|
110
|
+
const associationData = Array.isArray(data[associationName])
|
|
111
|
+
? data[associationName]
|
|
112
|
+
: [data[associationName]];
|
|
113
|
+
|
|
114
|
+
const pkField = associatedModel.primaryKeyField;
|
|
115
|
+
const ids = [];
|
|
116
|
+
|
|
117
|
+
for (const item of associationData) {
|
|
118
|
+
const exists = await associatedModel.findByPk(item[pkField]);
|
|
119
|
+
if (!exists) {
|
|
120
|
+
const err = new Error(
|
|
121
|
+
`Before you can associate ${associationName} with ${this.name}, ${associationName} must be created separately.`
|
|
122
|
+
);
|
|
123
|
+
err.status = 406;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
ids.push(item[pkField]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await instance[accessors.set](ids);
|
|
130
|
+
fullInstanceData[associationName] = associationData;
|
|
131
|
+
}
|
|
107
132
|
|
|
108
133
|
fullInstanceData[associationName] = fullInstanceData[associationName] ?? data[associationName];
|
|
109
134
|
}
|
|
@@ -114,16 +139,16 @@ async function _createWithIncludes(
|
|
|
114
139
|
// Variable, that is used by _updateById,
|
|
115
140
|
// but we will also put it here to make API consistent.
|
|
116
141
|
const isNewRecord = true;
|
|
117
|
-
return Promise.resolve([
|
|
142
|
+
return Promise.resolve([isNewRecord, fullInstanceData]);
|
|
118
143
|
}
|
|
119
|
-
catch(error) {
|
|
144
|
+
catch (error) {
|
|
120
145
|
return Promise.reject(error);
|
|
121
146
|
}
|
|
122
147
|
}
|
|
123
148
|
|
|
124
149
|
function _findById(
|
|
125
|
-
id=null,
|
|
126
|
-
opts={}
|
|
150
|
+
id = null,
|
|
151
|
+
opts = {}
|
|
127
152
|
) {
|
|
128
153
|
const { query } = opts;
|
|
129
154
|
|
|
@@ -154,7 +179,7 @@ function _findById(
|
|
|
154
179
|
return this.findOne(_query);
|
|
155
180
|
}
|
|
156
181
|
|
|
157
|
-
function _findMany(opts={}) {
|
|
182
|
+
function _findMany(opts = {}) {
|
|
158
183
|
const { query } = opts;
|
|
159
184
|
|
|
160
185
|
let _query = {};
|
|
@@ -189,7 +214,7 @@ function _findMany(opts={}) {
|
|
|
189
214
|
async function _updateOne(
|
|
190
215
|
where,
|
|
191
216
|
data,
|
|
192
|
-
opts={}
|
|
217
|
+
opts = {}
|
|
193
218
|
) {
|
|
194
219
|
try {
|
|
195
220
|
const include = opts.include ?? [];
|
|
@@ -226,11 +251,11 @@ async function _updateOne(
|
|
|
226
251
|
|
|
227
252
|
const associationDefinition = this.associations[association];
|
|
228
253
|
const includeData = data[association];
|
|
229
|
-
|
|
254
|
+
|
|
230
255
|
const {
|
|
231
256
|
associatedModel,
|
|
232
257
|
associationType,
|
|
233
|
-
|
|
258
|
+
|
|
234
259
|
foreignKey,
|
|
235
260
|
sourceKey
|
|
236
261
|
} = getModelAssociationProps(associationDefinition);
|
|
@@ -242,13 +267,12 @@ async function _updateOne(
|
|
|
242
267
|
|
|
243
268
|
const pkField = associatedModel.primaryKeyField;
|
|
244
269
|
|
|
245
|
-
|
|
246
|
-
switch(associationType) {
|
|
270
|
+
switch (associationType) {
|
|
247
271
|
case 'HasMany': {
|
|
248
272
|
// Handle empty array (remove all old associations):
|
|
249
273
|
if (Array.isArray(includeData) && includeData.length === 0) {
|
|
250
274
|
const where = {
|
|
251
|
-
[foreignKey]: instance.id
|
|
275
|
+
[foreignKey]: instance.id
|
|
252
276
|
}
|
|
253
277
|
await associatedModel.destroy({ where });
|
|
254
278
|
fullInstanceData[association] = [];
|
|
@@ -260,10 +284,10 @@ async function _updateOne(
|
|
|
260
284
|
[pkField]: singleData[pkField]
|
|
261
285
|
}
|
|
262
286
|
return associatedModel.updateOne(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
287
|
+
where,
|
|
288
|
+
singleData,
|
|
289
|
+
associationUpdateOpts
|
|
290
|
+
)
|
|
267
291
|
});
|
|
268
292
|
fullInstanceData[association] = await Promise.all(promises);
|
|
269
293
|
|
|
@@ -285,14 +309,39 @@ async function _updateOne(
|
|
|
285
309
|
[pkField]: includeData[pkField]
|
|
286
310
|
}
|
|
287
311
|
fullInstanceData[association] = await associatedModel.updateOne(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
312
|
+
where,
|
|
313
|
+
includeData,
|
|
314
|
+
associationUpdateOpts
|
|
315
|
+
);
|
|
292
316
|
}
|
|
293
317
|
continue;
|
|
294
318
|
}
|
|
295
319
|
|
|
320
|
+
case 'BelongsToMany': {
|
|
321
|
+
const associationItems = Array.isArray(includeData) ? includeData : [includeData];
|
|
322
|
+
const ids = [];
|
|
323
|
+
|
|
324
|
+
for (const item of associationItems) {
|
|
325
|
+
const exists = await associatedModel.findByPk(item[pkField]);
|
|
326
|
+
if (!exists) {
|
|
327
|
+
const err = new Error(
|
|
328
|
+
`Before you can associate ${association} with ${this.name}, ${association} must be created separately.`
|
|
329
|
+
);
|
|
330
|
+
err.status = 406;
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
ids.push(item[pkField]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Replaces the full set of associations via the junction table.
|
|
337
|
+
// Sending an empty array removes all associations.
|
|
338
|
+
const { accessors: btmAccessors } = getModelAssociationProps(associationDefinition);
|
|
339
|
+
await instance[btmAccessors.set](ids);
|
|
340
|
+
fullInstanceData[association] = associationItems;
|
|
341
|
+
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
296
345
|
default:
|
|
297
346
|
continue;
|
|
298
347
|
}
|
|
@@ -309,15 +358,15 @@ async function _updateOne(
|
|
|
309
358
|
...fullInstanceData
|
|
310
359
|
});
|
|
311
360
|
}
|
|
312
|
-
catch(error) {
|
|
361
|
+
catch (error) {
|
|
313
362
|
return Promise.reject(error);
|
|
314
363
|
}
|
|
315
364
|
}
|
|
316
365
|
|
|
317
366
|
async function _updateById(
|
|
318
|
-
id=null,
|
|
319
|
-
data={},
|
|
320
|
-
opts={}
|
|
367
|
+
id = null,
|
|
368
|
+
data = {},
|
|
369
|
+
opts = {}
|
|
321
370
|
) {
|
|
322
371
|
const where = { id };
|
|
323
372
|
return this.updateOne(
|
|
@@ -328,7 +377,7 @@ async function _updateById(
|
|
|
328
377
|
}
|
|
329
378
|
|
|
330
379
|
|
|
331
|
-
function _deleteOne(query={}) {
|
|
380
|
+
function _deleteOne(query = {}) {
|
|
332
381
|
const _query = {
|
|
333
382
|
...query,
|
|
334
383
|
limit: 1
|
|
@@ -337,7 +386,7 @@ function _deleteOne(query={}) {
|
|
|
337
386
|
}
|
|
338
387
|
|
|
339
388
|
function _deleteById(
|
|
340
|
-
id=null
|
|
389
|
+
id = null
|
|
341
390
|
) {
|
|
342
391
|
const query = {
|
|
343
392
|
where: { id }
|
|
@@ -378,7 +427,7 @@ async function _updateOrCreateOrDelete(
|
|
|
378
427
|
operation,
|
|
379
428
|
});
|
|
380
429
|
}
|
|
381
|
-
catch(error) {
|
|
430
|
+
catch (error) {
|
|
382
431
|
return Promise.reject(error);
|
|
383
432
|
}
|
|
384
433
|
}
|
|
@@ -398,7 +447,7 @@ async function _updateOrCreateOrDeleteBasedOnAssociationData({
|
|
|
398
447
|
// If single instance of associated model:
|
|
399
448
|
if (isSingleInstance) {
|
|
400
449
|
const operationResult = await _updateOrCreateOrDelete(
|
|
401
|
-
associatedModel,
|
|
450
|
+
associatedModel,
|
|
402
451
|
compileModelAssociationData({
|
|
403
452
|
dataOfAssociation,
|
|
404
453
|
parentForeignKey,
|
|
@@ -406,14 +455,14 @@ async function _updateOrCreateOrDeleteBasedOnAssociationData({
|
|
|
406
455
|
})
|
|
407
456
|
);
|
|
408
457
|
|
|
409
|
-
result = [
|
|
458
|
+
result = [operationResult];
|
|
410
459
|
}
|
|
411
460
|
// If multiple instances of associated model:
|
|
412
461
|
else {
|
|
413
462
|
// Update or create each:
|
|
414
463
|
const promises = dataOfAssociation.map(
|
|
415
464
|
(data) => _updateOrCreateOrDelete(
|
|
416
|
-
associatedModel,
|
|
465
|
+
associatedModel,
|
|
417
466
|
compileModelAssociationData({
|
|
418
467
|
dataOfAssociation: data,
|
|
419
468
|
parentForeignKey: parentForeignKey,
|
|
@@ -428,7 +477,7 @@ async function _updateOrCreateOrDeleteBasedOnAssociationData({
|
|
|
428
477
|
|
|
429
478
|
return Promise.resolve(result);
|
|
430
479
|
}
|
|
431
|
-
catch(error) {
|
|
480
|
+
catch (error) {
|
|
432
481
|
return Promise.reject(error);
|
|
433
482
|
}
|
|
434
483
|
}
|
|
@@ -439,7 +488,7 @@ function _unwrapUpdateOrCreateOrDeleteOperationsResults(
|
|
|
439
488
|
) {
|
|
440
489
|
// If instance was not deleted, add it to final array.
|
|
441
490
|
const result = operationsResults.filter(({ operation }) => operation !== 'deleted')
|
|
442
|
-
|
|
491
|
+
.map(({ newInstance }) => newInstance[1]);
|
|
443
492
|
|
|
444
493
|
// If this association referenced only certain record,
|
|
445
494
|
// unwrap "result" array and send first element:
|
|
@@ -148,7 +148,9 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
148
148
|
for (const fnParams of functions) {
|
|
149
149
|
const fnName = fnParams.fn;
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
const isAllowed = filter.functions.indexOf(fnName) > -1;
|
|
152
|
+
|
|
153
|
+
if (!isAllowed) {
|
|
152
154
|
const err = new NodesterQueryError(`Function '${fnName}' is not allowed.`);
|
|
153
155
|
Error.captureStackTrace(err, traverse);
|
|
154
156
|
throw err;
|
package/lib/structures/Filter.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* nodester
|
|
3
3
|
* MIT Licensed
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
8
|
const BOUNDS = require('../constants/Bounds');
|
|
@@ -37,9 +37,9 @@ const consl = require('nodester/loggers/console');
|
|
|
37
37
|
*/
|
|
38
38
|
module.exports = class NodesterFilter {
|
|
39
39
|
|
|
40
|
-
constructor(model=null, options={}) {
|
|
40
|
+
constructor(model = null, options = {}) {
|
|
41
41
|
ensure(options, 'object,required', 'options');
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
this._model = model;
|
|
44
44
|
|
|
45
45
|
this._attributes = [];
|
|
@@ -52,7 +52,7 @@ module.exports = class NodesterFilter {
|
|
|
52
52
|
attributes: {},
|
|
53
53
|
clauses: {}
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
this._statics = {
|
|
57
57
|
attributes: {},
|
|
58
58
|
clauses: {}
|
|
@@ -72,7 +72,7 @@ module.exports = class NodesterFilter {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
const {
|
|
77
77
|
attributes,
|
|
78
78
|
clauses,
|
|
@@ -105,12 +105,8 @@ module.exports = class NodesterFilter {
|
|
|
105
105
|
|
|
106
106
|
// If functions are set:
|
|
107
107
|
if (!!functions) {
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
||
|
|
111
|
-
Array.isArray(functions)
|
|
112
|
-
) {
|
|
113
|
-
const err = new TypeError(`[NodesterFilter]: 'functions' parameter must be an object.`);
|
|
108
|
+
if (!Array.isArray(functions)) {
|
|
109
|
+
const err = new TypeError(`[NodesterFilter]: 'functions' parameter must be an array.`);
|
|
114
110
|
throw err;
|
|
115
111
|
}
|
|
116
112
|
|
|
@@ -120,12 +116,12 @@ module.exports = class NodesterFilter {
|
|
|
120
116
|
// Includes:
|
|
121
117
|
if (typeof includes === 'object') {
|
|
122
118
|
const { associations } = this.model;
|
|
123
|
-
for (const [
|
|
119
|
+
for (const [includeName, includeFilter] of Object.entries(includes)) {
|
|
124
120
|
const association = associations[includeName];
|
|
125
|
-
|
|
121
|
+
|
|
126
122
|
// Validate association by name:
|
|
127
123
|
if (association === undefined) {
|
|
128
|
-
const error = new TypeError(`No include named '${
|
|
124
|
+
const error = new TypeError(`No include named '${includeName}'.`);
|
|
129
125
|
Error.captureStackTrace(error, this.constructor);
|
|
130
126
|
throw error;
|
|
131
127
|
}
|
|
@@ -138,8 +134,8 @@ module.exports = class NodesterFilter {
|
|
|
138
134
|
// Empty bounds:
|
|
139
135
|
if (!!includeFilter.statics.clauses.limit) {
|
|
140
136
|
const msg = [
|
|
141
|
-
`include "${
|
|
142
|
-
`"${
|
|
137
|
+
`include "${includeName}" has association type of`,
|
|
138
|
+
`"${associationType}", but has a filter clause "limit",`,
|
|
143
139
|
`which is forbidden on any association type except for "HasMany".`,
|
|
144
140
|
`It was automatically removed from clauses.`,
|
|
145
141
|
`Consider also removing it from your code.`
|
package/package.json
CHANGED
package/tests/aggregates.test.js
CHANGED
|
@@ -135,13 +135,7 @@ describe('nodester Aggregates', () => {
|
|
|
135
135
|
const mockFilter = {
|
|
136
136
|
model: mockModel,
|
|
137
137
|
attributes: ['id', 'price', 'score', 'age', 'category_id', 'brand_id'],
|
|
138
|
-
functions:
|
|
139
|
-
count: { target: 'comments' }, // Just to satisfy ensure calls if any
|
|
140
|
-
sum: true,
|
|
141
|
-
avg: true,
|
|
142
|
-
min: true,
|
|
143
|
-
max: true
|
|
144
|
-
},
|
|
138
|
+
functions: ['count', 'sum', 'avg', 'min', 'max'],
|
|
145
139
|
clauses: ['group_by'],
|
|
146
140
|
bounds: { clauses: {} },
|
|
147
141
|
statics: { attributes: {}, clauses: {} },
|
|
@@ -149,7 +143,7 @@ describe('nodester Aggregates', () => {
|
|
|
149
143
|
comments: {
|
|
150
144
|
model: { options: { name: { singular: 'Comment', plural: 'Comments' } }, tableAttributes: { id: {} } },
|
|
151
145
|
attributes: ['id'],
|
|
152
|
-
functions:
|
|
146
|
+
functions: ['count', 'sum'],
|
|
153
147
|
clauses: [],
|
|
154
148
|
bounds: { clauses: {} },
|
|
155
149
|
statics: { attributes: {}, clauses: {} },
|
package/tests/clauses.test.js
CHANGED
|
@@ -85,10 +85,7 @@ describe('nodester Clauses', () => {
|
|
|
85
85
|
const mockFilter = {
|
|
86
86
|
model: mockModel,
|
|
87
87
|
attributes: ['id', 'title', 'category_id', 'brand_id', 'created_at'],
|
|
88
|
-
functions:
|
|
89
|
-
count: true,
|
|
90
|
-
sum: true
|
|
91
|
-
},
|
|
88
|
+
functions: ['count', 'sum'],
|
|
92
89
|
clauses: ['group_by', 'order', 'order_by', 'limit', 'skip'],
|
|
93
90
|
bounds: {
|
|
94
91
|
clauses: {
|
package/tests/nql.test.js
CHANGED
|
@@ -507,6 +507,32 @@ describe('nodester Query Language', () => {
|
|
|
507
507
|
|
|
508
508
|
expect(result).toMatchObject(expected);
|
|
509
509
|
});
|
|
510
|
+
|
|
511
|
+
test('"Like" with empty argument is a no-op', async () => {
|
|
512
|
+
// Simulates: GET /api/v3/suggestions?type=COUNTRIES&name=like()
|
|
513
|
+
// Frontend sends like() when the search field is cleared.
|
|
514
|
+
const lexer = new QueryLexer('name=like()');
|
|
515
|
+
const result = await lexer.parse();
|
|
516
|
+
|
|
517
|
+
// No where clause should be produced for `name`.
|
|
518
|
+
const tree = new ModelsTree();
|
|
519
|
+
const expected = tree.root.toObject();
|
|
520
|
+
|
|
521
|
+
expect(result).toMatchObject(expected);
|
|
522
|
+
expect(result.where).not.toHaveProperty('name');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('"Like" empty arg mixed with another filter keeps the other filter', async () => {
|
|
526
|
+
const lexer = new QueryLexer('id=10&name=like()');
|
|
527
|
+
const result = await lexer.parse();
|
|
528
|
+
|
|
529
|
+
const tree = new ModelsTree();
|
|
530
|
+
tree.node.addWhere({ id: ['10'] });
|
|
531
|
+
const expected = tree.root.toObject();
|
|
532
|
+
|
|
533
|
+
expect(result).toMatchObject(expected);
|
|
534
|
+
expect(result.where).not.toHaveProperty('name');
|
|
535
|
+
});
|
|
510
536
|
});
|
|
511
537
|
|
|
512
538
|
describe('operators:in', () => {
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nodester
|
|
3
|
+
* MIT Licensed
|
|
4
|
+
*
|
|
5
|
+
* Tests for query/traverse — exercises a realistic 3-model hierarchy:
|
|
6
|
+
*
|
|
7
|
+
* Order ─HasMany─► Review
|
|
8
|
+
* ─BelongsTo─► Product
|
|
9
|
+
*
|
|
10
|
+
* All models are plain mock objects; no DB connection is needed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
describe,
|
|
17
|
+
it,
|
|
18
|
+
expect,
|
|
19
|
+
beforeEach,
|
|
20
|
+
} = require('@jest/globals');
|
|
21
|
+
|
|
22
|
+
const traverse = require('../lib/query/traverse');
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// ─── Mock models ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minimal sequelize-like helper used by traverse for ORDER BY and functions.
|
|
29
|
+
*/
|
|
30
|
+
const mockSequelize = {
|
|
31
|
+
fn: (fn, col) => ({ fn, col }),
|
|
32
|
+
col: (col) => ({ col }),
|
|
33
|
+
literal: (sql) => ({ literal: sql }),
|
|
34
|
+
random: () => 'RANDOM()',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ReviewMock = {
|
|
38
|
+
options: {
|
|
39
|
+
name: {
|
|
40
|
+
singular: 'Review',
|
|
41
|
+
plural: 'Reviews'
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
tableAttributes: {
|
|
45
|
+
id: {
|
|
46
|
+
type: { key: 'INTEGER' }
|
|
47
|
+
},
|
|
48
|
+
order_id: {
|
|
49
|
+
type: { key: 'INTEGER' }
|
|
50
|
+
},
|
|
51
|
+
rating: {
|
|
52
|
+
type: { key: 'INTEGER' }
|
|
53
|
+
},
|
|
54
|
+
body: {
|
|
55
|
+
type: { key: 'STRING' }
|
|
56
|
+
},
|
|
57
|
+
created_at: {
|
|
58
|
+
type: { key: 'DATE' }
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
associations: {},
|
|
62
|
+
sequelize: mockSequelize,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const ProductMock = {
|
|
66
|
+
options: {
|
|
67
|
+
name: { singular: 'Product', plural: 'Products' },
|
|
68
|
+
},
|
|
69
|
+
tableAttributes: {
|
|
70
|
+
id: {
|
|
71
|
+
type: { key: 'INTEGER' }
|
|
72
|
+
},
|
|
73
|
+
title: {
|
|
74
|
+
type: { key: 'STRING' }
|
|
75
|
+
},
|
|
76
|
+
price: {
|
|
77
|
+
type: { key: 'DECIMAL' }
|
|
78
|
+
},
|
|
79
|
+
sku: {
|
|
80
|
+
type: { key: 'STRING' }
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
associations: {},
|
|
84
|
+
sequelize: mockSequelize,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const OrderMock = {
|
|
88
|
+
options: {
|
|
89
|
+
name: { singular: 'Order', plural: 'Orders' },
|
|
90
|
+
},
|
|
91
|
+
tableAttributes: {
|
|
92
|
+
id: {
|
|
93
|
+
type: { key: 'INTEGER' }
|
|
94
|
+
},
|
|
95
|
+
status: {
|
|
96
|
+
type: { key: 'STRING' }
|
|
97
|
+
},
|
|
98
|
+
total: {
|
|
99
|
+
type: { key: 'DECIMAL' }
|
|
100
|
+
},
|
|
101
|
+
product_id: {
|
|
102
|
+
type: { key: 'INTEGER' }
|
|
103
|
+
},
|
|
104
|
+
created_at: {
|
|
105
|
+
type: { key: 'DATE' }
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
associations: {
|
|
109
|
+
reviews: {
|
|
110
|
+
as: 'reviews',
|
|
111
|
+
associationType: 'HasMany',
|
|
112
|
+
target: ReviewMock,
|
|
113
|
+
foreignKey: 'order_id',
|
|
114
|
+
sourceKey: 'id',
|
|
115
|
+
},
|
|
116
|
+
product: {
|
|
117
|
+
as: 'product',
|
|
118
|
+
associationType: 'BelongsTo',
|
|
119
|
+
target: ProductMock,
|
|
120
|
+
foreignKey: 'product_id',
|
|
121
|
+
sourceKey: 'id',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
sequelize: mockSequelize,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
// ─── Mock filters ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function makeReviewFilter(overrides = {}) {
|
|
131
|
+
return {
|
|
132
|
+
model: ReviewMock,
|
|
133
|
+
attributes: ['id', 'order_id', 'rating', 'body', 'created_at'],
|
|
134
|
+
functions: ['avg', 'count'],
|
|
135
|
+
clauses: ['limit', 'skip', 'order', 'order_by'],
|
|
136
|
+
bounds: { clauses: {} },
|
|
137
|
+
statics: { attributes: {}, clauses: {} },
|
|
138
|
+
includes: {},
|
|
139
|
+
...overrides,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeProductFilter(overrides = {}) {
|
|
144
|
+
return {
|
|
145
|
+
model: ProductMock,
|
|
146
|
+
attributes: ['id', 'title', 'price', 'sku'],
|
|
147
|
+
functions: [],
|
|
148
|
+
clauses: [],
|
|
149
|
+
bounds: { clauses: {} },
|
|
150
|
+
statics: { attributes: {}, clauses: {} },
|
|
151
|
+
includes: {},
|
|
152
|
+
...overrides,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function makeOrderFilter(overrides = {}) {
|
|
157
|
+
return {
|
|
158
|
+
model: OrderMock,
|
|
159
|
+
attributes: ['id', 'status', 'total', 'product_id', 'created_at'],
|
|
160
|
+
functions: ['sum', 'avg', 'count', 'min', 'max'],
|
|
161
|
+
clauses: ['limit', 'skip', 'order', 'order_by', 'group_by'],
|
|
162
|
+
bounds: { clauses: { limit: { min: 1, max: 100 } } },
|
|
163
|
+
statics: { attributes: {}, clauses: {} },
|
|
164
|
+
includes: {
|
|
165
|
+
reviews: makeReviewFilter(),
|
|
166
|
+
product: makeProductFilter(),
|
|
167
|
+
},
|
|
168
|
+
...overrides,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe('traverse', () => {
|
|
176
|
+
|
|
177
|
+
describe('attributes', () => {
|
|
178
|
+
|
|
179
|
+
it('returns all filter attributes when no query attributes are specified', () => {
|
|
180
|
+
const result = traverse({ attributes: [], where: {}, includes: [] }, makeOrderFilter());
|
|
181
|
+
|
|
182
|
+
expect(result.attributes).toEqual(
|
|
183
|
+
expect.arrayContaining(['id', 'status', 'total', 'product_id', 'created_at'])
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns only the requested attribute subset', () => {
|
|
188
|
+
const result = traverse(
|
|
189
|
+
{ attributes: ['id', 'status'], where: {}, includes: [] },
|
|
190
|
+
makeOrderFilter()
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(result.attributes).toEqual(expect.arrayContaining(['id', 'status']));
|
|
194
|
+
expect(result.attributes).not.toContain('total');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('throws when a requested attribute is not in the filter whitelist', () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
traverse(
|
|
200
|
+
{ attributes: ['secret_field'], where: {}, includes: [] },
|
|
201
|
+
makeOrderFilter()
|
|
202
|
+
)
|
|
203
|
+
).toThrow();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
describe('where', () => {
|
|
209
|
+
|
|
210
|
+
it('maps a simple equality condition', () => {
|
|
211
|
+
const result = traverse(
|
|
212
|
+
{ attributes: [], where: { status: ['paid'] }, includes: [] },
|
|
213
|
+
makeOrderFilter()
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(result.where).toMatchObject({ status: ['paid'] });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('maps an equality condition on a string-like field', () => {
|
|
220
|
+
const result = traverse(
|
|
221
|
+
{ attributes: [], where: { status: { not: ['cancelled'] } }, includes: [] },
|
|
222
|
+
makeOrderFilter()
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// traverse produces a Sequelize Op; just verify the key is present:
|
|
226
|
+
expect(result.where).toHaveProperty('status');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('skips where entirely when no conditions are set', () => {
|
|
230
|
+
const result = traverse(
|
|
231
|
+
{ attributes: [], where: {}, includes: [] },
|
|
232
|
+
makeOrderFilter()
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// traverse removes the where key when empty:
|
|
236
|
+
expect(result).not.toHaveProperty('where');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('applies statics.attributes on top of query where', () => {
|
|
240
|
+
const filter = makeOrderFilter({
|
|
241
|
+
statics: { attributes: { status: 'shipped' }, clauses: {} },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = traverse(
|
|
245
|
+
{ attributes: [], where: {}, includes: [] },
|
|
246
|
+
filter
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(result.where).toHaveProperty('status');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
describe('clauses', () => {
|
|
255
|
+
|
|
256
|
+
it('applies limit within bounds', () => {
|
|
257
|
+
const result = traverse(
|
|
258
|
+
{ attributes: [], where: {}, includes: [], limit: 10 },
|
|
259
|
+
makeOrderFilter()
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(result.limit).toBe(10);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('clamps limit to max bound', () => {
|
|
266
|
+
const result = traverse(
|
|
267
|
+
{ attributes: [], where: {}, includes: [], limit: 9999 },
|
|
268
|
+
makeOrderFilter()
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(result.limit).toBe(100); // max is 100
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('applies skip (offset)', () => {
|
|
275
|
+
const result = traverse(
|
|
276
|
+
{ attributes: [], where: {}, includes: [], skip: 20 },
|
|
277
|
+
makeOrderFilter()
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(result.offset).toBe(20);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('applies order asc/desc', () => {
|
|
284
|
+
const result = traverse(
|
|
285
|
+
{ attributes: [], where: {}, includes: [], order: 'desc', order_by: 'created_at' },
|
|
286
|
+
makeOrderFilter()
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(result.order).toEqual([['created_at', 'desc']]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('applies random order', () => {
|
|
293
|
+
const result = traverse(
|
|
294
|
+
{ attributes: [], where: {}, includes: [], order: 'rand' },
|
|
295
|
+
makeOrderFilter()
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
expect(result.order).toBe('RANDOM()');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
describe('functions', () => {
|
|
304
|
+
|
|
305
|
+
it('allows a whitelisted function (sum)', () => {
|
|
306
|
+
const result = traverse(
|
|
307
|
+
{ functions: [{ fn: 'sum', args: ['total'] }], attributes: [], where: {}, includes: [] },
|
|
308
|
+
makeOrderFilter()
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(result.attributes).toContainEqual([
|
|
312
|
+
{ fn: 'SUM', col: { col: 'total' } },
|
|
313
|
+
'total_sum',
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('throws for a function not in the whitelist', () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
traverse(
|
|
320
|
+
{ functions: [{ fn: 'median', args: ['total'] }], attributes: [], where: {}, includes: [] },
|
|
321
|
+
makeOrderFilter()
|
|
322
|
+
)
|
|
323
|
+
).toThrow(/not allowed/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('allows functions defined via array in Filter', () => {
|
|
327
|
+
const filter = makeOrderFilter({
|
|
328
|
+
functions: ['count', 'avg'],
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// count and avg should be allowed, sum should not:
|
|
332
|
+
expect(() =>
|
|
333
|
+
traverse(
|
|
334
|
+
{ functions: [{ fn: 'count', args: ['reviews'] }], attributes: [], where: {}, includes: [] },
|
|
335
|
+
filter
|
|
336
|
+
)
|
|
337
|
+
).not.toThrow();
|
|
338
|
+
|
|
339
|
+
expect(() =>
|
|
340
|
+
traverse(
|
|
341
|
+
{ functions: [{ fn: 'sum', args: ['total'] }], attributes: [], where: {}, includes: [] },
|
|
342
|
+
filter
|
|
343
|
+
)
|
|
344
|
+
).toThrow(/not allowed/);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
describe('includes', () => {
|
|
350
|
+
|
|
351
|
+
it('includes reviews when requested', () => {
|
|
352
|
+
const result = traverse(
|
|
353
|
+
{
|
|
354
|
+
attributes: [],
|
|
355
|
+
where: {},
|
|
356
|
+
includes: [
|
|
357
|
+
{ model: 'reviews', attributes: [], where: {}, includes: [] }
|
|
358
|
+
]
|
|
359
|
+
},
|
|
360
|
+
makeOrderFilter()
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const reviewInclude = result.include.find(i => i.association === 'reviews');
|
|
364
|
+
expect(reviewInclude).toBeDefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('includes product when requested', () => {
|
|
368
|
+
const result = traverse(
|
|
369
|
+
{
|
|
370
|
+
attributes: [],
|
|
371
|
+
where: {},
|
|
372
|
+
includes: [
|
|
373
|
+
{ model: 'product', attributes: [], where: {}, includes: [] }
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
makeOrderFilter()
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const productInclude = result.include.find(i => i.association === 'product');
|
|
380
|
+
expect(productInclude).toBeDefined();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('applies sub-filter attributes on the included model', () => {
|
|
384
|
+
const result = traverse(
|
|
385
|
+
{
|
|
386
|
+
attributes: [],
|
|
387
|
+
where: {},
|
|
388
|
+
includes: [
|
|
389
|
+
{ model: 'product', attributes: ['id', 'title'], where: {}, includes: [] }
|
|
390
|
+
]
|
|
391
|
+
},
|
|
392
|
+
makeOrderFilter()
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const productInclude = result.include.find(i => i.association === 'product');
|
|
396
|
+
expect(productInclude.attributes).toEqual(expect.arrayContaining(['id', 'title']));
|
|
397
|
+
expect(productInclude.attributes).not.toContain('sku');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('applies where clause on an included model', () => {
|
|
401
|
+
const result = traverse(
|
|
402
|
+
{
|
|
403
|
+
attributes: [],
|
|
404
|
+
where: {},
|
|
405
|
+
includes: [
|
|
406
|
+
{
|
|
407
|
+
model: 'reviews',
|
|
408
|
+
attributes: [],
|
|
409
|
+
where: { rating: ['5'] },
|
|
410
|
+
includes: []
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
},
|
|
414
|
+
makeOrderFilter()
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const reviewInclude = result.include.find(i => i.association === 'reviews');
|
|
418
|
+
expect(reviewInclude.where).toHaveProperty('rating');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('throws when requesting an include not in the filter whitelist', () => {
|
|
422
|
+
expect(() =>
|
|
423
|
+
traverse(
|
|
424
|
+
{
|
|
425
|
+
attributes: [],
|
|
426
|
+
where: {},
|
|
427
|
+
includes: [
|
|
428
|
+
{ model: 'unknown_assoc', attributes: [], where: {}, includes: [] }
|
|
429
|
+
]
|
|
430
|
+
},
|
|
431
|
+
makeOrderFilter()
|
|
432
|
+
)
|
|
433
|
+
).toThrow();
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
describe('Filter constructor — functions validation', () => {
|
|
439
|
+
const Filter = require('../lib/structures/Filter');
|
|
440
|
+
|
|
441
|
+
// A bare-minimum mock model to satisfy Filter's isModel() check.
|
|
442
|
+
const bareModel = {
|
|
443
|
+
tableName: 'orders',
|
|
444
|
+
_schema: {},
|
|
445
|
+
options: { name: { singular: 'Order', plural: 'Orders' } },
|
|
446
|
+
tableAttributes: { id: {} },
|
|
447
|
+
associations: {},
|
|
448
|
+
sequelize: mockSequelize,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
it('accepts an array for functions', () => {
|
|
452
|
+
expect(() => new Filter(bareModel, { functions: ['sum', 'avg'] })).not.toThrow();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('throws a TypeError when functions is a plain object', () => {
|
|
456
|
+
expect(() => new Filter(bareModel, { functions: { sum: true } })).toThrow(TypeError);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('throws a TypeError when functions is a string', () => {
|
|
460
|
+
expect(() => new Filter(bareModel, { functions: 'sum' })).toThrow(TypeError);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('defaults functions to an empty array when not provided', () => {
|
|
464
|
+
const f = new Filter(bareModel, {});
|
|
465
|
+
expect(f.functions).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|