@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.
Files changed (50) hide show
  1. package/README.md +135 -2
  2. package/lib/WebdaQLLexer.d.ts +20 -13
  3. package/lib/WebdaQLLexer.d.ts.map +1 -0
  4. package/lib/WebdaQLLexer.js +305 -252
  5. package/lib/WebdaQLLexer.js.map +1 -1
  6. package/lib/WebdaQLParserLexer.d.ts +20 -13
  7. package/lib/WebdaQLParserLexer.d.ts.map +1 -0
  8. package/lib/WebdaQLParserLexer.js +305 -252
  9. package/lib/WebdaQLParserLexer.js.map +1 -1
  10. package/lib/WebdaQLParserListener.d.ts +90 -3
  11. package/lib/WebdaQLParserListener.d.ts.map +1 -0
  12. package/lib/WebdaQLParserListener.js +1 -1
  13. package/lib/WebdaQLParserListener.js.map +1 -1
  14. package/lib/WebdaQLParserParser.d.ts +84 -22
  15. package/lib/WebdaQLParserParser.d.ts.map +1 -0
  16. package/lib/WebdaQLParserParser.js +777 -277
  17. package/lib/WebdaQLParserParser.js.map +1 -1
  18. package/lib/WebdaQLParserVisitor.d.ts +66 -2
  19. package/lib/WebdaQLParserVisitor.d.ts.map +1 -0
  20. package/lib/WebdaQLParserVisitor.js +1 -1
  21. package/lib/WebdaQLParserVisitor.js.map +1 -1
  22. package/lib/bind.d.ts +29 -0
  23. package/lib/bind.d.ts.map +1 -0
  24. package/lib/bind.js +139 -0
  25. package/lib/bind.js.map +1 -0
  26. package/lib/bind.spec.d.ts +2 -0
  27. package/lib/bind.spec.d.ts.map +1 -0
  28. package/lib/bind.spec.js +206 -0
  29. package/lib/bind.spec.js.map +1 -0
  30. package/lib/index.d.ts +4 -0
  31. package/lib/index.d.ts.map +1 -0
  32. package/lib/index.js +4 -0
  33. package/lib/index.js.map +1 -0
  34. package/lib/query.d.ts +399 -76
  35. package/lib/query.d.ts.map +1 -0
  36. package/lib/query.js +621 -117
  37. package/lib/query.js.map +1 -1
  38. package/lib/query.spec.d.ts +1 -0
  39. package/lib/query.spec.d.ts.map +1 -0
  40. package/lib/query.spec.js +593 -180
  41. package/lib/query.spec.js.map +1 -1
  42. package/lib/webdaql-string.d.ts +59 -0
  43. package/lib/webdaql-string.d.ts.map +1 -0
  44. package/lib/webdaql-string.js +112 -0
  45. package/lib/webdaql-string.js.map +1 -0
  46. package/lib/webdaql-string.spec.d.ts +2 -0
  47. package/lib/webdaql-string.spec.d.ts.map +1 -0
  48. package/lib/webdaql-string.spec.js +282 -0
  49. package/lib/webdaql-string.spec.js.map +1 -0
  50. 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
- if (!condition) {
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
- * Create Expression based on the parsed token
77
+ * ANTLR parse tree visitor that builds an optimized Expression AST from the parsed tokens
29
78
  *
30
- * Expression allow to optimize and split between Query and Filter
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
- * Default result for the override
35
- * @returns
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 offset
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 limit
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
- * Read the limit
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.visitIntegerLiteral(ctx.getChild(1));
124
+ this.limit = this.visit(ctx.getChild(1));
63
125
  }
64
126
  /**
65
- * Read the offset if provided
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.visitStringLiteral(ctx.getChild(1));
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
- * Return only AndExpression
90
- * @param ctx
91
- * @returns
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
- * Simplify Logic expression and regroup them
123
- * @param ctx
124
- * @returns
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 new AndExpression(this.getComparison(ctx).map(c => this.visit(c)));
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 "%A?"
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
- let value = this.visit(right);
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
- let value = this.visit(right);
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
- let value = this.visit(right);
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 new OrExpression(this.getComparison(ctx).map(c => this.visit(c)));
307
+ return foldLogical("OR", this.getComparison(ctx).map(c => this.visit(c)));
206
308
  }
207
309
  /**
208
- * Read the string literal (removing the simple or double bracket)
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 ctx.text.substring(1, ctx.text.length - 1);
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 expression
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
- * @param operator of the expression
241
- * @param attribute of the object to read
242
- * @param value
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
- * Read the value from the object
434
+ * Traverse an object using a dot-notation attribute path
268
435
  *
269
- * @param target
270
- * @returns
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[_a = this.attribute[i]] ?? (res[_a] = {});
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
- * Return a string represantation of a value
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 logic expression (AND|OR)
552
+ * Abstract base for logical expressions that combine child expressions (AND / OR)
370
553
  *
371
- * Could add XOR in the future
554
+ * @typeParam T - the literal operator type (`"AND"` or `"OR"`)
372
555
  */
373
556
  export class LogicalExpression extends Expression {
374
557
  /**
375
- *
376
- * @param operator
377
- * @param children
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 Expression implementation
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 (let child of this.children) {
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 Expression implementation
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 (let child of this.children) {
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
- this.lexer = new WebdaQLLexer(CharStreams.fromString(sql || ""));
448
- let tokenStream = new CommonTokenStream(this.lexer);
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 offset
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
- * Get limit
470
- * @returns
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 by itself
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
- * For now reuse same parser
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 "%A?"
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
- let value = this.visit(right);
1084
+ const value = this.visit(right);
611
1085
  return new PartialComparisonExpression(this, "LIKE", left.text, value);
612
1086
  }
613
1087
  /**
614
- * Implement the BinaryComparison with all methods managed
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
- * Map the a IN ['b','c']
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
- let value = this.visit(right);
1104
+ const value = this.visit(right);
627
1105
  return new PartialComparisonExpression(this, "IN", left.text, value);
628
1106
  }
629
1107
  /**
630
- * Map the a CONTAINS 'b'
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
- let value = this.visit(right);
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(/&lt;/g, "<").replace(/&gt;/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