@omgbase/oqx 0.21.0 → 0.24.0

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 (65) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +79 -2
  3. package/dist/ast.d.ts +48 -3
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +17 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +7 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +9 -2
  10. package/dist/build.js.map +1 -1
  11. package/dist/engine.d.ts +3 -0
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +153 -11
  14. package/dist/engine.js.map +1 -1
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/lexer.d.ts.map +1 -1
  20. package/dist/lexer.js +13 -3
  21. package/dist/lexer.js.map +1 -1
  22. package/dist/optimize/analysis.d.ts.map +1 -1
  23. package/dist/optimize/analysis.js +19 -2
  24. package/dist/optimize/analysis.js.map +1 -1
  25. package/dist/optimize/ir.d.ts +5 -1
  26. package/dist/optimize/ir.d.ts.map +1 -1
  27. package/dist/optimize/ir.js +2 -0
  28. package/dist/optimize/ir.js.map +1 -1
  29. package/dist/optimize/rules.d.ts.map +1 -1
  30. package/dist/optimize/rules.js +4 -3
  31. package/dist/optimize/rules.js.map +1 -1
  32. package/dist/parser.d.ts.map +1 -1
  33. package/dist/parser.js +406 -21
  34. package/dist/parser.js.map +1 -1
  35. package/dist/print.d.ts.map +1 -1
  36. package/dist/print.js +17 -0
  37. package/dist/print.js.map +1 -1
  38. package/dist/references.d.ts +28 -0
  39. package/dist/references.d.ts.map +1 -0
  40. package/dist/references.js +287 -0
  41. package/dist/references.js.map +1 -0
  42. package/dist/resolve.d.ts.map +1 -1
  43. package/dist/resolve.js +20 -1
  44. package/dist/resolve.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/walk.d.ts +1 -1
  48. package/dist/walk.d.ts.map +1 -1
  49. package/dist/walk.js +10 -0
  50. package/dist/walk.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/ast.ts +64 -4
  53. package/src/build.ts +11 -4
  54. package/src/engine.ts +154 -11
  55. package/src/index.ts +3 -1
  56. package/src/lexer.ts +10 -4
  57. package/src/optimize/analysis.ts +17 -2
  58. package/src/optimize/ir.ts +7 -1
  59. package/src/optimize/rules.ts +4 -3
  60. package/src/parser.ts +328 -21
  61. package/src/print.ts +19 -3
  62. package/src/references.ts +293 -0
  63. package/src/resolve.ts +22 -2
  64. package/src/version.ts +1 -1
  65. package/src/walk.ts +12 -1
package/src/ast.ts CHANGED
@@ -26,8 +26,26 @@ export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
26
26
  /** The identity operators (SEMANTICS §5b, since 0.17). */
27
27
  export type IdentityOp = "is" | "is not";
28
28
 
29
- /** The operator of a `binary` expression: a comparison, an identity test, or arithmetic. */
30
- export type BinaryOp = RelOp | IdentityOp | "+" | "-" | "*" | "/" | "%";
29
+ /** The infix regex operators (SEMANTICS §11, since 0.22): sugar over `matches` —
30
+ * `a ~ b` ≡ `a.matches(b)`, `a ~* b` ≡ `a.matches(b, "i")`, `a !~ b` ≡
31
+ * `!a.matches(b)`, `a !~* b` ≡ `!a.matches(b, "i")`. The tree keeps the
32
+ * operator as written so the canonical print is what the user wrote. */
33
+ export type RegexOp = "~" | "~*" | "!~" | "!~*";
34
+
35
+ /** The operator of a `binary` expression: a comparison, an identity test, a regex match, or arithmetic. */
36
+ export type BinaryOp = RelOp | IdentityOp | RegexOp | "+" | "-" | "*" | "/" | "%";
37
+
38
+ /** The regex operators, with the `matches` call each stands for. */
39
+ export const REGEX_OPS: Readonly<Record<RegexOp, { negated: boolean; flags: string | undefined }>> = {
40
+ "~": { negated: false, flags: undefined },
41
+ "~*": { negated: false, flags: "i" },
42
+ "!~": { negated: true, flags: undefined },
43
+ "!~*": { negated: true, flags: "i" },
44
+ };
45
+
46
+ export function isRegexOp(op: string): op is RegexOp {
47
+ return Object.hasOwn(REGEX_OPS, op);
48
+ }
31
49
 
32
50
  /** The operator of a `unary` expression. */
33
51
  export type UnaryOp = "!" | "-";
@@ -35,6 +53,19 @@ export type UnaryOp = "!" | "-";
35
53
  /** The operator of a `logical` expression. */
36
54
  export type LogicalOp = "&&" | "||";
37
55
 
56
+ /** The aggregate functions (SEMANTICS §24, since 0.24): reserved free-function
57
+ * names that reduce a group's rows. `count` takes zero or one argument, every
58
+ * other exactly one; `any`/`all` take a predicate and admit no `distinct`. */
59
+ export type AggregateName = "count" | "sum" | "min" | "max" | "avg" | "collect" | "first" | "last" | "any" | "all";
60
+
61
+ export const AGGREGATE_NAMES: ReadonlySet<string> = new Set<AggregateName>([
62
+ "count", "sum", "min", "max", "avg", "collect", "first", "last", "any", "all",
63
+ ]);
64
+
65
+ export function isAggregateName(name: string): name is AggregateName {
66
+ return AGGREGATE_NAMES.has(name);
67
+ }
68
+
38
69
  /** One part of a template literal (since 0.21): a decoded text run, or an
39
70
  * interpolated expression. The strings are not nodes (no `kind`, no span). */
40
71
  export type TemplatePart = string | Expr;
@@ -68,11 +99,32 @@ export type Expr =
68
99
  // GRAMMAR §1 escapes resolved). Evaluates to the concatenation of the text and
69
100
  // the interpolated values' string forms (SEMANTICS §7b).
70
101
  | { kind: "template"; span: Span; parts: TemplatePart[] }
102
+ // An aggregate call `sum(e)`, `count()`, `collect(distinct e)` (since 0.24):
103
+ // reduces the rows of the group it is read in (SEMANTICS §24). `arg` is null
104
+ // for the bare `count()`; `distinct` is the `(distinct e)` modifier. Legal
105
+ // only in the post-group clauses of a body with `group by`.
106
+ | { kind: "aggregate"; span: Span; name: AggregateName; arg: Expr | null; distinct: boolean }
71
107
  // A value-position directive (since 0.17): `recv first { … }.pay`, and what a
72
108
  // bracket lookup `x[…]` desugars to. `isExpr` is false for it — an op is an
73
109
  // expression only by position; discriminate on `kind === "op"`.
74
110
  | OpNode;
75
111
 
112
+ /** One `group by` key (since 0.24): `name: expr`, or an unaliased navigation
113
+ * keyed by its last segment, exactly as a `select` item is. */
114
+ export interface GroupKey {
115
+ kind: "key";
116
+ span: Span;
117
+ name: string;
118
+ expr: Expr;
119
+ }
120
+
121
+ /** The `group by` clause (since 0.24): one row per distinct key tuple. */
122
+ export interface GroupBy {
123
+ kind: "groupBy";
124
+ span: Span;
125
+ keys: GroupKey[]; // ≥ 1, in source order
126
+ }
127
+
76
128
  /** One `order by` term. */
77
129
  export interface OrderSpec {
78
130
  kind: "order";
@@ -147,6 +199,11 @@ export interface Subquery {
147
199
  * `depth` is destination-only. Both `null` when absent. */
148
200
  frontier: Expr | null;
149
201
  depth: number | null;
202
+ /** `group by` / `having` (since 0.24): the block's rows become one row per
203
+ * distinct key tuple (SEMANTICS §24); `having` filters the groups. Both
204
+ * `null` when absent; `having` is set only together with `groupBy`. */
205
+ groupBy: GroupBy | null;
206
+ having: Where | null;
150
207
  /** `values` — scalar projection mode: the (single) projected expression is the
151
208
  * row's result itself rather than being wrapped in a `{ name: value }` record,
152
209
  * so `name values` yields `["Bob", …]` and `$it values` yields the rows. */
@@ -186,6 +243,9 @@ export interface Query {
186
243
  * scope, like `where`) is a frontier (`$stop == "frontier"`, not expanded).
187
244
  * `null` without one. */
188
245
  frontier: Expr | null;
246
+ /** `group by` / `having` (since 0.24; see Subquery). */
247
+ groupBy: GroupBy | null;
248
+ having: Where | null;
189
249
  /** `distinct` — dedup the result rows by their projected value (see OpNode). */
190
250
  distinct: boolean;
191
251
  /** `values` — scalar projection mode (see Subquery). */
@@ -196,14 +256,14 @@ export interface Query {
196
256
  }
197
257
 
198
258
  /** Every node of the tree. `AstNode["kind"]` is the closed set of node kinds. */
199
- export type AstNode = Query | Subquery | OpNode | Follow | OrderSpec | SelectItem | Where | Expr;
259
+ export type AstNode = Query | Subquery | OpNode | Follow | GroupBy | GroupKey | OrderSpec | SelectItem | Where | Expr;
200
260
 
201
261
  /** The closed set of node kinds. */
202
262
  export type AstKind = AstNode["kind"];
203
263
 
204
264
  /** The expression kinds (`Expr["kind"]`), for `isExpr`. */
205
265
  const EXPR_KINDS: ReadonlySet<string> = new Set([
206
- "lit", "ident", "outer", "binding", "member", "call", "unary", "required", "binary", "logical", "in", "range", "tuple", "template",
266
+ "lit", "ident", "outer", "binding", "member", "call", "unary", "required", "binary", "logical", "in", "range", "tuple", "template", "aggregate",
207
267
  ]);
208
268
 
209
269
  /** Whether a node is a scalar `Expr` (an `op` in expression position is not: it
package/src/build.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // tree has the same shape as a parsed one and prints with `print`.
5
5
 
6
6
  import type {
7
- BinaryOp, Consumer, CountCmp, Expr, Follow, FollowDestination, LogicalOp, OpNode, OrderSpec, Query, SelectItem, Span,
8
- Subquery, TemplatePart, UnaryOp, Where,
7
+ AggregateName, BinaryOp, Consumer, CountCmp, Expr, Follow, FollowDestination, GroupBy, GroupKey, LogicalOp, OpNode, OrderSpec,
8
+ Query, SelectItem, Span, Subquery, TemplatePart, UnaryOp, Where,
9
9
  } from "./ast.ts";
10
10
 
11
11
  /** A fresh empty span (never shared: spans are mutable tuples). */
@@ -31,6 +31,8 @@ export const tuple = (items: Expr[]): Expr => ({ kind: "tuple", span: span(), it
31
31
  /** `` `text ${expr} …` `` — a template literal (SEMANTICS §7b); `parts` alternates
32
32
  * text, expression, text, … (odd length, text first and last). */
33
33
  export const template = (parts: TemplatePart[]): Expr => ({ kind: "template", span: span(), parts });
34
+ /** `name(arg)` / `name(distinct arg)` / `count()` — an aggregate over the group's rows (SEMANTICS §24). */
35
+ export const aggregate = (name: AggregateName, arg: Expr | null = null, distinct = false): Expr => ({ kind: "aggregate", span: span(), name, arg, distinct });
34
36
 
35
37
  export const scalar = (expr: Expr): Where => ({ kind: "scalar", span: span(), expr });
36
38
  export const and = (parts: Where[]): Where => ({ kind: "and", span: span(), parts });
@@ -40,12 +42,17 @@ export const not = (expr: Where): Where => ({ kind: "not", span: span(), expr })
40
42
  export const field = (name: string, expr: Expr, lift = 0): SelectItem => ({ kind: "field", span: span(), name, expr, lift });
41
43
  export const collect = (name: string, op: OpNode): SelectItem => ({ kind: "collect", span: span(), name, op });
42
44
  export const order = (expr: Expr, desc = false): OrderSpec => ({ kind: "order", span: span(), expr, desc });
45
+ /** One `group by` key, `name: expr`. */
46
+ export const key = (name: string, expr: Expr): GroupKey => ({ kind: "key", span: span(), name, expr });
47
+ /** `group by <keys>` (SEMANTICS §24). */
48
+ export const groupBy = (keys: GroupKey[]): GroupBy => ({ kind: "groupBy", span: span(), keys });
43
49
 
44
50
  /** A block body; every clause defaults to empty/absent. */
45
51
  export function subquery(parts: Partial<Omit<Subquery, "kind" | "span">> = {}): Subquery {
46
52
  return {
47
53
  kind: "subquery", span: span(),
48
- from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null,
54
+ from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, groupBy: null, having: null,
55
+ values: false, limit: null, offset: null,
49
56
  ...parts,
50
57
  };
51
58
  }
@@ -69,7 +76,7 @@ export function follow(destinations: FollowDestination[], opts: { distinct?: boo
69
76
  export function query(source: Expr, parts: Partial<Omit<Query, "kind" | "span" | "source">> = {}): Query {
70
77
  return {
71
78
  kind: "query", span: span(), source,
72
- from: [], where: null, select: [], orderBy: null, consumer: "collect", follow: null, frontier: null,
79
+ from: [], where: null, select: [], orderBy: null, consumer: "collect", follow: null, frontier: null, groupBy: null, having: null,
73
80
  distinct: false, values: false, limit: null, offset: null,
74
81
  ...parts,
75
82
  };
package/src/engine.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  import type {
23
23
  Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow, Subquery,
24
24
  } from "./ast.ts";
25
+ import { isRegexOp, REGEX_OPS } from "./ast.ts";
25
26
  import type { DataContext } from "./context.ts";
26
27
  import { DefaultContext } from "./context.ts";
27
28
  import { OqxError } from "./errors.ts";
@@ -64,6 +65,20 @@ interface Scope {
64
65
  run: RunState;
65
66
  lifts?: Record<string, unknown>;
66
67
  meta?: Record<string, unknown>;
68
+ /** The row as it arrived, when `row` is its unwrapped form (an entry's value):
69
+ * what the group's `rows` hands back so a re-entered row is the same row. */
70
+ raw?: unknown;
71
+ /** Set on a GROUP scope (SEMANTICS §24): the scope's row is the key record,
72
+ * `rows` the group's rows, and the aggregates reduce `rowScopes`. */
73
+ group?: Group;
74
+ }
75
+
76
+ // One group of a `group by` body: the scopes of its rows (for the aggregates,
77
+ // which read each row as the body's `where` did — lifts and walk intrinsics
78
+ // included) and the rows themselves (what `rows` yields).
79
+ interface Group {
80
+ rowScopes: Scope[];
81
+ rows: unknown[];
67
82
  }
68
83
 
69
84
  // State that lives for exactly one `run()` (nothing here outlives it, so data
@@ -116,11 +131,14 @@ export interface InMemoryEngineOptions {
116
131
 
117
132
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
118
133
  const KEY = "$key";
134
+ const ROWS = "rows";
119
135
  const HARD_DEPTH_CAP = 8;
120
136
 
121
137
  // What a scope projects to: the select list plus the `values` mode flag. Both
122
138
  // `Query` and `Subquery` carry this shape.
123
139
  type Projection = Pick<Subquery, "select" | "values">;
140
+ // The grouping clauses of a body (0.24). Both `Query` and `Subquery` carry them.
141
+ type Grouping = Pick<Subquery, "groupBy" | "having">;
124
142
 
125
143
  // The `$key: expr` item of a projection, if any — the identity the rows it
126
144
  // yields carry (SEMANTICS §16, since 0.20).
@@ -171,10 +189,11 @@ export class InMemoryEngine implements Engine {
171
189
  if (query.follow) return this.runFollow(query, rows, root, bound);
172
190
 
173
191
  // Consumer-directed short-circuits (skipped under `distinct`, which must
174
- // materialize + dedup by projection before reducing). Counting never
175
- // materializes rows; exists/none stop as soon as the bound is known to be
176
- // non-empty (the (offset+1)th match — or the first, when unbounded).
177
- if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
192
+ // materialize + dedup by projection before reducing, and under `group by`,
193
+ // whose consumer counts groups). Counting never materializes rows;
194
+ // exists/none stop as soon as the bound is known to be non-empty (the
195
+ // (offset+1)th match — or the first, when unbounded).
196
+ if (!query.distinct && !query.groupBy && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
178
197
  const need = query.consumer === "count" ? Infinity : bound.offset + 1;
179
198
  let n = 0;
180
199
  for (const r of rows) {
@@ -189,7 +208,7 @@ export class InMemoryEngine implements Engine {
189
208
  // first over an unordered, non-distinct set needs only the rows up to the
190
209
  // bound (offset + 1). single materializes everything so its error can
191
210
  // report how many rows actually matched.
192
- const cap = query.consumer === "first" && !query.orderBy && !query.distinct
211
+ const cap = query.consumer === "first" && !query.orderBy && !query.distinct && !query.groupBy
193
212
  ? bound.offset + Math.min(1, bound.limit ?? 1)
194
213
  : Infinity;
195
214
  let kept: Scope[] = [];
@@ -200,6 +219,7 @@ export class InMemoryEngine implements Engine {
200
219
  if (kept.length >= cap) break;
201
220
  }
202
221
  }
222
+ if (query.groupBy) kept = this.groupScopes(kept, query, root);
203
223
  this.applyKey(kept, query);
204
224
  this.sortScopes(kept, query.orderBy);
205
225
  if (query.distinct) kept = this.dedupByProjection(kept, query);
@@ -302,12 +322,101 @@ export class InMemoryEngine implements Engine {
302
322
  if (extra.lifts) s.lifts = extra.lifts;
303
323
  if (extra.meta) s.meta = extra.meta;
304
324
  if (isEntry(row)) {
325
+ s.raw = row;
305
326
  s.row = row.value;
306
327
  s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
307
328
  }
308
329
  return s;
309
330
  }
310
331
 
332
+ // ---- grouping (SEMANTICS §24) ----------------------------------------------
333
+
334
+ // The kept scopes of a body, one GROUP scope per distinct key tuple, in order
335
+ // of first appearance; then `having` over each group. A group scope sits where
336
+ // the rows did (same parent, same depth): its row is the key record — one
337
+ // property per key, in key order, holding the first row's value — so bare
338
+ // names read the keys, `$it` is the record and `$key` its structural identity;
339
+ // `rows` yields the group's rows and the aggregates reduce their scopes. Key
340
+ // values compare as identities (§16): absent ≡ null, a host row by its id,
341
+ // anything else structurally.
342
+ private groupScopes(scopes: Scope[], body: Grouping, parent: Scope): Scope[] {
343
+ const g = body.groupBy!;
344
+ const groups = new Map<string, Scope>();
345
+ for (const s of scopes) {
346
+ const record: Record<string, unknown> = {};
347
+ const ids: string[] = [];
348
+ for (const k of g.keys) {
349
+ const v = this.evalExpr(k.expr, s);
350
+ record[k.name] = v;
351
+ ids.push(canonicalKey(this.hostIdentity(v)));
352
+ }
353
+ const id = ids.join("");
354
+ let gs = groups.get(id);
355
+ if (!gs) {
356
+ gs = { row: record, parent, bindings: parent.bindings, run: parent.run, lifts: {}, group: { rowScopes: [], rows: [] } };
357
+ groups.set(id, gs);
358
+ }
359
+ gs.group!.rowScopes.push(s);
360
+ gs.group!.rows.push(s.raw !== undefined ? s.raw : s.row);
361
+ }
362
+ const out = [...groups.values()];
363
+ return body.having ? out.filter((gs) => this.evalWhere(body.having!, gs)) : out;
364
+ }
365
+
366
+ // An aggregate (§24) over the group of the scope it is read in. The argument
367
+ // is read on each row's scope in group order; every value aggregate sees the
368
+ // PRESENT values only (`count()` alone counts rows), `distinct` dedups them by
369
+ // identity (§16), and `any`/`all` test truthiness (absent is false) and stop
370
+ // at the first row that decides, like `||` / `&&`.
371
+ private aggregate(e: Extract<Expr, { kind: "aggregate" }>, scope: Scope): unknown {
372
+ const g = scope.group;
373
+ if (!g) throw new OqxError(`\`${describe(e)}\` is an aggregate and is read on a group — it needs a \`group by\` in its body`, "eval");
374
+ if (e.arg === null) return g.rowScopes.length;
375
+ const arg = e.arg;
376
+ if (e.name === "any") return g.rowScopes.some((s) => truthy(this.evalExpr(arg, s)));
377
+ if (e.name === "all") return g.rowScopes.every((s) => truthy(this.evalExpr(arg, s)));
378
+ let values: unknown[] = [];
379
+ for (const s of g.rowScopes) {
380
+ const v = this.evalExpr(arg, s);
381
+ if (v != null) values.push(v);
382
+ }
383
+ if (e.distinct) {
384
+ const seen = new Set<string>();
385
+ values = values.filter((v) => {
386
+ const k = canonicalKey(this.hostIdentity(v));
387
+ if (seen.has(k)) return false;
388
+ seen.add(k);
389
+ return true;
390
+ });
391
+ }
392
+ switch (e.name) {
393
+ case "count": return values.length;
394
+ case "collect": return values;
395
+ case "first": return values.length > 0 ? values[0] : undefined;
396
+ case "last": return values.length > 0 ? values[values.length - 1] : undefined;
397
+ case "sum": case "avg": {
398
+ let total = 0;
399
+ for (const v of values) {
400
+ if (typeof v !== "number") {
401
+ throw new OqxError(`cannot ${e.name === "sum" ? "sum" : "average"} \`${describe(arg)}\` over the group: it is ${describeValueKind(v)} — \`${e.name}\` takes numbers`, "eval");
402
+ }
403
+ total += v;
404
+ }
405
+ if (e.name === "sum") return total;
406
+ return values.length === 0 ? undefined : total / values.length;
407
+ }
408
+ case "min": case "max": {
409
+ if (values.length === 0) return undefined;
410
+ let best = values[0];
411
+ for (let i = 1; i < values.length; i++) {
412
+ const c = compare(values[i], best);
413
+ if (c !== undefined && (e.name === "min" ? c < 0 : c > 0)) best = values[i];
414
+ }
415
+ return best;
416
+ }
417
+ }
418
+ }
419
+
311
420
  // ---- follow ---------------------------------------------------------------
312
421
 
313
422
  private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
@@ -316,6 +425,7 @@ export class InMemoryEngine implements Engine {
316
425
  const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
317
426
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
318
427
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
428
+ if (query.groupBy) scopes = this.groupScopes(scopes, query, root);
319
429
  this.sortScopes(scopes, query.orderBy);
320
430
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
321
431
  scopes = sliceBound(scopes, bound);
@@ -539,9 +649,10 @@ export class InMemoryEngine implements Engine {
539
649
  // rows, and the consumer only needs their count after the bound.
540
650
  n = boundedCount(acc.rows.length, bound);
541
651
  scope.run.trace?.({ kind: "cardinality", count: n });
542
- } else if (op.op !== "count" && bound === UNBOUNDED) {
652
+ } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy) {
543
653
  // Unbounded exists/none: stop at the first match (dedup cannot change
544
- // emptiness). Bounded: the offset/limit decide emptiness, so materialize.
654
+ // emptiness). Bounded, or grouped (`having` may empty the groups): the
655
+ // offset/limit decide emptiness, so materialize.
545
656
  n = this.anyMatch(op, scope, acc) ? 1 : 0;
546
657
  } else n = this.opRows(op, scope, bound, acc).length;
547
658
  if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
@@ -566,9 +677,10 @@ export class InMemoryEngine implements Engine {
566
677
  return this.opRows(op, scope, bound).length > 0;
567
678
  }
568
679
 
569
- // The rows a consumer op reduces: matched → ordered → distinct → bounded.
680
+ // The rows a consumer op reduces: matched → grouped → ordered → distinct → bounded.
570
681
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
571
682
  let scopes = this.matchRows(op, scope, acc);
683
+ if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
572
684
  this.applyKey(scopes, op.sub);
573
685
  this.sortScopes(scopes, op.sub.orderBy);
574
686
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
@@ -757,6 +869,8 @@ export class InMemoryEngine implements Engine {
757
869
  // Over a single receiver (one object) a `collect` is the record or absent,
758
870
  // not a one-element list (SEMANTICS §3).
759
871
  case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
872
+ // `count { … }` as a value (0.24): the number of rows the block yields.
873
+ case "count": return scopes.length;
760
874
  default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : null;
761
875
  }
762
876
  }
@@ -777,6 +891,7 @@ export class InMemoryEngine implements Engine {
777
891
  const { rows, where } = this.access(op, plan, scope);
778
892
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
779
893
  scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
894
+ if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
780
895
  this.sortScopes(scopes, sub.orderBy);
781
896
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
782
897
  scopes = sliceBound(scopes, bound);
@@ -786,7 +901,7 @@ export class InMemoryEngine implements Engine {
786
901
  scopes = this.opRows(op, scope, bound, acc);
787
902
  }
788
903
  switch (op.op) {
789
- case "collect": return { scopes, single };
904
+ case "collect": case "count": return { scopes, single };
790
905
  case "first": return { scopes: scopes.slice(0, 1), single };
791
906
  case "single":
792
907
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
@@ -877,6 +992,15 @@ export class InMemoryEngine implements Engine {
877
992
  const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
878
993
  return e.op === "is" ? same : !same;
879
994
  }
995
+ if (isRegexOp(e.op)) {
996
+ // The regex operators (SEMANTICS §11, since 0.22) are sugar over
997
+ // `matches`: `a ~ b` is `a.matches(b)`, `~*` adds the `i` flag, `!~`
998
+ // / `!~*` negate. They go through the same method dispatch as the
999
+ // call would, so a host's `matches` (and its regex dialect) applies.
1000
+ const { negated, flags } = REGEX_OPS[e.op];
1001
+ const hit = this.callMethod("matches", l, flags === undefined ? [r] : [r, flags]);
1002
+ return negated ? !truthy(hit) : hit;
1003
+ }
880
1004
  return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
881
1005
  }
882
1006
  case "logical": {
@@ -924,6 +1048,8 @@ export class InMemoryEngine implements Engine {
924
1048
  }
925
1049
  return absent ? undefined : out;
926
1050
  }
1051
+ // An aggregate (SEMANTICS §24) over the group of the current scope.
1052
+ case "aggregate": return this.aggregate(e, scope);
927
1053
  }
928
1054
  }
929
1055
 
@@ -955,6 +1081,8 @@ export class InMemoryEngine implements Engine {
955
1081
  if (name === "$it") return scope.row;
956
1082
  // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
957
1083
  if (name === KEY) return this.scopeIdentity(scope);
1084
+ // `rows` on a group scope is the group's rows (§24); elsewhere a property.
1085
+ if (name === ROWS && scope.group) return scope.group.rows;
958
1086
  if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
959
1087
  if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
960
1088
  if (scope.parent === null) return this.ctx.root(name);
@@ -978,9 +1106,15 @@ export class InMemoryEngine implements Engine {
978
1106
  throw new OqxError(`unknown function '${e.name}(…)'`, "eval");
979
1107
  }
980
1108
  const recv = this.evalExpr(e.recv, scope);
981
- const r = this.ctx.callMethod?.(e.name, recv, args);
1109
+ return this.callMethod(e.name, recv, args);
1110
+ }
1111
+
1112
+ // `recv.name(args)` on evaluated values — the dispatch a method call and the
1113
+ // regex operators share.
1114
+ private callMethod(name: string, recv: unknown, args: unknown[]): unknown {
1115
+ const r = this.ctx.callMethod?.(name, recv, args);
982
1116
  if (r?.handled) return r.value;
983
- throw new OqxError(`unknown method '.${e.name}(…)'`, "eval");
1117
+ throw new OqxError(`unknown method '.${name}(…)'`, "eval");
984
1118
  }
985
1119
  }
986
1120
 
@@ -1074,6 +1208,14 @@ function describe(e: Expr): string {
1074
1208
  return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
1075
1209
  }
1076
1210
 
1211
+ // The kind of a value, for an aggregate's error: `a string ("abc")`, `a list`, …
1212
+ function describeValueKind(v: unknown): string {
1213
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return `a ${typeof v} (${JSON.stringify(v)})`;
1214
+ if (isRange(v)) return "a range";
1215
+ if (Array.isArray(v) || (typeof v === "object" && v !== null && !isEntry(v) && Symbol.iterator in v)) return "a list";
1216
+ return "a record";
1217
+ }
1218
+
1077
1219
  function describeReceiver(e: Expr): string {
1078
1220
  if (e.kind === "ident") return e.name;
1079
1221
  if (e.kind === "member") return `${describeReceiver(e.recv)}.${e.name}`;
@@ -1114,6 +1256,7 @@ function exprHasRecur(e: Expr): boolean {
1114
1256
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1115
1257
  case "tuple": return e.items.some(exprHasRecur);
1116
1258
  case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
1259
+ case "aggregate": return e.arg !== null && exprHasRecur(e.arg);
1117
1260
  default: return false;
1118
1261
  }
1119
1262
  }
package/src/index.ts CHANGED
@@ -53,11 +53,13 @@ export { IndexedCollection } from "./adapters/indexed.ts";
53
53
  export { ROWS_ROOT, partitionPushable, residualQuery, asEquality, isConst, constValue } from "./plan.ts";
54
54
  export * as semantics from "./semantics.ts";
55
55
  export type * from "./ast.ts";
56
- export { isExpr } from "./ast.ts";
56
+ export { isExpr, isRegexOp, REGEX_OPS, isAggregateName, AGGREGATE_NAMES } from "./ast.ts";
57
57
  export { parseTemplate } from "./parser.ts";
58
58
  export { rawSource, toUtf16, codePointLength } from "./lexer.ts";
59
59
  export { resolveAliases } from "./resolve.ts";
60
60
  export type { Clause, VisitContext, Visitor, ChildKey, Stripped } from "./walk.ts";
61
+ export type { Reference, ReferenceScope, ReferenceStep } from "./references.ts";
62
+ export { references } from "./references.ts";
61
63
  export { CHILDREN, visit, transform, stripSpans, toJSON } from "./walk.ts";
62
64
  export type { Template } from "./print.ts";
63
65
  export { print, printTemplate } from "./print.ts";
package/src/lexer.ts CHANGED
@@ -44,7 +44,7 @@ export type TokType =
44
44
  | "caret" // ^ — one-scope lift marker
45
45
  | "dot"
46
46
  | "range" // `..` (inclusive) or `...` (exclusive end) — a Ruby-style range operator (value carries which)
47
- | "op" // == != <= >= < > && || ! + - * / % (value carries the operator)
47
+ | "op" // == != <= >= < > && || ! + - * / % ~ ~* !~ !~* (value carries the operator)
48
48
  | "binding" // a ${…} interpolation; `index` names the value slot
49
49
  // A template literal (since 0.21): the opening and closing backticks, a run of
50
50
  // its decoded text, and the `${` / `}` around an interpolation — whose `}` is a
@@ -72,8 +72,12 @@ export interface Token {
72
72
  const KEYWORDS = new Set(["from", "where", "select", "is", "not", "and", "or"]);
73
73
 
74
74
  // Multi-char operators, longest first (the scanner tries these before singles).
75
- const MULTI_OPS = ["==", "!=", "<=", ">=", "&&", "||"];
76
- const SINGLE_OPS = new Set(["<", ">", "!", "+", "-", "*", "/", "%"]);
75
+ // The regex operators (since 0.22) are among them: `!~*` is tried before `!~`,
76
+ // which is tried before `!` — so `name!~"B"` is `name !~ "B"`, not a required
77
+ // `name!` — and `~*` before `~`, so `x~*y` is `x ~* y`.
78
+ const TRIPLE_OPS = ["!~*"];
79
+ const MULTI_OPS = ["==", "!=", "<=", ">=", "&&", "||", "!~", "~*"];
80
+ const SINGLE_OPS = new Set(["<", ">", "!", "+", "-", "*", "/", "%", "~"]);
77
81
 
78
82
  const isIdentStart = (c: string): boolean => /^[A-Za-z_$]$/.test(c);
79
83
  const isIdentPart = (c: string): boolean => /^[A-Za-z0-9_$]$/.test(c);
@@ -246,7 +250,9 @@ function lexFragment(src: string, base: number, out: Token[]): void {
246
250
  continue;
247
251
  }
248
252
 
249
- // operators (multi-char first)
253
+ // operators (multi-char first, longest first)
254
+ const three = c + at(i + 1) + at(i + 2);
255
+ if (TRIPLE_OPS.includes(three)) { push("op", three, i, i + 3); i += 3; continue; }
250
256
  const two = c + at(i + 1);
251
257
  if (MULTI_OPS.includes(two)) { push("op", two, i, i + 2); i += 2; continue; }
252
258
  if (SINGLE_OPS.has(c)) { push("op", c, i, i + 1); i++; continue; }
@@ -16,6 +16,7 @@
16
16
  // value into an enclosing scope (§19), observable by later conjuncts.
17
17
 
18
18
  import type { Expr, Follow, OpNode, SelectItem, Subquery, Where } from "../ast.ts";
19
+ import { isRegexOp } from "../ast.ts";
19
20
  import type { RuleContext } from "./ir.ts";
20
21
 
21
22
  // ---- scope reads --------------------------------------------------------------
@@ -36,6 +37,8 @@ export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
36
37
  case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
37
38
  case "tuple": return e.items.some((x) => exprReadsScopeIn(x, at, upTo));
38
39
  case "template": return e.parts.some((p) => typeof p !== "string" && exprReadsScopeIn(p, at, upTo));
40
+ // The argument is read on the group's rows, which sit at the group's depth.
41
+ case "aggregate": return e.arg !== null && exprReadsScopeIn(e.arg, at, upTo);
39
42
  }
40
43
  }
41
44
 
@@ -69,6 +72,9 @@ function subReadsScopeIn(sub: Subquery, at: number, upTo: number, destination: b
69
72
  if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
70
73
  if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
71
74
  if (sub.frontier && exprReadsScopeIn(sub.frontier, destination ? at - 1 : at, upTo)) return true;
75
+ // `group by` keys are read on the rows, `having` on the groups — both at `at` (0.24).
76
+ if (sub.groupBy?.keys.some((k) => exprReadsScopeIn(k.expr, at, upTo))) return true;
77
+ if (sub.having && whereReadsScopeIn(sub.having, at, upTo)) return true;
72
78
  return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
73
79
  }
74
80
 
@@ -99,12 +105,18 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
99
105
  case "op": return opRaiseFree(e, ctx); // a value-position directive projects: never raise-free
100
106
  case "member": return exprRaiseFree(e.recv, ctx);
101
107
  case "unary": return exprRaiseFree(e.expr, ctx);
102
- case "binary": case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
108
+ // A regex operator (0.22) is a `matches` call in disguise: the pattern may
109
+ // be invalid, so it is never raise-free.
110
+ case "binary": return !isRegexOp(e.op) && exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
111
+ case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
103
112
  case "range": return (e.lo === null || exprRaiseFree(e.lo, ctx)) && (e.hi === null || exprRaiseFree(e.hi, ctx));
104
113
  case "tuple": return e.items.every((x) => exprRaiseFree(x, ctx));
105
114
  // A template literal raises on a list or record interpolation (§7b), which
106
115
  // only the data decides: with an interpolation it is never raise-free.
107
116
  case "template": return e.parts.every((p) => typeof p === "string");
117
+ // `sum`/`avg` raise on a non-number (§24), which only the data decides;
118
+ // and an aggregate outside a group scope raises.
119
+ case "aggregate": return false;
108
120
  }
109
121
  }
110
122
 
@@ -129,7 +141,9 @@ export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
129
141
  export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
130
142
  if (op.op !== "exists" && op.op !== "none" && op.op !== "count") return false;
131
143
  const sub = op.sub;
132
- if (sub.follow || sub.limit || sub.offset || op.distinct) return false;
144
+ // A grouped block evaluates its keys, `having` and the aggregates of its
145
+ // projection per group (a `sum` over a string raises): never raise-free.
146
+ if (sub.follow || sub.limit || sub.offset || op.distinct || sub.groupBy) return false;
133
147
  if (!exprRaiseFree(op.receiver, ctx)) return false;
134
148
  if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
135
149
  if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
@@ -148,6 +162,7 @@ export function opHasLifts(op: OpNode): boolean {
148
162
  function subHasLifts(sub: Subquery): boolean {
149
163
  if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
150
164
  if (sub.where && whereHasLifts(sub.where)) return true;
165
+ if (sub.having && whereHasLifts(sub.having)) return true;
151
166
  if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
152
167
  return false;
153
168
  }
@@ -10,7 +10,7 @@
10
10
  // cannot be built, a receiver that is not stable) always degrade to the plain
11
11
  // scan the plan started from.
12
12
 
13
- import type { Consumer, Expr, Follow, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
13
+ import type { Consumer, Expr, Follow, GroupBy, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
14
14
 
15
15
  /** One hoisted equality `local == outer` from the block's top-level `&&`
16
16
  * conjunction: `local` reads only the block's row (an identifier or member
@@ -45,6 +45,10 @@ export interface BlockPlan {
45
45
  readonly distinct: boolean;
46
46
  readonly bound: { readonly limit: Expr | null; readonly offset: Expr | null };
47
47
  readonly follow: Follow | null;
48
+ /** `group by` / `having` (0.24): the matched rows are grouped before the
49
+ * consumer sees them, so a consumer counts GROUPS, never rows. */
50
+ readonly groupBy: GroupBy | null;
51
+ readonly having: Where | null;
48
52
  /** Equalities answered by an index probe on the receiver (in conjunct order). */
49
53
  readonly correlated: readonly Correlation[];
50
54
  /** The conjuncts still evaluated per row once the probe has selected its
@@ -90,6 +94,8 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
90
94
  distinct: node.distinct,
91
95
  bound: { limit: sub.limit, offset: sub.offset },
92
96
  follow: sub.follow,
97
+ groupBy: sub.groupBy,
98
+ having: sub.having,
93
99
  correlated: [],
94
100
  residual: sub.where,
95
101
  receiverStable: false,
@@ -112,8 +112,9 @@ export const invariantBlock: Rule = (plan) => {
112
112
  //
113
113
  // Precondition. A where-position `exists`, `none` or `count` without `distinct`
114
114
  // (dedup projects every row), without `order by` (`count` evaluates the keys),
115
- // without `follow`, and with nothing left to evaluate per row: either the block
116
- // has no `where`, or every conjunct is correlated (so the probe's bucket is the
115
+ // without `follow`, without `group by` (the consumer counts groups, not rows —
116
+ // 0.24), and with nothing left to evaluate per row: either the block has no
117
+ // `where`, or every conjunct is correlated (so the probe's bucket is the
117
118
  // matched set).
118
119
  //
119
120
  // Argument. With no residual predicate the matched rows are exactly the
@@ -127,7 +128,7 @@ export const invariantBlock: Rule = (plan) => {
127
128
  export const cardinalityOnly: Rule = (plan) => {
128
129
  if (plan.fromCardinality) return null;
129
130
  if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count") return null;
130
- if (plan.distinct || plan.orderBy || plan.follow) return null;
131
+ if (plan.distinct || plan.orderBy || plan.follow || plan.groupBy) return null;
131
132
  if (plan.where !== null && plan.residual !== null) return null;
132
133
  return { ...plan, fromCardinality: true };
133
134
  };