@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.
- package/CHANGELOG.md +17 -0
- package/README.md +79 -2
- package/dist/ast.d.ts +48 -3
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +17 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +7 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +9 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +3 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +153 -11
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +13 -3
- package/dist/lexer.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +19 -2
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +5 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +2 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +4 -3
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +406 -21
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +17 -0
- package/dist/print.js.map +1 -1
- package/dist/references.d.ts +28 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +287 -0
- package/dist/references.js.map +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +20 -1
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +1 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +10 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +64 -4
- package/src/build.ts +11 -4
- package/src/engine.ts +154 -11
- package/src/index.ts +3 -1
- package/src/lexer.ts +10 -4
- package/src/optimize/analysis.ts +17 -2
- package/src/optimize/ir.ts +7 -1
- package/src/optimize/rules.ts +4 -3
- package/src/parser.ts +328 -21
- package/src/print.ts +19 -3
- package/src/references.ts +293 -0
- package/src/resolve.ts +22 -2
- package/src/version.ts +1 -1
- 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
|
|
30
|
-
|
|
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,
|
|
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,
|
|
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
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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 '.${
|
|
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
|
-
|
|
76
|
-
|
|
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; }
|
package/src/optimize/analysis.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/optimize/ir.ts
CHANGED
|
@@ -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,
|
package/src/optimize/rules.ts
CHANGED
|
@@ -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`,
|
|
116
|
-
//
|
|
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
|
};
|