@jarenjs/linq 0.34.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 +63 -0
- package/docs/LINQ-FORMAT.md +382 -0
- package/package.json +53 -0
- package/src/async.js +599 -0
- package/src/concurrency.js +213 -0
- package/src/document.js +212 -0
- package/src/errors.js +94 -0
- package/src/expression.js +320 -0
- package/src/index.js +14 -0
- package/src/provider.js +166 -0
- package/src/sequence.js +543 -0
- package/src/sources.js +98 -0
- package/types/index.d.ts +430 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `mapAsync` — the ONE explicit bounded-concurrency boundary
|
|
4
|
+
* (LINQ-FORMAT.md §11). Element-wise asynchronous work (an HTTP call, a
|
|
5
|
+
* model call, a file read per row) happens here and nowhere else: there
|
|
6
|
+
* is no parallel universe of `selectAwait`-shaped operators, and a
|
|
7
|
+
* per-element async *predicate* is `mapAsync` then `where`, by design.
|
|
8
|
+
*
|
|
9
|
+
* `concurrency` is REQUIRED — the unbounded default is how libraries
|
|
10
|
+
* like this take down a downstream service. `mode` reuses the
|
|
11
|
+
* `createTaskEffect` vocabulary (`parallel`/`concat`/`switch`/
|
|
12
|
+
* `exhaust`) so a reader who knows one knows the other. Failure is
|
|
13
|
+
* fail-closed: the first rejection aborts every in-flight callback and
|
|
14
|
+
* the source, the `compileDag` discipline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { LinqBuildError } from './errors.js';
|
|
18
|
+
|
|
19
|
+
const MODES = new Set(['parallel', 'concat', 'switch', 'exhaust']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a mapAsync options bag at BUILD time.
|
|
23
|
+
* @param {any} options
|
|
24
|
+
* @returns {{ concurrency: number, mode: string, ordered: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeMapAsyncOptions(options) {
|
|
27
|
+
if (options === null || typeof options !== 'object'
|
|
28
|
+
|| !Number.isInteger(options.concurrency) || options.concurrency < 1) {
|
|
29
|
+
throw new LinqBuildError('JL0005',
|
|
30
|
+
'mapAsync requires { concurrency: <positive integer> } — an unbounded default is a denial of service waiting for a slow downstream');
|
|
31
|
+
}
|
|
32
|
+
const mode = options.mode ?? 'parallel';
|
|
33
|
+
if (!MODES.has(mode)) {
|
|
34
|
+
throw new LinqBuildError('JL0005',
|
|
35
|
+
`mapAsync mode must be one of parallel|concat|switch|exhaust, got '${mode}'`);
|
|
36
|
+
}
|
|
37
|
+
return { concurrency: options.concurrency, mode, ordered: options.ordered !== false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Apply one mapAsync stage over an async item stream. The returned
|
|
42
|
+
* generator owns an AbortController: early termination (a downstream
|
|
43
|
+
* `break`/`return`) and the first rejection both abort every in-flight
|
|
44
|
+
* callback; the rejection then rethrows (fail closed, first failure
|
|
45
|
+
* wins).
|
|
46
|
+
* @param {AsyncIterator<any>} items
|
|
47
|
+
* @param {(item: any, signal: AbortSignal) => any} fn
|
|
48
|
+
* @param {{ concurrency: number, mode: string, ordered: boolean }} opts
|
|
49
|
+
* @returns {AsyncGenerator<any>}
|
|
50
|
+
*/
|
|
51
|
+
export async function* applyMapAsync(items, fn, opts) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const { signal } = controller;
|
|
54
|
+
/** The pipeline's own failure, so source cleanup cannot displace it.
|
|
55
|
+
* @type {{ reason: any } | null} */
|
|
56
|
+
let failure = null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (opts.mode === 'concat' || (opts.mode === 'parallel' && opts.concurrency === 1)) {
|
|
60
|
+
// strictly sequential; `concat` ignores the window by definition,
|
|
61
|
+
// and parallel-of-one degenerates to it — but `switch`/`exhaust`
|
|
62
|
+
// keep their racing semantics even at one in-flight task
|
|
63
|
+
for (;;) {
|
|
64
|
+
const step = await items.next();
|
|
65
|
+
if (step.done) return;
|
|
66
|
+
yield await fn(step.value, signal);
|
|
67
|
+
if (signal.aborted) return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (opts.mode === 'switch' || opts.mode === 'exhaust') {
|
|
72
|
+
// one in-flight task, with the source pulled EAGERLY: the next
|
|
73
|
+
// item races the running work. `switch` supersedes the task when
|
|
74
|
+
// a newer item wins the race (the task's own signal aborts and
|
|
75
|
+
// its result is discarded); `exhaust` drops the newer item.
|
|
76
|
+
const PULL = Symbol('pull');
|
|
77
|
+
let pending = items.next().then((step) => ({ [PULL]: step }));
|
|
78
|
+
let task = null; // { promise, abort }
|
|
79
|
+
let exhausted = false;
|
|
80
|
+
while (!exhausted || task !== null) {
|
|
81
|
+
const race = task === null
|
|
82
|
+
? [pending]
|
|
83
|
+
: [pending, task.promise.then((value) => ({ value }))];
|
|
84
|
+
const won = await Promise.race(exhausted && task !== null ? [race[1]] : race);
|
|
85
|
+
if (won !== undefined && PULL in won) {
|
|
86
|
+
const step = won[PULL];
|
|
87
|
+
if (step.done) {
|
|
88
|
+
exhausted = true;
|
|
89
|
+
pending = new Promise(() => {}); // never resolves again
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
pending = items.next().then((s) => ({ [PULL]: s }));
|
|
93
|
+
if (task !== null) {
|
|
94
|
+
if (opts.mode === 'exhaust') continue; // drop while busy
|
|
95
|
+
task.abort.abort(); // switch: supersede, discard
|
|
96
|
+
task.promise.catch(() => {}); // superseded rejections are moot
|
|
97
|
+
task = null;
|
|
98
|
+
}
|
|
99
|
+
const abort = new AbortController();
|
|
100
|
+
const onOuter = () => abort.abort();
|
|
101
|
+
signal.addEventListener('abort', onOuter, { once: true });
|
|
102
|
+
const current = {
|
|
103
|
+
abort,
|
|
104
|
+
promise: Promise.resolve(fn(step.value, abort.signal))
|
|
105
|
+
.finally(() => signal.removeEventListener('abort', onOuter)),
|
|
106
|
+
};
|
|
107
|
+
task = current;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// the task settled while still current
|
|
111
|
+
const { value } = /** @type {{ value: any }} */ (won);
|
|
112
|
+
task = null;
|
|
113
|
+
yield value;
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// parallel: a sliding window of `concurrency` in-flight tasks
|
|
119
|
+
const window = [];
|
|
120
|
+
let sourceDone = false;
|
|
121
|
+
const pull = async () => {
|
|
122
|
+
const step = await items.next();
|
|
123
|
+
if (step.done) { sourceDone = true; return null; }
|
|
124
|
+
const promise = Promise.resolve(fn(step.value, signal));
|
|
125
|
+
// a rejection must wait its turn in the ordered window without
|
|
126
|
+
// firing unhandledRejection while an earlier task is in flight
|
|
127
|
+
promise.catch(() => {});
|
|
128
|
+
return { promise };
|
|
129
|
+
};
|
|
130
|
+
if (opts.ordered) {
|
|
131
|
+
// completion order = source order; the window buffers at most
|
|
132
|
+
// `concurrency` results (the documented buffering cost)
|
|
133
|
+
while (!sourceDone && window.length < opts.concurrency) {
|
|
134
|
+
const task = await pull();
|
|
135
|
+
if (task !== null) window.push(task.promise);
|
|
136
|
+
}
|
|
137
|
+
while (window.length > 0) {
|
|
138
|
+
const value = await window.shift();
|
|
139
|
+
if (!sourceDone) {
|
|
140
|
+
const task = await pull();
|
|
141
|
+
if (task !== null) window.push(task.promise);
|
|
142
|
+
}
|
|
143
|
+
yield value;
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// unordered: yield on completion
|
|
148
|
+
let nextId = 0;
|
|
149
|
+
const inflight = new Map();
|
|
150
|
+
const start = async () => {
|
|
151
|
+
const step = await items.next();
|
|
152
|
+
if (step.done) { sourceDone = true; return; }
|
|
153
|
+
const id = nextId++;
|
|
154
|
+
inflight.set(id, Promise.resolve(fn(step.value, signal)).then(
|
|
155
|
+
(value) => ({ id, value }),
|
|
156
|
+
// the task's identity rides in an internal ENVELOPE, never on the
|
|
157
|
+
// rejection value. Stamping the value mutated whatever the handler
|
|
158
|
+
// threw: a frozen error became a different TypeError, a thrown
|
|
159
|
+
// string came back boxed, an ordinary error grew a private
|
|
160
|
+
// property, and a hostile proxy could break normalization outright.
|
|
161
|
+
(error) => { throw new TaskFailure(id, error); }));
|
|
162
|
+
};
|
|
163
|
+
while (!sourceDone && inflight.size < opts.concurrency) await start();
|
|
164
|
+
while (inflight.size > 0) {
|
|
165
|
+
let settled;
|
|
166
|
+
try {
|
|
167
|
+
settled = await Promise.race(inflight.values());
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (err instanceof TaskFailure) {
|
|
171
|
+
inflight.delete(err.id);
|
|
172
|
+
throw err.reason; // the ORIGINAL value, unmodified
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
inflight.delete(settled.id);
|
|
177
|
+
if (!sourceDone) await start();
|
|
178
|
+
yield settled.value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
failure = { reason: err };
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
controller.abort(); // early termination aborts in-flight work
|
|
187
|
+
if (typeof items.return === 'function') {
|
|
188
|
+
// The TASK's failure is the primary one — it is why the caller is
|
|
189
|
+
// here — so a failing close travels ALONGSIDE it as an aggregate
|
|
190
|
+
// rather than replacing it, and stands alone only when the pipeline
|
|
191
|
+
// itself succeeded. Composed as a rejection the `await` adopts,
|
|
192
|
+
// because a `throw` here would be the very substitution this
|
|
193
|
+
// avoids: it discards whatever completion the block was carrying.
|
|
194
|
+
await Promise.resolve(items.return(undefined)).then(undefined,
|
|
195
|
+
(cleanupError) => Promise.reject(failure === null
|
|
196
|
+
? cleanupError
|
|
197
|
+
: new AggregateError([failure.reason, cleanupError],
|
|
198
|
+
'mapAsync failed, and closing the source failed too')));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The internal envelope carrying which in-flight task rejected, so the
|
|
205
|
+
* rejection VALUE never has to be touched to find out.
|
|
206
|
+
*/
|
|
207
|
+
class TaskFailure {
|
|
208
|
+
/** @param {number} id @param {any} reason */
|
|
209
|
+
constructor(id, reason) {
|
|
210
|
+
this.id = id;
|
|
211
|
+
this.reason = reason;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/document.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Chain → document emission: the stage list a `Sequence` carries
|
|
4
|
+
* folds into ONE Jaren query document (QUERY-FORMAT.md). The item
|
|
5
|
+
* binding is always named `it` — nested phrases shadow it legally, the
|
|
6
|
+
* emitted JSON stays hand-readable, and every captured expression
|
|
7
|
+
* references `$it…` regardless of chain depth.
|
|
8
|
+
*
|
|
9
|
+
* Folding rule: consecutive stages join one FLWOR phrase while their
|
|
10
|
+
* clause slots stay free AND the chain order agrees with the phrase's
|
|
11
|
+
* fixed semantic order (`$where` → `$groupby` → `$orderby` → `$return`,
|
|
12
|
+
* QUERY-FORMAT §6.1). A stage that would violate either closes the
|
|
13
|
+
* phrase (default `$return: "$it"`) and opens a new segment iterating
|
|
14
|
+
* the closed one — `orderBy().where()` really does sort first and
|
|
15
|
+
* filter second, exactly as written.
|
|
16
|
+
*
|
|
17
|
+
* Empty clause slots carry {@link EMPTY}, never `null`. `null` is a
|
|
18
|
+
* legal query expression — `where(() => null)` filters everything out,
|
|
19
|
+
* `select(() => null)` projects nulls, `groupBy(() => null)` is one
|
|
20
|
+
* null-keyed group — so a slot that used `null` for "nothing here" could
|
|
21
|
+
* not tell an absent clause from a null-valued one, and silently emitted
|
|
22
|
+
* the document WITHOUT the clause. Every such query then returned its
|
|
23
|
+
* unfiltered, unprojected source.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { LinqBuildError } from './errors.js';
|
|
27
|
+
|
|
28
|
+
/** The fixed clause order a phrase may fill left-to-right. */
|
|
29
|
+
const SLOT_ORDER = ['where', 'groupby', 'orderby', 'return'];
|
|
30
|
+
|
|
31
|
+
/** The "this clause slot is unfilled" sentinel: a fresh object, so no
|
|
32
|
+
* value a caller can express is ever mistaken for it. */
|
|
33
|
+
const EMPTY = Symbol('linq.emptySlot');
|
|
34
|
+
|
|
35
|
+
/** An open FLWOR phrase under construction. */
|
|
36
|
+
function openPhrase(source) {
|
|
37
|
+
return {
|
|
38
|
+
source, fold: EMPTY, where: EMPTY, groupby: EMPTY, orderby: EMPTY, ret: EMPTY,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether every slot AFTER `slot` is still empty — chain order must
|
|
43
|
+
* agree with the phrase's fixed semantic order. */
|
|
44
|
+
function laterSlotsFree(phrase, slot) {
|
|
45
|
+
for (let i = SLOT_ORDER.indexOf(slot) + 1; i < SLOT_ORDER.length; i++) {
|
|
46
|
+
const later = SLOT_ORDER[i];
|
|
47
|
+
if (phrase[later === 'return' ? 'ret' : later] !== EMPTY) return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether `slot` may be filled fresh (itself empty, order respected). */
|
|
53
|
+
function slotFree(phrase, slot) {
|
|
54
|
+
return phrase[slot === 'return' ? 'ret' : slot] === EMPTY
|
|
55
|
+
&& laterSlotsFree(phrase, slot);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Close a phrase into a query expression. */
|
|
59
|
+
function closePhrase(phrase) {
|
|
60
|
+
const untouched = phrase.fold === EMPTY && phrase.where === EMPTY
|
|
61
|
+
&& phrase.groupby === EMPTY && phrase.orderby === EMPTY && phrase.ret === EMPTY;
|
|
62
|
+
if (untouched) return phrase.source;
|
|
63
|
+
const doc = {};
|
|
64
|
+
if (phrase.fold !== EMPTY) doc.$fold = { acc: phrase.fold };
|
|
65
|
+
doc.$for = { it: phrase.source };
|
|
66
|
+
if (phrase.where !== EMPTY) doc.$where = phrase.where;
|
|
67
|
+
if (phrase.groupby !== EMPTY) doc.$groupby = { g: phrase.groupby };
|
|
68
|
+
if (phrase.orderby !== EMPTY) {
|
|
69
|
+
doc.$orderby = phrase.orderby.length === 1 ? phrase.orderby[0] : phrase.orderby;
|
|
70
|
+
}
|
|
71
|
+
// the default group shape: an object member takes exactly one item,
|
|
72
|
+
// so the member sequence packs into an array constructor and an
|
|
73
|
+
// empty grouping key reads as null
|
|
74
|
+
doc.$return = phrase.ret !== EMPTY ? phrase.ret : (phrase.groupby !== EMPTY
|
|
75
|
+
? { key: { $default: ['$g', null] }, items: ['$it'] }
|
|
76
|
+
: '$it');
|
|
77
|
+
return doc;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Combine two predicates. */
|
|
81
|
+
const andJoin = (a, b) => (a === EMPTY ? b : { $and: [a, b] });
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Emit the query document for a stage list.
|
|
85
|
+
* @param {any} root - the root expression the items come from
|
|
86
|
+
* (`'$[*]'` for a plain source; a stripped document for
|
|
87
|
+
* `fromDocument`)
|
|
88
|
+
* @param {readonly any[]} stages
|
|
89
|
+
* @returns {any} the emitted query document (plain JSON)
|
|
90
|
+
*/
|
|
91
|
+
export function emitDocument(root, stages) {
|
|
92
|
+
let phrase = openPhrase(root);
|
|
93
|
+
/** Close the open phrase and reopen over its result. */
|
|
94
|
+
const reseat = () => { phrase = openPhrase(closePhrase(phrase)); };
|
|
95
|
+
|
|
96
|
+
for (const stage of stages) {
|
|
97
|
+
switch (stage.kind) {
|
|
98
|
+
case 'where':
|
|
99
|
+
// a second where in the same phrase COMBINES ($and); only a
|
|
100
|
+
// later-slot occupant (orderby/groupby/return) forces a segment
|
|
101
|
+
if (!laterSlotsFree(phrase, 'where')) reseat();
|
|
102
|
+
phrase.where = andJoin(phrase.where, stage.predicate);
|
|
103
|
+
break;
|
|
104
|
+
case 'select': // also selectMany: a multi-item projection flattens
|
|
105
|
+
if (!slotFree(phrase, 'return')) reseat();
|
|
106
|
+
phrase.ret = stage.projection;
|
|
107
|
+
reseat(); // later operators see projected items
|
|
108
|
+
break;
|
|
109
|
+
case 'orderBy':
|
|
110
|
+
if (!slotFree(phrase, 'orderby')) reseat();
|
|
111
|
+
phrase.orderby = [stage.spec];
|
|
112
|
+
break;
|
|
113
|
+
case 'thenBy': {
|
|
114
|
+
if (phrase.orderby === EMPTY || phrase.ret !== EMPTY) {
|
|
115
|
+
throw new LinqBuildError('JL0005',
|
|
116
|
+
'thenBy/thenByDescending must directly follow orderBy/orderByDescending');
|
|
117
|
+
}
|
|
118
|
+
phrase.orderby = [...phrase.orderby, stage.spec];
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'groupBy':
|
|
122
|
+
if (!slotFree(phrase, 'groupby')) reseat();
|
|
123
|
+
phrase.groupby = stage.key;
|
|
124
|
+
reseat(); // later operators see { key, items } groups
|
|
125
|
+
break;
|
|
126
|
+
case 'join':
|
|
127
|
+
// the hash-join shape: nested bindings + equality (the engine's
|
|
128
|
+
// compile-time rewrite turns exactly this into a hash probe)
|
|
129
|
+
phrase = openPhrase({
|
|
130
|
+
$for: { it: closePhrase(phrase), it2: stage.inner },
|
|
131
|
+
$where: stage.on,
|
|
132
|
+
$return: stage.result,
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
case 'aggregate': {
|
|
136
|
+
// the seeded fold: its own phrase, closed immediately — the
|
|
137
|
+
// result is one accumulated value, not a tuple stream
|
|
138
|
+
if (phrase.fold !== EMPTY || phrase.where !== EMPTY || phrase.groupby !== EMPTY
|
|
139
|
+
|| phrase.orderby !== EMPTY || phrase.ret !== EMPTY) reseat();
|
|
140
|
+
phrase.fold = stage.seed;
|
|
141
|
+
phrase.ret = stage.step;
|
|
142
|
+
reseat();
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'skip':
|
|
146
|
+
phrase = openPhrase({ $subsequence: [closePhrase(phrase), stage.count] });
|
|
147
|
+
break;
|
|
148
|
+
case 'take':
|
|
149
|
+
phrase = openPhrase({ $subsequence: [closePhrase(phrase), 0, stage.count] });
|
|
150
|
+
break;
|
|
151
|
+
case 'distinct':
|
|
152
|
+
phrase = openPhrase({ $distinct: closePhrase(phrase) });
|
|
153
|
+
break;
|
|
154
|
+
case 'reverse':
|
|
155
|
+
phrase = openPhrase({ $reverse: closePhrase(phrase) });
|
|
156
|
+
break;
|
|
157
|
+
case 'concat':
|
|
158
|
+
phrase = openPhrase({ $seq: [closePhrase(phrase), stage.other] });
|
|
159
|
+
break;
|
|
160
|
+
case 'defaultIfEmpty':
|
|
161
|
+
phrase = openPhrase({ $default: [closePhrase(phrase), stage.fallback] });
|
|
162
|
+
break;
|
|
163
|
+
case 'ofType': // keep only items the schema accepts
|
|
164
|
+
// the second $valid argument is a SCHEMA literal — verbatim by
|
|
165
|
+
// the registry's `schema` parameter kind, never an expression
|
|
166
|
+
phrase = openPhrase(closePhrase(phrase));
|
|
167
|
+
phrase.where = { $valid: ['$it', stage.schema] };
|
|
168
|
+
break;
|
|
169
|
+
case 'cast': // assert every item against the schema
|
|
170
|
+
phrase = openPhrase(closePhrase(phrase));
|
|
171
|
+
phrase.ret = { $assert: ['$it', stage.schema] };
|
|
172
|
+
break;
|
|
173
|
+
/* c8 ignore next 2 -- stages are produced by Sequence alone */
|
|
174
|
+
default:
|
|
175
|
+
throw new LinqBuildError('JL0005', `unknown stage kind '${stage.kind}'`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return closePhrase(phrase);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Wrap the emitted expression per terminal so results extract without
|
|
183
|
+
* ambiguity: the engine maps a result to `undefined | item | array of
|
|
184
|
+
* items`, and a SINGLE item that is itself an array would be
|
|
185
|
+
* indistinguishable from two items — so every element-window terminal
|
|
186
|
+
* emits `[window]` (one array item, Rule 3 constructor: elements
|
|
187
|
+
* flatten) and reads its elements.
|
|
188
|
+
* @param {any} expr - the emitted chain expression
|
|
189
|
+
* @param {string} terminal
|
|
190
|
+
* @param {readonly any[]} [args]
|
|
191
|
+
* @returns {any}
|
|
192
|
+
*/
|
|
193
|
+
export function wrapTerminal(expr, terminal, args = []) {
|
|
194
|
+
switch (terminal) {
|
|
195
|
+
case 'toArray': return [expr];
|
|
196
|
+
case 'first': return [{ $subsequence: [expr, 0, 1] }];
|
|
197
|
+
case 'single': return [{ $subsequence: [expr, 0, 2] }];
|
|
198
|
+
case 'last': return [{ $subsequence: [{ $reverse: expr }, 0, 1] }];
|
|
199
|
+
case 'elementAt': return [{ $subsequence: [expr, args[0], 1] }];
|
|
200
|
+
case 'count': return { $count: expr };
|
|
201
|
+
case 'sum': return { $sum: expr };
|
|
202
|
+
case 'average': return { $avg: expr };
|
|
203
|
+
case 'min': return { $min: expr };
|
|
204
|
+
case 'max': return { $max: expr };
|
|
205
|
+
case 'exists': return { $exists: expr };
|
|
206
|
+
case 'some': return { $some: { it: expr }, $satisfies: args[0] };
|
|
207
|
+
case 'every': return { $every: { it: expr }, $satisfies: args[0] };
|
|
208
|
+
/* c8 ignore next 2 -- terminals are produced by Sequence alone */
|
|
209
|
+
default:
|
|
210
|
+
throw new LinqBuildError('JL0005', `unknown terminal '${terminal}'`);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Error types for @jarenjs/linq, built on `@jarenjs/core`'s coded
|
|
4
|
+
* contract: every failure carries a stable `code` (JL0xxx build, JL2xxx
|
|
5
|
+
* runtime), a bare `reason`, a composed `message`, and — where a
|
|
6
|
+
* document position exists — a `docPath`. The normative table lives in
|
|
7
|
+
* docs/LINQ-FORMAT.md §9, proven in sync with `LINQ_CODES` below by a
|
|
8
|
+
* test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CodedError } from '@jarenjs/core/errors';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The runtime code table (the `CSV_CODES` shape): one entry per code
|
|
15
|
+
* this package can raise, proven in sync with LINQ-FORMAT.md §9's
|
|
16
|
+
* normative table by a test.
|
|
17
|
+
*/
|
|
18
|
+
export const LINQ_CODES = Object.freeze({
|
|
19
|
+
JL0001: 'from() received neither an iterable nor a provider',
|
|
20
|
+
JL0002: 'an expression proxy escaped its capture callback',
|
|
21
|
+
JL0003: 'ofType/cast need an injected compileTypeTest',
|
|
22
|
+
JL0004: 'an undeclared or reserved parameter name was used',
|
|
23
|
+
JL0005: 'an operator was used invalidly at build time',
|
|
24
|
+
JL0006: 'an unsupported operator was invoked',
|
|
25
|
+
JL2001: 'first/single found no element',
|
|
26
|
+
JL2002: 'single found more than one element',
|
|
27
|
+
JL2003: 'elementAt is out of range',
|
|
28
|
+
JL2004: 'an asynchronous provider cannot back the synchronous surface',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A defect in how the query was BUILT — raised while capturing
|
|
33
|
+
* expressions or emitting the document, before anything runs. Codes:
|
|
34
|
+
*
|
|
35
|
+
* - `JL0001` — `from()`/`fromDocument()` received a source that is
|
|
36
|
+
* neither an iterable nor a provider (`execute` duck)
|
|
37
|
+
* - `JL0002` — an expression proxy escaped the callback it was handed
|
|
38
|
+
* to (stored and reused across captures); the emitted document
|
|
39
|
+
* would be nonsense, so the build fails instead
|
|
40
|
+
* - `JL0003` — `ofType`/`cast` compile schema operators, which need
|
|
41
|
+
* the injected `compileTypeTest` hook (`from(src, {
|
|
42
|
+
* compileTypeTest })`, e.g. `createTypeTestCompiler()` from
|
|
43
|
+
* `@jarenjs/validate/query`)
|
|
44
|
+
* - `JL0004` — a parameter was referenced without being declared via
|
|
45
|
+
* `.params({...})`, or a declared name is reserved (`it`, `acc`,
|
|
46
|
+
* `g` — the document's own binding names)
|
|
47
|
+
* - `JL0005` — an operator was used invalidly at build time (`thenBy`
|
|
48
|
+
* without `orderBy`, `all()` off a plain path, a negative
|
|
49
|
+
* `skip`/`take`, a value that cannot embed in a document)
|
|
50
|
+
* - `JL0006` — an operator the mapping table records as
|
|
51
|
+
* `unsupported` was invoked (`zip`); the table names the reason
|
|
52
|
+
*/
|
|
53
|
+
export class LinqBuildError extends CodedError {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} code
|
|
56
|
+
* @param {string} reason - The bare reason; `message` is composed per
|
|
57
|
+
* the coded contract.
|
|
58
|
+
* @param {string} [docPath] - JSON Pointer into the emitted query
|
|
59
|
+
* document, where one exists.
|
|
60
|
+
* @param {Error} [cause]
|
|
61
|
+
*/
|
|
62
|
+
constructor(code, reason, docPath, cause) {
|
|
63
|
+
super('LinqBuildError', code, reason, docPath,
|
|
64
|
+
cause !== undefined ? { cause } : undefined);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A failure while a terminal operation ran. Codes:
|
|
70
|
+
*
|
|
71
|
+
* - `JL2001` — `first()`/`single()` over an empty sequence (the
|
|
72
|
+
* `OrDefault` variants return the default instead)
|
|
73
|
+
* - `JL2002` — `single()`/`singleOrDefault()` over two or more
|
|
74
|
+
* elements
|
|
75
|
+
* - `JL2003` — `elementAt(i)` with no element at position `i`
|
|
76
|
+
* - `JL2004` — a provider's `execute()` answered a promise. A
|
|
77
|
+
* `Sequence` terminal is a value (`toArray(): T[]`), so a promise
|
|
78
|
+
* cannot be returned under that type; emit `toDocument()` and await
|
|
79
|
+
* the provider directly instead.
|
|
80
|
+
*/
|
|
81
|
+
export class LinqRuntimeError extends CodedError {
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} code
|
|
84
|
+
* @param {string} reason - The bare reason; `message` is composed per
|
|
85
|
+
* the coded contract.
|
|
86
|
+
* @param {string} [docPath] - JSON Pointer into the emitted query
|
|
87
|
+
* document, where one exists.
|
|
88
|
+
* @param {Error} [cause]
|
|
89
|
+
*/
|
|
90
|
+
constructor(code, reason, docPath, cause) {
|
|
91
|
+
super('LinqRuntimeError', code, reason, docPath,
|
|
92
|
+
cause !== undefined ? { cause } : undefined);
|
|
93
|
+
}
|
|
94
|
+
}
|