@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/README.md +31 -0
- package/dist/ast.d.ts +12 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +51 -8
- package/dist/engine.js.map +1 -1
- package/dist/lexer.d.ts +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +16 -1
- package/dist/lexer.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +92 -23
- package/dist/parser.js.map +1 -1
- package/dist/semantics.d.ts +21 -0
- package/dist/semantics.d.ts.map +1 -1
- package/dist/semantics.js +23 -2
- package/dist/semantics.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +12 -1
- package/src/engine.ts +49 -8
- package/src/lexer.ts +12 -1
- package/src/parser.ts +75 -17
- package/src/semantics.ts +36 -2
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"
|
|
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)
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
}
|