@webda/ql 4.0.0-beta.2 → 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 (48) hide show
  1. package/README.md +135 -2
  2. package/lib/WebdaQLLexer.d.ts +19 -13
  3. package/lib/WebdaQLLexer.d.ts.map +1 -1
  4. package/lib/WebdaQLLexer.js +305 -252
  5. package/lib/WebdaQLLexer.js.map +1 -1
  6. package/lib/WebdaQLParserLexer.d.ts +19 -13
  7. package/lib/WebdaQLParserLexer.d.ts.map +1 -1
  8. package/lib/WebdaQLParserLexer.js +305 -252
  9. package/lib/WebdaQLParserLexer.js.map +1 -1
  10. package/lib/WebdaQLParserListener.d.ts +89 -3
  11. package/lib/WebdaQLParserListener.d.ts.map +1 -1
  12. package/lib/WebdaQLParserListener.js +1 -1
  13. package/lib/WebdaQLParserListener.js.map +1 -1
  14. package/lib/WebdaQLParserParser.d.ts +83 -22
  15. package/lib/WebdaQLParserParser.d.ts.map +1 -1
  16. package/lib/WebdaQLParserParser.js +790 -290
  17. package/lib/WebdaQLParserParser.js.map +1 -1
  18. package/lib/WebdaQLParserVisitor.d.ts +65 -2
  19. package/lib/WebdaQLParserVisitor.d.ts.map +1 -1
  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 +395 -76
  35. package/lib/query.d.ts.map +1 -1
  36. package/lib/query.js +562 -91
  37. package/lib/query.js.map +1 -1
  38. package/lib/query.spec.js +593 -195
  39. package/lib/query.spec.js.map +1 -1
  40. package/lib/webdaql-string.d.ts +59 -0
  41. package/lib/webdaql-string.d.ts.map +1 -0
  42. package/lib/webdaql-string.js +112 -0
  43. package/lib/webdaql-string.js.map +1 -0
  44. package/lib/webdaql-string.spec.d.ts +2 -0
  45. package/lib/webdaql-string.spec.d.ts.map +1 -0
  46. package/lib/webdaql-string.spec.js +282 -0
  47. package/lib/webdaql-string.spec.js.map +1 -0
  48. package/package.json +30 -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
- * Create Expression based on the parsed token
77
+ * ANTLR parse tree visitor that builds an optimized Expression AST from the parsed tokens
10
78
  *
11
- * 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.
12
81
  */
13
82
  export class ExpressionBuilder extends AbstractParseTreeVisitor {
14
83
  /**
15
- * Default result for the override
16
- * @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
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 offset
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 limit
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
- * Read the limit
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.visitIntegerLiteral(ctx.getChild(1));
124
+ this.limit = this.visit(ctx.getChild(1));
44
125
  }
45
126
  /**
46
- * Read the offset if provided
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.visitStringLiteral(ctx.getChild(1));
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
- * Return only AndExpression
71
- * @param ctx
72
- * @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
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
- * Simplify Logic expression and regroup them
104
- * @param ctx
105
- * @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
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 new AndExpression(this.getComparison(ctx).map(c => this.visit(c)));
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 "%A?"
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 new OrExpression(this.getComparison(ctx).map(c => this.visit(c)));
307
+ return foldLogical("OR", this.getComparison(ctx).map(c => this.visit(c)));
188
308
  }
189
309
  /**
190
- * 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
191
326
  */
192
327
  visitStringLiteral(ctx) {
193
- return ctx.text.substring(1, ctx.text.length - 1);
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 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
218
385
  */
219
386
  export class ComparisonExpression extends Expression {
220
387
  /**
221
- *
222
- * @param operator of the expression
223
- * @param attribute of the object to read
224
- * @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
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
- * Read the value from the object
434
+ * Traverse an object using a dot-notation attribute path
250
435
  *
251
- * @param target
252
- * @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
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[_a = this.attribute[i]] ?? (res[_a] = {});
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
- * 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
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 logic expression (AND|OR)
552
+ * Abstract base for logical expressions that combine child expressions (AND / OR)
352
553
  *
353
- * Could add XOR in the future
554
+ * @typeParam T - the literal operator type (`"AND"` or `"OR"`)
354
555
  */
355
556
  export class LogicalExpression extends Expression {
356
557
  /**
357
- *
358
- * @param operator
359
- * @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
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 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`.
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 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`.
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
- this.lexer = new WebdaQLLexer(CharStreams.fromString(sql || ""));
430
- const tokenStream = new CommonTokenStream(this.lexer);
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 offset
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
- (_a = this.query).orderBy ?? (_a.orderBy = []);
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 limit
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 by itself
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
- * 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
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 "%A?"
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
- * 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
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
- * 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
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
- * 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
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(/&lt;/g, "<").replace(/&gt;/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