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

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 (55) hide show
  1. package/README.md +68 -27
  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 +164 -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/index.d.ts +1 -0
  38. package/lib/index.d.ts.map +1 -1
  39. package/lib/index.js +1 -0
  40. package/lib/index.js.map +1 -1
  41. package/lib/query.d.ts +162 -8
  42. package/lib/query.d.ts.map +1 -1
  43. package/lib/query.js +489 -45
  44. package/lib/query.js.map +1 -1
  45. package/lib/query.spec.js +6 -4
  46. package/lib/query.spec.js.map +1 -1
  47. package/lib/statements.spec.d.ts +2 -0
  48. package/lib/statements.spec.d.ts.map +1 -0
  49. package/lib/statements.spec.js +422 -0
  50. package/lib/statements.spec.js.map +1 -0
  51. package/lib/webdaql-string.d.ts +9 -0
  52. package/lib/webdaql-string.d.ts.map +1 -1
  53. package/lib/webdaql-string.js +9 -8
  54. package/lib/webdaql-string.js.map +1 -1
  55. 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
  }
@@ -151,36 +156,160 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
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
+ return ctx.statement() ? this.visitStatement(ctx.statement()) : this.visitFilterQuery(ctx.filterQuery());
165
+ }
166
+ /**
167
+ * Visit a DELETE, UPDATE or SELECT statement
168
+ * @param ctx - the statement context
169
+ * @returns the built Query
170
+ */
171
+ visitStatement(ctx) {
172
+ return this.visit(ctx.getChild(0));
173
+ }
174
+ /**
175
+ * Visit a plain filter query: an implicit SELECT of every field
155
176
  *
156
177
  * Parses filter expression, ORDER BY, LIMIT, and OFFSET clauses.
157
178
  * Returns an empty AND expression (always true) when no filter is present.
158
- * @param ctx - the webdaql parse context
179
+ * @param ctx - the filter query context
159
180
  * @returns the built Query
160
181
  */
161
- visitWebdaql(ctx) {
162
- if (ctx.childCount === 1) {
163
- // An empty AND return true
164
- return {
165
- filter: new AndExpression([])
166
- };
182
+ visitFilterQuery(ctx) {
183
+ return this.buildQuery("SELECT", ctx.expression(), ctx);
184
+ }
185
+ /**
186
+ * Visit `DELETE [WHERE ...] [LIMIT n]`
187
+ * @param ctx - the DELETE statement context
188
+ * @returns the built Query
189
+ */
190
+ visitDeleteStatement(ctx) {
191
+ return this.buildQuery("DELETE", ctx.whereClause()?.expression(), ctx);
192
+ }
193
+ /**
194
+ * Visit `UPDATE SET a = v, ... [WHERE ...] [LIMIT n]`
195
+ * @param ctx - the UPDATE statement context
196
+ * @returns the built Query
197
+ */
198
+ visitUpdateStatement(ctx) {
199
+ return {
200
+ ...this.buildQuery("UPDATE", ctx.whereClause()?.expression(), ctx),
201
+ assignments: ctx
202
+ .assignmentList()
203
+ .assignment()
204
+ .map(a => this.visitAssignment(a))
205
+ };
206
+ }
207
+ /**
208
+ * Visit a SELECT statement: a field list, or an aggregation when it has metrics or a GROUP BY
209
+ * @param ctx - the select statement context
210
+ * @returns the query
211
+ */
212
+ visitSelectStatement(ctx) {
213
+ const query = { ...this.buildQuery("SELECT", ctx.whereClause()?.expression(), ctx) };
214
+ const { fields, metrics } = this.visitFieldList(ctx.fieldList());
215
+ query.fields = fields;
216
+ const groupBy = ctx
217
+ .groupByExpression()
218
+ ?.identifier()
219
+ .map(identifier => identifier.text);
220
+ if (!Object.keys(metrics).length && groupBy === undefined) {
221
+ return query;
167
222
  }
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));
223
+ if (query.continuationToken !== undefined) {
224
+ throw new WebdaQLError("OFFSET is not supported with GROUP BY or aggregate functions");
171
225
  }
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
- };
226
+ const by = groupBy ?? [];
227
+ if (fields.length !== by.length || fields.some(field => !by.includes(field))) {
228
+ throw new WebdaQLError("With aggregate functions, the selected fields must be exactly the GROUP BY fields");
180
229
  }
181
- // Go down one level - if expression empty it means no expression were provided
230
+ query.aggregation = validateAggregation({
231
+ filter: query.filter,
232
+ groupBy: by,
233
+ metrics,
234
+ orderBy: query.orderBy?.map(order => ({ key: order.field, direction: order.direction })),
235
+ limit: query.limit
236
+ });
237
+ return query;
238
+ }
239
+ /**
240
+ * Read one `field = value` of an UPDATE SET
241
+ * @param ctx - the assignment context
242
+ * @returns the assignment
243
+ */
244
+ visitAssignment(ctx) {
182
245
  return {
183
- filter: normalizeFilter(this.visit(ctx.getChild(0)) || new AndExpression([])),
246
+ field: ctx.identifier().text,
247
+ value: this.visit(ctx.getChild(2))
248
+ };
249
+ }
250
+ /**
251
+ * Read the field list of a SELECT: plain fields and aggregate metrics
252
+ * @param ctx - the field list context
253
+ * @returns the field paths and the metrics by alias
254
+ */
255
+ visitFieldList(ctx) {
256
+ const fields = [];
257
+ const metrics = {};
258
+ const add = (alias, metric) => {
259
+ // Before assigning: a `__proto__` alias would otherwise change the prototype of `metrics`
260
+ checkAlias(alias);
261
+ if (Object.prototype.hasOwnProperty.call(metrics, alias)) {
262
+ throw new WebdaQLError(`Duplicate metric alias '${alias}'`);
263
+ }
264
+ metrics[alias] = metric;
265
+ };
266
+ for (const item of ctx.selectItem()) {
267
+ if (item instanceof FieldItemContext) {
268
+ fields.push(item.identifier().text);
269
+ }
270
+ else if (item instanceof CountAllItemContext) {
271
+ add(item.identifier().text, { fn: "COUNT" });
272
+ }
273
+ else if (item instanceof CountDistinctItemContext) {
274
+ add(item.identifier(1).text, { fn: "COUNT_DISTINCT", field: item.identifier(0).text });
275
+ }
276
+ else if (item instanceof MetricItemContext) {
277
+ add(item.identifier(1).text, {
278
+ fn: item.getChild(0).text,
279
+ field: item.identifier(0).text
280
+ });
281
+ }
282
+ }
283
+ return { fields, metrics };
284
+ }
285
+ /**
286
+ * Visit a parenthesised expression: the inner expression
287
+ * @param ctx - the sub expression context
288
+ * @returns the inner expression
289
+ */
290
+ visitSubExpression(ctx) {
291
+ return this.visit(ctx.expression());
292
+ }
293
+ /**
294
+ * Build the Query of a statement or filter query: its condition and its ORDER BY, LIMIT and OFFSET clauses
295
+ * @param type - the statement type
296
+ * @param expression - the condition, if any
297
+ * @param ctx - the statement context holding the optional clauses
298
+ * @returns the Query
299
+ */
300
+ buildQuery(type, expression, ctx) {
301
+ for (const clause of [
302
+ "orderExpression" in ctx ? ctx.orderExpression() : undefined,
303
+ ctx.limitExpression(),
304
+ "offsetExpression" in ctx ? ctx.offsetExpression() : undefined
305
+ ]) {
306
+ if (clause) {
307
+ this.visit(clause);
308
+ }
309
+ }
310
+ return {
311
+ type,
312
+ filter: normalizeFilter((expression ? this.visit(expression) : undefined) || new AndExpression([])),
184
313
  limit: this.limit,
185
314
  continuationToken: this.offset,
186
315
  orderBy: this.orderBy
@@ -801,17 +930,7 @@ export class QueryValidator {
801
930
  * @returns the reconstructed query string
802
931
  */
803
932
  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();
933
+ return stringifyQuery(this.query);
815
934
  }
816
935
  /**
817
936
  * Merge another query string into this query
@@ -825,6 +944,13 @@ export class QueryValidator {
825
944
  */
826
945
  merge(query, type = "AND") {
827
946
  const adds = new QueryValidator(query);
947
+ if (adds.query.type !== "SELECT" || adds.query.fields) {
948
+ throw new SyntaxError(`Only a filter can be merged into a query, not a statement (Query: ${query})`);
949
+ }
950
+ if ((this.query.type === "DELETE" || this.query.type === "UPDATE") &&
951
+ (adds.query.orderBy || adds.query.continuationToken)) {
952
+ throw new SyntaxError(`${this.query.type} statements take no ORDER BY or OFFSET (Query: ${query})`);
953
+ }
828
954
  // Add additional conditions
829
955
  if (adds.hasCondition()) {
830
956
  if ((this.query.filter instanceof AndExpression && type === "AND") ||
@@ -836,7 +962,7 @@ export class QueryValidator {
836
962
  }
837
963
  }
838
964
  // Set the limit if overriden
839
- if (adds.query.limit) {
965
+ if (adds.query.limit !== undefined) {
840
966
  this.query.limit = adds.query.limit;
841
967
  }
842
968
  // Set the offset if overriden
@@ -871,11 +997,10 @@ export class QueryValidator {
871
997
  * @returns the full query object
872
998
  */
873
999
  getQuery() {
874
- return {
875
- ...this.query,
876
- // Use displayTree to get the truely executed query
877
- toString: () => this.displayTree()
878
- };
1000
+ const query = { ...this.query };
1001
+ // Print the current state of the query, so a caller changing its LIMIT or OFFSET prints the new values
1002
+ query.toString = () => stringifyQuery(query);
1003
+ return query;
879
1004
  }
880
1005
  /**
881
1006
  * Verify if a target fit the expression
@@ -1139,12 +1264,331 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
1139
1264
  export function unsanitize(query) {
1140
1265
  return query.replace(/&lt;/g, "<").replace(/&gt;/g, ">");
1141
1266
  }
1267
+ /**
1268
+ * Print a query in its canonical form, which parses back to the same query
1269
+ *
1270
+ * - plain filter: `<filter> [ORDER BY ...] [LIMIT n] [OFFSET "token"]`
1271
+ * - `SELECT f1, f2 [WHERE <filter>] [ORDER BY ...] [LIMIT n] [OFFSET "token"]`
1272
+ * - `DELETE [WHERE <filter>] [LIMIT n]`
1273
+ * - `UPDATE SET a = v, ... [WHERE <filter>] [LIMIT n]`
1274
+ *
1275
+ * @param query - the query
1276
+ * @returns the query string
1277
+ */
1278
+ export function stringifyQuery(query) {
1279
+ const filter = query.filter.toString();
1280
+ let head = "";
1281
+ if (query.type === "DELETE") {
1282
+ head = "DELETE";
1283
+ }
1284
+ else if (query.type === "UPDATE") {
1285
+ head = `UPDATE SET ${(query.assignments ?? []).map(a => new ComparisonExpression("=", a.field, a.value).toString()).join(", ")}`;
1286
+ }
1287
+ else if (query.aggregation) {
1288
+ const metrics = Object.entries(query.aggregation.metrics).map(([alias, metric]) => {
1289
+ if (metric.fn === "COUNT_DISTINCT") {
1290
+ return `COUNT(DISTINCT ${metric.field}) AS ${alias}`;
1291
+ }
1292
+ return `${metric.fn}(${metric.field ?? "*"}) AS ${alias}`;
1293
+ });
1294
+ head = `SELECT ${[...query.aggregation.groupBy, ...metrics].join(", ")}`;
1295
+ }
1296
+ else if (query.fields) {
1297
+ head = `SELECT ${query.fields.join(", ")}`;
1298
+ }
1299
+ let res = head ? `${head}${filter ? ` WHERE ${filter}` : ""}` : filter;
1300
+ if (query.aggregation?.groupBy.length) {
1301
+ res += ` GROUP BY ${query.aggregation.groupBy.join(", ")}`;
1302
+ }
1303
+ if (query.orderBy?.length) {
1304
+ res += ` ORDER BY ${query.orderBy.map(o => `${o.field} ${o.direction}`).join(", ")}`;
1305
+ }
1306
+ if (query.limit !== undefined) {
1307
+ res += ` LIMIT ${query.limit}`;
1308
+ }
1309
+ if (query.continuationToken) {
1310
+ res += ` OFFSET ${ComparisonExpression.prototype.toStringValue(query.continuationToken)}`;
1311
+ }
1312
+ return res.trim();
1313
+ }
1314
+ /**
1315
+ * One segment of a field path, as the grammar's identifiers allow it: letters, digits and `_`
1316
+ */
1317
+ const FIELD_SEGMENT = /^[A-Za-z0-9_]+$/;
1318
+ /**
1319
+ * Check a field path of a query object
1320
+ * @param field - the path, as a string or its segments
1321
+ * @returns the dotted path
1322
+ * @throws {WebdaQLError} when a segment is empty or holds anything but letters, digits and `_`
1323
+ */
1324
+ function checkFieldPath(field) {
1325
+ // Copy first: what is checked is what is printed, whatever the object does on later reads
1326
+ const segments = Array.isArray(field) ? snapshot(field) : typeof field === "string" ? field.split(".") : undefined;
1327
+ if (!segments?.length || segments.some(segment => typeof segment !== "string" || !FIELD_SEGMENT.test(segment))) {
1328
+ throw new WebdaQLError("Invalid field path in WebdaQL query");
1329
+ }
1330
+ return segments.join(".");
1331
+ }
1332
+ /**
1333
+ * Copy an array of a query object into a plain array, reading each element once
1334
+ * @param value - the array
1335
+ * @returns the copy
1336
+ */
1337
+ function snapshot(value) {
1338
+ const length = value.length;
1339
+ const copy = [];
1340
+ for (let i = 0; i < length; i++) {
1341
+ copy.push(value[i]);
1342
+ }
1343
+ return copy;
1344
+ }
1345
+ /**
1346
+ * Print a single value of a query object
1347
+ * @param value - the value
1348
+ * @returns its WebdaQL literal
1349
+ * @throws {WebdaQLError} for anything but a string, a finite number or a boolean
1350
+ */
1351
+ function checkScalar(value) {
1352
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
1353
+ throw new WebdaQLError(`Invalid value in WebdaQL query: ${typeof value}`);
1354
+ }
1355
+ return escapeValue(value);
1356
+ }
1357
+ /**
1358
+ * Print an expression of a query object from its data only (never its own toString); every property and array is
1359
+ * read once, so what is checked is what is printed
1360
+ * @param expression - the expression
1361
+ * @param nested - whether the expression is inside a logical expression
1362
+ * @returns the WebdaQL text
1363
+ * @throws {WebdaQLError} for an expression the grammar cannot produce
1364
+ */
1365
+ function printExpression(expression, nested) {
1366
+ if (expression instanceof BooleanExpression) {
1367
+ const value = expression.value;
1368
+ if (typeof value === "boolean") {
1369
+ return value ? "TRUE" : "FALSE";
1370
+ }
1371
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1372
+ }
1373
+ if (expression instanceof AndExpression || expression instanceof OrExpression) {
1374
+ const operator = expression instanceof AndExpression ? "AND" : "OR";
1375
+ const children = expression.children;
1376
+ if (!Array.isArray(children)) {
1377
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1378
+ }
1379
+ const copy = snapshot(children);
1380
+ if (copy.length === 0) {
1381
+ // An empty AND or OR matches everything
1382
+ return nested ? "TRUE" : "";
1383
+ }
1384
+ const text = copy.map(child => printExpression(child, true)).join(` ${operator} `);
1385
+ return nested ? `( ${text} )` : text;
1386
+ }
1387
+ if (expression instanceof ComparisonExpression) {
1388
+ const attribute = checkFieldPath(expression.attribute);
1389
+ const operator = expression.operator;
1390
+ const value = expression.value;
1391
+ if (typeof operator !== "string") {
1392
+ throw new WebdaQLError("Invalid operator in WebdaQL query");
1393
+ }
1394
+ if (UNARY_COMPARISON_OPERATORS.includes(operator)) {
1395
+ return `${attribute} ${operator}`;
1396
+ }
1397
+ if (operator === "IN") {
1398
+ const values = Array.isArray(value) ? snapshot(value) : undefined;
1399
+ if (!values?.length) {
1400
+ throw new WebdaQLError("Invalid IN values in WebdaQL query");
1401
+ }
1402
+ return `${attribute} IN [${values.map(checkScalar).join(", ")}]`;
1403
+ }
1404
+ if (!["=", "!=", "<", "<=", ">", ">=", "LIKE", "CONTAINS"].includes(operator)) {
1405
+ throw new WebdaQLError(`Invalid operator in WebdaQL query: ${JSON.stringify(operator)}`);
1406
+ }
1407
+ if ((operator === "LIKE" || operator === "CONTAINS") && typeof value !== "string") {
1408
+ throw new WebdaQLError(`${operator} takes a string in WebdaQL query`);
1409
+ }
1410
+ return `${attribute} ${operator} ${checkScalar(value)}`;
1411
+ }
1412
+ throw new WebdaQLError("Invalid expression in WebdaQL query");
1413
+ }
1414
+ /**
1415
+ * Normalize a query object built or changed by code: every part is checked against what the grammar allows, the
1416
+ * query is printed from its data and parsed again
1417
+ *
1418
+ * Use it before handing a `Query` object received from a caller to a backend: field paths (letters, digits, `_`),
1419
+ * operators, values (strings, finite numbers, booleans), LIMIT (non-negative integer) and OFFSET (string) are
1420
+ * checked, and the result only holds expressions built by the parser.
1421
+ *
1422
+ * @param query - the query object
1423
+ * @returns the same query, as parsed from its canonical text
1424
+ * @throws {WebdaQLError} when a part of the query cannot be written in WebdaQL
1425
+ */
1426
+ export function normalizeQuery(query) {
1427
+ if (!query || typeof query !== "object") {
1428
+ throw new WebdaQLError("Invalid WebdaQL query object");
1429
+ }
1430
+ const type = query.type ?? "SELECT";
1431
+ if (typeof type !== "string" || !["SELECT", "DELETE", "UPDATE"].includes(type)) {
1432
+ throw new WebdaQLError(`Invalid statement type in WebdaQL query: ${JSON.stringify(type)}`);
1433
+ }
1434
+ if (query.aggregation !== undefined) {
1435
+ throw new WebdaQLError("Aggregation queries cannot be normalized: use toAggregationQuery");
1436
+ }
1437
+ const filterObject = query.filter;
1438
+ const filter = printExpression(filterObject ?? new AndExpression([]), false);
1439
+ let head = "";
1440
+ if (type === "DELETE") {
1441
+ head = "DELETE";
1442
+ }
1443
+ else if (type === "UPDATE") {
1444
+ const assignments = Array.isArray(query.assignments) ? snapshot(query.assignments) : [];
1445
+ if (assignments.length === 0) {
1446
+ throw new WebdaQLError("UPDATE needs at least one SET assignment");
1447
+ }
1448
+ head = `UPDATE SET ${assignments
1449
+ .map(a => {
1450
+ const field = a?.field;
1451
+ const value = a?.value;
1452
+ return `${checkFieldPath(field)} = ${checkScalar(value)}`;
1453
+ })
1454
+ .join(", ")}`;
1455
+ }
1456
+ else if (query.fields !== undefined) {
1457
+ const fields = Array.isArray(query.fields) ? snapshot(query.fields) : [];
1458
+ if (fields.length === 0) {
1459
+ throw new WebdaQLError("Invalid SELECT field list in WebdaQL query");
1460
+ }
1461
+ head = `SELECT ${fields.map(checkFieldPath).join(", ")}`;
1462
+ }
1463
+ let res = head ? `${head}${filter ? ` WHERE ${filter}` : ""}` : filter;
1464
+ const orderBy = query.orderBy;
1465
+ if (orderBy !== undefined) {
1466
+ if (!Array.isArray(orderBy)) {
1467
+ throw new WebdaQLError("Invalid ORDER BY in WebdaQL query");
1468
+ }
1469
+ const orders = snapshot(orderBy);
1470
+ if (orders.length) {
1471
+ res += ` ORDER BY ${orders
1472
+ .map(o => {
1473
+ const field = o?.field;
1474
+ const direction = o?.direction;
1475
+ if (direction !== "ASC" && direction !== "DESC") {
1476
+ throw new WebdaQLError("Invalid ORDER BY direction in WebdaQL query");
1477
+ }
1478
+ return `${checkFieldPath(field)} ${direction}`;
1479
+ })
1480
+ .join(", ")}`;
1481
+ }
1482
+ }
1483
+ const limit = query.limit;
1484
+ if (limit !== undefined) {
1485
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) {
1486
+ throw new WebdaQLError("Invalid LIMIT in WebdaQL query");
1487
+ }
1488
+ res += ` LIMIT ${limit}`;
1489
+ }
1490
+ const token = query.continuationToken;
1491
+ if (token !== undefined && token !== null) {
1492
+ if (typeof token !== "string") {
1493
+ throw new WebdaQLError("Invalid OFFSET in WebdaQL query");
1494
+ }
1495
+ if (token) {
1496
+ res += ` OFFSET ${escapeValue(token)}`;
1497
+ }
1498
+ }
1499
+ try {
1500
+ return parse(res.trim());
1501
+ }
1502
+ catch (err) {
1503
+ throw new WebdaQLError(`Invalid WebdaQL query object: ${err.message}`);
1504
+ }
1505
+ }
1506
+ /**
1507
+ * Whether a field path is allowed: listed itself, or nested in a listed field (`address.city` with `address`)
1508
+ * @param field - the field path
1509
+ * @param allowed - the allowed field paths
1510
+ * @returns true when allowed
1511
+ */
1512
+ function isAllowedField(field, allowed) {
1513
+ const segments = field.split(".");
1514
+ for (let i = 1; i <= segments.length; i++) {
1515
+ if (allowed.has(segments.slice(0, i).join("."))) {
1516
+ return true;
1517
+ }
1518
+ }
1519
+ return false;
1520
+ }
1521
+ /**
1522
+ * Validate the SELECT fields, the aggregation metric fields and the UPDATE SET targets of a query against the
1523
+ * allowed fields
1524
+ *
1525
+ * A dotted path is allowed when it is listed, or when one of its parents is (`profile.email` is allowed by
1526
+ * `profile` or by `profile.email`). GROUP BY paths are the SELECT fields; the field of every aggregation metric
1527
+ * (`SUM(points)`) is checked too, `COUNT(*)` has none. Filter attributes are not checked.
1528
+ *
1529
+ * @param query - parsed query to validate
1530
+ * @param allowedFields - allowed field paths (dot-notation)
1531
+ * @throws {SyntaxError} if a field, a metric field or an assignment target is not allowed
1532
+ */
1533
+ export function validateQueryFields(query, allowedFields) {
1534
+ const allowed = new Set(allowedFields);
1535
+ for (const field of query.fields ?? []) {
1536
+ if (!isAllowedField(field, allowed)) {
1537
+ throw new SyntaxError(`Unknown field "${field}". Allowed fields: ${allowedFields.join(", ")}`);
1538
+ }
1539
+ }
1540
+ for (const { field } of query.assignments ?? []) {
1541
+ if (!isAllowedField(field, allowed)) {
1542
+ throw new SyntaxError(`Unknown assignment field "${field}". Allowed fields: ${allowedFields.join(", ")}`);
1543
+ }
1544
+ }
1545
+ for (const [alias, metric] of Object.entries(query.aggregation?.metrics ?? {})) {
1546
+ if (metric.field !== undefined && !isAllowedField(metric.field, allowed)) {
1547
+ throw new SyntaxError(`Unknown field "${metric.field}" in "${alias}". Allowed fields: ${allowedFields.join(", ")}`);
1548
+ }
1549
+ }
1550
+ }
1551
+ /**
1552
+ * Refuse anything but a plain filter query: DELETE, UPDATE and SELECT with a field list
1553
+ *
1554
+ * Used where a query only selects objects, such as the Query operations exposed to clients. A query built by hand
1555
+ * without `type` is a filter.
1556
+ *
1557
+ * @param query - the parsed query
1558
+ * @throws {WebdaQLError} for a DELETE or UPDATE statement, or a SELECT field list
1559
+ */
1560
+ export function assertFilterQuery(query) {
1561
+ if (query.type !== undefined && query.type !== "SELECT") {
1562
+ throw new WebdaQLError(`${query.type} statements are not accepted here: only a filter query is`);
1563
+ }
1564
+ if (query.fields !== undefined) {
1565
+ throw new WebdaQLError("SELECT field lists are not accepted here: only a filter query is");
1566
+ }
1567
+ }
1142
1568
  /**
1143
1569
  * Parse a query string into a Query object
1570
+ *
1571
+ * Accepts a plain filter query (an implicit SELECT of every field) or a statement:
1572
+ * - `SELECT f1, f2 [WHERE <condition>] [ORDER BY ...] [LIMIT n] [OFFSET token]`
1573
+ * - `SELECT g1, COUNT(*) AS n, SUM(f) AS s [WHERE <condition>] [GROUP BY g1] [ORDER BY ...] [LIMIT n]`: an
1574
+ * aggregation (metrics COUNT, COUNT(DISTINCT f), SUM, AVG, MIN, MAX), returned in `aggregation`; the
1575
+ * selected fields must be the GROUP BY paths, every metric needs an alias, OFFSET is refused
1576
+ * - `DELETE [WHERE <condition>] [LIMIT n]`
1577
+ * - `UPDATE SET a = v, b = v [WHERE <condition>] [LIMIT n]`
1578
+ *
1579
+ * Keywords are uppercase only: `select`, `delete`, `set`, `count`... remain field names.
1580
+ *
1144
1581
  * @param query - the query string to parse
1582
+ * @param allowedFields - when given, SELECT fields, aggregation metric fields and UPDATE SET targets must be in
1583
+ * this list (see {@link validateQueryFields})
1145
1584
  * @returns the parsed Query object
1585
+ * @throws {SyntaxError} on a grammar error or a field outside `allowedFields`
1146
1586
  */
1147
- export function parse(query) {
1148
- return new QueryValidator(query).getQuery();
1587
+ export function parse(query, allowedFields) {
1588
+ const result = new QueryValidator(query).getQuery();
1589
+ if (allowedFields) {
1590
+ validateQueryFields(result, allowedFields);
1591
+ }
1592
+ return result;
1149
1593
  }
1150
1594
  //# sourceMappingURL=query.js.map