@omgbase/oqx 0.8.0 → 0.10.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 = "==" | "!=" | "<" | "<=" | ">" | ">=";
@@ -77,6 +78,14 @@ export interface Subquery {
77
78
  * row's result itself rather than being wrapped in a `{ name: value }` record,
78
79
  * so `name values` yields `["Bob", …]` and `$value values` yields the rows. */
79
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;
80
89
  }
81
90
 
82
91
  /** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
@@ -101,4 +110,7 @@ export interface Query {
101
110
  distinct?: boolean;
102
111
  /** `values` — scalar projection mode (see Subquery). */
103
112
  values?: boolean;
113
+ /** `limit N` / `offset N` (see Subquery); evaluated at the root scope. */
114
+ limit?: Expr;
115
+ offset?: Expr;
104
116
  }
package/src/engine.ts CHANGED
@@ -19,13 +19,14 @@ import type { DataContext } from "./context.ts";
19
19
  import { DefaultContext } from "./context.ts";
20
20
  import { OqxError } from "./errors.ts";
21
21
  import {
22
- equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange,
22
+ equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange, isEntry,
23
23
  } from "./semantics.ts";
24
24
 
25
25
  /** The shaped result of a top-level query, discriminated by consumer. */
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 };
@@ -38,8 +39,9 @@ export interface Engine {
38
39
  // One query scope: the row under evaluation plus the chain of enclosing scopes
39
40
  // that `^` walks. The root scope (parent === null) has no row; its names are the
40
41
  // context's named roots. `lifts` holds values bound INTO this scope by `^name:`
41
- // items in nested blocks; `meta` holds recursion intrinsics for a follow
42
- // occurrence.
42
+ // items in nested blocks; `meta` holds the scope's intrinsics: recursion
43
+ // metadata for a follow occurrence, and `$key` for an entry scope (a row that
44
+ // arrived as an `entries()` entry — see `enter`).
43
45
  interface Scope {
44
46
  row: unknown;
45
47
  parent: Scope | null;
@@ -49,12 +51,17 @@ interface Scope {
49
51
  }
50
52
 
51
53
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
54
+ const KEY = "$key";
52
55
  const HARD_DEPTH_CAP = 8;
53
56
 
54
57
  // What a scope projects to: the select list plus the `values` mode flag. Both
55
58
  // `Query` and `Subquery` carry this shape.
56
59
  type Projection = Pick<Subquery, "select" | "values">;
57
60
 
61
+ // An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
62
+ interface Bound { offset: number; limit: number | null; }
63
+ const UNBOUNDED: Bound = { offset: 0, limit: null };
64
+
58
65
  export class InMemoryEngine implements Engine {
59
66
  private ctx: DataContext;
60
67
 
@@ -69,26 +76,34 @@ export class InMemoryEngine implements Engine {
69
76
  rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
70
77
  }
71
78
 
72
- if (query.follow) return this.runFollow(query, rows, root);
79
+ const bound = this.boundOf(query, root);
80
+ if (query.follow) return this.runFollow(query, rows, root, bound);
73
81
 
74
82
  // Consumer-directed short-circuits (skipped under `distinct`, which must
75
- // materialize + dedup by projection before reducing).
76
- if (!query.distinct && query.consumer === "exists") {
77
- for (const r of rows) if (this.matches(query.where, r, root)) return { consumer: "exists", exists: true };
78
- return { consumer: "exists", exists: false };
79
- }
80
- if (!query.distinct && query.consumer === "count") {
81
- let count = 0;
82
- for (const r of rows) if (this.matches(query.where, r, root)) count++;
83
- return { consumer: "count", count };
83
+ // materialize + dedup by projection before reducing). Counting never
84
+ // materializes rows; exists/none stop as soon as the bound is known to be
85
+ // non-empty (the (offset+1)th match — or the first, when unbounded).
86
+ if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
87
+ const need = query.consumer === "count" ? Infinity : bound.offset + 1;
88
+ let n = 0;
89
+ for (const r of rows) {
90
+ if (this.matches(query.where, r, root)) { n++; if (n >= need) break; }
91
+ }
92
+ const m = boundedCount(n, bound);
93
+ if (query.consumer === "count") return { consumer: "count", count: m };
94
+ if (query.consumer === "exists") return { consumer: "exists", exists: m > 0 };
95
+ return { consumer: "none", none: m === 0 };
84
96
  }
85
97
 
86
- const cap = !query.orderBy && !query.distinct && query.consumer === "first" ? 1
87
- : !query.orderBy && !query.distinct && query.consumer === "single" ? 2
98
+ // first/single over an unordered, non-distinct set need only the rows up to
99
+ // the bound: offset + 1 (first) / offset + 2 (single, to detect a second).
100
+ const want = query.consumer === "first" ? 1 : query.consumer === "single" ? 2 : Infinity;
101
+ const cap = !query.orderBy && !query.distinct && want !== Infinity
102
+ ? bound.offset + Math.min(want, bound.limit ?? want)
88
103
  : Infinity;
89
104
  let kept: Scope[] = [];
90
105
  for (const r of rows) {
91
- const s: Scope = { row: r, parent: root, bindings, lifts: {} };
106
+ const s = this.enter(r, root, { lifts: {} });
92
107
  if (!query.where || this.evalWhere(query.where, s)) {
93
108
  kept.push(s);
94
109
  if (kept.length >= cap) break;
@@ -96,13 +111,33 @@ export class InMemoryEngine implements Engine {
96
111
  }
97
112
  this.sortScopes(kept, query.orderBy);
98
113
  if (query.distinct) kept = this.dedupByProjection(kept, query);
114
+ kept = sliceBound(kept, bound);
99
115
  return this.shape(query.consumer, kept, query);
100
116
  }
101
117
 
118
+ // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
119
+ // is read in a row-less scope INSIDE it: a bare name is absent (there is no
120
+ // current item yet), `^name` is the enclosing row — exactly as in the block's
121
+ // body — and literals/bindings are themselves. For a top-level query `scope`
122
+ // is the root. Each must be a non-negative integer.
123
+ private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
124
+ if (!b.limit && !b.offset) return UNBOUNDED;
125
+ const scope: Scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
126
+ const read = (e: Expr | undefined, word: string): number | null => {
127
+ if (!e) return null;
128
+ const v = this.evalExpr(e, scope);
129
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
130
+ throw new OqxError(`${word} must be a non-negative integer (got ${JSON.stringify(v ?? null)})`, "eval");
131
+ }
132
+ return v;
133
+ };
134
+ return { offset: read(b.offset, "offset") ?? 0, limit: read(b.limit, "limit") };
135
+ }
136
+
102
137
  // A where match that needs no lift capture (exists/count fast paths).
103
138
  private matches(where: Where | null, row: unknown, parent: Scope): boolean {
104
139
  if (!where) return true;
105
- return this.evalWhere(where, { row, parent, bindings: parent.bindings });
140
+ return this.evalWhere(where, this.enter(row, parent));
106
141
  }
107
142
 
108
143
  private rowsOf(v: unknown): unknown[] {
@@ -110,19 +145,36 @@ export class InMemoryEngine implements Engine {
110
145
  }
111
146
 
112
147
  private child(row: unknown, parent: Scope): Scope {
113
- return { row, parent, bindings: parent.bindings };
148
+ return this.enter(row, parent);
149
+ }
150
+
151
+ // Make the scope for a row. An `entries()` entry is unwrapped here: the scope's
152
+ // row is the property's VALUE (so `$value` and bare names read it) and the key
153
+ // becomes the `$key` intrinsic in `meta`. Every place a row becomes a scope
154
+ // goes through this, so entries behave the same at the top level, in nested
155
+ // blocks, as `from` re-projections, and as follow seeds.
156
+ private enter(row: unknown, parent: Scope, extra: { lifts?: Record<string, unknown>; meta?: Record<string, unknown> } = {}): Scope {
157
+ const s: Scope = { row, parent, bindings: parent.bindings };
158
+ if (extra.lifts) s.lifts = extra.lifts;
159
+ if (extra.meta) s.meta = extra.meta;
160
+ if (isEntry(row)) {
161
+ s.row = row.value;
162
+ s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
163
+ }
164
+ return s;
114
165
  }
115
166
 
116
167
  // ---- follow ---------------------------------------------------------------
117
168
 
118
- private runFollow(query: Query, rows: unknown[], root: Scope): OqxResult {
169
+ private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
119
170
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
120
171
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
121
172
  const occ = this.followWalk(seeds, query.follow!, root);
122
- let scopes: Scope[] = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
173
+ let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
123
174
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
124
175
  this.sortScopes(scopes, query.orderBy);
125
176
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
177
+ scopes = sliceBound(scopes, bound);
126
178
  return this.shape(query.consumer, scopes, query);
127
179
  }
128
180
 
@@ -158,7 +210,7 @@ export class InMemoryEngine implements Engine {
158
210
  // `ctx.identity(row)`.
159
211
  private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
160
212
  const cap = follow.depth ?? HARD_DEPTH_CAP;
161
- const scopeFor = (row: unknown): Scope => ({ row, parent, bindings: parent.bindings });
213
+ const scopeFor = (row: unknown): Scope => this.enter(row, parent);
162
214
  const keyOf = (row: unknown): string =>
163
215
  String(follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(row));
164
216
  const succOf = (row: unknown): unknown[] => {
@@ -220,6 +272,7 @@ export class InMemoryEngine implements Engine {
220
272
  private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
221
273
  switch (consumer) {
222
274
  case "exists": return { consumer, exists: scopes.length > 0 };
275
+ case "none": return { consumer, none: scopes.length === 0 };
223
276
  case "count": return { consumer, count: scopes.length };
224
277
  case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
225
278
  case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
@@ -262,9 +315,15 @@ export class InMemoryEngine implements Engine {
262
315
 
263
316
  private evalWhereOp(op: OpNode, scope: Scope): boolean {
264
317
  if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
265
- if (op.op === "exists") return this.anyMatch(op, scope);
318
+ const bound = this.boundOf(op.sub, scope);
319
+ if (op.op === "exists" || op.op === "none") {
320
+ // Unbounded: stop at the first match (dedup cannot change emptiness).
321
+ // Bounded: the offset/limit decide emptiness, so materialize the set.
322
+ const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
323
+ return op.op === "exists" ? any : !any;
324
+ }
266
325
  if (op.op === "collect") {
267
- const matched = this.matchRows(op, scope);
326
+ const matched = this.opRows(op, scope, bound);
268
327
  for (const item of op.sub.select) {
269
328
  if (item.kind !== "field") continue;
270
329
  // Bind `item.lift` scopes out: `^` = the collect's own scope, `^^` its
@@ -280,12 +339,18 @@ export class InMemoryEngine implements Engine {
280
339
  return matched.length > 0;
281
340
  }
282
341
  if (op.op === "count") {
283
- let rows = this.matchRows(op, scope);
284
- if (op.distinct) rows = this.dedupByProjection(rows, op.sub);
285
- const n = rows.length;
342
+ const n = this.opRows(op, scope, bound).length;
286
343
  return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
287
344
  }
288
- return this.matchRows(op, scope).length > 0;
345
+ return this.opRows(op, scope, bound).length > 0;
346
+ }
347
+
348
+ // The rows a consumer op reduces: matched → ordered → distinct → bounded.
349
+ private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
350
+ let scopes = this.matchRows(op, scope);
351
+ this.sortScopes(scopes, op.sub.orderBy);
352
+ if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
353
+ return sliceBound(scopes, bound);
289
354
  }
290
355
 
291
356
  // Short-circuiting existence check over a consumer receiver.
@@ -298,7 +363,7 @@ export class InMemoryEngine implements Engine {
298
363
  let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
299
364
  for (const proj of op.sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
300
365
  for (const r of rows) {
301
- const s: Scope = { row: r, parent: scope, bindings: scope.bindings, lifts: {} };
366
+ const s = this.enter(r, scope, { lifts: {} });
302
367
  if (!op.sub.where || this.evalWhere(op.sub.where, s)) yield s;
303
368
  }
304
369
  }
@@ -310,17 +375,19 @@ export class InMemoryEngine implements Engine {
310
375
  // A select-position collect/first/single, optionally recursive via `follow`.
311
376
  private evalCollectValue(op: OpNode, scope: Scope): unknown {
312
377
  const sub = op.sub;
378
+ const bound = this.boundOf(sub, scope);
313
379
  let scopes: Scope[];
314
380
  if (sub.follow) {
315
381
  let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
316
382
  for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
317
- const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, { row: r, parent: scope, bindings: scope.bindings, lifts: {} })) : rows;
318
- scopes = this.followWalk(seeds, sub.follow, scope).map((o) => ({ row: o.row, parent: scope, bindings: scope.bindings, meta: o.meta }));
383
+ const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, this.enter(r, scope, { lifts: {} }))) : rows;
384
+ scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
385
+ this.sortScopes(scopes, sub.orderBy);
386
+ if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
387
+ scopes = sliceBound(scopes, bound);
319
388
  } else {
320
- scopes = this.matchRows(op, scope);
389
+ scopes = this.opRows(op, scope, bound);
321
390
  }
322
- this.sortScopes(scopes, sub.orderBy);
323
- if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
324
391
  switch (op.op) {
325
392
  case "collect": return scopes.map((s) => this.projectRow(sub, s));
326
393
  case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
@@ -402,9 +469,10 @@ export class InMemoryEngine implements Engine {
402
469
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
403
470
  // in order: `$value` (the scope's row itself — the current item, whatever its
404
471
  // type, so scalar collections are queryable; absent at the root, which has no
405
- // row); the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
406
- // values lifted into it by `^name:` items; then either the row's own property
407
- // or, for the root scope (no row), the context's named roots.
472
+ // row); `$key` (the property key, for an entry scope only — see `enter`); the
473
+ // recursion intrinsics (`$depth`, …) when it is a follow occurrence; values
474
+ // lifted into it by `^name:` items; then either the row's own property or,
475
+ // for the root scope (no row), the context's named roots.
408
476
  //
409
477
  // A name the scope lacks is simply absent (undefined). It does NOT fall
410
478
  // through to an enclosing scope, so a query's meaning never depends on which
@@ -414,7 +482,7 @@ export class InMemoryEngine implements Engine {
414
482
  // 0, "") need no special case — there is no "absent, so look outward" rule.
415
483
  private resolveIn(name: string, scope: Scope): unknown {
416
484
  if (name === "$value") return scope.parent === null ? undefined : scope.row;
417
- if (RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
485
+ if (name === KEY || RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
418
486
  if (scope.lifts && name in scope.lifts) return scope.lifts[name];
419
487
  if (scope.parent === null) return this.ctx.root(name);
420
488
  return this.ctx.get(scope.row, name);
@@ -459,6 +527,16 @@ function compareCount(n: number, cmp: { op: string; value: number }): boolean {
459
527
  return relate(cmp.op, n, cmp.value);
460
528
  }
461
529
 
530
+ // Apply a bound to an ordered row set / to a match count.
531
+ function sliceBound<T>(rows: T[], b: Bound): T[] {
532
+ if (b === UNBOUNDED) return rows;
533
+ return rows.slice(b.offset, b.limit == null ? undefined : b.offset + b.limit);
534
+ }
535
+ function boundedCount(n: number, b: Bound): number {
536
+ const rest = Math.max(0, n - b.offset);
537
+ return b.limit == null ? rest : Math.min(rest, b.limit);
538
+ }
539
+
462
540
  // Order `&&` conjuncts so cheap scalar leaves run before consumer-op leaves.
463
541
  function orderByCost(parts: Where[]): Where[] {
464
542
  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",
27
+ "collect", "exists", "none", "count", "first", "single",
28
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>(["==", "!=", "<", "<=", ">", ">="]);
@@ -50,6 +51,8 @@ interface BodyClauses {
50
51
  follow: Follow | null;
51
52
  distinct: boolean;
52
53
  values: boolean;
54
+ limit: Expr | null;
55
+ offset: Expr | null;
53
56
  }
54
57
 
55
58
  class Parser {
@@ -91,6 +94,7 @@ class Parser {
91
94
  follow: directive.sub.follow,
92
95
  distinct: directive.distinct ?? false,
93
96
  values: directive.sub.values ?? false,
97
+ ...bounds(directive.sub),
94
98
  };
95
99
  }
96
100
  if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
@@ -111,6 +115,7 @@ class Parser {
111
115
  follow: body.follow,
112
116
  distinct: body.distinct,
113
117
  values: body.values,
118
+ ...bounds(body),
114
119
  };
115
120
  }
116
121
 
@@ -128,6 +133,8 @@ class Parser {
128
133
  let follow: Follow | null = null;
129
134
  let distinct = false;
130
135
  let values = false;
136
+ let limit: Expr | null = null;
137
+ let offset: Expr | null = null;
131
138
  let sawWhere = false, sawSelect = false, sawOrder = false;
132
139
 
133
140
  while (!this.at("eof") && !this.at("rbrace")) {
@@ -155,6 +162,13 @@ class Parser {
155
162
  ({ items: select, values } = this.parseProjection());
156
163
  continue;
157
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; }
170
+ continue;
171
+ }
158
172
  if (orderByAllowed && this.atOrderBy()) {
159
173
  if (sawOrder) this.fail("duplicate `order by` clause");
160
174
  sawOrder = true;
@@ -174,9 +188,19 @@ class Parser {
174
188
  ({ items: select, values } = this.parseProjection());
175
189
  continue;
176
190
  }
177
- 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`);
178
192
  }
179
- return { froms, where, select, orderBy, follow, distinct, values };
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");
180
204
  }
181
205
 
182
206
  // Decide, by syntactic shape only, whether a leading unkeyworded run is a
@@ -270,7 +294,8 @@ class Parser {
270
294
  // A receiver/source: a `${…}` binding, or a dotted identifier navigation chain
271
295
  // whose head may be an outer reference (`^rel`, `^^root.rel`) — since a bare
272
296
  // name is the current row's own property, an enclosing row's relation or a
273
- // named root is only reachable as a receiver through `^`.
297
+ // named root is only reachable as a receiver through `^` — or a free-function
298
+ // call (`entries(prefs)`), so a computed collection can be consumed directly.
274
299
  private parseReceiver(): Expr {
275
300
  if (this.at("binding")) return { kind: "binding", index: this.next().index! };
276
301
  const levels = this.parseCarets();
@@ -286,10 +311,12 @@ class Parser {
286
311
  }
287
312
 
288
313
  // A dotted navigation chain from `head`; `levels` > 0 makes the head an outer
289
- // reference read exactly that many scopes out.
314
+ // reference read exactly that many scopes out. A bare head followed by `(` is
315
+ // a free-function call (`entries(x)`), which may then be navigated further.
290
316
  private parseNavFrom(head: Token, levels = 0): { expr: Expr; name: string } {
291
317
  let expr: Expr = levels > 0 ? { kind: "outer", levels, name: head.value } : { kind: "ident", name: head.value };
292
318
  let name = head.value;
319
+ if (levels === 0 && this.at("lparen")) expr = { kind: "call", recv: null, name: head.value, args: this.parseArgs() };
293
320
  while (this.at("dot")) {
294
321
  this.next();
295
322
  if (!this.at("ident")) this.fail("expected an identifier after '.' in a navigation");
@@ -333,7 +360,7 @@ class Parser {
333
360
  const op = this.tryOp();
334
361
  if (op) {
335
362
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
336
- this.fail(`projection '${name}' must use collect/first/single, not ${op.op}`);
363
+ this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
337
364
  }
338
365
  if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
339
366
  return { kind: "collect", name, op };
@@ -405,7 +432,7 @@ class Parser {
405
432
  // Validate a consumer op used in where position and attach any `count { … } <op> N`.
406
433
  private finishWhereOp(op: OpNode): OpNode {
407
434
  if (op.op === "first" || op.op === "single") {
408
- this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } or count { … } <op> N`);
435
+ this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } / none { … } or count { … } <op> N`);
409
436
  }
410
437
  if (op.op === "collect") {
411
438
  const allLift = op.sub.select.length > 0 && op.sub.select.every((s) => s.kind === "field" && s.lift > 0);
@@ -457,7 +484,7 @@ class Parser {
457
484
  private parseSubquery(): { sub: Subquery; distinct: boolean } {
458
485
  const body = this.parseBody(true);
459
486
  return {
460
- sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values },
487
+ sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
461
488
  distinct: body.distinct,
462
489
  };
463
490
  }
@@ -624,3 +651,9 @@ function navKey(e: Expr): string | null {
624
651
  default: return null;
625
652
  }
626
653
  }
654
+
655
+ // The optional `limit`/`offset` fields of a Query/Subquery, present only when set
656
+ // (so a Query built without them is unchanged).
657
+ function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {
658
+ return { ...(b.limit ? { limit: b.limit } : {}), ...(b.offset ? { offset: b.offset } : {}) };
659
+ }
package/src/semantics.ts CHANGED
@@ -167,9 +167,51 @@ export function parseRangeString(s: string): OqxRange | null {
167
167
  return null;
168
168
  }
169
169
 
170
+ // ---- entries: the explicit record → collection bridge -----------------------
171
+ //
172
+ // A plain object is NOT iterable in OQX (`from ${obj}` is one row). `entries(x)`
173
+ // converts it explicitly into a collection of ENTRY values, each `{ key, value }`
174
+ // tagged (non-enumerably, so JSON/deepEqual see a plain record) so the engine
175
+ // can recognize one: when an entry becomes a query scope, the scope's ROW is
176
+ // the property's value (`$value`, bare names) and `$key` is scope metadata.
177
+ // Arrays yield numeric index keys; a Map yields its entries; null/scalars yield
178
+ // nothing. (Arrays never expose an implicit `$key` — `entries(arr)` is how you
179
+ // ask for the index.)
180
+
181
+ const ENTRY: unique symbol = Symbol.for("oqx.entry");
182
+
183
+ export interface OqxEntry {
184
+ readonly key: unknown;
185
+ readonly value: unknown;
186
+ readonly [ENTRY]: true;
187
+ }
188
+
189
+ /** Construct an entry value (see entriesOf). */
190
+ export function makeEntry(key: unknown, value: unknown): OqxEntry {
191
+ const e = { key, value } as { key: unknown; value: unknown; [ENTRY]?: true };
192
+ Object.defineProperty(e, ENTRY, { value: true, enumerable: false });
193
+ return e as OqxEntry;
194
+ }
195
+
196
+ /** Whether a value is an entry produced by `entries()` / `makeEntry`. */
197
+ export function isEntry(v: unknown): v is OqxEntry {
198
+ return typeof v === "object" && v !== null && (v as { [ENTRY]?: unknown })[ENTRY] === true;
199
+ }
200
+
201
+ /** The entries of a host value: object → own enumerable (key, value) pairs in
202
+ * insertion order; array → (index, element); Map → its entries; anything else
203
+ * (absent, scalars, ranges) → none. */
204
+ export function entriesOf(v: unknown): OqxEntry[] {
205
+ if (v == null || typeof v !== "object" || isRange(v)) return [];
206
+ if (Array.isArray(v)) return v.map((x, i) => makeEntry(i, x));
207
+ if (v instanceof Map) return Array.from(v, ([k, x]) => makeEntry(k, x));
208
+ return Object.entries(v as Record<string, unknown>).map(([k, x]) => makeEntry(k, x));
209
+ }
210
+
170
211
  /** Free functions callable as `name(args)`. */
171
212
  export const BUILTIN_FUNCTIONS: Record<string, (args: unknown[]) => unknown> = {
172
213
  list: (args) => toList(args[0]),
214
+ entries: (args) => entriesOf(args[0]),
173
215
  size: (args) => sizeOf(args[0]),
174
216
  has: (args) => args[0] != null,
175
217
  // Coerce a string to a range value (or pass a range through); anything else,