inibase 3.1.1 → 3.3.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 +84 -4
- package/dist/expression.d.ts +9 -0
- package/dist/expression.js +18 -5
- package/dist/file.d.ts +16 -0
- package/dist/file.js +151 -25
- package/dist/index.d.ts +37 -2
- package/dist/index.js +508 -106
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -220,6 +220,9 @@ export default class Inibase {
|
|
|
220
220
|
* @param {TableConfig} [config]
|
|
221
221
|
*/
|
|
222
222
|
async createTable(tableName, schema, config) {
|
|
223
|
+
return File.runWithLockStore(() => this._createTable(tableName, schema, config));
|
|
224
|
+
}
|
|
225
|
+
async _createTable(tableName, schema, config) {
|
|
223
226
|
this.validateName(tableName);
|
|
224
227
|
// DDL does not participate in the write-ahead journal: schema surgery
|
|
225
228
|
// inside a transaction would escape the atomic publish/rollback scope.
|
|
@@ -291,6 +294,9 @@ export default class Inibase {
|
|
|
291
294
|
* @param {(TableConfig&{name?: string})} [config]
|
|
292
295
|
*/
|
|
293
296
|
async updateTable(tableName, schema, config) {
|
|
297
|
+
return File.runWithLockStore(() => this._updateTable(tableName, schema, config));
|
|
298
|
+
}
|
|
299
|
+
async _updateTable(tableName, schema, config) {
|
|
294
300
|
this.validateName(tableName);
|
|
295
301
|
// DDL does not participate in the write-ahead journal: schema surgery
|
|
296
302
|
// inside a transaction would escape the atomic publish/rollback scope.
|
|
@@ -364,14 +370,14 @@ export default class Inibase {
|
|
|
364
370
|
// row. Evaluation happens BEFORE the schema is rewritten, so a
|
|
365
371
|
// failed migration (dangling link, arithmetic error, unknown
|
|
366
372
|
// field, ...) leaves the old schema and column values untouched.
|
|
367
|
-
const oldComputed = new Map((table.schema ?? [])
|
|
368
|
-
|
|
369
|
-
|
|
373
|
+
const oldComputed = new Map(this.computedEntries(table.schema ?? []).map(({ key, expr }) => [
|
|
374
|
+
key,
|
|
375
|
+
expr,
|
|
376
|
+
]));
|
|
370
377
|
const changedComputedKeys = new Set(totalLines > 0
|
|
371
|
-
? schema
|
|
372
|
-
.filter((
|
|
373
|
-
|
|
374
|
-
.map((field) => field.key)
|
|
378
|
+
? this.computedEntries(schema)
|
|
379
|
+
.filter(({ key, expr }) => oldComputed.get(key) !== expr)
|
|
380
|
+
.map(({ key }) => key)
|
|
375
381
|
: []);
|
|
376
382
|
const backfillReplacers = changedComputedKeys.size
|
|
377
383
|
? await (async () => {
|
|
@@ -1026,6 +1032,25 @@ export default class Inibase {
|
|
|
1026
1032
|
const computed = field.computed;
|
|
1027
1033
|
return typeof computed === "string" ? computed : (computed?.expr ?? "");
|
|
1028
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Every computed field of a schema as dotted keys (computed children of an
|
|
1037
|
+
* array-of-objects column use their full path, e.g. `items.lineTotal`),
|
|
1038
|
+
* with its raw expression — used for updateTable change detection.
|
|
1039
|
+
*/
|
|
1040
|
+
computedEntries(schema) {
|
|
1041
|
+
const out = [];
|
|
1042
|
+
const walk = (fields, prefix) => {
|
|
1043
|
+
for (const field of fields) {
|
|
1044
|
+
const key = prefix ? `${prefix}.${field.key}` : field.key;
|
|
1045
|
+
if (typeof field.computed !== "undefined")
|
|
1046
|
+
out.push({ key, expr: this.computedExprOf(field) });
|
|
1047
|
+
if (field.children && Utils.isArrayOfObjects(field.children))
|
|
1048
|
+
walk(field.children, key);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
walk(schema, "");
|
|
1052
|
+
return out;
|
|
1053
|
+
}
|
|
1029
1054
|
/**
|
|
1030
1055
|
* Compile every `computed` expression of a schema (which must already have
|
|
1031
1056
|
* ids assigned) into its persisted `{ expr, ast }` form, in dependency
|
|
@@ -1033,27 +1058,67 @@ export default class Inibase {
|
|
|
1033
1058
|
* on-disk schema always carries compiled ASTs.
|
|
1034
1059
|
*/
|
|
1035
1060
|
async compileComputedFields(schema) {
|
|
1036
|
-
const
|
|
1037
|
-
if (!computedFields.length)
|
|
1038
|
-
return schema;
|
|
1061
|
+
const entries = [];
|
|
1039
1062
|
const index = buildFieldIndex(schema);
|
|
1063
|
+
const walk = (fields, prefix) => {
|
|
1064
|
+
for (const field of fields) {
|
|
1065
|
+
const key = prefix ? `${prefix}.${field.key}` : field.key;
|
|
1066
|
+
if (typeof field.computed !== "undefined") {
|
|
1067
|
+
const ref = index.get(field.id);
|
|
1068
|
+
entries.push({
|
|
1069
|
+
field,
|
|
1070
|
+
key,
|
|
1071
|
+
elementRoot: ref?.arrayAncestor
|
|
1072
|
+
? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
|
|
1073
|
+
: null,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
if (field.children && Utils.isArrayOfObjects(field.children))
|
|
1077
|
+
walk(field.children, key);
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
walk(schema, "");
|
|
1081
|
+
if (!entries.length)
|
|
1082
|
+
return schema;
|
|
1040
1083
|
const ctx = {
|
|
1041
1084
|
language: this.language,
|
|
1042
1085
|
ownKey: "",
|
|
1043
1086
|
index,
|
|
1044
1087
|
getTableIndex: async (target) => this.tableFieldIndex(target),
|
|
1088
|
+
elementContext: null,
|
|
1045
1089
|
};
|
|
1046
1090
|
const resolved = [];
|
|
1047
|
-
for (const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1091
|
+
for (const entry of entries) {
|
|
1092
|
+
const ref = index.get(entry.field.id);
|
|
1093
|
+
if (!ref)
|
|
1094
|
+
throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
|
|
1095
|
+
if (entry.elementRoot) {
|
|
1096
|
+
// Element computed field (a child of an array-of-objects
|
|
1097
|
+
// column): must hold a plain value (never a container), and v1
|
|
1098
|
+
// keeps them numeric so writes are always summable.
|
|
1099
|
+
if ((ref.nestedInArrayOfArrays && ref.arrayAncestor) ||
|
|
1100
|
+
entry.field.type !== "number")
|
|
1101
|
+
throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
|
|
1102
|
+
ctx.elementContext = {
|
|
1103
|
+
arrayRootId: entry.elementRoot.id,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
// Top-level computed field: cannot live inside an array.
|
|
1108
|
+
if (ref.arrayAncestor)
|
|
1109
|
+
throw this.createError("COMPUTED_FIELD_INVALID_TARGET", entry.key);
|
|
1110
|
+
ctx.elementContext = null;
|
|
1111
|
+
}
|
|
1112
|
+
ctx.ownKey = entry.key;
|
|
1113
|
+
const raw = parseExpression(this.computedExprOf(entry.field), this.language, entry.key);
|
|
1055
1114
|
const { ast, deps } = await resolveExpression(raw, ctx);
|
|
1056
|
-
resolved.push({
|
|
1115
|
+
resolved.push({
|
|
1116
|
+
field: entry.field,
|
|
1117
|
+
key: entry.key,
|
|
1118
|
+
elementRoot: entry.elementRoot,
|
|
1119
|
+
ast,
|
|
1120
|
+
deps,
|
|
1121
|
+
});
|
|
1057
1122
|
}
|
|
1058
1123
|
const ordered = topoSortComputedFields(resolved.map(({ field, deps }) => ({
|
|
1059
1124
|
id: field.id,
|
|
@@ -1065,14 +1130,27 @@ export default class Inibase {
|
|
|
1065
1130
|
const entry = resolved.find(({ field }) => field.id === meta.id);
|
|
1066
1131
|
if (!entry)
|
|
1067
1132
|
continue;
|
|
1068
|
-
specByKey.set(
|
|
1133
|
+
specByKey.set(entry.key, {
|
|
1069
1134
|
expr: this.computedExprOf(entry.field),
|
|
1070
1135
|
ast: entry.ast,
|
|
1071
1136
|
});
|
|
1072
1137
|
}
|
|
1073
|
-
|
|
1074
|
-
? {
|
|
1075
|
-
|
|
1138
|
+
const applySpecs = (fields, prefix) => fields.map((field) => {
|
|
1139
|
+
const key = prefix ? `${prefix}.${field.key}` : field.key;
|
|
1140
|
+
let next = field;
|
|
1141
|
+
if (typeof field.computed !== "undefined") {
|
|
1142
|
+
const spec = specByKey.get(key);
|
|
1143
|
+
if (spec)
|
|
1144
|
+
next = { ...field, computed: spec };
|
|
1145
|
+
}
|
|
1146
|
+
if (field.children && Utils.isArrayOfObjects(field.children))
|
|
1147
|
+
next = {
|
|
1148
|
+
...next,
|
|
1149
|
+
children: applySpecs(field.children, key),
|
|
1150
|
+
};
|
|
1151
|
+
return next;
|
|
1152
|
+
});
|
|
1153
|
+
return applySpecs(schema, "");
|
|
1076
1154
|
}
|
|
1077
1155
|
/**
|
|
1078
1156
|
* Build the evaluation plan (topological order + id index) for a table's
|
|
@@ -1098,6 +1176,7 @@ export default class Inibase {
|
|
|
1098
1176
|
language: this.language,
|
|
1099
1177
|
ownKey: "",
|
|
1100
1178
|
index,
|
|
1179
|
+
elementContext: null,
|
|
1101
1180
|
getTableIndex: async (target) => {
|
|
1102
1181
|
let cached = indexCache.get(target);
|
|
1103
1182
|
if (!cached) {
|
|
@@ -1108,29 +1187,46 @@ export default class Inibase {
|
|
|
1108
1187
|
return cached;
|
|
1109
1188
|
},
|
|
1110
1189
|
};
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1190
|
+
// Collect every computed field: top-level ones and, when a schema
|
|
1191
|
+
// carries them, the computed children of array-of-objects columns.
|
|
1192
|
+
const walk = async (fieldsSchema, prefix) => {
|
|
1193
|
+
for (const field of fieldsSchema) {
|
|
1194
|
+
const key = prefix ? `${prefix}.${field.key}` : field.key;
|
|
1195
|
+
if (typeof field.computed !== "undefined") {
|
|
1196
|
+
const ref = index.get(field.id);
|
|
1197
|
+
const elementRoot = ref?.arrayAncestor
|
|
1198
|
+
? { id: ref.arrayAncestor.id, key: ref.arrayAncestor.key }
|
|
1199
|
+
: null;
|
|
1200
|
+
const computed = field.computed;
|
|
1201
|
+
if (typeof computed === "string") {
|
|
1202
|
+
ctx.ownKey = key;
|
|
1203
|
+
ctx.elementContext = elementRoot
|
|
1204
|
+
? { arrayRootId: elementRoot.id }
|
|
1205
|
+
: null;
|
|
1206
|
+
const raw = parseExpression(computed, this.language, key);
|
|
1207
|
+
const { ast, deps } = await resolveExpression(raw, ctx);
|
|
1208
|
+
fields.push({
|
|
1209
|
+
id: field.id,
|
|
1210
|
+
key,
|
|
1211
|
+
ast,
|
|
1212
|
+
deps,
|
|
1213
|
+
elementRoot,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
else
|
|
1217
|
+
fields.push({
|
|
1218
|
+
id: field.id,
|
|
1219
|
+
key,
|
|
1220
|
+
ast: computed.ast,
|
|
1221
|
+
deps: collectFieldDeps(computed.ast),
|
|
1222
|
+
elementRoot,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
if (field.children && Utils.isArrayOfObjects(field.children))
|
|
1226
|
+
await walk(field.children, key);
|
|
1125
1227
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
id: field.id,
|
|
1129
|
-
key: field.key,
|
|
1130
|
-
ast: computed.ast,
|
|
1131
|
-
deps: collectFieldDeps(computed.ast),
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1228
|
+
};
|
|
1229
|
+
await walk(s, "");
|
|
1134
1230
|
if (!fields.length) {
|
|
1135
1231
|
if (schema === undefined)
|
|
1136
1232
|
this.computedPlanCache.set(tableName, null);
|
|
@@ -1206,6 +1302,42 @@ export default class Inibase {
|
|
|
1206
1302
|
collect,
|
|
1207
1303
|
};
|
|
1208
1304
|
for (const field of plan.fields) {
|
|
1305
|
+
if (field.elementRoot) {
|
|
1306
|
+
// Element computed field (child of an array-of-objects column):
|
|
1307
|
+
// evaluate once per element (frame = element, strip =
|
|
1308
|
+
// `${rootKey}.`), write back into the element so other computed
|
|
1309
|
+
// fields and the flatten pass see it, and report the
|
|
1310
|
+
// per-element array under the dotted column key. Missing
|
|
1311
|
+
// operands read as 0 (`env.nullAsZero`), never an error.
|
|
1312
|
+
const rootKey = field.elementRoot.key;
|
|
1313
|
+
const childKey = field.key.slice(rootKey.length + 1);
|
|
1314
|
+
const elements = Array.isArray(env.row[rootKey])
|
|
1315
|
+
? env.row[rootKey]
|
|
1316
|
+
: [];
|
|
1317
|
+
const savedFrame = env.frame;
|
|
1318
|
+
const savedStrip = env.strip;
|
|
1319
|
+
const savedNullAsZero = env.nullAsZero;
|
|
1320
|
+
env.strip = `${rootKey}.`;
|
|
1321
|
+
env.ownKey = field.key;
|
|
1322
|
+
env.nullAsZero = true;
|
|
1323
|
+
const values = [];
|
|
1324
|
+
for (const element of elements) {
|
|
1325
|
+
if (!Utils.isObject(element)) {
|
|
1326
|
+
values.push(0);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
env.frame = element;
|
|
1330
|
+
const value = await this.evaluateNode(field.ast, env);
|
|
1331
|
+
const written = value === null || value === undefined ? 0 : value;
|
|
1332
|
+
element[childKey] = written;
|
|
1333
|
+
values.push(written);
|
|
1334
|
+
}
|
|
1335
|
+
env.frame = savedFrame;
|
|
1336
|
+
env.strip = savedStrip;
|
|
1337
|
+
env.nullAsZero = savedNullAsZero;
|
|
1338
|
+
out[field.key] = values;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1209
1341
|
env.strip = "";
|
|
1210
1342
|
env.ownKey = field.key;
|
|
1211
1343
|
const value = await this.evaluateNode(field.ast, env);
|
|
@@ -1223,7 +1355,7 @@ export default class Inibase {
|
|
|
1223
1355
|
case "bin": {
|
|
1224
1356
|
const a = await this.evaluateNode(node.left, env);
|
|
1225
1357
|
const b = await this.evaluateNode(node.right, env);
|
|
1226
|
-
return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect);
|
|
1358
|
+
return this.applyBinaryOp(node.op, a, b, env.ownKey, env.collect, env.nullAsZero);
|
|
1227
1359
|
}
|
|
1228
1360
|
case "fn": {
|
|
1229
1361
|
const arrayRef = env.index.get(node.arrayFieldId);
|
|
@@ -1283,7 +1415,12 @@ export default class Inibase {
|
|
|
1283
1415
|
strip = "";
|
|
1284
1416
|
}
|
|
1285
1417
|
else {
|
|
1286
|
-
// `value` is the id of the row this hop lives in.
|
|
1418
|
+
// `value` is the id of the row this hop lives in. Rows that
|
|
1419
|
+
// came off disk are eagerly resolved by processSchemaData, so
|
|
1420
|
+
// table children may already be linked objects — unwrap those
|
|
1421
|
+
// back to their id before the hop lookup. Nothing else hops.
|
|
1422
|
+
if (typeof value === "object" && value !== null)
|
|
1423
|
+
value = value.id;
|
|
1287
1424
|
if (value === undefined ||
|
|
1288
1425
|
value === null ||
|
|
1289
1426
|
value === "" ||
|
|
@@ -1302,8 +1439,7 @@ export default class Inibase {
|
|
|
1302
1439
|
? (env.links.record(table, ref.key, value), null)
|
|
1303
1440
|
: await env.links.read(table, ref.key, value)
|
|
1304
1441
|
: await this.readLinkedRow(table, value, ref.key);
|
|
1305
|
-
if (!env.collect &&
|
|
1306
|
-
(row === undefined || row === null))
|
|
1442
|
+
if (!env.collect && (row === undefined || row === null))
|
|
1307
1443
|
throw this.createError("COMPUTED_FIELD_DANGLING_LINK", [
|
|
1308
1444
|
env.ownKey,
|
|
1309
1445
|
table,
|
|
@@ -1352,18 +1488,28 @@ export default class Inibase {
|
|
|
1352
1488
|
]);
|
|
1353
1489
|
return num;
|
|
1354
1490
|
}
|
|
1355
|
-
applyBinaryOp(op, a, b, ownKey, collect = false) {
|
|
1491
|
+
applyBinaryOp(op, a, b, ownKey, collect = false, nullAsZero = false) {
|
|
1356
1492
|
if (a === null || a === undefined || b === null || b === undefined) {
|
|
1357
1493
|
// Collection passes must tolerate unresolved (null) link hops:
|
|
1358
1494
|
// the pass only discovers which links are needed, so a neutral
|
|
1359
1495
|
// result is fine. Real evaluation still throws.
|
|
1360
1496
|
if (collect)
|
|
1361
1497
|
return 0;
|
|
1498
|
+
// Element computed evaluation treats a missing operand as the
|
|
1499
|
+
// value 0 (per the element-sum feature), so a single missing
|
|
1500
|
+
// child never aborts the write.
|
|
1501
|
+
if (nullAsZero) {
|
|
1502
|
+
if (a === null || a === undefined)
|
|
1503
|
+
a = 0;
|
|
1504
|
+
if (b === null || b === undefined)
|
|
1505
|
+
b = 0;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
1362
1509
|
throw this.createError("COMPUTED_FIELD_ARITHMETIC", [
|
|
1363
1510
|
ownKey,
|
|
1364
1511
|
"missing or null operand",
|
|
1365
1512
|
]);
|
|
1366
|
-
}
|
|
1367
1513
|
const an = this.coerceNumber(a, ownKey);
|
|
1368
1514
|
const bn = this.coerceNumber(b, ownKey);
|
|
1369
1515
|
switch (op) {
|
|
@@ -1447,9 +1593,11 @@ export default class Inibase {
|
|
|
1447
1593
|
if (existing) {
|
|
1448
1594
|
let matches = true;
|
|
1449
1595
|
for (const [line, values] of entries) {
|
|
1596
|
+
if (!Object.hasOwn(values, field.key))
|
|
1597
|
+
continue;
|
|
1598
|
+
const lineValue = values[field.key];
|
|
1450
1599
|
const old = existing[Number(line)]?.[field.key];
|
|
1451
|
-
|
|
1452
|
-
if (old === undefined || File.encode(old) !== fresh) {
|
|
1600
|
+
if (old === undefined || !this.sameCellValue(lineValue, old)) {
|
|
1453
1601
|
matches = false;
|
|
1454
1602
|
break;
|
|
1455
1603
|
}
|
|
@@ -1459,10 +1607,26 @@ export default class Inibase {
|
|
|
1459
1607
|
}
|
|
1460
1608
|
const content = pathesContents[path] ?? {};
|
|
1461
1609
|
for (const [line, values] of entries)
|
|
1462
|
-
|
|
1610
|
+
if (Object.hasOwn(values, field.key) && values[field.key] !== undefined)
|
|
1611
|
+
content[Number(line)] = File.encode(values[field.key]);
|
|
1463
1612
|
pathesContents[path] = content;
|
|
1464
1613
|
}
|
|
1465
1614
|
}
|
|
1615
|
+
/** Compare a freshly evaluated cell value against a stored (already
|
|
1616
|
+
* decoded) one — both are scalar or index-aligned element arrays. */
|
|
1617
|
+
sameCellValue(fresh, stored) {
|
|
1618
|
+
if (fresh === stored)
|
|
1619
|
+
return true;
|
|
1620
|
+
if (Array.isArray(fresh) &&
|
|
1621
|
+
Array.isArray(stored) &&
|
|
1622
|
+
fresh.length === stored.length) {
|
|
1623
|
+
for (let i = 0; i < fresh.length; i++)
|
|
1624
|
+
if (fresh[i] !== stored[i])
|
|
1625
|
+
return false;
|
|
1626
|
+
return true;
|
|
1627
|
+
}
|
|
1628
|
+
return File.encode(fresh) === File.encode(stored);
|
|
1629
|
+
}
|
|
1466
1630
|
// Helper function to determine if a field is simple
|
|
1467
1631
|
isSimpleField(fieldType) {
|
|
1468
1632
|
const complexTypes = ["array", "object", "table"];
|
|
@@ -2150,6 +2314,9 @@ export default class Inibase {
|
|
|
2150
2314
|
* locked on first touch, in first-touch order.
|
|
2151
2315
|
*/
|
|
2152
2316
|
async begin(tables = []) {
|
|
2317
|
+
return File.runWithLockStore(() => this._begin(tables));
|
|
2318
|
+
}
|
|
2319
|
+
async _begin(tables = []) {
|
|
2153
2320
|
if (this.transaction)
|
|
2154
2321
|
throw this.createError("INVALID_PARAMETERS");
|
|
2155
2322
|
await this.ensureDatabaseTmpDir();
|
|
@@ -2301,6 +2468,12 @@ export default class Inibase {
|
|
|
2301
2468
|
async get(tableName, where, options = {
|
|
2302
2469
|
page: 1,
|
|
2303
2470
|
perPage: 15,
|
|
2471
|
+
}, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
|
|
2472
|
+
return File.runWithLockStore(() => this._get(tableName, where, options, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers));
|
|
2473
|
+
}
|
|
2474
|
+
async _get(tableName, where, options = {
|
|
2475
|
+
page: 1,
|
|
2476
|
+
perPage: 15,
|
|
2304
2477
|
}, onlyOne, onlyLinesNumbers, _whereIsLinesNumbers) {
|
|
2305
2478
|
this.validateName(tableName);
|
|
2306
2479
|
await this.ensureTableRecovered(tableName);
|
|
@@ -2473,9 +2646,13 @@ export default class Inibase {
|
|
|
2473
2646
|
}
|
|
2474
2647
|
else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
2475
2648
|
Utils.isNumber(where)) &&
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2649
|
+
// Numeric where is a line number only through the internal
|
|
2650
|
+
// recursion (flag set). An external numeric where is an id — even
|
|
2651
|
+
// on non-decodeID tables, where ids are NOT line numbers (prepend
|
|
2652
|
+
// layout / rows deleted); using them as lines edited the wrong row
|
|
2653
|
+
// and padded the column file (Sep-2026 rename bug). Ids are
|
|
2654
|
+
// resolved by the next branch.
|
|
2655
|
+
_whereIsLinesNumbers) {
|
|
2479
2656
|
// "where" in this case, is the line(s) number(s) and not id(s)
|
|
2480
2657
|
let lineNumbers = where;
|
|
2481
2658
|
if (!Array.isArray(lineNumbers))
|
|
@@ -2489,8 +2666,6 @@ export default class Inibase {
|
|
|
2489
2666
|
RETURN = RETURN[0];
|
|
2490
2667
|
}
|
|
2491
2668
|
else if ((!_whereIsLinesNumbers &&
|
|
2492
|
-
globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
2493
|
-
.decodeID &&
|
|
2494
2669
|
((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
2495
2670
|
Utils.isNumber(where))) ||
|
|
2496
2671
|
(Array.isArray(where) && where.every(Utils.isValidID)) ||
|
|
@@ -2616,6 +2791,9 @@ export default class Inibase {
|
|
|
2616
2791
|
return onlyOne && Array.isArray(RETURN) ? RETURN[0] : RETURN;
|
|
2617
2792
|
}
|
|
2618
2793
|
async post(tableName, data, options, returnPostedData) {
|
|
2794
|
+
return File.runWithLockStore(() => this._post(tableName, data, options, returnPostedData));
|
|
2795
|
+
}
|
|
2796
|
+
async _post(tableName, data, options, returnPostedData) {
|
|
2619
2797
|
if (!options)
|
|
2620
2798
|
options = {
|
|
2621
2799
|
page: 1,
|
|
@@ -2713,7 +2891,7 @@ export default class Inibase {
|
|
|
2713
2891
|
// No read-your-writes yet: return the formatted staged rows
|
|
2714
2892
|
// (ids + defaults) instead of a committed-state read.
|
|
2715
2893
|
return (Array.isArray(clonedData) ? clonedData : clonedData);
|
|
2716
|
-
return this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
|
|
2894
|
+
return await this.get(tableName, globalConfig[this.databasePath].tables?.get(tableName)?.config.prepend
|
|
2717
2895
|
? Array.isArray(clonedData)
|
|
2718
2896
|
? clonedData.map((_, index) => index + 1).toReversed()
|
|
2719
2897
|
: 1
|
|
@@ -2738,13 +2916,13 @@ export default class Inibase {
|
|
|
2738
2916
|
// here so nothing leaks.
|
|
2739
2917
|
if (!txnStaged && renameList.length)
|
|
2740
2918
|
await Promise.allSettled(renameList
|
|
2741
|
-
.filter((pair) => Boolean(pair[
|
|
2919
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2742
2920
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2743
2921
|
}
|
|
2744
2922
|
else {
|
|
2745
2923
|
if (renameList.length)
|
|
2746
2924
|
await Promise.allSettled(renameList
|
|
2747
|
-
.filter((pair) => Boolean(pair[
|
|
2925
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2748
2926
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2749
2927
|
await File.unlock(join(tablePath, ".tmp"));
|
|
2750
2928
|
}
|
|
@@ -2753,6 +2931,12 @@ export default class Inibase {
|
|
|
2753
2931
|
async put(tableName, data, where, options = {
|
|
2754
2932
|
page: 1,
|
|
2755
2933
|
perPage: 15,
|
|
2934
|
+
}, returnUpdatedData, _whereIsLinesNumbers) {
|
|
2935
|
+
return File.runWithLockStore(() => this._put(tableName, data, where, options, returnUpdatedData, _whereIsLinesNumbers));
|
|
2936
|
+
}
|
|
2937
|
+
async _put(tableName, data, where, options = {
|
|
2938
|
+
page: 1,
|
|
2939
|
+
perPage: 15,
|
|
2756
2940
|
}, returnUpdatedData, _whereIsLinesNumbers) {
|
|
2757
2941
|
const renameList = [];
|
|
2758
2942
|
let txnStaged = false;
|
|
@@ -2843,13 +3027,13 @@ export default class Inibase {
|
|
|
2843
3027
|
// owns them.
|
|
2844
3028
|
if (!txnStaged && renameList.length)
|
|
2845
3029
|
await Promise.allSettled(renameList
|
|
2846
|
-
.filter((pair) => Boolean(pair[
|
|
3030
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2847
3031
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2848
3032
|
}
|
|
2849
3033
|
else {
|
|
2850
3034
|
if (renameList.length)
|
|
2851
3035
|
await Promise.allSettled(renameList
|
|
2852
|
-
.filter((pair) => Boolean(pair[
|
|
3036
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2853
3037
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2854
3038
|
await File.unlock(join(tablePath, ".tmp"));
|
|
2855
3039
|
}
|
|
@@ -2857,9 +3041,13 @@ export default class Inibase {
|
|
|
2857
3041
|
}
|
|
2858
3042
|
else if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
2859
3043
|
Utils.isNumber(where)) &&
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
3044
|
+
// Numeric where is a line number only through the internal
|
|
3045
|
+
// recursion (flag set). An external numeric where is an id — even
|
|
3046
|
+
// on non-decodeID tables, where ids are NOT line numbers (prepend
|
|
3047
|
+
// layout / rows deleted); using them as lines edited the wrong row
|
|
3048
|
+
// and padded the column file (Sep-2026 rename bug). Ids are
|
|
3049
|
+
// resolved by the next branch.
|
|
3050
|
+
_whereIsLinesNumbers) {
|
|
2863
3051
|
// "where" in this case, is the line(s) number(s) and not id(s)
|
|
2864
3052
|
await this.validateTableData(tableName, clonedData, true);
|
|
2865
3053
|
clonedData = this.formatData(clonedData, globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [], true);
|
|
@@ -2922,42 +3110,63 @@ export default class Inibase {
|
|
|
2922
3110
|
if (returnUpdatedData) {
|
|
2923
3111
|
if (this.transaction)
|
|
2924
3112
|
throw this.createError("INVALID_PARAMETERS");
|
|
2925
|
-
|
|
3113
|
+
// AWAITED readback: must complete inside the writer lock. An
|
|
3114
|
+
// async-tail `return this.get(...)` lets the finally below
|
|
3115
|
+
// unlock before the readback's file reads resume.
|
|
3116
|
+
return await this.get(tableName, where, options, !Array.isArray(where), undefined, true);
|
|
2926
3117
|
}
|
|
2927
3118
|
}
|
|
2928
3119
|
finally {
|
|
2929
3120
|
if (this.transaction) {
|
|
2930
3121
|
if (!txnStaged && renameList.length)
|
|
2931
3122
|
await Promise.allSettled(renameList
|
|
2932
|
-
.filter((pair) => Boolean(pair[
|
|
3123
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2933
3124
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2934
3125
|
}
|
|
2935
3126
|
else {
|
|
2936
3127
|
if (renameList.length)
|
|
2937
3128
|
await Promise.allSettled(renameList
|
|
2938
|
-
.filter((pair) => Boolean(pair[
|
|
3129
|
+
.filter((pair) => Boolean(pair[0]))
|
|
2939
3130
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
2940
3131
|
await File.unlock(join(tablePath, ".tmp"));
|
|
2941
3132
|
}
|
|
2942
3133
|
}
|
|
2943
3134
|
}
|
|
2944
3135
|
else if ((!_whereIsLinesNumbers &&
|
|
2945
|
-
globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
2946
|
-
.decodeID &&
|
|
2947
3136
|
((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
2948
3137
|
Utils.isNumber(where))) ||
|
|
2949
3138
|
(Array.isArray(where) && where.every(Utils.isValidID)) ||
|
|
2950
3139
|
Utils.isValidID(where)) {
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3140
|
+
// Resolve id -> line(s) WHILE holding the writer lock: a post
|
|
3141
|
+
// landing between the resolution and the write would shift every
|
|
3142
|
+
// line of a prepend table and the update would hit the wrong row.
|
|
3143
|
+
// The recursive call re-takes the lock reentrantly (same task), so
|
|
3144
|
+
// the lock is held continuously from resolution to publish.
|
|
3145
|
+
let inlineLock = false;
|
|
3146
|
+
try {
|
|
3147
|
+
if (this.transaction)
|
|
3148
|
+
await this.ensureTxnLock(tableName);
|
|
3149
|
+
else {
|
|
3150
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
3151
|
+
inlineLock = true;
|
|
3152
|
+
}
|
|
3153
|
+
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
3154
|
+
if (lineNumbers)
|
|
3155
|
+
return await this.put(tableName, clonedData,
|
|
3156
|
+
// get() with onlyLinesNumbers always returns an array; a
|
|
3157
|
+
// single-id update must keep the scalar so the recursive
|
|
3158
|
+
// line-numbers branch returns a single row (matching the
|
|
3159
|
+
// shape of get(singleId)) instead of a one-element array.
|
|
3160
|
+
!Array.isArray(where) && Array.isArray(lineNumbers)
|
|
3161
|
+
? lineNumbers[0]
|
|
3162
|
+
: lineNumbers, options, returnUpdatedData, true);
|
|
3163
|
+
}
|
|
3164
|
+
finally {
|
|
3165
|
+
// The recursion released its own (reentrant) acquisition;
|
|
3166
|
+
// release ours too (transactions keep the lock until commit).
|
|
3167
|
+
if (inlineLock)
|
|
3168
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
3169
|
+
}
|
|
2961
3170
|
}
|
|
2962
3171
|
else if (Utils.isObject(where)) {
|
|
2963
3172
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
@@ -2975,6 +3184,9 @@ export default class Inibase {
|
|
|
2975
3184
|
* @return {boolean | null} {(Promise<boolean | null>)}
|
|
2976
3185
|
*/
|
|
2977
3186
|
async delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
|
|
3187
|
+
return File.runWithLockStore(() => this._delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard));
|
|
3188
|
+
}
|
|
3189
|
+
async _delete(tableName, where, _whereIsLinesNumbers, _cascadeGuard) {
|
|
2978
3190
|
this.validateName(tableName);
|
|
2979
3191
|
if (!this.transaction)
|
|
2980
3192
|
await this.ensureDatabaseRecovered();
|
|
@@ -3023,13 +3235,13 @@ export default class Inibase {
|
|
|
3023
3235
|
if (this.transaction) {
|
|
3024
3236
|
if (!txnStaged && renameList.length)
|
|
3025
3237
|
await Promise.allSettled(renameList
|
|
3026
|
-
.filter((pair) => Boolean(pair[
|
|
3238
|
+
.filter((pair) => Boolean(pair[0]))
|
|
3027
3239
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
3028
3240
|
}
|
|
3029
3241
|
else {
|
|
3030
3242
|
if (renameList.length)
|
|
3031
3243
|
await Promise.allSettled(renameList
|
|
3032
|
-
.filter((pair) => Boolean(pair[
|
|
3244
|
+
.filter((pair) => Boolean(pair[0]))
|
|
3033
3245
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
3034
3246
|
await File.unlock(join(tablePath, ".tmp"));
|
|
3035
3247
|
}
|
|
@@ -3037,9 +3249,13 @@ export default class Inibase {
|
|
|
3037
3249
|
}
|
|
3038
3250
|
if (((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
3039
3251
|
Utils.isNumber(where)) &&
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3252
|
+
// Numeric where is a line number only through the internal
|
|
3253
|
+
// recursion (flag set). An external numeric where is an id — even
|
|
3254
|
+
// on non-decodeID tables, where ids are NOT line numbers (prepend
|
|
3255
|
+
// layout / rows deleted); using them as lines edited the wrong row
|
|
3256
|
+
// and padded the column file (Sep-2026 rename bug). Ids are
|
|
3257
|
+
// resolved by the next branch.
|
|
3258
|
+
_whereIsLinesNumbers) {
|
|
3043
3259
|
// "where" in this case, is the line(s) number(s) and not id(s)
|
|
3044
3260
|
const files = (await readdir(tablePath))?.filter((fileName) => fileName.endsWith(this.getFileExtension(tableName)));
|
|
3045
3261
|
if (files.length) {
|
|
@@ -3105,13 +3321,13 @@ export default class Inibase {
|
|
|
3105
3321
|
if (this.transaction) {
|
|
3106
3322
|
if (!txnStaged && renameList.length)
|
|
3107
3323
|
await Promise.allSettled(renameList
|
|
3108
|
-
.filter((pair) => Boolean(pair[
|
|
3324
|
+
.filter((pair) => Boolean(pair[0]))
|
|
3109
3325
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
3110
3326
|
}
|
|
3111
3327
|
else {
|
|
3112
3328
|
if (renameList.length)
|
|
3113
3329
|
await Promise.allSettled(renameList
|
|
3114
|
-
.filter((pair) => Boolean(pair[
|
|
3330
|
+
.filter((pair) => Boolean(pair[0]))
|
|
3115
3331
|
.map(async ([tempPath, _]) => unlink(tempPath)));
|
|
3116
3332
|
await File.unlock(join(tablePath, ".tmp"));
|
|
3117
3333
|
}
|
|
@@ -3119,20 +3335,34 @@ export default class Inibase {
|
|
|
3119
3335
|
}
|
|
3120
3336
|
}
|
|
3121
3337
|
if ((!_whereIsLinesNumbers &&
|
|
3122
|
-
globalConfig[this.databasePath].tables?.get(tableName)?.config
|
|
3123
|
-
.decodeID &&
|
|
3124
3338
|
((Array.isArray(where) && where.every(Utils.isNumber)) ||
|
|
3125
3339
|
Utils.isNumber(where))) ||
|
|
3126
3340
|
(Array.isArray(where) && where.every(Utils.isValidID)) ||
|
|
3127
3341
|
Utils.isValidID(where)) {
|
|
3128
|
-
|
|
3129
|
-
//
|
|
3130
|
-
//
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3342
|
+
// Resolve id -> line(s) WHILE holding the writer lock (see put():
|
|
3343
|
+
// a concurrent post between resolution and delete would shift the
|
|
3344
|
+
// lines of a prepend table and remove the wrong row).
|
|
3345
|
+
let inlineLock = false;
|
|
3346
|
+
try {
|
|
3347
|
+
if (this.transaction)
|
|
3348
|
+
await this.ensureTxnLock(tableName);
|
|
3349
|
+
else {
|
|
3350
|
+
await File.lock(join(tablePath, ".tmp"));
|
|
3351
|
+
inlineLock = true;
|
|
3352
|
+
}
|
|
3353
|
+
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
3354
|
+
// Deleting a non-existent id must not fall through to the
|
|
3355
|
+
// "delete all rows" branch (this.delete(_, null, _) would truncate
|
|
3356
|
+
// the whole table), so resolve the id to line numbers first and
|
|
3357
|
+
// only delegate when something actually matched.
|
|
3358
|
+
if (lineNumbers)
|
|
3359
|
+
return await this.delete(tableName, lineNumbers, true, _cascadeGuard ?? new Set());
|
|
3360
|
+
return false;
|
|
3361
|
+
}
|
|
3362
|
+
finally {
|
|
3363
|
+
if (inlineLock)
|
|
3364
|
+
await File.unlock(join(tablePath, ".tmp"));
|
|
3365
|
+
}
|
|
3136
3366
|
}
|
|
3137
3367
|
if (Utils.isObject(where)) {
|
|
3138
3368
|
const lineNumbers = await this.get(tableName, where, undefined, undefined, true);
|
|
@@ -3214,20 +3444,30 @@ export default class Inibase {
|
|
|
3214
3444
|
}
|
|
3215
3445
|
}
|
|
3216
3446
|
}
|
|
3217
|
-
async sum(tableName, columns, where) {
|
|
3447
|
+
async sum(tableName, columns, where, options) {
|
|
3218
3448
|
this.validateName(tableName);
|
|
3219
3449
|
if (!Array.isArray(columns))
|
|
3220
3450
|
columns = [columns];
|
|
3221
|
-
for (const column of columns)
|
|
3222
|
-
|
|
3451
|
+
for (const column of columns) {
|
|
3452
|
+
// Nested columns are dotted child paths (validated structurally by
|
|
3453
|
+
// `sumNested`); everything else must be a plain safe name.
|
|
3454
|
+
if (!options?.nested)
|
|
3455
|
+
this.validateName(column);
|
|
3456
|
+
}
|
|
3223
3457
|
await this.throwErrorIfTableEmpty(tableName);
|
|
3224
3458
|
const RETURN = {};
|
|
3225
3459
|
const tablePath = join(this.databasePath, tableName);
|
|
3460
|
+
if (options?.nested) {
|
|
3461
|
+
const nested = await this.sumNested(tableName, columns, where, tablePath);
|
|
3462
|
+
return columns.length > 1 ? nested : Object.values(nested)[0];
|
|
3463
|
+
}
|
|
3226
3464
|
for await (const column of columns) {
|
|
3227
3465
|
const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
|
|
3228
3466
|
if (await File.isExists(columnPath)) {
|
|
3229
3467
|
if (where) {
|
|
3230
|
-
|
|
3468
|
+
// `{ perPage: -1 }` so criteria matching is never capped by
|
|
3469
|
+
// the default page size (15): every matching row counts.
|
|
3470
|
+
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3231
3471
|
RETURN[column] = lineNumbers
|
|
3232
3472
|
? await File.sum(columnPath, lineNumbers)
|
|
3233
3473
|
: 0;
|
|
@@ -3238,6 +3478,168 @@ export default class Inibase {
|
|
|
3238
3478
|
}
|
|
3239
3479
|
return columns.length > 1 ? RETURN : Object.values(RETURN)[0];
|
|
3240
3480
|
}
|
|
3481
|
+
/**
|
|
3482
|
+
* Element-wise aggregation over an array-of-objects column (`nested` mode).
|
|
3483
|
+
*
|
|
3484
|
+
* `where` keys that resolve as children of the array root become per-element
|
|
3485
|
+
* predicates (AND); everything else is a row-level filter. Only elements
|
|
3486
|
+
* passing every element predicate, inside rows passing the row filters, are
|
|
3487
|
+
* accumulated.
|
|
3488
|
+
*/
|
|
3489
|
+
async sumNested(tableName, columns, where, tablePath) {
|
|
3490
|
+
const tablePathSafe = tablePath ?? join(this.databasePath, tableName);
|
|
3491
|
+
const schema = globalConfig[this.databasePath].tables?.get(tableName)?.schema ?? [];
|
|
3492
|
+
const RETURN = {};
|
|
3493
|
+
// Resolve the array-of-objects root from the summed columns (v1: exactly
|
|
3494
|
+
// one nesting level, e.g. "items.quantity" -> root "items").
|
|
3495
|
+
let rootKey = null;
|
|
3496
|
+
let rootField = null;
|
|
3497
|
+
const targetFields = new Map();
|
|
3498
|
+
for (const column of columns) {
|
|
3499
|
+
const dot = column.indexOf(".");
|
|
3500
|
+
if (dot <= 0 || dot === column.length - 1 || column.indexOf(".", dot + 1) !== -1)
|
|
3501
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3502
|
+
`sum nested: '${column}' must be a single-level child path of an array-of-objects column`,
|
|
3503
|
+
]);
|
|
3504
|
+
const rk = column.slice(0, dot);
|
|
3505
|
+
if (rootKey !== null && rk !== rootKey)
|
|
3506
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3507
|
+
"sum nested: all summed columns must share the same array root",
|
|
3508
|
+
]);
|
|
3509
|
+
rootKey = rk;
|
|
3510
|
+
// NB: `getField` on a single segment that resolves to an
|
|
3511
|
+
// array-of-objects field returns its *first child*, so resolve the
|
|
3512
|
+
// root field directly from the top-level schema.
|
|
3513
|
+
rootField = schema.find((f) => f.key === rk) ?? null;
|
|
3514
|
+
const okRoot = rootField &&
|
|
3515
|
+
rootField.type === "array" &&
|
|
3516
|
+
Array.isArray(rootField.children) &&
|
|
3517
|
+
Utils.isArrayOfObjects(rootField.children);
|
|
3518
|
+
if (!okRoot)
|
|
3519
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3520
|
+
`sum nested: '${rk}' is not an array-of-objects column`,
|
|
3521
|
+
]);
|
|
3522
|
+
const child = Utils.getField(column, schema);
|
|
3523
|
+
if (!child || child.type === "array" || child.type === "object")
|
|
3524
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3525
|
+
`sum nested: '${column}' does not resolve to a scalar child`,
|
|
3526
|
+
]);
|
|
3527
|
+
targetFields.set(column, child);
|
|
3528
|
+
}
|
|
3529
|
+
if (!rootKey)
|
|
3530
|
+
return RETURN;
|
|
3531
|
+
// Split `where` into element predicates (children of the root) and a
|
|
3532
|
+
// row-level filter (everything else, incl. `and`/`or` groups).
|
|
3533
|
+
const elementPredicates = [];
|
|
3534
|
+
const rowWhere = {};
|
|
3535
|
+
let idWhere;
|
|
3536
|
+
if (where && Utils.isObject(where)) {
|
|
3537
|
+
for (const [key, value] of Object.entries(where)) {
|
|
3538
|
+
if (key === "and" || key === "or") {
|
|
3539
|
+
rowWhere[key] = value;
|
|
3540
|
+
continue;
|
|
3541
|
+
}
|
|
3542
|
+
const candidate = key.startsWith(`${rootKey}.`)
|
|
3543
|
+
? key
|
|
3544
|
+
: `${rootKey}.${key}`;
|
|
3545
|
+
const child = Utils.getField(candidate, schema);
|
|
3546
|
+
if (child) {
|
|
3547
|
+
if (Utils.isObject(value))
|
|
3548
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3549
|
+
`sum nested: element predicate '${key}' must be a scalar, not an object`,
|
|
3550
|
+
]);
|
|
3551
|
+
const [operator, comparedValue] = typeof value === "string"
|
|
3552
|
+
? Utils.FormatObjectCriteriaValue(value)
|
|
3553
|
+
: ["=", value];
|
|
3554
|
+
if (operator === "[]" || operator === "![]")
|
|
3555
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3556
|
+
`sum nested: operator '${operator}' is not supported on element predicates`,
|
|
3557
|
+
]);
|
|
3558
|
+
elementPredicates.push({
|
|
3559
|
+
field: child,
|
|
3560
|
+
operator,
|
|
3561
|
+
comparedValue,
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
else {
|
|
3565
|
+
if (!Utils.getField(key, schema))
|
|
3566
|
+
throw this.createError("INVALID_PARAMETERS", [
|
|
3567
|
+
`sum nested: criteria key '${key}' matches neither the array root nor a row column`,
|
|
3568
|
+
]);
|
|
3569
|
+
rowWhere[key] = value;
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
else if (typeof where === "number" ||
|
|
3574
|
+
typeof where === "string" ||
|
|
3575
|
+
Array.isArray(where)) {
|
|
3576
|
+
idWhere = where;
|
|
3577
|
+
}
|
|
3578
|
+
// Row-level narrowing (ids or criteria). `undefined` lines = whole file.
|
|
3579
|
+
let lines;
|
|
3580
|
+
if (idWhere !== undefined)
|
|
3581
|
+
lines = (await this.get(tableName, idWhere, { perPage: -1 }, undefined, true));
|
|
3582
|
+
else if (Object.keys(rowWhere).length)
|
|
3583
|
+
lines = (await this.get(tableName, rowWhere, { perPage: -1 }, undefined, true));
|
|
3584
|
+
if (lines === null) {
|
|
3585
|
+
for (const column of columns)
|
|
3586
|
+
RETURN[column] = 0;
|
|
3587
|
+
return RETURN;
|
|
3588
|
+
}
|
|
3589
|
+
const fieldOpt = (field) => ({
|
|
3590
|
+
...field,
|
|
3591
|
+
databasePath: this.databasePath,
|
|
3592
|
+
});
|
|
3593
|
+
const ext = this.getFileExtension(tableName);
|
|
3594
|
+
// Element predicate cells, read once for every summed column.
|
|
3595
|
+
const predCells = new Map();
|
|
3596
|
+
for (const p of elementPredicates) {
|
|
3597
|
+
const path = join(tablePathSafe, `${rootKey}.${p.field.key}${ext}`);
|
|
3598
|
+
if (!(await File.isExists(path)))
|
|
3599
|
+
continue;
|
|
3600
|
+
const cell = (await File.get(path, lines, fieldOpt(p.field)));
|
|
3601
|
+
if (cell)
|
|
3602
|
+
predCells.set(p.field.key, cell);
|
|
3603
|
+
}
|
|
3604
|
+
for (const [column, child] of targetFields) {
|
|
3605
|
+
const path = join(tablePathSafe, `${column}${ext}`);
|
|
3606
|
+
if (!(await File.isExists(path))) {
|
|
3607
|
+
RETURN[column] = 0;
|
|
3608
|
+
continue;
|
|
3609
|
+
}
|
|
3610
|
+
const cell = (await File.get(path, lines, fieldOpt(child)));
|
|
3611
|
+
if (!cell) {
|
|
3612
|
+
RETURN[column] = 0;
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
let sum = 0;
|
|
3616
|
+
for (const [lineStr, values] of Object.entries(cell)) {
|
|
3617
|
+
if (!Array.isArray(values))
|
|
3618
|
+
continue;
|
|
3619
|
+
for (let i = 0; i < values.length; i++) {
|
|
3620
|
+
const v = values[i];
|
|
3621
|
+
if (v === null || v === undefined)
|
|
3622
|
+
continue;
|
|
3623
|
+
let matches = true;
|
|
3624
|
+
for (const p of elementPredicates) {
|
|
3625
|
+
const pv = predCells.get(p.field.key)?.[Number(lineStr)]?.[i];
|
|
3626
|
+
if (pv === undefined || !UtilsServer.compare(p.operator, pv, p.comparedValue, p.field.type)) {
|
|
3627
|
+
matches = false;
|
|
3628
|
+
break;
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
if (!matches)
|
|
3632
|
+
continue;
|
|
3633
|
+
const num = Number(v);
|
|
3634
|
+
if (Number.isNaN(num))
|
|
3635
|
+
continue;
|
|
3636
|
+
sum += num;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
RETURN[column] = sum;
|
|
3640
|
+
}
|
|
3641
|
+
return RETURN;
|
|
3642
|
+
}
|
|
3241
3643
|
async avg(tableName, columns, where) {
|
|
3242
3644
|
this.validateName(tableName);
|
|
3243
3645
|
if (!Array.isArray(columns))
|
|
@@ -3251,7 +3653,7 @@ export default class Inibase {
|
|
|
3251
3653
|
const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
|
|
3252
3654
|
if (await File.isExists(columnPath)) {
|
|
3253
3655
|
if (where) {
|
|
3254
|
-
const lineNumbers = await this.get(tableName, where,
|
|
3656
|
+
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3255
3657
|
RETURN[column] = lineNumbers
|
|
3256
3658
|
? await File.avg(columnPath, lineNumbers)
|
|
3257
3659
|
: 0;
|
|
@@ -3275,7 +3677,7 @@ export default class Inibase {
|
|
|
3275
3677
|
const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
|
|
3276
3678
|
if (await File.isExists(columnPath)) {
|
|
3277
3679
|
if (where) {
|
|
3278
|
-
const lineNumbers = await this.get(tableName, where,
|
|
3680
|
+
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3279
3681
|
RETURN[column] = lineNumbers
|
|
3280
3682
|
? await File.max(columnPath, lineNumbers)
|
|
3281
3683
|
: 0;
|
|
@@ -3299,7 +3701,7 @@ export default class Inibase {
|
|
|
3299
3701
|
const columnPath = join(tablePath, `${column}${this.getFileExtension(tableName)}`);
|
|
3300
3702
|
if (await File.isExists(columnPath)) {
|
|
3301
3703
|
if (where) {
|
|
3302
|
-
const lineNumbers = await this.get(tableName, where,
|
|
3704
|
+
const lineNumbers = await this.get(tableName, where, { perPage: -1 }, undefined, true);
|
|
3303
3705
|
RETURN[column] = lineNumbers
|
|
3304
3706
|
? await File.min(columnPath, lineNumbers)
|
|
3305
3707
|
: 0;
|