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.
@@ -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 alias = m[1];
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 (alias === this.type) {
968
- var _qualified = q(alias) + "." + q(field);
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[alias];
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(alias);
987
+ return q(this.type) + "." + q(_alias);
977
988
  }
978
- if (arraySet.has(alias)) {
979
- var proj = this.buildArrayProjection(alias, field);
980
- if (explicitAs && explicitAs !== alias + "_" + field) {
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(alias)) {
986
- var aliased = q(alias) + "." + (field.startsWith("`") ? field : q(field));
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(alias) + "." + q(field);
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, if a dot is present, take the part after the last dot
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
- return col.split(".").pop().trim();
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
- //console.log("args.columns", args);
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
- fetchResults(args, query) {
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 result = yield _this11.database.search(_this11.type, args.columns || "", query || "", args.limit, args.offset, args.sort, args.do_count, args.statement_consistent, args._join);
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
- if (_.isArray(result[source_property])) {
1579
- value = result[source_property];
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(result[source_property])) {
1582
- value = [result[source_property]];
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
- result[store_property] = _.map(result[source_property], item => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-js",
3
- "version": "2.7.18",
3
+ "version": "2.7.19",
4
4
  "description": "spice",
5
5
  "main": "build/index.js",
6
6
  "repository": {
@@ -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, if a dot is present, take the part after the last dot
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
- return col.split(".").pop().trim();
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
- //console.log("args.columns", args);
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
- async fetchResults(args, query) {
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
- if (_.isArray(result[source_property])) {
1810
- value = result[source_property];
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(result[source_property])) {
1814
- value = [result[source_property]];
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
- result[store_property] = _.map(result[source_property], (item) => {
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