@jarenjs/linq 0.49.2 → 0.56.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/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
package/src/expression.js
CHANGED
|
@@ -15,9 +15,18 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Method names shadow member access: `u.eq` is the operator, never the
|
|
17
17
|
* data member — reach a colliding member with `u.get('eq')`.
|
|
18
|
+
*
|
|
19
|
+
* A root whose items are an entity's rows carries the entity's RELATION
|
|
20
|
+
* TABLE (QUERY-PEN §3, MODEL-FORMAT §10.1): a member access naming a
|
|
21
|
+
* relation records a HOP and lowers, right here, to the correlated
|
|
22
|
+
* phrase the engine and the store both run — `p.author.email` is
|
|
23
|
+
* `{ $for: { r1: '$.User[*]' }, $where: { $eq: ['$r1.id', '$it.authorId'] },
|
|
24
|
+
* $return: '$r1.email' }` — so the emitted document never carries a
|
|
25
|
+
* relation name and never needs a dialect the oracle could not prove.
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
import { LinqBuildError } from './errors.js';
|
|
29
|
+
import { GROUP_ITEMS } from './document.js';
|
|
21
30
|
|
|
22
31
|
/** The unwrap key: proxy → its internal record. */
|
|
23
32
|
const NODE = Symbol('jaren-linq-node');
|
|
@@ -26,14 +35,71 @@ const NODE = Symbol('jaren-linq-node');
|
|
|
26
35
|
* goes through a bracketed, single-quoted selector. */
|
|
27
36
|
const SHORTHAND_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
/** A bare binding variable (`$it`, `$it2`, `$r1`): the one subject a
|
|
39
|
+
* hop correlates with directly; a fanned path is bound first. */
|
|
40
|
+
const BARE_VAR_RE = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
|
|
41
|
+
|
|
42
|
+
let epochCounter = 0;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The captures in progress, innermost last. A proxy is LIVE while its
|
|
46
|
+
* capture is on this stack and CURRENT only at the top: a nested
|
|
47
|
+
* capture (a chain built and run inside another callback) leaves the
|
|
48
|
+
* enclosing proxies usable once it returns, while a proxy of the
|
|
49
|
+
* enclosing capture used INSIDE the nested one is refused by name —
|
|
50
|
+
* the inner document would rebind `$it`, so the outer reference would
|
|
51
|
+
* silently point at the wrong item.
|
|
52
|
+
* @type {number[]}
|
|
53
|
+
*/
|
|
54
|
+
const captureStack = [];
|
|
30
55
|
|
|
31
56
|
/** @param {any} record */
|
|
32
57
|
function assertLive(record) {
|
|
33
|
-
if (record.epoch
|
|
58
|
+
if (record.epoch === captureStack[captureStack.length - 1]) return;
|
|
59
|
+
if (captureStack.includes(record.epoch)) {
|
|
34
60
|
throw new LinqBuildError('JL0002',
|
|
35
|
-
'an expression proxy
|
|
61
|
+
'an expression proxy of an enclosing capture was used inside a nested capture — '
|
|
62
|
+
+ 'a correlated subquery cannot be spelled this way (the inner document rebinds the '
|
|
63
|
+
+ 'item); compute the inner query first and use its result');
|
|
36
64
|
}
|
|
65
|
+
throw new LinqBuildError('JL0002',
|
|
66
|
+
'an expression proxy escaped its capture callback; expressions cannot be stored and replayed across operators');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A member name as an RFC 9535 bracketed name selector. Backslashes and
|
|
71
|
+
* quotes are escaped, and so are the control characters the grammar
|
|
72
|
+
* forbids unescaped (U+0000–U+001F): without this a key holding a tab
|
|
73
|
+
* emitted a path the engine refused at RUN time (`JQ0004`), while
|
|
74
|
+
* `get()` is documented as the way to reach any key at all.
|
|
75
|
+
* @param {string} name
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function bracketName(name) {
|
|
79
|
+
let escaped = '';
|
|
80
|
+
for (const c of name) {
|
|
81
|
+
const code = c.charCodeAt(0);
|
|
82
|
+
if (c === '\\') escaped += '\\\\';
|
|
83
|
+
else if (c === "'") escaped += "\\'";
|
|
84
|
+
else if (code >= 0x20) escaped += c;
|
|
85
|
+
else if (c === '\b') escaped += '\\b';
|
|
86
|
+
else if (c === '\f') escaped += '\\f';
|
|
87
|
+
else if (c === '\n') escaped += '\\n';
|
|
88
|
+
else if (c === '\r') escaped += '\\r';
|
|
89
|
+
else if (c === '\t') escaped += '\\t';
|
|
90
|
+
else escaped += '\\u' + code.toString(16).padStart(4, '0');
|
|
91
|
+
}
|
|
92
|
+
return `['${escaped}']`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One member step on a path: the shorthand for an identifier, the
|
|
97
|
+
* bracketed selector for anything else.
|
|
98
|
+
* @param {string} name
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function memberSegment(name) {
|
|
102
|
+
return SHORTHAND_RE.test(name) ? `.${name}` : bracketName(name);
|
|
37
103
|
}
|
|
38
104
|
|
|
39
105
|
/**
|
|
@@ -63,7 +129,7 @@ function embedString(s) {
|
|
|
63
129
|
* where the caller can see which value it was.
|
|
64
130
|
* @param {any} value
|
|
65
131
|
*/
|
|
66
|
-
function isPlainJson(value) {
|
|
132
|
+
export function isPlainJson(value) {
|
|
67
133
|
if (value === null) return true;
|
|
68
134
|
const type = typeof value;
|
|
69
135
|
if (type === 'string' || type === 'boolean') return true;
|
|
@@ -81,17 +147,42 @@ function isPlainJson(value) {
|
|
|
81
147
|
return true;
|
|
82
148
|
}
|
|
83
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Refuse a parameter value that is not query data. A binding travels
|
|
152
|
+
* into a document as an external, and later into a provider as a bound
|
|
153
|
+
* SQL parameter, so a `Date`, `Map`, `NaN` or `-0` here would compare
|
|
154
|
+
* against nothing and answer `[]` with no error anywhere. One check for
|
|
155
|
+
* both surfaces, one message.
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @param {any} value
|
|
158
|
+
*/
|
|
159
|
+
export function requireJsonBinding(name, value) {
|
|
160
|
+
if (isPlainJson(value)) return;
|
|
161
|
+
const what = value !== null && typeof value === 'object'
|
|
162
|
+
? `a ${value.constructor?.name ?? 'non-plain'} instance`
|
|
163
|
+
: typeof value === 'number' ? (Object.is(value, -0) ? '-0' : String(value))
|
|
164
|
+
: `a ${typeof value}`;
|
|
165
|
+
throw new LinqBuildError('JL0004',
|
|
166
|
+
`parameter '${name}' is bound to ${what}, which is not query data — convert it `
|
|
167
|
+
+ 'first (a Date to its ISO string or epoch number, a Map to an object, NaN or -0 to a number)');
|
|
168
|
+
}
|
|
169
|
+
|
|
84
170
|
/**
|
|
85
171
|
* Turn a captured callback result — a proxy, a literal, or a plain
|
|
86
172
|
* object/array tree containing proxies — into a query expression.
|
|
87
173
|
* Objects without `$`-prefixed keys become Rule 1 constructors; a
|
|
88
174
|
* `$`-keyed data object embeds through `$map` so it stays a
|
|
89
175
|
* constructor rather than colliding with the operator vocabulary;
|
|
90
|
-
* pure data trees embed as `$const
|
|
176
|
+
* pure data trees embed as `$const` — unless `fold` is false, when
|
|
177
|
+
* they are spelled as constructor trees too (a stylesheet body writes
|
|
178
|
+
* its output the way the format's own examples do: `{ "level":
|
|
179
|
+
* "unknown" }`, not `{ "$const": … }` — the same value, the published
|
|
180
|
+
* spelling).
|
|
91
181
|
* @param {any} value
|
|
182
|
+
* @param {boolean} [fold] - whether a pure data tree folds into one `$const`
|
|
92
183
|
* @returns {any} a query expression (plain JSON)
|
|
93
184
|
*/
|
|
94
|
-
export function toExpression(value) {
|
|
185
|
+
export function toExpression(value, fold = true) {
|
|
95
186
|
if (value === null) return null;
|
|
96
187
|
const t = typeof value;
|
|
97
188
|
if (t === 'string') return embedString(value);
|
|
@@ -117,7 +208,7 @@ export function toExpression(value) {
|
|
|
117
208
|
assertLive(record);
|
|
118
209
|
return record.doc;
|
|
119
210
|
}
|
|
120
|
-
if (isPlainJson(value)) {
|
|
211
|
+
if (fold && isPlainJson(value)) {
|
|
121
212
|
// verbatim data: cheaper and clearer than a constructor tree
|
|
122
213
|
return { $const: value };
|
|
123
214
|
}
|
|
@@ -128,7 +219,7 @@ export function toExpression(value) {
|
|
|
128
219
|
'a captured expression cannot embed an Array subclass instance — its behaviour '
|
|
129
220
|
+ 'is not expressible as query data');
|
|
130
221
|
}
|
|
131
|
-
return value.map(toExpression);
|
|
222
|
+
return value.map((v) => toExpression(v, fold));
|
|
132
223
|
}
|
|
133
224
|
if (proto !== Object.prototype && proto !== null) {
|
|
134
225
|
// a Date, Map, Set, RegExp or class instance: `Object.keys` reports
|
|
@@ -142,14 +233,14 @@ export function toExpression(value) {
|
|
|
142
233
|
}
|
|
143
234
|
const keys = Object.keys(value);
|
|
144
235
|
if (keys.some((k) => k.charCodeAt(0) === 0x24)) {
|
|
145
|
-
return { $map: keys.map((k) => [embedString(k), toExpression(value[k])]) };
|
|
236
|
+
return { $map: keys.map((k) => [embedString(k), toExpression(value[k], fold)]) };
|
|
146
237
|
}
|
|
147
238
|
const out = {};
|
|
148
239
|
// an own `__proto__` member is DATA here; plain assignment would set
|
|
149
240
|
// the builder's prototype and drop the member
|
|
150
241
|
for (const key of keys) {
|
|
151
242
|
Object.defineProperty(out, key, {
|
|
152
|
-
value: toExpression(value[key]),
|
|
243
|
+
value: toExpression(value[key], fold),
|
|
153
244
|
writable: true,
|
|
154
245
|
enumerable: true,
|
|
155
246
|
configurable: true,
|
|
@@ -158,7 +249,9 @@ export function toExpression(value) {
|
|
|
158
249
|
return out;
|
|
159
250
|
}
|
|
160
251
|
throw new LinqBuildError('JL0005',
|
|
161
|
-
`
|
|
252
|
+
// `typeof undefined` IS 'undefined', so the two arms of the article
|
|
253
|
+
// are what differ here — not the noun
|
|
254
|
+
`a captured expression cannot embed ${t === 'undefined' ? 'an' : 'a'} ${t} value`);
|
|
162
255
|
}
|
|
163
256
|
|
|
164
257
|
/** Binary operator helper. @param {string} op */
|
|
@@ -171,6 +264,18 @@ const unary = (op) => function (/** @type {any} */ record) {
|
|
|
171
264
|
return makeExpr({ [op]: record.doc }, record.epoch, false);
|
|
172
265
|
};
|
|
173
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A unary operator that ranges over ITEMS: on a root that stands for
|
|
269
|
+
* an array value with a fanned twin (`groupJoin`'s group is bound as an
|
|
270
|
+
* array so `at`/`all`/member position work, and its aggregates count
|
|
271
|
+
* the members, not the array), the operator applies to the fanned
|
|
272
|
+
* path; elsewhere it is `unary`.
|
|
273
|
+
* @param {string} op
|
|
274
|
+
*/
|
|
275
|
+
const fanned = (op) => function (/** @type {any} */ record) {
|
|
276
|
+
return makeExpr({ [op]: record.seq ?? record.doc }, record.epoch, false);
|
|
277
|
+
};
|
|
278
|
+
|
|
174
279
|
/**
|
|
175
280
|
* `$date-add` / `$date-sub`: `[date, duration]` or `[date, amount, unit]`.
|
|
176
281
|
* @param {any} record
|
|
@@ -210,7 +315,7 @@ function literalSpec(spec, method) {
|
|
|
210
315
|
|
|
211
316
|
/**
|
|
212
317
|
* The operator methods, name → builder(record, ...args). One table so
|
|
213
|
-
* the mapping in
|
|
318
|
+
* the mapping in QUERY-PEN.md §4 has exactly one code counterpart.
|
|
214
319
|
* Null prototype: `constructor`/`toString` must read as member access,
|
|
215
320
|
* never as inherited "methods".
|
|
216
321
|
*/
|
|
@@ -227,7 +332,7 @@ const METHODS = {
|
|
|
227
332
|
div: binary('$div'), idiv: binary('$idiv'), mod: binary('$mod'),
|
|
228
333
|
neg: unary('$neg'),
|
|
229
334
|
// §8.2 existence
|
|
230
|
-
exists:
|
|
335
|
+
exists: fanned('$exists'), isEmpty: fanned('$empty'),
|
|
231
336
|
// §8.7 strings
|
|
232
337
|
startsWith: binary('$starts-with'), endsWith: binary('$ends-with'),
|
|
233
338
|
contains: binary('$contains'), matches: binary('$match'),
|
|
@@ -247,8 +352,8 @@ const METHODS = {
|
|
|
247
352
|
},
|
|
248
353
|
// §8.8 aggregates as EXPRESSIONS (a group inside a projection:
|
|
249
354
|
// `(u, g) => ({ n: g.count() })`)
|
|
250
|
-
count:
|
|
251
|
-
min:
|
|
355
|
+
count: fanned('$count'), sum: fanned('$sum'), avg: fanned('$avg'),
|
|
356
|
+
min: fanned('$min'), max: fanned('$max'),
|
|
252
357
|
// §8.13 dates — the whole family, not a corner of it. A date in this
|
|
253
358
|
// suite is an RFC 3339 STRING, so every one of these is an ordinary
|
|
254
359
|
// string operator with a calendar's worth of rules behind it, and
|
|
@@ -342,6 +447,11 @@ const METHODS = {
|
|
|
342
447
|
return makeExpr({ $get: [record.doc, toExpression(index)] }, record.epoch, false);
|
|
343
448
|
},
|
|
344
449
|
all(record) {
|
|
450
|
+
// a hop's related rows, fanned: the phrase itself, a sequence
|
|
451
|
+
if (record.hop !== undefined) return makeHop({ ...record.hop, fan: true }, record.epoch, record.nav);
|
|
452
|
+
// a bound array's fan carries the array's navigation (a group-join's
|
|
453
|
+
// group holds the inner rows; a hop off them binds each row first)
|
|
454
|
+
if (record.seq !== undefined) return makeExpr(record.seq, record.epoch, true, { nav: record.nav });
|
|
345
455
|
if (!record.pathable) {
|
|
346
456
|
throw new LinqBuildError('JL0005',
|
|
347
457
|
"all() fans out a PATH ('$it.tags[*]'); it cannot follow an operator result");
|
|
@@ -349,24 +459,249 @@ const METHODS = {
|
|
|
349
459
|
return makeExpr(`${record.doc}[*]`, record.epoch, true);
|
|
350
460
|
},
|
|
351
461
|
get(record, name) {
|
|
352
|
-
if (typeof name === 'string'
|
|
353
|
-
|
|
354
|
-
|
|
462
|
+
if (typeof name === 'string') {
|
|
463
|
+
// a relation name hops through get() as it does through member
|
|
464
|
+
// access — the escape reaches a relation that collides with a
|
|
465
|
+
// method name (`get('count')`), not a stored member of that name
|
|
466
|
+
if (navigates(record, name)) return startHop(record, name);
|
|
467
|
+
if (record.hop !== undefined) return extendHop(record, bracketName(name));
|
|
468
|
+
if (record.pathable) return pathStep(record, name, bracketName(name));
|
|
355
469
|
}
|
|
356
470
|
return makeExpr({ $get: [record.doc, toExpression(name)] }, record.epoch, false);
|
|
357
471
|
},
|
|
358
472
|
};
|
|
359
473
|
|
|
474
|
+
//#region relation hops
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The root spelling of an entity array in a multi-entity document —
|
|
478
|
+
* MODEL-FORMAT §10.1's `$.<Entity>[*]`, the one spelling the store's
|
|
479
|
+
* planner reads. Spelled here, never imported.
|
|
480
|
+
* @param {string} name
|
|
481
|
+
*/
|
|
482
|
+
const entityRootOf = (name) => `$.${name}[*]`;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether a member name on this record is a relation of the entity
|
|
486
|
+
* whose rows the record stands for: the record carries a relation
|
|
487
|
+
* table (a binding root, or a hop's target at its root) and the table
|
|
488
|
+
* names the member. A group-join's group carries its table for its FAN
|
|
489
|
+
* only — the group itself is an array, not a row.
|
|
490
|
+
* @param {any} record
|
|
491
|
+
* @param {string} name
|
|
492
|
+
*/
|
|
493
|
+
function navigates(record, name) {
|
|
494
|
+
return record.nav !== undefined && !record.navOnFan
|
|
495
|
+
&& Object.hasOwn(record.nav.table, name);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The relation entry a hop may lower from, or the coded refusal: a
|
|
500
|
+
* many-to-many member has no queryable join-table root in this version
|
|
501
|
+
* (the phrase would need one — `load({ include })` reads the
|
|
502
|
+
* memberships), and a foreign-key relation needs its key column and the
|
|
503
|
+
* single key it references (a composite key would need a tuple equality
|
|
504
|
+
* the vocabulary does not spell).
|
|
505
|
+
* @param {any} relation
|
|
506
|
+
* @param {string} member
|
|
507
|
+
*/
|
|
508
|
+
function checkRelation(relation, member) {
|
|
509
|
+
if (relation === null || typeof relation !== 'object' || typeof relation.to !== 'string') {
|
|
510
|
+
throw new LinqBuildError('JL0105',
|
|
511
|
+
`the relation table names '${member}' but its entry is not a relation record `
|
|
512
|
+
+ '({ to, kind, via, fkEntity, fkTargets, targetKey } — MODEL-FORMAT §10.1)');
|
|
513
|
+
}
|
|
514
|
+
if (relation.kind === 'manyToMany') {
|
|
515
|
+
throw new LinqBuildError('JL0105',
|
|
516
|
+
`'${member}' is a many-to-many relation: the join table '${relation.joinTable}' is not a `
|
|
517
|
+
+ 'queryable root in this version, so the hop has no phrase to lower to — read the '
|
|
518
|
+
+ `memberships with load({ include: { ${member}: true } })`);
|
|
519
|
+
}
|
|
520
|
+
if (relation.kind !== 'oneToOne' && relation.kind !== 'oneToMany') {
|
|
521
|
+
throw new LinqBuildError('JL0105',
|
|
522
|
+
`'${member}' has relation kind '${String(relation.kind)}', which is not one this `
|
|
523
|
+
+ 'surface lowers (oneToOne, oneToMany)');
|
|
524
|
+
}
|
|
525
|
+
if (typeof relation.via !== 'string' || typeof relation.targetKey !== 'string') {
|
|
526
|
+
throw new LinqBuildError('JL0105',
|
|
527
|
+
`'${member}' cannot lower: its foreign key or the key it references is composite or `
|
|
528
|
+
+ 'undeclared, and the hop\'s equality would need a tuple the vocabulary does not spell');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A hop chain as one nested document: each link binds its source,
|
|
534
|
+
* correlates through its `$where`, and returns the next link — the
|
|
535
|
+
* innermost returns `ret`, the path over the last binding.
|
|
536
|
+
* @param {readonly { binding: string, source: any, where?: any }[]} chain
|
|
537
|
+
* @param {any} ret
|
|
538
|
+
* @returns {any}
|
|
539
|
+
*/
|
|
540
|
+
function hopDocument(chain, ret) {
|
|
541
|
+
let doc = ret;
|
|
542
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
543
|
+
const link = chain[i];
|
|
544
|
+
/** @type {any} */
|
|
545
|
+
const phrase = { $for: { [link.binding]: link.source } };
|
|
546
|
+
if (link.where !== undefined) phrase.$where = link.where;
|
|
547
|
+
phrase.$return = doc;
|
|
548
|
+
doc = phrase;
|
|
549
|
+
}
|
|
550
|
+
return doc;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The proxy over a hop: as a VALUE a to-one hop is its phrase (zero or
|
|
555
|
+
* one row — an object member's one value, an empty operand elsewhere)
|
|
556
|
+
* and a to-many hop is the phrase packed into an array (`[phrase]`, the
|
|
557
|
+
* array of related rows a member holds); fanned (`all()`), either is the
|
|
558
|
+
* bare phrase, a sequence the aggregates and `exists()` range over. The
|
|
559
|
+
* fanned twin is the phrase in every case, so `count()`/`exists()` on
|
|
560
|
+
* the value count the rows, as they do on a group-join's group.
|
|
561
|
+
* @param {{ chain: any[], ret: string, many: boolean, fan: boolean }} hop
|
|
562
|
+
* @param {number} epoch
|
|
563
|
+
* @param {any} nav - the target entity's navigation, while `ret` is its root
|
|
564
|
+
*/
|
|
565
|
+
function makeHop(hop, epoch, nav) {
|
|
566
|
+
const phrase = hopDocument(hop.chain, hop.ret);
|
|
567
|
+
return makeExpr(hop.many && !hop.fan ? [phrase] : phrase, epoch, false,
|
|
568
|
+
{ seq: phrase, nav, hop });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Record one hop: `member` is a relation of the rows `target` stands
|
|
573
|
+
* for. The subject the new binding correlates with is a bare binding
|
|
574
|
+
* variable — the root (`$it`), or the previous hop's binding (`$r1`) —
|
|
575
|
+
* or a fanned path (`$g[*]`), which is bound to a binding of its own
|
|
576
|
+
* first so the equality reads one row. The equality follows the
|
|
577
|
+
* table's placement of the foreign key: on the declaring entity
|
|
578
|
+
* (`oneToOne`) the target's key equals the subject's `via`; on the
|
|
579
|
+
* target (`oneToMany`) the target's `via` equals the subject's key.
|
|
580
|
+
* @param {any} target - the record the member was read on
|
|
581
|
+
* @param {string} member
|
|
582
|
+
*/
|
|
583
|
+
function startHop(target, member) {
|
|
584
|
+
const { table, resolve, sink } = target.nav;
|
|
585
|
+
const relation = table[member];
|
|
586
|
+
checkRelation(relation, member);
|
|
587
|
+
const chain = target.hop === undefined ? [] : [...target.hop.chain];
|
|
588
|
+
let subject = target.hop === undefined ? target.doc : target.hop.ret;
|
|
589
|
+
// a hop off a FANNED subject (a fanned to-many hop, a bound fan) is a
|
|
590
|
+
// sequence — one target per subject row, flattened by the outer
|
|
591
|
+
// phrase — never an array value; off a singular subject it is a
|
|
592
|
+
// value: the row (to-one) or the array of rows (to-many)
|
|
593
|
+
let many = target.hop !== undefined && target.hop.many;
|
|
594
|
+
let fan = target.hop !== undefined && target.hop.fan;
|
|
595
|
+
if (!BARE_VAR_RE.test(subject)) {
|
|
596
|
+
const binding = `r${sink.next++}`;
|
|
597
|
+
chain.push({ binding, source: subject });
|
|
598
|
+
subject = '$' + binding;
|
|
599
|
+
many = true;
|
|
600
|
+
fan = true;
|
|
601
|
+
}
|
|
602
|
+
const binding = `r${sink.next++}`;
|
|
603
|
+
const where = relation.kind === 'oneToMany'
|
|
604
|
+
? { $eq: [`$${binding}${memberSegment(relation.via)}`, `${subject}${memberSegment(relation.targetKey)}`] }
|
|
605
|
+
: { $eq: [`$${binding}${memberSegment(relation.targetKey)}`, `${subject}${memberSegment(relation.via)}`] };
|
|
606
|
+
chain.push({ binding, source: entityRootOf(relation.to), where });
|
|
607
|
+
sink.hops.push({ member, kind: relation.kind, binding });
|
|
608
|
+
const targetTable = resolve(relation.to);
|
|
609
|
+
return makeHop(
|
|
610
|
+
{ chain, ret: '$' + binding, many: many || relation.kind === 'oneToMany', fan },
|
|
611
|
+
target.epoch,
|
|
612
|
+
targetTable === undefined ? undefined : { table: targetTable, resolve, sink });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* A member of the hop's target row: the phrase returns the path over its
|
|
617
|
+
* last binding, extended. A to-many hop is an ARRAY of rows until it is
|
|
618
|
+
* fanned — a member read off the array is refused with the fix named,
|
|
619
|
+
* where the same read off a stored array would answer nothing.
|
|
620
|
+
* @param {any} target
|
|
621
|
+
* @param {string} segment - the spelled path step (`.email`, `['odd key']`)
|
|
622
|
+
*/
|
|
623
|
+
function extendHop(target, segment) {
|
|
624
|
+
const hop = target.hop;
|
|
625
|
+
if (hop.many && !hop.fan) {
|
|
626
|
+
throw new LinqBuildError('JL0005',
|
|
627
|
+
`${segment} is read off a to-many relation, which holds an array of related rows — `
|
|
628
|
+
+ `fan them first (.all()${segment}), index one (.at(0)), or aggregate the array`);
|
|
629
|
+
}
|
|
630
|
+
return makeHop({ ...hop, ret: `${hop.ret}${segment}` }, target.epoch, undefined);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A fresh hop sink for one capture: the bindings allocated so far
|
|
635
|
+
* (`r1`, `r2`, … — numbered per capture, across all its roots) and the
|
|
636
|
+
* hops recorded, in the order the callback navigated them.
|
|
637
|
+
* @returns {{ hops: { member: string, kind: string, binding: string }[], next: number }}
|
|
638
|
+
*/
|
|
639
|
+
export function createHopSink() {
|
|
640
|
+
return { hops: [], next: 1 };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A binding root whose items are an entity's rows: the bare name when
|
|
645
|
+
* there is nothing to carry, else the root record with the relation
|
|
646
|
+
* table, the resolver for the other roots of its scope, the capture's
|
|
647
|
+
* sink, and — after a `groupBy` — the member the group's rows live in.
|
|
648
|
+
* @param {string} name - the binding (`it`, `it2`)
|
|
649
|
+
* @param {{ table: any, resolve: (name: string) => any } | null} relations
|
|
650
|
+
* @param {ReturnType<typeof createHopSink>} sink
|
|
651
|
+
* @param {boolean} [grouped] - whether the items are a `{ key, items }`
|
|
652
|
+
* group, whose `items` aggregate as rows ({@link pathStep})
|
|
653
|
+
* @returns {any}
|
|
654
|
+
*/
|
|
655
|
+
export function rowRoot(name, relations, sink, grouped = false) {
|
|
656
|
+
if (relations === null && !grouped) return name;
|
|
657
|
+
const root = { doc: '$' + name, pathable: true };
|
|
658
|
+
if (grouped) root.group = GROUP_ITEMS;
|
|
659
|
+
if (relations !== null) {
|
|
660
|
+
root.nav = { table: relations.table, resolve: relations.resolve, sink };
|
|
661
|
+
}
|
|
662
|
+
return root;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A group-join's group root: the `$g` array value with its `$g[*]` fan
|
|
667
|
+
* — and, when the inner rows have a relation table, that table on the
|
|
668
|
+
* FAN, so `g.all().author` hops from each row.
|
|
669
|
+
* @param {{ table: any, resolve: (name: string) => any } | null} relations
|
|
670
|
+
* @param {ReturnType<typeof createHopSink>} sink
|
|
671
|
+
* @returns {any}
|
|
672
|
+
*/
|
|
673
|
+
export function groupRoot(relations, sink) {
|
|
674
|
+
const root = { doc: '$g', pathable: true, seq: '$g[*]' };
|
|
675
|
+
return relations === null
|
|
676
|
+
? root
|
|
677
|
+
: { ...root, nav: { table: relations.table, resolve: relations.resolve, sink }, navOnFan: true };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
//#endregion
|
|
681
|
+
|
|
360
682
|
/**
|
|
361
683
|
* Build one expression proxy.
|
|
362
684
|
* @param {any} doc - the expression JSON so far
|
|
363
685
|
* @param {number} epoch - the owning capture
|
|
364
686
|
* @param {boolean} pathable - whether `doc` is a pure path string that
|
|
365
687
|
* member access may extend
|
|
688
|
+
* @param {{ seq?: any, nav?: any, navOnFan?: boolean, hop?: any,
|
|
689
|
+
* group?: string }} [extra] -
|
|
690
|
+
* `seq`: for a value standing for an array or a hop, the fanned form
|
|
691
|
+
* its aggregates range over (`'$g[*]'`, a hop's phrase); `nav`: the
|
|
692
|
+
* relation table of the rows the value stands for, with the resolver
|
|
693
|
+
* for the other roots and the capture's hop sink; `navOnFan`: the
|
|
694
|
+
* table applies to the fan, not the value; `hop`: the hop chain;
|
|
695
|
+
* `group`: the member this value carries a GROUP's rows in, whose
|
|
696
|
+
* aggregates therefore range over the rows
|
|
366
697
|
* @returns {any}
|
|
367
698
|
*/
|
|
368
|
-
function makeExpr(doc, epoch, pathable) {
|
|
369
|
-
const record = {
|
|
699
|
+
function makeExpr(doc, epoch, pathable, extra = undefined) {
|
|
700
|
+
const record = {
|
|
701
|
+
doc, epoch, pathable,
|
|
702
|
+
seq: extra?.seq, nav: extra?.nav, navOnFan: extra?.navOnFan === true, hop: extra?.hop,
|
|
703
|
+
group: extra?.group,
|
|
704
|
+
};
|
|
370
705
|
return new Proxy(record, {
|
|
371
706
|
get(target, prop) {
|
|
372
707
|
if (prop === NODE) return target;
|
|
@@ -379,17 +714,43 @@ function makeExpr(doc, epoch, pathable) {
|
|
|
379
714
|
};
|
|
380
715
|
}
|
|
381
716
|
assertLive(target);
|
|
382
|
-
|
|
383
|
-
const step = SHORTHAND_RE.test(prop)
|
|
384
|
-
? `${target.doc}.${prop}`
|
|
385
|
-
: `${target.doc}['${prop.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`;
|
|
386
|
-
return makeExpr(step, target.epoch, true);
|
|
387
|
-
}
|
|
388
|
-
return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
|
|
717
|
+
return member(target, prop);
|
|
389
718
|
},
|
|
390
719
|
});
|
|
391
720
|
}
|
|
392
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Member access: a relation name records a hop; a hop's target extends
|
|
724
|
+
* the phrase's return path; a pathable path extends; anything else is
|
|
725
|
+
* a `$get` over the operator result.
|
|
726
|
+
* @param {any} target
|
|
727
|
+
* @param {string} prop
|
|
728
|
+
*/
|
|
729
|
+
function member(target, prop) {
|
|
730
|
+
if (navigates(target, prop)) return startHop(target, prop);
|
|
731
|
+
if (target.hop !== undefined) return extendHop(target, memberSegment(prop));
|
|
732
|
+
if (target.pathable) return pathStep(target, prop, memberSegment(prop));
|
|
733
|
+
return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* One path step off a pathable value, member access or `get()`.
|
|
738
|
+
*
|
|
739
|
+
* A GROUP's rows aggregate as rows: after `groupBy` the item is
|
|
740
|
+
* `{ key, items }` and `items` holds the group, so `g.items.count()`
|
|
741
|
+
* ranges over the rows the way a group-join's `g.count()` already does
|
|
742
|
+
* ({@link groupRoot}). `g.items` itself is still the array — a member
|
|
743
|
+
* takes it whole (`{ matches: g.items }`), `at()` indexes it and
|
|
744
|
+
* `all()` fans it — so only the aggregates change, and only for the one
|
|
745
|
+
* member the emitter writes the group into.
|
|
746
|
+
* @param {any} target @param {string|number} prop @param {string} segment
|
|
747
|
+
*/
|
|
748
|
+
function pathStep(target, prop, segment) {
|
|
749
|
+
const doc = `${target.doc}${segment}`;
|
|
750
|
+
return makeExpr(doc, target.epoch, true,
|
|
751
|
+
target.group === prop ? { seq: `${doc}[*]` } : undefined);
|
|
752
|
+
}
|
|
753
|
+
|
|
393
754
|
/**
|
|
394
755
|
* The parameters proxy: `p.tenantId` emits the external `$tenantId` —
|
|
395
756
|
* when the name was declared via `.params({...})`. Undeclared use is
|
|
@@ -415,24 +776,64 @@ function makeParams(declared, epoch) {
|
|
|
415
776
|
* Run one capture: `fn` receives a proxy per root (plus the parameters
|
|
416
777
|
* proxy last) and its result becomes an expression via
|
|
417
778
|
* {@link toExpression}. A root is a binding NAME (`'it'` → the pathable
|
|
418
|
-
* `$it`) or a `{ doc, pathable }` record for
|
|
419
|
-
* (
|
|
420
|
-
* `
|
|
779
|
+
* `$it`) or a `{ doc, pathable, seq?, nav?, navOnFan? }` record for a
|
|
780
|
+
* bound root ({@link rowRoot}: an entity's rows with their relation
|
|
781
|
+
* table; {@link groupRoot}: groupJoin's group, the `$g` array whose
|
|
782
|
+
* aggregates fan over `$g[*]`). Captures nest — a chain built and run
|
|
783
|
+
* inside a callback is ordinary — but a proxy used outside its capture,
|
|
784
|
+
* or an enclosing capture's proxy used inside a nested one, is `JL0002`.
|
|
421
785
|
* @param {(...roots: any[]) => any} fn - the user callback
|
|
422
|
-
* @param {readonly (string | { doc: any, pathable: boolean
|
|
786
|
+
* @param {readonly (string | { doc: any, pathable: boolean, seq?: string,
|
|
787
|
+
* nav?: any, navOnFan?: boolean })[]} roots
|
|
423
788
|
* @param {Set<string>} declaredParams
|
|
789
|
+
* @param {boolean} [fold] - whether a pure data tree folds into one
|
|
790
|
+
* `$const` (the chain's spelling) or is a constructor tree (a pen's)
|
|
424
791
|
* @returns {any} the captured expression (plain JSON)
|
|
425
792
|
*/
|
|
426
|
-
export function captureExpression(fn, roots, declaredParams) {
|
|
427
|
-
const epoch = ++
|
|
793
|
+
export function captureExpression(fn, roots, declaredParams, fold = true) {
|
|
794
|
+
const epoch = ++epochCounter;
|
|
428
795
|
const proxies = roots.map((root) => (typeof root === 'string'
|
|
429
796
|
? makeExpr('$' + root, epoch, true)
|
|
430
|
-
: makeExpr(root.doc, epoch, root.pathable)));
|
|
797
|
+
: makeExpr(root.doc, epoch, root.pathable, root)));
|
|
431
798
|
proxies.push(makeParams(declaredParams, epoch));
|
|
799
|
+
captureStack.push(epoch);
|
|
432
800
|
try {
|
|
433
|
-
return toExpression(fn(...proxies));
|
|
801
|
+
return toExpression(fn(...proxies), fold);
|
|
434
802
|
}
|
|
435
803
|
finally {
|
|
436
|
-
|
|
804
|
+
captureStack.pop(); // every proxy of this capture is now dead
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Whether `value` is an expression proxy of some capture (live or not).
|
|
810
|
+
* A pen walking a captured result before it lowers needs to tell a
|
|
811
|
+
* proxy from the plain object it would otherwise descend into.
|
|
812
|
+
* @param {any} value
|
|
813
|
+
* @returns {boolean}
|
|
814
|
+
*/
|
|
815
|
+
export function isExpression(value) {
|
|
816
|
+
return value !== null && typeof value === 'object' && value[NODE] !== undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Lift a hand-spelled operator expression into the capture in
|
|
821
|
+
* progress: the escape for an operator the method table does not name
|
|
822
|
+
* — a registered one (`{ $npv: [...] }`, JSLT-FORMAT §13) or a
|
|
823
|
+
* body-local one (`$apply`, §6). The document is taken as given — the
|
|
824
|
+
* engine's compiler is the judge of it (`JQ0002` for an operator it
|
|
825
|
+
* does not know) — and the proxy it answers is bound to the innermost
|
|
826
|
+
* capture, so it composes with that capture's own proxies and dies
|
|
827
|
+
* with them. Outside a capture there is nothing to bind it to:
|
|
828
|
+
* `JL0005`.
|
|
829
|
+
* @param {any} doc - the operator expression, plain JSON
|
|
830
|
+
* @returns {any} an expression proxy over `doc`
|
|
831
|
+
*/
|
|
832
|
+
export function liftExpression(doc) {
|
|
833
|
+
if (captureStack.length === 0) {
|
|
834
|
+
throw new LinqBuildError('JL0005',
|
|
835
|
+
'an operator expression can only be lifted inside a capture callback — no capture '
|
|
836
|
+
+ 'is in progress to bind it to');
|
|
437
837
|
}
|
|
838
|
+
return makeExpr(doc, captureStack[captureStack.length - 1], false);
|
|
438
839
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The one capture under every query-valued member of the flow
|
|
4
|
+
* documents: a transition guard and an effect's `with` over
|
|
5
|
+
* FLOW-FORMAT §3's step scope, a `query` node's document, a task's
|
|
6
|
+
* `with` and an edge's `select` over §6.1's input scope.
|
|
7
|
+
*
|
|
8
|
+
* The scope binds NO externals — both engines evaluate these with a
|
|
9
|
+
* single `$` and nothing else — so a name read off the capture's second
|
|
10
|
+
* argument is `JL0104` here, where the fix can be named, rather than
|
|
11
|
+
* `JQ2006` at step time, where a guard that cannot bind reads as a
|
|
12
|
+
* recorded false (FLOW-FORMAT §5.2). A returned literal is spelled as
|
|
13
|
+
* the format's own constructor and never folded into `$const`:
|
|
14
|
+
* §2's `{ "text": "retrying" }` is what a machine document carries.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { cloneJson } from '@jarenjs/core/object';
|
|
18
|
+
import { captureQuery } from '../capture-root.js';
|
|
19
|
+
import { requireJson } from '../json-boundary.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One query-valued member: a callback captured over `$`, or a query
|
|
23
|
+
* document written by hand, copied.
|
|
24
|
+
* @param {string} what - the method, for the message
|
|
25
|
+
* @param {string} scope - what `$` is bound to, for the `JL0104` advice
|
|
26
|
+
* @param {any} value - a callback `(s) => …`, or a query document
|
|
27
|
+
* @returns {any} the query document (plain JSON)
|
|
28
|
+
*/
|
|
29
|
+
export function queryMember(what, scope, value) {
|
|
30
|
+
if (typeof value !== 'function') return cloneJson(requireJson(value, what));
|
|
31
|
+
const advice = () => ` — ${what} evaluates over ${scope}, which its argument IS`;
|
|
32
|
+
return cloneJson(captureQuery(what, [], value, { advice, fold: false, noun: 'callback' }));
|
|
33
|
+
}
|