inibase 3.1.0 → 3.2.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 CHANGED
@@ -798,6 +798,20 @@ await db.sum("user", "age");
798
798
 
799
799
  // get the sum of column "age" by criteria (where "isActive" is equal to "false") in "user" table
800
800
  await db.sum("user", ["age", ...], { isActive: false });
801
+
802
+ // aggregate over the elements of an array-of-objects column: `items.quantity`
803
+ // counts every element of every row's `items` (single array path per call;
804
+ // `product` here is an *element* predicate, matching elements whose linked
805
+ // product is the given row)
806
+ await db.sum("orders", "items.quantity", { product: "id-of-widget" }, { nested: true });
807
+
808
+ // multiple columns share one predicate and return a record
809
+ await db.sum(
810
+ "orders",
811
+ ["items.quantity", "items.lineTotal"],
812
+ { product: "id-of-widget" },
813
+ { nested: true },
814
+ ); // => { "items.quantity": 5, "items.lineTotal": 1295 }
801
815
  ```
802
816
 
803
817
  </blockquote>
@@ -889,8 +903,8 @@ await db.createTable("orders", [
889
903
  { key: "unitPriceCents", type: "number" },
890
904
  ],
891
905
  },
892
- { key: "totalCents", type: "number", computed: "sum(5, 6)" }, // quantity x unitPriceCents
893
- { key: "totalCentsLive", type: "number", computed: "sum(5, 4.2)" }, // quantity x product.price
906
+ { key: "totalCents", type: "number", computed: "sum(5 * 6)" }, // quantity x unitPriceCents
907
+ { key: "totalCentsLive", type: "number", computed: "sum(5 * 4.2)" }, // quantity x product.price
894
908
  ]);
895
909
 
896
910
  const posted = await db.post(
@@ -910,12 +924,74 @@ const posted = await db.post(
910
924
  // posted.totalCentsLive === 847 (2*price(widget) + 1*price(gadget))
911
925
  ```
912
926
 
927
+ A child of an array-of-objects column can itself be computed. It is evaluated
928
+ once **per element** and stored in its own element cell, so it works like any
929
+ other element child in queries — including `sum` with `{ nested: true }`:
930
+
931
+ ```ts
932
+ // ids are assigned per table in schema order: status=1, items=2, product=3,
933
+ // quantity=4, lineTotal=5
934
+ await db.createTable("orders", [
935
+ { key: "status", type: "number" },
936
+ {
937
+ key: "items",
938
+ type: "array",
939
+ children: [
940
+ { key: "product", type: "table", table: "product" },
941
+ { key: "quantity", type: "number" },
942
+ // lineTotal = quantity × product.price ("4 * 3.2": id 4 = quantity,
943
+ // hop to the linked product's price, id 2 in `product`)
944
+ { key: "lineTotal", type: "number", computed: "4 * 3.2" },
945
+ ],
946
+ },
947
+ ]);
948
+
949
+ const posted = await db.post(
950
+ "orders",
951
+ {
952
+ status: 1,
953
+ items: [
954
+ { product: "id-of-widget", quantity: 2 },
955
+ { product: "id-of-gadget", quantity: 1 },
956
+ ],
957
+ },
958
+ undefined,
959
+ true,
960
+ );
961
+ // posted.items[0].lineTotal === 2 * price(widget)
962
+ // posted.items[1].lineTotal === 1 * price(gadget)
963
+
964
+ // per-product revenue without touching the line items yourself:
965
+ await db.sum("orders", "items.lineTotal", { product: "id-of-widget" }, { nested: true });
966
+ ```
967
+
968
+ A top-level computed may read a computed child through a helper
969
+ (`{ key: "totalCents", type: "number", computed: "sum(5)" }`) — the children
970
+ are derived first, in dependency order.
971
+
972
+ **Element expressions (child computeds).**
973
+
974
+ - Every reference must be a sibling child of the same array root — top-level
975
+ columns and siblings of a *different* array are rejected
976
+ (`COMPUTED_FIELD_INVALID_TARGET`), and helpers (`sum`/`avg`/...) are not
977
+ allowed inside element expressions.
978
+ - Link hops are supported (`4 * 3.2` = sibling `quantity` × linked
979
+ `product.price`).
980
+ - The element field must be `type: "number"`.
981
+ - A **missing/null** operand in an element evaluates to `0` for that element
982
+ (it never aborts the write and never stores null). A genuinely dangling link
983
+ (a link to a missing row) still raises `COMPUTED_FIELD_DANGLING_LINK`, same
984
+ as top-level computeds.
985
+ - `updateTable` backfills element cells when a child computed is added or its
986
+ expression changes; the backfill resolves links from the persisted element
987
+ values and follows the same missing-operand → `0` rule.
988
+
913
989
  **Expression language (v1, integer-only).**
914
990
 
915
- - Operators: `+` `-` `,` (multiply) `/` `%`; `( )` for grouping. Multiplication
916
- binds tighter than addition (`1 , 2 + 3` = `(1*2) + 3`).
991
+ - Operators: `+` `-` `*` `/` `%`; `( )` for grouping. Multiplication
992
+ binds tighter than addition (`1 * 2 + 3` = `(1*2) + 3`).
917
993
  - Helpers: `sum` `count` `avg` `min` `max` iterate an array-of-objects found by
918
- the ids inside the parentheses (`sum(5, 6)` = quantity x unit-price per item).
994
+ the ids inside the parentheses (`sum(5 * 6)` = quantity x unit-price per item).
919
995
  - Paths: `id ("." id)*` where `.` hops through a `table` link
920
996
  (`4.2` = price of the linked product row).
921
997
  - Integers only: there are no decimal literals — `.` is the path separator, so
@@ -941,10 +1017,14 @@ const posted = await db.post(
941
1017
  `avg`/`min`/`max` over no values throw `COMPUTED_FIELD_ARITHMETIC`.
942
1018
  - Non-numeric operands, division/modulo by zero, and arithmetic over missing
943
1019
  (null) values throw `COMPUTED_FIELD_ARITHMETIC`; a link to a missing row
944
- throws `COMPUTED_FIELD_DANGLING_LINK`.
945
- - Only top-level schema fields can be computed in v1; array contents are
946
- reachable through helpers. Missing link values make a single bare path
947
- evaluate to null (stored empty).
1020
+ throws `COMPUTED_FIELD_DANGLING_LINK`. (Element expressions are the one
1021
+ exception: a missing/null operand there evaluates that element to `0` see
1022
+ above.)
1023
+ - Child computeds (see above) extend the v1 rules to element fields of
1024
+ array-of-objects columns; array contents stay reachable from top-level
1025
+ helpers. Only one level of nesting is computed.
1026
+ - Missing link values make a single bare path evaluate to null (stored
1027
+ empty).
948
1028
 
949
1029
  </blockquote>
950
1030
  </details>
@@ -982,7 +1062,7 @@ const posted = await db.post(
982
1062
 
983
1063
  ### Computed fields
984
1064
 
985
- Write-time evaluation cost (helpers `sum(3 , 4)`, `avg(4)`, `min(4)`, `max(4)`, `count(3)` over an `items` array with 3 lines, plus an arithmetic field `itemTotal × (314 / 100)`), compared against the identical table without computed fields:
1065
+ Write-time evaluation cost (helpers `sum(3 * 4)`, `avg(4)`, `min(4)`, `max(4)`, `count(3)` over an `items` array with 3 lines, plus an arithmetic field `itemTotal × (314 / 100)`), compared against the identical table without computed fields:
986
1066
 
987
1067
  | rows | POST bulk (plain / computed) | POST single (plain / computed) | PUT recompute (plain / computed) |
988
1068
  |------|------------------------------|--------------------------------|----------------------------------|
@@ -5,7 +5,7 @@
5
5
  * -------
6
6
  * ```
7
7
  * expression := term (("+" | "-") term)*
8
- * term := factor (("," | "/" | "%") factor)* // "," = multiply
8
+ * term := factor (("*" | "/" | "%") factor)* // "*" = multiply
9
9
  * factor := integer-literal | path | function-call | "(" expression ")"
10
10
  * path := id ( "." id )* // "." = link/binding hop
11
11
  * function := "sum" | "count" | "avg" | "min" | "max" "(" expression ")"
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * A bare integer that **matches a field id in the table's schema** is that
21
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 =
22
+ * is an integer literal. This is what makes both `sum(4 * 3.4)` (id 4 =
23
23
  * quantity) and `314 / 100` (no such ids) usable.
24
24
  *
25
25
  * Compiled expressions are persisted with the schema as
@@ -118,6 +118,15 @@ export interface ResolveContext {
118
118
  index: Map<number, FieldRef>;
119
119
  /** Fetch (and cache) the id index of another table, or undefined. */
120
120
  getTableIndex: (tableName: string) => Promise<Map<number, FieldRef> | undefined>;
121
+ /**
122
+ * When set, the expression being compiled belongs to an *element* of the
123
+ * array-of-objects field with this id (a child computed field). Every
124
+ * referenced field must be a sibling child of that same array; top-level
125
+ * fields and helpers are rejected.
126
+ */
127
+ elementContext?: {
128
+ arrayRootId: number;
129
+ } | null;
121
130
  }
122
131
  /**
123
132
  * Resolve a raw expression against the schema (and linked tables), returning
@@ -5,7 +5,7 @@
5
5
  * -------
6
6
  * ```
7
7
  * expression := term (("+" | "-") term)*
8
- * term := factor (("," | "/" | "%") factor)* // "," = multiply
8
+ * term := factor (("*" | "/" | "%") factor)* // "*" = multiply
9
9
  * factor := integer-literal | path | function-call | "(" expression ")"
10
10
  * path := id ( "." id )* // "." = link/binding hop
11
11
  * function := "sum" | "count" | "avg" | "min" | "max" "(" expression ")"
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * A bare integer that **matches a field id in the table's schema** is that
21
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 =
22
+ * is an integer literal. This is what makes both `sum(4 * 3.4)` (id 4 =
23
23
  * quantity) and `314 / 100` (no such ids) usable.
24
24
  *
25
25
  * Compiled expressions are persisted with the schema as
@@ -79,7 +79,7 @@ function tokenize(source) {
79
79
  continue;
80
80
  case "+":
81
81
  case "-":
82
- case ",":
82
+ case "*":
83
83
  case "/":
84
84
  case "%":
85
85
  tokens.push({ t: "op", v: ch });
@@ -135,9 +135,9 @@ export function parseExpression(source, language = "en", fieldKey = "") {
135
135
  const term = () => {
136
136
  enter();
137
137
  let left = factor();
138
- while (peek().t === "op" && [",", "/", "%"].includes(peek().v)) {
138
+ while (peek().t === "op" && ["*", "/", "%"].includes(peek().v)) {
139
139
  const op = next().v;
140
- const binOp = op === "," ? "mul" : op === "/" ? "div" : "mod";
140
+ const binOp = op === "*" ? "mul" : op === "/" ? "div" : "mod";
141
141
  left = { kind: "bin", op: binOp, left, right: factor() };
142
142
  }
143
143
  leave();
@@ -267,14 +267,27 @@ async function resolvePathNode(node, ctx, inHelper) {
267
267
  ids[0],
268
268
  ]);
269
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)
270
+ if (ctx.elementContext) {
271
+ // Child computed field: every reference must be a sibling child of the
272
+ // computed field's own array root (helpers that recurse into other
273
+ // arrays and reads of top-level columns are invalid here).
274
+ if (hop0.arrayAncestor?.id !== ctx.elementContext.arrayRootId)
275
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
276
+ ctx.ownKey,
277
+ ids[0],
278
+ ]);
279
+ if (hop0.nestedInArrayOfArrays)
280
+ throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
281
+ ctx.ownKey,
282
+ ids[0],
283
+ ]);
284
+ }
285
+ else if (!inHelper && arrayFieldId !== null)
273
286
  throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
274
287
  ctx.ownKey,
275
288
  ids[0],
276
289
  ]);
277
- if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
290
+ else if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
278
291
  throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
279
292
  ctx.ownKey,
280
293
  ids[0],
@@ -315,7 +328,7 @@ async function resolveNode(node, ctx, inHelper) {
315
328
  case "num":
316
329
  // A bare integer that matches an existing field id is that field
317
330
  // (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
331
+ // `sum(5 * 4.2)` and `sum(5 * 6)` usable); any other bare integer is
319
332
  // an integer literal (`314 / 100`).
320
333
  if (ctx.index.has(node.value))
321
334
  return resolvePathNode({ kind: "path", ids: [node.value] }, ctx, inHelper);
@@ -332,7 +345,7 @@ async function resolveNode(node, ctx, inHelper) {
332
345
  };
333
346
  }
334
347
  case "fn": {
335
- if (inHelper)
348
+ if (inHelper || ctx.elementContext)
336
349
  throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
337
350
  ctx.ownKey,
338
351
  ]);
package/dist/index.d.ts CHANGED
@@ -29,6 +29,16 @@ export interface Options {
29
29
  columns?: string[] | string;
30
30
  sort?: Record<string, 1 | -1 | "asc" | "ASC" | "desc" | "DESC"> | string[] | string;
31
31
  }
32
+ /** Options for the aggregate actions (`sum`, and later avg/min/max). */
33
+ export interface AggregateOptions {
34
+ /**
35
+ * Aggregate over the *elements* of an array-of-objects column instead of
36
+ * whole rows. The summed columns must be single-level child paths of the
37
+ * same array root (e.g. `"items.quantity"`); `where` keys that resolve as
38
+ * children of that root become per-element predicates.
39
+ */
40
+ nested?: boolean;
41
+ }
32
42
  export interface TableConfig {
33
43
  compression?: boolean;
34
44
  cache?: boolean;
@@ -183,6 +193,12 @@ export default class Inibase {
183
193
  * persisted `{ expr, ast }` spec).
184
194
  */
185
195
  private computedExprOf;
196
+ /**
197
+ * Every computed field of a schema as dotted keys (computed children of an
198
+ * array-of-objects column use their full path, e.g. `items.lineTotal`),
199
+ * with its raw expression — used for updateTable change detection.
200
+ */
201
+ private computedEntries;
186
202
  /**
187
203
  * Compile every `computed` expression of a schema (which must already have
188
204
  * ids assigned) into its persisted `{ expr, ast }` form, in dependency
@@ -229,6 +245,9 @@ export default class Inibase {
229
245
  /** Merge evaluated per-line computed values into a `pathesContents` map
230
246
  * as line-numbered replace records (encoded cells). */
231
247
  private mergeComputedLineRecords;
248
+ /** Compare a freshly evaluated cell value against a stored (already
249
+ * decoded) one — both are scalar or index-aligned element arrays. */
250
+ private sameCellValue;
232
251
  private isSimpleField;
233
252
  private processSimpleField;
234
253
  /**
@@ -407,6 +426,15 @@ export default class Inibase {
407
426
  */
408
427
  sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
409
428
  sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
429
+ /**
430
+ * Element-wise aggregation over an array-of-objects column (`nested` mode).
431
+ *
432
+ * `where` keys that resolve as children of the array root become per-element
433
+ * predicates (AND); everything else is a row-level filter. Only elements
434
+ * passing every element predicate, inside rows passing the row filters, are
435
+ * accumulated.
436
+ */
437
+ private sumNested;
410
438
  /**
411
439
  * Generate average of column(s) in a table
412
440
  *
package/dist/index.js CHANGED
@@ -364,14 +364,14 @@ export default class Inibase {
364
364
  // row. Evaluation happens BEFORE the schema is rewritten, so a
365
365
  // failed migration (dangling link, arithmetic error, unknown
366
366
  // field, ...) leaves the old schema and column values untouched.
367
- const oldComputed = new Map((table.schema ?? [])
368
- .filter((field) => typeof field.computed !== "undefined")
369
- .map((field) => [field.key, this.computedExprOf(field)]));
367
+ const oldComputed = new Map(this.computedEntries(table.schema ?? []).map(({ key, expr }) => [
368
+ key,
369
+ expr,
370
+ ]));
370
371
  const changedComputedKeys = new Set(totalLines > 0
371
- ? schema
372
- .filter((field) => typeof field.computed !== "undefined" &&
373
- oldComputed.get(field.key) !== this.computedExprOf(field))
374
- .map((field) => field.key)
372
+ ? this.computedEntries(schema)
373
+ .filter(({ key, expr }) => oldComputed.get(key) !== expr)
374
+ .map(({ key }) => key)
375
375
  : []);
376
376
  const backfillReplacers = changedComputedKeys.size
377
377
  ? await (async () => {
@@ -1026,6 +1026,25 @@ export default class Inibase {
1026
1026
  const computed = field.computed;
1027
1027
  return typeof computed === "string" ? computed : (computed?.expr ?? "");
1028
1028
  }
1029
+ /**
1030
+ * Every computed field of a schema as dotted keys (computed children of an
1031
+ * array-of-objects column use their full path, e.g. `items.lineTotal`),
1032
+ * with its raw expression — used for updateTable change detection.
1033
+ */
1034
+ computedEntries(schema) {
1035
+ const out = [];
1036
+ const walk = (fields, prefix) => {
1037
+ for (const field of fields) {
1038
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1039
+ if (typeof field.computed !== "undefined")
1040
+ out.push({ key, expr: this.computedExprOf(field) });
1041
+ if (field.children && Utils.isArrayOfObjects(field.children))
1042
+ walk(field.children, key);
1043
+ }
1044
+ };
1045
+ walk(schema, "");
1046
+ return out;
1047
+ }
1029
1048
  /**
1030
1049
  * Compile every `computed` expression of a schema (which must already have
1031
1050
  * ids assigned) into its persisted `{ expr, ast }` form, in dependency
@@ -1033,27 +1052,67 @@ export default class Inibase {
1033
1052
  * on-disk schema always carries compiled ASTs.
1034
1053
  */
1035
1054
  async compileComputedFields(schema) {
1036
- const computedFields = schema.filter((field) => typeof field.computed !== "undefined");
1037
- if (!computedFields.length)
1038
- return schema;
1055
+ const entries = [];
1039
1056
  const index = buildFieldIndex(schema);
1057
+ const walk = (fields, prefix) => {
1058
+ for (const field of fields) {
1059
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1060
+ if (typeof field.computed !== "undefined") {
1061
+ const ref = index.get(field.id);
1062
+ entries.push({
1063
+ field,
1064
+ key,
1065
+ elementRoot: ref?.arrayAncestor
1066
+ ? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
1067
+ : null,
1068
+ });
1069
+ }
1070
+ if (field.children && Utils.isArrayOfObjects(field.children))
1071
+ walk(field.children, key);
1072
+ }
1073
+ };
1074
+ walk(schema, "");
1075
+ if (!entries.length)
1076
+ return schema;
1040
1077
  const ctx = {
1041
1078
  language: this.language,
1042
1079
  ownKey: "",
1043
1080
  index,
1044
1081
  getTableIndex: async (target) => this.tableFieldIndex(target),
1082
+ elementContext: null,
1045
1083
  };
1046
1084
  const resolved = [];
1047
- for (const field of computedFields) {
1048
- // Computed fields are evaluated from the row of the table they
1049
- // belong to, so v1 restricts them to top-level schema fields.
1050
- const ref = index.get(field.id);
1051
- if (!ref || ref.arrayAncestor)
1052
- throw this.createError("COMPUTED_FIELD_INVALID_TARGET", field.key);
1053
- ctx.ownKey = field.key;
1054
- const raw = parseExpression(this.computedExprOf(field), this.language, field.key);
1085
+ for (const entry of entries) {
1086
+ const ref = index.get(entry.field.id);
1087
+ if (!ref)
1088
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1089
+ if (entry.elementRoot) {
1090
+ // Element computed field (a child of an array-of-objects
1091
+ // column): must hold a plain value (never a container), and v1
1092
+ // keeps them numeric so writes are always summable.
1093
+ if ((ref.nestedInArrayOfArrays && ref.arrayAncestor) ||
1094
+ entry.field.type !== "number")
1095
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1096
+ ctx.elementContext = {
1097
+ arrayRootId: entry.elementRoot.id,
1098
+ };
1099
+ }
1100
+ else {
1101
+ // Top-level computed field: cannot live inside an array.
1102
+ if (ref.arrayAncestor)
1103
+ throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
1104
+ ctx.elementContext = null;
1105
+ }
1106
+ ctx.ownKey = entry.key;
1107
+ const raw = parseExpression(this.computedExprOf(entry.field), this.language, entry.key);
1055
1108
  const { ast, deps } = await resolveExpression(raw, ctx);
1056
- resolved.push({ field, ast, deps });
1109
+ resolved.push({
1110
+ field: entry.field,
1111
+ key: entry.key,
1112
+ elementRoot: entry.elementRoot,
1113
+ ast,
1114
+ deps,
1115
+ });
1057
1116
  }
1058
1117
  const ordered = topoSortComputedFields(resolved.map(({ field, deps }) => ({
1059
1118
  id: field.id,
@@ -1065,14 +1124,27 @@ export default class Inibase {
1065
1124
  const entry = resolved.find(({ field }) => field.id === meta.id);
1066
1125
  if (!entry)
1067
1126
  continue;
1068
- specByKey.set(meta.key, {
1127
+ specByKey.set(entry.key, {
1069
1128
  expr: this.computedExprOf(entry.field),
1070
1129
  ast: entry.ast,
1071
1130
  });
1072
1131
  }
1073
- return schema.map((field) => typeof field.computed !== "undefined"
1074
- ? { ...field, computed: specByKey.get(field.key) }
1075
- : field);
1132
+ const applySpecs = (fields, prefix) => fields.map((field) => {
1133
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1134
+ let next = field;
1135
+ if (typeof field.computed !== "undefined") {
1136
+ const spec = specByKey.get(key);
1137
+ if (spec)
1138
+ next = { ...field, computed: spec };
1139
+ }
1140
+ if (field.children && Utils.isArrayOfObjects(field.children))
1141
+ next = {
1142
+ ...next,
1143
+ children: applySpecs(field.children, key),
1144
+ };
1145
+ return next;
1146
+ });
1147
+ return applySpecs(schema, "");
1076
1148
  }
1077
1149
  /**
1078
1150
  * Build the evaluation plan (topological order + id index) for a table's
@@ -1098,6 +1170,7 @@ export default class Inibase {
1098
1170
  language: this.language,
1099
1171
  ownKey: "",
1100
1172
  index,
1173
+ elementContext: null,
1101
1174
  getTableIndex: async (target) => {
1102
1175
  let cached = indexCache.get(target);
1103
1176
  if (!cached) {
@@ -1108,29 +1181,46 @@ export default class Inibase {
1108
1181
  return cached;
1109
1182
  },
1110
1183
  };
1111
- for (const field of s) {
1112
- if (typeof field.computed === "undefined")
1113
- continue;
1114
- const computed = field.computed;
1115
- if (typeof computed === "string") {
1116
- ctx.ownKey = field.key;
1117
- const raw = parseExpression(computed, this.language, field.key);
1118
- const { ast, deps } = await resolveExpression(raw, ctx);
1119
- fields.push({
1120
- id: field.id,
1121
- key: field.key,
1122
- ast,
1123
- deps,
1124
- });
1184
+ // Collect every computed field: top-level ones and, when a schema
1185
+ // carries them, the computed children of array-of-objects columns.
1186
+ const walk = async (fieldsSchema, prefix) => {
1187
+ for (const field of fieldsSchema) {
1188
+ const key = prefix ? `${prefix}.${field.key}` : field.key;
1189
+ if (typeof field.computed !== "undefined") {
1190
+ const ref = index.get(field.id);
1191
+ const elementRoot = ref?.arrayAncestor
1192
+ ? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
1193
+ : null;
1194
+ const computed = field.computed;
1195
+ if (typeof computed === "string") {
1196
+ ctx.ownKey = key;
1197
+ ctx.elementContext = elementRoot
1198
+ ? { arrayRootId: elementRoot.id }
1199
+ : null;
1200
+ const raw = parseExpression(computed, this.language, key);
1201
+ const { ast, deps } = await resolveExpression(raw, ctx);
1202
+ fields.push({
1203
+ id: field.id,
1204
+ key,
1205
+ ast,
1206
+ deps,
1207
+ elementRoot,
1208
+ });
1209
+ }
1210
+ else
1211
+ fields.push({
1212
+ id: field.id,
1213
+ key,
1214
+ ast: computed.ast,
1215
+ deps: collectFieldDeps(computed.ast),
1216
+ elementRoot,
1217
+ });
1218
+ }
1219
+ if (field.children && Utils.isArrayOfObjects(field.children))
1220
+ await walk(field.children, key);
1125
1221
  }
1126
- else
1127
- fields.push({
1128
- id: field.id,
1129
- key: field.key,
1130
- ast: computed.ast,
1131
- deps: collectFieldDeps(computed.ast),
1132
- });
1133
- }
1222
+ };
1223
+ await walk(s, "");
1134
1224
  if (!fields.length) {
1135
1225
  if (schema === undefined)
1136
1226
  this.computedPlanCache.set(tableName, null);
@@ -1206,6 +1296,42 @@ export default class Inibase {
1206
1296
  collect,
1207
1297
  };
1208
1298
  for (const field of plan.fields) {
1299
+ if (field.elementRoot) {
1300
+ // Element computed field (child of an array-of-objects column):
1301
+ // evaluate once per element (frame = element, strip =
1302
+ // `${rootKey}.`), write back into the element so other computed
1303
+ // fields and the flatten pass see it, and report the
1304
+ // per-element array under the dotted column key. Missing
1305
+ // operands read as 0 (`env.nullAsZero`), never an error.
1306
+ const rootKey = field.elementRoot.key;
1307
+ const childKey = field.key.slice(rootKey.length + 1);
1308
+ const elements = Array.isArray(env.row[rootKey])
1309
+ ? env.row[rootKey]
1310
+ : [];
1311
+ const savedFrame = env.frame;
1312
+ const savedStrip = env.strip;
1313
+ const savedNullAsZero = env.nullAsZero;
1314
+ env.strip = `${rootKey}.`;
1315
+ env.ownKey = field.key;
1316
+ env.nullAsZero = true;
1317
+ const values = [];
1318
+ for (const element of elements) {
1319
+ if (!Utils.isObject(element)) {
1320
+ values.push(0);
1321
+ continue;
1322
+ }
1323
+ env.frame = element;
1324
+ const value = await this.evaluateNode(field.ast, env);
1325
+ const written = value === null || value === undefined ? 0 : value;
1326
+ element[childKey] = written;
1327
+ values.push(written);
1328
+ }
1329
+ env.frame = savedFrame;
1330
+ env.strip = savedStrip;
1331
+ env.nullAsZero = savedNullAsZero;
1332
+ out[field.key] = values;
1333
+ continue;
1334
+ }
1209
1335
  env.strip = "";
1210
1336
  env.ownKey = field.key;
1211
1337
  const value = await this.evaluateNode(field.ast, env);
@@ -1223,7 +1349,7 @@ export default class Inibase {
1223
1349
  case "bin": {
1224
1350
  const a = await this.evaluateNode(node.left, env);
1225
1351
  const b = await this.evaluateNode(node.right, env);
1226
- return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect);
1352
+ return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect, env.nullAsZero);
1227
1353
  }
1228
1354
  case "fn": {
1229
1355
  const arrayRef = env.index.get(node.arrayFieldId);
@@ -1283,7 +1409,12 @@ export default class Inibase {
1283
1409
  strip = "";
1284
1410
  }
1285
1411
  else {
1286
- // `value` is the id of the row this hop lives in.
1412
+ // `value` is the id of the row this hop lives in. Rows that
1413
+ // came off disk are eagerly resolved by processSchemaData, so
1414
+ // table children may already be linked objects — unwrap those
1415
+ // back to their id before the hop lookup. Nothing else hops.
1416
+ if (typeof value === "object" && value !== null)
1417
+ value = value.id;
1287
1418
  if (value === undefined ||
1288
1419
  value === null ||
1289
1420
  value === "" ||
@@ -1302,8 +1433,7 @@ export default class Inibase {
1302
1433
  ? (env.links.record(table, ref.key, value), null)
1303
1434
  : await env.links.read(table, ref.key, value)
1304
1435
  : await this.readLinkedRow(table, value, ref.key);
1305
- if (!env.collect &&
1306
- (row === undefined || row === null))
1436
+ if (!env.collect && (row === undefined || row === null))
1307
1437
  throw this.createError("COMPUTED_FIELD_DANGLING_LINK", [
1308
1438
  env.ownKey,
1309
1439
  table,
@@ -1352,18 +1482,28 @@ export default class Inibase {
1352
1482
  ]);
1353
1483
  return num;
1354
1484
  }
1355
- applyBinaryOp(op, a, b, ownKey, collect = false) {
1485
+ applyBinaryOp(op, a, b, ownKey, collect = false, nullAsZero = false) {
1356
1486
  if (a === null || a === undefined || b === null || b === undefined) {
1357
1487
  // Collection passes must tolerate unresolved (null) link hops:
1358
1488
  // the pass only discovers which links are needed, so a neutral
1359
1489
  // result is fine. Real evaluation still throws.
1360
1490
  if (collect)
1361
1491
  return 0;
1492
+ // Element computed evaluation treats a missing operand as the
1493
+ // value 0 (per the element-sum feature), so a single missing
1494
+ // child never aborts the write.
1495
+ if (nullAsZero) {
1496
+ if (a === null || a === undefined)
1497
+ a = 0;
1498
+ if (b === null || b === undefined)
1499
+ b = 0;
1500
+ }
1501
+ }
1502
+ if (a === null || a === undefined || b === null || b === undefined)
1362
1503
  throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
1363
1504
  ownKey,
1364
1505
  "missing or null operand",
1365
1506
  ]);
1366
- }
1367
1507
  const an = this.coerceNumber(a, ownKey);
1368
1508
  const bn = this.coerceNumber(b, ownKey);
1369
1509
  switch (op) {
@@ -1447,9 +1587,11 @@ export default class Inibase {
1447
1587
  if (existing) {
1448
1588
  let matches = true;
1449
1589
  for (const [line, values] of entries) {
1590
+ if (!Object.hasOwn(values, field.key))
1591
+ continue;
1592
+ const lineValue = values[field.key];
1450
1593
  const old = existing[Number(line)]?.[field.key];
1451
- const fresh = File.encode(values[field.key]);
1452
- if (old === undefined || File.encode(old) !== fresh) {
1594
+ if (old === undefined || !this.sameCellValue(lineValue, old)) {
1453
1595
  matches = false;
1454
1596
  break;
1455
1597
  }
@@ -1459,10 +1601,26 @@ export default class Inibase {
1459
1601
  }
1460
1602
  const content = pathesContents[path] ?? {};
1461
1603
  for (const [line, values] of entries)
1462
- content[Number(line)] = File.encode(values[field.key]);
1604
+ if (Object.hasOwn(values, field.key) && values[field.key] !== undefined)
1605
+ content[Number(line)] = File.encode(values[field.key]);
1463
1606
  pathesContents[path] = content;
1464
1607
  }
1465
1608
  }
1609
+ /** Compare a freshly evaluated cell value against a stored (already
1610
+ * decoded) one — both are scalar or index-aligned element arrays. */
1611
+ sameCellValue(fresh, stored) {
1612
+ if (fresh === stored)
1613
+ return true;
1614
+ if (Array.isArray(fresh) &&
1615
+ Array.isArray(stored) &&
1616
+ fresh.length === stored.length) {
1617
+ for (let i = 0; i < fresh.length; i++)
1618
+ if (fresh[i] !== stored[i])
1619
+ return false;
1620
+ return true;
1621
+ }
1622
+ return File.encode(fresh) === File.encode(stored);
1623
+ }
1466
1624
  // Helper function to determine if a field is simple
1467
1625
  isSimpleField(fieldType) {
1468
1626
  const complexTypes = ["array", "object", "table"];
@@ -3214,20 +3372,30 @@ export default class Inibase {
3214
3372
  }
3215
3373
  }
3216
3374
  }
3217
- async sum(tableName, columns, where) {
3375
+ async sum(tableName, columns, where, options) {
3218
3376
  this.validateName(tableName);
3219
3377
  if (!Array.isArray(columns))
3220
3378
  columns = [columns];
3221
- for (const column of columns)
3222
- this.validateName(column);
3379
+ for (const column of columns) {
3380
+ // Nested columns are dotted child paths (validated structurally by
3381
+ // `sumNested`); everything else must be a plain safe name.
3382
+ if (!options?.nested)
3383
+ this.validateName(column);
3384
+ }
3223
3385
  await this.throwErrorIfTableEmpty(tableName);
3224
3386
  const RETURN = {};
3225
3387
  const tablePath = join(this.databasePath, tableName);
3388
+ if (options?.nested) {
3389
+ const nested = await this.sumNested(tableName, columns, where, tablePath);
3390
+ return columns.length > 1 ? nested : Object.values(nested)[0];
3391
+ }
3226
3392
  for await (const column of columns) {
3227
3393
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3228
3394
  if (await File.isExists(columnPath)) {
3229
3395
  if (where) {
3230
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3396
+ // `{ perPage: -1 }` so criteria matching is never capped by
3397
+ // the default page size (15): every matching row counts.
3398
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3231
3399
  RETURN[column] = lineNumbers
3232
3400
  ? await File.sum(columnPath, lineNumbers)
3233
3401
  : 0;
@@ -3238,6 +3406,168 @@ export default class Inibase {
3238
3406
  }
3239
3407
  return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
3240
3408
  }
3409
+ /**
3410
+ * Element-wise aggregation over an array-of-objects column (`nested` mode).
3411
+ *
3412
+ * `where` keys that resolve as children of the array root become per-element
3413
+ * predicates (AND); everything else is a row-level filter. Only elements
3414
+ * passing every element predicate, inside rows passing the row filters, are
3415
+ * accumulated.
3416
+ */
3417
+ async sumNested(tableName, columns, where, tablePath) {
3418
+ const tablePathSafe = tablePath ?? join(this.databasePath, tableName);
3419
+ const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [];
3420
+ const RETURN = {};
3421
+ // Resolve the array-of-objects root from the summed columns (v1: exactly
3422
+ // one nesting level, e.g. "items.quantity" -> root "items").
3423
+ let rootKey = null;
3424
+ let rootField = null;
3425
+ const targetFields = new Map();
3426
+ for (const column of columns) {
3427
+ const dot = column.indexOf(".");
3428
+ if (dot <= 0 || dot === column.length - 1 || column.indexOf(".", dot + 1) !== -1)
3429
+ throw this.createError("INVALID_PARAMETERS", [
3430
+ `sum nested: '${column}' must be a single-level child path of an array-of-objects column`,
3431
+ ]);
3432
+ const rk = column.slice(0, dot);
3433
+ if (rootKey !== null && rk !== rootKey)
3434
+ throw this.createError("INVALID_PARAMETERS", [
3435
+ "sum nested: all summed columns must share the same array root",
3436
+ ]);
3437
+ rootKey = rk;
3438
+ // NB: `getField` on a single segment that resolves to an
3439
+ // array-of-objects field returns its *first child*, so resolve the
3440
+ // root field directly from the top-level schema.
3441
+ rootField = schema.find((f) => f.key === rk) ?? null;
3442
+ const okRoot = rootField &&
3443
+ rootField.type === "array" &&
3444
+ Array.isArray(rootField.children) &&
3445
+ Utils.isArrayOfObjects(rootField.children);
3446
+ if (!okRoot)
3447
+ throw this.createError("INVALID_PARAMETERS", [
3448
+ `sum nested: '${rk}' is not an array-of-objects column`,
3449
+ ]);
3450
+ const child = Utils.getField(column, schema);
3451
+ if (!child || child.type === "array" || child.type === "object")
3452
+ throw this.createError("INVALID_PARAMETERS", [
3453
+ `sum nested: '${column}' does not resolve to a scalar child`,
3454
+ ]);
3455
+ targetFields.set(column, child);
3456
+ }
3457
+ if (!rootKey)
3458
+ return RETURN;
3459
+ // Split `where` into element predicates (children of the root) and a
3460
+ // row-level filter (everything else, incl. `and`/`or` groups).
3461
+ const elementPredicates = [];
3462
+ const rowWhere = {};
3463
+ let idWhere;
3464
+ if (where && Utils.isObject(where)) {
3465
+ for (const [key, value] of Object.entries(where)) {
3466
+ if (key === "and" || key === "or") {
3467
+ rowWhere[key] = value;
3468
+ continue;
3469
+ }
3470
+ const candidate = key.startsWith(`${rootKey}.`)
3471
+ ? key
3472
+ : `${rootKey}.${key}`;
3473
+ const child = Utils.getField(candidate, schema);
3474
+ if (child) {
3475
+ if (Utils.isObject(value))
3476
+ throw this.createError("INVALID_PARAMETERS", [
3477
+ `sum nested: element predicate '${key}' must be a scalar, not an object`,
3478
+ ]);
3479
+ const [operator, comparedValue] = typeof value === "string"
3480
+ ? Utils.FormatObjectCriteriaValue(value)
3481
+ : ["=", value];
3482
+ if (operator === "[]" || operator === "![]")
3483
+ throw this.createError("INVALID_PARAMETERS", [
3484
+ `sum nested: operator '${operator}' is not supported on element predicates`,
3485
+ ]);
3486
+ elementPredicates.push({
3487
+ field: child,
3488
+ operator,
3489
+ comparedValue,
3490
+ });
3491
+ }
3492
+ else {
3493
+ if (!Utils.getField(key, schema))
3494
+ throw this.createError("INVALID_PARAMETERS", [
3495
+ `sum nested: criteria key '${key}' matches neither the array root nor a row column`,
3496
+ ]);
3497
+ rowWhere[key] = value;
3498
+ }
3499
+ }
3500
+ }
3501
+ else if (typeof where === "number" ||
3502
+ typeof where === "string" ||
3503
+ Array.isArray(where)) {
3504
+ idWhere = where;
3505
+ }
3506
+ // Row-level narrowing (ids or criteria). `undefined` lines = whole file.
3507
+ let lines;
3508
+ if (idWhere !== undefined)
3509
+ lines = (await this.get(tableName, idWhere, { perPage: -1 }, undefined, true));
3510
+ else if (Object.keys(rowWhere).length)
3511
+ lines = (await this.get(tableName, rowWhere, { perPage: -1 }, undefined, true));
3512
+ if (lines === null) {
3513
+ for (const column of columns)
3514
+ RETURN[column] = 0;
3515
+ return RETURN;
3516
+ }
3517
+ const fieldOpt = (field) => ({
3518
+ ...field,
3519
+ databasePath: this.databasePath,
3520
+ });
3521
+ const ext = this.getFileExtension(tableName);
3522
+ // Element predicate cells, read once for every summed column.
3523
+ const predCells = new Map();
3524
+ for (const p of elementPredicates) {
3525
+ const path = join(tablePathSafe, `${rootKey}.${p.field.key}${ext}`);
3526
+ if (!(await File.isExists(path)))
3527
+ continue;
3528
+ const cell = (await File.get(path, lines, fieldOpt(p.field)));
3529
+ if (cell)
3530
+ predCells.set(p.field.key, cell);
3531
+ }
3532
+ for (const [column, child] of targetFields) {
3533
+ const path = join(tablePathSafe, `${column}${ext}`);
3534
+ if (!(await File.isExists(path))) {
3535
+ RETURN[column] = 0;
3536
+ continue;
3537
+ }
3538
+ const cell = (await File.get(path, lines, fieldOpt(child)));
3539
+ if (!cell) {
3540
+ RETURN[column] = 0;
3541
+ continue;
3542
+ }
3543
+ let sum = 0;
3544
+ for (const [lineStr, values] of Object.entries(cell)) {
3545
+ if (!Array.isArray(values))
3546
+ continue;
3547
+ for (let i = 0; i < values.length; i++) {
3548
+ const v = values[i];
3549
+ if (v === null || v === undefined)
3550
+ continue;
3551
+ let matches = true;
3552
+ for (const p of elementPredicates) {
3553
+ const pv = predCells.get(p.field.key)?.[Number(lineStr)]?.[i];
3554
+ if (pv === undefined || !UtilsServer.compare(p.operator, pv, p.comparedValue, p.field.type)) {
3555
+ matches = false;
3556
+ break;
3557
+ }
3558
+ }
3559
+ if (!matches)
3560
+ continue;
3561
+ const num = Number(v);
3562
+ if (Number.isNaN(num))
3563
+ continue;
3564
+ sum += num;
3565
+ }
3566
+ }
3567
+ RETURN[column] = sum;
3568
+ }
3569
+ return RETURN;
3570
+ }
3241
3571
  async avg(tableName, columns, where) {
3242
3572
  this.validateName(tableName);
3243
3573
  if (!Array.isArray(columns))
@@ -3251,7 +3581,7 @@ export default class Inibase {
3251
3581
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3252
3582
  if (await File.isExists(columnPath)) {
3253
3583
  if (where) {
3254
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3584
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3255
3585
  RETURN[column] = lineNumbers
3256
3586
  ? await File.avg(columnPath, lineNumbers)
3257
3587
  : 0;
@@ -3275,7 +3605,7 @@ export default class Inibase {
3275
3605
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3276
3606
  if (await File.isExists(columnPath)) {
3277
3607
  if (where) {
3278
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3608
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3279
3609
  RETURN[column] = lineNumbers
3280
3610
  ? await File.max(columnPath, lineNumbers)
3281
3611
  : 0;
@@ -3299,7 +3629,7 @@ export default class Inibase {
3299
3629
  const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
3300
3630
  if (await File.isExists(columnPath)) {
3301
3631
  if (where) {
3302
- const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
3632
+ const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
3303
3633
  RETURN[column] = lineNumbers
3304
3634
  ? await File.min(columnPath, lineNumbers)
3305
3635
  : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",
@@ -88,7 +88,7 @@
88
88
  "benchmark": "./benchmark/run.js",
89
89
  "benchmark:durability": "tsx ./benchmark/durability.ts",
90
90
  "benchmark:computed": "tsx ./benchmark/computed.ts",
91
- "test": "tsx ./tests/inibase.test.ts",
91
+ "test": "tsx ./tests/inibase.test.ts && tsx ./tests/inibase.advanced.test.ts && tsx ./tests/transaction.test.ts && tsx ./tests/durability.test.ts && tsx ./tests/expression.test.ts && tsx ./tests/utils.test.ts && tsx ./tests/sum.nested.test.ts && tsx ./tests/computed.child.test.ts",
92
92
  "test:durability": "tsx ./tests/durability.test.ts",
93
93
  "test:transaction": "tsx ./tests/transaction.test.ts",
94
94
  "test:utils": "tsx ./tests/utils.test.ts",