spice-js 2.7.18 → 2.7.19
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/build/models/SpiceModel.js +90 -25
- package/package.json +1 -1
- package/src/models/SpiceModel.js +110 -14
- package/tests/models/SpiceModel.test.js +156 -0
- package/src/models/SpiceModel.js.bak +0 -2186
|
@@ -957,36 +957,47 @@ class SpiceModel {
|
|
|
957
957
|
var col = (raw || "").trim();
|
|
958
958
|
if (col === "" || col === "meta().id") return undefined;
|
|
959
959
|
if (/^\s*ARRAY\s+/i.test(col) || /\w+\s*\(/.test(col)) return col;
|
|
960
|
+
|
|
961
|
+
// If the first segment is a mapped model property, always project the
|
|
962
|
+
// root mapped field (supports deep paths like dependents.passports.holder).
|
|
963
|
+
var deepMappedPathMatch = col.match(/^\s*`?(\w+)`?(?:\.`?\w+`?)+(?:\s+AS\s+`?([\w]+)`?)?\s*$/i);
|
|
964
|
+
if (deepMappedPathMatch) {
|
|
965
|
+
var _this$props$alias;
|
|
966
|
+
var alias = deepMappedPathMatch[1];
|
|
967
|
+
if (alias !== this.type && ((_this$props$alias = this.props[alias]) == null ? void 0 : _this$props$alias.map)) {
|
|
968
|
+
return q(this.type) + "." + q(alias);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
960
971
|
var m = col.match(/^\s*`?(\w+)`?\.`?(\w+)`?(?:\s+AS\s+`?([\w]+)`?)?\s*$/i);
|
|
961
972
|
if (m) {
|
|
962
|
-
var
|
|
973
|
+
var _alias = m[1];
|
|
963
974
|
var field = m[2];
|
|
964
975
|
var explicitAs = m[3];
|
|
965
976
|
|
|
966
977
|
// If alias matches this.type, don't prepend again
|
|
967
|
-
if (
|
|
968
|
-
var _qualified = q(
|
|
978
|
+
if (_alias === this.type) {
|
|
979
|
+
var _qualified = q(_alias) + "." + q(field);
|
|
969
980
|
return explicitAs ? _qualified + " AS " + q(explicitAs) : _qualified;
|
|
970
981
|
}
|
|
971
982
|
|
|
972
983
|
// Check if alias has a map - if so, simplify to root column only (raw ID)
|
|
973
|
-
var prop = this.props[
|
|
984
|
+
var prop = this.props[_alias];
|
|
974
985
|
if (prop == null ? void 0 : prop.map) {
|
|
975
986
|
// Return base table qualified root column, ignoring the .field part
|
|
976
|
-
return q(this.type) + "." + q(
|
|
987
|
+
return q(this.type) + "." + q(_alias);
|
|
977
988
|
}
|
|
978
|
-
if (arraySet.has(
|
|
979
|
-
var proj = this.buildArrayProjection(
|
|
980
|
-
if (explicitAs && explicitAs !==
|
|
989
|
+
if (arraySet.has(_alias)) {
|
|
990
|
+
var proj = this.buildArrayProjection(_alias, field);
|
|
991
|
+
if (explicitAs && explicitAs !== _alias + "_" + field) {
|
|
981
992
|
proj = proj.replace(/AS\s+`[^`]+`$/i, "AS " + q(explicitAs));
|
|
982
993
|
}
|
|
983
994
|
return proj;
|
|
984
995
|
}
|
|
985
|
-
if (protectedSet.has(
|
|
986
|
-
var aliased = q(
|
|
996
|
+
if (protectedSet.has(_alias)) {
|
|
997
|
+
var aliased = q(_alias) + "." + (field.startsWith("`") ? field : q(field));
|
|
987
998
|
return explicitAs ? aliased + " AS " + q(explicitAs) : aliased;
|
|
988
999
|
}
|
|
989
|
-
var qualified = q(this.type) + "." + q(
|
|
1000
|
+
var qualified = q(this.type) + "." + q(_alias) + "." + q(field);
|
|
990
1001
|
return explicitAs ? qualified + " AS " + q(explicitAs) : qualified;
|
|
991
1002
|
}
|
|
992
1003
|
var looksProtected = [...protectedSet].some(a => col === a || col === q(a) || col.startsWith(a + ".") || col.startsWith(q(a) + "."));
|
|
@@ -1032,9 +1043,14 @@ class SpiceModel {
|
|
|
1032
1043
|
if (aliasMatch) {
|
|
1033
1044
|
return aliasMatch[1].trim();
|
|
1034
1045
|
}
|
|
1035
|
-
// Otherwise,
|
|
1046
|
+
// Otherwise, for dot-notation, keep the first segment after removing
|
|
1047
|
+
// the base model prefix (e.g. user.group.name -> group).
|
|
1036
1048
|
if (col.includes(".")) {
|
|
1037
|
-
|
|
1049
|
+
var pathParts = col.split(".").map(part => part.trim()).filter(part => part !== "");
|
|
1050
|
+
if (pathParts[0] === this.type && pathParts.length > 1) {
|
|
1051
|
+
pathParts = pathParts.slice(1);
|
|
1052
|
+
}
|
|
1053
|
+
return pathParts[0];
|
|
1038
1054
|
}
|
|
1039
1055
|
return col;
|
|
1040
1056
|
});
|
|
@@ -1262,7 +1278,7 @@ class SpiceModel {
|
|
|
1262
1278
|
|
|
1263
1279
|
// Build JOIN/NEST from the mapped keyspaces
|
|
1264
1280
|
args._join = _this10.createJoinSection(mappedNestings);
|
|
1265
|
-
|
|
1281
|
+
|
|
1266
1282
|
// WHERE
|
|
1267
1283
|
var query = "";
|
|
1268
1284
|
var deletedCondition = "(`" + _this10.type + "`.deleted = false OR `" + _this10.type + "`.deleted IS MISSING)";
|
|
@@ -1292,14 +1308,14 @@ class SpiceModel {
|
|
|
1292
1308
|
var isEmpty = (cached == null ? void 0 : cached.value) === undefined;
|
|
1293
1309
|
var refresh = yield _this10.shouldForceRefresh(cached);
|
|
1294
1310
|
if (isEmpty || refresh) {
|
|
1295
|
-
results = yield _this10.fetchResults(args, query);
|
|
1311
|
+
results = yield _this10.fetchResults(args, query, mappedNestings);
|
|
1296
1312
|
_this10.getCacheProviderObject(_this10.type).set(cacheKey, {
|
|
1297
1313
|
value: results,
|
|
1298
1314
|
time: new Date().getTime()
|
|
1299
1315
|
}, _this10.getCacheConfig(_this10.type));
|
|
1300
1316
|
}
|
|
1301
1317
|
} else {
|
|
1302
|
-
results = yield _this10.fetchResults(args, query);
|
|
1318
|
+
results = yield _this10.fetchResults(args, query, mappedNestings);
|
|
1303
1319
|
}
|
|
1304
1320
|
|
|
1305
1321
|
// Serializer still handles class-based refs and value_field
|
|
@@ -1332,7 +1348,36 @@ class SpiceModel {
|
|
|
1332
1348
|
}
|
|
1333
1349
|
})();
|
|
1334
1350
|
}
|
|
1335
|
-
|
|
1351
|
+
correctColumns(columns, mappedNestings) {
|
|
1352
|
+
// Preserve normal columns and only rewrite mapped alias dot-paths
|
|
1353
|
+
// to the root mapped field on the base resource.
|
|
1354
|
+
if (!columns || typeof columns !== "string") return columns;
|
|
1355
|
+
var mappedAliasSet = new Set((mappedNestings || []).map(m => m.alias));
|
|
1356
|
+
var isMappedAlias = alias => {
|
|
1357
|
+
var _this$props, _this$props$alias2;
|
|
1358
|
+
return !!((_this$props = this.props) == null ? void 0 : (_this$props$alias2 = _this$props[alias]) == null ? void 0 : _this$props$alias2.map);
|
|
1359
|
+
};
|
|
1360
|
+
var normalized = columns.split(",").map(col => col.trim()).filter(col => col !== "").map(col => {
|
|
1361
|
+
var cleanCol = col.replace(/`/g, "").trim();
|
|
1362
|
+
var parts = cleanCol.split(".");
|
|
1363
|
+
var firstSegment = parts[0];
|
|
1364
|
+
var secondSegment = parts[1];
|
|
1365
|
+
var mappedAlias = null;
|
|
1366
|
+
if (isMappedAlias(firstSegment)) {
|
|
1367
|
+
mappedAlias = firstSegment;
|
|
1368
|
+
} else if (firstSegment === this.type && secondSegment && isMappedAlias(secondSegment)) {
|
|
1369
|
+
mappedAlias = secondSegment;
|
|
1370
|
+
} else if (mappedAliasSet.has(firstSegment)) {
|
|
1371
|
+
mappedAlias = firstSegment;
|
|
1372
|
+
}
|
|
1373
|
+
if (mappedAlias) {
|
|
1374
|
+
return "`" + this.type + "`.`" + mappedAlias + "`";
|
|
1375
|
+
}
|
|
1376
|
+
return col;
|
|
1377
|
+
});
|
|
1378
|
+
return _.join(_.uniq(_.compact(normalized)), ",");
|
|
1379
|
+
}
|
|
1380
|
+
fetchResults(args, query, mappedNestings) {
|
|
1336
1381
|
var _this11 = this;
|
|
1337
1382
|
return _asyncToGenerator(function* () {
|
|
1338
1383
|
var _this11$_ctx;
|
|
@@ -1345,7 +1390,8 @@ class SpiceModel {
|
|
|
1345
1390
|
} else if (args.is_full_text === "true") {
|
|
1346
1391
|
return yield _this11.database.full_text_search(_this11.type, query || "", args.limit, args.offset, args._join);
|
|
1347
1392
|
} else {
|
|
1348
|
-
var
|
|
1393
|
+
var correctedColumns = _this11.correctColumns(args.columns, mappedNestings);
|
|
1394
|
+
var result = yield _this11.database.search(_this11.type, correctedColumns || args.columns || "", query || "", args.limit, args.offset, args.sort, args.do_count, args.statement_consistent, args._join);
|
|
1349
1395
|
return result;
|
|
1350
1396
|
}
|
|
1351
1397
|
});
|
|
@@ -1574,12 +1620,16 @@ class SpiceModel {
|
|
|
1574
1620
|
if (isExempt || _this14[_level] + 1 < _this14[_mapping_dept]) {
|
|
1575
1621
|
var ids = [];
|
|
1576
1622
|
_.each(data, result => {
|
|
1577
|
-
var value = [];
|
|
1578
|
-
|
|
1579
|
-
|
|
1623
|
+
var value = result[source_property];
|
|
1624
|
+
|
|
1625
|
+
// Some list/select projections place fields under an empty-string key.
|
|
1626
|
+
if ((value === undefined || _.isObject(value) && _.isEmpty(value)) && result[""] && result[""][source_property] !== undefined) {
|
|
1627
|
+
value = result[""][source_property];
|
|
1580
1628
|
}
|
|
1581
|
-
if (_.isString(
|
|
1582
|
-
value = [
|
|
1629
|
+
if (_.isString(value)) {
|
|
1630
|
+
value = [value];
|
|
1631
|
+
} else if (!_.isArray(value)) {
|
|
1632
|
+
value = [];
|
|
1583
1633
|
}
|
|
1584
1634
|
|
|
1585
1635
|
// Extract IDs - handle both string IDs and objects with id property
|
|
@@ -1632,14 +1682,29 @@ class SpiceModel {
|
|
|
1632
1682
|
if (returned_obj.status == "fulfilled") return returned_obj.value;
|
|
1633
1683
|
})));
|
|
1634
1684
|
_.each(data, result => {
|
|
1685
|
+
var sourceValue = result[source_property];
|
|
1686
|
+
var hasFallbackSource = result[""] && result[""][source_property] !== undefined;
|
|
1635
1687
|
if (_.isString(result[store_property])) {
|
|
1636
1688
|
result[store_property] = [result[store_property]];
|
|
1637
1689
|
}
|
|
1638
|
-
if (!_.has(result, source_property)) {
|
|
1690
|
+
if (!_.has(result, source_property) && !hasFallbackSource) {
|
|
1691
|
+
if (!_.isArray(result[store_property])) {
|
|
1692
|
+
result[store_property] = [];
|
|
1693
|
+
}
|
|
1639
1694
|
result[source_property] = [];
|
|
1640
1695
|
return;
|
|
1641
1696
|
}
|
|
1642
|
-
|
|
1697
|
+
|
|
1698
|
+
// Match mapToObject behavior for projected rows that use result[""].
|
|
1699
|
+
if ((sourceValue === undefined || _.isObject(sourceValue) && _.isEmpty(sourceValue)) && hasFallbackSource) {
|
|
1700
|
+
sourceValue = result[""][source_property];
|
|
1701
|
+
}
|
|
1702
|
+
if (_.isString(sourceValue)) {
|
|
1703
|
+
sourceValue = [sourceValue];
|
|
1704
|
+
} else if (!_.isArray(sourceValue)) {
|
|
1705
|
+
sourceValue = [];
|
|
1706
|
+
}
|
|
1707
|
+
result[store_property] = _.map(sourceValue, item => {
|
|
1643
1708
|
// Get the ID to match - either a string ID or the id property of an object
|
|
1644
1709
|
var itemId = _.isString(item) ? item : _.isObject(item) ? item.id : null;
|
|
1645
1710
|
return _.find(returned_objects, p => p.id === itemId);
|
package/package.json
CHANGED
package/src/models/SpiceModel.js
CHANGED
|
@@ -690,6 +690,7 @@ export default class SpiceModel {
|
|
|
690
690
|
_.remove(args.ids, (o) => o == undefined);
|
|
691
691
|
let key = `multi-get::${this.type}::${_.join(args.ids, "|")}:${args.columns}`;
|
|
692
692
|
let results = [];
|
|
693
|
+
|
|
693
694
|
if (args.ids.length > 0) {
|
|
694
695
|
if (this.shouldUseCache(this.type)) {
|
|
695
696
|
let cached_results = await this.getCacheProviderObject(this.type).get(
|
|
@@ -1031,6 +1032,18 @@ export default class SpiceModel {
|
|
|
1031
1032
|
|
|
1032
1033
|
if (/^\s*ARRAY\s+/i.test(col) || /\w+\s*\(/.test(col)) return col;
|
|
1033
1034
|
|
|
1035
|
+
// If the first segment is a mapped model property, always project the
|
|
1036
|
+
// root mapped field (supports deep paths like dependents.passports.holder).
|
|
1037
|
+
const deepMappedPathMatch = col.match(
|
|
1038
|
+
/^\s*`?(\w+)`?(?:\.`?\w+`?)+(?:\s+AS\s+`?([\w]+)`?)?\s*$/i,
|
|
1039
|
+
);
|
|
1040
|
+
if (deepMappedPathMatch) {
|
|
1041
|
+
const alias = deepMappedPathMatch[1];
|
|
1042
|
+
if (alias !== this.type && this.props[alias]?.map) {
|
|
1043
|
+
return `${q(this.type)}.${q(alias)}`;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1034
1047
|
const m = col.match(
|
|
1035
1048
|
/^\s*`?(\w+)`?\.`?(\w+)`?(?:\s+AS\s+`?([\w]+)`?)?\s*$/i,
|
|
1036
1049
|
);
|
|
@@ -1127,9 +1140,17 @@ export default class SpiceModel {
|
|
|
1127
1140
|
if (aliasMatch) {
|
|
1128
1141
|
return aliasMatch[1].trim();
|
|
1129
1142
|
}
|
|
1130
|
-
// Otherwise,
|
|
1143
|
+
// Otherwise, for dot-notation, keep the first segment after removing
|
|
1144
|
+
// the base model prefix (e.g. user.group.name -> group).
|
|
1131
1145
|
if (col.includes(".")) {
|
|
1132
|
-
|
|
1146
|
+
let pathParts = col
|
|
1147
|
+
.split(".")
|
|
1148
|
+
.map((part) => part.trim())
|
|
1149
|
+
.filter((part) => part !== "");
|
|
1150
|
+
if (pathParts[0] === this.type && pathParts.length > 1) {
|
|
1151
|
+
pathParts = pathParts.slice(1);
|
|
1152
|
+
}
|
|
1153
|
+
return pathParts[0];
|
|
1133
1154
|
}
|
|
1134
1155
|
return col;
|
|
1135
1156
|
});
|
|
@@ -1385,7 +1406,7 @@ export default class SpiceModel {
|
|
|
1385
1406
|
|
|
1386
1407
|
// Build JOIN/NEST from the mapped keyspaces
|
|
1387
1408
|
args._join = this.createJoinSection(mappedNestings);
|
|
1388
|
-
|
|
1409
|
+
|
|
1389
1410
|
// WHERE
|
|
1390
1411
|
let query = "";
|
|
1391
1412
|
const deletedCondition = `(\`${this.type}\`.deleted = false OR \`${this.type}\`.deleted IS MISSING)`;
|
|
@@ -1431,7 +1452,7 @@ export default class SpiceModel {
|
|
|
1431
1452
|
const isEmpty = cached?.value === undefined;
|
|
1432
1453
|
const refresh = await this.shouldForceRefresh(cached);
|
|
1433
1454
|
if (isEmpty || refresh) {
|
|
1434
|
-
results = await this.fetchResults(args, query);
|
|
1455
|
+
results = await this.fetchResults(args, query, mappedNestings);
|
|
1435
1456
|
this.getCacheProviderObject(this.type).set(
|
|
1436
1457
|
cacheKey,
|
|
1437
1458
|
{ value: results, time: new Date().getTime() },
|
|
@@ -1439,7 +1460,7 @@ export default class SpiceModel {
|
|
|
1439
1460
|
);
|
|
1440
1461
|
}
|
|
1441
1462
|
} else {
|
|
1442
|
-
results = await this.fetchResults(args, query);
|
|
1463
|
+
results = await this.fetchResults(args, query, mappedNestings);
|
|
1443
1464
|
}
|
|
1444
1465
|
|
|
1445
1466
|
// Serializer still handles class-based refs and value_field
|
|
@@ -1476,7 +1497,48 @@ export default class SpiceModel {
|
|
|
1476
1497
|
}
|
|
1477
1498
|
}
|
|
1478
1499
|
|
|
1479
|
-
|
|
1500
|
+
correctColumns(columns, mappedNestings) {
|
|
1501
|
+
// Preserve normal columns and only rewrite mapped alias dot-paths
|
|
1502
|
+
// to the root mapped field on the base resource.
|
|
1503
|
+
if (!columns || typeof columns !== "string") return columns;
|
|
1504
|
+
|
|
1505
|
+
const mappedAliasSet = new Set((mappedNestings || []).map((m) => m.alias));
|
|
1506
|
+
const isMappedAlias = (alias) => !!this.props?.[alias]?.map;
|
|
1507
|
+
|
|
1508
|
+
const normalized = columns
|
|
1509
|
+
.split(",")
|
|
1510
|
+
.map((col) => col.trim())
|
|
1511
|
+
.filter((col) => col !== "")
|
|
1512
|
+
.map((col) => {
|
|
1513
|
+
const cleanCol = col.replace(/`/g, "").trim();
|
|
1514
|
+
const parts = cleanCol.split(".");
|
|
1515
|
+
const firstSegment = parts[0];
|
|
1516
|
+
const secondSegment = parts[1];
|
|
1517
|
+
|
|
1518
|
+
let mappedAlias = null;
|
|
1519
|
+
if (isMappedAlias(firstSegment)) {
|
|
1520
|
+
mappedAlias = firstSegment;
|
|
1521
|
+
} else if (
|
|
1522
|
+
firstSegment === this.type &&
|
|
1523
|
+
secondSegment &&
|
|
1524
|
+
isMappedAlias(secondSegment)
|
|
1525
|
+
) {
|
|
1526
|
+
mappedAlias = secondSegment;
|
|
1527
|
+
} else if (mappedAliasSet.has(firstSegment)) {
|
|
1528
|
+
mappedAlias = firstSegment;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (mappedAlias) {
|
|
1532
|
+
return `\`${this.type}\`.\`${mappedAlias}\``;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return col;
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
return _.join(_.uniq(_.compact(normalized)), ",");
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async fetchResults(args, query, mappedNestings) {
|
|
1480
1542
|
// Profiling: use track() for proper async context forking
|
|
1481
1543
|
const p = this[_ctx]?.profiler;
|
|
1482
1544
|
|
|
@@ -1492,9 +1554,13 @@ export default class SpiceModel {
|
|
|
1492
1554
|
args._join,
|
|
1493
1555
|
);
|
|
1494
1556
|
} else {
|
|
1557
|
+
let correctedColumns = this.correctColumns(
|
|
1558
|
+
args.columns,
|
|
1559
|
+
mappedNestings,
|
|
1560
|
+
);
|
|
1495
1561
|
let result = await this.database.search(
|
|
1496
1562
|
this.type,
|
|
1497
|
-
args.columns || "",
|
|
1563
|
+
correctedColumns || args.columns || "",
|
|
1498
1564
|
query || "",
|
|
1499
1565
|
args.limit,
|
|
1500
1566
|
args.offset,
|
|
@@ -1804,14 +1870,21 @@ export default class SpiceModel {
|
|
|
1804
1870
|
if (isExempt || this[_level] + 1 < this[_mapping_dept]) {
|
|
1805
1871
|
let ids = [];
|
|
1806
1872
|
_.each(data, (result) => {
|
|
1807
|
-
let value = [];
|
|
1873
|
+
let value = result[source_property];
|
|
1808
1874
|
|
|
1809
|
-
|
|
1810
|
-
|
|
1875
|
+
// Some list/select projections place fields under an empty-string key.
|
|
1876
|
+
if (
|
|
1877
|
+
(value === undefined || (_.isObject(value) && _.isEmpty(value))) &&
|
|
1878
|
+
result[""] &&
|
|
1879
|
+
result[""][source_property] !== undefined
|
|
1880
|
+
) {
|
|
1881
|
+
value = result[""][source_property];
|
|
1811
1882
|
}
|
|
1812
1883
|
|
|
1813
|
-
if (_.isString(
|
|
1814
|
-
value = [
|
|
1884
|
+
if (_.isString(value)) {
|
|
1885
|
+
value = [value];
|
|
1886
|
+
} else if (!_.isArray(value)) {
|
|
1887
|
+
value = [];
|
|
1815
1888
|
}
|
|
1816
1889
|
|
|
1817
1890
|
// Extract IDs - handle both string IDs and objects with id property
|
|
@@ -1877,16 +1950,38 @@ export default class SpiceModel {
|
|
|
1877
1950
|
);
|
|
1878
1951
|
|
|
1879
1952
|
_.each(data, (result) => {
|
|
1953
|
+
let sourceValue = result[source_property];
|
|
1954
|
+
const hasFallbackSource =
|
|
1955
|
+
result[""] && result[""][source_property] !== undefined;
|
|
1956
|
+
|
|
1880
1957
|
if (_.isString(result[store_property])) {
|
|
1881
1958
|
result[store_property] = [result[store_property]];
|
|
1882
1959
|
}
|
|
1883
1960
|
|
|
1884
|
-
if (!_.has(result, source_property)) {
|
|
1961
|
+
if (!_.has(result, source_property) && !hasFallbackSource) {
|
|
1962
|
+
if (!_.isArray(result[store_property])) {
|
|
1963
|
+
result[store_property] = [];
|
|
1964
|
+
}
|
|
1885
1965
|
result[source_property] = [];
|
|
1886
1966
|
return;
|
|
1887
1967
|
}
|
|
1888
1968
|
|
|
1889
|
-
|
|
1969
|
+
// Match mapToObject behavior for projected rows that use result[""].
|
|
1970
|
+
if (
|
|
1971
|
+
(sourceValue === undefined ||
|
|
1972
|
+
(_.isObject(sourceValue) && _.isEmpty(sourceValue))) &&
|
|
1973
|
+
hasFallbackSource
|
|
1974
|
+
) {
|
|
1975
|
+
sourceValue = result[""][source_property];
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (_.isString(sourceValue)) {
|
|
1979
|
+
sourceValue = [sourceValue];
|
|
1980
|
+
} else if (!_.isArray(sourceValue)) {
|
|
1981
|
+
sourceValue = [];
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
result[store_property] = _.map(sourceValue, (item) => {
|
|
1890
1985
|
// Get the ID to match - either a string ID or the id property of an object
|
|
1891
1986
|
const itemId =
|
|
1892
1987
|
_.isString(item) ? item
|
|
@@ -2028,6 +2123,7 @@ export default class SpiceModel {
|
|
|
2028
2123
|
if (!this.isFieldInColumns(i, columns)) {
|
|
2029
2124
|
return data;
|
|
2030
2125
|
}
|
|
2126
|
+
|
|
2031
2127
|
return await this.mapToObjectArray(
|
|
2032
2128
|
data,
|
|
2033
2129
|
_.isString(properties[i].map.reference) ?
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const { createTestModel, seedDatabase, clearDatabase } = require('../helpers/modelFactory');
|
|
18
18
|
const { expectValidIdsOnly, expectUniqueIds, expectNoEmptyIdCalls } = require('../helpers/assertions');
|
|
19
19
|
const { emptyIdCases, sampleDbResults, relationshipData, duplicateIdData, generateLargeDataset } = require('../fixtures/testData');
|
|
20
|
+
const SpiceModel = require('../../src/models/SpiceModel').default;
|
|
20
21
|
|
|
21
22
|
describe('SpiceModel - Critical Fixes for Empty/Null Values', () => {
|
|
22
23
|
|
|
@@ -167,6 +168,104 @@ describe('SpiceModel - Critical Fixes for Empty/Null Values', () => {
|
|
|
167
168
|
expect(result.name).toBe('Alice');
|
|
168
169
|
expect(result.email).toBe('alice@example.com');
|
|
169
170
|
});
|
|
171
|
+
|
|
172
|
+
test('should keep deep dot-notation under the first nested segment', () => {
|
|
173
|
+
const rows = [{
|
|
174
|
+
id: 'user-1',
|
|
175
|
+
first_name: '',
|
|
176
|
+
committee_votes: [
|
|
177
|
+
{
|
|
178
|
+
member: {
|
|
179
|
+
first_name: 'Ada',
|
|
180
|
+
last_name: 'Lovelace'
|
|
181
|
+
},
|
|
182
|
+
value: 'yes'
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}];
|
|
186
|
+
|
|
187
|
+
const [result] = model.filterResultsByColumns(
|
|
188
|
+
rows,
|
|
189
|
+
'`committee_votes`.`member`.`first_name`'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(result.first_name).toBeUndefined();
|
|
193
|
+
expect(result.committee_votes).toBeDefined();
|
|
194
|
+
expect(result.committee_votes[0].member.first_name).toBe('Ada');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('should remove base model prefix before selecting nested segment', () => {
|
|
198
|
+
const rows = [{
|
|
199
|
+
id: 'user-1',
|
|
200
|
+
group: {
|
|
201
|
+
name: 'Admins'
|
|
202
|
+
}
|
|
203
|
+
}];
|
|
204
|
+
|
|
205
|
+
const [result] = model.filterResultsByColumns(
|
|
206
|
+
rows,
|
|
207
|
+
'`user`.`group`.`name`'
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(result.group).toBeDefined();
|
|
211
|
+
expect(result.group.name).toBe('Admins');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('should keep mixed scalar and mapped columns without empty select slots', () => {
|
|
215
|
+
const applicationModel = createTestModel({ type: 'application' });
|
|
216
|
+
const requestedColumns = 'meta().id,dependents,dd_assessment_notes,ceo_assessment_notes,first_name,title,last_name,project,`committee_votes`.`member`,`committee_votes`.`date_of_vote`,`committee_votes`.`vote`,`committee_votes`.`rejection_reasons`,`committee_votes`.`vote_justification`,`committee_votes`.`comments`,`committee_votes`.`application`,control_number,full_name,application_type,stage,committee_submission_date,votes_casted';
|
|
217
|
+
|
|
218
|
+
const corrected = applicationModel.correctColumns(
|
|
219
|
+
requestedColumns,
|
|
220
|
+
[{ alias: 'committee_votes' }]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const selectedColumns = corrected
|
|
224
|
+
.split(',')
|
|
225
|
+
.map((item) => item.trim());
|
|
226
|
+
|
|
227
|
+
expect(selectedColumns.every((item) => item !== '')).toBe(true);
|
|
228
|
+
expect(selectedColumns).toContain('dependents');
|
|
229
|
+
expect(selectedColumns).toContain('first_name');
|
|
230
|
+
expect(selectedColumns).toContain('`application`.`committee_votes`');
|
|
231
|
+
expect(selectedColumns).toContain('meta().id');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('should collapse deep mapped paths even without mapped nestings', () => {
|
|
235
|
+
expect(
|
|
236
|
+
SpiceModel.prototype.correctColumns.call(
|
|
237
|
+
{
|
|
238
|
+
type: 'application',
|
|
239
|
+
props: {
|
|
240
|
+
dependents: {
|
|
241
|
+
map: {
|
|
242
|
+
reference: 'dependent'
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
'dependents.passports.holder',
|
|
248
|
+
[],
|
|
249
|
+
)
|
|
250
|
+
).toBe('`application`.`dependents`');
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
SpiceModel.prototype.correctColumns.call(
|
|
254
|
+
{
|
|
255
|
+
type: 'application',
|
|
256
|
+
props: {
|
|
257
|
+
dependents: {
|
|
258
|
+
map: {
|
|
259
|
+
reference: 'dependent'
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
'`application`.`dependents`.`passports`.`holder`',
|
|
265
|
+
[],
|
|
266
|
+
)
|
|
267
|
+
).toBe('`application`.`dependents`');
|
|
268
|
+
});
|
|
170
269
|
});
|
|
171
270
|
|
|
172
271
|
describe('Fix #4-6: mapToObject() - Strict Checks and Deduplication', () => {
|
|
@@ -258,6 +357,63 @@ describe('SpiceModel - Critical Fixes for Empty/Null Values', () => {
|
|
|
258
357
|
});
|
|
259
358
|
});
|
|
260
359
|
|
|
360
|
+
describe('Fix #8: mapToObjectArray() - Empty Key Projection Fallback', () => {
|
|
361
|
+
let model;
|
|
362
|
+
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
model = createTestModel({ type: 'user' });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
afterEach(() => {
|
|
368
|
+
clearDatabase(model);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('should map array relationships when source field is under empty-string key', async () => {
|
|
372
|
+
let capturedIds = [];
|
|
373
|
+
let capturedColumns = [];
|
|
374
|
+
|
|
375
|
+
class FakeRelatedModel {
|
|
376
|
+
async getMulti({ ids, columns }) {
|
|
377
|
+
capturedIds = ids;
|
|
378
|
+
capturedColumns = columns;
|
|
379
|
+
return [
|
|
380
|
+
{ id: 'group-1', type: 'group', name: 'Admins' },
|
|
381
|
+
{ id: 'group-2', type: 'group', name: 'Editors' }
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const sourceRows = [
|
|
387
|
+
{
|
|
388
|
+
id: 'user-1',
|
|
389
|
+
type: 'user',
|
|
390
|
+
'': {
|
|
391
|
+
groups: ['group-1', 'group-2']
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
const mapped = await model.mapToObjectArray(
|
|
397
|
+
sourceRows,
|
|
398
|
+
FakeRelatedModel,
|
|
399
|
+
'groups',
|
|
400
|
+
'groups',
|
|
401
|
+
{},
|
|
402
|
+
{},
|
|
403
|
+
'read',
|
|
404
|
+
3,
|
|
405
|
+
0,
|
|
406
|
+
'groups.name'
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
expect(capturedIds).toEqual(['group-1', 'group-2']);
|
|
410
|
+
expect(capturedColumns).toEqual(['name']);
|
|
411
|
+
expect(mapped[0].groups).toHaveLength(2);
|
|
412
|
+
expect(mapped[0].groups[0].name).toBe('Admins');
|
|
413
|
+
expect(mapped[0].groups[1].name).toBe('Editors');
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
261
417
|
describe('Fix #7: Strict Equality in ID Matching', () => {
|
|
262
418
|
let model;
|
|
263
419
|
|