nodester 0.7.12 → 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.
@@ -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, error:
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
- const err = UnexpectedCharError(i, char);
166
- return Promise.reject(err);
167
+ tree.node.resetOP();
168
+ continue;
167
169
  }
168
170
 
169
171
  // Structure of a value depends on OP:
@@ -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
- if (typeof filter.functions[fnName] === 'undefined') {
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;
@@ -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
- typeof functions !== 'object'
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 [ includeName, includeFilter ] of Object.entries(includes)) {
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 '${ includeName }'.`);
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 "${ includeName }" has association type of`,
142
- `"${ associationType }", but has a filter clause "limit",`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodester",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "A versatile REST framework for Node.js",
5
5
  "directories": {
6
6
  "docs": "docs",
@@ -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: { count: true, sum: true },
146
+ functions: ['count', 'sum'],
153
147
  clauses: [],
154
148
  bounds: { clauses: {} },
155
149
  statics: { attributes: {}, clauses: {} },
@@ -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
+ });