@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/dist/parser.js CHANGED
@@ -9,12 +9,36 @@
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
  import { lexTemplate, lexString } from "./lexer.js";
16
38
  import { OqxError } from "./errors.js";
17
39
  const CONSUMERS = new Set(["collect", "exists", "none", "count", "first", "single"]);
40
+ // The fixed clause order of a body (ADR-020). Each clause appears at most once.
41
+ const CLAUSE_ORDER = ["select", "from", "where", "follow", "order by", "limit", "offset"];
18
42
  // Contextual clause words lexed as bare idents; an open-ended range must stop
19
43
  // before them rather than consume them as its high bound.
20
44
  const CLAUSE_WORDS = new Set([
@@ -26,6 +50,10 @@ const RELOPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
26
50
  const CMP_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
27
51
  const ADD_OPS = new Set(["+", "-"]);
28
52
  const MUL_OPS = new Set(["*", "/", "%"]);
53
+ // `true`/`false`/`null` are literals in every position, so they can never name a
54
+ // receiver (`true exists { … }`, `follow null`).
55
+ const LITERAL_WORDS = new Set(["true", "false", "null"]);
56
+ const TOP_CTX = { top: true, op: null, liftsAllowed: false };
29
57
  /** Parse a tagged-template call into a Query. */
30
58
  export function parseTemplate(fragments, values) {
31
59
  return new Parser(lexTemplate(fragments, values)).parseQuery();
@@ -58,7 +86,7 @@ class Parser {
58
86
  // ---- top level ------------------------------------------------------------
59
87
  parseQuery() {
60
88
  // Directive form: `<receiver> <consumer> { … }` consuming the whole query.
61
- const directive = this.tryOp();
89
+ const directive = this.tryOp(false);
62
90
  if (directive && this.at("eof")) {
63
91
  return {
64
92
  source: directive.receiver,
@@ -75,12 +103,12 @@ class Parser {
75
103
  }
76
104
  if (directive)
77
105
  this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
78
- // Body form: order-flexible clauses; the first `from` is the source.
79
- const body = this.parseBody(true);
106
+ // Body form: the fixed clause list; its `from` is the source.
107
+ const body = this.parseBody(TOP_CTX);
80
108
  if (!this.at("eof"))
81
- this.fail(`unexpected ${this.tokDesc()} after the query`);
109
+ this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause`);
82
110
  if (body.froms.length === 0) {
83
- this.fail("a query must select a source with `from <collection>`");
111
+ this.fail("a query must name its source with `from <collection>` (or be `<collection> <consumer> { … }`)");
84
112
  }
85
113
  return {
86
114
  source: body.froms[0],
@@ -100,7 +128,11 @@ class Parser {
100
128
  return t.type === "eof" ? "end of query" : `'${t.value || t.type}'`;
101
129
  }
102
130
  // ---- clause body (shared by top level and consumer blocks) ----------------
103
- parseBody(orderByAllowed) {
131
+ // An ordered state machine over the fixed clause sequence: `stage` is the
132
+ // index (in CLAUSE_ORDER) of the last clause parsed, so a clause with a lower
133
+ // index is out of order and an equal index is a duplicate. The only
134
+ // keyword-less clause is a leading projection (while `stage` is still -1).
135
+ parseBody(ctx) {
104
136
  const froms = [];
105
137
  let where = null;
106
138
  let select = [];
@@ -110,83 +142,122 @@ class Parser {
110
142
  let values = false;
111
143
  let limit = null;
112
144
  let offset = null;
113
- let sawWhere = false, sawSelect = false, sawOrder = false;
145
+ let stage = -1;
146
+ const enter = (clause) => {
147
+ const idx = CLAUSE_ORDER.indexOf(clause);
148
+ if (idx === stage)
149
+ this.fail(`duplicate \`${clause}\` clause`);
150
+ if (idx < stage) {
151
+ this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${CLAUSE_ORDER.join(", ")}`);
152
+ }
153
+ stage = idx;
154
+ };
114
155
  while (!this.at("eof") && !this.at("rbrace")) {
115
- if (this.atFollow()) {
116
- if (follow)
117
- this.fail("duplicate `follow` clause");
118
- follow = this.parseFollow();
156
+ if (this.at("kw", "select")) {
157
+ enter("select");
158
+ this.next();
159
+ if (this.at("ident", "distinct")) {
160
+ this.next();
161
+ distinct = true;
162
+ }
163
+ ({ items: select, values } = this.parseProjection(ctx));
119
164
  continue;
120
165
  }
121
166
  if (this.at("kw", "from")) {
167
+ enter("from");
122
168
  this.next();
123
169
  froms.push(this.parseValueExpr());
124
170
  continue;
125
171
  }
126
172
  if (this.at("kw", "where")) {
127
- if (sawWhere)
128
- this.fail("duplicate `where` clause");
129
- sawWhere = true;
173
+ enter("where");
130
174
  this.next();
131
175
  where = this.parseWhere();
132
176
  continue;
133
177
  }
134
- if (this.at("kw", "select") || this.at("caret")) {
135
- if (sawSelect)
136
- this.fail("duplicate projection");
137
- sawSelect = true;
138
- if (this.at("kw")) {
139
- this.next();
140
- if (this.at("ident", "distinct")) {
141
- this.next();
142
- distinct = true;
143
- }
144
- } // consume `select` + optional `distinct`; a leading `^` is part of the item
145
- ({ items: select, values } = this.parseProjection());
146
- continue;
147
- }
148
- if (this.atBound()) {
149
- const word = this.next().value;
150
- const e = this.parsePostfix();
151
- if (word === "limit") {
152
- if (limit)
153
- this.fail("duplicate `limit` clause");
154
- limit = e;
155
- }
156
- else {
157
- if (offset)
158
- this.fail("duplicate `offset` clause");
159
- offset = e;
160
- }
178
+ if (this.atFollow()) {
179
+ enter("follow");
180
+ follow = this.parseFollow();
161
181
  continue;
162
182
  }
163
- if (orderByAllowed && this.atOrderBy()) {
164
- if (sawOrder)
165
- this.fail("duplicate `order by` clause");
166
- sawOrder = true;
183
+ if (this.atOrderBy()) {
184
+ enter("order by");
167
185
  this.next();
168
186
  this.next(); // `order` `by`
169
187
  orderBy = this.parseOrderSpecs();
170
188
  continue;
171
189
  }
172
- if (this.looksLikePredicate()) {
173
- if (sawWhere)
174
- this.fail("duplicate `where` (an implicit predicate cannot follow a `where`)");
175
- sawWhere = true;
176
- where = this.parseWhere();
190
+ if (this.atBound()) {
191
+ const word = this.peek().value;
192
+ enter(word);
193
+ this.next();
194
+ // A top-level bound is evaluated at the root scope itself, so `^n` there
195
+ // has nothing to read: say so now instead of an "absent" error at eval.
196
+ if (ctx.top && this.at("caret")) {
197
+ 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`);
198
+ }
199
+ const e = this.parsePostfix();
200
+ if (word === "limit")
201
+ limit = e;
202
+ else
203
+ offset = e;
177
204
  continue;
178
205
  }
179
- if (this.at("ident") || this.at("binding")) {
180
- if (sawSelect)
181
- this.fail("duplicate projection (an implicit select cannot follow a `select`)");
182
- sawSelect = true;
183
- ({ items: select, values } = this.parseProjection());
206
+ // A keyword-less run. Before any clause it is the projection (`select` is
207
+ // the one keyword that may be dropped, and only in first position); after
208
+ // any clause it is an error — a predicate is never implicit.
209
+ if (stage === -1 && (this.at("ident") || this.at("binding") || this.at("caret") || this.canStartValue())) {
210
+ enter("select");
211
+ ({ items: select, values } = this.parseProjection(ctx));
184
212
  continue;
185
213
  }
186
- this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/limit/offset/follow`);
214
+ this.failUnexpectedInBody(stage, ctx);
187
215
  }
216
+ where = this.inlineAliases(select, where);
188
217
  return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
189
218
  }
219
+ // The error for a token that starts no clause, phrased for the mistake it most
220
+ // likely is: a bare run right after `from` (the old implicit `where`, or a
221
+ // consumer word where a whole-query directive was meant), or a stray token.
222
+ failUnexpectedInBody(stage, ctx) {
223
+ const t = this.peek();
224
+ const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
225
+ const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
226
+ const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
227
+ // Punctuation, an operator, or a literal after a complete clause is a stray
228
+ // token, not a misplaced predicate: name where the body ends. (A comma keeps
229
+ // the projection hint below — `name from r, id` almost always meant a
230
+ // projection.)
231
+ if (last !== null && !isWord && t.type !== "comma") {
232
+ if (ctx.top)
233
+ this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause (expected ${remaining} or the end of the query)`);
234
+ this.fail(`unexpected ${this.tokDesc()} in the ${ctx.op} { … } block — expected ${remaining} or '}' to close the block`);
235
+ }
236
+ // Clause words that did not form a clause: say what the clause needs.
237
+ if (last !== null && t.type === "ident") {
238
+ if (t.value === "order")
239
+ this.fail(`unexpected 'order' after \`${last}\` — an ordering is written \`order by <expr> [asc|desc]\``);
240
+ if (t.value === "follow")
241
+ this.fail(`unexpected 'follow' after \`${last}\` — \`follow\` needs a relation: \`follow <relation>\` or \`follow distinct <relation>\``);
242
+ if (t.value === "limit" || t.value === "offset") {
243
+ 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\``);
244
+ }
245
+ }
246
+ if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
247
+ 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\``);
248
+ }
249
+ if (last === "from") {
250
+ 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}`);
251
+ }
252
+ // `{ rel exists { … } }` / `{ rel count { … } >= 2 }`: the leading `rel` was
253
+ // read as the projection, so the consumer word is where the mistake shows.
254
+ if (last === "select" && t.type === "ident" && CONSUMERS.has(t.value)) {
255
+ 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 { … }\`)`);
256
+ }
257
+ if (last === null)
258
+ this.fail(`unexpected ${this.tokDesc()} — expected a projection or ${remaining}`);
259
+ this.fail(`unexpected ${this.tokDesc()} after \`${last}\` — expected ${remaining}`);
260
+ }
190
261
  // `limit <n>` / `offset <n>` — the word must be followed by something that can
191
262
  // be a bound (a number, a binding, or an outer reference), so a field that
192
263
  // happens to be called `limit` still projects/filters as a bare name.
@@ -197,79 +268,113 @@ class Parser {
197
268
  const nx = this.peekAt(1);
198
269
  return !!nx && (nx.type === "number" || nx.type === "binding" || nx.type === "caret");
199
270
  }
200
- // Decide, by syntactic shape only, whether a leading unkeyworded run is a
201
- // predicate (→ implicit where) or a projection (→ implicit select). A depth-0
202
- // comparison / && / || / `in` before any comma/colon marks a predicate; a bare
203
- // reference or a `name:`/comma projection list marks a projection.
204
- looksLikePredicate() {
205
- if (this.at("op"))
206
- return true; // leading unary `!` (or a stray operator)
207
- if (this.at("lparen"))
208
- return true; // a grouped boolean expression
209
- let depth = 0;
210
- for (let i = this.pos; i < this.tokens.length; i++) {
211
- const t = this.tokens[i];
212
- if (t.type === "eof")
213
- break;
214
- // Skip nested `{ … }` consumer blocks and `( … )` groups: a keyword/select
215
- // inside them is not part of the top-level shape (e.g. `count { select x } == N`).
216
- if (t.type === "lbrace" || t.type === "lparen") {
217
- depth++;
218
- continue;
219
- }
220
- if (t.type === "rbrace" || t.type === "rparen") {
221
- if (depth === 0)
222
- break;
223
- depth--;
224
- continue;
225
- }
226
- if (depth === 0) {
227
- if (t.type === "colon" || t.type === "comma")
228
- return false;
229
- if (t.type === "kw")
230
- break;
231
- if (t.type === "op" && (CMP_OPS.has(t.value) || t.value === "&&" || t.value === "||"))
232
- return true;
233
- if (t.type === "ident" && t.value === "in")
234
- return true;
235
- // A consumer directive in where position (`… exists { … }`, `… count { … }`,
236
- // optionally `count distinct { … }`) is a predicate.
237
- if (t.type === "ident" && CONSUMERS.has(t.value)) {
238
- const nx = this.tokens[i + 1];
239
- if (nx && (nx.type === "lbrace" || (nx.type === "ident" && nx.value === "distinct")))
240
- return true;
271
+ // ---- alias inlining -------------------------------------------------------
272
+ // `where` may reference the same body's `select` aliases. This is a
273
+ // compile-time rewrite, not a second execution pass: every bare identifier in
274
+ // the where tree that names an alias is replaced by that alias's expression,
275
+ // so the engine and any pushdown planner see an ordinary where over row
276
+ // fields. Rules:
277
+ // • an alias shadows a same-named row field inside `where`;
278
+ // • an alias's own name inside its own expression is the row field
279
+ // (`name: name.upper()` is not recursive), but a chain of aliases that
280
+ // comes back to one being resolved (`a: b, b: a`) is a cycle → error;
281
+ // • an alias whose value is a `collect`/`first`/`single { … }` block may
282
+ // stand alone as a where leaf (a collection in predicate position means
283
+ // non-empty) but not appear inside an expression;
284
+ // • nested blocks (consumer bodies, follow blocks) are their own scopes and
285
+ // are not rewritten against this body's select — each body rewrites
286
+ // against its own.
287
+ inlineAliases(select, where) {
288
+ if (!where || select.length === 0)
289
+ return where;
290
+ const aliases = new Map();
291
+ for (const it of select) {
292
+ if (it.kind === "collect")
293
+ aliases.set(it.name, it);
294
+ else if (it.lift === 0 && it.name !== "")
295
+ aliases.set(it.name, it);
296
+ }
297
+ if (aliases.size === 0)
298
+ return where;
299
+ const resolving = [];
300
+ const subst = (e) => {
301
+ switch (e.kind) {
302
+ case "ident": {
303
+ const a = aliases.get(e.name);
304
+ if (!a)
305
+ return e;
306
+ if (resolving[resolving.length - 1] === e.name)
307
+ return e; // its own name inside its own expression: the row field
308
+ if (resolving.includes(e.name)) {
309
+ const cycle = [...resolving.slice(resolving.indexOf(e.name)), e.name].join(" → ");
310
+ this.fail(`select aliases form a cycle: ${cycle} — an alias used in \`where\` cannot depend on itself`);
311
+ }
312
+ if (a.kind === "collect") {
313
+ 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`);
314
+ }
315
+ resolving.push(e.name);
316
+ const out = subst(a.expr);
317
+ resolving.pop();
318
+ return out;
241
319
  }
242
- if (t.type === "ident" && (t.value === "order" || t.value === "follow")) {
243
- const nx = this.tokens[i + 1];
244
- if (nx && nx.type === "ident")
245
- break;
320
+ case "member": return { ...e, recv: subst(e.recv) };
321
+ case "index": return { ...e, recv: subst(e.recv), index: subst(e.index) };
322
+ case "call": return { ...e, recv: e.recv ? subst(e.recv) : null, args: e.args.map(subst) };
323
+ case "unary": return { ...e, expr: subst(e.expr) };
324
+ case "binary":
325
+ case "logical":
326
+ case "in": return { ...e, left: subst(e.left), right: subst(e.right) };
327
+ case "range": return { ...e, lo: e.lo ? subst(e.lo) : null, hi: e.hi ? subst(e.hi) : null };
328
+ default: return e; // lit, binding, outer (`^name` reads an enclosing row, never an alias)
329
+ }
330
+ };
331
+ const walk = (w) => {
332
+ switch (w.kind) {
333
+ case "and": return { kind: "and", parts: w.parts.map(walk) };
334
+ case "or": return { kind: "or", parts: w.parts.map(walk) };
335
+ case "not": return { kind: "not", expr: walk(w.expr) };
336
+ case "scalar": {
337
+ if (w.expr.kind === "ident") {
338
+ const a = aliases.get(w.expr.name);
339
+ if (a?.kind === "collect")
340
+ return a.op; // a collection in predicate position: non-empty
341
+ }
342
+ return { kind: "scalar", expr: subst(w.expr) };
246
343
  }
344
+ case "op": return { ...w, receiver: subst(w.receiver) }; // the receiver is read in this scope; the block is its own scope
247
345
  }
248
- }
249
- return false;
346
+ };
347
+ return walk(where);
250
348
  }
251
349
  atOrderBy() {
252
350
  const t = this.peek();
253
351
  const nx = this.peekAt(1);
254
352
  return t.type === "ident" && t.value === "order" && !!nx && nx.type === "ident" && nx.value === "by";
255
353
  }
354
+ // `follow` is a clause when a relation (or something that tries to be one —
355
+ // `distinct`, an outer reference) follows; otherwise it is a field name.
256
356
  atFollow() {
257
357
  if (!this.at("ident", "follow"))
258
358
  return false;
259
359
  const nx = this.peekAt(1);
260
- return !!nx && (nx.type === "ident" || nx.type === "binding");
360
+ return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "caret");
261
361
  }
262
362
  // ---- follow ---------------------------------------------------------------
263
363
  parseFollow() {
264
364
  this.next(); // `follow`
265
365
  let distinct = false;
366
+ // After `follow`, `distinct` is a keyword (a relation literally named
367
+ // `distinct` is not supported); it must be followed by the relation.
266
368
  if (this.at("ident", "distinct")) {
267
- const nx = this.peekAt(1);
268
- if (nx && (nx.type === "ident" || nx.type === "binding")) {
269
- this.next();
270
- distinct = true;
369
+ this.next();
370
+ distinct = true;
371
+ if (!this.at("ident") && !this.at("binding") && !this.at("caret")) {
372
+ this.fail("expected a relation after `follow distinct` (`follow distinct <relation>`)");
271
373
  }
272
374
  }
375
+ if (this.at("caret")) {
376
+ this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference `^name` is not allowed there");
377
+ }
273
378
  const receiver = this.parseReceiver();
274
379
  const follow = { receiver, distinct, where: null, frontier: null, depth: null, by: null };
275
380
  if (!this.at("lbrace"))
@@ -325,6 +430,8 @@ class Parser {
325
430
  const levels = this.parseCarets();
326
431
  if (!this.at("ident"))
327
432
  this.fail("expected a collection navigation (a property/relation name)");
433
+ if (LITERAL_WORDS.has(this.peek().value))
434
+ this.fail(`\`${this.peek().value}\` is a literal, not a collection`);
328
435
  return this.parseNavFrom(this.next(), levels).expr;
329
436
  }
330
437
  // Consume a run of `^` and return its length (0 when there is none).
@@ -347,7 +454,7 @@ class Parser {
347
454
  while (this.at("dot")) {
348
455
  this.next();
349
456
  if (!this.at("ident"))
350
- this.fail("expected an identifier after '.' in a navigation");
457
+ this.fail("expected a property name after '.'");
351
458
  name = this.next().value;
352
459
  expr = { kind: "member", recv: expr, name };
353
460
  }
@@ -360,11 +467,11 @@ class Parser {
360
467
  // meaningless. Without `values`, every item needs a key — a bare/dotted
361
468
  // navigation supplies its own (the last segment); any other expression must be
362
469
  // aliased (`name: expr`).
363
- parseProjection() {
364
- const items = [this.parseSelectItem()];
470
+ parseProjection(ctx) {
471
+ const items = [this.parseSelectItem(ctx)];
365
472
  while (this.at("comma")) {
366
473
  this.next();
367
- items.push(this.parseSelectItem());
474
+ items.push(this.parseSelectItem(ctx));
368
475
  }
369
476
  let values = false;
370
477
  if (this.at("ident", "values")) {
@@ -377,20 +484,38 @@ class Parser {
377
484
  this.fail("a lift (^name: …) cannot be combined with `values`");
378
485
  }
379
486
  else {
487
+ // Every item needs a distinct key: two items with one name would silently
488
+ // overwrite each other in the record. Lifts are keyed per scope (`^x` and
489
+ // `^^x` bind different rows), so the lift depth is part of the key.
490
+ const seen = new Set();
380
491
  for (const it of items) {
381
492
  if (it.name === "")
382
- this.fail("a projection item that is not a plain name needs an alias (`name: expr`) unless it is followed by `values`");
493
+ 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");
494
+ const key = `${"^".repeat(it.kind === "field" ? it.lift : 0)}${it.name}`;
495
+ if (seen.has(key))
496
+ this.fail(`duplicate projection name '${it.name}' — each projected item needs its own name (alias one: \`other: expr\`)`);
497
+ seen.add(key);
383
498
  }
384
499
  }
385
500
  return { items, values };
386
501
  }
387
- parseSelectItem() {
388
- // Leading `^`s mark a lift; the count is how many scopes out it binds.
502
+ parseSelectItem(ctx) {
503
+ // Leading `^`s mark a lift; the count is how many scopes out it binds. A
504
+ // lift is bound by a `collect { … }` in where position and nowhere else — at
505
+ // the top level, in a select-position block, or in an exists/none/count
506
+ // block it would silently do nothing (or act as a plain field), so it is an
507
+ // error there.
389
508
  const lift = this.parseCarets();
509
+ if (!this.at("ident") && !this.canStartValue())
510
+ this.fail("expected a projection name");
511
+ if (lift > 0 && !ctx.liftsAllowed) {
512
+ const what = this.at("ident") ? `^${this.peek().value}` : "^name";
513
+ 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}: … }\`)`);
514
+ }
390
515
  if (this.at("ident") && this.peekAt(1)?.type === "colon") {
391
516
  const name = this.next().value;
392
517
  this.next(); // ':'
393
- const op = this.tryOp();
518
+ const op = this.tryOp(false);
394
519
  if (op) {
395
520
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
396
521
  this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
@@ -402,8 +527,6 @@ class Parser {
402
527
  const expr = this.parseValueExpr();
403
528
  return { kind: "field", name, expr, lift };
404
529
  }
405
- if (!this.at("ident") && !this.canStartValue())
406
- this.fail("expected a projection name");
407
530
  // Unaliased item: a bare/dotted navigation keys by its last segment; any
408
531
  // other expression is unnamed ("") — legal only under `values` (checked by
409
532
  // parseProjection, which sees the whole list).
@@ -430,7 +553,10 @@ class Parser {
430
553
  }
431
554
  return { expr, desc };
432
555
  }
433
- // ---- where boolean tree: or → and → not → primary -------------------------
556
+ // ---- where boolean tree: or → and → primary --------------------------------
557
+ // `!` is handled in `parseWherePrimary`: it applies to the operand right after
558
+ // it (a consumer test, a parenthesized group, or a scalar primary), never to a
559
+ // whole comparison — `!a == b` is `(!a) == b`, exactly as in value position.
434
560
  parseWhere() { return this.parseWhereOr(); }
435
561
  parseWhereOr() {
436
562
  const left = this.parseWhereAnd();
@@ -444,37 +570,78 @@ class Parser {
444
570
  return { kind: "or", parts };
445
571
  }
446
572
  parseWhereAnd() {
447
- const left = this.parseWhereNot();
573
+ const left = this.parseWherePrimary();
448
574
  if (!this.atOp("&&"))
449
575
  return left;
450
576
  const parts = [left];
451
577
  while (this.atOp("&&")) {
452
578
  this.next();
453
- parts.push(this.parseWhereNot());
579
+ parts.push(this.parseWherePrimary());
454
580
  }
455
581
  return { kind: "and", parts };
456
582
  }
457
- parseWhereNot() {
458
- if (this.atOp("!")) {
583
+ // A where operand: `[!…] ( where )`, `[!…] <receiver> <consumer> { … }`, or a
584
+ // scalar leaf (a cmp-level expression, which handles its own `!`).
585
+ //
586
+ // Parentheses group EITHER a predicate or a scalar, decided by what follows
587
+ // the `)`: a comparison, arithmetic, `in`, a range, or `.`-navigation means
588
+ // the group is a scalar operand (`(a + 1) > 2`, `(a || b) == 5`,
589
+ // `(x).size() > 1`); anything else means it is a predicate group
590
+ // (`(a > 1) && b`). A group that contains a consumer test can only be a
591
+ // predicate.
592
+ parseWherePrimary() {
593
+ const start = this.pos;
594
+ let nots = 0;
595
+ while (this.atOp("!")) {
459
596
  this.next();
460
- return { kind: "not", expr: this.parseWhereNot() };
597
+ nots++;
461
598
  }
462
- return this.parseWherePrimary();
463
- }
464
- parseWherePrimary() {
465
599
  if (this.at("lparen")) {
466
600
  this.next();
467
- const e = this.parseWhere();
601
+ const inner = this.parseWhere();
468
602
  if (!this.at("rparen"))
469
603
  this.fail("expected ')' to close a grouped where expression");
470
604
  this.next();
471
- return e;
605
+ if (this.atScalarContinuation()) {
606
+ let e = this.parsePostfix(this.whereToExpr(inner));
607
+ for (let i = 0; i < nots; i++)
608
+ e = { kind: "unary", op: "!", expr: e };
609
+ return { kind: "scalar", expr: this.parseCmp(e) };
610
+ }
611
+ return wrapNot(inner, nots);
472
612
  }
473
- const op = this.tryOp();
613
+ const op = this.tryOp(true);
474
614
  if (op)
475
- return this.finishWhereOp(op);
476
- const expr = this.parseCmp();
477
- return { kind: "scalar", expr };
615
+ return wrapNot(this.finishWhereOp(op), nots);
616
+ // A scalar leaf, re-read from the first `!` so the scalar grammar gives `!`
617
+ // its one precedence (tighter than comparison). A leaf whose whole value is
618
+ // a negation keeps the `not` node shape (`where !active`).
619
+ this.pos = start;
620
+ return scalarLeaf(this.parseCmp());
621
+ }
622
+ // Whether the token after a `)` continues a scalar expression.
623
+ atScalarContinuation() {
624
+ const t = this.peek();
625
+ if (t.type === "op")
626
+ return CMP_OPS.has(t.value) || ADD_OPS.has(t.value) || MUL_OPS.has(t.value);
627
+ return t.type === "dot" || t.type === "range" || (t.type === "ident" && t.value === "in");
628
+ }
629
+ // A parenthesized where-group that turned out to be a scalar operand, as an
630
+ // expression. A consumer test has no scalar value, so it cannot be operated on.
631
+ whereToExpr(w) {
632
+ switch (w.kind) {
633
+ case "scalar": return w.expr;
634
+ case "not": return { kind: "unary", op: "!", expr: this.whereToExpr(w.expr) };
635
+ case "and":
636
+ case "or": {
637
+ const op = w.kind === "and" ? "&&" : "||";
638
+ return w.parts.map((p) => this.whereToExpr(p)).reduce((l, r) => ({ kind: "logical", op, left: l, right: r }));
639
+ }
640
+ case "op": {
641
+ const hint = w.op === "count" ? ` — write \`<relation> count { … } ${this.peek().value} N\` without the parentheses` : "";
642
+ this.fail(`a consumer test (${w.op} { … }) is a predicate, not a value, so it cannot be compared or operated on${hint}`);
643
+ }
644
+ }
478
645
  }
479
646
  // Validate a consumer op used in where position and attach any `count { … } <op> N`.
480
647
  finishWhereOp(op) {
@@ -501,7 +668,7 @@ class Parser {
501
668
  }
502
669
  // Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }`.
503
670
  // Returns null (rewinding) when the lookahead is not a consumer op.
504
- tryOp() {
671
+ tryOp(inWhere) {
505
672
  const start = this.pos;
506
673
  let receiver;
507
674
  if (this.at("binding"))
@@ -522,6 +689,8 @@ class Parser {
522
689
  const opThenBrace = after?.type === "lbrace";
523
690
  const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
524
691
  if (opThenBrace || opDistinctBrace) {
692
+ if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name))
693
+ this.fail(`\`${receiver.name}\` is a literal, not a collection`);
525
694
  const op = this.next().value;
526
695
  let distinct = false;
527
696
  if (this.at("ident", "distinct")) {
@@ -529,7 +698,7 @@ class Parser {
529
698
  distinct = true;
530
699
  }
531
700
  this.next(); // '{'
532
- const { sub, distinct: bodyDistinct } = this.parseSubquery();
701
+ const { sub, distinct: bodyDistinct } = this.parseSubquery({ top: false, op, liftsAllowed: inWhere && op === "collect" });
533
702
  if (!this.at("rbrace"))
534
703
  this.fail(`expected '}' to close the ${op} { … } block`);
535
704
  this.next();
@@ -539,8 +708,8 @@ class Parser {
539
708
  this.pos = start;
540
709
  return null;
541
710
  }
542
- parseSubquery() {
543
- const body = this.parseBody(true);
711
+ parseSubquery(ctx) {
712
+ const body = this.parseBody(ctx);
544
713
  return {
545
714
  sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
546
715
  distinct: body.distinct,
@@ -566,30 +735,39 @@ class Parser {
566
735
  return left;
567
736
  }
568
737
  // Comparison / membership (also the entry point for a where scalar leaf, so a
569
- // where leaf never swallows the where-tree's && / ||).
570
- parseCmp() {
571
- let left = this.parseRange();
572
- if (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) {
573
- const op = this.next().value;
574
- return { kind: "binary", op, left, right: this.parseRange() };
575
- }
576
- if (this.at("ident", "in")) {
577
- this.next();
578
- return { kind: "in", left, right: this.parseRange() };
738
+ // where leaf never swallows the where-tree's && / ||). Non-associative: a
739
+ // second comparison in a row is an error, not a silent stray token.
740
+ //
741
+ // Each level from here down takes an optional already-parsed `left` operand,
742
+ // so a parenthesized where-group promoted to a scalar continues into the
743
+ // operator tail without re-reading its tokens.
744
+ parseCmp(left) {
745
+ const lhs = this.parseRange(left);
746
+ const t = this.peek();
747
+ const isCmp = () => (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) || this.at("ident", "in");
748
+ if (!isCmp())
749
+ return lhs;
750
+ const op = this.next().value;
751
+ const rhs = this.parseRange();
752
+ const result = op === "in" ? { kind: "in", left: lhs, right: rhs } : { kind: "binary", op, left: lhs, right: rhs };
753
+ if (isCmp()) {
754
+ this.fail(`comparisons do not chain: \`a ${t.value} b ${this.peek().value} c\` — write two comparisons joined with \`&&\``);
579
755
  }
580
- return left;
756
+ return result;
581
757
  }
582
758
  // Range literal: `lo..hi` / `lo...hi` and the open-ended forms `..hi`, `lo..`.
583
759
  // Binds looser than arithmetic (so `1+1..2*3` is the range 2..6) but tighter
584
760
  // than comparison / `in` (so `n in 1..5` reads as `n in (1..5)`). A leading
585
761
  // `..`/`...` opens the low end; a trailing `..`/`...` with no following value
586
762
  // opens the high end.
587
- parseRange() {
588
- if (this.at("range")) {
763
+ parseRange(left) {
764
+ if (left === undefined && this.at("range")) {
589
765
  const exclusiveEnd = this.next().value === "...";
766
+ if (!this.canStartValue())
767
+ this.fail("a range needs at least one bound: `lo..hi`, `lo..`, or `..hi`");
590
768
  return { kind: "range", lo: null, hi: this.parseAdd(), exclusiveEnd };
591
769
  }
592
- const lo = this.parseAdd();
770
+ const lo = this.parseAdd(left);
593
771
  if (this.at("range")) {
594
772
  const exclusiveEnd = this.next().value === "...";
595
773
  const hi = this.canStartValue() ? this.parseAdd() : null;
@@ -611,23 +789,25 @@ class Parser {
611
789
  return true;
612
790
  return t.type === "op" && (t.value === "-" || t.value === "!");
613
791
  }
614
- parseAdd() {
615
- let left = this.parseMul();
792
+ parseAdd(left) {
793
+ left = this.parseMul(left);
616
794
  while (this.peek().type === "op" && ADD_OPS.has(this.peek().value)) {
617
795
  const op = this.next().value;
618
796
  left = { kind: "binary", op, left, right: this.parseMul() };
619
797
  }
620
798
  return left;
621
799
  }
622
- parseMul() {
623
- let left = this.parseUnary();
800
+ parseMul(left) {
801
+ left = this.parseUnary(left);
624
802
  while (this.peek().type === "op" && MUL_OPS.has(this.peek().value)) {
625
803
  const op = this.next().value;
626
804
  left = { kind: "binary", op, left, right: this.parseUnary() };
627
805
  }
628
806
  return left;
629
807
  }
630
- parseUnary() {
808
+ parseUnary(left) {
809
+ if (left !== undefined)
810
+ return this.parsePostfix(left);
631
811
  if (this.atOp("!")) {
632
812
  this.next();
633
813
  return { kind: "unary", op: "!", expr: this.parseUnary() };
@@ -638,8 +818,8 @@ class Parser {
638
818
  }
639
819
  return this.parsePostfix();
640
820
  }
641
- parsePostfix() {
642
- let expr = this.parsePrimary();
821
+ parsePostfix(left) {
822
+ let expr = left ?? this.parsePrimary();
643
823
  for (;;) {
644
824
  if (this.at("dot")) {
645
825
  this.next();
@@ -653,8 +833,8 @@ class Parser {
653
833
  expr = { kind: "member", recv: expr, name };
654
834
  }
655
835
  }
656
- else if (this.at("lparen") && expr.kind === "ident") {
657
- // free function call: name(args)
836
+ else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
837
+ // free function call: name(args) — a bare name read here, not `(f)(x)`
658
838
  expr = { kind: "call", recv: null, name: expr.name, args: this.parseArgs() };
659
839
  }
660
840
  else if (this.at("lbrace")) {
@@ -736,6 +916,19 @@ function navKey(e) {
736
916
  default: return null;
737
917
  }
738
918
  }
919
+ // `!` applied `n` times to a where node.
920
+ function wrapNot(w, n) {
921
+ for (let i = 0; i < n; i++)
922
+ w = { kind: "not", expr: w };
923
+ return w;
924
+ }
925
+ // A scalar where leaf. A leading `!` on the whole leaf becomes a `not` node (so
926
+ // `where !active` keeps its shape); inside a comparison it stays a unary `!`.
927
+ function scalarLeaf(e) {
928
+ if (e.kind === "unary" && e.op === "!")
929
+ return { kind: "not", expr: scalarLeaf(e.expr) };
930
+ return { kind: "scalar", expr: e };
931
+ }
739
932
  // The optional `limit`/`offset` fields of a Query/Subquery, present only when set
740
933
  // (so a Query built without them is unchanged).
741
934
  function bounds(b) {