@webda/ql 4.0.0-beta.2 → 4.0.0-beta.5
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 +19 -13
- package/lib/WebdaQLLexer.d.ts.map +1 -1
- package/lib/WebdaQLLexer.js +305 -252
- package/lib/WebdaQLLexer.js.map +1 -1
- package/lib/WebdaQLParserLexer.d.ts +19 -13
- package/lib/WebdaQLParserLexer.d.ts.map +1 -1
- package/lib/WebdaQLParserLexer.js +305 -252
- package/lib/WebdaQLParserLexer.js.map +1 -1
- package/lib/WebdaQLParserListener.d.ts +89 -3
- package/lib/WebdaQLParserListener.d.ts.map +1 -1
- package/lib/WebdaQLParserListener.js +1 -1
- package/lib/WebdaQLParserListener.js.map +1 -1
- package/lib/WebdaQLParserParser.d.ts +83 -22
- package/lib/WebdaQLParserParser.d.ts.map +1 -1
- package/lib/WebdaQLParserParser.js +790 -290
- package/lib/WebdaQLParserParser.js.map +1 -1
- package/lib/WebdaQLParserVisitor.d.ts +65 -2
- package/lib/WebdaQLParserVisitor.d.ts.map +1 -1
- 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 +395 -76
- package/lib/query.d.ts.map +1 -1
- package/lib/query.js +562 -91
- package/lib/query.js.map +1 -1
- package/lib/query.spec.js +593 -195
- 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 +34 -42
package/lib/query.js
CHANGED
|
@@ -2,18 +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
74
|
return new QueryValidator(query).merge(condition).toString();
|
|
7
75
|
}
|
|
8
76
|
/**
|
|
9
|
-
*
|
|
77
|
+
* ANTLR parse tree visitor that builds an optimized Expression AST from the parsed tokens
|
|
10
78
|
*
|
|
11
|
-
*
|
|
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.
|
|
12
81
|
*/
|
|
13
82
|
export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
14
83
|
/**
|
|
15
|
-
*
|
|
16
|
-
|
|
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
|
|
17
98
|
*/
|
|
18
99
|
defaultResult() {
|
|
19
100
|
// An empty AND return true
|
|
@@ -22,35 +103,37 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
22
103
|
};
|
|
23
104
|
}
|
|
24
105
|
/**
|
|
25
|
-
* Get
|
|
26
|
-
* @returns
|
|
106
|
+
* Get parsed OFFSET continuation token
|
|
107
|
+
* @returns the offset token
|
|
27
108
|
*/
|
|
28
109
|
getOffset() {
|
|
29
110
|
return this.offset;
|
|
30
111
|
}
|
|
31
112
|
/**
|
|
32
|
-
* Get
|
|
33
|
-
* @returns
|
|
113
|
+
* Get parsed LIMIT value
|
|
114
|
+
* @returns the limit value
|
|
34
115
|
*/
|
|
35
116
|
getLimit() {
|
|
36
117
|
return this.limit;
|
|
37
118
|
}
|
|
38
119
|
/**
|
|
39
|
-
*
|
|
40
|
-
* @param ctx
|
|
120
|
+
* Visit a LIMIT clause and store the integer value
|
|
121
|
+
* @param ctx - the limit expression context
|
|
41
122
|
*/
|
|
42
123
|
visitLimitExpression(ctx) {
|
|
43
|
-
this.limit = this.
|
|
124
|
+
this.limit = this.visit(ctx.getChild(1));
|
|
44
125
|
}
|
|
45
126
|
/**
|
|
46
|
-
*
|
|
47
|
-
* @param ctx
|
|
127
|
+
* Visit an OFFSET clause and store the string continuation token
|
|
128
|
+
* @param ctx - the offset expression context
|
|
48
129
|
*/
|
|
49
130
|
visitOffsetExpression(ctx) {
|
|
50
|
-
this.offset = this.
|
|
131
|
+
this.offset = this.visit(ctx.getChild(1));
|
|
51
132
|
}
|
|
52
133
|
/**
|
|
53
134
|
* Visit a order field expression
|
|
135
|
+
* @param ctx - the order field expression context
|
|
136
|
+
* @returns the parsed OrderBy
|
|
54
137
|
*/
|
|
55
138
|
visitOrderFieldExpression(ctx) {
|
|
56
139
|
return {
|
|
@@ -60,6 +143,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
60
143
|
}
|
|
61
144
|
/**
|
|
62
145
|
* Read the order by values
|
|
146
|
+
* @param ctx - the order expression context
|
|
63
147
|
*/
|
|
64
148
|
visitOrderExpression(ctx) {
|
|
65
149
|
this.orderBy = ctx.children
|
|
@@ -67,9 +151,12 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
67
151
|
.map((c) => this.visitOrderFieldExpression(c));
|
|
68
152
|
}
|
|
69
153
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
73
160
|
*/
|
|
74
161
|
visitWebdaql(ctx) {
|
|
75
162
|
if (ctx.childCount === 1) {
|
|
@@ -85,7 +172,7 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
85
172
|
// If the first element is a sub expression, it means we have a filter
|
|
86
173
|
if (ctx.getChild(0) instanceof SubExpressionContext) {
|
|
87
174
|
return {
|
|
88
|
-
filter: this.visit(ctx.getChild(0).getChild(1)) || new AndExpression([]),
|
|
175
|
+
filter: normalizeFilter(this.visit(ctx.getChild(0).getChild(1)) || new AndExpression([])),
|
|
89
176
|
limit: this.limit,
|
|
90
177
|
continuationToken: this.offset,
|
|
91
178
|
orderBy: this.orderBy
|
|
@@ -93,16 +180,21 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
93
180
|
}
|
|
94
181
|
// Go down one level - if expression empty it means no expression were provided
|
|
95
182
|
return {
|
|
96
|
-
filter: this.visit(ctx.getChild(0)) || new AndExpression([]),
|
|
183
|
+
filter: normalizeFilter(this.visit(ctx.getChild(0)) || new AndExpression([])),
|
|
97
184
|
limit: this.limit,
|
|
98
185
|
continuationToken: this.offset,
|
|
99
186
|
orderBy: this.orderBy
|
|
100
187
|
};
|
|
101
188
|
}
|
|
102
189
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
106
198
|
*/
|
|
107
199
|
getComparison(ctx) {
|
|
108
200
|
const res = [];
|
|
@@ -133,12 +225,16 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
133
225
|
*
|
|
134
226
|
* By default the parser is doing a AND (b AND (c AND d)) creating 3 depth expressions
|
|
135
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
|
|
136
230
|
*/
|
|
137
231
|
visitAndLogicExpression(ctx) {
|
|
138
|
-
return
|
|
232
|
+
return foldLogical("AND", this.getComparison(ctx).map(c => this.visit(c)));
|
|
139
233
|
}
|
|
140
234
|
/**
|
|
141
235
|
* Implement the BinaryComparison with all methods managed
|
|
236
|
+
* @param ctx - the binary comparison context
|
|
237
|
+
* @returns the comparison expression
|
|
142
238
|
*/
|
|
143
239
|
visitBinaryComparisonExpression(ctx) {
|
|
144
240
|
const [left, op, right] = ctx.children;
|
|
@@ -147,14 +243,16 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
147
243
|
}
|
|
148
244
|
/**
|
|
149
245
|
* Visit each value of the [..., ..., ...] set
|
|
246
|
+
* @param ctx - the set expression context
|
|
247
|
+
* @returns the array of values
|
|
150
248
|
*/
|
|
151
249
|
visitSetExpression(ctx) {
|
|
152
250
|
return ctx.children.filter((_i, id) => id % 2).map(c => this.visit(c));
|
|
153
251
|
}
|
|
154
252
|
/**
|
|
155
|
-
* a LIKE
|
|
156
|
-
* @param ctx
|
|
157
|
-
* @returns
|
|
253
|
+
* Visit a LIKE expression (e.g. `field LIKE '%pattern_'`)
|
|
254
|
+
* @param ctx - the LIKE expression context
|
|
255
|
+
* @returns the comparison expression
|
|
158
256
|
*/
|
|
159
257
|
visitLikeExpression(ctx) {
|
|
160
258
|
const [left, _, right] = ctx.children;
|
|
@@ -163,6 +261,8 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
163
261
|
}
|
|
164
262
|
/**
|
|
165
263
|
* Map the a IN ['b','c']
|
|
264
|
+
* @param ctx - the IN expression context
|
|
265
|
+
* @returns the comparison expression
|
|
166
266
|
*/
|
|
167
267
|
visitInExpression(ctx) {
|
|
168
268
|
const [left, _, right] = ctx.children;
|
|
@@ -171,63 +271,148 @@ export class ExpressionBuilder extends AbstractParseTreeVisitor {
|
|
|
171
271
|
}
|
|
172
272
|
/**
|
|
173
273
|
* Map the a CONTAINS 'b'
|
|
274
|
+
* @param ctx - the CONTAINS expression context
|
|
275
|
+
* @returns the comparison expression
|
|
174
276
|
*/
|
|
175
277
|
visitContainsExpression(ctx) {
|
|
176
278
|
const [left, _, right] = ctx.children;
|
|
177
279
|
const value = this.visit(right);
|
|
178
280
|
return new ComparisonExpression("CONTAINS", left.text, value);
|
|
179
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
|
+
}
|
|
180
298
|
/**
|
|
181
299
|
* Get the OrExpression, regrouping all the parameters
|
|
182
300
|
*
|
|
183
301
|
* By default the parser is doing a OR (b OR (c OR d)) creating 3 depth expressions
|
|
184
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
|
|
185
305
|
*/
|
|
186
306
|
visitOrLogicExpression(ctx) {
|
|
187
|
-
return
|
|
307
|
+
return foldLogical("OR", this.getComparison(ctx).map(c => this.visit(c)));
|
|
188
308
|
}
|
|
189
309
|
/**
|
|
190
|
-
*
|
|
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
|
|
191
326
|
*/
|
|
192
327
|
visitStringLiteral(ctx) {
|
|
193
|
-
return
|
|
328
|
+
return unescapeStringLiteral(ctx.text);
|
|
194
329
|
}
|
|
195
330
|
/**
|
|
196
331
|
* Read the boolean literal
|
|
332
|
+
* @param ctx - the boolean literal context
|
|
333
|
+
* @returns the boolean value
|
|
197
334
|
*/
|
|
198
335
|
visitBooleanLiteral(ctx) {
|
|
199
336
|
return "TRUE" === ctx.text;
|
|
200
337
|
}
|
|
201
338
|
/**
|
|
202
339
|
* Read the number literal
|
|
340
|
+
* @param ctx - the integer literal context
|
|
341
|
+
* @returns the parsed integer value
|
|
203
342
|
*/
|
|
204
343
|
visitIntegerLiteral(ctx) {
|
|
205
344
|
return parseInt(ctx.text);
|
|
206
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
|
+
}
|
|
207
362
|
}
|
|
208
363
|
/**
|
|
209
364
|
* Represent the query expression or subset
|
|
210
365
|
*/
|
|
211
366
|
export class Expression {
|
|
367
|
+
operator;
|
|
368
|
+
/** Create a new Expression.
|
|
369
|
+
* @param operator - the expression operator
|
|
370
|
+
*/
|
|
212
371
|
constructor(operator) {
|
|
213
372
|
this.operator = operator;
|
|
214
373
|
}
|
|
215
374
|
}
|
|
216
375
|
/**
|
|
217
|
-
* 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
|
|
218
385
|
*/
|
|
219
386
|
export class ComparisonExpression extends Expression {
|
|
220
387
|
/**
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
225
399
|
*/
|
|
226
400
|
constructor(operator, attribute, value) {
|
|
227
401
|
super(operator);
|
|
228
402
|
this.value = value;
|
|
229
403
|
this.attribute = attribute.split(".");
|
|
230
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
|
+
*/
|
|
231
416
|
static likeToRegex(like) {
|
|
232
417
|
return new RegExp(like
|
|
233
418
|
// Prevent common regexp chars
|
|
@@ -246,10 +431,11 @@ export class ComparisonExpression extends Expression {
|
|
|
246
431
|
.replace(/\\([^?[{(])/g, "\\\\"));
|
|
247
432
|
}
|
|
248
433
|
/**
|
|
249
|
-
*
|
|
434
|
+
* Traverse an object using a dot-notation attribute path
|
|
250
435
|
*
|
|
251
|
-
* @param target
|
|
252
|
-
* @
|
|
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
|
|
253
439
|
*/
|
|
254
440
|
static getAttributeValue(target, attribute) {
|
|
255
441
|
let res = target;
|
|
@@ -262,10 +448,9 @@ export class ComparisonExpression extends Expression {
|
|
|
262
448
|
* Set the value of the attribute based on the assignment
|
|
263
449
|
*
|
|
264
450
|
* If used as a Set expression
|
|
265
|
-
* @param target
|
|
451
|
+
* @param target - the object to assign to
|
|
266
452
|
*/
|
|
267
453
|
setAttributeValue(target) {
|
|
268
|
-
var _a;
|
|
269
454
|
// Avoid alteration of prototype for security reason
|
|
270
455
|
if (this.attribute.includes("__proto__")) {
|
|
271
456
|
return;
|
|
@@ -273,7 +458,7 @@ export class ComparisonExpression extends Expression {
|
|
|
273
458
|
if (this.operator === "=") {
|
|
274
459
|
let res = target;
|
|
275
460
|
for (let i = 0; res && i < this.attribute.length - 1; i++) {
|
|
276
|
-
res[
|
|
461
|
+
res[this.attribute[i]] ??= {};
|
|
277
462
|
res = res[this.attribute[i]];
|
|
278
463
|
}
|
|
279
464
|
res[this.attribute[this.attribute.length - 1]] = this.value;
|
|
@@ -299,6 +484,9 @@ export class ComparisonExpression extends Expression {
|
|
|
299
484
|
case "!=":
|
|
300
485
|
return left != this.value;
|
|
301
486
|
case "LIKE":
|
|
487
|
+
if (left === undefined || left === null) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
302
490
|
if (typeof left === "string") {
|
|
303
491
|
// Grammar definie value as stringLiteral
|
|
304
492
|
return left.match(ComparisonExpression.likeToRegex(this.value)) !== null;
|
|
@@ -311,10 +499,18 @@ export class ComparisonExpression extends Expression {
|
|
|
311
499
|
return left.includes(this.value);
|
|
312
500
|
}
|
|
313
501
|
return false;
|
|
502
|
+
case "IS NULL":
|
|
503
|
+
return left === undefined || left === null;
|
|
504
|
+
case "IS NOT NULL":
|
|
505
|
+
return left !== undefined && left !== null;
|
|
314
506
|
}
|
|
315
507
|
}
|
|
316
508
|
/**
|
|
317
|
-
*
|
|
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
|
|
318
514
|
*/
|
|
319
515
|
toStringValue(value) {
|
|
320
516
|
if (Array.isArray(value)) {
|
|
@@ -322,7 +518,7 @@ export class ComparisonExpression extends Expression {
|
|
|
322
518
|
}
|
|
323
519
|
switch (typeof value) {
|
|
324
520
|
case "string":
|
|
325
|
-
return `"${value}"`;
|
|
521
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '""')}"`;
|
|
326
522
|
case "boolean":
|
|
327
523
|
return value.toString().toUpperCase();
|
|
328
524
|
}
|
|
@@ -330,12 +526,14 @@ export class ComparisonExpression extends Expression {
|
|
|
330
526
|
}
|
|
331
527
|
/**
|
|
332
528
|
* Allow subclass to create different display
|
|
529
|
+
* @returns the attribute string
|
|
333
530
|
*/
|
|
334
531
|
toStringAttribute() {
|
|
335
532
|
return this.attribute.join(".");
|
|
336
533
|
}
|
|
337
534
|
/**
|
|
338
535
|
* Allow subclass to create different display
|
|
536
|
+
* @returns the operator string
|
|
339
537
|
*/
|
|
340
538
|
toStringOperator() {
|
|
341
539
|
return this.operator;
|
|
@@ -344,26 +542,28 @@ export class ComparisonExpression extends Expression {
|
|
|
344
542
|
* @override
|
|
345
543
|
*/
|
|
346
544
|
toString() {
|
|
545
|
+
if (UNARY_COMPARISON_OPERATORS.includes(this.operator)) {
|
|
546
|
+
return `${this.toStringAttribute()} ${this.toStringOperator()}`;
|
|
547
|
+
}
|
|
347
548
|
return `${this.toStringAttribute()} ${this.toStringOperator()} ${this.toStringValue(this.value)}`;
|
|
348
549
|
}
|
|
349
550
|
}
|
|
350
551
|
/**
|
|
351
|
-
* Abstract
|
|
552
|
+
* Abstract base for logical expressions that combine child expressions (AND / OR)
|
|
352
553
|
*
|
|
353
|
-
*
|
|
554
|
+
* @typeParam T - the literal operator type (`"AND"` or `"OR"`)
|
|
354
555
|
*/
|
|
355
556
|
export class LogicalExpression extends Expression {
|
|
356
557
|
/**
|
|
357
|
-
*
|
|
358
|
-
|
|
359
|
-
|
|
558
|
+
* Child expressions combined by this logical operator
|
|
559
|
+
*/
|
|
560
|
+
children = [];
|
|
561
|
+
/**
|
|
562
|
+
* @param operator - logical operator
|
|
563
|
+
* @param children - child expressions to combine
|
|
360
564
|
*/
|
|
361
565
|
constructor(operator, children) {
|
|
362
566
|
super(operator);
|
|
363
|
-
/**
|
|
364
|
-
* Contains the members of the logical expression
|
|
365
|
-
*/
|
|
366
|
-
this.children = [];
|
|
367
567
|
this.children = children;
|
|
368
568
|
}
|
|
369
569
|
/**
|
|
@@ -377,7 +577,10 @@ export class LogicalExpression extends Expression {
|
|
|
377
577
|
}
|
|
378
578
|
}
|
|
379
579
|
/**
|
|
380
|
-
* 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`.
|
|
381
584
|
*/
|
|
382
585
|
export class AndExpression extends LogicalExpression {
|
|
383
586
|
/**
|
|
@@ -399,7 +602,10 @@ export class AndExpression extends LogicalExpression {
|
|
|
399
602
|
}
|
|
400
603
|
}
|
|
401
604
|
/**
|
|
402
|
-
* 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`.
|
|
403
609
|
*/
|
|
404
610
|
export class OrExpression extends LogicalExpression {
|
|
405
611
|
/**
|
|
@@ -421,32 +627,165 @@ export class OrExpression extends LogicalExpression {
|
|
|
421
627
|
}
|
|
422
628
|
}
|
|
423
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
|
|
424
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
|
+
* ```
|
|
425
753
|
*/
|
|
426
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
|
+
*/
|
|
427
767
|
constructor(sql, builder = new ExpressionBuilder()) {
|
|
428
768
|
this.sql = sql;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const parser = new WebdaQLParserParser(tokenStream);
|
|
432
|
-
parser.removeErrorListeners();
|
|
433
|
-
parser.addErrorListener({
|
|
434
|
-
syntaxError: (_recognizer, _offendingSymbol, _line, _charPositionInLine, msg, _e) => {
|
|
435
|
-
throw new SyntaxError(`${msg} (Query: ${sql})`);
|
|
436
|
-
}
|
|
437
|
-
});
|
|
769
|
+
const { lexer, parser } = createParser(sql);
|
|
770
|
+
this.lexer = lexer;
|
|
438
771
|
// Parse the input, where `compilationUnit` is whatever entry point you defined
|
|
439
772
|
this.tree = parser.webdaql();
|
|
440
773
|
this.builder = builder;
|
|
441
774
|
this.query = this.builder.visit(this.tree);
|
|
442
775
|
}
|
|
443
776
|
/**
|
|
444
|
-
* Get
|
|
445
|
-
* @returns
|
|
777
|
+
* Get parsed OFFSET continuation token, or empty string if none
|
|
778
|
+
* @returns the offset token
|
|
446
779
|
*/
|
|
447
780
|
getOffset() {
|
|
448
781
|
return this.builder.getOffset() || "";
|
|
449
782
|
}
|
|
783
|
+
/**
|
|
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
|
+
*/
|
|
450
789
|
hasCondition() {
|
|
451
790
|
const filter = this.query.filter;
|
|
452
791
|
const isAnd = filter instanceof AndExpression;
|
|
@@ -455,6 +794,12 @@ export class QueryValidator {
|
|
|
455
794
|
}
|
|
456
795
|
return true;
|
|
457
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
|
+
*/
|
|
458
803
|
toString() {
|
|
459
804
|
let res = this.query.filter.toString();
|
|
460
805
|
if (this.query.orderBy) {
|
|
@@ -468,8 +813,17 @@ export class QueryValidator {
|
|
|
468
813
|
}
|
|
469
814
|
return res.trim();
|
|
470
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
|
+
*/
|
|
471
826
|
merge(query, type = "AND") {
|
|
472
|
-
var _a;
|
|
473
827
|
const adds = new QueryValidator(query);
|
|
474
828
|
// Add additional conditions
|
|
475
829
|
if (adds.hasCondition()) {
|
|
@@ -492,29 +846,29 @@ export class QueryValidator {
|
|
|
492
846
|
// Add the order by if overriden
|
|
493
847
|
if (adds.query.orderBy) {
|
|
494
848
|
const fields = adds.query.orderBy.map(o => o.field);
|
|
495
|
-
|
|
849
|
+
this.query.orderBy ??= [];
|
|
496
850
|
// Remove the fields that are already in the query
|
|
497
851
|
this.query.orderBy = [...adds.query.orderBy, ...this.query.orderBy.filter(o => !fields.includes(o.field))];
|
|
498
852
|
}
|
|
499
853
|
return this;
|
|
500
854
|
}
|
|
501
855
|
/**
|
|
502
|
-
* Get
|
|
503
|
-
* @returns
|
|
856
|
+
* Get parsed LIMIT value, defaulting to 1000
|
|
857
|
+
* @returns the limit value
|
|
504
858
|
*/
|
|
505
859
|
getLimit() {
|
|
506
860
|
return this.builder.getLimit() || 1000;
|
|
507
861
|
}
|
|
508
862
|
/**
|
|
509
|
-
* Get the expression
|
|
510
|
-
* @returns
|
|
863
|
+
* Get the filter expression (without LIMIT / OFFSET / ORDER BY)
|
|
864
|
+
* @returns the filter expression
|
|
511
865
|
*/
|
|
512
866
|
getExpression() {
|
|
513
867
|
return this.query.filter;
|
|
514
868
|
}
|
|
515
869
|
/**
|
|
516
|
-
* Retrieve parsed query
|
|
517
|
-
* @returns
|
|
870
|
+
* Retrieve the full parsed query including filter, limit, offset, and orderBy
|
|
871
|
+
* @returns the full query object
|
|
518
872
|
*/
|
|
519
873
|
getQuery() {
|
|
520
874
|
return {
|
|
@@ -525,16 +879,16 @@ export class QueryValidator {
|
|
|
525
879
|
}
|
|
526
880
|
/**
|
|
527
881
|
* Verify if a target fit the expression
|
|
528
|
-
* @param target
|
|
529
|
-
* @returns
|
|
882
|
+
* @param target - the object to evaluate
|
|
883
|
+
* @returns true if the target matches
|
|
530
884
|
*/
|
|
531
885
|
eval(target) {
|
|
532
886
|
return this.query.filter.eval(target);
|
|
533
887
|
}
|
|
534
888
|
/**
|
|
535
889
|
* Display parse tree back as query
|
|
536
|
-
* @param tree
|
|
537
|
-
* @returns
|
|
890
|
+
* @param tree - the parse tree to display
|
|
891
|
+
* @returns the query string representation
|
|
538
892
|
*/
|
|
539
893
|
displayTree(tree = this.tree) {
|
|
540
894
|
let res = "";
|
|
@@ -554,20 +908,51 @@ export class QueryValidator {
|
|
|
554
908
|
}
|
|
555
909
|
}
|
|
556
910
|
/**
|
|
557
|
-
*
|
|
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
|
|
558
926
|
*/
|
|
559
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
|
+
*/
|
|
560
932
|
constructor(sql) {
|
|
561
933
|
super(sql);
|
|
562
934
|
// Do one empty run to raise any issue with disallowed expression
|
|
563
935
|
this.eval({});
|
|
564
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
|
+
*/
|
|
565
943
|
eval(target) {
|
|
566
944
|
if (this.query.filter) {
|
|
567
945
|
this.assign(target, this.query.filter);
|
|
568
946
|
}
|
|
569
947
|
return true;
|
|
570
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
|
+
*/
|
|
571
956
|
assign(target, expression) {
|
|
572
957
|
if (expression instanceof AndExpression) {
|
|
573
958
|
expression.children.forEach(c => this.assign(target, c));
|
|
@@ -580,15 +965,34 @@ export class SetterValidator extends QueryValidator {
|
|
|
580
965
|
}
|
|
581
966
|
}
|
|
582
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
|
+
*/
|
|
583
983
|
export class PartialValidator extends QueryValidator {
|
|
984
|
+
/**
|
|
985
|
+
* @param query - WebdaQL query string
|
|
986
|
+
* @param builder - partial expression builder (override for customization)
|
|
987
|
+
*/
|
|
584
988
|
constructor(query, builder = new PartialExpressionBuilder()) {
|
|
585
989
|
super(query, builder);
|
|
586
990
|
}
|
|
587
991
|
/**
|
|
588
992
|
* Eval the query
|
|
589
|
-
* @param target
|
|
590
|
-
* @param partial
|
|
591
|
-
* @returns
|
|
993
|
+
* @param target - the object to evaluate
|
|
994
|
+
* @param partial - whether to use partial matching
|
|
995
|
+
* @returns true if the target matches
|
|
592
996
|
*/
|
|
593
997
|
eval(target, partial = true) {
|
|
594
998
|
this.builder.setPartial(partial);
|
|
@@ -597,13 +1001,28 @@ export class PartialValidator extends QueryValidator {
|
|
|
597
1001
|
}
|
|
598
1002
|
/**
|
|
599
1003
|
* Return if the result ignored some fields
|
|
600
|
-
* @returns
|
|
1004
|
+
* @returns true if some fields were skipped
|
|
601
1005
|
*/
|
|
602
1006
|
wasPartialMatch() {
|
|
603
1007
|
return this.builder.partialMatch;
|
|
604
1008
|
}
|
|
605
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
|
+
*/
|
|
606
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
|
+
*/
|
|
607
1026
|
constructor(builder, op, attribute, value) {
|
|
608
1027
|
super(op, attribute, value);
|
|
609
1028
|
this.builder = builder;
|
|
@@ -612,8 +1031,8 @@ export class PartialComparisonExpression extends ComparisonExpression {
|
|
|
612
1031
|
* Override the eval to check if the attribute is present
|
|
613
1032
|
* if not and we are in partial mode, return true
|
|
614
1033
|
*
|
|
615
|
-
* @param target
|
|
616
|
-
* @returns
|
|
1034
|
+
* @param target - the object to evaluate
|
|
1035
|
+
* @returns true if the target matches
|
|
617
1036
|
*/
|
|
618
1037
|
eval(target) {
|
|
619
1038
|
if (this.builder.partial) {
|
|
@@ -626,17 +1045,39 @@ export class PartialComparisonExpression extends ComparisonExpression {
|
|
|
626
1045
|
return super.eval(target);
|
|
627
1046
|
}
|
|
628
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
|
+
*/
|
|
629
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
|
+
*/
|
|
630
1067
|
setPartial(partial) {
|
|
631
1068
|
this.partial = partial;
|
|
632
1069
|
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Set the partial match flag (reset before each evaluation)
|
|
1072
|
+
* @param partial - the partial match flag value
|
|
1073
|
+
*/
|
|
633
1074
|
setPartialMatch(partial) {
|
|
634
1075
|
this.partialMatch = partial;
|
|
635
1076
|
}
|
|
636
1077
|
/**
|
|
637
|
-
* a LIKE
|
|
638
|
-
* @param ctx
|
|
639
|
-
* @returns
|
|
1078
|
+
* Visit a LIKE expression, returning a PartialComparisonExpression
|
|
1079
|
+
* @param ctx - the LIKE expression context
|
|
1080
|
+
* @returns the partial comparison expression
|
|
640
1081
|
*/
|
|
641
1082
|
visitLikeExpression(ctx) {
|
|
642
1083
|
const [left, _, right] = ctx.children;
|
|
@@ -644,7 +1085,9 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
|
|
|
644
1085
|
return new PartialComparisonExpression(this, "LIKE", left.text, value);
|
|
645
1086
|
}
|
|
646
1087
|
/**
|
|
647
|
-
*
|
|
1088
|
+
* Visit a binary comparison, returning a PartialComparisonExpression
|
|
1089
|
+
* @param ctx - the binary comparison context
|
|
1090
|
+
* @returns the partial comparison expression
|
|
648
1091
|
*/
|
|
649
1092
|
visitBinaryComparisonExpression(ctx) {
|
|
650
1093
|
const [left, op, right] = ctx.children;
|
|
@@ -652,7 +1095,9 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
|
|
|
652
1095
|
return new PartialComparisonExpression(this, op.text, left.text, this.visit(right));
|
|
653
1096
|
}
|
|
654
1097
|
/**
|
|
655
|
-
*
|
|
1098
|
+
* Visit an IN expression, returning a PartialComparisonExpression
|
|
1099
|
+
* @param ctx - the IN expression context
|
|
1100
|
+
* @returns the partial comparison expression
|
|
656
1101
|
*/
|
|
657
1102
|
visitInExpression(ctx) {
|
|
658
1103
|
const [left, _, right] = ctx.children;
|
|
@@ -660,20 +1105,46 @@ export class PartialExpressionBuilder extends ExpressionBuilder {
|
|
|
660
1105
|
return new PartialComparisonExpression(this, "IN", left.text, value);
|
|
661
1106
|
}
|
|
662
1107
|
/**
|
|
663
|
-
*
|
|
1108
|
+
* Visit a CONTAINS expression, returning a PartialComparisonExpression
|
|
1109
|
+
* @param ctx - the CONTAINS expression context
|
|
1110
|
+
* @returns the partial comparison expression
|
|
664
1111
|
*/
|
|
665
1112
|
visitContainsExpression(ctx) {
|
|
666
1113
|
const [left, _, right] = ctx.children;
|
|
667
1114
|
const value = this.visit(right);
|
|
668
1115
|
return new PartialComparisonExpression(this, "CONTAINS", left.text, value);
|
|
669
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
|
+
}
|
|
670
1133
|
}
|
|
671
1134
|
/**
|
|
672
1135
|
* Remove artifact from sanitize-html inside query
|
|
673
|
-
* @param query
|
|
674
|
-
* @returns
|
|
1136
|
+
* @param query - the query string to unsanitize
|
|
1137
|
+
* @returns the cleaned query string
|
|
675
1138
|
*/
|
|
676
1139
|
export function unsanitize(query) {
|
|
677
1140
|
return query.replace(/</g, "<").replace(/>/g, ">");
|
|
678
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
|
+
}
|
|
679
1150
|
//# sourceMappingURL=query.js.map
|