@omgbase/oqx 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts CHANGED
@@ -21,6 +21,12 @@ import type {
21
21
  } from "./ast.ts";
22
22
 
23
23
  const CONSUMERS = new Set<string>(["collect", "exists", "count", "first", "single"]);
24
+ // Contextual clause words lexed as bare idents; an open-ended range must stop
25
+ // before them rather than consume them as its high bound.
26
+ const CLAUSE_WORDS = new Set<string>([
27
+ "collect", "exists", "count", "first", "single",
28
+ "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in",
29
+ ]);
24
30
  const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
25
31
  const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
26
32
  const ADD_OPS = new Set<string>(["+", "-"]);
@@ -42,6 +48,7 @@ interface BodyClauses {
42
48
  select: SelectItem[];
43
49
  orderBy: OrderSpec[] | null;
44
50
  follow: Follow | null;
51
+ distinct: boolean;
45
52
  }
46
53
 
47
54
  class Parser {
@@ -81,6 +88,7 @@ class Parser {
81
88
  orderBy: directive.sub.orderBy,
82
89
  consumer: directive.op,
83
90
  follow: directive.sub.follow,
91
+ distinct: directive.distinct ?? false,
84
92
  };
85
93
  }
86
94
  if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
@@ -99,6 +107,7 @@ class Parser {
99
107
  orderBy: body.orderBy,
100
108
  consumer: "collect",
101
109
  follow: body.follow,
110
+ distinct: body.distinct,
102
111
  };
103
112
  }
104
113
 
@@ -114,6 +123,7 @@ class Parser {
114
123
  let select: SelectItem[] = [];
115
124
  let orderBy: OrderSpec[] | null = null;
116
125
  let follow: Follow | null = null;
126
+ let distinct = false;
117
127
  let sawWhere = false, sawSelect = false, sawOrder = false;
118
128
 
119
129
  while (!this.at("eof") && !this.at("rbrace")) {
@@ -137,7 +147,7 @@ class Parser {
137
147
  if (this.at("kw", "select") || this.at("caret")) {
138
148
  if (sawSelect) this.fail("duplicate projection");
139
149
  sawSelect = true;
140
- if (this.at("kw")) this.next(); // consume `select`; a leading `^` is part of the item
150
+ 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
141
151
  select = this.parseSelectItems();
142
152
  continue;
143
153
  }
@@ -162,7 +172,7 @@ class Parser {
162
172
  }
163
173
  this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/follow`);
164
174
  }
165
- return { froms, where, select, orderBy, follow };
175
+ return { froms, where, select, orderBy, follow, distinct };
166
176
  }
167
177
 
168
178
  // Decide, by syntactic shape only, whether a leading unkeyworded run is a
@@ -175,19 +185,27 @@ class Parser {
175
185
  let depth = 0;
176
186
  for (let i = this.pos; i < this.tokens.length; i++) {
177
187
  const t = this.tokens[i]!;
178
- if (t.type === "eof" || t.type === "rbrace") break;
188
+ if (t.type === "eof") break;
189
+ // Skip nested `{ … }` consumer blocks and `( … )` groups: a keyword/select
190
+ // inside them is not part of the top-level shape (e.g. `count { select x } == N`).
191
+ if (t.type === "lbrace" || t.type === "lparen") { depth++; continue; }
192
+ if (t.type === "rbrace" || t.type === "rparen") { if (depth === 0) break; depth--; continue; }
179
193
  if (depth === 0) {
180
194
  if (t.type === "colon" || t.type === "comma") return false;
181
195
  if (t.type === "kw") break;
182
196
  if (t.type === "op" && (CMP_OPS.has(t.value) || t.value === "&&" || t.value === "||")) return true;
183
197
  if (t.type === "ident" && t.value === "in") return true;
198
+ // A consumer directive in where position (`… exists { … }`, `… count { … }`,
199
+ // optionally `count distinct { … }`) is a predicate.
200
+ if (t.type === "ident" && CONSUMERS.has(t.value)) {
201
+ const nx = this.tokens[i + 1];
202
+ if (nx && (nx.type === "lbrace" || (nx.type === "ident" && nx.value === "distinct"))) return true;
203
+ }
184
204
  if (t.type === "ident" && (t.value === "order" || t.value === "follow")) {
185
205
  const nx = this.tokens[i + 1];
186
206
  if (nx && nx.type === "ident") break;
187
207
  }
188
208
  }
189
- if (t.type === "lparen") depth++;
190
- else if (t.type === "rparen") { if (depth === 0) break; depth--; }
191
209
  }
192
210
  return false;
193
211
  }
@@ -378,21 +396,29 @@ class Parser {
378
396
  else if (this.at("ident")) receiver = this.parseNavFrom(this.next()).expr;
379
397
  else return null;
380
398
 
381
- if (this.at("ident") && CONSUMERS.has(this.peek().value) && this.peekAt(1)?.type === "lbrace") {
382
- const op = this.next().value as Consumer;
383
- this.next(); // '{'
384
- const sub = this.parseSubquery();
385
- if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { } block`);
386
- this.next();
387
- return { kind: "op", receiver, op, sub };
399
+ if (this.at("ident") && CONSUMERS.has(this.peek().value)) {
400
+ const after = this.peekAt(1);
401
+ // `<op> { … }` or `<op> distinct { … }`.
402
+ const opThenBrace = after?.type === "lbrace";
403
+ const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
404
+ if (opThenBrace || opDistinctBrace) {
405
+ const op = this.next().value as Consumer;
406
+ let distinct = false;
407
+ if (this.at("ident", "distinct")) { this.next(); distinct = true; }
408
+ this.next(); // '{'
409
+ const { sub, distinct: bodyDistinct } = this.parseSubquery();
410
+ if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
411
+ this.next();
412
+ return { kind: "op", receiver, op, sub, distinct: distinct || bodyDistinct };
413
+ }
388
414
  }
389
415
  this.pos = start;
390
416
  return null;
391
417
  }
392
418
 
393
- private parseSubquery(): Subquery {
419
+ private parseSubquery(): { sub: Subquery; distinct: boolean } {
394
420
  const body = this.parseBody(true);
395
- return { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow };
421
+ return { sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow }, distinct: body.distinct };
396
422
  }
397
423
 
398
424
  // ---- expression Pratt parser ----------------------------------------------
@@ -414,15 +440,47 @@ class Parser {
414
440
  // Comparison / membership (also the entry point for a where scalar leaf, so a
415
441
  // where leaf never swallows the where-tree's && / ||).
416
442
  private parseCmp(): Expr {
417
- let left = this.parseAdd();
443
+ let left = this.parseRange();
418
444
  if (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) {
419
445
  const op = this.next().value;
420
- return { kind: "binary", op, left, right: this.parseAdd() };
446
+ return { kind: "binary", op, left, right: this.parseRange() };
421
447
  }
422
- if (this.at("ident", "in")) { this.next(); return { kind: "in", left, right: this.parseAdd() }; }
448
+ if (this.at("ident", "in")) { this.next(); return { kind: "in", left, right: this.parseRange() }; }
423
449
  return left;
424
450
  }
425
451
 
452
+ // Range literal: `lo..hi` / `lo...hi` and the open-ended forms `..hi`, `lo..`.
453
+ // Binds looser than arithmetic (so `1+1..2*3` is the range 2..6) but tighter
454
+ // than comparison / `in` (so `n in 1..5` reads as `n in (1..5)`). A leading
455
+ // `..`/`...` opens the low end; a trailing `..`/`...` with no following value
456
+ // opens the high end.
457
+ private parseRange(): Expr {
458
+ if (this.at("range")) {
459
+ const exclusiveEnd = this.next().value === "...";
460
+ return { kind: "range", lo: null, hi: this.parseAdd(), exclusiveEnd };
461
+ }
462
+ const lo = this.parseAdd();
463
+ if (this.at("range")) {
464
+ const exclusiveEnd = this.next().value === "...";
465
+ const hi = this.canStartValue() ? this.parseAdd() : null;
466
+ return { kind: "range", lo, hi, exclusiveEnd };
467
+ }
468
+ return lo;
469
+ }
470
+
471
+ // Whether the current token can begin a value expression — used to tell an
472
+ // open-ended range (`5..` followed by a clause boundary) from a bounded one.
473
+ // Clause-continuation words (`order`, `by`, `follow`, `asc/desc`, `distinct`,
474
+ // consumers, …) are lexed as bare idents, so they must NOT count as a value
475
+ // start, or `where age in 18.. order by name` would read `order` as the bound.
476
+ private canStartValue(): boolean {
477
+ const t = this.peek();
478
+ if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
479
+ if (t.type === "number" || t.type === "string" ||
480
+ t.type === "binding" || t.type === "lparen" || t.type === "caret") return true;
481
+ return t.type === "op" && (t.value === "-" || t.value === "!");
482
+ }
483
+
426
484
  private parseAdd(): Expr {
427
485
  let left = this.parseMul();
428
486
  while (this.peek().type === "op" && ADD_OPS.has(this.peek().value)) {
package/src/semantics.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  // throws, never orders). Numbers and strings order naturally; mixed types do
15
15
  // not order (false).
16
16
  // • Truthiness: JavaScript truthiness of the value.
17
- // • `in`: membership in an array (by ==), substring in a string, or key in an
18
- // object.
17
+ // • `in`: membership in an array (by ==), substring in a string, key in an
18
+ // object, or coverage by a range (lo..hi / lo...hi / open-ended).
19
19
 
20
20
  /** Equality with absence-normalization (undefined ≡ null) and strict typing. */
21
21
  export function equals(a: unknown, b: unknown): boolean {
@@ -55,12 +55,46 @@ export function arith(op: string, a: unknown, b: unknown): unknown {
55
55
 
56
56
  export function membership(needle: unknown, haystack: unknown): boolean {
57
57
  if (haystack == null) return false;
58
+ if (isRange(haystack)) return rangeCovers(haystack, needle);
58
59
  if (Array.isArray(haystack)) return haystack.some((x) => equals(x, needle));
59
60
  if (typeof haystack === "string") return haystack.includes(String(needle));
60
61
  if (typeof haystack === "object") return String(needle) in (haystack as object);
61
62
  return false;
62
63
  }
63
64
 
65
+ /** A Ruby-style range value produced by a `lo..hi` / `lo...hi` expression. A
66
+ * null bound is an open end (`..hi` / `lo..`). `exclusiveEnd` marks the `...`
67
+ * form (hi is excluded). Tagged so `membership`/`in` can recognize it among
68
+ * plain objects. */
69
+ export interface OqxRange {
70
+ readonly __oqxRange: true;
71
+ readonly lo: unknown;
72
+ readonly hi: unknown;
73
+ readonly exclusiveEnd: boolean;
74
+ }
75
+
76
+ /** Construct a range value (absent bounds normalized to null → open end). */
77
+ export function makeRange(lo: unknown, hi: unknown, exclusiveEnd: boolean): OqxRange {
78
+ return { __oqxRange: true, lo: lo ?? null, hi: hi ?? null, exclusiveEnd };
79
+ }
80
+
81
+ /** Whether a value is a range produced by `makeRange`. */
82
+ export function isRange(v: unknown): v is OqxRange {
83
+ return typeof v === "object" && v !== null && (v as { __oqxRange?: unknown }).__oqxRange === true;
84
+ }
85
+
86
+ /** Whether `x` falls within `range`: `lo <= x` (when lo is present) and either
87
+ * `x <= hi` (inclusive) or `x < hi` (exclusive end) (when hi is present). Bound
88
+ * checks go through `relate`, so an absent `x` — or one that doesn't order
89
+ * against a bound (mixed types) — is simply not covered (never throws). This
90
+ * also makes date/time ranges work over ISO-8601 strings or `Date` values,
91
+ * whose natural ordering `relate` already honors. */
92
+ export function rangeCovers(range: OqxRange, x: unknown): boolean {
93
+ const geLo = range.lo == null || relate(">=", x, range.lo);
94
+ const leHi = range.hi == null || relate(range.exclusiveEnd ? "<" : "<=", x, range.hi);
95
+ return geLo && leHi;
96
+ }
97
+
64
98
  export function truthy(v: unknown): boolean {
65
99
  return Boolean(v);
66
100
  }