inibase 3.0.0 → 3.1.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 +114 -0
- package/dist/expression.d.ts +159 -0
- package/dist/expression.js +495 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +676 -7
- package/dist/utils.js +54 -0
- package/package.json +3 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computed-fields expression language (v1 — id-only).
|
|
3
|
+
*
|
|
4
|
+
* Grammar
|
|
5
|
+
* -------
|
|
6
|
+
* ```
|
|
7
|
+
* expression := term (("+" | "-") term)*
|
|
8
|
+
* term := factor (("," | "/" | "%") factor)* // "," = multiply
|
|
9
|
+
* factor := integer-literal | path | function-call | "(" expression ")"
|
|
10
|
+
* path := id ( "." id )* // "." = link/binding hop
|
|
11
|
+
* function := "sum" | "count" | "avg" | "min" | "max" "(" expression ")"
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Every symbol is a numeric field `id` (per-table dense counter, nested
|
|
15
|
+
* children included). There are no decimal literals: `.` is reserved as the
|
|
16
|
+
* path separator, so `3.4` is a link hop (`field 3`, then `field 4` in the
|
|
17
|
+
* table `field 3` links to), never the decimal 3.4. Fractional results are
|
|
18
|
+
* reachable via division (`314 / 100` → 3.14).
|
|
19
|
+
*
|
|
20
|
+
* A bare integer that **matches a field id in the table's schema** is that
|
|
21
|
+
* field's value (ids are locative); a bare integer that matches no field id
|
|
22
|
+
* is an integer literal. This is what makes both `sum(4, 3.4)` (id 4 =
|
|
23
|
+
* quantity) and `314 / 100` (no such ids) usable.
|
|
24
|
+
*
|
|
25
|
+
* Compiled expressions are persisted with the schema as
|
|
26
|
+
* `{ expr: string, ast: CompiledExpressionNode }` so key/table renames never
|
|
27
|
+
* retarget an expression: the AST carries only field ids (+ the id of the
|
|
28
|
+
* array ancestor a helper iterates), and every key path is re-derived from
|
|
29
|
+
* the *current* schema at write time.
|
|
30
|
+
*/
|
|
31
|
+
import { createError, isArrayOfObjects } from "./utils.js";
|
|
32
|
+
/** Cap on the raw `computed` string length (bound work at validation). */
|
|
33
|
+
export const COMPUTED_EXPR_MAX_LENGTH = 512;
|
|
34
|
+
/** Cap on the parsed AST depth (protects the parser/evaluator recursion). */
|
|
35
|
+
export const COMPUTED_EXPR_MAX_DEPTH = 64;
|
|
36
|
+
const FN_NAMES = ["sum", "count", "avg", "min", "max"];
|
|
37
|
+
const syntaxError = (language, fieldKey) => createError(language, "COMPUTED_FIELD_SYNTAX", fieldKey);
|
|
38
|
+
function tokenize(source) {
|
|
39
|
+
const tokens = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < source.length) {
|
|
42
|
+
const ch = source[i];
|
|
43
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch >= "0" && ch <= "9") {
|
|
48
|
+
let j = i;
|
|
49
|
+
while (j < source.length && source[j] >= "0" && source[j] <= "9")
|
|
50
|
+
j++;
|
|
51
|
+
tokens.push({ t: "num", v: Number(source.slice(i, j)) });
|
|
52
|
+
i = j;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) {
|
|
56
|
+
let j = i;
|
|
57
|
+
while (j < source.length &&
|
|
58
|
+
((source[j] >= "a" && source[j] <= "z") ||
|
|
59
|
+
(source[j] >= "A" && source[j] <= "Z") ||
|
|
60
|
+
(source[j] >= "0" && source[j] <= "9") ||
|
|
61
|
+
source[j] === "_"))
|
|
62
|
+
j++;
|
|
63
|
+
tokens.push({ t: "ident", v: source.slice(i, j) });
|
|
64
|
+
i = j;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
switch (ch) {
|
|
68
|
+
case ".":
|
|
69
|
+
tokens.push({ t: "dot" });
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
case "(":
|
|
73
|
+
tokens.push({ t: "lp" });
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
case ")":
|
|
77
|
+
tokens.push({ t: "rp" });
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
case "+":
|
|
81
|
+
case "-":
|
|
82
|
+
case ",":
|
|
83
|
+
case "/":
|
|
84
|
+
case "%":
|
|
85
|
+
tokens.push({ t: "op", v: ch });
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
default:
|
|
89
|
+
throw syntaxError("en", "");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
tokens.push({ t: "eof" });
|
|
93
|
+
return tokens;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse a `computed` expression into a raw AST. Throws
|
|
97
|
+
* `COMPUTED_FIELD_SYNTAX` on invalid syntax, oversized input or excessive
|
|
98
|
+
* nesting.
|
|
99
|
+
*/
|
|
100
|
+
export function parseExpression(source, language = "en", fieldKey = "") {
|
|
101
|
+
if (typeof source !== "string" || !source.length)
|
|
102
|
+
throw syntaxError(language, fieldKey);
|
|
103
|
+
if (source.length > COMPUTED_EXPR_MAX_LENGTH)
|
|
104
|
+
throw syntaxError(language, fieldKey);
|
|
105
|
+
const tokens = tokenize(source);
|
|
106
|
+
let pos = 0;
|
|
107
|
+
let depth = 0;
|
|
108
|
+
const peek = () => tokens[pos];
|
|
109
|
+
const next = () => tokens[pos++];
|
|
110
|
+
const fail = () => syntaxError(language, fieldKey);
|
|
111
|
+
const enter = () => {
|
|
112
|
+
if (++depth > COMPUTED_EXPR_MAX_DEPTH)
|
|
113
|
+
throw fail();
|
|
114
|
+
};
|
|
115
|
+
const leave = () => {
|
|
116
|
+
depth--;
|
|
117
|
+
};
|
|
118
|
+
const expression = () => {
|
|
119
|
+
enter();
|
|
120
|
+
let left = term();
|
|
121
|
+
let token = peek();
|
|
122
|
+
while (token.t === "op" && (token.v === "+" || token.v === "-")) {
|
|
123
|
+
const op = next().v;
|
|
124
|
+
left = {
|
|
125
|
+
kind: "bin",
|
|
126
|
+
op: op === "+" ? "add" : "sub",
|
|
127
|
+
left,
|
|
128
|
+
right: term(),
|
|
129
|
+
};
|
|
130
|
+
token = peek();
|
|
131
|
+
}
|
|
132
|
+
leave();
|
|
133
|
+
return left;
|
|
134
|
+
};
|
|
135
|
+
const term = () => {
|
|
136
|
+
enter();
|
|
137
|
+
let left = factor();
|
|
138
|
+
while (peek().t === "op" && [",", "/", "%"].includes(peek().v)) {
|
|
139
|
+
const op = next().v;
|
|
140
|
+
const binOp = op === "," ? "mul" : op === "/" ? "div" : "mod";
|
|
141
|
+
left = { kind: "bin", op: binOp, left, right: factor() };
|
|
142
|
+
}
|
|
143
|
+
leave();
|
|
144
|
+
return left;
|
|
145
|
+
};
|
|
146
|
+
const factor = () => {
|
|
147
|
+
enter();
|
|
148
|
+
const token = next();
|
|
149
|
+
if (token.t === "num") {
|
|
150
|
+
if (peek().t === "dot") {
|
|
151
|
+
const ids = [token.v];
|
|
152
|
+
while (peek().t === "dot") {
|
|
153
|
+
next();
|
|
154
|
+
const seg = next();
|
|
155
|
+
if (seg.t !== "num")
|
|
156
|
+
throw fail();
|
|
157
|
+
ids.push(seg.v);
|
|
158
|
+
}
|
|
159
|
+
leave();
|
|
160
|
+
return { kind: "path", ids };
|
|
161
|
+
}
|
|
162
|
+
leave();
|
|
163
|
+
return { kind: "num", value: token.v };
|
|
164
|
+
}
|
|
165
|
+
if (token.t === "ident") {
|
|
166
|
+
const name = token.v;
|
|
167
|
+
if (!FN_NAMES.includes(name))
|
|
168
|
+
throw fail();
|
|
169
|
+
if (next().t !== "lp")
|
|
170
|
+
throw fail();
|
|
171
|
+
const arg = expression();
|
|
172
|
+
if (next().t !== "rp")
|
|
173
|
+
throw fail();
|
|
174
|
+
leave();
|
|
175
|
+
return { kind: "fn", name: name, arg };
|
|
176
|
+
}
|
|
177
|
+
if (token.t === "lp") {
|
|
178
|
+
const inner = expression();
|
|
179
|
+
if (next().t !== "rp")
|
|
180
|
+
throw fail();
|
|
181
|
+
leave();
|
|
182
|
+
return inner;
|
|
183
|
+
}
|
|
184
|
+
throw fail();
|
|
185
|
+
};
|
|
186
|
+
const root = expression();
|
|
187
|
+
if (next().t !== "eof")
|
|
188
|
+
throw fail();
|
|
189
|
+
return root;
|
|
190
|
+
}
|
|
191
|
+
/* ---------------------------------------------------------------------------
|
|
192
|
+
* Schema index
|
|
193
|
+
* ------------------------------------------------------------------------- */
|
|
194
|
+
/**
|
|
195
|
+
* Build the id → {@link FieldRef} index of a schema (nested children included,
|
|
196
|
+
* using dotted key paths). Container fields (array/object with object
|
|
197
|
+
* children) are indexed too so unknown ids are detected, but they can't be
|
|
198
|
+
* referenced by an expression.
|
|
199
|
+
*/
|
|
200
|
+
export function buildFieldIndex(schema) {
|
|
201
|
+
const index = new Map();
|
|
202
|
+
const isArrayOfObjectsContainer = (field) => Array.isArray(field.children) && isArrayOfObjects(field.children);
|
|
203
|
+
const walk = (fields, prefix, ancestors) => {
|
|
204
|
+
for (const field of fields) {
|
|
205
|
+
const key = prefix ? `${prefix}.${field.key}` : field.key;
|
|
206
|
+
if (field.id !== undefined) {
|
|
207
|
+
let arrayAncestor = null;
|
|
208
|
+
let nestedInArrayOfArrays = false;
|
|
209
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
210
|
+
const ancestor = ancestors[i];
|
|
211
|
+
if (ancestor.field.type === "array" &&
|
|
212
|
+
isArrayOfObjectsContainer(ancestor.field)) {
|
|
213
|
+
arrayAncestor = {
|
|
214
|
+
id: ancestor.field.id,
|
|
215
|
+
key: ancestor.key,
|
|
216
|
+
};
|
|
217
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
218
|
+
const parent = ancestors[j].field;
|
|
219
|
+
if (parent.type === "array" &&
|
|
220
|
+
isArrayOfObjectsContainer(parent)) {
|
|
221
|
+
nestedInArrayOfArrays = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
index.set(field.id, {
|
|
229
|
+
key,
|
|
230
|
+
field,
|
|
231
|
+
arrayAncestor,
|
|
232
|
+
nestedInArrayOfArrays,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (isArrayOfObjectsContainer(field))
|
|
236
|
+
walk(field.children, key, [...ancestors, { field, key }]);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
walk(schema, "", []);
|
|
240
|
+
return index;
|
|
241
|
+
}
|
|
242
|
+
const isContainer = (field) => Array.isArray(field.children) && isArrayOfObjects(field.children);
|
|
243
|
+
/** Collect every path node of a (compiled or raw) expression tree. */
|
|
244
|
+
function collectPaths(node) {
|
|
245
|
+
if (node.kind === "path")
|
|
246
|
+
return [node];
|
|
247
|
+
const paths = [];
|
|
248
|
+
if (node.kind === "bin") {
|
|
249
|
+
paths.push(...collectPaths(node.left));
|
|
250
|
+
paths.push(...collectPaths(node.right));
|
|
251
|
+
}
|
|
252
|
+
else if (node.kind === "fn")
|
|
253
|
+
paths.push(...collectPaths(node.arg));
|
|
254
|
+
return paths;
|
|
255
|
+
}
|
|
256
|
+
async function resolvePathNode(node, ctx, inHelper) {
|
|
257
|
+
const ids = node.ids;
|
|
258
|
+
const hop0 = ctx.index.get(ids[0]);
|
|
259
|
+
if (!hop0)
|
|
260
|
+
throw createError(ctx.language, "COMPUTED_FIELD_UNKNOWN_FIELD", [
|
|
261
|
+
ctx.ownKey,
|
|
262
|
+
ids[0],
|
|
263
|
+
]);
|
|
264
|
+
if (isContainer(hop0.field))
|
|
265
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
266
|
+
ctx.ownKey,
|
|
267
|
+
ids[0],
|
|
268
|
+
]);
|
|
269
|
+
const arrayFieldId = hop0.arrayAncestor?.id ?? null;
|
|
270
|
+
// A bare (non-helper) reference may only address fields outside arrays;
|
|
271
|
+
// array contents are reachable exclusively through sum/count/avg/min/max.
|
|
272
|
+
if (!inHelper && arrayFieldId !== null)
|
|
273
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
274
|
+
ctx.ownKey,
|
|
275
|
+
ids[0],
|
|
276
|
+
]);
|
|
277
|
+
if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
|
|
278
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
279
|
+
ctx.ownKey,
|
|
280
|
+
ids[0],
|
|
281
|
+
]);
|
|
282
|
+
// Link hops: each hop beyond the first must live in the table the previous
|
|
283
|
+
// hop's field links to.
|
|
284
|
+
let currentRef = hop0;
|
|
285
|
+
for (let i = 1; i < ids.length; i++) {
|
|
286
|
+
const linkField = currentRef.field;
|
|
287
|
+
if (linkField.type !== "table" || typeof linkField.table !== "string")
|
|
288
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_LINK", [
|
|
289
|
+
ctx.ownKey,
|
|
290
|
+
ids[i],
|
|
291
|
+
]);
|
|
292
|
+
const targetIndex = await ctx.getTableIndex(linkField.table);
|
|
293
|
+
if (!targetIndex)
|
|
294
|
+
throw createError(ctx.language, "TABLE_NOT_EXISTS", linkField.table);
|
|
295
|
+
const ref = targetIndex.get(ids[i]);
|
|
296
|
+
if (!ref)
|
|
297
|
+
throw createError(ctx.language, "COMPUTED_FIELD_UNKNOWN_FIELD", [
|
|
298
|
+
ctx.ownKey,
|
|
299
|
+
ids[i],
|
|
300
|
+
]);
|
|
301
|
+
if (isContainer(ref.field))
|
|
302
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
303
|
+
ctx.ownKey,
|
|
304
|
+
ids[i],
|
|
305
|
+
]);
|
|
306
|
+
currentRef = ref;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
node: { kind: "path", ids, arrayFieldId },
|
|
310
|
+
deps: new Set([ids[0]]),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function resolveNode(node, ctx, inHelper) {
|
|
314
|
+
switch (node.kind) {
|
|
315
|
+
case "num":
|
|
316
|
+
// A bare integer that matches an existing field id is that field
|
|
317
|
+
// (ids are locative, inside helpers included — this is what makes
|
|
318
|
+
// `sum(5, 4.2)` and `sum(5, 6)` usable); any other bare integer is
|
|
319
|
+
// an integer literal (`314 / 100`).
|
|
320
|
+
if (ctx.index.has(node.value))
|
|
321
|
+
return resolvePathNode({ kind: "path", ids: [node.value] }, ctx, inHelper);
|
|
322
|
+
return { node: { kind: "num", value: node.value }, deps: new Set() };
|
|
323
|
+
case "path":
|
|
324
|
+
return resolvePathNode(node, ctx, inHelper);
|
|
325
|
+
case "bin": {
|
|
326
|
+
const left = await resolveNode(node.left, ctx, inHelper);
|
|
327
|
+
const right = await resolveNode(node.right, ctx, inHelper);
|
|
328
|
+
const deps = new Set([...left.deps, ...right.deps]);
|
|
329
|
+
return {
|
|
330
|
+
node: { kind: "bin", op: node.op, left: left.node, right: right.node },
|
|
331
|
+
deps,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
case "fn": {
|
|
335
|
+
if (inHelper)
|
|
336
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
337
|
+
ctx.ownKey,
|
|
338
|
+
]);
|
|
339
|
+
const arg = await resolveNode(node.arg, ctx, true);
|
|
340
|
+
const paths = collectPaths(arg.node);
|
|
341
|
+
if (!paths.length)
|
|
342
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
343
|
+
ctx.ownKey,
|
|
344
|
+
]);
|
|
345
|
+
const arrayFieldIds = new Set(paths.map((path) => path.arrayFieldId ?? null));
|
|
346
|
+
if (arrayFieldIds.size !== 1 || arrayFieldIds.has(null))
|
|
347
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
348
|
+
ctx.ownKey,
|
|
349
|
+
]);
|
|
350
|
+
return {
|
|
351
|
+
node: {
|
|
352
|
+
kind: "fn",
|
|
353
|
+
name: node.name,
|
|
354
|
+
arrayFieldId: arrayFieldIds.values().next().value,
|
|
355
|
+
arg: arg.node,
|
|
356
|
+
},
|
|
357
|
+
deps: arg.deps,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Resolve a raw expression against the schema (and linked tables), returning
|
|
364
|
+
* the compiled node plus the set of field ids it reads from the current
|
|
365
|
+
* table (used for dependency ordering / cycle detection between computed
|
|
366
|
+
* fields).
|
|
367
|
+
*/
|
|
368
|
+
export async function resolveExpression(expression, ctx) {
|
|
369
|
+
const resolved = await resolveNode(expression, ctx, false);
|
|
370
|
+
return { ast: resolved.node, deps: resolved.deps };
|
|
371
|
+
}
|
|
372
|
+
/** Every field id a compiled expression reads from the *current* table. */
|
|
373
|
+
export function collectFieldDeps(node) {
|
|
374
|
+
const deps = new Set();
|
|
375
|
+
const visit = (n) => {
|
|
376
|
+
switch (n.kind) {
|
|
377
|
+
case "num":
|
|
378
|
+
return;
|
|
379
|
+
case "path":
|
|
380
|
+
deps.add(n.ids[0]);
|
|
381
|
+
return;
|
|
382
|
+
case "bin":
|
|
383
|
+
visit(n.left);
|
|
384
|
+
visit(n.right);
|
|
385
|
+
return;
|
|
386
|
+
case "fn":
|
|
387
|
+
visit(n.arg);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
visit(node);
|
|
392
|
+
return deps;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Order computed fields so every dependency is evaluated before its dependents.
|
|
396
|
+
* Throws `COMPUTED_FIELD_CYCLE` when a field (transitively) depends on itself.
|
|
397
|
+
*/
|
|
398
|
+
export function topoSortComputedFields(fields, language) {
|
|
399
|
+
const byId = new Map(fields.map((field) => [field.id, field]));
|
|
400
|
+
const remaining = new Set(fields.map((field) => field.id));
|
|
401
|
+
const ordered = [];
|
|
402
|
+
const blocked = new Map(); // dependent -> dependencies still missing
|
|
403
|
+
const indegree = new Map();
|
|
404
|
+
for (const field of fields) {
|
|
405
|
+
const dependents = [];
|
|
406
|
+
for (const depId of field.deps)
|
|
407
|
+
if (byId.has(depId))
|
|
408
|
+
dependents.push(depId);
|
|
409
|
+
blocked.set(field.id, dependents);
|
|
410
|
+
}
|
|
411
|
+
const ready = [];
|
|
412
|
+
for (const [id, deps] of blocked) {
|
|
413
|
+
indegree.set(id, deps.length);
|
|
414
|
+
if (deps.length === 0)
|
|
415
|
+
ready.push(id);
|
|
416
|
+
}
|
|
417
|
+
while (ready.length) {
|
|
418
|
+
const id = ready.shift();
|
|
419
|
+
const field = byId.get(id);
|
|
420
|
+
if (!field)
|
|
421
|
+
continue;
|
|
422
|
+
ordered.push(field);
|
|
423
|
+
remaining.delete(id);
|
|
424
|
+
for (const [otherId, deps] of blocked) {
|
|
425
|
+
if (!remaining.has(otherId))
|
|
426
|
+
continue;
|
|
427
|
+
if (deps.includes(id)) {
|
|
428
|
+
const nextIndegree = (indegree.get(otherId) ?? 1) - 1;
|
|
429
|
+
indegree.set(otherId, nextIndegree);
|
|
430
|
+
if (nextIndegree === 0)
|
|
431
|
+
ready.push(otherId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (remaining.size) {
|
|
436
|
+
const keys = fields
|
|
437
|
+
.filter((field) => remaining.has(field.id))
|
|
438
|
+
.map((field) => field.key);
|
|
439
|
+
throw createError(language, "COMPUTED_FIELD_CYCLE", keys.join(", "));
|
|
440
|
+
}
|
|
441
|
+
return ordered;
|
|
442
|
+
}
|
|
443
|
+
/* ---------------------------------------------------------------------------
|
|
444
|
+
* Row flattening (shared by the evaluator)
|
|
445
|
+
* ------------------------------------------------------------------------- */
|
|
446
|
+
/**
|
|
447
|
+
* Flatten a formatted row into a dot-notation record. Objects are flattened
|
|
448
|
+
* (`meta.age`), arrays of objects are combined per child key
|
|
449
|
+
* (`items.quantity` -> array of per-element values) and everything else is
|
|
450
|
+
* stored under its dotted key.
|
|
451
|
+
*/
|
|
452
|
+
export function flattenRecord(obj, prefix = "") {
|
|
453
|
+
const out = {};
|
|
454
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
455
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
456
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v))
|
|
457
|
+
Object.assign(out, flattenRecord(v, key));
|
|
458
|
+
else if (Array.isArray(v) && v.length && isArrayOfObjects(v)) {
|
|
459
|
+
const combined = {};
|
|
460
|
+
for (const element of v)
|
|
461
|
+
for (const [childKey, childValue] of Object.entries(element)) {
|
|
462
|
+
if (!combined[childKey])
|
|
463
|
+
combined[childKey] = [];
|
|
464
|
+
combined[childKey].push(childValue);
|
|
465
|
+
}
|
|
466
|
+
for (const [childKey, values] of Object.entries(combined))
|
|
467
|
+
out[`${key}.${childKey}`] = values;
|
|
468
|
+
}
|
|
469
|
+
else
|
|
470
|
+
out[key] = v;
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Resolve a dotted key path against a structured frame in place — the direct
|
|
476
|
+
* read-path counterpart of `flattenRecord`. Walks `obj` segment by segment
|
|
477
|
+
* and returns `undefined` when any intermediate is null, undefined, a
|
|
478
|
+
* non-object, or an array (arrays resolve numerically only, so they never
|
|
479
|
+
* match a dotted segment), mirroring the leaves `flattenRecord` would have
|
|
480
|
+
* produced without materialising the flattened record.
|
|
481
|
+
*/
|
|
482
|
+
export function resolveFramePath(obj, dottedKey) {
|
|
483
|
+
if (dottedKey.length === 0)
|
|
484
|
+
return obj;
|
|
485
|
+
let cur = obj;
|
|
486
|
+
for (const segment of dottedKey.split(".")) {
|
|
487
|
+
if (cur === null ||
|
|
488
|
+
cur === undefined ||
|
|
489
|
+
typeof cur !== "object" ||
|
|
490
|
+
Array.isArray(cur))
|
|
491
|
+
return undefined;
|
|
492
|
+
cur = cur[segment];
|
|
493
|
+
}
|
|
494
|
+
return cur;
|
|
495
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
+
import { type ComputedFieldSpec } from "./expression.js";
|
|
2
3
|
import { type JournalFileOp } from "./journal.js";
|
|
3
4
|
export interface Data {
|
|
4
5
|
id?: string | number;
|
|
@@ -16,6 +17,10 @@ export type Field = {
|
|
|
16
17
|
unique?: boolean | number | string;
|
|
17
18
|
children?: FieldType | FieldType[] | Schema;
|
|
18
19
|
regex?: string;
|
|
20
|
+
/** Computed-fields expression (see the README). Either a raw expression
|
|
21
|
+
* string, or the persisted `{ expr, ast }` spec written by Inibase once
|
|
22
|
+
* the expression has been compiled. */
|
|
23
|
+
computed?: string | ComputedFieldSpec;
|
|
19
24
|
};
|
|
20
25
|
export type Schema = Field[];
|
|
21
26
|
export interface Options {
|
|
@@ -73,7 +78,7 @@ declare global {
|
|
|
73
78
|
entries<T extends object>(o: T): Entries<T>;
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
|
-
export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME"];
|
|
81
|
+
export declare const ERROR_CODES: readonly ["GROUP_UNIQUE", "FIELD_UNIQUE", "FIELD_REQUIRED", "NO_SCHEMA", "TABLE_EMPTY", "INVALID_ID", "INVALID_TYPE", "INVALID_PARAMETERS", "NO_ENV", "TABLE_EXISTS", "TABLE_NOT_EXISTS", "INVALID_REGEX_MATCH", "INVALID_NAME", "COMPUTED_FIELD_SYNTAX", "COMPUTED_FIELD_UNKNOWN_FIELD", "COMPUTED_FIELD_INVALID_LINK", "COMPUTED_FIELD_INVALID_TARGET", "COMPUTED_FIELD_CONFLICT", "COMPUTED_FIELD_CYCLE", "COMPUTED_FIELD_SETTABLE", "COMPUTED_FIELD_DANGLING_LINK", "COMPUTED_FIELD_ARITHMETIC"];
|
|
77
82
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
78
83
|
export type ErrorLang = "en" | "ar" | "fr" | "es";
|
|
79
84
|
export declare const globalConfig: {
|
|
@@ -100,6 +105,12 @@ export default class Inibase {
|
|
|
100
105
|
* resolve numeric ids to line numbers arithmetically instead of scanning
|
|
101
106
|
* the id file. Set false by any partial row deletion. */
|
|
102
107
|
private idDensity;
|
|
108
|
+
/** Per-table computed-plan cache (null = table has no computed fields).
|
|
109
|
+
* A plan depends only on the table's persisted schema (compiled ASTs are
|
|
110
|
+
* id-based and rename-proof), so it is rebuilt only when the schema
|
|
111
|
+
* changes: see the invalidation in `getTable`'s reload branch,
|
|
112
|
+
* `createTable` and `updateTableLocked`. Bounded by the number of tables. */
|
|
113
|
+
private readonly computedPlanCache;
|
|
103
114
|
private databasePath;
|
|
104
115
|
private uniqueMap;
|
|
105
116
|
private schemaFileExtension;
|
|
@@ -167,6 +178,57 @@ export default class Inibase {
|
|
|
167
178
|
private joinPathesContents;
|
|
168
179
|
private _processSchemaDataHelper;
|
|
169
180
|
private processSchemaData;
|
|
181
|
+
/**
|
|
182
|
+
* Extract the raw expression from a field's `computed` property (string or
|
|
183
|
+
* persisted `{ expr, ast }` spec).
|
|
184
|
+
*/
|
|
185
|
+
private computedExprOf;
|
|
186
|
+
/**
|
|
187
|
+
* Compile every `computed` expression of a schema (which must already have
|
|
188
|
+
* ids assigned) into its persisted `{ expr, ast }` form, in dependency
|
|
189
|
+
* order. Throws `COMPUTED_FIELD_CYCLE` on cyclic fields. Used by DDL so the
|
|
190
|
+
* on-disk schema always carries compiled ASTs.
|
|
191
|
+
*/
|
|
192
|
+
private compileComputedFields;
|
|
193
|
+
/**
|
|
194
|
+
* Build the evaluation plan (topological order + id index) for a table's
|
|
195
|
+
* computed fields. Throws on cycles or unresolvable expressions.
|
|
196
|
+
*/
|
|
197
|
+
private buildComputedPlan;
|
|
198
|
+
/** Id index of a table's schema (link targets are re-resolved at write
|
|
199
|
+
* time, so renames never retarget a compiled expression). */
|
|
200
|
+
private tableFieldIndex;
|
|
201
|
+
/**
|
|
202
|
+
* Evaluate a batch of (merged) rows against the table's computed fields.
|
|
203
|
+
* Returns `lineNo -> { computedKey -> value }` in dependency order.
|
|
204
|
+
*
|
|
205
|
+
* Batched link-hop reads: when the plan contains any link hop, a dry
|
|
206
|
+
* collection pass records every (table, column, id) triple the rows'
|
|
207
|
+
* expressions need (no file I/O), each distinct triple is then resolved
|
|
208
|
+
* exactly once — deduplicated across rows and fields — and a final pass
|
|
209
|
+
* evaluates against the warm cache. Plans without hops run the single
|
|
210
|
+
* evaluation pass unchanged.
|
|
211
|
+
*/
|
|
212
|
+
private evaluateComputedRows;
|
|
213
|
+
private createLinkReader;
|
|
214
|
+
/** Evaluate every computed field of one row (topological order) and merge
|
|
215
|
+
* the results back into the row so dependent fields see them. */
|
|
216
|
+
private evaluateRowComputed;
|
|
217
|
+
private evaluateNode;
|
|
218
|
+
/** Evaluate a path (`ids`, dot-separated link hops) against the current
|
|
219
|
+
* frame. Returns `null` when an intermediate link value is missing. */
|
|
220
|
+
private evaluatePath;
|
|
221
|
+
/** Id index lookup with a shared per-evaluation cache. */
|
|
222
|
+
private indexFor;
|
|
223
|
+
/** Read a single column of one linked row via `get`, or null when the
|
|
224
|
+
* row does not exist (dangling link). */
|
|
225
|
+
private readLinkedRow;
|
|
226
|
+
private coerceNumber;
|
|
227
|
+
private applyBinaryOp;
|
|
228
|
+
private aggregate;
|
|
229
|
+
/** Merge evaluated per-line computed values into a `pathesContents` map
|
|
230
|
+
* as line-numbered replace records (encoded cells). */
|
|
231
|
+
private mergeComputedLineRecords;
|
|
170
232
|
private isSimpleField;
|
|
171
233
|
private processSimpleField;
|
|
172
234
|
/**
|