nodester 0.5.1 → 0.6.1
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/Readme.md +27 -8
- package/lib/errors/NodesterQueryError.js +4 -8
- package/lib/middlewares/ql/sequelize/interpreter/ModelsTree.js +9 -4
- package/lib/middlewares/ql/sequelize/interpreter/QueryLexer.js +84 -37
- package/lib/query/traverse/index.js +48 -46
- package/lib/query/traverse/mappers/functions/count.js +76 -0
- package/lib/structures/Filter.js +35 -3
- package/package.json +1 -1
- package/tests/nql.test.js +56 -0
package/Readme.md
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
> **nodester** is a Node.js framework designed to solve the problem of a complex data querying over HTTP.
|
|
7
7
|
|
|
8
|
-
The main reason of nodester's existence is the [nodester Query Language (NQL)
|
|
8
|
+
The main reason of nodester's existence is the [nodester Query Language (NQL) →](docs/nql/Introduction.md), an extension of standard REST API syntax, it lets you craft complex queries with hierarchical associations.
|
|
9
9
|
|
|
10
10
|
Building an application which allows users to build their own REST queries raises huge security concerns.
|
|
11
11
|
That's why **nodester** was not developped as a middleware. It's a framework equipped with a set of technologies enabling you to fully customize the request-response flow down to the specific user and a database column.
|
|
12
|
-
Check out [core concepts documentation
|
|
12
|
+
Check out [core concepts documentation →](docs/CoreConcepts.md) for more info.
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
@@ -39,36 +39,55 @@ const db = require('#db');
|
|
|
39
39
|
const app = new nodester();
|
|
40
40
|
app.set.database(db);
|
|
41
41
|
|
|
42
|
+
// Optional beforeStart hook:
|
|
43
|
+
app.beforeStart(async ()=>{
|
|
44
|
+
// Do any asynchronous initializations
|
|
45
|
+
// before app.listen
|
|
46
|
+
// ...
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Start the http server:
|
|
42
50
|
app.listen(8080, function() {
|
|
43
51
|
console.log('listening on port', app.port);
|
|
44
52
|
});
|
|
53
|
+
|
|
54
|
+
// Gracefully shut down:
|
|
55
|
+
process.once('SIGTERM', () => {
|
|
56
|
+
app.stop(() => {
|
|
57
|
+
const pid = process.pid;
|
|
58
|
+
console.info('Process', pid, 'terminated\n');
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
45
63
|
```
|
|
46
|
-
[How to setup "db" ➡️](docs/App.md#with-database)
|
|
47
64
|
|
|
48
65
|
|
|
49
66
|
## Documentation
|
|
50
67
|
|
|
51
68
|
|
|
52
69
|
### Core concepts
|
|
53
|
-
[Core concepts documentation
|
|
70
|
+
[Core concepts documentation →](docs/CoreConcepts.md)
|
|
54
71
|
|
|
55
72
|
|
|
56
|
-
### Queries &
|
|
73
|
+
### Queries & querying - nodester Query Language (NQL)
|
|
57
74
|
The true strength of nodester lies in its query language. Serving as an extension of standard REST API syntax, it brings many aspects of SQL into REST requests, providing developers with a simple yet potent tool for expressive and efficient data querying.
|
|
58
75
|
|
|
59
76
|
Read more about it in the documentation:
|
|
60
|
-
[NQL documentaion
|
|
77
|
+
[NQL documentaion →](docs/nql/Introduction.md)
|
|
61
78
|
|
|
62
79
|
|
|
63
80
|
### Database
|
|
64
|
-
Nodester is built upon a powerful [Sequelize](https://sequelize.org/)
|
|
81
|
+
Nodester is built upon a powerful [Sequelize](https://sequelize.org/).<br/>
|
|
65
82
|
Supported drivers:
|
|
66
83
|
- MySQL
|
|
67
84
|
- PostgreSQL
|
|
68
85
|
|
|
86
|
+
[How to setup a database →](docs/App.md#with-database)
|
|
87
|
+
|
|
69
88
|
|
|
70
89
|
### Application
|
|
71
|
-
[Application documentation
|
|
90
|
+
[Application documentation →](docs/App.md)
|
|
72
91
|
|
|
73
92
|
|
|
74
93
|
### Comments
|
|
@@ -5,15 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
-
const {
|
|
9
|
-
NODESTER_QUERY_ERROR,
|
|
10
|
-
} = require('nodester/constants/ErrorCodes');
|
|
11
8
|
const {
|
|
12
9
|
NOT_ACCEPTABLE
|
|
13
10
|
} = require('nodester/http/codes');
|
|
14
11
|
|
|
15
|
-
const NodesterError = require('./NodesterError');
|
|
16
|
-
|
|
17
12
|
|
|
18
13
|
/**
|
|
19
14
|
* @class
|
|
@@ -22,11 +17,12 @@ const NodesterError = require('./NodesterError');
|
|
|
22
17
|
*
|
|
23
18
|
* @access public
|
|
24
19
|
*/
|
|
25
|
-
module.exports = class NodesterQueryError extends
|
|
26
|
-
constructor(message) {
|
|
20
|
+
module.exports = class NodesterQueryError extends Error {
|
|
21
|
+
constructor(message, status) {
|
|
27
22
|
super(message);
|
|
28
23
|
|
|
29
|
-
this.
|
|
24
|
+
this.name = this.constructor.name;
|
|
25
|
+
this.status = status ?? NOT_ACCEPTABLE;
|
|
30
26
|
|
|
31
27
|
Error.captureStackTrace(this, this.constructor);
|
|
32
28
|
}
|
|
@@ -17,21 +17,25 @@ class ModelsTreeNode {
|
|
|
17
17
|
constructor(model, parent=null, opts={}) {
|
|
18
18
|
this.model = model;
|
|
19
19
|
this.parent = parent;
|
|
20
|
+
|
|
20
21
|
this.activeParam = null;
|
|
21
22
|
this.op = null;
|
|
22
23
|
this.fn = null;
|
|
23
24
|
|
|
24
|
-
// for override:
|
|
25
25
|
this._attributes = [];
|
|
26
26
|
this._where = {};
|
|
27
27
|
this._functions = [];
|
|
28
|
+
|
|
29
|
+
// Clauses:
|
|
30
|
+
this.group_by = opts.group_by ?? undefined;
|
|
28
31
|
this.skip = 0;
|
|
29
32
|
this.limit = -1; // No limit
|
|
30
|
-
|
|
31
|
-
this._includes = opts.includes ?? [];
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
this.order = opts.order ?? undefined;
|
|
34
35
|
this.order_by = opts.order_by ?? undefined;
|
|
36
|
+
// Clauses\
|
|
37
|
+
|
|
38
|
+
this._includes = opts.includes ?? [];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
// Getters:
|
|
@@ -107,6 +111,7 @@ class ModelsTreeNode {
|
|
|
107
111
|
|
|
108
112
|
where: this.where,
|
|
109
113
|
|
|
114
|
+
group_by: this.group_by,
|
|
110
115
|
skip: this.skip,
|
|
111
116
|
limit: this.limit,
|
|
112
117
|
order: this.order,
|
|
@@ -8,18 +8,26 @@
|
|
|
8
8
|
const Enum = require('nodester/enum');
|
|
9
9
|
|
|
10
10
|
const { ModelsTree, ModelsTreeNode } = require('./ModelsTree');
|
|
11
|
+
|
|
12
|
+
const { NodesterQueryError } = require('nodester/errors');
|
|
13
|
+
|
|
11
14
|
const util = require('util');
|
|
12
15
|
const debug = require('debug')('nodester:interpreter:QueryLexer');
|
|
13
16
|
|
|
14
17
|
const PARAM_TOKENS = new Enum({
|
|
15
18
|
ATTRIBUTES: Symbol('attributes'),
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
FUNCTIONS: Symbol('functions'),
|
|
21
|
+
|
|
22
|
+
INCLUDES: Symbol('includes'),
|
|
23
|
+
|
|
24
|
+
// Clauses:
|
|
25
|
+
GROUP_BY: Symbol('group_by'),
|
|
17
26
|
LIMIT: Symbol('limit'),
|
|
18
27
|
ORDER: Symbol('order'),
|
|
19
28
|
ORDER_BY: Symbol('order_by'),
|
|
20
29
|
SKIP: Symbol('skip'),
|
|
21
|
-
|
|
22
|
-
INCLUDES: Symbol('includes'),
|
|
30
|
+
// Clauses\
|
|
23
31
|
});
|
|
24
32
|
|
|
25
33
|
const OP_TOKENS = new Enum({
|
|
@@ -50,6 +58,7 @@ const OP_TOKENS = new Enum({
|
|
|
50
58
|
});
|
|
51
59
|
|
|
52
60
|
const FN_TOKENS = new Enum({
|
|
61
|
+
AVG: 'avg',
|
|
53
62
|
COUNT: 'count',
|
|
54
63
|
});
|
|
55
64
|
|
|
@@ -179,25 +188,15 @@ module.exports = class QueryLexer {
|
|
|
179
188
|
|
|
180
189
|
// If end of FN token:
|
|
181
190
|
if (!!tree.node.fn) {
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
let fnParams = {};
|
|
191
|
+
// Token is the param of this fn:
|
|
192
|
+
const fnParams = {
|
|
193
|
+
fn: tree.node.fn
|
|
194
|
+
};
|
|
189
195
|
switch (tree.node.fn) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
fn: 'count',
|
|
193
|
-
args: [token]
|
|
194
|
-
};
|
|
195
|
-
break;
|
|
196
|
+
// ToDo: cases with multiple args.
|
|
197
|
+
|
|
196
198
|
default:
|
|
197
|
-
fnParams =
|
|
198
|
-
fn: [tree.node.fn],
|
|
199
|
-
args: [token]
|
|
200
|
-
};
|
|
199
|
+
fnParams.args = [token];
|
|
201
200
|
break;
|
|
202
201
|
}
|
|
203
202
|
|
|
@@ -205,7 +204,6 @@ module.exports = class QueryLexer {
|
|
|
205
204
|
|
|
206
205
|
// Reset:
|
|
207
206
|
tree.node.resetFN();
|
|
208
|
-
tree.node.activeParam = PARAM_TOKENS.INCLUDES;
|
|
209
207
|
token = '';
|
|
210
208
|
value = [];
|
|
211
209
|
continue;
|
|
@@ -251,6 +249,13 @@ module.exports = class QueryLexer {
|
|
|
251
249
|
continue;
|
|
252
250
|
}
|
|
253
251
|
|
|
252
|
+
// If new function:
|
|
253
|
+
if (tree.node.activeParam === PARAM_TOKENS.FUNCTIONS) {
|
|
254
|
+
// Prepare for new function:
|
|
255
|
+
tree.node.resetFN();
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
254
259
|
// If param value:
|
|
255
260
|
if (tree.node.activeParam !== PARAM_TOKENS.INCLUDES) {
|
|
256
261
|
value.push(token);
|
|
@@ -354,7 +359,10 @@ module.exports = class QueryLexer {
|
|
|
354
359
|
}
|
|
355
360
|
|
|
356
361
|
// If end of key=value pair:
|
|
357
|
-
if (!!tree.node.activeParam
|
|
362
|
+
if (!!tree.node.activeParam
|
|
363
|
+
&& tree.node.activeParam !== PARAM_TOKENS.FUNCTIONS
|
|
364
|
+
&& tree.node.activeParam !== PARAM_TOKENS.INCLUDES
|
|
365
|
+
) {
|
|
358
366
|
// Set value.
|
|
359
367
|
this.setNodeParam(tree.node, token, value);
|
|
360
368
|
// Reset:
|
|
@@ -363,6 +371,20 @@ module.exports = class QueryLexer {
|
|
|
363
371
|
value = [];
|
|
364
372
|
continue;
|
|
365
373
|
}
|
|
374
|
+
else if (tree.node.activeParam === PARAM_TOKENS.FUNCTIONS) {
|
|
375
|
+
// If token has some chars,
|
|
376
|
+
// then it's a syntactic error:
|
|
377
|
+
if (token.length > 0) {
|
|
378
|
+
const err = new NodesterQueryError(`unrecognized char at position ${ i }: Unknown token '${ token }'`);
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Reset:
|
|
383
|
+
tree.node.resetActiveParam();
|
|
384
|
+
token = '';
|
|
385
|
+
value = [];
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
366
388
|
else if (tree.node.activeParam === PARAM_TOKENS.INCLUDES) {
|
|
367
389
|
// If token has some chars,
|
|
368
390
|
// then it's include of a new model:
|
|
@@ -460,7 +482,7 @@ module.exports = class QueryLexer {
|
|
|
460
482
|
const param = this.parseParamFromToken(token);
|
|
461
483
|
|
|
462
484
|
if (isSubQuery === true && param === PARAM_TOKENS.INCLUDES) {
|
|
463
|
-
const err = new
|
|
485
|
+
const err = new NodesterQueryError(`'include' is forbidden inside subquery (position ${ i }). Use: 'model.submodel' or 'model.submodel1+submodel2'.`);
|
|
464
486
|
throw err;
|
|
465
487
|
}
|
|
466
488
|
|
|
@@ -482,6 +504,12 @@ module.exports = class QueryLexer {
|
|
|
482
504
|
throw err;
|
|
483
505
|
}
|
|
484
506
|
|
|
507
|
+
// If any Function:
|
|
508
|
+
if (!!tree.node.fn) {
|
|
509
|
+
const err = MissingCharError(i+1, ')');
|
|
510
|
+
throw err;
|
|
511
|
+
}
|
|
512
|
+
|
|
485
513
|
// If any OP at all:
|
|
486
514
|
if (!!tree.node.op) {
|
|
487
515
|
const err = MissingCharError(i+1, ')');
|
|
@@ -507,14 +535,22 @@ module.exports = class QueryLexer {
|
|
|
507
535
|
case 'a':
|
|
508
536
|
return PARAM_TOKENS.ATTRIBUTES;
|
|
509
537
|
|
|
538
|
+
case 'functions':
|
|
539
|
+
case 'fn':
|
|
540
|
+
return PARAM_TOKENS.FUNCTIONS;
|
|
541
|
+
|
|
542
|
+
case 'includes':
|
|
543
|
+
case 'in':
|
|
544
|
+
return PARAM_TOKENS.INCLUDES;
|
|
545
|
+
|
|
546
|
+
// Clauses:
|
|
547
|
+
case 'group_by':
|
|
548
|
+
return PARAM_TOKENS.GROUP_BY;
|
|
549
|
+
|
|
510
550
|
case 'limit':
|
|
511
551
|
case 'l':
|
|
512
552
|
return PARAM_TOKENS.LIMIT;
|
|
513
553
|
|
|
514
|
-
case 'skip':
|
|
515
|
-
case 's':
|
|
516
|
-
return PARAM_TOKENS.SKIP;
|
|
517
|
-
|
|
518
554
|
case 'order':
|
|
519
555
|
case 'o':
|
|
520
556
|
return PARAM_TOKENS.ORDER;
|
|
@@ -523,9 +559,10 @@ module.exports = class QueryLexer {
|
|
|
523
559
|
case 'oby':
|
|
524
560
|
return PARAM_TOKENS.ORDER_BY;
|
|
525
561
|
|
|
526
|
-
case '
|
|
527
|
-
case '
|
|
528
|
-
return PARAM_TOKENS.
|
|
562
|
+
case 'skip':
|
|
563
|
+
case 's':
|
|
564
|
+
return PARAM_TOKENS.SKIP;
|
|
565
|
+
// Clauses\
|
|
529
566
|
|
|
530
567
|
default:
|
|
531
568
|
return token;
|
|
@@ -543,6 +580,20 @@ module.exports = class QueryLexer {
|
|
|
543
580
|
treeNode.attributes = value;
|
|
544
581
|
break;
|
|
545
582
|
|
|
583
|
+
case PARAM_TOKENS.FUNCTIONS:
|
|
584
|
+
treeNode.addFunction(value);
|
|
585
|
+
break;
|
|
586
|
+
|
|
587
|
+
case PARAM_TOKENS.INCLUDES:
|
|
588
|
+
const node = new ModelsTreeNode(token);
|
|
589
|
+
treeNode.include(node);
|
|
590
|
+
break;
|
|
591
|
+
|
|
592
|
+
// Clauses:
|
|
593
|
+
case PARAM_TOKENS.GROUP_BY:
|
|
594
|
+
treeNode.group_by = token;
|
|
595
|
+
break;
|
|
596
|
+
|
|
546
597
|
case PARAM_TOKENS.LIMIT:
|
|
547
598
|
treeNode.limit = parseInt(token);
|
|
548
599
|
break;
|
|
@@ -558,11 +609,7 @@ module.exports = class QueryLexer {
|
|
|
558
609
|
case PARAM_TOKENS.ORDER_BY:
|
|
559
610
|
treeNode.order_by = token;
|
|
560
611
|
break;
|
|
561
|
-
|
|
562
|
-
case PARAM_TOKENS.INCLUDES:
|
|
563
|
-
const node = new ModelsTreeNode(token);
|
|
564
|
-
treeNode.include(node);
|
|
565
|
-
break;
|
|
612
|
+
// Clauses\
|
|
566
613
|
|
|
567
614
|
default:
|
|
568
615
|
if (token) value.push(token);
|
|
@@ -597,11 +644,11 @@ module.exports = class QueryLexer {
|
|
|
597
644
|
|
|
598
645
|
|
|
599
646
|
function UnexpectedCharError(index, char) {
|
|
600
|
-
const err = new
|
|
647
|
+
const err = new NodesterQueryError(`Unexpected '${ char }' at position ${ index }`);
|
|
601
648
|
return err;
|
|
602
649
|
}
|
|
603
650
|
|
|
604
651
|
function MissingCharError(index, char) {
|
|
605
|
-
const err = new
|
|
652
|
+
const err = new NodesterQueryError(`Missing '${ char }' at position ${ index }`);
|
|
606
653
|
return err;
|
|
607
654
|
}
|
|
@@ -12,10 +12,14 @@ const httpCodes = require('nodester/http/codes');
|
|
|
12
12
|
|
|
13
13
|
const { ensure } = require('nodester/validators/arguments');
|
|
14
14
|
|
|
15
|
+
// Mappers & parsers:
|
|
16
|
+
const mapCOUNT = require('./mappers/functions/count');
|
|
17
|
+
|
|
15
18
|
const {
|
|
16
19
|
parseValue,
|
|
17
20
|
parseWhereEntry,
|
|
18
21
|
} = require('./parsers');
|
|
22
|
+
// Mappers & parsers\
|
|
19
23
|
|
|
20
24
|
const {
|
|
21
25
|
disassembleQueryNode,
|
|
@@ -26,6 +30,8 @@ const {
|
|
|
26
30
|
getModelAssociationProps
|
|
27
31
|
} = require('../../utils/modelAssociations.util');
|
|
28
32
|
|
|
33
|
+
const consl = require('nodester/loggers/console');
|
|
34
|
+
|
|
29
35
|
|
|
30
36
|
module.exports = traverse;
|
|
31
37
|
|
|
@@ -114,8 +120,13 @@ function traverse(queryNode, filter=null, model=null, association=null) {
|
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
//
|
|
118
|
-
|
|
123
|
+
// At least 1 attribute is mandatory
|
|
124
|
+
// or "functions" must be set:
|
|
125
|
+
if (
|
|
126
|
+
functions.length === 0
|
|
127
|
+
&&
|
|
128
|
+
newQuery.attributes.length === 0
|
|
129
|
+
) {
|
|
119
130
|
const err = new NodesterQueryError(`No attributes were selected.`);
|
|
120
131
|
Error.captureStackTrace(err, traverse);
|
|
121
132
|
throw err;
|
|
@@ -124,57 +135,28 @@ function traverse(queryNode, filter=null, model=null, association=null) {
|
|
|
124
135
|
|
|
125
136
|
// Functions:
|
|
126
137
|
for (const fnParams of functions) {
|
|
138
|
+
const fnName = fnParams.fn;
|
|
139
|
+
|
|
140
|
+
if (typeof filter.functions[fnName] === 'undefined') {
|
|
141
|
+
const err = new NodesterQueryError(`Function '${ fnName }' is not allowed.`);
|
|
142
|
+
Error.captureStackTrace(err, traverse);
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
127
145
|
|
|
128
|
-
switch(
|
|
146
|
+
switch(fnName) {
|
|
129
147
|
// SQL COUNT():
|
|
130
148
|
case 'count': {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// or for any of the available uncludes.
|
|
136
|
-
const isForRootModel = countTarget === rootModelName.plural.toLowerCase();
|
|
137
|
-
|
|
138
|
-
// Compile request:
|
|
139
|
-
// Example of desired SQL:
|
|
140
|
-
// `(SELECT COUNT(*) FROM comments WHERE comments.post_id=Post.id)`
|
|
141
|
-
//
|
|
142
|
-
let rawSQL = '(SELECT COUNT(*) FROM ';
|
|
143
|
-
let countAttribute = 'count';
|
|
144
|
-
|
|
145
|
-
// If request to count one of the includes:
|
|
146
|
-
if (!isForRootModel) {
|
|
147
|
-
// Check if it's available:
|
|
148
|
-
if (
|
|
149
|
-
!filter
|
|
150
|
-
||
|
|
151
|
-
!filter?.includes[countTarget]
|
|
152
|
-
||
|
|
153
|
-
rootModelAssociations[countTarget] === undefined
|
|
154
|
-
) {
|
|
155
|
-
const err = new NodesterQueryError(`Count for '${ countTarget }' is not available.`);
|
|
156
|
-
Error.captureStackTrace(err, traverse);
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const {
|
|
161
|
-
as,
|
|
162
|
-
target,
|
|
163
|
-
foreignKey,
|
|
164
|
-
sourceKey
|
|
165
|
-
} = rootModelAssociations[countTarget];
|
|
166
|
-
const { tableName } = target;
|
|
167
|
-
|
|
168
|
-
rawSQL += `${ tableName } where ${ tableName }.${ foreignKey }=${ rootModelName.singular }.${ sourceKey })`;
|
|
169
|
-
countAttribute = `${ as }_count`;
|
|
170
|
-
}
|
|
149
|
+
mapCOUNT(
|
|
150
|
+
fnParams,
|
|
151
|
+
_model,
|
|
152
|
+
filter?.includes,
|
|
171
153
|
|
|
172
|
-
|
|
173
|
-
[sequelize.literal(rawSQL), countAttribute]
|
|
154
|
+
newQuery
|
|
174
155
|
);
|
|
175
156
|
}
|
|
176
|
-
//
|
|
157
|
+
// Any other function:
|
|
177
158
|
default:
|
|
159
|
+
consl.warn(`function ${ fnName }() is not supported`);
|
|
178
160
|
break;
|
|
179
161
|
}
|
|
180
162
|
}
|
|
@@ -194,6 +176,22 @@ function traverse(queryNode, filter=null, model=null, association=null) {
|
|
|
194
176
|
}
|
|
195
177
|
|
|
196
178
|
switch(clauseName) {
|
|
179
|
+
case 'group_by': {
|
|
180
|
+
// Check if this value is a valid attribute:
|
|
181
|
+
if (typeof value === 'undefined') {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (typeof _model.tableAttributes[value] === 'undefined') {
|
|
186
|
+
const err = new NodesterQueryError(`group_by '${ value }' is not allowed.`);
|
|
187
|
+
Error.captureStackTrace(err, traverse);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
newQuery.group = value;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
197
195
|
case 'limit': {
|
|
198
196
|
const _value = _setValueWithBounds(value, 'number', filter.bounds.clauses.limit);
|
|
199
197
|
|
|
@@ -241,6 +239,10 @@ function traverse(queryNode, filter=null, model=null, association=null) {
|
|
|
241
239
|
const [ clauseName, staticClauseValue ] = entry;
|
|
242
240
|
|
|
243
241
|
switch(clauseName) {
|
|
242
|
+
case 'group_by':
|
|
243
|
+
newQuery.group = staticClauseValue;
|
|
244
|
+
continue;
|
|
245
|
+
|
|
244
246
|
case 'limit':
|
|
245
247
|
newQuery.limit = staticClauseValue;
|
|
246
248
|
continue;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { NodesterQueryError } = require('nodester/errors');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module.exports = function mapCOUNT(
|
|
5
|
+
fnParams,
|
|
6
|
+
rootModel,
|
|
7
|
+
filterIncludes,
|
|
8
|
+
|
|
9
|
+
sequelizeQuery
|
|
10
|
+
) {
|
|
11
|
+
try {
|
|
12
|
+
|
|
13
|
+
const { sequelize } = rootModel;
|
|
14
|
+
const rootModelName = rootModel.options.name;
|
|
15
|
+
|
|
16
|
+
const countParams = fnParams.args;
|
|
17
|
+
const [ countTarget ] = countParams;
|
|
18
|
+
|
|
19
|
+
// COUNT can be requested for this model,
|
|
20
|
+
// or for any of the available includes.
|
|
21
|
+
const associations = rootModel.associations;
|
|
22
|
+
|
|
23
|
+
const isForAssociation = typeof associations[countTarget] !== 'undefined';
|
|
24
|
+
const isForRootModel = countTarget === '';
|
|
25
|
+
|
|
26
|
+
// If request to count one of the includes:
|
|
27
|
+
if (isForAssociation) {
|
|
28
|
+
// Check if it's available:
|
|
29
|
+
if (!filterIncludes[countTarget]) {
|
|
30
|
+
const err = new NodesterQueryError(`Count for '${ countTarget }' is not available.`);
|
|
31
|
+
Error.captureStackTrace(err, mapCOUNT);
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Unwrap desired association info:
|
|
36
|
+
const {
|
|
37
|
+
as,
|
|
38
|
+
target,
|
|
39
|
+
foreignKey,
|
|
40
|
+
sourceKey
|
|
41
|
+
} = associations[countTarget];
|
|
42
|
+
const { tableName } = target;
|
|
43
|
+
|
|
44
|
+
// Compile request:
|
|
45
|
+
// Example of desired SQL:
|
|
46
|
+
// `(SELECT COUNT(*) FROM comments WHERE comments.post_id=Post.id)`
|
|
47
|
+
const rawSQL = `(SELECT COUNT(*) FROM ${ tableName } where ${ tableName }.${ foreignKey }=${ rootModelName.singular }.${ sourceKey })`;
|
|
48
|
+
|
|
49
|
+
const countAttributeName = `${ as }_count`;
|
|
50
|
+
sequelizeQuery.attributes.push(
|
|
51
|
+
[sequelize.literal(rawSQL), countAttributeName]
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If request to COUNT root model:
|
|
56
|
+
else if (isForRootModel) {
|
|
57
|
+
const firstAttribute = Object.keys(rootModel.tableAttributes)[0];
|
|
58
|
+
|
|
59
|
+
const countAttributeName = `${ rootModelName.plural.toLowerCase() }_count`;
|
|
60
|
+
sequelizeQuery.attributes.push(
|
|
61
|
+
[sequelize.fn('COUNT', firstAttribute), countAttributeName]
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Unknown attribute:
|
|
66
|
+
else {
|
|
67
|
+
const err = new NodesterQueryError(`Count for '${ countTarget }' is not available.`);
|
|
68
|
+
Error.captureStackTrace(err, mapCOUNT);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch(error) {
|
|
73
|
+
Error.captureStackTrace(error, mapCOUNT);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/structures/Filter.js
CHANGED
|
@@ -44,6 +44,8 @@ module.exports = class NodesterFilter {
|
|
|
44
44
|
|
|
45
45
|
this._attributes = [];
|
|
46
46
|
this._clauses = [];
|
|
47
|
+
this._functions = [];
|
|
48
|
+
|
|
47
49
|
this._includes = {};
|
|
48
50
|
|
|
49
51
|
this._bounds = {
|
|
@@ -74,21 +76,47 @@ module.exports = class NodesterFilter {
|
|
|
74
76
|
const {
|
|
75
77
|
attributes,
|
|
76
78
|
clauses,
|
|
79
|
+
functions,
|
|
77
80
|
includes,
|
|
78
81
|
bounds,
|
|
79
82
|
statics,
|
|
80
83
|
} = options;
|
|
81
84
|
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
if (
|
|
86
|
+
// Check attributes type:
|
|
87
|
+
if (!!attributes) {
|
|
88
|
+
if (!Array.isArray(attributes)) {
|
|
89
|
+
const err = new TypeError(`[NodesterFilter]: 'attributes' parameter must be an array.`);
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
this._attributes = attributes;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
// Check clauses type:
|
|
97
|
+
if (!!clauses) {
|
|
98
|
+
if (!Array.isArray(clauses)) {
|
|
99
|
+
const err = new TypeError(`[NodesterFilter]: 'clauses' parameter must be an array.`);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
this._clauses = clauses;
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
// If functions are set:
|
|
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.`);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this._functions = functions;
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
// Includes:
|
|
93
121
|
if (typeof includes === 'object') {
|
|
94
122
|
const { associations } = this.model;
|
|
@@ -152,6 +180,10 @@ module.exports = class NodesterFilter {
|
|
|
152
180
|
return this._clauses;
|
|
153
181
|
}
|
|
154
182
|
|
|
183
|
+
get functions() {
|
|
184
|
+
return this._functions;
|
|
185
|
+
}
|
|
186
|
+
|
|
155
187
|
get includes() {
|
|
156
188
|
return this._includes;
|
|
157
189
|
}
|
package/package.json
CHANGED
package/tests/nql.test.js
CHANGED
|
@@ -575,4 +575,60 @@ describe('nodester Query Language', () => {
|
|
|
575
575
|
expect(result).toMatchObject(expected);
|
|
576
576
|
});
|
|
577
577
|
});
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
describe('functions', () => {
|
|
581
|
+
const queryStrings = {
|
|
582
|
+
count_long: '?functions=count(comments)',
|
|
583
|
+
count_short: '?fn=count(comments)',
|
|
584
|
+
|
|
585
|
+
count_and_includes: '?fn=count(comments)&in=comments',
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
test('Count (full key name)', () => {
|
|
589
|
+
const lexer = new QueryLexer( queryStrings.count_long );
|
|
590
|
+
const result = lexer.query;
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
const tree = new ModelsTree();
|
|
594
|
+
tree.node.addFunction({
|
|
595
|
+
fn: 'count',
|
|
596
|
+
args: ['comments']
|
|
597
|
+
})
|
|
598
|
+
const expected = tree.root.toObject();
|
|
599
|
+
|
|
600
|
+
expect(result).toMatchObject(expected);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('Count (short key name)', () => {
|
|
604
|
+
const lexer = new QueryLexer( queryStrings.count_long );
|
|
605
|
+
const result = lexer.query;
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
const tree = new ModelsTree();
|
|
609
|
+
tree.node.addFunction({
|
|
610
|
+
fn: 'count',
|
|
611
|
+
args: ['comments']
|
|
612
|
+
})
|
|
613
|
+
const expected = tree.root.toObject();
|
|
614
|
+
|
|
615
|
+
expect(result).toMatchObject(expected);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('Count and includes', () => {
|
|
619
|
+
const lexer = new QueryLexer( queryStrings.count_and_includes );
|
|
620
|
+
const result = lexer.query;
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
const tree = new ModelsTree();
|
|
624
|
+
tree.node.addFunction({
|
|
625
|
+
fn: 'count',
|
|
626
|
+
args: ['comments']
|
|
627
|
+
})
|
|
628
|
+
tree.include('comments');
|
|
629
|
+
const expected = tree.root.toObject();
|
|
630
|
+
|
|
631
|
+
expect(result).toMatchObject(expected);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
578
634
|
});
|