@omgbase/oqx 0.1.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/README.md +424 -0
- package/package.json +31 -0
- package/src/adapters/indexed.ts +61 -0
- package/src/adapters/sqlite.ts +120 -0
- package/src/ast.ts +87 -0
- package/src/context.ts +79 -0
- package/src/engine.ts +406 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +90 -0
- package/src/lexer.ts +175 -0
- package/src/parser.ts +517 -0
- package/src/plan.ts +58 -0
- package/src/planner.ts +47 -0
- package/src/semantics.ts +131 -0
package/src/lexer.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// OQX structural lexer for the generic kernel.
|
|
2
|
+
//
|
|
3
|
+
// Unlike the reference lexer (which slices raw source for the CEL layer), this
|
|
4
|
+
// one fully tokenizes: the parser builds an evaluable expression AST directly
|
|
5
|
+
// from the token stream, so there is no source-slicing seam.
|
|
6
|
+
//
|
|
7
|
+
// Tagged-template bindings are lexed as first-class tokens. Each template string
|
|
8
|
+
// FRAGMENT is lexed independently and a synthetic `binding` token is injected
|
|
9
|
+
// between adjacent fragments. A binding therefore can never span a token or
|
|
10
|
+
// alter the grammar — the prepared-statement / injection-safe property the
|
|
11
|
+
// host-bindings design note calls for. (A consequence: `${x}` inside a string
|
|
12
|
+
// literal does not interpolate — that fragment would be an unterminated string —
|
|
13
|
+
// which is exactly the desired "interpolation is a value, never source text".)
|
|
14
|
+
|
|
15
|
+
import { OqxError } from "./errors.ts";
|
|
16
|
+
|
|
17
|
+
export type TokType =
|
|
18
|
+
| "ident"
|
|
19
|
+
| "kw" // from | where | select
|
|
20
|
+
| "string"
|
|
21
|
+
| "number"
|
|
22
|
+
| "lparen"
|
|
23
|
+
| "rparen"
|
|
24
|
+
| "lbrace"
|
|
25
|
+
| "rbrace"
|
|
26
|
+
| "comma"
|
|
27
|
+
| "colon"
|
|
28
|
+
| "caret" // ^ — one-scope lift marker
|
|
29
|
+
| "dot"
|
|
30
|
+
| "op" // == != <= >= < > && || ! + - * / % (value carries the operator)
|
|
31
|
+
| "binding" // a ${…} interpolation; `index` names the value slot
|
|
32
|
+
| "eof";
|
|
33
|
+
|
|
34
|
+
export interface Token {
|
|
35
|
+
type: TokType;
|
|
36
|
+
value: string;
|
|
37
|
+
pos: number;
|
|
38
|
+
index?: number; // binding tokens only
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const KEYWORDS = new Set(["from", "where", "select"]);
|
|
42
|
+
|
|
43
|
+
// Multi-char operators, longest first (the scanner tries these before singles).
|
|
44
|
+
const MULTI_OPS = ["==", "!=", "<=", ">=", "&&", "||"];
|
|
45
|
+
const SINGLE_OPS = new Set(["<", ">", "!", "+", "-", "*", "/", "%"]);
|
|
46
|
+
|
|
47
|
+
const isIdentStart = (c: string): boolean => /[A-Za-z_$]/.test(c);
|
|
48
|
+
const isIdentPart = (c: string): boolean => /[A-Za-z0-9_$]/.test(c);
|
|
49
|
+
const isDigit = (c: string): boolean => c >= "0" && c <= "9";
|
|
50
|
+
|
|
51
|
+
/** Lex a tagged-template call: the cooked string fragments and the count of
|
|
52
|
+
* interpolated values. Emits a single flat token stream with `binding` tokens
|
|
53
|
+
* (index 0..values-1) between fragments, terminated by `eof`. */
|
|
54
|
+
export function lexTemplate(fragments: readonly string[], values: number): Token[] {
|
|
55
|
+
const tokens: Token[] = [];
|
|
56
|
+
let base = 0; // running offset across fragments + rendered `${…}` markers
|
|
57
|
+
for (let f = 0; f < fragments.length; f++) {
|
|
58
|
+
lexFragment(fragments[f]!, base, tokens);
|
|
59
|
+
base += fragments[f]!.length;
|
|
60
|
+
if (f < fragments.length - 1) {
|
|
61
|
+
// account for the value's rendered width in the display source (see rawSource)
|
|
62
|
+
const marker = `\${${f}}`;
|
|
63
|
+
tokens.push({ type: "binding", value: marker, pos: base, index: f });
|
|
64
|
+
base += marker.length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (values !== fragments.length - 1) {
|
|
68
|
+
throw new OqxError(`template arity mismatch: ${fragments.length} fragments, ${values} values`, "lex");
|
|
69
|
+
}
|
|
70
|
+
tokens.push({ type: "eof", value: "", pos: base });
|
|
71
|
+
return tokens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Lex a plain string (no bindings) — used by the string entry point. */
|
|
75
|
+
export function lexString(src: string): Token[] {
|
|
76
|
+
const tokens: Token[] = [];
|
|
77
|
+
lexFragment(src, 0, tokens);
|
|
78
|
+
tokens.push({ type: "eof", value: "", pos: src.length });
|
|
79
|
+
return tokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function lexFragment(src: string, base: number, out: Token[]): void {
|
|
83
|
+
let i = 0;
|
|
84
|
+
const n = src.length;
|
|
85
|
+
const push = (type: TokType, value: string, at: number): void => {
|
|
86
|
+
out.push({ type, value, pos: base + at });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
while (i < n) {
|
|
90
|
+
const c = src[i]!;
|
|
91
|
+
|
|
92
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
|
|
93
|
+
|
|
94
|
+
if (c === "(") { push("lparen", c, i); i++; continue; }
|
|
95
|
+
if (c === ")") { push("rparen", c, i); i++; continue; }
|
|
96
|
+
if (c === "{") { push("lbrace", c, i); i++; continue; }
|
|
97
|
+
if (c === "}") { push("rbrace", c, i); i++; continue; }
|
|
98
|
+
if (c === ",") { push("comma", c, i); i++; continue; }
|
|
99
|
+
if (c === ":") { push("colon", c, i); i++; continue; }
|
|
100
|
+
if (c === "^") { push("caret", c, i); i++; continue; }
|
|
101
|
+
|
|
102
|
+
// `.` is a dot only when not the leading part of a number (.5) — but OQX has
|
|
103
|
+
// no leading-dot numerals, so a bare `.` is always navigation.
|
|
104
|
+
if (c === "." && !isDigit(src[i + 1] ?? "")) { push("dot", c, i); i++; continue; }
|
|
105
|
+
|
|
106
|
+
// string literal — decode into its VALUE (quotes stripped, escapes resolved).
|
|
107
|
+
if (c === '"' || c === "'") {
|
|
108
|
+
const quote = c;
|
|
109
|
+
const start = i;
|
|
110
|
+
i++;
|
|
111
|
+
let sval = "";
|
|
112
|
+
while (i < n && src[i] !== quote) {
|
|
113
|
+
if (src[i] === "\\") {
|
|
114
|
+
i++;
|
|
115
|
+
sval += unescape(src[i]);
|
|
116
|
+
} else {
|
|
117
|
+
sval += src[i];
|
|
118
|
+
}
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
if (i >= n) throw new OqxError(`unterminated string literal at ${base + start}`, "lex");
|
|
122
|
+
i++; // closing quote
|
|
123
|
+
out.push({ type: "string", value: sval, pos: base + start });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// number (integer or decimal, optional exponent)
|
|
128
|
+
if (isDigit(c) || (c === "." && isDigit(src[i + 1] ?? ""))) {
|
|
129
|
+
const start = i;
|
|
130
|
+
while (i < n && isDigit(src[i]!)) i++;
|
|
131
|
+
if (src[i] === ".") { i++; while (i < n && isDigit(src[i]!)) i++; }
|
|
132
|
+
if (src[i] === "e" || src[i] === "E") {
|
|
133
|
+
i++;
|
|
134
|
+
if (src[i] === "+" || src[i] === "-") i++;
|
|
135
|
+
while (i < n && isDigit(src[i]!)) i++;
|
|
136
|
+
}
|
|
137
|
+
push("number", src.slice(start, i), start);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// operators (multi-char first)
|
|
142
|
+
const two = src.slice(i, i + 2);
|
|
143
|
+
if (MULTI_OPS.includes(two)) { push("op", two, i); i += 2; continue; }
|
|
144
|
+
if (SINGLE_OPS.has(c)) { push("op", c, i); i++; continue; }
|
|
145
|
+
|
|
146
|
+
// identifier / keyword
|
|
147
|
+
if (isIdentStart(c)) {
|
|
148
|
+
const start = i;
|
|
149
|
+
i++;
|
|
150
|
+
while (i < n && isIdentPart(src[i]!)) i++;
|
|
151
|
+
const word = src.slice(start, i);
|
|
152
|
+
push(KEYWORDS.has(word) ? "kw" : "ident", word, start);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new OqxError(`unexpected character ${JSON.stringify(c)} at ${base + i}`, "lex");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unescape(c: string | undefined): string {
|
|
161
|
+
switch (c) {
|
|
162
|
+
case "n": return "\n";
|
|
163
|
+
case "t": return "\t";
|
|
164
|
+
case "r": return "\r";
|
|
165
|
+
case "0": return "\0";
|
|
166
|
+
case undefined: return "";
|
|
167
|
+
default: return c; // \\, \", \', \/, and anything else → the literal char
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A human-readable reconstruction of the query source, with `${N}` markers where
|
|
172
|
+
* bindings were, for error messages. */
|
|
173
|
+
export function rawSource(fragments: readonly string[]): string {
|
|
174
|
+
return fragments.map((s, i) => (i < fragments.length - 1 ? `${s}\${${i}}` : s)).join("");
|
|
175
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// OQX parser for the generic kernel. Parses query STRUCTURE (source chain,
|
|
2
|
+
// where/select/order/follow, the where-clause boolean tree, and postfix consumer
|
|
3
|
+
// directives) and, for scalar interiors, builds an evaluable expression AST with
|
|
4
|
+
// an embedded Pratt parser sharing the same token cursor.
|
|
5
|
+
//
|
|
6
|
+
// Two design-kernel rules (see the OQX syntax notes) shape the grammar:
|
|
7
|
+
// 1. Dot navigation belongs to the host object model — a receiver/source is a
|
|
8
|
+
// dotted identifier chain (or a `${…}` binding), NOT a method call.
|
|
9
|
+
// 2. Whitespace directives (collect/exists/count/first/single) belong to OQX —
|
|
10
|
+
// a consumer is `<receiver> <directive> { <block> }`, never a method.
|
|
11
|
+
//
|
|
12
|
+
// Departure from the reference parser: top-level clauses may appear in any order,
|
|
13
|
+
// so the SQL-style projection-first form `name, id from ${people} where …` is
|
|
14
|
+
// accepted (the reference requires `from` first).
|
|
15
|
+
|
|
16
|
+
import type { Token, TokType } from "./lexer.ts";
|
|
17
|
+
import { lexTemplate, lexString } from "./lexer.ts";
|
|
18
|
+
import { OqxError } from "./errors.ts";
|
|
19
|
+
import type {
|
|
20
|
+
Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, Consumer, RelOp,
|
|
21
|
+
} from "./ast.ts";
|
|
22
|
+
|
|
23
|
+
const CONSUMERS = new Set<string>(["collect", "exists", "count", "first", "single"]);
|
|
24
|
+
const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
25
|
+
const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
26
|
+
const ADD_OPS = new Set<string>(["+", "-"]);
|
|
27
|
+
const MUL_OPS = new Set<string>(["*", "/", "%"]);
|
|
28
|
+
|
|
29
|
+
/** Parse a tagged-template call into a Query. */
|
|
30
|
+
export function parseTemplate(fragments: readonly string[], values: number): Query {
|
|
31
|
+
return new Parser(lexTemplate(fragments, values)).parseQuery();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse a plain query string (no bindings) into a Query. */
|
|
35
|
+
export function parseString(src: string): Query {
|
|
36
|
+
return new Parser(lexString(src)).parseQuery();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BodyClauses {
|
|
40
|
+
froms: Expr[];
|
|
41
|
+
where: Where | null;
|
|
42
|
+
select: SelectItem[];
|
|
43
|
+
orderBy: OrderSpec[] | null;
|
|
44
|
+
follow: Follow | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class Parser {
|
|
48
|
+
private tokens: Token[];
|
|
49
|
+
private pos = 0;
|
|
50
|
+
|
|
51
|
+
constructor(tokens: Token[]) {
|
|
52
|
+
this.tokens = tokens;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- cursor helpers -------------------------------------------------------
|
|
56
|
+
private peek(): Token { return this.tokens[this.pos]!; }
|
|
57
|
+
private peekAt(n: number): Token | undefined { return this.tokens[this.pos + n]; }
|
|
58
|
+
private next(): Token { return this.tokens[this.pos++]!; }
|
|
59
|
+
private at(type: TokType, value?: string): boolean {
|
|
60
|
+
const t = this.peek();
|
|
61
|
+
return t.type === type && (value === undefined || t.value === value);
|
|
62
|
+
}
|
|
63
|
+
private atOp(value: string): boolean {
|
|
64
|
+
const t = this.peek();
|
|
65
|
+
return t.type === "op" && t.value === value;
|
|
66
|
+
}
|
|
67
|
+
private fail(msg: string): never {
|
|
68
|
+
throw new OqxError(`${msg} (at offset ${this.peek().pos})`, "parse");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- top level ------------------------------------------------------------
|
|
72
|
+
parseQuery(): Query {
|
|
73
|
+
// Directive form: `<receiver> <consumer> { … }` consuming the whole query.
|
|
74
|
+
const directive = this.tryOp();
|
|
75
|
+
if (directive && this.at("eof")) {
|
|
76
|
+
return {
|
|
77
|
+
source: directive.receiver,
|
|
78
|
+
from: directive.sub.from,
|
|
79
|
+
where: directive.sub.where,
|
|
80
|
+
select: directive.sub.select,
|
|
81
|
+
orderBy: directive.sub.orderBy,
|
|
82
|
+
consumer: directive.op,
|
|
83
|
+
follow: directive.sub.follow,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
|
|
87
|
+
|
|
88
|
+
// Body form: order-flexible clauses; the first `from` is the source.
|
|
89
|
+
const body = this.parseBody(true);
|
|
90
|
+
if (!this.at("eof")) this.fail(`unexpected ${this.tokDesc()} after the query`);
|
|
91
|
+
if (body.froms.length === 0) {
|
|
92
|
+
this.fail("a query must select a source with `from <collection>`");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
source: body.froms[0]!,
|
|
96
|
+
from: body.froms.slice(1),
|
|
97
|
+
where: body.where,
|
|
98
|
+
select: body.select,
|
|
99
|
+
orderBy: body.orderBy,
|
|
100
|
+
consumer: "collect",
|
|
101
|
+
follow: body.follow,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private tokDesc(): string {
|
|
106
|
+
const t = this.peek();
|
|
107
|
+
return t.type === "eof" ? "end of query" : `'${t.value || t.type}'`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- clause body (shared by top level and consumer blocks) ----------------
|
|
111
|
+
private parseBody(orderByAllowed: boolean): BodyClauses {
|
|
112
|
+
const froms: Expr[] = [];
|
|
113
|
+
let where: Where | null = null;
|
|
114
|
+
let select: SelectItem[] = [];
|
|
115
|
+
let orderBy: OrderSpec[] | null = null;
|
|
116
|
+
let follow: Follow | null = null;
|
|
117
|
+
let sawWhere = false, sawSelect = false, sawOrder = false;
|
|
118
|
+
|
|
119
|
+
while (!this.at("eof") && !this.at("rbrace")) {
|
|
120
|
+
if (this.atFollow()) {
|
|
121
|
+
if (follow) this.fail("duplicate `follow` clause");
|
|
122
|
+
follow = this.parseFollow();
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (this.at("kw", "from")) {
|
|
126
|
+
this.next();
|
|
127
|
+
froms.push(this.parseValueExpr());
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (this.at("kw", "where")) {
|
|
131
|
+
if (sawWhere) this.fail("duplicate `where` clause");
|
|
132
|
+
sawWhere = true;
|
|
133
|
+
this.next();
|
|
134
|
+
where = this.parseWhere();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (this.at("kw", "select") || this.at("caret")) {
|
|
138
|
+
if (sawSelect) this.fail("duplicate projection");
|
|
139
|
+
sawSelect = true;
|
|
140
|
+
if (this.at("kw")) this.next(); // consume `select`; a leading `^` is part of the item
|
|
141
|
+
select = this.parseSelectItems();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (orderByAllowed && this.atOrderBy()) {
|
|
145
|
+
if (sawOrder) this.fail("duplicate `order by` clause");
|
|
146
|
+
sawOrder = true;
|
|
147
|
+
this.next(); this.next(); // `order` `by`
|
|
148
|
+
orderBy = this.parseOrderSpecs();
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (this.looksLikePredicate()) {
|
|
152
|
+
if (sawWhere) this.fail("duplicate `where` (an implicit predicate cannot follow a `where`)");
|
|
153
|
+
sawWhere = true;
|
|
154
|
+
where = this.parseWhere();
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (this.at("ident") || this.at("binding")) {
|
|
158
|
+
if (sawSelect) this.fail("duplicate projection (an implicit select cannot follow a `select`)");
|
|
159
|
+
sawSelect = true;
|
|
160
|
+
select = this.parseSelectItems();
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/follow`);
|
|
164
|
+
}
|
|
165
|
+
return { froms, where, select, orderBy, follow };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Decide, by syntactic shape only, whether a leading unkeyworded run is a
|
|
169
|
+
// predicate (→ implicit where) or a projection (→ implicit select). A depth-0
|
|
170
|
+
// comparison / && / || / `in` before any comma/colon marks a predicate; a bare
|
|
171
|
+
// reference or a `name:`/comma projection list marks a projection.
|
|
172
|
+
private looksLikePredicate(): boolean {
|
|
173
|
+
if (this.at("op")) return true; // leading unary `!` (or a stray operator)
|
|
174
|
+
if (this.at("lparen")) return true; // a grouped boolean expression
|
|
175
|
+
let depth = 0;
|
|
176
|
+
for (let i = this.pos; i < this.tokens.length; i++) {
|
|
177
|
+
const t = this.tokens[i]!;
|
|
178
|
+
if (t.type === "eof" || t.type === "rbrace") break;
|
|
179
|
+
if (depth === 0) {
|
|
180
|
+
if (t.type === "colon" || t.type === "comma") return false;
|
|
181
|
+
if (t.type === "kw") break;
|
|
182
|
+
if (t.type === "op" && (CMP_OPS.has(t.value) || t.value === "&&" || t.value === "||")) return true;
|
|
183
|
+
if (t.type === "ident" && t.value === "in") return true;
|
|
184
|
+
if (t.type === "ident" && (t.value === "order" || t.value === "follow")) {
|
|
185
|
+
const nx = this.tokens[i + 1];
|
|
186
|
+
if (nx && nx.type === "ident") break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (t.type === "lparen") depth++;
|
|
190
|
+
else if (t.type === "rparen") { if (depth === 0) break; depth--; }
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private atOrderBy(): boolean {
|
|
196
|
+
const t = this.peek();
|
|
197
|
+
const nx = this.peekAt(1);
|
|
198
|
+
return t.type === "ident" && t.value === "order" && !!nx && nx.type === "ident" && nx.value === "by";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private atFollow(): boolean {
|
|
202
|
+
if (!this.at("ident", "follow")) return false;
|
|
203
|
+
const nx = this.peekAt(1);
|
|
204
|
+
return !!nx && (nx.type === "ident" || nx.type === "binding");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- follow ---------------------------------------------------------------
|
|
208
|
+
private parseFollow(): Follow {
|
|
209
|
+
this.next(); // `follow`
|
|
210
|
+
let distinct = false;
|
|
211
|
+
if (this.at("ident", "distinct")) {
|
|
212
|
+
const nx = this.peekAt(1);
|
|
213
|
+
if (nx && (nx.type === "ident" || nx.type === "binding")) { this.next(); distinct = true; }
|
|
214
|
+
}
|
|
215
|
+
const receiver = this.parseReceiver();
|
|
216
|
+
const follow: Follow = { receiver, distinct, where: null, frontier: null, depth: null, by: null };
|
|
217
|
+
if (!this.at("lbrace")) return follow;
|
|
218
|
+
this.next(); // '{'
|
|
219
|
+
while (!this.at("eof") && !this.at("rbrace")) {
|
|
220
|
+
if (this.at("kw", "where")) {
|
|
221
|
+
if (follow.where) this.fail("duplicate `where` in follow clause");
|
|
222
|
+
this.next();
|
|
223
|
+
follow.where = this.parseValueExpr();
|
|
224
|
+
} else if (this.at("ident", "frontier")) {
|
|
225
|
+
if (follow.frontier) this.fail("duplicate `frontier` in follow clause");
|
|
226
|
+
this.next();
|
|
227
|
+
follow.frontier = this.parseValueExpr();
|
|
228
|
+
} else if (this.at("ident", "by")) {
|
|
229
|
+
if (follow.by !== null) this.fail("duplicate `by` in follow clause");
|
|
230
|
+
this.next();
|
|
231
|
+
follow.by = this.parseValueExpr();
|
|
232
|
+
} else if (this.at("ident", "depth")) {
|
|
233
|
+
if (follow.depth !== null) this.fail("duplicate `depth` in follow clause");
|
|
234
|
+
this.next();
|
|
235
|
+
if (!this.at("number")) this.fail("expected an integer after `depth`");
|
|
236
|
+
const v = Number(this.next().value);
|
|
237
|
+
if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
|
|
238
|
+
follow.depth = v;
|
|
239
|
+
} else {
|
|
240
|
+
this.fail(`unexpected ${this.tokDesc()} in follow block — expected where/frontier/depth/by`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!this.at("rbrace")) this.fail("expected '}' to close the follow block");
|
|
244
|
+
this.next();
|
|
245
|
+
return follow;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A receiver/source: a `${…}` binding, or a dotted identifier navigation chain.
|
|
249
|
+
private parseReceiver(): Expr {
|
|
250
|
+
if (this.at("binding")) return { kind: "binding", index: this.next().index! };
|
|
251
|
+
if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
|
|
252
|
+
return this.parseNavFrom(this.next()).expr;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private parseNavFrom(head: Token): { expr: Expr; name: string } {
|
|
256
|
+
let expr: Expr = { kind: "ident", name: head.value };
|
|
257
|
+
let name = head.value;
|
|
258
|
+
while (this.at("dot")) {
|
|
259
|
+
this.next();
|
|
260
|
+
if (!this.at("ident")) this.fail("expected an identifier after '.' in a navigation");
|
|
261
|
+
name = this.next().value;
|
|
262
|
+
expr = { kind: "member", recv: expr, name };
|
|
263
|
+
}
|
|
264
|
+
return { expr, name };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- select ---------------------------------------------------------------
|
|
268
|
+
private parseSelectItems(): SelectItem[] {
|
|
269
|
+
const items = [this.parseSelectItem()];
|
|
270
|
+
while (this.at("comma")) { this.next(); items.push(this.parseSelectItem()); }
|
|
271
|
+
return items;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private parseSelectItem(): SelectItem {
|
|
275
|
+
// Leading `^`s mark a lift; the count is how many scopes out it binds.
|
|
276
|
+
let lift = 0;
|
|
277
|
+
while (this.at("caret")) { this.next(); lift++; }
|
|
278
|
+
if (!this.at("ident")) this.fail("expected a projection name");
|
|
279
|
+
const nameTok = this.next();
|
|
280
|
+
if (this.at("colon")) {
|
|
281
|
+
this.next();
|
|
282
|
+
const name = nameTok.value;
|
|
283
|
+
const op = this.tryOp();
|
|
284
|
+
if (op) {
|
|
285
|
+
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
286
|
+
this.fail(`projection '${name}' must use collect/first/single, not ${op.op}`);
|
|
287
|
+
}
|
|
288
|
+
if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
|
|
289
|
+
return { kind: "collect", name, op };
|
|
290
|
+
}
|
|
291
|
+
const expr = this.parseValueExpr();
|
|
292
|
+
return { kind: "field", name, expr, lift };
|
|
293
|
+
}
|
|
294
|
+
// bare/dotted projection: key defaults to the last navigation segment.
|
|
295
|
+
const { expr, name } = this.parseNavFrom(nameTok);
|
|
296
|
+
return { kind: "field", name, expr, lift };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- order by -------------------------------------------------------------
|
|
300
|
+
private parseOrderSpecs(): OrderSpec[] {
|
|
301
|
+
const specs = [this.parseOrderSpec()];
|
|
302
|
+
while (this.at("comma")) { this.next(); specs.push(this.parseOrderSpec()); }
|
|
303
|
+
return specs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private parseOrderSpec(): OrderSpec {
|
|
307
|
+
const expr = this.parseValueExpr();
|
|
308
|
+
let desc = false;
|
|
309
|
+
if (this.at("ident", "asc")) this.next();
|
|
310
|
+
else if (this.at("ident", "desc")) { this.next(); desc = true; }
|
|
311
|
+
return { expr, desc };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- where boolean tree: or → and → not → primary -------------------------
|
|
315
|
+
private parseWhere(): Where { return this.parseWhereOr(); }
|
|
316
|
+
|
|
317
|
+
private parseWhereOr(): Where {
|
|
318
|
+
const left = this.parseWhereAnd();
|
|
319
|
+
if (!this.atOp("||")) return left;
|
|
320
|
+
const parts = [left];
|
|
321
|
+
while (this.atOp("||")) { this.next(); parts.push(this.parseWhereAnd()); }
|
|
322
|
+
return { kind: "or", parts };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private parseWhereAnd(): Where {
|
|
326
|
+
const left = this.parseWhereNot();
|
|
327
|
+
if (!this.atOp("&&")) return left;
|
|
328
|
+
const parts = [left];
|
|
329
|
+
while (this.atOp("&&")) { this.next(); parts.push(this.parseWhereNot()); }
|
|
330
|
+
return { kind: "and", parts };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private parseWhereNot(): Where {
|
|
334
|
+
if (this.atOp("!")) { this.next(); return { kind: "not", expr: this.parseWhereNot() }; }
|
|
335
|
+
return this.parseWherePrimary();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private parseWherePrimary(): Where {
|
|
339
|
+
if (this.at("lparen")) {
|
|
340
|
+
this.next();
|
|
341
|
+
const e = this.parseWhere();
|
|
342
|
+
if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
|
|
343
|
+
this.next();
|
|
344
|
+
return e;
|
|
345
|
+
}
|
|
346
|
+
const op = this.tryOp();
|
|
347
|
+
if (op) return this.finishWhereOp(op);
|
|
348
|
+
const expr = this.parseCmp();
|
|
349
|
+
return { kind: "scalar", expr };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Validate a consumer op used in where position and attach any `count { … } <op> N`.
|
|
353
|
+
private finishWhereOp(op: OpNode): OpNode {
|
|
354
|
+
if (op.op === "first" || op.op === "single") {
|
|
355
|
+
this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } or count { … } <op> N`);
|
|
356
|
+
}
|
|
357
|
+
if (op.op === "collect") {
|
|
358
|
+
const allLift = op.sub.select.length > 0 && op.sub.select.every((s) => s.kind === "field" && s.lift > 0);
|
|
359
|
+
if (!allLift) this.fail("collect { … } in where must project only ^lift values (else use exists/count)");
|
|
360
|
+
}
|
|
361
|
+
if (this.peek().type === "op" && RELOPS.has(this.peek().value)) {
|
|
362
|
+
if (op.op !== "count") this.fail(`only count { … } is comparable; '${op.op} { … } <op> N' is not valid`);
|
|
363
|
+
const relop = this.next().value as RelOp;
|
|
364
|
+
if (!this.at("number")) this.fail(`expected an integer after 'count { … } ${relop}'`);
|
|
365
|
+
const v = Number(this.next().value);
|
|
366
|
+
if (!Number.isInteger(v)) this.fail("count comparison takes an integer");
|
|
367
|
+
op.countCmp = { op: relop, value: v };
|
|
368
|
+
}
|
|
369
|
+
return op;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }`.
|
|
373
|
+
// Returns null (rewinding) when the lookahead is not a consumer op.
|
|
374
|
+
private tryOp(): OpNode | null {
|
|
375
|
+
const start = this.pos;
|
|
376
|
+
let receiver: Expr;
|
|
377
|
+
if (this.at("binding")) receiver = { kind: "binding", index: this.next().index! };
|
|
378
|
+
else if (this.at("ident")) receiver = this.parseNavFrom(this.next()).expr;
|
|
379
|
+
else return null;
|
|
380
|
+
|
|
381
|
+
if (this.at("ident") && CONSUMERS.has(this.peek().value) && this.peekAt(1)?.type === "lbrace") {
|
|
382
|
+
const op = this.next().value as Consumer;
|
|
383
|
+
this.next(); // '{'
|
|
384
|
+
const sub = this.parseSubquery();
|
|
385
|
+
if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
|
|
386
|
+
this.next();
|
|
387
|
+
return { kind: "op", receiver, op, sub };
|
|
388
|
+
}
|
|
389
|
+
this.pos = start;
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private parseSubquery(): Subquery {
|
|
394
|
+
const body = this.parseBody(true);
|
|
395
|
+
return { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- expression Pratt parser ----------------------------------------------
|
|
399
|
+
// Value position (select/order/follow/source): full boolean+arithmetic.
|
|
400
|
+
private parseValueExpr(): Expr { return this.parseOr(); }
|
|
401
|
+
|
|
402
|
+
private parseOr(): Expr {
|
|
403
|
+
let left = this.parseAnd();
|
|
404
|
+
while (this.atOp("||")) { this.next(); left = { kind: "logical", op: "||", left, right: this.parseAnd() }; }
|
|
405
|
+
return left;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private parseAnd(): Expr {
|
|
409
|
+
let left = this.parseCmp();
|
|
410
|
+
while (this.atOp("&&")) { this.next(); left = { kind: "logical", op: "&&", left, right: this.parseCmp() }; }
|
|
411
|
+
return left;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Comparison / membership (also the entry point for a where scalar leaf, so a
|
|
415
|
+
// where leaf never swallows the where-tree's && / ||).
|
|
416
|
+
private parseCmp(): Expr {
|
|
417
|
+
let left = this.parseAdd();
|
|
418
|
+
if (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) {
|
|
419
|
+
const op = this.next().value;
|
|
420
|
+
return { kind: "binary", op, left, right: this.parseAdd() };
|
|
421
|
+
}
|
|
422
|
+
if (this.at("ident", "in")) { this.next(); return { kind: "in", left, right: this.parseAdd() }; }
|
|
423
|
+
return left;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private parseAdd(): Expr {
|
|
427
|
+
let left = this.parseMul();
|
|
428
|
+
while (this.peek().type === "op" && ADD_OPS.has(this.peek().value)) {
|
|
429
|
+
const op = this.next().value;
|
|
430
|
+
left = { kind: "binary", op, left, right: this.parseMul() };
|
|
431
|
+
}
|
|
432
|
+
return left;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private parseMul(): Expr {
|
|
436
|
+
let left = this.parseUnary();
|
|
437
|
+
while (this.peek().type === "op" && MUL_OPS.has(this.peek().value)) {
|
|
438
|
+
const op = this.next().value;
|
|
439
|
+
left = { kind: "binary", op, left, right: this.parseUnary() };
|
|
440
|
+
}
|
|
441
|
+
return left;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private parseUnary(): Expr {
|
|
445
|
+
if (this.atOp("!")) { this.next(); return { kind: "unary", op: "!", expr: this.parseUnary() }; }
|
|
446
|
+
if (this.atOp("-")) { this.next(); return { kind: "unary", op: "-", expr: this.parseUnary() }; }
|
|
447
|
+
return this.parsePostfix();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private parsePostfix(): Expr {
|
|
451
|
+
let expr = this.parsePrimary();
|
|
452
|
+
for (;;) {
|
|
453
|
+
if (this.at("dot")) {
|
|
454
|
+
this.next();
|
|
455
|
+
if (!this.at("ident")) this.fail("expected a property name after '.'");
|
|
456
|
+
const name = this.next().value;
|
|
457
|
+
if (this.at("lparen")) {
|
|
458
|
+
expr = { kind: "call", recv: expr, name, args: this.parseArgs() };
|
|
459
|
+
} else {
|
|
460
|
+
expr = { kind: "member", recv: expr, name };
|
|
461
|
+
}
|
|
462
|
+
} else if (this.at("lparen") && expr.kind === "ident") {
|
|
463
|
+
// free function call: name(args)
|
|
464
|
+
expr = { kind: "call", recv: null, name: expr.name, args: this.parseArgs() };
|
|
465
|
+
} else if (this.at("lbrace")) {
|
|
466
|
+
break; // a consumer block boundary — not part of a value expression
|
|
467
|
+
} else {
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return expr;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private parseArgs(): Expr[] {
|
|
475
|
+
this.next(); // '('
|
|
476
|
+
const args: Expr[] = [];
|
|
477
|
+
if (!this.at("rparen")) {
|
|
478
|
+
args.push(this.parseValueExpr());
|
|
479
|
+
while (this.at("comma")) { this.next(); args.push(this.parseValueExpr()); }
|
|
480
|
+
}
|
|
481
|
+
if (!this.at("rparen")) this.fail("expected ')' to close call arguments");
|
|
482
|
+
this.next();
|
|
483
|
+
return args;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private parsePrimary(): Expr {
|
|
487
|
+
const t = this.peek();
|
|
488
|
+
// `^name` / `^^name` — an outer reference reading `levels` scopes out. (As a
|
|
489
|
+
// select-item head `^name:` is a lift, handled in parseSelectItem; here, in
|
|
490
|
+
// expression position, `^` reads an enclosing row's field even when the
|
|
491
|
+
// current row shadows the name.)
|
|
492
|
+
if (t.type === "caret") {
|
|
493
|
+
let levels = 0;
|
|
494
|
+
while (this.at("caret")) { this.next(); levels++; }
|
|
495
|
+
if (!this.at("ident")) this.fail("expected an identifier after '^' (an outer reference)");
|
|
496
|
+
return { kind: "outer", levels, name: this.next().value };
|
|
497
|
+
}
|
|
498
|
+
if (t.type === "number") { this.next(); return { kind: "lit", value: Number(t.value) }; }
|
|
499
|
+
if (t.type === "string") { this.next(); return { kind: "lit", value: t.value }; }
|
|
500
|
+
if (t.type === "binding") { this.next(); return { kind: "binding", index: t.index! }; }
|
|
501
|
+
if (t.type === "lparen") {
|
|
502
|
+
this.next();
|
|
503
|
+
const e = this.parseValueExpr();
|
|
504
|
+
if (!this.at("rparen")) this.fail("expected ')'");
|
|
505
|
+
this.next();
|
|
506
|
+
return e;
|
|
507
|
+
}
|
|
508
|
+
if (t.type === "ident") {
|
|
509
|
+
this.next();
|
|
510
|
+
if (t.value === "true") return { kind: "lit", value: true };
|
|
511
|
+
if (t.value === "false") return { kind: "lit", value: false };
|
|
512
|
+
if (t.value === "null") return { kind: "lit", value: null };
|
|
513
|
+
return { kind: "ident", name: t.value };
|
|
514
|
+
}
|
|
515
|
+
this.fail(`unexpected ${this.tokDesc()} — expected a value`);
|
|
516
|
+
}
|
|
517
|
+
}
|