@webda/ql 4.0.0-beta.1 → 4.0.0-beta.3
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 +135 -2
- package/lib/WebdaQLLexer.d.ts +20 -13
- package/lib/WebdaQLLexer.d.ts.map +1 -0
- package/lib/WebdaQLLexer.js +305 -252
- package/lib/WebdaQLLexer.js.map +1 -1
- package/lib/WebdaQLParserLexer.d.ts +20 -13
- package/lib/WebdaQLParserLexer.d.ts.map +1 -0
- package/lib/WebdaQLParserLexer.js +305 -252
- package/lib/WebdaQLParserLexer.js.map +1 -1
- package/lib/WebdaQLParserListener.d.ts +90 -3
- package/lib/WebdaQLParserListener.d.ts.map +1 -0
- package/lib/WebdaQLParserListener.js +1 -1
- package/lib/WebdaQLParserListener.js.map +1 -1
- package/lib/WebdaQLParserParser.d.ts +84 -22
- package/lib/WebdaQLParserParser.d.ts.map +1 -0
- package/lib/WebdaQLParserParser.js +777 -277
- package/lib/WebdaQLParserParser.js.map +1 -1
- package/lib/WebdaQLParserVisitor.d.ts +66 -2
- package/lib/WebdaQLParserVisitor.d.ts.map +1 -0
- package/lib/WebdaQLParserVisitor.js +1 -1
- package/lib/WebdaQLParserVisitor.js.map +1 -1
- package/lib/bind.d.ts +29 -0
- package/lib/bind.d.ts.map +1 -0
- package/lib/bind.js +139 -0
- package/lib/bind.js.map +1 -0
- package/lib/bind.spec.d.ts +2 -0
- package/lib/bind.spec.d.ts.map +1 -0
- package/lib/bind.spec.js +206 -0
- package/lib/bind.spec.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -0
- package/lib/query.d.ts +399 -76
- package/lib/query.d.ts.map +1 -0
- package/lib/query.js +621 -117
- package/lib/query.js.map +1 -1
- package/lib/query.spec.d.ts +1 -0
- package/lib/query.spec.d.ts.map +1 -0
- package/lib/query.spec.js +593 -180
- package/lib/query.spec.js.map +1 -1
- package/lib/webdaql-string.d.ts +59 -0
- package/lib/webdaql-string.d.ts.map +1 -0
- package/lib/webdaql-string.js +112 -0
- package/lib/webdaql-string.js.map +1 -0
- package/lib/webdaql-string.spec.d.ts +2 -0
- package/lib/webdaql-string.spec.d.ts.map +1 -0
- package/lib/webdaql-string.spec.js +282 -0
- package/lib/webdaql-string.spec.js.map +1 -0
- package/package.json +32 -38
package/lib/query.js
CHANGED
|
@@ -2,37 +2,99 @@ import { CharStreams, CommonTokenStream } from "antlr4ts";
|
|
|
2
2
|
import { AbstractParseTreeVisitor, TerminalNode } from "antlr4ts/tree/index.js";
|
|
3
3
|
import { WebdaQLLexer } from "./WebdaQLLexer.js";
|
|
4
4
|
import { OrderFieldExpressionContext, SubExpressionContext, WebdaQLParserParser } from "./WebdaQLParserParser.js";
|
|
5
|
+
import { WebdaQLError } from "./webdaql-string.js";
|
|
6
|
+
/**
|
|
7
|
+
* Strip the quotes of a WebdaQL string literal and unescape its contents.
|
|
8
|
+
*
|
|
9
|
+
* Processes the inner text (between outer quotes) with these rules, applied left-to-right:
|
|
10
|
+
* - `\\` → `\` (escaped backslash)
|
|
11
|
+
* - `\` followed by the literal's quote char → the quote char (backslash-escaped quote)
|
|
12
|
+
* - the quote char doubled → one quote char (SQL-style doubled quote)
|
|
13
|
+
* - any other `\x` → `\x` kept verbatim (so LIKE's `\%` and `\_` reach likeToRegex unchanged)
|
|
14
|
+
*
|
|
15
|
+
* @param literal - the literal including its surrounding quotes
|
|
16
|
+
* @returns the literal value
|
|
17
|
+
*/
|
|
18
|
+
export function unescapeStringLiteral(literal) {
|
|
19
|
+
const quote = literal[0];
|
|
20
|
+
const inner = literal.substring(1, literal.length - 1);
|
|
21
|
+
let result = "";
|
|
22
|
+
for (let i = 0; i < inner.length; i++) {
|
|
23
|
+
if (inner[i] === "\\") {
|
|
24
|
+
if (i + 1 < inner.length) {
|
|
25
|
+
const next = inner[i + 1];
|
|
26
|
+
if (next === "\\") {
|
|
27
|
+
// \\ → \
|
|
28
|
+
result += "\\";
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
else if (next === quote) {
|
|
32
|
+
// \' or \" → ' or "
|
|
33
|
+
result += quote;
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// other \x → \x (kept verbatim, e.g., \%, \_, etc.)
|
|
38
|
+
result += "\\";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// trailing backslash
|
|
43
|
+
result += "\\";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (inner[i] === quote && i + 1 < inner.length && inner[i + 1] === quote) {
|
|
47
|
+
// '' or "" → ' or "
|
|
48
|
+
result += quote;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
result += inner[i];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Prepend a condition to an existing query string using AND logic
|
|
59
|
+
*
|
|
60
|
+
* Parses both strings, merges them, and reconstructs the combined query.
|
|
61
|
+
* ORDER BY, LIMIT, and OFFSET clauses from the original query are preserved.
|
|
62
|
+
*
|
|
63
|
+
* @param query - existing query string (may include ORDER BY / LIMIT / OFFSET)
|
|
64
|
+
* @param condition - condition to prepend (filter expression only)
|
|
65
|
+
* @returns merged query string
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* PrependCondition("status = 'active' ORDER BY name LIMIT 10", "age > 18")
|
|
70
|
+
* // => 'status = "active" AND age > 18 ORDER BY name LIMIT 10'
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
5
73
|
export function PrependCondition(query = "", condition) {
|
|
6
|
-
|
|
7
|
-
return query;
|
|
8
|
-
}
|
|
9
|
-
query = query.trim();
|
|
10
|
-
// Find the right part of the query this regex will always be true as all part are optional
|
|
11
|
-
const rightPart = query.match(/(?<order>ORDER BY ([a-zA-Z0-9\._]+( DESC| ASC)?,?)+ ?)?(?<offset>OFFSET ["']\w+["'] ?)?(?<limit>LIMIT \d+)?$/);
|
|
12
|
-
// Remove it if found
|
|
13
|
-
query = query.substring(0, query.length - rightPart[0].length).trim();
|
|
14
|
-
// Add the condition to the query now
|
|
15
|
-
if (!query) {
|
|
16
|
-
query = condition;
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
query = `(${condition}) AND (${query})`;
|
|
20
|
-
}
|
|
21
|
-
// Re-add the right part
|
|
22
|
-
if (rightPart[0].length) {
|
|
23
|
-
query += " " + rightPart[0];
|
|
24
|
-
}
|
|
25
|
-
return query;
|
|
74
|
+
return new QueryValidator(query).merge(condition).toString();
|
|
26
75
|
}
|
|
27
76
|
/**
|
|
28
|
-
*
|
|
77
|
+
* ANTLR parse tree visitor that builds an optimized Expression AST from the parsed tokens
|
|
29
78
|
*
|
|
30
|
-
*
|
|
79
|
+
* Converts the ANTLR parse tree into a flat, evaluatable expression tree.
|
|
80
|
+
* Automatically flattens nested AND/OR expressions of the same type to reduce depth.
|
|
31
81
|
*/
|
|
32
82
|
export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
33
83
|
/**
|
|
34
|
-
*
|
|
35
|
-
|
|
84
|
+
* Parsed LIMIT value, if present
|
|
85
|
+
*/
|
|
86
|
+
limit;
|
|
87
|
+
/**
|
|
88
|
+
* Parsed OFFSET continuation token, if present
|
|
89
|
+
*/
|
|
90
|
+
offset;
|
|
91
|
+
/**
|
|
92
|
+
* Parsed ORDER BY clauses, if present
|
|
93
|
+
*/
|
|
94
|
+
orderBy;
|
|
95
|
+
/**
|
|
96
|
+
* Default result when no expression is matched (empty AND, always true)
|
|
97
|
+
* @returns an empty AND query
|
|
36
98
|
*/
|
|
37
99
|
defaultResult() {
|
|
38
100
|
// An empty AND return true
|
|
@@ -41,35 +103,37 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
41
103
|
};
|
|
42
104
|
}
|
|
43
105
|
/**
|
|
44
|
-
* Get
|
|
45
|
-
* @returns
|
|
106
|
+
* Get parsed OFFSET continuation token
|
|
107
|
+
* @returns the offset token
|
|
46
108
|
*/
|
|
47
109
|
getOffset() {
|
|
48
110
|
return this.offset;
|
|
49
111
|
}
|
|
50
112
|
/**
|
|
51
|
-
* Get
|
|
52
|
-
* @returns
|
|
113
|
+
* Get parsed LIMIT value
|
|
114
|
+
* @returns the limit value
|
|
53
115
|
*/
|
|
54
116
|
getLimit() {
|
|
55
117
|
return this.limit;
|
|
56
118
|
}
|
|
57
119
|
/**
|
|
58
|
-
*
|
|
59
|
-
* @param ctx
|
|
120
|
+
* Visit a LIMIT clause and store the integer value
|
|
121
|
+
* @param ctx - the limit expression context
|
|
60
122
|
*/
|
|
61
123
|
visitLimitExpression(ctx) {
|
|
62
|
-
this.limit = this.
|
|
124
|
+
this.limit = this.visit(ctx.getChild(1));
|
|
63
125
|
}
|
|
64
126
|
/**
|
|
65
|
-
*
|
|
66
|
-
* @param ctx
|
|
127
|
+
* Visit an OFFSET clause and store the string continuation token
|
|
128
|
+
* @param ctx - the offset expression context
|
|
67
129
|
*/
|
|
68
130
|
visitOffsetExpression(ctx) {
|
|
69
|
-
this.offset = this.
|
|
131
|
+
this.offset = this.visit(ctx.getChild(1));
|
|
70
132
|
}
|
|
71
133
|
/**
|
|
72
134
|
* Visit a order field expression
|
|
135
|
+
* @param ctx - the order field expression context
|
|
136
|
+
* @returns the parsed OrderBy
|
|
73
137
|
*/
|
|
74
138
|
visitOrderFieldExpression(ctx) {
|
|
75
139
|
return {
|
|
@@ -79,6 +143,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
79
143
|
}
|
|
80
144
|
/**
|
|
81
145
|
* Read the order by values
|
|
146
|
+
* @param ctx - the order expression context
|
|
82
147
|
*/
|
|
83
148
|
visitOrderExpression(ctx) {
|
|
84
149
|
this.orderBy = ctx.children
|
|
@@ -86,9 +151,12 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
86
151
|
.map((c) => this.visitOrderFieldExpression(c));
|
|
87
152
|
}
|
|
88
153
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
154
|
+
* Visit the root `webdaql` rule and build the complete Query
|
|
155
|
+
*
|
|
156
|
+
* Parses filter expression, ORDER BY, LIMIT, and OFFSET clauses.
|
|
157
|
+
* Returns an empty AND expression (always true) when no filter is present.
|
|
158
|
+
* @param ctx - the webdaql parse context
|
|
159
|
+
* @returns the built Query
|
|
92
160
|
*/
|
|
93
161
|
visitWebdaql(ctx) {
|
|
94
162
|
if (ctx.childCount === 1) {
|
|
@@ -104,7 +172,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
104
172
|
// If the first element is a sub expression, it means we have a filter
|
|
105
173
|
if (ctx.getChild(0) instanceof SubExpressionContext) {
|
|
106
174
|
return {
|
|
107
|
-
filter: this.visit(ctx.getChild(0).getChild(1)) || new AndExpression([]),
|
|
175
|
+
filter: normalizeFilter(this.visit(ctx.getChild(0).getChild(1)) || new AndExpression([])),
|
|
108
176
|
limit: this.limit,
|
|
109
177
|
continuationToken: this.offset,
|
|
110
178
|
orderBy: this.orderBy
|
|
@@ -112,19 +180,25 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
112
180
|
}
|
|
113
181
|
// Go down one level - if expression empty it means no expression were provided
|
|
114
182
|
return {
|
|
115
|
-
filter: this.visit(ctx.getChild(0)) || new AndExpression([]),
|
|
183
|
+
filter: normalizeFilter(this.visit(ctx.getChild(0)) || new AndExpression([])),
|
|
116
184
|
limit: this.limit,
|
|
117
185
|
continuationToken: this.offset,
|
|
118
186
|
orderBy: this.orderBy
|
|
119
187
|
};
|
|
120
188
|
}
|
|
121
189
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
190
|
+
* Recursively flatten nested logical expressions of the same type
|
|
191
|
+
*
|
|
192
|
+
* ANTLR produces right-recursive trees like `a AND (b AND (c AND d))`.
|
|
193
|
+
* This method flattens them into `[a, b, c, d]` so a single LogicalExpression
|
|
194
|
+
* holds all children at one level.
|
|
195
|
+
*
|
|
196
|
+
* @param ctx - an AND or OR logical expression context
|
|
197
|
+
* @returns flat array of child parse tree nodes
|
|
125
198
|
*/
|
|
126
199
|
getComparison(ctx) {
|
|
127
200
|
const res = [];
|
|
201
|
+
// eslint-disable-next-line prefer-const
|
|
128
202
|
let [left, _, right] = ctx.children;
|
|
129
203
|
if (right instanceof SubExpressionContext) {
|
|
130
204
|
right = right.getChild(1);
|
|
@@ -151,12 +225,16 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
151
225
|
*
|
|
152
226
|
* By default the parser is doing a AND (b AND (c AND d)) creating 3 depth expressions
|
|
153
227
|
* This visitor simplify to a AND b AND c AND d with only one Expression
|
|
228
|
+
* @param ctx - the AND logic expression context
|
|
229
|
+
* @returns the flattened AndExpression
|
|
154
230
|
*/
|
|
155
231
|
visitAndLogicExpression(ctx) {
|
|
156
|
-
return
|
|
232
|
+
return foldLogical("AND", this.getComparison(ctx).map(c => this.visit(c)));
|
|
157
233
|
}
|
|
158
234
|
/**
|
|
159
235
|
* Implement the BinaryComparison with all methods managed
|
|
236
|
+
* @param ctx - the binary comparison context
|
|
237
|
+
* @returns the comparison expression
|
|
160
238
|
*/
|
|
161
239
|
visitBinaryComparisonExpression(ctx) {
|
|
162
240
|
const [left, op, right] = ctx.children;
|
|
@@ -165,87 +243,176 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
165
243
|
}
|
|
166
244
|
/**
|
|
167
245
|
* Visit each value of the [..., ..., ...] set
|
|
246
|
+
* @param ctx - the set expression context
|
|
247
|
+
* @returns the array of values
|
|
168
248
|
*/
|
|
169
249
|
visitSetExpression(ctx) {
|
|
170
250
|
return ctx.children.filter((_i, id) => id % 2).map(c => this.visit(c));
|
|
171
251
|
}
|
|
172
252
|
/**
|
|
173
|
-
* a LIKE
|
|
174
|
-
* @param ctx
|
|
175
|
-
* @returns
|
|
253
|
+
* Visit a LIKE expression (e.g. `field LIKE '%pattern_'`)
|
|
254
|
+
* @param ctx - the LIKE expression context
|
|
255
|
+
* @returns the comparison expression
|
|
176
256
|
*/
|
|
177
257
|
visitLikeExpression(ctx) {
|
|
178
258
|
const [left, _, right] = ctx.children;
|
|
179
|
-
|
|
259
|
+
const value = this.visit(right);
|
|
180
260
|
return new ComparisonExpression("LIKE", left.text, value);
|
|
181
261
|
}
|
|
182
262
|
/**
|
|
183
263
|
* Map the a IN ['b','c']
|
|
264
|
+
* @param ctx - the IN expression context
|
|
265
|
+
* @returns the comparison expression
|
|
184
266
|
*/
|
|
185
267
|
visitInExpression(ctx) {
|
|
186
268
|
const [left, _, right] = ctx.children;
|
|
187
|
-
|
|
269
|
+
const value = this.visit(right);
|
|
188
270
|
return new ComparisonExpression("IN", left.text, value);
|
|
189
271
|
}
|
|
190
272
|
/**
|
|
191
273
|
* Map the a CONTAINS 'b'
|
|
274
|
+
* @param ctx - the CONTAINS expression context
|
|
275
|
+
* @returns the comparison expression
|
|
192
276
|
*/
|
|
193
277
|
visitContainsExpression(ctx) {
|
|
194
278
|
const [left, _, right] = ctx.children;
|
|
195
|
-
|
|
279
|
+
const value = this.visit(right);
|
|
196
280
|
return new ComparisonExpression("CONTAINS", left.text, value);
|
|
197
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Map the a IS NULL
|
|
284
|
+
* @param ctx - the IS NULL expression context
|
|
285
|
+
* @returns the comparison expression
|
|
286
|
+
*/
|
|
287
|
+
visitIsNullExpression(ctx) {
|
|
288
|
+
return new ComparisonExpression("IS NULL", ctx.getChild(0).text, null);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Map the a IS NOT NULL
|
|
292
|
+
* @param ctx - the IS NOT NULL expression context
|
|
293
|
+
* @returns the comparison expression
|
|
294
|
+
*/
|
|
295
|
+
visitIsNotNullExpression(ctx) {
|
|
296
|
+
return new ComparisonExpression("IS NOT NULL", ctx.getChild(0).text, null);
|
|
297
|
+
}
|
|
198
298
|
/**
|
|
199
299
|
* Get the OrExpression, regrouping all the parameters
|
|
200
300
|
*
|
|
201
301
|
* By default the parser is doing a OR (b OR (c OR d)) creating 3 depth expressions
|
|
202
302
|
* This visitor simplify to a OR b OR c OR d with only one Expression
|
|
303
|
+
* @param ctx - the OR logic expression context
|
|
304
|
+
* @returns the flattened OrExpression
|
|
203
305
|
*/
|
|
204
306
|
visitOrLogicExpression(ctx) {
|
|
205
|
-
return
|
|
307
|
+
return foldLogical("OR", this.getComparison(ctx).map(c => this.visit(c)));
|
|
206
308
|
}
|
|
207
309
|
/**
|
|
208
|
-
*
|
|
310
|
+
* A bare atom used as an expression: only `TRUE` / `FALSE` are valid
|
|
311
|
+
* @param ctx - the atom expression context
|
|
312
|
+
* @returns the constant expression
|
|
313
|
+
* @throws {SyntaxError} for identifiers, numbers and strings
|
|
314
|
+
*/
|
|
315
|
+
visitAtomExpression(ctx) {
|
|
316
|
+
const value = this.visit(ctx.getChild(0));
|
|
317
|
+
if (typeof value === "boolean") {
|
|
318
|
+
return new BooleanExpression(value);
|
|
319
|
+
}
|
|
320
|
+
throw new SyntaxError(`Expected an expression, got '${ctx.text}'`);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Read a string literal, stripping the surrounding single or double quotes
|
|
324
|
+
* @param ctx - the string literal context
|
|
325
|
+
* @returns the unquoted string value
|
|
209
326
|
*/
|
|
210
327
|
visitStringLiteral(ctx) {
|
|
211
|
-
return
|
|
328
|
+
return unescapeStringLiteral(ctx.text);
|
|
212
329
|
}
|
|
213
330
|
/**
|
|
214
331
|
* Read the boolean literal
|
|
332
|
+
* @param ctx - the boolean literal context
|
|
333
|
+
* @returns the boolean value
|
|
215
334
|
*/
|
|
216
335
|
visitBooleanLiteral(ctx) {
|
|
217
336
|
return "TRUE" === ctx.text;
|
|
218
337
|
}
|
|
219
338
|
/**
|
|
220
339
|
* Read the number literal
|
|
340
|
+
* @param ctx - the integer literal context
|
|
341
|
+
* @returns the parsed integer value
|
|
221
342
|
*/
|
|
222
343
|
visitIntegerLiteral(ctx) {
|
|
223
344
|
return parseInt(ctx.text);
|
|
224
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Read a signed or decimal number literal
|
|
348
|
+
* @param ctx - the number literal context
|
|
349
|
+
* @returns the parsed number
|
|
350
|
+
*/
|
|
351
|
+
visitNumberLiteral(ctx) {
|
|
352
|
+
return parseFloat(ctx.text);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* A `?` or `:name` placeholder reaching evaluation was never bound
|
|
356
|
+
* @param ctx - the parameter context
|
|
357
|
+
* @throws {WebdaQLError} always: use `bind()` or pass the parameters to `query()`
|
|
358
|
+
*/
|
|
359
|
+
visitParameter(ctx) {
|
|
360
|
+
throw new WebdaQLError(`Unbound parameter '${ctx.text}' in WebdaQL query: pass its value with the query parameters`);
|
|
361
|
+
}
|
|
225
362
|
}
|
|
226
363
|
/**
|
|
227
364
|
* Represent the query expression or subset
|
|
228
365
|
*/
|
|
229
366
|
export class Expression {
|
|
367
|
+
operator;
|
|
368
|
+
/** Create a new Expression.
|
|
369
|
+
* @param operator - the expression operator
|
|
370
|
+
*/
|
|
230
371
|
constructor(operator) {
|
|
231
372
|
this.operator = operator;
|
|
232
373
|
}
|
|
233
374
|
}
|
|
234
375
|
/**
|
|
235
|
-
* Comparison
|
|
376
|
+
* Comparison operators that take no value
|
|
377
|
+
*/
|
|
378
|
+
export const UNARY_COMPARISON_OPERATORS = ["IS NULL", "IS NOT NULL"];
|
|
379
|
+
/**
|
|
380
|
+
* A leaf expression comparing an object attribute against a literal value
|
|
381
|
+
*
|
|
382
|
+
* Supports dot-notation for nested attribute access (e.g. `user.profile.name`).
|
|
383
|
+
*
|
|
384
|
+
* @typeParam T - the specific comparison operator type
|
|
236
385
|
*/
|
|
237
386
|
export class ComparisonExpression extends Expression {
|
|
238
387
|
/**
|
|
239
|
-
*
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
388
|
+
* Right side of the comparison
|
|
389
|
+
*/
|
|
390
|
+
value;
|
|
391
|
+
/**
|
|
392
|
+
* Attribute to read from the object (split by .)
|
|
393
|
+
*/
|
|
394
|
+
attribute;
|
|
395
|
+
/**
|
|
396
|
+
* @param operator - comparison operator
|
|
397
|
+
* @param attribute - dot-notation path to the object property (e.g. `"user.name"`)
|
|
398
|
+
* @param value - literal value or array of values (for IN operator) to compare against
|
|
243
399
|
*/
|
|
244
400
|
constructor(operator, attribute, value) {
|
|
245
401
|
super(operator);
|
|
246
402
|
this.value = value;
|
|
247
403
|
this.attribute = attribute.split(".");
|
|
248
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Convert a SQL LIKE pattern to a JavaScript RegExp
|
|
407
|
+
*
|
|
408
|
+
* - `%` matches zero or more characters
|
|
409
|
+
* - `_` matches exactly one character
|
|
410
|
+
* - `\%` and `\_` are literal percent/underscore
|
|
411
|
+
* - Common regex metacharacters are escaped
|
|
412
|
+
*
|
|
413
|
+
* @param like - SQL LIKE pattern string
|
|
414
|
+
* @returns compiled RegExp
|
|
415
|
+
*/
|
|
249
416
|
static likeToRegex(like) {
|
|
250
417
|
return new RegExp(like
|
|
251
418
|
// Prevent common regexp chars
|
|
@@ -264,10 +431,11 @@ export class ComparisonExpression extends Expression {
|
|
|
264
431
|
.replace(/\\([^?[{(])/g, "\\\\"));
|
|
265
432
|
}
|
|
266
433
|
/**
|
|
267
|
-
*
|
|
434
|
+
* Traverse an object using a dot-notation attribute path
|
|
268
435
|
*
|
|
269
|
-
* @param target
|
|
270
|
-
* @
|
|
436
|
+
* @param target - object to read from
|
|
437
|
+
* @param attribute - path segments (e.g. `["user", "profile", "name"]`)
|
|
438
|
+
* @returns the resolved value, or `undefined` if any segment is missing
|
|
271
439
|
*/
|
|
272
440
|
static getAttributeValue(target, attribute) {
|
|
273
441
|
let res = target;
|
|
@@ -280,10 +448,9 @@ export class ComparisonExpression extends Expression {
|
|
|
280
448
|
* Set the value of the attribute based on the assignment
|
|
281
449
|
*
|
|
282
450
|
* If used as a Set expression
|
|
283
|
-
* @param target
|
|
451
|
+
* @param target - the object to assign to
|
|
284
452
|
*/
|
|
285
453
|
setAttributeValue(target) {
|
|
286
|
-
var _a;
|
|
287
454
|
// Avoid alteration of prototype for security reason
|
|
288
455
|
if (this.attribute.includes("__proto__")) {
|
|
289
456
|
return;
|
|
@@ -291,7 +458,7 @@ export class ComparisonExpression extends Expression {
|
|
|
291
458
|
if (this.operator === "=") {
|
|
292
459
|
let res = target;
|
|
293
460
|
for (let i = 0; res && i < this.attribute.length - 1; i++) {
|
|
294
|
-
res[
|
|
461
|
+
res[this.attribute[i]] ??= {};
|
|
295
462
|
res = res[this.attribute[i]];
|
|
296
463
|
}
|
|
297
464
|
res[this.attribute[this.attribute.length - 1]] = this.value;
|
|
@@ -317,6 +484,9 @@ export class ComparisonExpression extends Expression {
|
|
|
317
484
|
case "!=":
|
|
318
485
|
return left != this.value;
|
|
319
486
|
case "LIKE":
|
|
487
|
+
if (left === undefined || left === null) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
320
490
|
if (typeof left === "string") {
|
|
321
491
|
// Grammar definie value as stringLiteral
|
|
322
492
|
return left.match(ComparisonExpression.likeToRegex(this.value)) !== null;
|
|
@@ -329,10 +499,18 @@ export class ComparisonExpression extends Expression {
|
|
|
329
499
|
return left.includes(this.value);
|
|
330
500
|
}
|
|
331
501
|
return false;
|
|
502
|
+
case "IS NULL":
|
|
503
|
+
return left === undefined || left === null;
|
|
504
|
+
case "IS NOT NULL":
|
|
505
|
+
return left !== undefined && left !== null;
|
|
332
506
|
}
|
|
333
507
|
}
|
|
334
508
|
/**
|
|
335
|
-
*
|
|
509
|
+
* Serialize a value to its WebdaQL string representation
|
|
510
|
+
*
|
|
511
|
+
* Strings are double-quoted, booleans are uppercased, arrays are bracket-wrapped.
|
|
512
|
+
* @param value - the value to serialize
|
|
513
|
+
* @returns the serialized string
|
|
336
514
|
*/
|
|
337
515
|
toStringValue(value) {
|
|
338
516
|
if (Array.isArray(value)) {
|
|
@@ -340,7 +518,7 @@ export class ComparisonExpression extends Expression {
|
|
|
340
518
|
}
|
|
341
519
|
switch (typeof value) {
|
|
342
520
|
case "string":
|
|
343
|
-
return `"${value}"`;
|
|
521
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '""')}"`;
|
|
344
522
|
case "boolean":
|
|
345
523
|
return value.toString().toUpperCase();
|
|
346
524
|
}
|
|
@@ -348,12 +526,14 @@ export class ComparisonExpression extends Expression {
|
|
|
348
526
|
}
|
|
349
527
|
/**
|
|
350
528
|
* Allow subclass to create different display
|
|
529
|
+
* @returns the attribute string
|
|
351
530
|
*/
|
|
352
531
|
toStringAttribute() {
|
|
353
532
|
return this.attribute.join(".");
|
|
354
533
|
}
|
|
355
534
|
/**
|
|
356
535
|
* Allow subclass to create different display
|
|
536
|
+
* @returns the operator string
|
|
357
537
|
*/
|
|
358
538
|
toStringOperator() {
|
|
359
539
|
return this.operator;
|
|
@@ -362,26 +542,28 @@ export class ComparisonExpression extends Expression {
|
|
|
362
542
|
* @override
|
|
363
543
|
*/
|
|
364
544
|
toString() {
|
|
545
|
+
if (UNARY_COMPARISON_OPERATORS.includes(this.operator)) {
|
|
546
|
+
return `${this.toStringAttribute()} ${this.toStringOperator()}`;
|
|
547
|
+
}
|
|
365
548
|
return `${this.toStringAttribute()} ${this.toStringOperator()} ${this.toStringValue(this.value)}`;
|
|
366
549
|
}
|
|
367
550
|
}
|
|
368
551
|
/**
|
|
369
|
-
* Abstract
|
|
552
|
+
* Abstract base for logical expressions that combine child expressions (AND / OR)
|
|
370
553
|
*
|
|
371
|
-
*
|
|
554
|
+
* @typeParam T - the literal operator type (`"AND"` or `"OR"`)
|
|
372
555
|
*/
|
|
373
556
|
export class LogicalExpression extends Expression {
|
|
374
557
|
/**
|
|
375
|
-
*
|
|
376
|
-
|
|
377
|
-
|
|
558
|
+
* Child expressions combined by this logical operator
|
|
559
|
+
*/
|
|
560
|
+
children = [];
|
|
561
|
+
/**
|
|
562
|
+
* @param operator - logical operator
|
|
563
|
+
* @param children - child expressions to combine
|
|
378
564
|
*/
|
|
379
565
|
constructor(operator, children) {
|
|
380
566
|
super(operator);
|
|
381
|
-
/**
|
|
382
|
-
* Contains the members of the logical expression
|
|
383
|
-
*/
|
|
384
|
-
this.children = [];
|
|
385
567
|
this.children = children;
|
|
386
568
|
}
|
|
387
569
|
/**
|
|
@@ -395,7 +577,10 @@ export class LogicalExpression extends Expression {
|
|
|
395
577
|
}
|
|
396
578
|
}
|
|
397
579
|
/**
|
|
398
|
-
* AND
|
|
580
|
+
* Logical AND expression — evaluates to `true` only if all children are `true`
|
|
581
|
+
*
|
|
582
|
+
* Uses short-circuit evaluation: returns `false` as soon as any child fails.
|
|
583
|
+
* An empty AND (no children) evaluates to `true`.
|
|
399
584
|
*/
|
|
400
585
|
export class AndExpression extends LogicalExpression {
|
|
401
586
|
/**
|
|
@@ -408,7 +593,7 @@ export class AndExpression extends LogicalExpression {
|
|
|
408
593
|
* @override
|
|
409
594
|
*/
|
|
410
595
|
eval(target) {
|
|
411
|
-
for (
|
|
596
|
+
for (const child of this.children) {
|
|
412
597
|
if (!child.eval(target)) {
|
|
413
598
|
return false;
|
|
414
599
|
}
|
|
@@ -417,7 +602,10 @@ export class AndExpression extends LogicalExpression {
|
|
|
417
602
|
}
|
|
418
603
|
}
|
|
419
604
|
/**
|
|
420
|
-
* OR
|
|
605
|
+
* Logical OR expression — evaluates to `true` if any child is `true`
|
|
606
|
+
*
|
|
607
|
+
* Uses short-circuit evaluation: returns `true` as soon as any child passes.
|
|
608
|
+
* An empty OR (no children) evaluates to `true`.
|
|
421
609
|
*/
|
|
422
610
|
export class OrExpression extends LogicalExpression {
|
|
423
611
|
/**
|
|
@@ -430,7 +618,7 @@ export class OrExpression extends LogicalExpression {
|
|
|
430
618
|
* @override
|
|
431
619
|
*/
|
|
432
620
|
eval(target) {
|
|
433
|
-
for (
|
|
621
|
+
for (const child of this.children) {
|
|
434
622
|
if (child.eval(target)) {
|
|
435
623
|
return true;
|
|
436
624
|
}
|
|
@@ -439,49 +627,248 @@ export class OrExpression extends LogicalExpression {
|
|
|
439
627
|
}
|
|
440
628
|
}
|
|
441
629
|
/**
|
|
630
|
+
* Constant expression from a `TRUE` / `FALSE` literal
|
|
631
|
+
*
|
|
632
|
+
* Constants are folded while the tree is built (see `foldLogical`), so this only
|
|
633
|
+
* remains when the whole filter is `FALSE`; a filter that is entirely `TRUE` is
|
|
634
|
+
* the match-everything `AndExpression([])`.
|
|
635
|
+
*/
|
|
636
|
+
export class BooleanExpression extends Expression {
|
|
637
|
+
/**
|
|
638
|
+
* The constant value
|
|
639
|
+
*/
|
|
640
|
+
value;
|
|
641
|
+
/**
|
|
642
|
+
* @param value - the constant value
|
|
643
|
+
*/
|
|
644
|
+
constructor(value) {
|
|
645
|
+
super(value ? "TRUE" : "FALSE");
|
|
646
|
+
this.value = value;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* @override
|
|
650
|
+
*/
|
|
651
|
+
eval(_target) {
|
|
652
|
+
return this.value;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* @override
|
|
656
|
+
*/
|
|
657
|
+
toString() {
|
|
658
|
+
return this.operator;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Build an AND / OR expression, folding `TRUE` / `FALSE` constants away
|
|
442
663
|
*
|
|
664
|
+
* AND drops TRUE and is FALSE as soon as one child is FALSE; OR drops FALSE and is
|
|
665
|
+
* TRUE as soon as one child is TRUE. A constant only remains when every child was one.
|
|
666
|
+
* @param operator - the logical operator
|
|
667
|
+
* @param children - the visited child expressions
|
|
668
|
+
* @returns the folded expression
|
|
669
|
+
*/
|
|
670
|
+
function foldLogical(operator, children) {
|
|
671
|
+
// The constant that decides the whole expression: FALSE for AND, TRUE for OR
|
|
672
|
+
const decisive = operator === "OR";
|
|
673
|
+
const kept = [];
|
|
674
|
+
for (const child of children) {
|
|
675
|
+
if (child instanceof BooleanExpression) {
|
|
676
|
+
if (child.value === decisive) {
|
|
677
|
+
return child;
|
|
678
|
+
}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
kept.push(child);
|
|
682
|
+
}
|
|
683
|
+
if (kept.length === 0) {
|
|
684
|
+
return new BooleanExpression(!decisive);
|
|
685
|
+
}
|
|
686
|
+
return operator === "AND" ? new AndExpression(kept) : new OrExpression(kept);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Turn a filter that is entirely `TRUE` into the match-everything expression
|
|
690
|
+
* @param filter - the top-level filter
|
|
691
|
+
* @returns the filter stores understand
|
|
692
|
+
*/
|
|
693
|
+
function normalizeFilter(filter) {
|
|
694
|
+
return filter instanceof BooleanExpression && filter.value ? new AndExpression([]) : filter;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Error listener turning lexer and parser errors into exceptions
|
|
698
|
+
*
|
|
699
|
+
* Without it, ANTLR logs the error and recovers: an unknown character was dropped,
|
|
700
|
+
* so `x = #1` was evaluated as `x = 1`.
|
|
701
|
+
*
|
|
702
|
+
* @param sql - the query, for the error message
|
|
703
|
+
* @returns the error listener
|
|
704
|
+
*/
|
|
705
|
+
function throwingErrorListener(sql) {
|
|
706
|
+
return {
|
|
707
|
+
syntaxError: (_recognizer, _offendingSymbol, _line, _charPositionInLine, msg, _e) => {
|
|
708
|
+
throw new SyntaxError(`${msg} (Query: ${sql})`);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Create a lexer and a parser for a query, both throwing on errors
|
|
714
|
+
* @param sql - the query string
|
|
715
|
+
* @returns the lexer and the parser
|
|
716
|
+
*/
|
|
717
|
+
function createParser(sql) {
|
|
718
|
+
const lexer = new WebdaQLLexer(CharStreams.fromString(sql || ""));
|
|
719
|
+
lexer.removeErrorListeners();
|
|
720
|
+
lexer.addErrorListener(throwingErrorListener(sql));
|
|
721
|
+
const parser = new WebdaQLParserParser(new CommonTokenStream(lexer));
|
|
722
|
+
parser.removeErrorListeners();
|
|
723
|
+
parser.addErrorListener(throwingErrorListener(sql));
|
|
724
|
+
return { lexer, parser };
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Check a query against the WebdaQL grammar without evaluating it
|
|
728
|
+
*
|
|
729
|
+
* Unlike {@link QueryValidator}, unbound `?` / `:name` parameters are accepted, so a
|
|
730
|
+
* query can be checked before its parameters are bound.
|
|
731
|
+
*
|
|
732
|
+
* @param query - the query string
|
|
733
|
+
* @throws {SyntaxError} if the query does not follow the grammar
|
|
734
|
+
*/
|
|
735
|
+
export function validateSyntax(query) {
|
|
736
|
+
createParser(query).parser.webdaql();
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Parses a WebdaQL query string and provides evaluation, merging, and serialization
|
|
740
|
+
*
|
|
741
|
+
* This is the main entry point for working with WebdaQL queries. It handles:
|
|
742
|
+
* - Parsing query strings via ANTLR into an expression AST
|
|
743
|
+
* - Evaluating objects against the parsed filter
|
|
744
|
+
* - Merging multiple queries together
|
|
745
|
+
* - Serializing back to a query string
|
|
746
|
+
*
|
|
747
|
+
* @example
|
|
748
|
+
* ```ts
|
|
749
|
+
* const validator = new QueryValidator("status = 'active' AND age > 18 LIMIT 50");
|
|
750
|
+
* validator.eval({ status: "active", age: 25 }); // true
|
|
751
|
+
* validator.getLimit(); // 50
|
|
752
|
+
* ```
|
|
443
753
|
*/
|
|
444
754
|
export class QueryValidator {
|
|
755
|
+
sql;
|
|
756
|
+
lexer;
|
|
757
|
+
tree;
|
|
758
|
+
query;
|
|
759
|
+
builder;
|
|
760
|
+
/**
|
|
761
|
+
* Parse a WebdaQL query string
|
|
762
|
+
*
|
|
763
|
+
* @param sql - the query string to parse
|
|
764
|
+
* @param builder - expression builder to use (override for custom expression types)
|
|
765
|
+
* @throws {SyntaxError} if the query string is malformed
|
|
766
|
+
*/
|
|
445
767
|
constructor(sql, builder = new ExpressionBuilder()) {
|
|
446
768
|
this.sql = sql;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
let parser = new WebdaQLParserParser(tokenStream);
|
|
450
|
-
parser.removeErrorListeners();
|
|
451
|
-
parser.addErrorListener({
|
|
452
|
-
syntaxError: (_recognizer, _offendingSymbol, _line, _charPositionInLine, msg, _e) => {
|
|
453
|
-
throw new SyntaxError(`${msg} (Query: ${sql})`);
|
|
454
|
-
}
|
|
455
|
-
});
|
|
769
|
+
const { lexer, parser } = createParser(sql);
|
|
770
|
+
this.lexer = lexer;
|
|
456
771
|
// Parse the input, where `compilationUnit` is whatever entry point you defined
|
|
457
772
|
this.tree = parser.webdaql();
|
|
458
773
|
this.builder = builder;
|
|
459
774
|
this.query = this.builder.visit(this.tree);
|
|
460
775
|
}
|
|
461
776
|
/**
|
|
462
|
-
* Get
|
|
463
|
-
* @returns
|
|
777
|
+
* Get parsed OFFSET continuation token, or empty string if none
|
|
778
|
+
* @returns the offset token
|
|
464
779
|
*/
|
|
465
780
|
getOffset() {
|
|
466
781
|
return this.builder.getOffset() || "";
|
|
467
782
|
}
|
|
468
783
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
784
|
+
* Check whether the query has a non-empty filter condition
|
|
785
|
+
*
|
|
786
|
+
* An empty AND expression (no children) is considered to have no condition.
|
|
787
|
+
* @returns true if there is a filter condition
|
|
788
|
+
*/
|
|
789
|
+
hasCondition() {
|
|
790
|
+
const filter = this.query.filter;
|
|
791
|
+
const isAnd = filter instanceof AndExpression;
|
|
792
|
+
if (isAnd) {
|
|
793
|
+
return filter.children.length > 0;
|
|
794
|
+
}
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Reconstruct the query string from the parsed AST
|
|
799
|
+
*
|
|
800
|
+
* Includes filter, ORDER BY, LIMIT, and OFFSET clauses.
|
|
801
|
+
* @returns the reconstructed query string
|
|
802
|
+
*/
|
|
803
|
+
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();
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Merge another query string into this query
|
|
818
|
+
*
|
|
819
|
+
* Filter expressions are combined using the specified logical operator.
|
|
820
|
+
* LIMIT, OFFSET, and ORDER BY from the merged query override existing values.
|
|
821
|
+
*
|
|
822
|
+
* @param query - query string to merge in
|
|
823
|
+
* @param type - logical operator to combine filters (`"AND"` or `"OR"`)
|
|
824
|
+
* @returns `this` for chaining
|
|
825
|
+
*/
|
|
826
|
+
merge(query, type = "AND") {
|
|
827
|
+
const adds = new QueryValidator(query);
|
|
828
|
+
// Add additional conditions
|
|
829
|
+
if (adds.hasCondition()) {
|
|
830
|
+
if ((this.query.filter instanceof AndExpression && type === "AND") ||
|
|
831
|
+
(this.query.filter instanceof OrExpression && type === "OR")) {
|
|
832
|
+
this.query.filter.children.push(adds.query.filter);
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
this.query.filter = new (type === "AND" ? AndExpression : OrExpression)([this.query.filter, adds.query.filter]);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// Set the limit if overriden
|
|
839
|
+
if (adds.query.limit) {
|
|
840
|
+
this.query.limit = adds.query.limit;
|
|
841
|
+
}
|
|
842
|
+
// Set the offset if overriden
|
|
843
|
+
if (adds.query.continuationToken) {
|
|
844
|
+
this.query.continuationToken = adds.query.continuationToken;
|
|
845
|
+
}
|
|
846
|
+
// Add the order by if overriden
|
|
847
|
+
if (adds.query.orderBy) {
|
|
848
|
+
const fields = adds.query.orderBy.map(o => o.field);
|
|
849
|
+
this.query.orderBy ??= [];
|
|
850
|
+
// Remove the fields that are already in the query
|
|
851
|
+
this.query.orderBy = [...adds.query.orderBy, ...this.query.orderBy.filter(o => !fields.includes(o.field))];
|
|
852
|
+
}
|
|
853
|
+
return this;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Get parsed LIMIT value, defaulting to 1000
|
|
857
|
+
* @returns the limit value
|
|
471
858
|
*/
|
|
472
859
|
getLimit() {
|
|
473
860
|
return this.builder.getLimit() || 1000;
|
|
474
861
|
}
|
|
475
862
|
/**
|
|
476
|
-
* Get the expression
|
|
477
|
-
* @returns
|
|
863
|
+
* Get the filter expression (without LIMIT / OFFSET / ORDER BY)
|
|
864
|
+
* @returns the filter expression
|
|
478
865
|
*/
|
|
479
866
|
getExpression() {
|
|
480
867
|
return this.query.filter;
|
|
481
868
|
}
|
|
482
869
|
/**
|
|
483
|
-
* Retrieve parsed query
|
|
484
|
-
* @returns
|
|
870
|
+
* Retrieve the full parsed query including filter, limit, offset, and orderBy
|
|
871
|
+
* @returns the full query object
|
|
485
872
|
*/
|
|
486
873
|
getQuery() {
|
|
487
874
|
return {
|
|
@@ -492,16 +879,16 @@ export class QueryValidator {
|
|
|
492
879
|
}
|
|
493
880
|
/**
|
|
494
881
|
* Verify if a target fit the expression
|
|
495
|
-
* @param target
|
|
496
|
-
* @returns
|
|
882
|
+
* @param target - the object to evaluate
|
|
883
|
+
* @returns true if the target matches
|
|
497
884
|
*/
|
|
498
885
|
eval(target) {
|
|
499
886
|
return this.query.filter.eval(target);
|
|
500
887
|
}
|
|
501
888
|
/**
|
|
502
889
|
* Display parse tree back as query
|
|
503
|
-
* @param tree
|
|
504
|
-
* @returns
|
|
890
|
+
* @param tree - the parse tree to display
|
|
891
|
+
* @returns the query string representation
|
|
505
892
|
*/
|
|
506
893
|
displayTree(tree = this.tree) {
|
|
507
894
|
let res = "";
|
|
@@ -521,20 +908,51 @@ export class QueryValidator {
|
|
|
521
908
|
}
|
|
522
909
|
}
|
|
523
910
|
/**
|
|
524
|
-
*
|
|
911
|
+
* A query validator that operates in assignment mode
|
|
912
|
+
*
|
|
913
|
+
* Instead of filtering objects, it assigns values to object properties.
|
|
914
|
+
* Only supports `=` (assignment) combined with `AND`. No comparisons, OR, or other operators.
|
|
915
|
+
*
|
|
916
|
+
* Protects against prototype pollution by ignoring `__proto__` attributes.
|
|
917
|
+
*
|
|
918
|
+
* @example
|
|
919
|
+
* ```ts
|
|
920
|
+
* const target: any = {};
|
|
921
|
+
* new SetterValidator('name = "John" AND age = 30').eval(target);
|
|
922
|
+
* // target = { name: "John", age: 30 }
|
|
923
|
+
* ```
|
|
924
|
+
*
|
|
925
|
+
* @throws {SyntaxError} if non-assignment operators or OR expressions are used
|
|
525
926
|
*/
|
|
526
927
|
export class SetterValidator extends QueryValidator {
|
|
928
|
+
/**
|
|
929
|
+
* @param sql - assignment expression (e.g. `'field = "value" AND other = 10'`)
|
|
930
|
+
* @throws {SyntaxError} if the expression contains non-assignment operators
|
|
931
|
+
*/
|
|
527
932
|
constructor(sql) {
|
|
528
933
|
super(sql);
|
|
529
934
|
// Do one empty run to raise any issue with disallowed expression
|
|
530
935
|
this.eval({});
|
|
531
936
|
}
|
|
937
|
+
/**
|
|
938
|
+
* Apply the assignments to the target object
|
|
939
|
+
*
|
|
940
|
+
* @param target - object to assign values to (mutated in place)
|
|
941
|
+
* @returns always `true`
|
|
942
|
+
*/
|
|
532
943
|
eval(target) {
|
|
533
944
|
if (this.query.filter) {
|
|
534
945
|
this.assign(target, this.query.filter);
|
|
535
946
|
}
|
|
536
947
|
return true;
|
|
537
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* Recursively walk the expression tree and apply assignments
|
|
951
|
+
*
|
|
952
|
+
* @param target - object to assign values to
|
|
953
|
+
* @param expression - must be an AndExpression or a `=` ComparisonExpression
|
|
954
|
+
* @throws {SyntaxError} if any non-assignment expression is encountered
|
|
955
|
+
*/
|
|
538
956
|
assign(target, expression) {
|
|
539
957
|
if (expression instanceof AndExpression) {
|
|
540
958
|
expression.children.forEach(c => this.assign(target, c));
|
|
@@ -547,15 +965,34 @@ export class SetterValidator extends QueryValidator {
|
|
|
547
965
|
}
|
|
548
966
|
}
|
|
549
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* A query validator that supports partial object matching
|
|
970
|
+
*
|
|
971
|
+
* In partial mode, comparisons against `undefined` attributes evaluate to `true`
|
|
972
|
+
* (the missing field is ignored). This is useful for PATCH operations where only
|
|
973
|
+
* a subset of fields is provided.
|
|
974
|
+
*
|
|
975
|
+
* @example
|
|
976
|
+
* ```ts
|
|
977
|
+
* const v = new PartialValidator("name = 'John' AND age > 18");
|
|
978
|
+
* v.eval({ name: "John" }); // true (age is undefined, skipped)
|
|
979
|
+
* v.wasPartialMatch(); // true
|
|
980
|
+
* v.eval({ name: "John" }, false); // false (strict mode, age required)
|
|
981
|
+
* ```
|
|
982
|
+
*/
|
|
550
983
|
export class PartialValidator extends QueryValidator {
|
|
984
|
+
/**
|
|
985
|
+
* @param query - WebdaQL query string
|
|
986
|
+
* @param builder - partial expression builder (override for customization)
|
|
987
|
+
*/
|
|
551
988
|
constructor(query, builder = new PartialExpressionBuilder()) {
|
|
552
989
|
super(query, builder);
|
|
553
990
|
}
|
|
554
991
|
/**
|
|
555
992
|
* Eval the query
|
|
556
|
-
* @param target
|
|
557
|
-
* @param partial
|
|
558
|
-
* @returns
|
|
993
|
+
* @param target - the object to evaluate
|
|
994
|
+
* @param partial - whether to use partial matching
|
|
995
|
+
* @returns true if the target matches
|
|
559
996
|
*/
|
|
560
997
|
eval(target, partial = true) {
|
|
561
998
|
this.builder.setPartial(partial);
|
|
@@ -564,13 +1001,28 @@ export class PartialValidator extends QueryValidator {
|
|
|
564
1001
|
}
|
|
565
1002
|
/**
|
|
566
1003
|
* Return if the result ignored some fields
|
|
567
|
-
* @returns
|
|
1004
|
+
* @returns true if some fields were skipped
|
|
568
1005
|
*/
|
|
569
1006
|
wasPartialMatch() {
|
|
570
1007
|
return this.builder.partialMatch;
|
|
571
1008
|
}
|
|
572
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* A comparison expression that treats `undefined` attributes as a match in partial mode
|
|
1012
|
+
*
|
|
1013
|
+
* When the builder is in partial mode and the target attribute is `undefined`,
|
|
1014
|
+
* evaluation returns `true` and flags the result as a partial match.
|
|
1015
|
+
*
|
|
1016
|
+
* @typeParam T - the specific comparison operator type
|
|
1017
|
+
*/
|
|
573
1018
|
export class PartialComparisonExpression extends ComparisonExpression {
|
|
1019
|
+
builder;
|
|
1020
|
+
/**
|
|
1021
|
+
* @param builder - the partial expression builder (used to read partial mode flag)
|
|
1022
|
+
* @param op - comparison operator
|
|
1023
|
+
* @param attribute - dot-notation attribute path
|
|
1024
|
+
* @param value - value to compare against
|
|
1025
|
+
*/
|
|
574
1026
|
constructor(builder, op, attribute, value) {
|
|
575
1027
|
super(op, attribute, value);
|
|
576
1028
|
this.builder = builder;
|
|
@@ -579,8 +1031,8 @@ export class PartialComparisonExpression extends ComparisonExpression {
|
|
|
579
1031
|
* Override the eval to check if the attribute is present
|
|
580
1032
|
* if not and we are in partial mode, return true
|
|
581
1033
|
*
|
|
582
|
-
* @param target
|
|
583
|
-
* @returns
|
|
1034
|
+
* @param target - the object to evaluate
|
|
1035
|
+
* @returns true if the target matches
|
|
584
1036
|
*/
|
|
585
1037
|
eval(target) {
|
|
586
1038
|
if (this.builder.partial) {
|
|
@@ -593,25 +1045,49 @@ export class PartialComparisonExpression extends ComparisonExpression {
|
|
|
593
1045
|
return super.eval(target);
|
|
594
1046
|
}
|
|
595
1047
|
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Expression builder that creates {@link PartialComparisonExpression} nodes
|
|
1050
|
+
* instead of regular {@link ComparisonExpression} nodes
|
|
1051
|
+
*
|
|
1052
|
+
* Used by {@link PartialValidator} to support partial object matching.
|
|
1053
|
+
*/
|
|
596
1054
|
export class PartialExpressionBuilder extends ExpressionBuilder {
|
|
1055
|
+
/**
|
|
1056
|
+
* Whether partial mode is active (undefined attributes treated as matching)
|
|
1057
|
+
*/
|
|
1058
|
+
partial;
|
|
1059
|
+
/**
|
|
1060
|
+
* Set to `true` during evaluation if any attribute was undefined and skipped
|
|
1061
|
+
*/
|
|
1062
|
+
partialMatch;
|
|
1063
|
+
/**
|
|
1064
|
+
* Enable or disable partial evaluation mode
|
|
1065
|
+
* @param partial - whether to enable partial mode
|
|
1066
|
+
*/
|
|
597
1067
|
setPartial(partial) {
|
|
598
1068
|
this.partial = partial;
|
|
599
1069
|
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Set the partial match flag (reset before each evaluation)
|
|
1072
|
+
* @param partial - the partial match flag value
|
|
1073
|
+
*/
|
|
600
1074
|
setPartialMatch(partial) {
|
|
601
1075
|
this.partialMatch = partial;
|
|
602
1076
|
}
|
|
603
1077
|
/**
|
|
604
|
-
* a LIKE
|
|
605
|
-
* @param ctx
|
|
606
|
-
* @returns
|
|
1078
|
+
* Visit a LIKE expression, returning a PartialComparisonExpression
|
|
1079
|
+
* @param ctx - the LIKE expression context
|
|
1080
|
+
* @returns the partial comparison expression
|
|
607
1081
|
*/
|
|
608
1082
|
visitLikeExpression(ctx) {
|
|
609
1083
|
const [left, _, right] = ctx.children;
|
|
610
|
-
|
|
1084
|
+
const value = this.visit(right);
|
|
611
1085
|
return new PartialComparisonExpression(this, "LIKE", left.text, value);
|
|
612
1086
|
}
|
|
613
1087
|
/**
|
|
614
|
-
*
|
|
1088
|
+
* Visit a binary comparison, returning a PartialComparisonExpression
|
|
1089
|
+
* @param ctx - the binary comparison context
|
|
1090
|
+
* @returns the partial comparison expression
|
|
615
1091
|
*/
|
|
616
1092
|
visitBinaryComparisonExpression(ctx) {
|
|
617
1093
|
const [left, op, right] = ctx.children;
|
|
@@ -619,28 +1095,56 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
|
|
|
619
1095
|
return new PartialComparisonExpression(this, op.text, left.text, this.visit(right));
|
|
620
1096
|
}
|
|
621
1097
|
/**
|
|
622
|
-
*
|
|
1098
|
+
* Visit an IN expression, returning a PartialComparisonExpression
|
|
1099
|
+
* @param ctx - the IN expression context
|
|
1100
|
+
* @returns the partial comparison expression
|
|
623
1101
|
*/
|
|
624
1102
|
visitInExpression(ctx) {
|
|
625
1103
|
const [left, _, right] = ctx.children;
|
|
626
|
-
|
|
1104
|
+
const value = this.visit(right);
|
|
627
1105
|
return new PartialComparisonExpression(this, "IN", left.text, value);
|
|
628
1106
|
}
|
|
629
1107
|
/**
|
|
630
|
-
*
|
|
1108
|
+
* Visit a CONTAINS expression, returning a PartialComparisonExpression
|
|
1109
|
+
* @param ctx - the CONTAINS expression context
|
|
1110
|
+
* @returns the partial comparison expression
|
|
631
1111
|
*/
|
|
632
1112
|
visitContainsExpression(ctx) {
|
|
633
1113
|
const [left, _, right] = ctx.children;
|
|
634
|
-
|
|
1114
|
+
const value = this.visit(right);
|
|
635
1115
|
return new PartialComparisonExpression(this, "CONTAINS", left.text, value);
|
|
636
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Visit an IS NULL expression, returning a PartialComparisonExpression
|
|
1119
|
+
* @param ctx - the IS NULL expression context
|
|
1120
|
+
* @returns the partial comparison expression
|
|
1121
|
+
*/
|
|
1122
|
+
visitIsNullExpression(ctx) {
|
|
1123
|
+
return new PartialComparisonExpression(this, "IS NULL", ctx.getChild(0).text, null);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Visit an IS NOT NULL expression, returning a PartialComparisonExpression
|
|
1127
|
+
* @param ctx - the IS NOT NULL expression context
|
|
1128
|
+
* @returns the partial comparison expression
|
|
1129
|
+
*/
|
|
1130
|
+
visitIsNotNullExpression(ctx) {
|
|
1131
|
+
return new PartialComparisonExpression(this, "IS NOT NULL", ctx.getChild(0).text, null);
|
|
1132
|
+
}
|
|
637
1133
|
}
|
|
638
1134
|
/**
|
|
639
1135
|
* Remove artifact from sanitize-html inside query
|
|
640
|
-
* @param query
|
|
641
|
-
* @returns
|
|
1136
|
+
* @param query - the query string to unsanitize
|
|
1137
|
+
* @returns the cleaned query string
|
|
642
1138
|
*/
|
|
643
1139
|
export function unsanitize(query) {
|
|
644
1140
|
return query.replace(/</g, "<").replace(/>/g, ">");
|
|
645
1141
|
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Parse a query string into a Query object
|
|
1144
|
+
* @param query - the query string to parse
|
|
1145
|
+
* @returns the parsed Query object
|
|
1146
|
+
*/
|
|
1147
|
+
export function parse(query) {
|
|
1148
|
+
return new QueryValidator(query).getQuery();
|
|
1149
|
+
}
|
|
646
1150
|
//# sourceMappingURL=query.js.map
|