@webda/ql 4.0.0-beta.5 → 4.0.0-beta.7

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.
Files changed (63) hide show
  1. package/README.md +72 -31
  2. package/lib/WebdaQLLexer.d.ts +44 -30
  3. package/lib/WebdaQLLexer.d.ts.map +1 -1
  4. package/lib/WebdaQLLexer.js +257 -152
  5. package/lib/WebdaQLLexer.js.map +1 -1
  6. package/lib/WebdaQLParserLexer.d.ts +44 -30
  7. package/lib/WebdaQLParserLexer.d.ts.map +1 -1
  8. package/lib/WebdaQLParserLexer.js +257 -152
  9. package/lib/WebdaQLParserLexer.js.map +1 -1
  10. package/lib/WebdaQLParserListener.d.ts +203 -30
  11. package/lib/WebdaQLParserListener.d.ts.map +1 -1
  12. package/lib/WebdaQLParserParser.d.ts +242 -44
  13. package/lib/WebdaQLParserParser.d.ts.map +1 -1
  14. package/lib/WebdaQLParserParser.js +2054 -715
  15. package/lib/WebdaQLParserParser.js.map +1 -1
  16. package/lib/WebdaQLParserVisitor.d.ts +126 -17
  17. package/lib/WebdaQLParserVisitor.d.ts.map +1 -1
  18. package/lib/aggregation-query.d.ts +95 -0
  19. package/lib/aggregation-query.d.ts.map +1 -0
  20. package/lib/aggregation-query.js +104 -0
  21. package/lib/aggregation-query.js.map +1 -0
  22. package/lib/aggregation-select.spec.d.ts +2 -0
  23. package/lib/aggregation-select.spec.d.ts.map +1 -0
  24. package/lib/aggregation-select.spec.js +165 -0
  25. package/lib/aggregation-select.spec.js.map +1 -0
  26. package/lib/aggregation.d.ts +97 -0
  27. package/lib/aggregation.d.ts.map +1 -0
  28. package/lib/aggregation.js +241 -0
  29. package/lib/aggregation.js.map +1 -0
  30. package/lib/aggregation.spec.d.ts +2 -0
  31. package/lib/aggregation.spec.d.ts.map +1 -0
  32. package/lib/aggregation.spec.js +360 -0
  33. package/lib/aggregation.spec.js.map +1 -0
  34. package/lib/bind.d.ts.map +1 -1
  35. package/lib/bind.js +29 -0
  36. package/lib/bind.js.map +1 -1
  37. package/lib/filter.d.ts +54 -0
  38. package/lib/filter.d.ts.map +1 -0
  39. package/lib/filter.js +135 -0
  40. package/lib/filter.js.map +1 -0
  41. package/lib/filter.spec.d.ts +2 -0
  42. package/lib/filter.spec.d.ts.map +1 -0
  43. package/lib/filter.spec.js +124 -0
  44. package/lib/filter.spec.js.map +1 -0
  45. package/lib/index.d.ts +2 -0
  46. package/lib/index.d.ts.map +1 -1
  47. package/lib/index.js +2 -0
  48. package/lib/index.js.map +1 -1
  49. package/lib/query.d.ts +173 -12
  50. package/lib/query.d.ts.map +1 -1
  51. package/lib/query.js +529 -64
  52. package/lib/query.js.map +1 -1
  53. package/lib/query.spec.js +7 -4
  54. package/lib/query.spec.js.map +1 -1
  55. package/lib/statements.spec.d.ts +2 -0
  56. package/lib/statements.spec.d.ts.map +1 -0
  57. package/lib/statements.spec.js +423 -0
  58. package/lib/statements.spec.js.map +1 -0
  59. package/lib/webdaql-string.d.ts +9 -0
  60. package/lib/webdaql-string.d.ts.map +1 -1
  61. package/lib/webdaql-string.js +9 -8
  62. package/lib/webdaql-string.js.map +1 -1
  63. package/package.json +2 -2
package/lib/query.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { CharStreams, CommonTokenStream } from "antlr4ts";
2
2
  import { AbstractParseTreeVisitor, TerminalNode } from "antlr4ts/tree/index.js";
3
3
  import { WebdaQLLexer } from "./WebdaQLLexer.js";
4
- import { OrderFieldExpressionContext, SubExpressionContext, WebdaQLParserParser } from "./WebdaQLParserParser.js";
5
- import { WebdaQLError } from "./webdaql-string.js";
4
+ import { CountAllItemContext, CountDistinctItemContext, FieldItemContext, MetricItemContext, OrderFieldExpressionContext, SubExpressionContext, WebdaQLParserParser } from "./WebdaQLParserParser.js";
5
+ import { checkAlias, validateAggregation } from "./aggregation-query.js";
6
+ import { escapeValue, WebdaQLError } from "./webdaql-string.js";
6
7
  /**
7
8
  * Strip the quotes of a WebdaQL string literal and unescape its contents.
8
9
  *
@@ -58,11 +59,14 @@ export function unescapeStringLiteral(literal) {
58
59
  * Prepend a condition to an existing query string using AND logic
59
60
  *
60
61
  * Parses both strings, merges them, and reconstructs the combined query.
61
- * ORDER BY, LIMIT, and OFFSET clauses from the original query are preserved.
62
+ * The statement head (`SELECT fields`, `DELETE`, `UPDATE SET ...`) and the ORDER BY, LIMIT, and OFFSET clauses
63
+ * of the original query are preserved; the condition joins the WHERE part. Both sides keep their own grouping:
64
+ * `a OR b` merged with `c OR d` is `( a OR b ) AND ( c OR d )`.
62
65
  *
63
- * @param query - existing query string (may include ORDER BY / LIMIT / OFFSET)
64
- * @param condition - condition to prepend (filter expression only)
66
+ * @param query - existing query string (a plain filter or a statement, may include ORDER BY / LIMIT / OFFSET)
67
+ * @param condition - condition to prepend (a plain filter: a statement is refused)
65
68
  * @returns merged query string
69
+ * @throws {SyntaxError} if the condition is a statement, or adds ORDER BY / OFFSET to a DELETE or UPDATE
66
70
  *
67
71
  * @example
68
72
  * ```ts
@@ -99,6 +103,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
99
103
  defaultResult() {
100
104
  // An empty AND return true
101
105
  return {
106
+ type: "SELECT",
102
107
  filter: new AndExpression([])
103
108
  };
104
109
  }
@@ -146,41 +151,167 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
146
151
  * @param ctx - the order expression context
147
152
  */
148
153
  visitOrderExpression(ctx) {
149
- this.orderBy = ctx.children
150
- ?.filter(c => c instanceof OrderFieldExpressionContext)
154
+ this.orderBy = (ctx.children ?? [])
155
+ .filter(c => c instanceof OrderFieldExpressionContext)
151
156
  .map((c) => this.visitOrderFieldExpression(c));
152
157
  }
153
158
  /**
154
- * Visit the root `webdaql` rule and build the complete Query
159
+ * Visit the root `webdaql` rule and build the complete Query: a statement or a plain filter query
160
+ * @param ctx - the webdaql parse context
161
+ * @returns the built Query
162
+ */
163
+ visitWebdaql(ctx) {
164
+ const statement = ctx.statement();
165
+ const filter = ctx.filterQuery();
166
+ return statement ? this.visitStatement(statement) : filter ? this.visitFilterQuery(filter) : this.defaultResult();
167
+ }
168
+ /**
169
+ * Visit a DELETE, UPDATE or SELECT statement
170
+ * @param ctx - the statement context
171
+ * @returns the built Query
172
+ */
173
+ visitStatement(ctx) {
174
+ return this.visit(ctx.getChild(0));
175
+ }
176
+ /**
177
+ * Visit a plain filter query: an implicit SELECT of every field
155
178
  *
156
179
  * Parses filter expression, ORDER BY, LIMIT, and OFFSET clauses.
157
180
  * Returns an empty AND expression (always true) when no filter is present.
158
- * @param ctx - the webdaql parse context
181
+ * @param ctx - the filter query context
159
182
  * @returns the built Query
160
183
  */
161
- visitWebdaql(ctx) {
162
- if (ctx.childCount === 1) {
163
- // An empty AND return true
164
- return {
165
- filter: new AndExpression([])
166
- };
184
+ visitFilterQuery(ctx) {
185
+ return this.buildQuery("SELECT", ctx.expression(), ctx);
186
+ }
187
+ /**
188
+ * Visit `DELETE [WHERE ...] [LIMIT n]`
189
+ * @param ctx - the DELETE statement context
190
+ * @returns the built Query
191
+ */
192
+ visitDeleteStatement(ctx) {
193
+ return this.buildQuery("DELETE", ctx.whereClause()?.expression(), ctx);
194
+ }
195
+ /**
196
+ * Visit `UPDATE SET a = v, ... [WHERE ...] [LIMIT n]`
197
+ * @param ctx - the UPDATE statement context
198
+ * @returns the built Query
199
+ */
200
+ visitUpdateStatement(ctx) {
201
+ return {
202
+ ...this.buildQuery("UPDATE", ctx.whereClause()?.expression(), ctx),
203
+ assignments: ctx
204
+ .assignmentList()
205
+ .assignment()
206
+ .map(a => this.visitAssignment(a))
207
+ };
208
+ }
209
+ /**
210
+ * Visit a SELECT statement: a field list, or an aggregation when it has metrics or a GROUP BY
211
+ * @param ctx - the select statement context
212
+ * @returns the query
213
+ */
214
+ visitSelectStatement(ctx) {
215
+ const query = { ...this.buildQuery("SELECT", ctx.whereClause()?.expression(), ctx) };
216
+ const { fields, metrics } = this.visitFieldList(ctx.fieldList());
217
+ query.fields = fields;
218
+ const groupBy = ctx
219
+ .groupByExpression()
220
+ ?.identifier()
221
+ .map(identifier => identifier.text);
222
+ if (!Object.keys(metrics).length && groupBy === undefined) {
223
+ return query;
224
+ }
225
+ if (query.continuationToken !== undefined) {
226
+ throw new WebdaQLError("OFFSET is not supported with GROUP BY or aggregate functions");
167
227
  }
168
- // To parse offset and limit and order by
169
- for (let i = 1; i < ctx.childCount - 1; i++) {
170
- this.visit(ctx.getChild(i));
228
+ const by = groupBy ?? [];
229
+ if (fields.length !== by.length || fields.some(field => !by.includes(field))) {
230
+ throw new WebdaQLError("With aggregate functions, the selected fields must be exactly the GROUP BY fields");
231
+ }
232
+ query.aggregation = validateAggregation({
233
+ filter: query.filter,
234
+ groupBy: by,
235
+ metrics,
236
+ orderBy: query.orderBy?.map(order => ({ key: order.field, direction: order.direction })),
237
+ limit: query.limit
238
+ });
239
+ return query;
240
+ }
241
+ /**
242
+ * Read one `field = value` of an UPDATE SET
243
+ * @param ctx - the assignment context
244
+ * @returns the assignment
245
+ */
246
+ visitAssignment(ctx) {
247
+ return {
248
+ field: ctx.identifier().text,
249
+ value: this.visit(ctx.getChild(2))
250
+ };
251
+ }
252
+ /**
253
+ * Read the field list of a SELECT: plain fields and aggregate metrics
254
+ * @param ctx - the field list context
255
+ * @returns the field paths and the metrics by alias
256
+ */
257
+ visitFieldList(ctx) {
258
+ const fields = [];
259
+ const metrics = {};
260
+ const add = (alias, metric) => {
261
+ // Before assigning: a `__proto__` alias would otherwise change the prototype of `metrics`
262
+ checkAlias(alias);
263
+ if (Object.prototype.hasOwnProperty.call(metrics, alias)) {
264
+ throw new WebdaQLError(`Duplicate metric alias '${alias}'`);
265
+ }
266
+ metrics[alias] = metric;
267
+ };
268
+ for (const item of ctx.selectItem()) {
269
+ if (item instanceof FieldItemContext) {
270
+ fields.push(item.identifier().text);
271
+ }
272
+ else if (item instanceof CountAllItemContext) {
273
+ add(item.identifier().text, { fn: "COUNT" });
274
+ }
275
+ else if (item instanceof CountDistinctItemContext) {
276
+ add(item.identifier(1).text, { fn: "COUNT_DISTINCT", field: item.identifier(0).text });
277
+ }
278
+ else if (item instanceof MetricItemContext) {
279
+ add(item.identifier(1).text, {
280
+ fn: item.getChild(0).text,
281
+ field: item.identifier(0).text
282
+ });
283
+ }
171
284
  }
172
- // If the first element is a sub expression, it means we have a filter
173
- if (ctx.getChild(0) instanceof SubExpressionContext) {
174
- return {
175
- filter: normalizeFilter(this.visit(ctx.getChild(0).getChild(1)) || new AndExpression([])),
176
- limit: this.limit,
177
- continuationToken: this.offset,
178
- orderBy: this.orderBy
179
- };
285
+ return { fields, metrics };
286
+ }
287
+ /**
288
+ * Visit a parenthesised expression: the inner expression
289
+ * @param ctx - the sub expression context
290
+ * @returns the inner expression
291
+ */
292
+ visitSubExpression(ctx) {
293
+ return this.visit(ctx.expression());
294
+ }
295
+ /**
296
+ * Build the Query of a statement or filter query: its condition and its ORDER BY, LIMIT and OFFSET clauses
297
+ * @param type - the statement type
298
+ * @param expression - the condition, if any
299
+ * @param ctx - the statement context holding the optional clauses
300
+ * @returns the Query
301
+ */
302
+ buildQuery(type, expression, ctx) {
303
+ for (const clause of [
304
+ "orderExpression" in ctx ? ctx.orderExpression() : undefined,
305
+ ctx.limitExpression(),
306
+ "offsetExpression" in ctx ? ctx.offsetExpression() : undefined
307
+ ]) {
308
+ if (clause) {
309
+ this.visit(clause);
310
+ }
180
311
  }
181
- // Go down one level - if expression empty it means no expression were provided
182
312
  return {
183
- filter: normalizeFilter(this.visit(ctx.getChild(0)) || new AndExpression([])),
313
+ type,
314
+ filter: normalizeFilter((expression ? this.visit(expression) : undefined) || new AndExpression([])),
184
315
  limit: this.limit,
185
316
  continuationToken: this.offset,
186
317
  orderBy: this.orderBy
@@ -199,7 +330,8 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
199
330
  getComparison(ctx) {
200
331
  const res = [];
201
332
  // eslint-disable-next-line prefer-const
202
- let [left, _, right] = ctx.children;
333
+ let left = ctx.getChild(0);
334
+ let right = ctx.getChild(2);
203
335
  if (right instanceof SubExpressionContext) {
204
336
  right = right.getChild(1);
205
337
  }
@@ -237,7 +369,9 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
237
369
  * @returns the comparison expression
238
370
  */
239
371
  visitBinaryComparisonExpression(ctx) {
240
- const [left, op, right] = ctx.children;
372
+ const left = ctx.getChild(0);
373
+ const op = ctx.getChild(1);
374
+ const right = ctx.getChild(2);
241
375
  // @ts-ignore
242
376
  return new ComparisonExpression(op.text, left.text, this.visit(right));
243
377
  }
@@ -247,7 +381,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
247
381
  * @returns the array of values
248
382
  */
249
383
  visitSetExpression(ctx) {
250
- return ctx.children.filter((_i, id) => id % 2).map(c => this.visit(c));
384
+ return (ctx.children ?? []).filter((_i, id) => id % 2).map(c => this.visit(c));
251
385
  }
252
386
  /**
253
387
  * Visit a LIKE expression (e.g. `field LIKE '%pattern_'`)
@@ -255,7 +389,8 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
255
389
  * @returns the comparison expression
256
390
  */
257
391
  visitLikeExpression(ctx) {
258
- const [left, _, right] = ctx.children;
392
+ const left = ctx.getChild(0);
393
+ const right = ctx.getChild(2);
259
394
  const value = this.visit(right);
260
395
  return new ComparisonExpression("LIKE", left.text, value);
261
396
  }
@@ -265,7 +400,8 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
265
400
  * @returns the comparison expression
266
401
  */
267
402
  visitInExpression(ctx) {
268
- const [left, _, right] = ctx.children;
403
+ const left = ctx.getChild(0);
404
+ const right = ctx.getChild(2);
269
405
  const value = this.visit(right);
270
406
  return new ComparisonExpression("IN", left.text, value);
271
407
  }
@@ -275,7 +411,8 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
275
411
  * @returns the comparison expression
276
412
  */
277
413
  visitContainsExpression(ctx) {
278
- const [left, _, right] = ctx.children;
414
+ const left = ctx.getChild(0);
415
+ const right = ctx.getChild(2);
279
416
  const value = this.visit(right);
280
417
  return new ComparisonExpression("CONTAINS", left.text, value);
281
418
  }
@@ -468,19 +605,27 @@ export class ComparisonExpression extends Expression {
468
605
  * @override
469
606
  */
470
607
  eval(target) {
471
- const left = ComparisonExpression.getAttributeValue(target, this.attribute);
608
+ let left = ComparisonExpression.getAttributeValue(target, this.attribute);
609
+ // WebdaQL has no date literal: a date compared with a string compares as its ISO 8601 form, the way it is
610
+ // serialized; comparisons with numbers keep the native Date behavior
611
+ if (left instanceof Date &&
612
+ !isNaN(left.getTime()) &&
613
+ this.operator !== "LIKE" &&
614
+ (typeof this.value === "string" || (Array.isArray(this.value) && this.value.some(v => typeof v === "string")))) {
615
+ left = left.toISOString();
616
+ }
472
617
  switch (this.operator) {
473
618
  case "=":
474
619
  // ignore strong type on purpose
475
620
  return left == this.value;
476
621
  case "<=":
477
- return left <= this.value;
622
+ return left <= (this.value ?? 0);
478
623
  case ">=":
479
- return left >= this.value;
624
+ return left >= (this.value ?? 0);
480
625
  case "<":
481
- return left < this.value;
626
+ return left < (this.value ?? 0);
482
627
  case ">":
483
- return left > this.value;
628
+ return left > (this.value ?? 0);
484
629
  case "!=":
485
630
  return left != this.value;
486
631
  case "LIKE":
@@ -522,7 +667,7 @@ export class ComparisonExpression extends Expression {
522
667
  case "boolean":
523
668
  return value.toString().toUpperCase();
524
669
  }
525
- return value?.toString();
670
+ return value === null ? "NULL" : value.toString();
526
671
  }
527
672
  /**
528
673
  * Allow subclass to create different display
@@ -801,17 +946,7 @@ export class QueryValidator {
801
946
  * @returns the reconstructed query string
802
947
  */
803
948
  toString() {
804
- let res = this.query.filter.toString();
805
- if (this.query.orderBy) {
806
- res += ` ORDER BY ${this.query.orderBy.map(o => `${o.field} ${o.direction}`).join(", ")}`;
807
- }
808
- if (this.query.limit) {
809
- res += ` LIMIT ${this.query.limit}`;
810
- }
811
- if (this.query.continuationToken) {
812
- res += ` OFFSET "${this.query.continuationToken}"`;
813
- }
814
- return res.trim();
949
+ return stringifyQuery(this.query);
815
950
  }
816
951
  /**
817
952
  * Merge another query string into this query
@@ -823,8 +958,15 @@ export class QueryValidator {
823
958
  * @param type - logical operator to combine filters (`"AND"` or `"OR"`)
824
959
  * @returns `this` for chaining
825
960
  */
826
- merge(query, type = "AND") {
961
+ merge(query = "", type = "AND") {
827
962
  const adds = new QueryValidator(query);
963
+ if (adds.query.type !== "SELECT" || adds.query.fields) {
964
+ throw new SyntaxError(`Only a filter can be merged into a query, not a statement (Query: ${query})`);
965
+ }
966
+ if ((this.query.type === "DELETE" || this.query.type === "UPDATE") &&
967
+ (adds.query.orderBy || adds.query.continuationToken)) {
968
+ throw new SyntaxError(`${this.query.type} statements take no ORDER BY or OFFSET (Query: ${query})`);
969
+ }
828
970
  // Add additional conditions
829
971
  if (adds.hasCondition()) {
830
972
  if ((this.query.filter instanceof AndExpression && type === "AND") ||
@@ -836,7 +978,7 @@ export class QueryValidator {
836
978
  }
837
979
  }
838
980
  // Set the limit if overriden
839
- if (adds.query.limit) {
981
+ if (adds.query.limit !== undefined) {
840
982
  this.query.limit = adds.query.limit;
841
983
  }
842
984
  // Set the offset if overriden
@@ -871,11 +1013,10 @@ export class QueryValidator {
871
1013
  * @returns the full query object
872
1014
  */
873
1015
  getQuery() {
874
- return {
875
- ...this.query,
876
- // Use displayTree to get the truely executed query
877
- toString: () => this.displayTree()
878
- };
1016
+ const query = { ...this.query };
1017
+ // Print the current state of the query, so a caller changing its LIMIT or OFFSET prints the new values
1018
+ query.toString = () => stringifyQuery(query);
1019
+ return query;
879
1020
  }
880
1021
  /**
881
1022
  * Verify if a target fit the expression
@@ -1080,7 +1221,8 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1080
1221
  * @returns the partial comparison expression
1081
1222
  */
1082
1223
  visitLikeExpression(ctx) {
1083
- const [left, _, right] = ctx.children;
1224
+ const left = ctx.getChild(0);
1225
+ const right = ctx.getChild(2);
1084
1226
  const value = this.visit(right);
1085
1227
  return new PartialComparisonExpression(this, "LIKE", left.text, value);
1086
1228
  }
@@ -1090,7 +1232,9 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1090
1232
  * @returns the partial comparison expression
1091
1233
  */
1092
1234
  visitBinaryComparisonExpression(ctx) {
1093
- const [left, op, right] = ctx.children;
1235
+ const left = ctx.getChild(0);
1236
+ const op = ctx.getChild(1);
1237
+ const right = ctx.getChild(2);
1094
1238
  // @ts-ignore
1095
1239
  return new PartialComparisonExpression(this, op.text, left.text, this.visit(right));
1096
1240
  }
@@ -1100,7 +1244,8 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1100
1244
  * @returns the partial comparison expression
1101
1245
  */
1102
1246
  visitInExpression(ctx) {
1103
- const [left, _, right] = ctx.children;
1247
+ const left = ctx.getChild(0);
1248
+ const right = ctx.getChild(2);
1104
1249
  const value = this.visit(right);
1105
1250
  return new PartialComparisonExpression(this, "IN", left.text, value);
1106
1251
  }
@@ -1110,7 +1255,8 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1110
1255
  * @returns the partial comparison expression
1111
1256
  */
1112
1257
  visitContainsExpression(ctx) {
1113
- const [left, _, right] = ctx.children;
1258
+ const left = ctx.getChild(0);
1259
+ const right = ctx.getChild(2);
1114
1260
  const value = this.visit(right);
1115
1261
  return new PartialComparisonExpression(this, "CONTAINS", left.text, value);
1116
1262
  }
@@ -1139,12 +1285,331 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1139
1285
  export function unsanitize(query) {
1140
1286
  return query.replace(/&lt;/g, "<").replace(/&gt;/g, ">");
1141
1287
  }
1288
+ /**
1289
+ * Print a query in its canonical form, which parses back to the same query
1290
+ *
1291
+ * - plain filter: `<filter> [ORDER BY ...] [LIMIT n] [OFFSET "token"]`
1292
+ * - `SELECT f1, f2 [WHERE <filter>] [ORDER BY ...] [LIMIT n] [OFFSET "token"]`
1293
+ * - `DELETE [WHERE <filter>] [LIMIT n]`
1294
+ * - `UPDATE SET a = v, ... [WHERE <filter>] [LIMIT n]`
1295
+ *
1296
+ * @param query - the query
1297
+ * @returns the query string
1298
+ */
1299
+ export function stringifyQuery(query) {
1300
+ const filter = query.filter.toString();
1301
+ let head = "";
1302
+ if (query.type === "DELETE") {
1303
+ head = "DELETE";
1304
+ }
1305
+ else if (query.type === "UPDATE") {
1306
+ head = `UPDATE SET ${(query.assignments ?? []).map(a => new ComparisonExpression("=", a.field, a.value).toString()).join(", ")}`;
1307
+ }
1308
+ else if (query.aggregation) {
1309
+ const metrics = Object.entries(query.aggregation.metrics).map(([alias, metric]) => {
1310
+ if (metric.fn === "COUNT_DISTINCT") {
1311
+ return `COUNT(DISTINCT ${metric.field}) AS ${alias}`;
1312
+ }
1313
+ return `${metric.fn}(${metric.field ?? "*"}) AS ${alias}`;
1314
+ });
1315
+ head = `SELECT ${[...query.aggregation.groupBy, ...metrics].join(", ")}`;
1316
+ }
1317
+ else if (query.fields) {
1318
+ head = `SELECT ${query.fields.join(", ")}`;
1319
+ }
1320
+ let res = head ? `${head}${filter ? ` WHERE ${filter}` : ""}` : filter;
1321
+ if (query.aggregation?.groupBy.length) {
1322
+ res += ` GROUP BY ${query.aggregation.groupBy.join(", ")}`;
1323
+ }
1324
+ if (query.orderBy?.length) {
1325
+ res += ` ORDER BY ${query.orderBy.map(o => `${o.field} ${o.direction}`).join(", ")}`;
1326
+ }
1327
+ if (query.limit !== undefined) {
1328
+ res += ` LIMIT ${query.limit}`;
1329
+ }
1330
+ if (query.continuationToken) {
1331
+ res += ` OFFSET ${ComparisonExpression.prototype.toStringValue(query.continuationToken)}`;
1332
+ }
1333
+ return res.trim();
1334
+ }
1335
+ /**
1336
+ * One segment of a field path, as the grammar's identifiers allow it: letters, digits and `_`
1337
+ */
1338
+ const FIELD_SEGMENT = /^[A-Za-z0-9_]+$/;
1339
+ /**
1340
+ * Check a field path of a query object
1341
+ * @param field - the path, as a string or its segments
1342
+ * @returns the dotted path
1343
+ * @throws {WebdaQLError} when a segment is empty or holds anything but letters, digits and `_`
1344
+ */
1345
+ export function checkFieldPath(field) {
1346
+ // Copy first: what is checked is what is printed, whatever the object does on later reads
1347
+ const segments = Array.isArray(field) ? snapshot(field) : typeof field === "string" ? field.split(".") : undefined;
1348
+ if (!segments?.length || segments.some(segment => typeof segment !== "string" || !FIELD_SEGMENT.test(segment))) {
1349
+ throw new WebdaQLError("Invalid field path in WebdaQL query");
1350
+ }
1351
+ return segments.join(".");
1352
+ }
1353
+ /**
1354
+ * Copy an array of a query object into a plain array, reading each element once
1355
+ * @param value - the array
1356
+ * @returns the copy
1357
+ */
1358
+ function snapshot(value) {
1359
+ const length = value.length;
1360
+ const copy = [];
1361
+ for (let i = 0; i < length; i++) {
1362
+ copy.push(value[i]);
1363
+ }
1364
+ return copy;
1365
+ }
1366
+ /**
1367
+ * Print a single value of a query object
1368
+ * @param value - the value
1369
+ * @returns its WebdaQL literal
1370
+ * @throws {WebdaQLError} for anything but a string, a finite number or a boolean
1371
+ */
1372
+ function checkScalar(value) {
1373
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
1374
+ throw new WebdaQLError(`Invalid value in WebdaQL query: ${typeof value}`);
1375
+ }
1376
+ return escapeValue(value);
1377
+ }
1378
+ /**
1379
+ * Print an expression of a query object from its data only (never its own toString); every property and array is
1380
+ * read once, so what is checked is what is printed
1381
+ * @param expression - the expression
1382
+ * @param nested - whether the expression is inside a logical expression
1383
+ * @returns the WebdaQL text
1384
+ * @throws {WebdaQLError} for an expression the grammar cannot produce
1385
+ */
1386
+ function printExpression(expression, nested) {
1387
+ if (expression instanceof BooleanExpression) {
1388
+ const value = expression.value;
1389
+ if (typeof value === "boolean") {
1390
+ return value ? "TRUE" : "FALSE";
1391
+ }
1392
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1393
+ }
1394
+ if (expression instanceof AndExpression || expression instanceof OrExpression) {
1395
+ const operator = expression instanceof AndExpression ? "AND" : "OR";
1396
+ const children = expression.children;
1397
+ if (!Array.isArray(children)) {
1398
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1399
+ }
1400
+ const copy = snapshot(children);
1401
+ if (copy.length === 0) {
1402
+ // An empty AND or OR matches everything
1403
+ return nested ? "TRUE" : "";
1404
+ }
1405
+ const text = copy.map(child => printExpression(child, true)).join(` ${operator} `);
1406
+ return nested ? `( ${text} )` : text;
1407
+ }
1408
+ if (expression instanceof ComparisonExpression) {
1409
+ const attribute = checkFieldPath(expression.attribute);
1410
+ const operator = expression.operator;
1411
+ const value = expression.value;
1412
+ if (typeof operator !== "string") {
1413
+ throw new WebdaQLError("Invalid operator in WebdaQL query");
1414
+ }
1415
+ if (UNARY_COMPARISON_OPERATORS.includes(operator)) {
1416
+ return `${attribute} ${operator}`;
1417
+ }
1418
+ if (operator === "IN") {
1419
+ const values = Array.isArray(value) ? snapshot(value) : undefined;
1420
+ if (!values?.length) {
1421
+ throw new WebdaQLError("Invalid IN values in WebdaQL query");
1422
+ }
1423
+ return `${attribute} IN [${values.map(checkScalar).join(", ")}]`;
1424
+ }
1425
+ if (!["=", "!=", "<", "<=", ">", ">=", "LIKE", "CONTAINS"].includes(operator)) {
1426
+ throw new WebdaQLError(`Invalid operator in WebdaQL query: ${JSON.stringify(operator)}`);
1427
+ }
1428
+ if ((operator === "LIKE" || operator === "CONTAINS") && typeof value !== "string") {
1429
+ throw new WebdaQLError(`${operator} takes a string in WebdaQL query`);
1430
+ }
1431
+ return `${attribute} ${operator} ${checkScalar(value)}`;
1432
+ }
1433
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1434
+ }
1435
+ /**
1436
+ * Normalize a query object built or changed by code: every part is checked against what the grammar allows, the
1437
+ * query is printed from its data and parsed again
1438
+ *
1439
+ * Use it before handing a `Query` object received from a caller to a backend: field paths (letters, digits, `_`),
1440
+ * operators, values (strings, finite numbers, booleans), LIMIT (non-negative integer) and OFFSET (string) are
1441
+ * checked, and the result only holds expressions built by the parser.
1442
+ *
1443
+ * @param query - the query object
1444
+ * @returns the same query, as parsed from its canonical text
1445
+ * @throws {WebdaQLError} when a part of the query cannot be written in WebdaQL
1446
+ */
1447
+ export function normalizeQuery(query) {
1448
+ if (!query || typeof query !== "object") {
1449
+ throw new WebdaQLError("Invalid WebdaQL query object");
1450
+ }
1451
+ const type = query.type ?? "SELECT";
1452
+ if (typeof type !== "string" || !["SELECT", "DELETE", "UPDATE"].includes(type)) {
1453
+ throw new WebdaQLError(`Invalid statement type in WebdaQL query: ${JSON.stringify(type)}`);
1454
+ }
1455
+ if (query.aggregation !== undefined) {
1456
+ throw new WebdaQLError("Aggregation queries cannot be normalized: use toAggregationQuery");
1457
+ }
1458
+ const filterObject = query.filter;
1459
+ const filter = printExpression(filterObject ?? new AndExpression([]), false);
1460
+ let head = "";
1461
+ if (type === "DELETE") {
1462
+ head = "DELETE";
1463
+ }
1464
+ else if (type === "UPDATE") {
1465
+ const assignments = Array.isArray(query.assignments) ? snapshot(query.assignments) : [];
1466
+ if (assignments.length === 0) {
1467
+ throw new WebdaQLError("UPDATE needs at least one SET assignment");
1468
+ }
1469
+ head = `UPDATE SET ${assignments
1470
+ .map(a => {
1471
+ const field = a?.field;
1472
+ const value = a?.value;
1473
+ return `${checkFieldPath(field)} = ${checkScalar(value)}`;
1474
+ })
1475
+ .join(", ")}`;
1476
+ }
1477
+ else if (query.fields !== undefined) {
1478
+ const fields = Array.isArray(query.fields) ? snapshot(query.fields) : [];
1479
+ if (fields.length === 0) {
1480
+ throw new WebdaQLError("Invalid SELECT field list in WebdaQL query");
1481
+ }
1482
+ head = `SELECT ${fields.map(checkFieldPath).join(", ")}`;
1483
+ }
1484
+ let res = head ? `${head}${filter ? ` WHERE ${filter}` : ""}` : filter;
1485
+ const orderBy = query.orderBy;
1486
+ if (orderBy !== undefined) {
1487
+ if (!Array.isArray(orderBy)) {
1488
+ throw new WebdaQLError("Invalid ORDER BY in WebdaQL query");
1489
+ }
1490
+ const orders = snapshot(orderBy);
1491
+ if (orders.length) {
1492
+ res += ` ORDER BY ${orders
1493
+ .map(o => {
1494
+ const field = o?.field;
1495
+ const direction = o?.direction;
1496
+ if (direction !== "ASC" && direction !== "DESC") {
1497
+ throw new WebdaQLError("Invalid ORDER BY direction in WebdaQL query");
1498
+ }
1499
+ return `${checkFieldPath(field)} ${direction}`;
1500
+ })
1501
+ .join(", ")}`;
1502
+ }
1503
+ }
1504
+ const limit = query.limit;
1505
+ if (limit !== undefined) {
1506
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) {
1507
+ throw new WebdaQLError("Invalid LIMIT in WebdaQL query");
1508
+ }
1509
+ res += ` LIMIT ${limit}`;
1510
+ }
1511
+ const token = query.continuationToken;
1512
+ if (token !== undefined && token !== null) {
1513
+ if (typeof token !== "string") {
1514
+ throw new WebdaQLError("Invalid OFFSET in WebdaQL query");
1515
+ }
1516
+ if (token) {
1517
+ res += ` OFFSET ${escapeValue(token)}`;
1518
+ }
1519
+ }
1520
+ try {
1521
+ return parse(res.trim());
1522
+ }
1523
+ catch (err) {
1524
+ throw new WebdaQLError(`Invalid WebdaQL query object: ${err.message}`);
1525
+ }
1526
+ }
1527
+ /**
1528
+ * Whether a field path is allowed: listed itself, or nested in a listed field (`address.city` with `address`)
1529
+ * @param field - the field path
1530
+ * @param allowed - the allowed field paths
1531
+ * @returns true when allowed
1532
+ */
1533
+ function isAllowedField(field, allowed) {
1534
+ const segments = field.split(".");
1535
+ for (let i = 1; i <= segments.length; i++) {
1536
+ if (allowed.has(segments.slice(0, i).join("."))) {
1537
+ return true;
1538
+ }
1539
+ }
1540
+ return false;
1541
+ }
1542
+ /**
1543
+ * Validate the SELECT fields, the aggregation metric fields and the UPDATE SET targets of a query against the
1544
+ * allowed fields
1545
+ *
1546
+ * A dotted path is allowed when it is listed, or when one of its parents is (`profile.email` is allowed by
1547
+ * `profile` or by `profile.email`). GROUP BY paths are the SELECT fields; the field of every aggregation metric
1548
+ * (`SUM(points)`) is checked too, `COUNT(*)` has none. Filter attributes are not checked.
1549
+ *
1550
+ * @param query - parsed query to validate
1551
+ * @param allowedFields - allowed field paths (dot-notation)
1552
+ * @throws {SyntaxError} if a field, a metric field or an assignment target is not allowed
1553
+ */
1554
+ export function validateQueryFields(query, allowedFields) {
1555
+ const allowed = new Set(allowedFields);
1556
+ for (const field of query.fields ?? []) {
1557
+ if (!isAllowedField(field, allowed)) {
1558
+ throw new SyntaxError(`Unknown field "${field}". Allowed fields: ${allowedFields.join(", ")}`);
1559
+ }
1560
+ }
1561
+ for (const { field } of query.assignments ?? []) {
1562
+ if (!isAllowedField(field, allowed)) {
1563
+ throw new SyntaxError(`Unknown assignment field "${field}". Allowed fields: ${allowedFields.join(", ")}`);
1564
+ }
1565
+ }
1566
+ for (const [alias, metric] of Object.entries(query.aggregation?.metrics ?? {})) {
1567
+ if (metric.field !== undefined && !isAllowedField(metric.field, allowed)) {
1568
+ throw new SyntaxError(`Unknown field "${metric.field}" in "${alias}". Allowed fields: ${allowedFields.join(", ")}`);
1569
+ }
1570
+ }
1571
+ }
1572
+ /**
1573
+ * Refuse anything but a plain filter query: DELETE, UPDATE and SELECT with a field list
1574
+ *
1575
+ * Used where a query only selects objects, such as the Query operations exposed to clients. A query built by hand
1576
+ * without `type` is a filter.
1577
+ *
1578
+ * @param query - the parsed query
1579
+ * @throws {WebdaQLError} for a DELETE or UPDATE statement, or a SELECT field list
1580
+ */
1581
+ export function assertFilterQuery(query) {
1582
+ if (query.type !== undefined && query.type !== "SELECT") {
1583
+ throw new WebdaQLError(`${query.type} statements are not accepted here: only a filter query is`);
1584
+ }
1585
+ if (query.fields !== undefined) {
1586
+ throw new WebdaQLError("SELECT field lists are not accepted here: only a filter query is");
1587
+ }
1588
+ }
1142
1589
  /**
1143
1590
  * Parse a query string into a Query object
1591
+ *
1592
+ * Accepts a plain filter query (an implicit SELECT of every field) or a statement:
1593
+ * - `SELECT f1, f2 [WHERE <condition>] [ORDER BY ...] [LIMIT n] [OFFSET token]`
1594
+ * - `SELECT g1, COUNT(*) AS n, SUM(f) AS s [WHERE <condition>] [GROUP BY g1] [ORDER BY ...] [LIMIT n]`: an
1595
+ * aggregation (metrics COUNT, COUNT(DISTINCT f), SUM, AVG, MIN, MAX), returned in `aggregation`; the
1596
+ * selected fields must be the GROUP BY paths, every metric needs an alias, OFFSET is refused
1597
+ * - `DELETE [WHERE <condition>] [LIMIT n]`
1598
+ * - `UPDATE SET a = v, b = v [WHERE <condition>] [LIMIT n]`
1599
+ *
1600
+ * Keywords are uppercase only: `select`, `delete`, `set`, `count`... remain field names.
1601
+ *
1144
1602
  * @param query - the query string to parse
1603
+ * @param allowedFields - when given, SELECT fields, aggregation metric fields and UPDATE SET targets must be in
1604
+ * this list (see {@link validateQueryFields})
1145
1605
  * @returns the parsed Query object
1606
+ * @throws {SyntaxError} on a grammar error or a field outside `allowedFields`
1146
1607
  */
1147
- export function parse(query) {
1148
- return new QueryValidator(query).getQuery();
1608
+ export function parse(query, allowedFields) {
1609
+ const result = new QueryValidator(query).getQuery();
1610
+ if (allowedFields) {
1611
+ validateQueryFields(result, allowedFields);
1612
+ }
1613
+ return result;
1149
1614
  }
1150
1615
  //# sourceMappingURL=query.js.map