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 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) ➡️](docs/nql/Introduction.md), an extension of standard REST API syntax, it lets you craft complex queries with hierarchical associations.
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 ➡️](docs/CoreConcepts.md) for more info.
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 ➡️](docs/CoreConcepts.md)
70
+ [Core concepts documentation ](docs/CoreConcepts.md)
54
71
 
55
72
 
56
- ### Queries & Querying - nodester Query Language (NQL)
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 ➡️](docs/nql/Introduction.md)
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 ➡️](docs/App.md)
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 NodesterError {
26
- constructor(message) {
20
+ module.exports = class NodesterQueryError extends Error {
21
+ constructor(message, status) {
27
22
  super(message);
28
23
 
29
- this.status = NOT_ACCEPTABLE;
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
- // If token is empty, error:
183
- if (token === '') {
184
- const err = UnexpectedCharError(i, char);
185
- throw err;
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
- case 'count':
191
- fnParams = {
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 && tree.node.activeParam !== PARAM_TOKENS.INCLUDES) {
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 TypeError(`'include' is forbidden inside subquery (position ${ i }). Use: 'model.submodel' or 'model.submodel1+submodel2'.`);
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 'includes':
527
- case 'in':
528
- return PARAM_TOKENS.INCLUDES;
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 TypeError(`Unexpected ${ char } at position ${ index }`);
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 TypeError(`Missing ${ char } at position ${ index }`);
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
- // At least 1 attribute is mandatory:
118
- if (newQuery.attributes.length === 0) {
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(fnParams.fn) {
146
+ switch(fnName) {
129
147
  // SQL COUNT():
130
148
  case 'count': {
131
- const countParams = fnParams.args;
132
-
133
- const [ countTarget ] = countParams;
134
- // Count can be requested for this model,
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
- newQuery.attributes.push(
173
- [sequelize.literal(rawSQL), countAttribute]
154
+ newQuery
174
155
  );
175
156
  }
176
- // Unknow function:
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
+ }
@@ -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
- // If attributes are array:
84
- if (Array.isArray(attributes)) {
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
- if (Array.isArray(clauses)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodester",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "A versatile REST framework for Node.js",
5
5
  "directories": {
6
6
  "docs": "docs",
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
  });