@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.
@@ -0,0 +1,320 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Expression capture (D4): a predicate or projection callback
4
+ * receives a RECORDING PROXY, never a stringified function. Member
5
+ * access records a path segment; a method call records an operator; the
6
+ * output is a plain Jaren query expression (QUERY-FORMAT.md §§3–8) —
7
+ * hand-readable JSON, nothing else.
8
+ *
9
+ * Capture is epoch-scoped: every proxy belongs to exactly one `capture`
10
+ * call, and using one outside it (stored and replayed into a later
11
+ * chain) is `JL0002` — a proxy that escaped would otherwise emit a
12
+ * document that silently refers to the wrong binding. (`===` between
13
+ * proxies is untrappable and therefore undetectable; the format doc
14
+ * says so.)
15
+ *
16
+ * Method names shadow member access: `u.eq` is the operator, never the
17
+ * data member — reach a colliding member with `u.get('eq')`.
18
+ */
19
+
20
+ import { LinqBuildError } from './errors.js';
21
+
22
+ /** The unwrap key: proxy → its internal record. */
23
+ const NODE = Symbol('jaren-linq-node');
24
+
25
+ /** RFC 9535 shorthand member names travel as `.name`; everything else
26
+ * goes through a bracketed, single-quoted selector. */
27
+ const SHORTHAND_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
+
29
+ let activeEpoch = 0;
30
+
31
+ /** @param {any} record */
32
+ function assertLive(record) {
33
+ if (record.epoch !== activeEpoch) {
34
+ throw new LinqBuildError('JL0002',
35
+ 'an expression proxy escaped its capture callback; expressions cannot be stored and replayed across operators');
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Embed a literal string as a query expression: plain strings are
41
+ * literals by Rule 2, and a string starting `$` needs the `$$` escape
42
+ * so it stays data.
43
+ * @param {string} s
44
+ */
45
+ function embedString(s) {
46
+ return s.charCodeAt(0) === 0x24 ? '$' + s : s;
47
+ }
48
+
49
+ /**
50
+ * True when `value` is a plain JSON tree (no proxies) that can embed
51
+ * verbatim.
52
+ *
53
+ * "Plain JSON" is checked, not assumed. Walking `Object.keys` alone
54
+ * accepted every value whose own enumerable keys happen to be JSON —
55
+ * which a `Date`, a `Map`, a `RegExp`, a `Set` and every class instance
56
+ * satisfy vacuously, because `Object.keys` reports nothing for them. Each
57
+ * one then embedded as `{}` and the query filtered on an empty object.
58
+ * The numeric domain matters just as much: `NaN` and `±Infinity` are not
59
+ * JSON numbers, and `-0` is a legal number that shares JSON text with
60
+ * `0` while dividing to the opposite infinity. So the prototype is
61
+ * required to be plain and the numeric domain is required to be finite,
62
+ * and a captured constant outside that boundary is refused at capture —
63
+ * where the caller can see which value it was.
64
+ * @param {any} value
65
+ */
66
+ function isPlainJson(value) {
67
+ if (value === null) return true;
68
+ const type = typeof value;
69
+ if (type === 'string' || type === 'boolean') return true;
70
+ if (type === 'number') return Number.isFinite(value) && !Object.is(value, -0);
71
+ if (type !== 'object') return false; // undefined, function, symbol, bigint
72
+ if (value[NODE] !== undefined) return false;
73
+ const proto = Object.getPrototypeOf(value);
74
+ if (Array.isArray(value)) {
75
+ return proto === Array.prototype && value.every(isPlainJson);
76
+ }
77
+ if (proto !== Object.prototype && proto !== null) return false;
78
+ for (const key of Object.keys(value)) {
79
+ if (!isPlainJson(value[key])) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Turn a captured callback result — a proxy, a literal, or a plain
86
+ * object/array tree containing proxies — into a query expression.
87
+ * Objects without `$`-prefixed keys become Rule 1 constructors; a
88
+ * `$`-keyed data object embeds through `$map` so it stays a
89
+ * constructor rather than colliding with the operator vocabulary;
90
+ * pure data trees embed as `$const`.
91
+ * @param {any} value
92
+ * @returns {any} a query expression (plain JSON)
93
+ */
94
+ export function toExpression(value) {
95
+ if (value === null) return null;
96
+ const t = typeof value;
97
+ if (t === 'string') return embedString(value);
98
+ if (t === 'boolean') return value;
99
+ if (t === 'number') {
100
+ if (!Number.isFinite(value)) {
101
+ throw new LinqBuildError('JL0005',
102
+ `a captured expression cannot embed ${String(value)} — the query data model is `
103
+ + 'JSON, which has no NaN or Infinity, and lenient serialization would fold it '
104
+ + 'into null');
105
+ }
106
+ if (Object.is(value, -0)) {
107
+ throw new LinqBuildError('JL0005',
108
+ 'a captured expression cannot embed -0 — it shares its JSON text with 0 while '
109
+ + 'dividing to the opposite infinity, so a document holding it cannot be '
110
+ + 'keyed, stored or compared faithfully; use 0, or negate at query time');
111
+ }
112
+ return value;
113
+ }
114
+ if (t === 'object') {
115
+ const record = value[NODE];
116
+ if (record !== undefined) {
117
+ assertLive(record);
118
+ return record.doc;
119
+ }
120
+ if (isPlainJson(value)) {
121
+ // verbatim data: cheaper and clearer than a constructor tree
122
+ return { $const: value };
123
+ }
124
+ const proto = Object.getPrototypeOf(value);
125
+ if (Array.isArray(value)) {
126
+ if (proto !== Array.prototype) {
127
+ throw new LinqBuildError('JL0005',
128
+ 'a captured expression cannot embed an Array subclass instance — its behaviour '
129
+ + 'is not expressible as query data');
130
+ }
131
+ return value.map(toExpression);
132
+ }
133
+ if (proto !== Object.prototype && proto !== null) {
134
+ // a Date, Map, Set, RegExp or class instance: `Object.keys` reports
135
+ // nothing for it, so embedding it verbatim produced `{}` and the
136
+ // query silently compared against an empty object
137
+ throw new LinqBuildError('JL0005',
138
+ `a captured expression cannot embed a ${value.constructor?.name ?? 'non-plain'} `
139
+ + 'instance — it carries no own enumerable members, so it would embed as {}. '
140
+ + 'Convert it to query data first (a Date to its ISO string or epoch number, a '
141
+ + 'Map to an object), or bind it through params().');
142
+ }
143
+ const keys = Object.keys(value);
144
+ if (keys.some((k) => k.charCodeAt(0) === 0x24)) {
145
+ return { $map: keys.map((k) => [embedString(k), toExpression(value[k])]) };
146
+ }
147
+ const out = {};
148
+ // an own `__proto__` member is DATA here; plain assignment would set
149
+ // the builder's prototype and drop the member
150
+ for (const key of keys) {
151
+ Object.defineProperty(out, key, {
152
+ value: toExpression(value[key]),
153
+ writable: true,
154
+ enumerable: true,
155
+ configurable: true,
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+ throw new LinqBuildError('JL0005',
161
+ `a captured expression cannot embed a ${t === 'undefined' ? 'undefined' : t} value`);
162
+ }
163
+
164
+ /** Binary operator helper. @param {string} op */
165
+ const binary = (op) => function (/** @type {any} */ record, /** @type {any} */ operand) {
166
+ return makeExpr({ [op]: [record.doc, toExpression(operand)] }, record.epoch, false);
167
+ };
168
+
169
+ /** Unary operator helper. @param {string} op */
170
+ const unary = (op) => function (/** @type {any} */ record) {
171
+ return makeExpr({ [op]: record.doc }, record.epoch, false);
172
+ };
173
+
174
+ /**
175
+ * The operator methods, name → builder(record, ...args). One table so
176
+ * the mapping in LINQ-FORMAT.md §4 has exactly one code counterpart.
177
+ * Null prototype: `constructor`/`toString` must read as member access,
178
+ * never as inherited "methods".
179
+ */
180
+ const METHODS = {
181
+ __proto__: null,
182
+ // §8.4 comparisons
183
+ eq: binary('$eq'), ne: binary('$ne'),
184
+ lt: binary('$lt'), le: binary('$le'),
185
+ gt: binary('$gt'), ge: binary('$ge'),
186
+ // §8.6 logic
187
+ and: binary('$and'), or: binary('$or'), not: unary('$not'),
188
+ // §8.5 arithmetic
189
+ add: binary('$add'), sub: binary('$sub'), mul: binary('$mul'),
190
+ div: binary('$div'), idiv: binary('$idiv'), mod: binary('$mod'),
191
+ neg: unary('$neg'),
192
+ // §8.2 existence
193
+ exists: unary('$exists'), isEmpty: unary('$empty'),
194
+ // §8.7 strings
195
+ startsWith: binary('$starts-with'), endsWith: binary('$ends-with'),
196
+ contains: binary('$contains'), matches: binary('$match'),
197
+ upper: unary('$upper'), lower: unary('$lower'),
198
+ length: unary('$string-length'),
199
+ concat: binary('$concat'),
200
+ substring(record, start, len) {
201
+ const args = len === undefined
202
+ ? [record.doc, toExpression(start)]
203
+ : [record.doc, toExpression(start), toExpression(len)];
204
+ return makeExpr({ $substring: args }, record.epoch, false);
205
+ },
206
+ replace(record, pattern, replacement) {
207
+ return makeExpr(
208
+ { $replace: [record.doc, toExpression(pattern), toExpression(replacement)] },
209
+ record.epoch, false);
210
+ },
211
+ // §8.8 aggregates as EXPRESSIONS (a group inside a projection:
212
+ // `(u, g) => ({ n: g.count() })`)
213
+ count: unary('$count'), sum: unary('$sum'), avg: unary('$avg'),
214
+ min: unary('$min'), max: unary('$max'),
215
+ // §8.13 dates (the scalar component family; the full date surface
216
+ // arrives with the relational order)
217
+ year: unary('$year'), month: unary('$month'), day: unary('$day'),
218
+ epoch: unary('$epoch'),
219
+ // path navigation
220
+ at(record, index) {
221
+ if (record.pathable && Number.isInteger(index)) {
222
+ return makeExpr(`${record.doc}[${index}]`, record.epoch, true);
223
+ }
224
+ return makeExpr({ $get: [record.doc, toExpression(index)] }, record.epoch, false);
225
+ },
226
+ all(record) {
227
+ if (!record.pathable) {
228
+ throw new LinqBuildError('JL0005',
229
+ "all() fans out a PATH ('$it.tags[*]'); it cannot follow an operator result");
230
+ }
231
+ return makeExpr(`${record.doc}[*]`, record.epoch, true);
232
+ },
233
+ get(record, name) {
234
+ if (typeof name === 'string' && record.pathable) {
235
+ return makeExpr(`${record.doc}['${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`,
236
+ record.epoch, true);
237
+ }
238
+ return makeExpr({ $get: [record.doc, toExpression(name)] }, record.epoch, false);
239
+ },
240
+ };
241
+
242
+ /**
243
+ * Build one expression proxy.
244
+ * @param {any} doc - the expression JSON so far
245
+ * @param {number} epoch - the owning capture
246
+ * @param {boolean} pathable - whether `doc` is a pure path string that
247
+ * member access may extend
248
+ * @returns {any}
249
+ */
250
+ function makeExpr(doc, epoch, pathable) {
251
+ const record = { doc, epoch, pathable };
252
+ return new Proxy(record, {
253
+ get(target, prop) {
254
+ if (prop === NODE) return target;
255
+ if (typeof prop === 'symbol') return undefined;
256
+ const method = METHODS[prop];
257
+ if (method !== undefined) {
258
+ return (...args) => {
259
+ assertLive(target);
260
+ return method(target, ...args);
261
+ };
262
+ }
263
+ assertLive(target);
264
+ if (target.pathable) {
265
+ const step = SHORTHAND_RE.test(prop)
266
+ ? `${target.doc}.${prop}`
267
+ : `${target.doc}['${prop.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`;
268
+ return makeExpr(step, target.epoch, true);
269
+ }
270
+ return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
271
+ },
272
+ });
273
+ }
274
+
275
+ /**
276
+ * The parameters proxy: `p.tenantId` emits the external `$tenantId` —
277
+ * when the name was declared via `.params({...})`. Undeclared use is
278
+ * `JL0004` at BUILD time, with the fix in the message (the engine
279
+ * would say JQ0005 at compile time; earlier and clearer beats later).
280
+ * @param {Set<string>} declared
281
+ * @param {number} epoch
282
+ */
283
+ function makeParams(declared, epoch) {
284
+ return new Proxy({}, {
285
+ get(_target, prop) {
286
+ if (typeof prop === 'symbol') return undefined;
287
+ if (!declared.has(prop)) {
288
+ throw new LinqBuildError('JL0004',
289
+ `parameter '${prop}' is not declared — declare it first: .params({ ${prop}: value })`);
290
+ }
291
+ return makeExpr('$' + prop, epoch, true);
292
+ },
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Run one capture: `fn` receives a proxy per root (plus the parameters
298
+ * proxy last) and its result becomes an expression via
299
+ * {@link toExpression}. A root is a binding NAME (`'it'` → the pathable
300
+ * `$it`) or a `{ doc, pathable }` record for an expression-valued root
301
+ * (groupJoin's inner group). Proxies die with the capture — reuse is
302
+ * `JL0002`.
303
+ * @param {(...roots: any[]) => any} fn - the user callback
304
+ * @param {readonly (string | { doc: any, pathable: boolean })[]} roots
305
+ * @param {Set<string>} declaredParams
306
+ * @returns {any} the captured expression (plain JSON)
307
+ */
308
+ export function captureExpression(fn, roots, declaredParams) {
309
+ const epoch = ++activeEpoch;
310
+ const proxies = roots.map((root) => (typeof root === 'string'
311
+ ? makeExpr('$' + root, epoch, true)
312
+ : makeExpr(root.doc, epoch, root.pathable)));
313
+ proxies.push(makeParams(declaredParams, epoch));
314
+ try {
315
+ return toExpression(fn(...proxies));
316
+ }
317
+ finally {
318
+ activeEpoch++; // every proxy of this capture is now dead
319
+ }
320
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ //@ts-check
2
+ /**
3
+ * @file @jarenjs/linq — a C#-familiar fluent surface that captures
4
+ * expressions as plain Jaren query documents (QUERY-FORMAT.md),
5
+ * executes deferred over any iterable, and hands the SAME document
6
+ * whole to any provider exposing `execute(document, options)` (D2:
7
+ * contract-level coupling, never an import edge). The normative
8
+ * surface, mapping table and error codes live in docs/LINQ-FORMAT.md.
9
+ */
10
+
11
+ export { from, fromDocument, Sequence } from './sequence.js';
12
+ export { fromAsync, AsyncSequence } from './async.js';
13
+ export { createPushQueue } from './sources.js';
14
+ export { LinqBuildError, LinqRuntimeError, LINQ_CODES } from './errors.js';
@@ -0,0 +1,166 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The provider seam (D2): a provider is any object exposing
4
+ * `execute(queryDocument, options)` — contract-level coupling, never an
5
+ * import edge. `@jarenjs/db` will implement this interface without
6
+ * either package importing the other. The in-memory runner implements
7
+ * the SAME interface over an iterable, so it is both the reference
8
+ * semantics every other provider must match and the proof the seam is
9
+ * real.
10
+ *
11
+ * Compiled documents are shared through a bounded LRU keyed by the
12
+ * document's COLLISION-FREE structural identity, one cache per REGISTRY
13
+ * identity — the hooks change what compiles, so two different registries
14
+ * must not share compiled programs.
15
+ * A fingerprint would not do: a 32-bit content hash collides after tens
16
+ * of thousands of documents, and a collision here runs one query's
17
+ * compiled program for another query's document — silently wrong rows.
18
+ */
19
+
20
+ import { compileJsonQuery, JsonQueryCompileError } from '@jarenjs/json/query';
21
+ import { createSemanticCache, createWeakCache } from '@jarenjs/core/cache';
22
+ import { LinqBuildError } from './errors.js';
23
+
24
+ /** Stands in for an absent hook while walking the identity chain. */
25
+ const NO_HOOK = Object.freeze({});
26
+
27
+ const CACHES = createWeakCache();
28
+ const cacheFor = () => createSemanticCache(512);
29
+
30
+ /** The root of the hook-identity chain. */
31
+ const REGISTRY_IDS = createWeakCache();
32
+ /** The token minted for each distinct COMBINATION of hook identities. */
33
+ const REGISTRY_TOKEN = Symbol('linq.registryToken');
34
+
35
+ /**
36
+ * The compile options a sequence forwards to the engine, beyond the
37
+ * document itself. `orderBy(..., {collation})` and `$call` emit perfectly
38
+ * good documents, and without the matching registry the in-memory
39
+ * compiler could only answer `JQ0010` — so a Dutch sort was expressible
40
+ * and not executable. These are the registries that close that gap; the
41
+ * engine's own option names, deliberately, so there is one vocabulary.
42
+ */
43
+ export const COMPILE_OPTION_KEYS = Object.freeze([
44
+ 'compileTypeTest', 'functions', 'collations', 'pathFunctions', 'limits',
45
+ ]);
46
+
47
+ /**
48
+ * Pick the forwarded compile options out of a sequence's options bag.
49
+ * @param {Record<string, any>} options
50
+ * @returns {Record<string, any>}
51
+ */
52
+ export function compileOptionsOf(options) {
53
+ /** @type {Record<string, any>} */
54
+ const out = {};
55
+ for (const key of COMPILE_OPTION_KEYS) {
56
+ if (options[key] !== undefined) out[key] = options[key];
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * A stable token for one COMBINATION of hook identities, so compiled
63
+ * programs are shared exactly among callers whose hooks agree.
64
+ *
65
+ * Partitioning on one hook is not enough: a document naming a `nl`
66
+ * collation compiles to different code with and without that registry,
67
+ * and a shared partition would serve the compiled-with version to a
68
+ * caller who passed no collations at all — which is a wrong answer, not
69
+ * a missing error. The chain walks every hook slot in a fixed order
70
+ * through WeakMaps, so a token lives exactly as long as the registries
71
+ * that produced it. `limits` is plain data and rides in the cache key
72
+ * instead of here, so a fresh `{ steps: 1000 }` literal per call does
73
+ * not mint a fresh partition every time.
74
+ * @param {Record<string, any>} options
75
+ * @returns {object} the partition key
76
+ */
77
+ function registryIdentity(options) {
78
+ if (options.registry !== undefined) return options.registry;
79
+ let node = /** @type {any} */ (REGISTRY_IDS);
80
+ for (const key of ['compileTypeTest', 'functions', 'collations', 'pathFunctions']) {
81
+ const hook = options[key];
82
+ const slot = hook === undefined || hook === null ? NO_HOOK : hook;
83
+ node = node.getOrCreate(slot, () => {
84
+ const next = createWeakCache();
85
+ /** @type {any} */ (next)[REGISTRY_TOKEN] = Object.freeze({});
86
+ return next;
87
+ });
88
+ }
89
+ return /** @type {any} */ (node)[REGISTRY_TOKEN];
90
+ }
91
+
92
+ /**
93
+ * Compile a query document, shared across equal documents.
94
+ * @param {any} document - the emitted (or hand-written) query document
95
+ * @param {{ compileTypeTest?: any, functions?: any, collations?: any,
96
+ * pathFunctions?: any, limits?: any, registry?: object,
97
+ * externals: readonly string[] }} options - `registry` is the cache
98
+ * partition key: one object identity per distinct set of hooks, since
99
+ * the hooks decide what a document compiles to
100
+ * @returns {any} the compiled query
101
+ * @throws {LinqBuildError} `JL0003` when a schema operator needs the
102
+ * missing `compileTypeTest` hook
103
+ */
104
+ export function compileDocument(document, options) {
105
+ const cache = /** @type {any} */ (CACHES.getOrCreate(registryIdentity(options), cacheFor));
106
+ // the externals and the limits are part of the identity: the same
107
+ // document compiles differently against a different set of declared
108
+ // names, and differently again under a step or result bound
109
+ return cache.getOrCreate([document, options.externals, options.limits ?? null], () => {
110
+ try {
111
+ return compileJsonQuery(document, {
112
+ ...compileOptionsOf(options),
113
+ externals: options.externals,
114
+ });
115
+ }
116
+ catch (err) {
117
+ if (err instanceof JsonQueryCompileError && err.code === 'JQ0008') {
118
+ throw new LinqBuildError('JL0003',
119
+ 'ofType/cast compile schema operators, which need a type-test compiler — '
120
+ + 'pass options.compileTypeTest to from()/fromDocument() '
121
+ + '(e.g. createTypeTestCompiler() from @jarenjs/validate/query)',
122
+ err.docPath, err);
123
+ }
124
+ throw err;
125
+ }
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Classify a `from()` source: an `execute` duck is a PROVIDER (the
131
+ * document is handed over whole; nothing is enumerated locally);
132
+ * any iterable is in-memory. Anything else is `JL0001` — at `from()`
133
+ * time, not at enumeration time.
134
+ * @param {any} source
135
+ * @returns {'provider' | 'iterable'}
136
+ */
137
+ export function classifySource(source) {
138
+ if (source !== null && typeof source === 'object'
139
+ && typeof (/** @type {any} */ (source).execute) === 'function') {
140
+ return 'provider';
141
+ }
142
+ if (source != null && (typeof source === 'string'
143
+ || typeof (/** @type {any} */ (source))[Symbol.iterator] === 'function')) {
144
+ return 'iterable';
145
+ }
146
+ throw new LinqBuildError('JL0001',
147
+ 'from() needs an iterable or a provider exposing execute(document, options)');
148
+ }
149
+
150
+ /**
151
+ * The in-memory execution of one document over an iterable source:
152
+ * materialise (each execution re-reads the source — the deferred
153
+ * re-enumeration contract), compile shared, run. The signature IS the
154
+ * provider contract, deliberately.
155
+ * @param {any} source - the iterable
156
+ * @param {any} document
157
+ * @param {{ compileTypeTest?: any, externalNames: readonly string[],
158
+ * externals: Record<string, any> }} options
159
+ * @returns {any} the engine-shaped result (`undefined | item | items[]`)
160
+ */
161
+ export function executeInMemory(source, document, options) {
162
+ const compiled = compileDocument(document,
163
+ { ...options, externals: options.externalNames });
164
+ const data = Array.isArray(source) ? source : [...source];
165
+ return compiled(data, options.externals);
166
+ }