@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.
- package/README.md +68 -27
- package/lib/WebdaQLLexer.d.ts +44 -30
- package/lib/WebdaQLLexer.d.ts.map +1 -1
- package/lib/WebdaQLLexer.js +257 -152
- package/lib/WebdaQLLexer.js.map +1 -1
- package/lib/WebdaQLParserLexer.d.ts +44 -30
- package/lib/WebdaQLParserLexer.d.ts.map +1 -1
- package/lib/WebdaQLParserLexer.js +257 -152
- package/lib/WebdaQLParserLexer.js.map +1 -1
- package/lib/WebdaQLParserListener.d.ts +203 -30
- package/lib/WebdaQLParserListener.d.ts.map +1 -1
- package/lib/WebdaQLParserParser.d.ts +242 -44
- package/lib/WebdaQLParserParser.d.ts.map +1 -1
- package/lib/WebdaQLParserParser.js +2054 -715
- package/lib/WebdaQLParserParser.js.map +1 -1
- package/lib/WebdaQLParserVisitor.d.ts +126 -17
- package/lib/WebdaQLParserVisitor.d.ts.map +1 -1
- package/lib/aggregation-query.d.ts +95 -0
- package/lib/aggregation-query.d.ts.map +1 -0
- package/lib/aggregation-query.js +104 -0
- package/lib/aggregation-query.js.map +1 -0
- package/lib/aggregation-select.spec.d.ts +2 -0
- package/lib/aggregation-select.spec.d.ts.map +1 -0
- package/lib/aggregation-select.spec.js +164 -0
- package/lib/aggregation-select.spec.js.map +1 -0
- package/lib/aggregation.d.ts +97 -0
- package/lib/aggregation.d.ts.map +1 -0
- package/lib/aggregation.js +241 -0
- package/lib/aggregation.js.map +1 -0
- package/lib/aggregation.spec.d.ts +2 -0
- package/lib/aggregation.spec.d.ts.map +1 -0
- package/lib/aggregation.spec.js +360 -0
- package/lib/aggregation.spec.js.map +1 -0
- package/lib/bind.d.ts.map +1 -1
- package/lib/bind.js +29 -0
- package/lib/bind.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/query.d.ts +162 -8
- package/lib/query.d.ts.map +1 -1
- package/lib/query.js +489 -45
- package/lib/query.js.map +1 -1
- package/lib/query.spec.js +6 -4
- package/lib/query.spec.js.map +1 -1
- package/lib/statements.spec.d.ts +2 -0
- package/lib/statements.spec.d.ts.map +1 -0
- package/lib/statements.spec.js +422 -0
- package/lib/statements.spec.js.map +1 -0
- package/lib/webdaql-string.d.ts +9 -0
- package/lib/webdaql-string.d.ts.map +1 -1
- package/lib/webdaql-string.js +9 -8
- package/lib/webdaql-string.js.map +1 -1
- 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 {
|
|
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
|
|
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
|
|
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
|
|
179
|
+
* @param ctx - the filter query context
|
|
159
180
|
* @returns the built Query
|
|
160
181
|
*/
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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(/</g, "<").replace(/>/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
|
-
|
|
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
|