@omgbase/oqx 0.7.0 → 0.9.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/ast.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  // current row (property navigation on the host object model), resolved at run
5
5
  // time rather than against a fixed schema.
6
6
 
7
- /** Query consumers — how a (sub)query's row set is shaped. */
8
- export type Consumer = "collect" | "exists" | "count" | "first" | "single";
7
+ /** Query consumers — how a (sub)query's row set is shaped. `none` is the
8
+ * zero-cardinality complement of `exists` (true iff the block yields no rows). */
9
+ export type Consumer = "collect" | "exists" | "none" | "count" | "first" | "single";
9
10
 
10
11
  /** Comparison operators usable in a `count { … } <op> <int>` test. */
11
12
  export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
@@ -13,7 +14,7 @@ export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
13
14
  /** Scalar value/predicate expression, evaluated against a row scope + bindings. */
14
15
  export type Expr =
15
16
  | { kind: "lit"; value: string | number | boolean | null }
16
- | { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs)
17
+ | { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs); `$value` is the row itself
17
18
  | { kind: "outer"; levels: number; name: string } // `^name` — read from exactly `levels` scopes out
18
19
  | { kind: "binding"; index: number } // a ${…} interpolated host value
19
20
  | { kind: "member"; recv: Expr; name: string } // .prop navigation on the value to its left
@@ -73,6 +74,18 @@ export interface Subquery {
73
74
  select: SelectItem[];
74
75
  orderBy: OrderSpec[] | null;
75
76
  follow: Follow | null;
77
+ /** `values` — scalar projection mode: the (single) projected expression is the
78
+ * row's result itself rather than being wrapped in a `{ name: value }` record,
79
+ * so `name values` yields `["Bob", …]` and `$value values` yields the rows. */
80
+ values?: boolean;
81
+ /** `limit N` / `offset N` — bound the row set AFTER where/order/distinct and
82
+ * BEFORE the consumer reduces it, so `count { … limit 5 }` is at most 5 and
83
+ * `first { … offset 1 }` is the second row. Each is a value expression
84
+ * (a literal, a `${…}` binding, or an outer reference) read as part of the
85
+ * block — `^n` is the enclosing row's field, as everywhere inside `{ … }` —
86
+ * and must yield a non-negative integer. */
87
+ limit?: Expr;
88
+ offset?: Expr;
76
89
  }
77
90
 
78
91
  /** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
@@ -95,4 +108,9 @@ export interface Query {
95
108
  follow: Follow | null;
96
109
  /** `distinct` — dedup the result rows by their projected value (see OpNode). */
97
110
  distinct?: boolean;
111
+ /** `values` — scalar projection mode (see Subquery). */
112
+ values?: boolean;
113
+ /** `limit N` / `offset N` (see Subquery); evaluated at the root scope. */
114
+ limit?: Expr;
115
+ offset?: Expr;
98
116
  }
package/src/engine.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // fall-through from an inner scope to an outer one — see `resolveIn`.
14
14
 
15
15
  import type {
16
- Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow,
16
+ Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow, Subquery,
17
17
  } from "./ast.ts";
18
18
  import type { DataContext } from "./context.ts";
19
19
  import { DefaultContext } from "./context.ts";
@@ -26,6 +26,7 @@ import {
26
26
  export type OqxResult =
27
27
  | { consumer: "collect"; rows: unknown[] }
28
28
  | { consumer: "exists"; exists: boolean }
29
+ | { consumer: "none"; none: boolean }
29
30
  | { consumer: "count"; count: number }
30
31
  | { consumer: "first"; row: unknown | null }
31
32
  | { consumer: "single"; row: unknown | null };
@@ -51,6 +52,14 @@ interface Scope {
51
52
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
52
53
  const HARD_DEPTH_CAP = 8;
53
54
 
55
+ // What a scope projects to: the select list plus the `values` mode flag. Both
56
+ // `Query` and `Subquery` carry this shape.
57
+ type Projection = Pick<Subquery, "select" | "values">;
58
+
59
+ // An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
60
+ interface Bound { offset: number; limit: number | null; }
61
+ const UNBOUNDED: Bound = { offset: 0, limit: null };
62
+
54
63
  export class InMemoryEngine implements Engine {
55
64
  private ctx: DataContext;
56
65
 
@@ -65,22 +74,30 @@ export class InMemoryEngine implements Engine {
65
74
  rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
66
75
  }
67
76
 
68
- if (query.follow) return this.runFollow(query, rows, root);
77
+ const bound = this.boundOf(query, root);
78
+ if (query.follow) return this.runFollow(query, rows, root, bound);
69
79
 
70
80
  // Consumer-directed short-circuits (skipped under `distinct`, which must
71
- // materialize + dedup by projection before reducing).
72
- if (!query.distinct && query.consumer === "exists") {
73
- for (const r of rows) if (this.matches(query.where, r, root)) return { consumer: "exists", exists: true };
74
- return { consumer: "exists", exists: false };
75
- }
76
- if (!query.distinct && query.consumer === "count") {
77
- let count = 0;
78
- for (const r of rows) if (this.matches(query.where, r, root)) count++;
79
- return { consumer: "count", count };
81
+ // materialize + dedup by projection before reducing). Counting never
82
+ // materializes rows; exists/none stop as soon as the bound is known to be
83
+ // non-empty (the (offset+1)th match or the first, when unbounded).
84
+ if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
85
+ const need = query.consumer === "count" ? Infinity : bound.offset + 1;
86
+ let n = 0;
87
+ for (const r of rows) {
88
+ if (this.matches(query.where, r, root)) { n++; if (n >= need) break; }
89
+ }
90
+ const m = boundedCount(n, bound);
91
+ if (query.consumer === "count") return { consumer: "count", count: m };
92
+ if (query.consumer === "exists") return { consumer: "exists", exists: m > 0 };
93
+ return { consumer: "none", none: m === 0 };
80
94
  }
81
95
 
82
- const cap = !query.orderBy && !query.distinct && query.consumer === "first" ? 1
83
- : !query.orderBy && !query.distinct && query.consumer === "single" ? 2
96
+ // first/single over an unordered, non-distinct set need only the rows up to
97
+ // the bound: offset + 1 (first) / offset + 2 (single, to detect a second).
98
+ const want = query.consumer === "first" ? 1 : query.consumer === "single" ? 2 : Infinity;
99
+ const cap = !query.orderBy && !query.distinct && want !== Infinity
100
+ ? bound.offset + Math.min(want, bound.limit ?? want)
84
101
  : Infinity;
85
102
  let kept: Scope[] = [];
86
103
  for (const r of rows) {
@@ -91,8 +108,28 @@ export class InMemoryEngine implements Engine {
91
108
  }
92
109
  }
93
110
  this.sortScopes(kept, query.orderBy);
94
- if (query.distinct) kept = this.dedupByProjection(kept, query.select);
95
- return this.shape(query.consumer, kept, query.select);
111
+ if (query.distinct) kept = this.dedupByProjection(kept, query);
112
+ kept = sliceBound(kept, bound);
113
+ return this.shape(query.consumer, kept, query);
114
+ }
115
+
116
+ // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
117
+ // is read in a row-less scope INSIDE it: a bare name is absent (there is no
118
+ // current item yet), `^name` is the enclosing row — exactly as in the block's
119
+ // body — and literals/bindings are themselves. For a top-level query `scope`
120
+ // is the root. Each must be a non-negative integer.
121
+ private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
122
+ if (!b.limit && !b.offset) return UNBOUNDED;
123
+ const scope: Scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
124
+ const read = (e: Expr | undefined, word: string): number | null => {
125
+ if (!e) return null;
126
+ const v = this.evalExpr(e, scope);
127
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
128
+ throw new OqxError(`${word} must be a non-negative integer (got ${JSON.stringify(v ?? null)})`, "eval");
129
+ }
130
+ return v;
131
+ };
132
+ return { offset: read(b.offset, "offset") ?? 0, limit: read(b.limit, "limit") };
96
133
  }
97
134
 
98
135
  // A where match that needs no lift capture (exists/count fast paths).
@@ -111,27 +148,28 @@ export class InMemoryEngine implements Engine {
111
148
 
112
149
  // ---- follow ---------------------------------------------------------------
113
150
 
114
- private runFollow(query: Query, rows: unknown[], root: Scope): OqxResult {
151
+ private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
115
152
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
116
153
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
117
154
  const occ = this.followWalk(seeds, query.follow!, root);
118
155
  let scopes: Scope[] = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
119
156
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
120
157
  this.sortScopes(scopes, query.orderBy);
121
- if (query.distinct) scopes = this.dedupByProjection(scopes, query.select);
122
- return this.shape(query.consumer, scopes, query.select);
158
+ if (query.distinct) scopes = this.dedupByProjection(scopes, query);
159
+ scopes = sliceBound(scopes, bound);
160
+ return this.shape(query.consumer, scopes, query);
123
161
  }
124
162
 
125
163
  // Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
126
164
  // distinct projection, preserving order. An empty projection dedups by row
127
165
  // identity (so `count distinct { }` counts distinct rows).
128
- private dedupByProjection(scopes: Scope[], select: SelectItem[]): Scope[] {
166
+ private dedupByProjection(scopes: Scope[], proj: Projection): Scope[] {
129
167
  const seen = new Set<string>();
130
168
  const out: Scope[] = [];
131
169
  for (const s of scopes) {
132
- const key = select.length === 0
170
+ const key = proj.select.length === 0
133
171
  ? `i:${String(this.ctx.identity(s.row))}`
134
- : `p:${stableStringify(this.projectRow(select, s))}`;
172
+ : `p:${stableStringify(this.projectRow(proj, s))}`;
135
173
  if (seen.has(key)) continue;
136
174
  seen.add(key);
137
175
  out.push(s);
@@ -213,27 +251,34 @@ export class InMemoryEngine implements Engine {
213
251
 
214
252
  // ---- consumer shaping -----------------------------------------------------
215
253
 
216
- private shape(consumer: Query["consumer"], scopes: Scope[], select: SelectItem[]): OqxResult {
254
+ private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
217
255
  switch (consumer) {
218
256
  case "exists": return { consumer, exists: scopes.length > 0 };
257
+ case "none": return { consumer, none: scopes.length === 0 };
219
258
  case "count": return { consumer, count: scopes.length };
220
- case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(select, s)) };
221
- case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(select, scopes[0]!) : null };
259
+ case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
260
+ case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
222
261
  case "single":
223
262
  if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
224
- return { consumer, row: scopes.length > 0 ? this.projectRow(select, scopes[0]!) : null };
263
+ return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
225
264
  }
226
265
  }
227
266
 
228
- private projectRow(select: SelectItem[], scope: Scope): unknown {
267
+ // The per-row result: the raw row (empty projection), the single item's value
268
+ // itself (`values` mode), or a `{ name: value }` record.
269
+ private projectRow(proj: Projection, scope: Scope): unknown {
270
+ const { select } = proj;
229
271
  if (select.length === 0) return scope.row;
272
+ if (proj.values) return this.itemValue(select[0]!, scope);
230
273
  const out: Record<string, unknown> = {};
231
- for (const item of select) {
232
- out[item.name] = item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
233
- }
274
+ for (const item of select) out[item.name] = this.itemValue(item, scope);
234
275
  return out;
235
276
  }
236
277
 
278
+ private itemValue(item: SelectItem, scope: Scope): unknown {
279
+ return item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
280
+ }
281
+
237
282
  // ---- where evaluation -----------------------------------------------------
238
283
 
239
284
  private evalWhere(w: Where, scope: Scope): boolean {
@@ -252,9 +297,15 @@ export class InMemoryEngine implements Engine {
252
297
 
253
298
  private evalWhereOp(op: OpNode, scope: Scope): boolean {
254
299
  if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
255
- if (op.op === "exists") return this.anyMatch(op, scope);
300
+ const bound = this.boundOf(op.sub, scope);
301
+ if (op.op === "exists" || op.op === "none") {
302
+ // Unbounded: stop at the first match (dedup cannot change emptiness).
303
+ // Bounded: the offset/limit decide emptiness, so materialize the set.
304
+ const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
305
+ return op.op === "exists" ? any : !any;
306
+ }
256
307
  if (op.op === "collect") {
257
- const matched = this.matchRows(op, scope);
308
+ const matched = this.opRows(op, scope, bound);
258
309
  for (const item of op.sub.select) {
259
310
  if (item.kind !== "field") continue;
260
311
  // Bind `item.lift` scopes out: `^` = the collect's own scope, `^^` its
@@ -270,12 +321,18 @@ export class InMemoryEngine implements Engine {
270
321
  return matched.length > 0;
271
322
  }
272
323
  if (op.op === "count") {
273
- let rows = this.matchRows(op, scope);
274
- if (op.distinct) rows = this.dedupByProjection(rows, op.sub.select);
275
- const n = rows.length;
324
+ const n = this.opRows(op, scope, bound).length;
276
325
  return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
277
326
  }
278
- return this.matchRows(op, scope).length > 0;
327
+ return this.opRows(op, scope, bound).length > 0;
328
+ }
329
+
330
+ // The rows a consumer op reduces: matched → ordered → distinct → bounded.
331
+ private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
332
+ let scopes = this.matchRows(op, scope);
333
+ this.sortScopes(scopes, op.sub.orderBy);
334
+ if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
335
+ return sliceBound(scopes, bound);
279
336
  }
280
337
 
281
338
  // Short-circuiting existence check over a consumer receiver.
@@ -300,23 +357,25 @@ export class InMemoryEngine implements Engine {
300
357
  // A select-position collect/first/single, optionally recursive via `follow`.
301
358
  private evalCollectValue(op: OpNode, scope: Scope): unknown {
302
359
  const sub = op.sub;
360
+ const bound = this.boundOf(sub, scope);
303
361
  let scopes: Scope[];
304
362
  if (sub.follow) {
305
363
  let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
306
364
  for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
307
365
  const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, { row: r, parent: scope, bindings: scope.bindings, lifts: {} })) : rows;
308
366
  scopes = this.followWalk(seeds, sub.follow, scope).map((o) => ({ row: o.row, parent: scope, bindings: scope.bindings, meta: o.meta }));
367
+ this.sortScopes(scopes, sub.orderBy);
368
+ if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
369
+ scopes = sliceBound(scopes, bound);
309
370
  } else {
310
- scopes = this.matchRows(op, scope);
371
+ scopes = this.opRows(op, scope, bound);
311
372
  }
312
- this.sortScopes(scopes, sub.orderBy);
313
- if (op.distinct) scopes = this.dedupByProjection(scopes, sub.select);
314
373
  switch (op.op) {
315
- case "collect": return scopes.map((s) => this.projectRow(sub.select, s));
316
- case "first": return scopes.length > 0 ? this.projectRow(sub.select, scopes[0]!) : null;
374
+ case "collect": return scopes.map((s) => this.projectRow(sub, s));
375
+ case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
317
376
  case "single":
318
377
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
319
- return scopes.length > 0 ? this.projectRow(sub.select, scopes[0]!) : null;
378
+ return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
320
379
  default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
321
380
  }
322
381
  }
@@ -390,9 +449,11 @@ export class InMemoryEngine implements Engine {
390
449
  }
391
450
 
392
451
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
393
- // in order: the recursion intrinsics (`$depth`, …) when it is a follow
394
- // occurrence; values lifted into it by `^name:` items; then either the row's
395
- // own property or, for the root scope (no row), the context's named roots.
452
+ // in order: `$value` (the scope's row itself the current item, whatever its
453
+ // type, so scalar collections are queryable; absent at the root, which has no
454
+ // row); the recursion intrinsics (`$depth`, ) when it is a follow occurrence;
455
+ // values lifted into it by `^name:` items; then either the row's own property
456
+ // or, for the root scope (no row), the context's named roots.
396
457
  //
397
458
  // A name the scope lacks is simply absent (undefined). It does NOT fall
398
459
  // through to an enclosing scope, so a query's meaning never depends on which
@@ -401,6 +462,7 @@ export class InMemoryEngine implements Engine {
401
462
  // always spelled explicitly as `^name`. Present-but-falsy values (null, false,
402
463
  // 0, "") need no special case — there is no "absent, so look outward" rule.
403
464
  private resolveIn(name: string, scope: Scope): unknown {
465
+ if (name === "$value") return scope.parent === null ? undefined : scope.row;
404
466
  if (RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
405
467
  if (scope.lifts && name in scope.lifts) return scope.lifts[name];
406
468
  if (scope.parent === null) return this.ctx.root(name);
@@ -446,6 +508,16 @@ function compareCount(n: number, cmp: { op: string; value: number }): boolean {
446
508
  return relate(cmp.op, n, cmp.value);
447
509
  }
448
510
 
511
+ // Apply a bound to an ordered row set / to a match count.
512
+ function sliceBound<T>(rows: T[], b: Bound): T[] {
513
+ if (b === UNBOUNDED) return rows;
514
+ return rows.slice(b.offset, b.limit == null ? undefined : b.offset + b.limit);
515
+ }
516
+ function boundedCount(n: number, b: Bound): number {
517
+ const rest = Math.max(0, n - b.offset);
518
+ return b.limit == null ? rest : Math.min(rest, b.limit);
519
+ }
520
+
449
521
  // Order `&&` conjuncts so cheap scalar leaves run before consumer-op leaves.
450
522
  function orderByCost(parts: Where[]): Where[] {
451
523
  return [...parts].sort((a, b) => whereCost(a) - whereCost(b));
package/src/index.ts CHANGED
@@ -43,8 +43,8 @@ export type * from "./ast.ts";
43
43
  const templateCache = new WeakMap<TemplateStringsArray, Query>();
44
44
 
45
45
  /** The OQX tagged template. Returns the query result shaped by its consumer:
46
- * an array for `collect` (the default), a boolean for `exists`, a number for
47
- * `count`, or a single record / null for `first` / `single`. */
46
+ * an array for `collect` (the default), a boolean for `exists` / `none`, a
47
+ * number for `count`, or a single record / null for `first` / `single`. */
48
48
  export function oqx(strings: TemplateStringsArray, ...values: unknown[]): unknown {
49
49
  let query = templateCache.get(strings);
50
50
  if (!query) {
@@ -82,6 +82,7 @@ function unwrap(result: OqxResult): unknown {
82
82
  switch (result.consumer) {
83
83
  case "collect": return result.rows;
84
84
  case "exists": return result.exists;
85
+ case "none": return result.none;
85
86
  case "count": return result.count;
86
87
  case "first": case "single": return result.row;
87
88
  }
package/src/parser.ts CHANGED
@@ -20,12 +20,13 @@ import type {
20
20
  Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, Consumer, RelOp,
21
21
  } from "./ast.ts";
22
22
 
23
- const CONSUMERS = new Set<string>(["collect", "exists", "count", "first", "single"]);
23
+ const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
24
24
  // Contextual clause words lexed as bare idents; an open-ended range must stop
25
25
  // before them rather than consume them as its high bound.
26
26
  const CLAUSE_WORDS = new Set<string>([
27
- "collect", "exists", "count", "first", "single",
28
- "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in",
27
+ "collect", "exists", "none", "count", "first", "single",
28
+ "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
29
+ "limit", "offset",
29
30
  ]);
30
31
  const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
31
32
  const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
@@ -49,6 +50,9 @@ interface BodyClauses {
49
50
  orderBy: OrderSpec[] | null;
50
51
  follow: Follow | null;
51
52
  distinct: boolean;
53
+ values: boolean;
54
+ limit: Expr | null;
55
+ offset: Expr | null;
52
56
  }
53
57
 
54
58
  class Parser {
@@ -89,6 +93,8 @@ class Parser {
89
93
  consumer: directive.op,
90
94
  follow: directive.sub.follow,
91
95
  distinct: directive.distinct ?? false,
96
+ values: directive.sub.values ?? false,
97
+ ...bounds(directive.sub),
92
98
  };
93
99
  }
94
100
  if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
@@ -108,6 +114,8 @@ class Parser {
108
114
  consumer: "collect",
109
115
  follow: body.follow,
110
116
  distinct: body.distinct,
117
+ values: body.values,
118
+ ...bounds(body),
111
119
  };
112
120
  }
113
121
 
@@ -124,6 +132,9 @@ class Parser {
124
132
  let orderBy: OrderSpec[] | null = null;
125
133
  let follow: Follow | null = null;
126
134
  let distinct = false;
135
+ let values = false;
136
+ let limit: Expr | null = null;
137
+ let offset: Expr | null = null;
127
138
  let sawWhere = false, sawSelect = false, sawOrder = false;
128
139
 
129
140
  while (!this.at("eof") && !this.at("rbrace")) {
@@ -148,7 +159,14 @@ class Parser {
148
159
  if (sawSelect) this.fail("duplicate projection");
149
160
  sawSelect = true;
150
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
151
- select = this.parseSelectItems();
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; }
152
170
  continue;
153
171
  }
154
172
  if (orderByAllowed && this.atOrderBy()) {
@@ -167,12 +185,22 @@ class Parser {
167
185
  if (this.at("ident") || this.at("binding")) {
168
186
  if (sawSelect) this.fail("duplicate projection (an implicit select cannot follow a `select`)");
169
187
  sawSelect = true;
170
- select = this.parseSelectItems();
188
+ ({ items: select, values } = this.parseProjection());
171
189
  continue;
172
190
  }
173
- this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/follow`);
191
+ this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/limit/offset/follow`);
174
192
  }
175
- return { froms, where, select, orderBy, follow, distinct };
193
+ return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
194
+ }
195
+
196
+ // `limit <n>` / `offset <n>` — the word must be followed by something that can
197
+ // be a bound (a number, a binding, or an outer reference), so a field that
198
+ // happens to be called `limit` still projects/filters as a bare name.
199
+ private atBound(): boolean {
200
+ const t = this.peek();
201
+ if (t.type !== "ident" || (t.value !== "limit" && t.value !== "offset")) return false;
202
+ const nx = this.peekAt(1);
203
+ return !!nx && (nx.type === "number" || nx.type === "binding" || nx.type === "caret");
176
204
  }
177
205
 
178
206
  // Decide, by syntactic shape only, whether a leading unkeyworded run is a
@@ -296,24 +324,40 @@ class Parser {
296
324
  }
297
325
 
298
326
  // ---- select ---------------------------------------------------------------
299
- private parseSelectItems(): SelectItem[] {
327
+ // A projection list, optionally followed by the `values` mode word. Under
328
+ // `values` the list must be exactly one item, which need not be named: the
329
+ // row's result IS that value (no `{ name: value }` record), so a name would be
330
+ // meaningless. Without `values`, every item needs a key — a bare/dotted
331
+ // navigation supplies its own (the last segment); any other expression must be
332
+ // aliased (`name: expr`).
333
+ private parseProjection(): { items: SelectItem[]; values: boolean } {
300
334
  const items = [this.parseSelectItem()];
301
335
  while (this.at("comma")) { this.next(); items.push(this.parseSelectItem()); }
302
- return items;
336
+ let values = false;
337
+ if (this.at("ident", "values")) {
338
+ this.next();
339
+ values = true;
340
+ if (items.length !== 1) this.fail("`values` projects exactly one expression (got " + items.length + ")");
341
+ const only = items[0]!;
342
+ if (only.kind === "field" && only.lift > 0) this.fail("a lift (^name: …) cannot be combined with `values`");
343
+ } else {
344
+ for (const it of items) {
345
+ 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`");
346
+ }
347
+ }
348
+ return { items, values };
303
349
  }
304
350
 
305
351
  private parseSelectItem(): SelectItem {
306
352
  // Leading `^`s mark a lift; the count is how many scopes out it binds.
307
353
  const lift = this.parseCarets();
308
- if (!this.at("ident")) this.fail("expected a projection name");
309
- const nameTok = this.next();
310
- if (this.at("colon")) {
311
- this.next();
312
- const name = nameTok.value;
354
+ if (this.at("ident") && this.peekAt(1)?.type === "colon") {
355
+ const name = this.next().value;
356
+ this.next(); // ':'
313
357
  const op = this.tryOp();
314
358
  if (op) {
315
359
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
316
- this.fail(`projection '${name}' must use collect/first/single, not ${op.op}`);
360
+ this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
317
361
  }
318
362
  if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
319
363
  return { kind: "collect", name, op };
@@ -321,9 +365,12 @@ class Parser {
321
365
  const expr = this.parseValueExpr();
322
366
  return { kind: "field", name, expr, lift };
323
367
  }
324
- // bare/dotted projection: key defaults to the last navigation segment.
325
- const { expr, name } = this.parseNavFrom(nameTok);
326
- return { kind: "field", name, expr, lift };
368
+ if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
369
+ // Unaliased item: a bare/dotted navigation keys by its last segment; any
370
+ // other expression is unnamed ("") legal only under `values` (checked by
371
+ // parseProjection, which sees the whole list).
372
+ const expr = this.parseValueExpr();
373
+ return { kind: "field", name: navKey(expr) ?? "", expr, lift };
327
374
  }
328
375
 
329
376
  // ---- order by -------------------------------------------------------------
@@ -382,7 +429,7 @@ class Parser {
382
429
  // Validate a consumer op used in where position and attach any `count { … } <op> N`.
383
430
  private finishWhereOp(op: OpNode): OpNode {
384
431
  if (op.op === "first" || op.op === "single") {
385
- this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } or count { … } <op> N`);
432
+ this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } / none { … } or count { … } <op> N`);
386
433
  }
387
434
  if (op.op === "collect") {
388
435
  const allLift = op.sub.select.length > 0 && op.sub.select.every((s) => s.kind === "field" && s.lift > 0);
@@ -433,7 +480,10 @@ class Parser {
433
480
 
434
481
  private parseSubquery(): { sub: Subquery; distinct: boolean } {
435
482
  const body = this.parseBody(true);
436
- return { sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow }, distinct: body.distinct };
483
+ return {
484
+ sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
485
+ distinct: body.distinct,
486
+ };
437
487
  }
438
488
 
439
489
  // ---- expression Pratt parser ----------------------------------------------
@@ -587,3 +637,20 @@ class Parser {
587
637
  this.fail(`unexpected ${this.tokDesc()} — expected a value`);
588
638
  }
589
639
  }
640
+
641
+ // The default key of an unaliased projection item: the last segment of a bare /
642
+ // dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
643
+ function navKey(e: Expr): string | null {
644
+ switch (e.kind) {
645
+ case "ident": return e.name;
646
+ case "outer": return e.name;
647
+ case "member": return e.name;
648
+ default: return null;
649
+ }
650
+ }
651
+
652
+ // The optional `limit`/`offset` fields of a Query/Subquery, present only when set
653
+ // (so a Query built without them is unchanged).
654
+ function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {
655
+ return { ...(b.limit ? { limit: b.limit } : {}), ...(b.offset ? { offset: b.offset } : {}) };
656
+ }
package/src/plan.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  // Because a bare identifier resolves against the current row ONLY (it never
13
13
  // climbs to an enclosing scope or a named root), an `ident` in a top-level
14
14
  // `where` is unambiguously a column of the scanned rows and is safe to push. An
15
- // `outer` (`^name`) reference is not a row column and stays residual.
15
+ // `outer` (`^name`) reference is not a row column and stays residual, and so is
16
+ // the `$value` intrinsic (the row itself, not one of its columns) — adapters
17
+ // gate idents on their known column set, which never includes it.
16
18
 
17
19
  import type { Query, Where, Expr } from "./ast.ts";
18
20