nodester 0.7.11 → 0.7.12

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.
@@ -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
 
@@ -315,7 +318,7 @@ module.exports = class QueryLexer {
315
318
  // . can mean:
316
319
  // • vertical include
317
320
  // • or it can be a part of a param for "where"
318
- if (char === '.') {
321
+ if (char === '.' && !tree.node.fn && !tree.node.op) {
319
322
  debug('char', char, { token, node: tree.node });
320
323
 
321
324
  // Vertical include:
@@ -344,7 +347,7 @@ module.exports = class QueryLexer {
344
347
  }
345
348
 
346
349
  // + can only mean horizontal include:
347
- if (char === '+') {
350
+ if (char === '+' && !tree.node.fn && !tree.node.op) {
348
351
  debug('char', char, { token, node: tree.node });
349
352
 
350
353
  // If include of a new model:
@@ -636,7 +639,8 @@ module.exports = class QueryLexer {
636
639
 
637
640
  // Clauses:
638
641
  case PARAM_TOKENS.GROUP_BY:
639
- treeNode.group_by = token;
642
+ if (token) value.push(token);
643
+ treeNode.group_by = value.length > 1 ? value : value[0];
640
644
  break;
641
645
 
642
646
  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 (attributes.length === 0 || attributes.indexOf(attribute) > -1) {
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 (attributes.length === 0 || attributes.indexOf(attribute) > -1) {
127
+ if (selectAllByDefault || attributes.indexOf(attribute) > -1) {
124
128
  newQuery.attributes.push(attribute);
125
129
  continue;
126
130
  }
@@ -162,6 +166,20 @@ function traverse(queryNode, filter = null, model = null, association = null) {
162
166
  );
163
167
  break;
164
168
  }
169
+ // SQL SUM(), AVG(), MIN(), MAX():
170
+ case 'sum':
171
+ case 'avg':
172
+ case 'min':
173
+ case 'max': {
174
+ mapAGG(
175
+ fnParams,
176
+ _model,
177
+ filter?.includes,
178
+
179
+ newQuery
180
+ );
181
+ break;
182
+ }
165
183
  // Any other function:
166
184
  default:
167
185
  consl.warn(`function ${fnName}() is not supported`);
@@ -185,15 +203,22 @@ function traverse(queryNode, filter = null, model = null, association = null) {
185
203
 
186
204
  switch (clauseName) {
187
205
  case 'group_by': {
188
- // Check if this value is a valid attribute:
189
206
  if (typeof value === 'undefined') {
190
207
  continue;
191
208
  }
192
209
 
193
- if (typeof _model.tableAttributes[value] === 'undefined') {
194
- const err = new NodesterQueryError(`group_by '${value}' is not allowed.`);
195
- Error.captureStackTrace(err, traverse);
196
- throw err;
210
+ const groupFields = Array.isArray(value) ? value : [value];
211
+ for (const field of groupFields) {
212
+ if (typeof _model.tableAttributes[field] === 'undefined') {
213
+ const err = new NodesterQueryError(`group_by '${field}' is not allowed.`);
214
+ Error.captureStackTrace(err, traverse);
215
+ throw err;
216
+ }
217
+
218
+ // For aggregate queries, group_by field MUST be in attributes:
219
+ if (newQuery.attributes.indexOf(field) === -1) {
220
+ newQuery.attributes.push(field);
221
+ }
197
222
  }
198
223
 
199
224
  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 [ opKey, rawValue ] of entries) {
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
- _value = new Date(rawValue);
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:
@@ -24,7 +24,7 @@ function _disassembleQueryNode(queryNode) {
24
24
 
25
25
  return {
26
26
  attributes: attributes ?? [],
27
- clauses: clauses ?? [],
27
+ clauses: clauses ?? {},
28
28
  functions: functions ?? [],
29
29
  where: where ?? {},
30
30
  includes: includes ?? [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodester",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "A versatile REST framework for Node.js",
5
5
  "directories": {
6
6
  "docs": "docs",
@@ -0,0 +1,231 @@
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: {
139
+ count: { target: 'comments' }, // Just to satisfy ensure calls if any
140
+ sum: true,
141
+ avg: true,
142
+ min: true,
143
+ max: true
144
+ },
145
+ clauses: ['group_by'],
146
+ bounds: { clauses: {} },
147
+ statics: { attributes: {}, clauses: {} },
148
+ includes: {
149
+ comments: {
150
+ model: { options: { name: { singular: 'Comment', plural: 'Comments' } }, tableAttributes: { id: {} } },
151
+ attributes: ['id'],
152
+ functions: { count: true, sum: true },
153
+ clauses: [],
154
+ bounds: { clauses: {} },
155
+ statics: { attributes: {}, clauses: {} },
156
+ includes: {}
157
+ }
158
+ }
159
+ };
160
+
161
+ it('maps root sum(price)', () => {
162
+ const queryNode = {
163
+ functions: [{ fn: 'sum', args: ['price'] }]
164
+ };
165
+ const result = traverse(queryNode, mockFilter, mockModel);
166
+
167
+ expect(result.attributes).toContainEqual([
168
+ { fn: 'SUM', col: { col: 'price' } },
169
+ 'price_sum'
170
+ ]);
171
+ });
172
+
173
+ it('maps association count(comments)', () => {
174
+ const queryNode = {
175
+ functions: [{ fn: 'count', args: ['comments'] }]
176
+ };
177
+ const result = traverse(queryNode, mockFilter, mockModel);
178
+
179
+ expect(result.attributes).toContainEqual([
180
+ { literal: '(SELECT COUNT(*) FROM comments where comments.post_id=Post.id)' },
181
+ 'comments_count'
182
+ ]);
183
+ });
184
+
185
+ it('maps association sum(comments.votes)', () => {
186
+ const queryNode = {
187
+ functions: [{ fn: 'sum', args: ['comments.votes'] }]
188
+ };
189
+ const result = traverse(queryNode, mockFilter, mockModel);
190
+
191
+ expect(result.attributes).toContainEqual([
192
+ { literal: '(SELECT SUM(votes) FROM comments WHERE comments.post_id=Post.id)' },
193
+ 'comments_sum_votes'
194
+ ]);
195
+ });
196
+
197
+ it('maps root sum(price) with group_by', () => {
198
+ const queryNode = {
199
+ functions: [{ fn: 'sum', args: ['price'] }],
200
+ group_by: 'category_id'
201
+ };
202
+ const result = traverse(queryNode, mockFilter, mockModel);
203
+
204
+ expect(result.group).toBe('category_id');
205
+ expect(result.attributes).toContainEqual([
206
+ { fn: 'SUM', col: { col: 'price' } },
207
+ 'price_sum'
208
+ ]);
209
+ // Check if it also includes category_id in attributes
210
+ expect(result.attributes).toContain('category_id');
211
+ // Ensure id is NOT there (default attributes should be excluded)
212
+ expect(result.attributes).not.toContain('id');
213
+ });
214
+
215
+ it('maps root sum(price) with multiple group_by', () => {
216
+ const queryNode = {
217
+ functions: [{ fn: 'sum', args: ['price'] }],
218
+ group_by: ['category_id', 'brand_id']
219
+ };
220
+ const result = traverse(queryNode, mockFilter, mockModel);
221
+
222
+ expect(result.group).toEqual(['category_id', 'brand_id']);
223
+ expect(result.attributes).toContain('category_id');
224
+ expect(result.attributes).toContain('brand_id');
225
+ expect(result.attributes).toContainEqual([
226
+ { fn: 'SUM', col: { col: 'price' } },
227
+ 'price_sum'
228
+ ]);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,174 @@
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: {
89
+ count: true,
90
+ sum: true
91
+ },
92
+ clauses: ['group_by', 'order', 'order_by', 'limit', 'skip'],
93
+ bounds: {
94
+ clauses: {
95
+ limit: { min: 1, max: 100 },
96
+ skip: { min: 0, max: 1000 }
97
+ }
98
+ },
99
+ statics: { attributes: {}, clauses: {} },
100
+ includes: {}
101
+ };
102
+
103
+ it('maps group_by to Sequelize group', () => {
104
+ const queryNode = {
105
+ group_by: 'category_id'
106
+ };
107
+ const result = traverse(queryNode, mockFilter, mockModel);
108
+
109
+ expect(result.group).toBe('category_id');
110
+ expect(result.attributes).toContain('category_id');
111
+ });
112
+
113
+ it('maps multiple group_by to Sequelize group array', () => {
114
+ const queryNode = {
115
+ group_by: ['category_id', 'brand_id']
116
+ };
117
+ const result = traverse(queryNode, mockFilter, mockModel);
118
+
119
+ expect(result.group).toEqual(['category_id', 'brand_id']);
120
+ expect(result.attributes).toContain('category_id');
121
+ expect(result.attributes).toContain('brand_id');
122
+ });
123
+
124
+ it('maps order and order_by to Sequelize order', () => {
125
+ const queryNode = {
126
+ order: 'desc',
127
+ order_by: 'created_at'
128
+ };
129
+ const result = traverse(queryNode, mockFilter, mockModel);
130
+
131
+ expect(result.order).toEqual([['created_at', 'desc']]);
132
+ });
133
+
134
+ it('maps limit and skip to Sequelize limit and offset', () => {
135
+ const queryNode = {
136
+ limit: 10,
137
+ skip: 5
138
+ };
139
+ const result = traverse(queryNode, mockFilter, mockModel);
140
+
141
+ expect(result.limit).toBe(10);
142
+ expect(result.offset).toBe(5);
143
+ });
144
+
145
+ it('maps group_by with functions (aggregates)', () => {
146
+ const queryNode = {
147
+ functions: [{ fn: 'sum', args: ['price'] }],
148
+ group_by: 'category_id'
149
+ };
150
+ const result = traverse(queryNode, mockFilter, mockModel);
151
+
152
+ expect(result.group).toBe('category_id');
153
+ expect(result.attributes).toContain('category_id');
154
+ expect(result.attributes).toContainEqual([
155
+ { fn: 'SUM', col: { col: 'price' } },
156
+ 'price_sum'
157
+ ]);
158
+ // Ensure default attributes are excluded in aggregate+group queries:
159
+ expect(result.attributes).not.toContain('id');
160
+ expect(result.attributes).not.toContain('title');
161
+ });
162
+
163
+ it('enforces bounds on limit and skip', () => {
164
+ const queryNode = {
165
+ limit: 500, // max is 100
166
+ skip: -10 // min is 0 (default in _setValueWithBounds)
167
+ };
168
+ const result = traverse(queryNode, mockFilter, mockModel);
169
+
170
+ expect(result.limit).toBe(100);
171
+ expect(result.offset).toBeUndefined(); // _value <= 0 returns continue
172
+ });
173
+ });
174
+ });
@@ -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
+ });