nodester 0.7.11 → 0.7.13
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 +12 -6
- package/lib/query/traverse/index.js +35 -8
- package/lib/query/traverse/mappers/functions/aggregate.js +85 -0
- package/lib/query/traverse/parsers.js +26 -4
- package/lib/query/traverse/utils.js +1 -1
- package/lib/structures/Filter.js +12 -16
- package/package.json +1 -1
- package/tests/aggregates.test.js +225 -0
- package/tests/clauses.test.js +171 -0
- package/tests/dates.test.js +104 -0
- package/tests/nql.test.js +26 -0
- package/tests/traverse.test.js +468 -0
|
@@ -64,6 +64,9 @@ const OP_TOKENS = new Enum({
|
|
|
64
64
|
const FN_TOKENS = new Enum({
|
|
65
65
|
AVG: 'avg',
|
|
66
66
|
COUNT: 'count',
|
|
67
|
+
SUM: 'sum',
|
|
68
|
+
MIN: 'min',
|
|
69
|
+
MAX: 'max',
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
|
|
@@ -157,10 +160,12 @@ module.exports = class QueryLexer {
|
|
|
157
160
|
// If end of OP token:
|
|
158
161
|
if (!!tree.node.op) {
|
|
159
162
|
|
|
160
|
-
// 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).
|
|
161
166
|
if (token === '') {
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
tree.node.resetOP();
|
|
168
|
+
continue;
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
// Structure of a value depends on OP:
|
|
@@ -315,7 +320,7 @@ module.exports = class QueryLexer {
|
|
|
315
320
|
// . can mean:
|
|
316
321
|
// • vertical include
|
|
317
322
|
// • or it can be a part of a param for "where"
|
|
318
|
-
if (char === '.') {
|
|
323
|
+
if (char === '.' && !tree.node.fn && !tree.node.op) {
|
|
319
324
|
debug('char', char, { token, node: tree.node });
|
|
320
325
|
|
|
321
326
|
// Vertical include:
|
|
@@ -344,7 +349,7 @@ module.exports = class QueryLexer {
|
|
|
344
349
|
}
|
|
345
350
|
|
|
346
351
|
// + can only mean horizontal include:
|
|
347
|
-
if (char === '+') {
|
|
352
|
+
if (char === '+' && !tree.node.fn && !tree.node.op) {
|
|
348
353
|
debug('char', char, { token, node: tree.node });
|
|
349
354
|
|
|
350
355
|
// If include of a new model:
|
|
@@ -636,7 +641,8 @@ module.exports = class QueryLexer {
|
|
|
636
641
|
|
|
637
642
|
// Clauses:
|
|
638
643
|
case PARAM_TOKENS.GROUP_BY:
|
|
639
|
-
|
|
644
|
+
if (token) value.push(token);
|
|
645
|
+
treeNode.group_by = value.length > 1 ? value : value[0];
|
|
640
646
|
break;
|
|
641
647
|
|
|
642
648
|
case PARAM_TOKENS.LIMIT:
|
|
@@ -14,6 +14,7 @@ const { ensure } = require('nodester/validators/arguments');
|
|
|
14
14
|
|
|
15
15
|
// Mappers & parsers:
|
|
16
16
|
const mapCOUNT = require('./mappers/functions/count');
|
|
17
|
+
const mapAGG = require('./mappers/functions/aggregate');
|
|
17
18
|
|
|
18
19
|
const {
|
|
19
20
|
parseValue,
|
|
@@ -92,10 +93,13 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
92
93
|
//
|
|
93
94
|
// If Filter is not set,
|
|
94
95
|
// use every available attribute:
|
|
96
|
+
const isAggregateQuery = functions.length > 0 && clauses.group_by;
|
|
97
|
+
const selectAllByDefault = attributes.length === 0 && !isAggregateQuery;
|
|
98
|
+
|
|
95
99
|
if (filter === null) {
|
|
96
100
|
for (let attribute of attributesAvailable) {
|
|
97
101
|
// If no query filter or attribute is requested:
|
|
98
|
-
if (
|
|
102
|
+
if (selectAllByDefault || attributes.indexOf(attribute) > -1) {
|
|
99
103
|
newQuery.attributes.push(attribute);
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
@@ -120,7 +124,7 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
120
124
|
// }
|
|
121
125
|
|
|
122
126
|
// If no query filter or attribute is requested:
|
|
123
|
-
if (
|
|
127
|
+
if (selectAllByDefault || attributes.indexOf(attribute) > -1) {
|
|
124
128
|
newQuery.attributes.push(attribute);
|
|
125
129
|
continue;
|
|
126
130
|
}
|
|
@@ -144,7 +148,9 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
144
148
|
for (const fnParams of functions) {
|
|
145
149
|
const fnName = fnParams.fn;
|
|
146
150
|
|
|
147
|
-
|
|
151
|
+
const isAllowed = filter.functions.indexOf(fnName) > -1;
|
|
152
|
+
|
|
153
|
+
if (!isAllowed) {
|
|
148
154
|
const err = new NodesterQueryError(`Function '${fnName}' is not allowed.`);
|
|
149
155
|
Error.captureStackTrace(err, traverse);
|
|
150
156
|
throw err;
|
|
@@ -162,6 +168,20 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
162
168
|
);
|
|
163
169
|
break;
|
|
164
170
|
}
|
|
171
|
+
// SQL SUM(), AVG(), MIN(), MAX():
|
|
172
|
+
case 'sum':
|
|
173
|
+
case 'avg':
|
|
174
|
+
case 'min':
|
|
175
|
+
case 'max': {
|
|
176
|
+
mapAGG(
|
|
177
|
+
fnParams,
|
|
178
|
+
_model,
|
|
179
|
+
filter?.includes,
|
|
180
|
+
|
|
181
|
+
newQuery
|
|
182
|
+
);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
165
185
|
// Any other function:
|
|
166
186
|
default:
|
|
167
187
|
consl.warn(`function ${fnName}() is not supported`);
|
|
@@ -185,15 +205,22 @@ function traverse(queryNode, filter = null, model = null, association = null) {
|
|
|
185
205
|
|
|
186
206
|
switch (clauseName) {
|
|
187
207
|
case 'group_by': {
|
|
188
|
-
// Check if this value is a valid attribute:
|
|
189
208
|
if (typeof value === 'undefined') {
|
|
190
209
|
continue;
|
|
191
210
|
}
|
|
192
211
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
const groupFields = Array.isArray(value) ? value : [value];
|
|
213
|
+
for (const field of groupFields) {
|
|
214
|
+
if (typeof _model.tableAttributes[field] === 'undefined') {
|
|
215
|
+
const err = new NodesterQueryError(`group_by '${field}' is not allowed.`);
|
|
216
|
+
Error.captureStackTrace(err, traverse);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// For aggregate queries, group_by field MUST be in attributes:
|
|
221
|
+
if (newQuery.attributes.indexOf(field) === -1) {
|
|
222
|
+
newQuery.attributes.push(field);
|
|
223
|
+
}
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
newQuery.group = value;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { NodesterQueryError } = require('nodester/errors');
|
|
2
|
+
|
|
3
|
+
module.exports = function mapAggregate(
|
|
4
|
+
fnParams,
|
|
5
|
+
rootModel,
|
|
6
|
+
filterIncludes,
|
|
7
|
+
|
|
8
|
+
sequelizeQuery
|
|
9
|
+
) {
|
|
10
|
+
try {
|
|
11
|
+
const { sequelize } = rootModel;
|
|
12
|
+
const rootModelName = rootModel.options.name;
|
|
13
|
+
|
|
14
|
+
const { fn: fnName, args } = fnParams;
|
|
15
|
+
const [target] = args;
|
|
16
|
+
|
|
17
|
+
if (!target) {
|
|
18
|
+
const err = new NodesterQueryError(`Function '${fnName}' requires an attribute.`);
|
|
19
|
+
Error.captureStackTrace(err, mapAggregate);
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const associations = rootModel.associations;
|
|
24
|
+
let associationName = null;
|
|
25
|
+
let attributeName = target;
|
|
26
|
+
|
|
27
|
+
if (target.includes('.')) {
|
|
28
|
+
[associationName, attributeName] = target.split('.');
|
|
29
|
+
} else if (associations[target]) {
|
|
30
|
+
// If target IS an association name, it might be a mistake for sum/avg/min/max
|
|
31
|
+
// but we can try to be helpful or error out.
|
|
32
|
+
// However, count(comments) is valid.
|
|
33
|
+
const err = new NodesterQueryError(`Function '${fnName}' requires an attribute of '${target}'. Use '${target}.attribute'`);
|
|
34
|
+
Error.captureStackTrace(err, mapAggregate);
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (associationName) {
|
|
39
|
+
const association = associations[associationName];
|
|
40
|
+
if (!association) {
|
|
41
|
+
const err = new NodesterQueryError(`No include named '${associationName}'`);
|
|
42
|
+
Error.captureStackTrace(err, mapAggregate);
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if it's available in filter:
|
|
47
|
+
if (!filterIncludes[associationName]) {
|
|
48
|
+
const err = new NodesterQueryError(`Aggregate for '${associationName}' is not available.`);
|
|
49
|
+
Error.captureStackTrace(err, mapAggregate);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
as,
|
|
55
|
+
target: targetModel,
|
|
56
|
+
foreignKey,
|
|
57
|
+
sourceKey
|
|
58
|
+
} = association;
|
|
59
|
+
const { tableName } = targetModel;
|
|
60
|
+
|
|
61
|
+
// Compile request:
|
|
62
|
+
const rawSQL = `(SELECT ${fnName.toUpperCase()}(${attributeName}) FROM ${tableName} WHERE ${tableName}.${foreignKey}=${rootModelName.singular}.${sourceKey})`;
|
|
63
|
+
|
|
64
|
+
const resultAttributeName = `${as}_${fnName}_${attributeName}`;
|
|
65
|
+
sequelizeQuery.attributes.push(
|
|
66
|
+
[sequelize.literal(rawSQL), resultAttributeName]
|
|
67
|
+
);
|
|
68
|
+
} else {
|
|
69
|
+
// Root model attribute:
|
|
70
|
+
if (typeof rootModel.tableAttributes[attributeName] === 'undefined') {
|
|
71
|
+
const err = new NodesterQueryError(`Attribute '${attributeName}' is not present in model.`);
|
|
72
|
+
Error.captureStackTrace(err, mapAggregate);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const resultAttributeName = `${attributeName}_${fnName}`;
|
|
77
|
+
sequelizeQuery.attributes.push(
|
|
78
|
+
[sequelize.fn(fnName.toUpperCase(), sequelize.col(attributeName)), resultAttributeName]
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
Error.captureStackTrace(error, mapAggregate);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* nodester
|
|
3
3
|
* MIT Licensed
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
8
|
const { Op } = require('sequelize');
|
|
@@ -20,17 +20,39 @@ function _parseValue(value, attribute, model) {
|
|
|
20
20
|
// Combine all OPs into one query:
|
|
21
21
|
const allOPs = {};
|
|
22
22
|
const entries = Object.entries(value);
|
|
23
|
-
for (const [
|
|
23
|
+
for (const [opKey, rawValue] of entries) {
|
|
24
24
|
const op = Op[opKey];
|
|
25
25
|
|
|
26
26
|
let _value = rawValue;
|
|
27
27
|
|
|
28
28
|
// Sequilize does not allow Op comparisons of dates
|
|
29
29
|
// without converting the value to the Date object:
|
|
30
|
-
switch(model.tableAttributes[attribute].type.key) {
|
|
30
|
+
switch (model.tableAttributes[attribute].type.key) {
|
|
31
31
|
case DataTypes.DATE.key:
|
|
32
32
|
case DataTypes.DATEONLY.key:
|
|
33
|
-
|
|
33
|
+
const parse = (val) => {
|
|
34
|
+
if (!val) return null;
|
|
35
|
+
let d = new Date(val);
|
|
36
|
+
if (isNaN(d.valueOf()) && typeof val === 'string') {
|
|
37
|
+
// Try to fix common space/plus-instead-of-T issue:
|
|
38
|
+
d = new Date(val.trim().replace(/[ +]/g, 'T'));
|
|
39
|
+
}
|
|
40
|
+
if (isNaN(d.valueOf())) {
|
|
41
|
+
const err = new Error(`nodester: Invalid date value '${val}' for attribute '${attribute}'`);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
return d;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(rawValue)) {
|
|
48
|
+
if (opKey === 'in' || opKey === 'notIn' || opKey === 'between' || opKey === 'notBetween') {
|
|
49
|
+
_value = rawValue.map(v => parse(v));
|
|
50
|
+
} else {
|
|
51
|
+
_value = parse(rawValue[0]);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
_value = parse(rawValue);
|
|
55
|
+
}
|
|
34
56
|
break;
|
|
35
57
|
|
|
36
58
|
default:
|
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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Test utils.
|
|
2
|
+
const {
|
|
3
|
+
describe,
|
|
4
|
+
it,
|
|
5
|
+
expect
|
|
6
|
+
} = require('@jest/globals');
|
|
7
|
+
|
|
8
|
+
// Parser:
|
|
9
|
+
const { ModelsTree } = require('../lib/middlewares/ql/sequelize/interpreter/ModelsTree');
|
|
10
|
+
const QueryLexer = require('../lib/middlewares/ql/sequelize/interpreter/QueryLexer');
|
|
11
|
+
const traverse = require('../lib/query/traverse');
|
|
12
|
+
|
|
13
|
+
describe('nodester Aggregates', () => {
|
|
14
|
+
describe('Lexer', () => {
|
|
15
|
+
it('Root model: count()', async () => {
|
|
16
|
+
const lexer = new QueryLexer('fn=count()');
|
|
17
|
+
const result = await lexer.parse();
|
|
18
|
+
|
|
19
|
+
const tree = new ModelsTree();
|
|
20
|
+
tree.node.addFunction({
|
|
21
|
+
fn: 'count',
|
|
22
|
+
args: ['']
|
|
23
|
+
});
|
|
24
|
+
const expected = tree.root.toObject();
|
|
25
|
+
|
|
26
|
+
expect(result).toMatchObject(expected);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('Root model: sum(price)', async () => {
|
|
30
|
+
const lexer = new QueryLexer('fn=sum(price)');
|
|
31
|
+
const result = await lexer.parse();
|
|
32
|
+
|
|
33
|
+
const tree = new ModelsTree();
|
|
34
|
+
tree.node.addFunction({
|
|
35
|
+
fn: 'sum',
|
|
36
|
+
args: ['price']
|
|
37
|
+
});
|
|
38
|
+
const expected = tree.root.toObject();
|
|
39
|
+
|
|
40
|
+
expect(result).toMatchObject(expected);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('Root model: avg(score)', async () => {
|
|
44
|
+
const lexer = new QueryLexer('fn=avg(score)');
|
|
45
|
+
const result = await lexer.parse();
|
|
46
|
+
|
|
47
|
+
const tree = new ModelsTree();
|
|
48
|
+
tree.node.addFunction({
|
|
49
|
+
fn: 'avg',
|
|
50
|
+
args: ['score']
|
|
51
|
+
});
|
|
52
|
+
const expected = tree.root.toObject();
|
|
53
|
+
|
|
54
|
+
expect(result).toMatchObject(expected);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('Root model: min(age), max(age)', async () => {
|
|
58
|
+
const lexer = new QueryLexer('fn=min(age),max(age)');
|
|
59
|
+
const result = await lexer.parse();
|
|
60
|
+
|
|
61
|
+
const tree = new ModelsTree();
|
|
62
|
+
tree.node.addFunction({
|
|
63
|
+
fn: 'min',
|
|
64
|
+
args: ['age']
|
|
65
|
+
});
|
|
66
|
+
tree.node.addFunction({
|
|
67
|
+
fn: 'max',
|
|
68
|
+
args: ['age']
|
|
69
|
+
});
|
|
70
|
+
const expected = tree.root.toObject();
|
|
71
|
+
|
|
72
|
+
expect(result).toMatchObject(expected);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('Association: count(comments)', async () => {
|
|
76
|
+
const lexer = new QueryLexer('fn=count(comments)');
|
|
77
|
+
const result = await lexer.parse();
|
|
78
|
+
|
|
79
|
+
const tree = new ModelsTree();
|
|
80
|
+
tree.node.addFunction({
|
|
81
|
+
fn: 'count',
|
|
82
|
+
args: ['comments']
|
|
83
|
+
});
|
|
84
|
+
const expected = tree.root.toObject();
|
|
85
|
+
|
|
86
|
+
expect(result).toMatchObject(expected);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('Association: sum(items.price)', async () => {
|
|
90
|
+
const lexer = new QueryLexer('fn=sum(items.price)');
|
|
91
|
+
const result = await lexer.parse();
|
|
92
|
+
|
|
93
|
+
const tree = new ModelsTree();
|
|
94
|
+
tree.node.addFunction({
|
|
95
|
+
fn: 'sum',
|
|
96
|
+
args: ['items.price']
|
|
97
|
+
});
|
|
98
|
+
const expected = tree.root.toObject();
|
|
99
|
+
|
|
100
|
+
expect(result).toMatchObject(expected);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('Traverse', () => {
|
|
105
|
+
const mockModel = {
|
|
106
|
+
options: {
|
|
107
|
+
name: {
|
|
108
|
+
singular: 'Post',
|
|
109
|
+
plural: 'Posts'
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
associations: {
|
|
113
|
+
comments: {
|
|
114
|
+
as: 'comments',
|
|
115
|
+
target: { tableName: 'comments' },
|
|
116
|
+
foreignKey: 'post_id',
|
|
117
|
+
sourceKey: 'id'
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
tableAttributes: {
|
|
121
|
+
id: {},
|
|
122
|
+
price: {},
|
|
123
|
+
score: {},
|
|
124
|
+
age: {},
|
|
125
|
+
category_id: {},
|
|
126
|
+
brand_id: {}
|
|
127
|
+
},
|
|
128
|
+
sequelize: {
|
|
129
|
+
fn: (fn, col) => ({ fn, col }),
|
|
130
|
+
col: (col) => ({ col }),
|
|
131
|
+
literal: (sql) => ({ literal: sql })
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const mockFilter = {
|
|
136
|
+
model: mockModel,
|
|
137
|
+
attributes: ['id', 'price', 'score', 'age', 'category_id', 'brand_id'],
|
|
138
|
+
functions: ['count', 'sum', 'avg', 'min', 'max'],
|
|
139
|
+
clauses: ['group_by'],
|
|
140
|
+
bounds: { clauses: {} },
|
|
141
|
+
statics: { attributes: {}, clauses: {} },
|
|
142
|
+
includes: {
|
|
143
|
+
comments: {
|
|
144
|
+
model: { options: { name: { singular: 'Comment', plural: 'Comments' } }, tableAttributes: { id: {} } },
|
|
145
|
+
attributes: ['id'],
|
|
146
|
+
functions: ['count', 'sum'],
|
|
147
|
+
clauses: [],
|
|
148
|
+
bounds: { clauses: {} },
|
|
149
|
+
statics: { attributes: {}, clauses: {} },
|
|
150
|
+
includes: {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
it('maps root sum(price)', () => {
|
|
156
|
+
const queryNode = {
|
|
157
|
+
functions: [{ fn: 'sum', args: ['price'] }]
|
|
158
|
+
};
|
|
159
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
160
|
+
|
|
161
|
+
expect(result.attributes).toContainEqual([
|
|
162
|
+
{ fn: 'SUM', col: { col: 'price' } },
|
|
163
|
+
'price_sum'
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('maps association count(comments)', () => {
|
|
168
|
+
const queryNode = {
|
|
169
|
+
functions: [{ fn: 'count', args: ['comments'] }]
|
|
170
|
+
};
|
|
171
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
172
|
+
|
|
173
|
+
expect(result.attributes).toContainEqual([
|
|
174
|
+
{ literal: '(SELECT COUNT(*) FROM comments where comments.post_id=Post.id)' },
|
|
175
|
+
'comments_count'
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('maps association sum(comments.votes)', () => {
|
|
180
|
+
const queryNode = {
|
|
181
|
+
functions: [{ fn: 'sum', args: ['comments.votes'] }]
|
|
182
|
+
};
|
|
183
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
184
|
+
|
|
185
|
+
expect(result.attributes).toContainEqual([
|
|
186
|
+
{ literal: '(SELECT SUM(votes) FROM comments WHERE comments.post_id=Post.id)' },
|
|
187
|
+
'comments_sum_votes'
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('maps root sum(price) with group_by', () => {
|
|
192
|
+
const queryNode = {
|
|
193
|
+
functions: [{ fn: 'sum', args: ['price'] }],
|
|
194
|
+
group_by: 'category_id'
|
|
195
|
+
};
|
|
196
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
197
|
+
|
|
198
|
+
expect(result.group).toBe('category_id');
|
|
199
|
+
expect(result.attributes).toContainEqual([
|
|
200
|
+
{ fn: 'SUM', col: { col: 'price' } },
|
|
201
|
+
'price_sum'
|
|
202
|
+
]);
|
|
203
|
+
// Check if it also includes category_id in attributes
|
|
204
|
+
expect(result.attributes).toContain('category_id');
|
|
205
|
+
// Ensure id is NOT there (default attributes should be excluded)
|
|
206
|
+
expect(result.attributes).not.toContain('id');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('maps root sum(price) with multiple group_by', () => {
|
|
210
|
+
const queryNode = {
|
|
211
|
+
functions: [{ fn: 'sum', args: ['price'] }],
|
|
212
|
+
group_by: ['category_id', 'brand_id']
|
|
213
|
+
};
|
|
214
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
215
|
+
|
|
216
|
+
expect(result.group).toEqual(['category_id', 'brand_id']);
|
|
217
|
+
expect(result.attributes).toContain('category_id');
|
|
218
|
+
expect(result.attributes).toContain('brand_id');
|
|
219
|
+
expect(result.attributes).toContainEqual([
|
|
220
|
+
{ fn: 'SUM', col: { col: 'price' } },
|
|
221
|
+
'price_sum'
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Test utils.
|
|
2
|
+
const {
|
|
3
|
+
describe,
|
|
4
|
+
it,
|
|
5
|
+
expect
|
|
6
|
+
} = require('@jest/globals');
|
|
7
|
+
|
|
8
|
+
// Parser:
|
|
9
|
+
const { ModelsTree } = require('../lib/middlewares/ql/sequelize/interpreter/ModelsTree');
|
|
10
|
+
const QueryLexer = require('../lib/middlewares/ql/sequelize/interpreter/QueryLexer');
|
|
11
|
+
const traverse = require('../lib/query/traverse');
|
|
12
|
+
|
|
13
|
+
describe('nodester Clauses', () => {
|
|
14
|
+
describe('Lexer', () => {
|
|
15
|
+
it('supports group_by (single)', async () => {
|
|
16
|
+
const lexer = new QueryLexer('group_by=category_id');
|
|
17
|
+
const result = await lexer.parse();
|
|
18
|
+
|
|
19
|
+
const tree = new ModelsTree();
|
|
20
|
+
tree.node.group_by = 'category_id';
|
|
21
|
+
const expected = tree.root.toObject();
|
|
22
|
+
|
|
23
|
+
expect(result).toMatchObject(expected);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('supports group_by (multiple)', async () => {
|
|
27
|
+
const lexer = new QueryLexer('group_by=category_id,brand_id');
|
|
28
|
+
const result = await lexer.parse();
|
|
29
|
+
|
|
30
|
+
const tree = new ModelsTree();
|
|
31
|
+
tree.node.group_by = ['category_id', 'brand_id'];
|
|
32
|
+
const expected = tree.root.toObject();
|
|
33
|
+
|
|
34
|
+
expect(result).toMatchObject(expected);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('supports order and order_by', async () => {
|
|
38
|
+
const lexer = new QueryLexer('order_by=created_at&order=desc');
|
|
39
|
+
const result = await lexer.parse();
|
|
40
|
+
|
|
41
|
+
const tree = new ModelsTree();
|
|
42
|
+
tree.node.order_by = 'created_at';
|
|
43
|
+
tree.node.order = 'desc';
|
|
44
|
+
const expected = tree.root.toObject();
|
|
45
|
+
|
|
46
|
+
expect(result).toMatchObject(expected);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('supports limit and skip', async () => {
|
|
50
|
+
const lexer = new QueryLexer('limit=10&skip=20');
|
|
51
|
+
const result = await lexer.parse();
|
|
52
|
+
|
|
53
|
+
const tree = new ModelsTree();
|
|
54
|
+
tree.node.limit = 10;
|
|
55
|
+
tree.node.skip = 20;
|
|
56
|
+
const expected = tree.root.toObject();
|
|
57
|
+
|
|
58
|
+
expect(result).toMatchObject(expected);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('Traverse', () => {
|
|
63
|
+
const mockModel = {
|
|
64
|
+
options: {
|
|
65
|
+
name: {
|
|
66
|
+
singular: 'Post',
|
|
67
|
+
plural: 'Posts'
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
associations: {},
|
|
71
|
+
tableAttributes: {
|
|
72
|
+
id: {},
|
|
73
|
+
title: {},
|
|
74
|
+
price: {},
|
|
75
|
+
category_id: {},
|
|
76
|
+
brand_id: {},
|
|
77
|
+
created_at: {}
|
|
78
|
+
},
|
|
79
|
+
sequelize: {
|
|
80
|
+
col: (col) => ({ col }),
|
|
81
|
+
fn: (fn, col) => ({ fn, col })
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const mockFilter = {
|
|
86
|
+
model: mockModel,
|
|
87
|
+
attributes: ['id', 'title', 'category_id', 'brand_id', 'created_at'],
|
|
88
|
+
functions: ['count', 'sum'],
|
|
89
|
+
clauses: ['group_by', 'order', 'order_by', 'limit', 'skip'],
|
|
90
|
+
bounds: {
|
|
91
|
+
clauses: {
|
|
92
|
+
limit: { min: 1, max: 100 },
|
|
93
|
+
skip: { min: 0, max: 1000 }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
statics: { attributes: {}, clauses: {} },
|
|
97
|
+
includes: {}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it('maps group_by to Sequelize group', () => {
|
|
101
|
+
const queryNode = {
|
|
102
|
+
group_by: 'category_id'
|
|
103
|
+
};
|
|
104
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
105
|
+
|
|
106
|
+
expect(result.group).toBe('category_id');
|
|
107
|
+
expect(result.attributes).toContain('category_id');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('maps multiple group_by to Sequelize group array', () => {
|
|
111
|
+
const queryNode = {
|
|
112
|
+
group_by: ['category_id', 'brand_id']
|
|
113
|
+
};
|
|
114
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
115
|
+
|
|
116
|
+
expect(result.group).toEqual(['category_id', 'brand_id']);
|
|
117
|
+
expect(result.attributes).toContain('category_id');
|
|
118
|
+
expect(result.attributes).toContain('brand_id');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('maps order and order_by to Sequelize order', () => {
|
|
122
|
+
const queryNode = {
|
|
123
|
+
order: 'desc',
|
|
124
|
+
order_by: 'created_at'
|
|
125
|
+
};
|
|
126
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
127
|
+
|
|
128
|
+
expect(result.order).toEqual([['created_at', 'desc']]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('maps limit and skip to Sequelize limit and offset', () => {
|
|
132
|
+
const queryNode = {
|
|
133
|
+
limit: 10,
|
|
134
|
+
skip: 5
|
|
135
|
+
};
|
|
136
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
137
|
+
|
|
138
|
+
expect(result.limit).toBe(10);
|
|
139
|
+
expect(result.offset).toBe(5);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('maps group_by with functions (aggregates)', () => {
|
|
143
|
+
const queryNode = {
|
|
144
|
+
functions: [{ fn: 'sum', args: ['price'] }],
|
|
145
|
+
group_by: 'category_id'
|
|
146
|
+
};
|
|
147
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
148
|
+
|
|
149
|
+
expect(result.group).toBe('category_id');
|
|
150
|
+
expect(result.attributes).toContain('category_id');
|
|
151
|
+
expect(result.attributes).toContainEqual([
|
|
152
|
+
{ fn: 'SUM', col: { col: 'price' } },
|
|
153
|
+
'price_sum'
|
|
154
|
+
]);
|
|
155
|
+
// Ensure default attributes are excluded in aggregate+group queries:
|
|
156
|
+
expect(result.attributes).not.toContain('id');
|
|
157
|
+
expect(result.attributes).not.toContain('title');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('enforces bounds on limit and skip', () => {
|
|
161
|
+
const queryNode = {
|
|
162
|
+
limit: 500, // max is 100
|
|
163
|
+
skip: -10 // min is 0 (default in _setValueWithBounds)
|
|
164
|
+
};
|
|
165
|
+
const result = traverse(queryNode, mockFilter, mockModel);
|
|
166
|
+
|
|
167
|
+
expect(result.limit).toBe(100);
|
|
168
|
+
expect(result.offset).toBeUndefined(); // _value <= 0 returns continue
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Test utils.
|
|
2
|
+
const {
|
|
3
|
+
describe,
|
|
4
|
+
it,
|
|
5
|
+
expect
|
|
6
|
+
} = require('@jest/globals');
|
|
7
|
+
|
|
8
|
+
// Component to test:
|
|
9
|
+
const { parseValue } = require('../lib/query/traverse/parsers');
|
|
10
|
+
const { DataTypes } = require('sequelize');
|
|
11
|
+
|
|
12
|
+
describe('nodester Date Parsing', () => {
|
|
13
|
+
const mockModel = {
|
|
14
|
+
tableAttributes: {
|
|
15
|
+
created_at: {
|
|
16
|
+
type: { key: DataTypes.DATE.key }
|
|
17
|
+
},
|
|
18
|
+
birthday: {
|
|
19
|
+
type: { key: DataTypes.DATEONLY.key }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('Single value operators (gte, lte, gt, lt)', () => {
|
|
25
|
+
it('should parse ISO date string', () => {
|
|
26
|
+
const raw = { gte: ["2026-02-01T12:00:00Z"] };
|
|
27
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
28
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
29
|
+
|
|
30
|
+
expect(result[op]).toBeInstanceOf(Date);
|
|
31
|
+
expect(result[op].toISOString()).toBe("2026-02-01T12:00:00.000Z");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should handle space as separator', () => {
|
|
35
|
+
const raw = { gte: ["2026-02-01 12:00:00"] };
|
|
36
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
37
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
38
|
+
|
|
39
|
+
expect(result[op]).toBeInstanceOf(Date);
|
|
40
|
+
// Result depends on local timezone if no Z, but we check if it's a valid date
|
|
41
|
+
expect(isNaN(result[op].getTime())).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle + as separator (URL encoded)', () => {
|
|
45
|
+
const raw = { lte: ["2026-02-01+12:00:00"] };
|
|
46
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
47
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
48
|
+
|
|
49
|
+
expect(result[op]).toBeInstanceOf(Date);
|
|
50
|
+
expect(isNaN(result[op].getTime())).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should unwrap array for gte', () => {
|
|
54
|
+
const raw = { gte: ["2026-02-01"] };
|
|
55
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
56
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
57
|
+
|
|
58
|
+
expect(Array.isArray(result[op])).toBe(false);
|
|
59
|
+
expect(result[op]).toBeInstanceOf(Date);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('Multi-value operators (in, between)', () => {
|
|
64
|
+
it('should parse all values in IN operator', () => {
|
|
65
|
+
const raw = { in: ["2026-02-01", "2026-02-02"] };
|
|
66
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
67
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
68
|
+
|
|
69
|
+
expect(Array.isArray(result[op])).toBe(true);
|
|
70
|
+
expect(result[op][0]).toBeInstanceOf(Date);
|
|
71
|
+
expect(result[op][1]).toBeInstanceOf(Date);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should parse both values in BETWEEN operator', () => {
|
|
75
|
+
const raw = { between: ["2026-01-01", "2026-01-31"] };
|
|
76
|
+
const result = parseValue(raw, 'created_at', mockModel);
|
|
77
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
78
|
+
|
|
79
|
+
expect(Array.isArray(result[op])).toBe(true);
|
|
80
|
+
expect(result[op].length).toBe(2);
|
|
81
|
+
expect(result[op][0]).toBeInstanceOf(Date);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('DATEONLY type', () => {
|
|
86
|
+
it('should parse simple date string for DATEONLY', () => {
|
|
87
|
+
const raw = { eq: ["1990-05-15"] };
|
|
88
|
+
const result = parseValue(raw, 'birthday', mockModel);
|
|
89
|
+
const op = Object.getOwnPropertySymbols(result)[0];
|
|
90
|
+
|
|
91
|
+
expect(result[op]).toBeInstanceOf(Date);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Error handling', () => {
|
|
96
|
+
it('should throw descriptive error for invalid date', () => {
|
|
97
|
+
const raw = { gte: ["not-a-date"] };
|
|
98
|
+
|
|
99
|
+
expect(() => {
|
|
100
|
+
parseValue(raw, 'created_at', mockModel);
|
|
101
|
+
}).toThrow("nodester: Invalid date value 'not-a-date' for attribute 'created_at'");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
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
|
+
});
|