@omgbase/oqx 0.10.2 → 0.13.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/src/parser.ts CHANGED
@@ -9,9 +9,31 @@
9
9
  // 2. Whitespace directives (collect/exists/count/first/single) belong to OQX —
10
10
  // a consumer is `<receiver> <directive> { <block> }`, never a method.
11
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).
12
+ // Clause order is FIXED (ADR-020). Within one clause body — the top level or a
13
+ // consumer block — each clause appears at most once, in exactly this order:
14
+ //
15
+ // select <projection> from <source> where <predicate> follow <relation> {…}
16
+ // order by … limit N offset N
17
+ //
18
+ // Every clause is optional except that a top-level body needs `from` (the
19
+ // receiver-plus-consumer form `<receiver> <consumer> { block }` supplies the
20
+ // source itself, so its block's `from` is an optional re-projection). Only
21
+ // `select` may drop its keyword, and only when it is the first clause written
22
+ // (`name, age from people`); every other clause always carries its keyword, so
23
+ // a predicate is never implicit — a block filters with `where`. An out-of-order
24
+ // clause is a parse error naming the order. `where` may reference the same
25
+ // body's `select` aliases: after a body is parsed, each bare identifier in its
26
+ // `where` that names an alias is replaced by the alias's expression (a
27
+ // compile-time rewrite — see `inlineAliases`).
28
+ //
29
+ // Principle of least surprise, applied to the grammar: a rule a careful user
30
+ // would not predict is a bug. Hence `!` binds tighter than comparison in every
31
+ // position (`!a == b` is `(!a) == b`, as in C); parentheses in `where` group a
32
+ // predicate OR a scalar, decided by what follows the `)`; a range's open end
33
+ // stops at a clause word; duplicate projection names, a `follow distinct` with
34
+ // no relation, a lift outside a where-position `collect`, and a top-level
35
+ // `limit ^n` (there is no enclosing scope) are parse errors rather than silent
36
+ // misreads.
15
37
 
16
38
  import type { Token, TokType } from "./lexer.ts";
17
39
  import { lexTemplate, lexString } from "./lexer.ts";
@@ -21,6 +43,9 @@ import type {
21
43
  } from "./ast.ts";
22
44
 
23
45
  const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
46
+ // The fixed clause order of a body (ADR-020). Each clause appears at most once.
47
+ const CLAUSE_ORDER = ["select", "from", "where", "follow", "order by", "limit", "offset"] as const;
48
+ type Clause = (typeof CLAUSE_ORDER)[number];
24
49
  // Contextual clause words lexed as bare idents; an open-ended range must stop
25
50
  // before them rather than consume them as its high bound.
26
51
  const CLAUSE_WORDS = new Set<string>([
@@ -32,6 +57,20 @@ const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
32
57
  const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
33
58
  const ADD_OPS = new Set<string>(["+", "-"]);
34
59
  const MUL_OPS = new Set<string>(["*", "/", "%"]);
60
+ // `true`/`false`/`null` are literals in every position, so they can never name a
61
+ // receiver (`true exists { … }`, `follow null`).
62
+ const LITERAL_WORDS = new Set<string>(["true", "false", "null"]);
63
+
64
+ // Where a clause body sits, for error messages and position-dependent rules.
65
+ interface BodyCtx {
66
+ /** The top-level body form (a stray token there is "after the query"). */
67
+ top: boolean;
68
+ /** The consumer of the enclosing block, when not top-level. */
69
+ op: Consumer | null;
70
+ /** Whether `^name: expr` lift items are legal: only in a where-position `collect { … }`. */
71
+ liftsAllowed: boolean;
72
+ }
73
+ const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false };
35
74
 
36
75
  /** Parse a tagged-template call into a Query. */
37
76
  export function parseTemplate(fragments: readonly string[], values: number): Query {
@@ -82,7 +121,7 @@ class Parser {
82
121
  // ---- top level ------------------------------------------------------------
83
122
  parseQuery(): Query {
84
123
  // Directive form: `<receiver> <consumer> { … }` consuming the whole query.
85
- const directive = this.tryOp();
124
+ const directive = this.tryOp(false);
86
125
  if (directive && this.at("eof")) {
87
126
  return {
88
127
  source: directive.receiver,
@@ -99,11 +138,11 @@ class Parser {
99
138
  }
100
139
  if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
101
140
 
102
- // Body form: order-flexible clauses; the first `from` is the source.
103
- const body = this.parseBody(true);
104
- if (!this.at("eof")) this.fail(`unexpected ${this.tokDesc()} after the query`);
141
+ // Body form: the fixed clause list; its `from` is the source.
142
+ const body = this.parseBody(TOP_CTX);
143
+ if (!this.at("eof")) this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause`);
105
144
  if (body.froms.length === 0) {
106
- this.fail("a query must select a source with `from <collection>`");
145
+ this.fail("a query must name its source with `from <collection>` (or be `<collection> <consumer> { … }`)");
107
146
  }
108
147
  return {
109
148
  source: body.froms[0]!,
@@ -125,7 +164,11 @@ class Parser {
125
164
  }
126
165
 
127
166
  // ---- clause body (shared by top level and consumer blocks) ----------------
128
- private parseBody(orderByAllowed: boolean): BodyClauses {
167
+ // An ordered state machine over the fixed clause sequence: `stage` is the
168
+ // index (in CLAUSE_ORDER) of the last clause parsed, so a clause with a lower
169
+ // index is out of order and an equal index is a duplicate. The only
170
+ // keyword-less clause is a leading projection (while `stage` is still -1).
171
+ private parseBody(ctx: BodyCtx): BodyClauses {
129
172
  const froms: Expr[] = [];
130
173
  let where: Where | null = null;
131
174
  let select: SelectItem[] = [];
@@ -135,64 +178,114 @@ class Parser {
135
178
  let values = false;
136
179
  let limit: Expr | null = null;
137
180
  let offset: Expr | null = null;
138
- let sawWhere = false, sawSelect = false, sawOrder = false;
181
+ let stage = -1;
182
+
183
+ const enter = (clause: Clause): void => {
184
+ const idx = CLAUSE_ORDER.indexOf(clause);
185
+ if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
186
+ if (idx < stage) {
187
+ this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${CLAUSE_ORDER.join(", ")}`);
188
+ }
189
+ stage = idx;
190
+ };
139
191
 
140
192
  while (!this.at("eof") && !this.at("rbrace")) {
141
- if (this.atFollow()) {
142
- if (follow) this.fail("duplicate `follow` clause");
143
- follow = this.parseFollow();
193
+ if (this.at("kw", "select")) {
194
+ enter("select");
195
+ this.next();
196
+ if (this.at("ident", "distinct")) { this.next(); distinct = true; }
197
+ ({ items: select, values } = this.parseProjection(ctx));
144
198
  continue;
145
199
  }
146
200
  if (this.at("kw", "from")) {
201
+ enter("from");
147
202
  this.next();
148
203
  froms.push(this.parseValueExpr());
149
204
  continue;
150
205
  }
151
206
  if (this.at("kw", "where")) {
152
- if (sawWhere) this.fail("duplicate `where` clause");
153
- sawWhere = true;
207
+ enter("where");
154
208
  this.next();
155
209
  where = this.parseWhere();
156
210
  continue;
157
211
  }
158
- if (this.at("kw", "select") || this.at("caret")) {
159
- if (sawSelect) this.fail("duplicate projection");
160
- sawSelect = true;
161
- if (this.at("kw")) { this.next(); if (this.at("ident", "distinct")) { this.next(); distinct = true; } } // consume `select` + optional `distinct`; a leading `^` is part of the item
162
- ({ items: select, values } = this.parseProjection());
163
- continue;
164
- }
165
- if (this.atBound()) {
166
- const word = this.next().value;
167
- const e = this.parsePostfix();
168
- if (word === "limit") { if (limit) this.fail("duplicate `limit` clause"); limit = e; }
169
- else { if (offset) this.fail("duplicate `offset` clause"); offset = e; }
212
+ if (this.atFollow()) {
213
+ enter("follow");
214
+ follow = this.parseFollow();
170
215
  continue;
171
216
  }
172
- if (orderByAllowed && this.atOrderBy()) {
173
- if (sawOrder) this.fail("duplicate `order by` clause");
174
- sawOrder = true;
217
+ if (this.atOrderBy()) {
218
+ enter("order by");
175
219
  this.next(); this.next(); // `order` `by`
176
220
  orderBy = this.parseOrderSpecs();
177
221
  continue;
178
222
  }
179
- if (this.looksLikePredicate()) {
180
- if (sawWhere) this.fail("duplicate `where` (an implicit predicate cannot follow a `where`)");
181
- sawWhere = true;
182
- where = this.parseWhere();
223
+ if (this.atBound()) {
224
+ const word = this.peek().value as "limit" | "offset";
225
+ enter(word);
226
+ this.next();
227
+ // A top-level bound is evaluated at the root scope itself, so `^n` there
228
+ // has nothing to read: say so now instead of an "absent" error at eval.
229
+ if (ctx.top && this.at("caret")) {
230
+ this.fail(`\`${word} ^…\` at the top level has no enclosing scope — a top-level bound is a number literal or a binding; inside a block \`^name\` reads the enclosing row`);
231
+ }
232
+ const e = this.parsePostfix();
233
+ if (word === "limit") limit = e; else offset = e;
183
234
  continue;
184
235
  }
185
- if (this.at("ident") || this.at("binding")) {
186
- if (sawSelect) this.fail("duplicate projection (an implicit select cannot follow a `select`)");
187
- sawSelect = true;
188
- ({ items: select, values } = this.parseProjection());
236
+ // A keyword-less run. Before any clause it is the projection (`select` is
237
+ // the one keyword that may be dropped, and only in first position); after
238
+ // any clause it is an error — a predicate is never implicit.
239
+ if (stage === -1 && (this.at("ident") || this.at("binding") || this.at("caret") || this.canStartValue())) {
240
+ enter("select");
241
+ ({ items: select, values } = this.parseProjection(ctx));
189
242
  continue;
190
243
  }
191
- this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/limit/offset/follow`);
244
+ this.failUnexpectedInBody(stage, ctx);
192
245
  }
246
+ where = this.inlineAliases(select, where);
193
247
  return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
194
248
  }
195
249
 
250
+ // The error for a token that starts no clause, phrased for the mistake it most
251
+ // likely is: a bare run right after `from` (the old implicit `where`, or a
252
+ // consumer word where a whole-query directive was meant), or a stray token.
253
+ private failUnexpectedInBody(stage: number, ctx: BodyCtx): never {
254
+ const t = this.peek();
255
+ const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
256
+ const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
257
+ const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
258
+ // Punctuation, an operator, or a literal after a complete clause is a stray
259
+ // token, not a misplaced predicate: name where the body ends. (A comma keeps
260
+ // the projection hint below — `name from r, id` almost always meant a
261
+ // projection.)
262
+ if (last !== null && !isWord && t.type !== "comma") {
263
+ if (ctx.top) this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause (expected ${remaining} or the end of the query)`);
264
+ this.fail(`unexpected ${this.tokDesc()} in the ${ctx.op} { … } block — expected ${remaining} or '}' to close the block`);
265
+ }
266
+ // Clause words that did not form a clause: say what the clause needs.
267
+ if (last !== null && t.type === "ident") {
268
+ if (t.value === "order") this.fail(`unexpected 'order' after \`${last}\` — an ordering is written \`order by <expr> [asc|desc]\``);
269
+ if (t.value === "follow") this.fail(`unexpected 'follow' after \`${last}\` — \`follow\` needs a relation: \`follow <relation>\` or \`follow distinct <relation>\``);
270
+ if (t.value === "limit" || t.value === "offset") {
271
+ this.fail(`unexpected '${t.value}' after \`${last}\` — a bound is a non-negative number literal, a binding, or (inside a block) an outer reference \`^name\``);
272
+ }
273
+ }
274
+ if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
275
+ this.fail(`unexpected \`${t.value}\` after \`from\` — a whole-query consumer is written \`<collection> ${t.value} { … }\`; to project a field named ${t.value} write \`select ${t.value} from …\`; a predicate needs \`where\``);
276
+ }
277
+ if (last === "from") {
278
+ this.fail(`unexpected ${this.tokDesc()} after \`from\` — a predicate needs \`where\` (there is no implicit where), and a projection goes before \`from\` (\`select … from …\`); expected ${remaining}`);
279
+ }
280
+ // `{ rel exists { … } }` / `{ rel count { … } >= 2 }`: the leading `rel` was
281
+ // read as the projection, so the consumer word is where the mistake shows.
282
+ if (last === "select" && t.type === "ident" && CONSUMERS.has(t.value)) {
283
+ this.fail(`unexpected \`${t.value}\` after a projection — a consumer test is a predicate: write \`where <relation> ${t.value} { … }\` — a predicate is never implicit; a nested block in a projection needs a name (\`name: <relation> collect { … }\`)`);
284
+ }
285
+ if (last === null) this.fail(`unexpected ${this.tokDesc()} — expected a projection or ${remaining}`);
286
+ this.fail(`unexpected ${this.tokDesc()} after \`${last}\` — expected ${remaining}`);
287
+ }
288
+
196
289
  // `limit <n>` / `offset <n>` — the word must be followed by something that can
197
290
  // be a bound (a number, a binding, or an outer reference), so a field that
198
291
  // happens to be called `limit` still projects/filters as a bare name.
@@ -203,39 +296,74 @@ class Parser {
203
296
  return !!nx && (nx.type === "number" || nx.type === "binding" || nx.type === "caret");
204
297
  }
205
298
 
206
- // Decide, by syntactic shape only, whether a leading unkeyworded run is a
207
- // predicate (→ implicit where) or a projection (→ implicit select). A depth-0
208
- // comparison / && / || / `in` before any comma/colon marks a predicate; a bare
209
- // reference or a `name:`/comma projection list marks a projection.
210
- private looksLikePredicate(): boolean {
211
- if (this.at("op")) return true; // leading unary `!` (or a stray operator)
212
- if (this.at("lparen")) return true; // a grouped boolean expression
213
- let depth = 0;
214
- for (let i = this.pos; i < this.tokens.length; i++) {
215
- const t = this.tokens[i]!;
216
- if (t.type === "eof") break;
217
- // Skip nested `{ … }` consumer blocks and `( … )` groups: a keyword/select
218
- // inside them is not part of the top-level shape (e.g. `count { select x } == N`).
219
- if (t.type === "lbrace" || t.type === "lparen") { depth++; continue; }
220
- if (t.type === "rbrace" || t.type === "rparen") { if (depth === 0) break; depth--; continue; }
221
- if (depth === 0) {
222
- if (t.type === "colon" || t.type === "comma") return false;
223
- if (t.type === "kw") break;
224
- if (t.type === "op" && (CMP_OPS.has(t.value) || t.value === "&&" || t.value === "||")) return true;
225
- if (t.type === "ident" && t.value === "in") return true;
226
- // A consumer directive in where position (`… exists { … }`, `… count { … }`,
227
- // optionally `count distinct { … }`) is a predicate.
228
- if (t.type === "ident" && CONSUMERS.has(t.value)) {
229
- const nx = this.tokens[i + 1];
230
- if (nx && (nx.type === "lbrace" || (nx.type === "ident" && nx.value === "distinct"))) return true;
299
+ // ---- alias inlining -------------------------------------------------------
300
+ // `where` may reference the same body's `select` aliases. This is a
301
+ // compile-time rewrite, not a second execution pass: every bare identifier in
302
+ // the where tree that names an alias is replaced by that alias's expression,
303
+ // so the engine and any pushdown planner see an ordinary where over row
304
+ // fields. Rules:
305
+ // • an alias shadows a same-named row field inside `where`;
306
+ // • an alias's own name inside its own expression is the row field
307
+ // (`name: name.upper()` is not recursive), but a chain of aliases that
308
+ // comes back to one being resolved (`a: b, b: a`) is a cycle → error;
309
+ // • an alias whose value is a `collect`/`first`/`single { … }` block may
310
+ // stand alone as a where leaf (a collection in predicate position means
311
+ // non-empty) but not appear inside an expression;
312
+ // • nested blocks (consumer bodies, follow blocks) are their own scopes and
313
+ // are not rewritten against this body's select — each body rewrites
314
+ // against its own.
315
+ private inlineAliases(select: SelectItem[], where: Where | null): Where | null {
316
+ if (!where || select.length === 0) return where;
317
+ const aliases = new Map<string, SelectItem>();
318
+ for (const it of select) {
319
+ if (it.kind === "collect") aliases.set(it.name, it);
320
+ else if (it.lift === 0 && it.name !== "") aliases.set(it.name, it);
321
+ }
322
+ if (aliases.size === 0) return where;
323
+ const resolving: string[] = [];
324
+ const subst = (e: Expr): Expr => {
325
+ switch (e.kind) {
326
+ case "ident": {
327
+ const a = aliases.get(e.name);
328
+ if (!a) return e;
329
+ if (resolving[resolving.length - 1] === e.name) return e; // its own name inside its own expression: the row field
330
+ if (resolving.includes(e.name)) {
331
+ const cycle = [...resolving.slice(resolving.indexOf(e.name)), e.name].join(" → ");
332
+ this.fail(`select aliases form a cycle: ${cycle} — an alias used in \`where\` cannot depend on itself`);
333
+ }
334
+ if (a.kind === "collect") {
335
+ this.fail(`select alias '${e.name}' is a ${a.op.op} { … } block — in \`where\` it can only stand alone as a non-empty test, not inside an expression`);
336
+ }
337
+ resolving.push(e.name);
338
+ const out = subst(a.expr);
339
+ resolving.pop();
340
+ return out;
231
341
  }
232
- if (t.type === "ident" && (t.value === "order" || t.value === "follow")) {
233
- const nx = this.tokens[i + 1];
234
- if (nx && nx.type === "ident") break;
342
+ case "member": return { ...e, recv: subst(e.recv) };
343
+ case "index": return { ...e, recv: subst(e.recv), index: subst(e.index) };
344
+ case "call": return { ...e, recv: e.recv ? subst(e.recv) : null, args: e.args.map(subst) };
345
+ case "unary": return { ...e, expr: subst(e.expr) };
346
+ case "binary": case "logical": case "in": return { ...e, left: subst(e.left), right: subst(e.right) };
347
+ case "range": return { ...e, lo: e.lo ? subst(e.lo) : null, hi: e.hi ? subst(e.hi) : null };
348
+ default: return e; // lit, binding, outer (`^name` reads an enclosing row, never an alias)
349
+ }
350
+ };
351
+ const walk = (w: Where): Where => {
352
+ switch (w.kind) {
353
+ case "and": return { kind: "and", parts: w.parts.map(walk) };
354
+ case "or": return { kind: "or", parts: w.parts.map(walk) };
355
+ case "not": return { kind: "not", expr: walk(w.expr) };
356
+ case "scalar": {
357
+ if (w.expr.kind === "ident") {
358
+ const a = aliases.get(w.expr.name);
359
+ if (a?.kind === "collect") return a.op; // a collection in predicate position: non-empty
360
+ }
361
+ return { kind: "scalar", expr: subst(w.expr) };
235
362
  }
363
+ case "op": return { ...w, receiver: subst(w.receiver) }; // the receiver is read in this scope; the block is its own scope
236
364
  }
237
- }
238
- return false;
365
+ };
366
+ return walk(where);
239
367
  }
240
368
 
241
369
  private atOrderBy(): boolean {
@@ -244,19 +372,29 @@ class Parser {
244
372
  return t.type === "ident" && t.value === "order" && !!nx && nx.type === "ident" && nx.value === "by";
245
373
  }
246
374
 
375
+ // `follow` is a clause when a relation (or something that tries to be one —
376
+ // `distinct`, an outer reference) follows; otherwise it is a field name.
247
377
  private atFollow(): boolean {
248
378
  if (!this.at("ident", "follow")) return false;
249
379
  const nx = this.peekAt(1);
250
- return !!nx && (nx.type === "ident" || nx.type === "binding");
380
+ return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "caret");
251
381
  }
252
382
 
253
383
  // ---- follow ---------------------------------------------------------------
254
384
  private parseFollow(): Follow {
255
385
  this.next(); // `follow`
256
386
  let distinct = false;
387
+ // After `follow`, `distinct` is a keyword (a relation literally named
388
+ // `distinct` is not supported); it must be followed by the relation.
257
389
  if (this.at("ident", "distinct")) {
258
- const nx = this.peekAt(1);
259
- if (nx && (nx.type === "ident" || nx.type === "binding")) { this.next(); distinct = true; }
390
+ this.next();
391
+ distinct = true;
392
+ if (!this.at("ident") && !this.at("binding") && !this.at("caret")) {
393
+ this.fail("expected a relation after `follow distinct` (`follow distinct <relation>`)");
394
+ }
395
+ }
396
+ if (this.at("caret")) {
397
+ this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference `^name` is not allowed there");
260
398
  }
261
399
  const receiver = this.parseReceiver();
262
400
  const follow: Follow = { receiver, distinct, where: null, frontier: null, depth: null, by: null };
@@ -300,6 +438,7 @@ class Parser {
300
438
  if (this.at("binding")) return { kind: "binding", index: this.next().index! };
301
439
  const levels = this.parseCarets();
302
440
  if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
441
+ if (LITERAL_WORDS.has(this.peek().value)) this.fail(`\`${this.peek().value}\` is a literal, not a collection`);
303
442
  return this.parseNavFrom(this.next(), levels).expr;
304
443
  }
305
444
 
@@ -319,7 +458,7 @@ class Parser {
319
458
  if (levels === 0 && this.at("lparen")) expr = { kind: "call", recv: null, name: head.value, args: this.parseArgs() };
320
459
  while (this.at("dot")) {
321
460
  this.next();
322
- if (!this.at("ident")) this.fail("expected an identifier after '.' in a navigation");
461
+ if (!this.at("ident")) this.fail("expected a property name after '.'");
323
462
  name = this.next().value;
324
463
  expr = { kind: "member", recv: expr, name };
325
464
  }
@@ -333,9 +472,9 @@ class Parser {
333
472
  // meaningless. Without `values`, every item needs a key — a bare/dotted
334
473
  // navigation supplies its own (the last segment); any other expression must be
335
474
  // aliased (`name: expr`).
336
- private parseProjection(): { items: SelectItem[]; values: boolean } {
337
- const items = [this.parseSelectItem()];
338
- while (this.at("comma")) { this.next(); items.push(this.parseSelectItem()); }
475
+ private parseProjection(ctx: BodyCtx): { items: SelectItem[]; values: boolean } {
476
+ const items = [this.parseSelectItem(ctx)];
477
+ while (this.at("comma")) { this.next(); items.push(this.parseSelectItem(ctx)); }
339
478
  let values = false;
340
479
  if (this.at("ident", "values")) {
341
480
  this.next();
@@ -344,20 +483,36 @@ class Parser {
344
483
  const only = items[0]!;
345
484
  if (only.kind === "field" && only.lift > 0) this.fail("a lift (^name: …) cannot be combined with `values`");
346
485
  } else {
486
+ // Every item needs a distinct key: two items with one name would silently
487
+ // overwrite each other in the record. Lifts are keyed per scope (`^x` and
488
+ // `^^x` bind different rows), so the lift depth is part of the key.
489
+ const seen = new Set<string>();
347
490
  for (const it of items) {
348
- if (it.name === "") this.fail("a projection item that is not a plain name needs an alias (`name: expr`) unless it is followed by `values`");
491
+ if (it.name === "") this.fail("a leading expression is a projection (select): an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` — a predicate is never implicit");
492
+ const key = `${"^".repeat(it.kind === "field" ? it.lift : 0)}${it.name}`;
493
+ if (seen.has(key)) this.fail(`duplicate projection name '${it.name}' — each projected item needs its own name (alias one: \`other: expr\`)`);
494
+ seen.add(key);
349
495
  }
350
496
  }
351
497
  return { items, values };
352
498
  }
353
499
 
354
- private parseSelectItem(): SelectItem {
355
- // Leading `^`s mark a lift; the count is how many scopes out it binds.
500
+ private parseSelectItem(ctx: BodyCtx): SelectItem {
501
+ // Leading `^`s mark a lift; the count is how many scopes out it binds. A
502
+ // lift is bound by a `collect { … }` in where position and nowhere else — at
503
+ // the top level, in a select-position block, or in an exists/none/count
504
+ // block it would silently do nothing (or act as a plain field), so it is an
505
+ // error there.
356
506
  const lift = this.parseCarets();
507
+ if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
508
+ if (lift > 0 && !ctx.liftsAllowed) {
509
+ const what = this.at("ident") ? `^${this.peek().value}` : "^name";
510
+ this.fail(`a lift (${what}) binds a value into the enclosing row and is only valid in a \`collect { … }\` in where position (\`where <relation> collect { ${what}: … }\`)`);
511
+ }
357
512
  if (this.at("ident") && this.peekAt(1)?.type === "colon") {
358
513
  const name = this.next().value;
359
514
  this.next(); // ':'
360
- const op = this.tryOp();
515
+ const op = this.tryOp(false);
361
516
  if (op) {
362
517
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
363
518
  this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
@@ -368,7 +523,6 @@ class Parser {
368
523
  const expr = this.parseValueExpr();
369
524
  return { kind: "field", name, expr, lift };
370
525
  }
371
- if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
372
526
  // Unaliased item: a bare/dotted navigation keys by its last segment; any
373
527
  // other expression is unnamed ("") — legal only under `values` (checked by
374
528
  // parseProjection, which sees the whole list).
@@ -391,7 +545,10 @@ class Parser {
391
545
  return { expr, desc };
392
546
  }
393
547
 
394
- // ---- where boolean tree: or → and → not → primary -------------------------
548
+ // ---- where boolean tree: or → and → primary --------------------------------
549
+ // `!` is handled in `parseWherePrimary`: it applies to the operand right after
550
+ // it (a consumer test, a parenthesized group, or a scalar primary), never to a
551
+ // whole comparison — `!a == b` is `(!a) == b`, exactly as in value position.
395
552
  private parseWhere(): Where { return this.parseWhereOr(); }
396
553
 
397
554
  private parseWhereOr(): Where {
@@ -403,30 +560,69 @@ class Parser {
403
560
  }
404
561
 
405
562
  private parseWhereAnd(): Where {
406
- const left = this.parseWhereNot();
563
+ const left = this.parseWherePrimary();
407
564
  if (!this.atOp("&&")) return left;
408
565
  const parts = [left];
409
- while (this.atOp("&&")) { this.next(); parts.push(this.parseWhereNot()); }
566
+ while (this.atOp("&&")) { this.next(); parts.push(this.parseWherePrimary()); }
410
567
  return { kind: "and", parts };
411
568
  }
412
569
 
413
- private parseWhereNot(): Where {
414
- if (this.atOp("!")) { this.next(); return { kind: "not", expr: this.parseWhereNot() }; }
415
- return this.parseWherePrimary();
416
- }
417
-
570
+ // A where operand: `[!…] ( where )`, `[!…] <receiver> <consumer> { … }`, or a
571
+ // scalar leaf (a cmp-level expression, which handles its own `!`).
572
+ //
573
+ // Parentheses group EITHER a predicate or a scalar, decided by what follows
574
+ // the `)`: a comparison, arithmetic, `in`, a range, or `.`-navigation means
575
+ // the group is a scalar operand (`(a + 1) > 2`, `(a || b) == 5`,
576
+ // `(x).size() > 1`); anything else means it is a predicate group
577
+ // (`(a > 1) && b`). A group that contains a consumer test can only be a
578
+ // predicate.
418
579
  private parseWherePrimary(): Where {
580
+ const start = this.pos;
581
+ let nots = 0;
582
+ while (this.atOp("!")) { this.next(); nots++; }
419
583
  if (this.at("lparen")) {
420
584
  this.next();
421
- const e = this.parseWhere();
585
+ const inner = this.parseWhere();
422
586
  if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
423
587
  this.next();
424
- return e;
588
+ if (this.atScalarContinuation()) {
589
+ let e = this.parsePostfix(this.whereToExpr(inner));
590
+ for (let i = 0; i < nots; i++) e = { kind: "unary", op: "!", expr: e };
591
+ return { kind: "scalar", expr: this.parseCmp(e) };
592
+ }
593
+ return wrapNot(inner, nots);
594
+ }
595
+ const op = this.tryOp(true);
596
+ if (op) return wrapNot(this.finishWhereOp(op), nots);
597
+ // A scalar leaf, re-read from the first `!` so the scalar grammar gives `!`
598
+ // its one precedence (tighter than comparison). A leaf whose whole value is
599
+ // a negation keeps the `not` node shape (`where !active`).
600
+ this.pos = start;
601
+ return scalarLeaf(this.parseCmp());
602
+ }
603
+
604
+ // Whether the token after a `)` continues a scalar expression.
605
+ private atScalarContinuation(): boolean {
606
+ const t = this.peek();
607
+ if (t.type === "op") return CMP_OPS.has(t.value) || ADD_OPS.has(t.value) || MUL_OPS.has(t.value);
608
+ return t.type === "dot" || t.type === "range" || (t.type === "ident" && t.value === "in");
609
+ }
610
+
611
+ // A parenthesized where-group that turned out to be a scalar operand, as an
612
+ // expression. A consumer test has no scalar value, so it cannot be operated on.
613
+ private whereToExpr(w: Where): Expr {
614
+ switch (w.kind) {
615
+ case "scalar": return w.expr;
616
+ case "not": return { kind: "unary", op: "!", expr: this.whereToExpr(w.expr) };
617
+ case "and": case "or": {
618
+ const op = w.kind === "and" ? "&&" : "||";
619
+ return w.parts.map((p) => this.whereToExpr(p)).reduce((l, r) => ({ kind: "logical", op, left: l, right: r }));
620
+ }
621
+ case "op": {
622
+ const hint = w.op === "count" ? ` — write \`<relation> count { … } ${this.peek().value} N\` without the parentheses` : "";
623
+ this.fail(`a consumer test (${w.op} { … }) is a predicate, not a value, so it cannot be compared or operated on${hint}`);
624
+ }
425
625
  }
426
- const op = this.tryOp();
427
- if (op) return this.finishWhereOp(op);
428
- const expr = this.parseCmp();
429
- return { kind: "scalar", expr };
430
626
  }
431
627
 
432
628
  // Validate a consumer op used in where position and attach any `count { … } <op> N`.
@@ -451,7 +647,7 @@ class Parser {
451
647
 
452
648
  // Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }`.
453
649
  // Returns null (rewinding) when the lookahead is not a consumer op.
454
- private tryOp(): OpNode | null {
650
+ private tryOp(inWhere: boolean): OpNode | null {
455
651
  const start = this.pos;
456
652
  let receiver: Expr;
457
653
  if (this.at("binding")) receiver = { kind: "binding", index: this.next().index! };
@@ -467,11 +663,12 @@ class Parser {
467
663
  const opThenBrace = after?.type === "lbrace";
468
664
  const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
469
665
  if (opThenBrace || opDistinctBrace) {
666
+ if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
470
667
  const op = this.next().value as Consumer;
471
668
  let distinct = false;
472
669
  if (this.at("ident", "distinct")) { this.next(); distinct = true; }
473
670
  this.next(); // '{'
474
- const { sub, distinct: bodyDistinct } = this.parseSubquery();
671
+ const { sub, distinct: bodyDistinct } = this.parseSubquery({ top: false, op, liftsAllowed: inWhere && op === "collect" });
475
672
  if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
476
673
  this.next();
477
674
  return { kind: "op", receiver, op, sub, distinct: distinct || bodyDistinct };
@@ -481,8 +678,8 @@ class Parser {
481
678
  return null;
482
679
  }
483
680
 
484
- private parseSubquery(): { sub: Subquery; distinct: boolean } {
485
- const body = this.parseBody(true);
681
+ private parseSubquery(ctx: BodyCtx): { sub: Subquery; distinct: boolean } {
682
+ const body = this.parseBody(ctx);
486
683
  return {
487
684
  sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
488
685
  distinct: body.distinct,
@@ -506,15 +703,24 @@ class Parser {
506
703
  }
507
704
 
508
705
  // Comparison / membership (also the entry point for a where scalar leaf, so a
509
- // where leaf never swallows the where-tree's && / ||).
510
- private parseCmp(): Expr {
511
- let left = this.parseRange();
512
- if (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) {
513
- const op = this.next().value;
514
- return { kind: "binary", op, left, right: this.parseRange() };
706
+ // where leaf never swallows the where-tree's && / ||). Non-associative: a
707
+ // second comparison in a row is an error, not a silent stray token.
708
+ //
709
+ // Each level from here down takes an optional already-parsed `left` operand,
710
+ // so a parenthesized where-group promoted to a scalar continues into the
711
+ // operator tail without re-reading its tokens.
712
+ private parseCmp(left?: Expr): Expr {
713
+ const lhs = this.parseRange(left);
714
+ const t = this.peek();
715
+ const isCmp = (): boolean => (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) || this.at("ident", "in");
716
+ if (!isCmp()) return lhs;
717
+ const op = this.next().value;
718
+ const rhs = this.parseRange();
719
+ const result: Expr = op === "in" ? { kind: "in", left: lhs, right: rhs } : { kind: "binary", op, left: lhs, right: rhs };
720
+ if (isCmp()) {
721
+ this.fail(`comparisons do not chain: \`a ${t.value} b ${this.peek().value} c\` — write two comparisons joined with \`&&\``);
515
722
  }
516
- if (this.at("ident", "in")) { this.next(); return { kind: "in", left, right: this.parseRange() }; }
517
- return left;
723
+ return result;
518
724
  }
519
725
 
520
726
  // Range literal: `lo..hi` / `lo...hi` and the open-ended forms `..hi`, `lo..`.
@@ -522,12 +728,13 @@ class Parser {
522
728
  // than comparison / `in` (so `n in 1..5` reads as `n in (1..5)`). A leading
523
729
  // `..`/`...` opens the low end; a trailing `..`/`...` with no following value
524
730
  // opens the high end.
525
- private parseRange(): Expr {
526
- if (this.at("range")) {
731
+ private parseRange(left?: Expr): Expr {
732
+ if (left === undefined && this.at("range")) {
527
733
  const exclusiveEnd = this.next().value === "...";
734
+ if (!this.canStartValue()) this.fail("a range needs at least one bound: `lo..hi`, `lo..`, or `..hi`");
528
735
  return { kind: "range", lo: null, hi: this.parseAdd(), exclusiveEnd };
529
736
  }
530
- const lo = this.parseAdd();
737
+ const lo = this.parseAdd(left);
531
738
  if (this.at("range")) {
532
739
  const exclusiveEnd = this.next().value === "...";
533
740
  const hi = this.canStartValue() ? this.parseAdd() : null;
@@ -549,8 +756,8 @@ class Parser {
549
756
  return t.type === "op" && (t.value === "-" || t.value === "!");
550
757
  }
551
758
 
552
- private parseAdd(): Expr {
553
- let left = this.parseMul();
759
+ private parseAdd(left?: Expr): Expr {
760
+ left = this.parseMul(left);
554
761
  while (this.peek().type === "op" && ADD_OPS.has(this.peek().value)) {
555
762
  const op = this.next().value;
556
763
  left = { kind: "binary", op, left, right: this.parseMul() };
@@ -558,8 +765,8 @@ class Parser {
558
765
  return left;
559
766
  }
560
767
 
561
- private parseMul(): Expr {
562
- let left = this.parseUnary();
768
+ private parseMul(left?: Expr): Expr {
769
+ left = this.parseUnary(left);
563
770
  while (this.peek().type === "op" && MUL_OPS.has(this.peek().value)) {
564
771
  const op = this.next().value;
565
772
  left = { kind: "binary", op, left, right: this.parseUnary() };
@@ -567,14 +774,15 @@ class Parser {
567
774
  return left;
568
775
  }
569
776
 
570
- private parseUnary(): Expr {
777
+ private parseUnary(left?: Expr): Expr {
778
+ if (left !== undefined) return this.parsePostfix(left);
571
779
  if (this.atOp("!")) { this.next(); return { kind: "unary", op: "!", expr: this.parseUnary() }; }
572
780
  if (this.atOp("-")) { this.next(); return { kind: "unary", op: "-", expr: this.parseUnary() }; }
573
781
  return this.parsePostfix();
574
782
  }
575
783
 
576
- private parsePostfix(): Expr {
577
- let expr = this.parsePrimary();
784
+ private parsePostfix(left?: Expr): Expr {
785
+ let expr = left ?? this.parsePrimary();
578
786
  for (;;) {
579
787
  if (this.at("dot")) {
580
788
  this.next();
@@ -585,8 +793,8 @@ class Parser {
585
793
  } else {
586
794
  expr = { kind: "member", recv: expr, name };
587
795
  }
588
- } else if (this.at("lparen") && expr.kind === "ident") {
589
- // free function call: name(args)
796
+ } else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
797
+ // free function call: name(args) — a bare name read here, not `(f)(x)`
590
798
  expr = { kind: "call", recv: null, name: expr.name, args: this.parseArgs() };
591
799
  } else if (this.at("lbrace")) {
592
800
  break; // a consumer block boundary — not part of a value expression
@@ -652,6 +860,19 @@ function navKey(e: Expr): string | null {
652
860
  }
653
861
  }
654
862
 
863
+ // `!` applied `n` times to a where node.
864
+ function wrapNot(w: Where, n: number): Where {
865
+ for (let i = 0; i < n; i++) w = { kind: "not", expr: w };
866
+ return w;
867
+ }
868
+
869
+ // A scalar where leaf. A leading `!` on the whole leaf becomes a `not` node (so
870
+ // `where !active` keeps its shape); inside a comparison it stays a unary `!`.
871
+ function scalarLeaf(e: Expr): Where {
872
+ if (e.kind === "unary" && e.op === "!") return { kind: "not", expr: scalarLeaf(e.expr) };
873
+ return { kind: "scalar", expr: e };
874
+ }
875
+
655
876
  // The optional `limit`/`offset` fields of a Query/Subquery, present only when set
656
877
  // (so a Query built without them is unchanged).
657
878
  function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {