Skip to content

Commit 059b0e3

Browse files
committed
fix: Scalar reversion
1 parent 5a63f74 commit 059b0e3

3 files changed

Lines changed: 97 additions & 7 deletions

File tree

docs/rest/api/Scalar.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,13 @@ method only supplies the `entityPk` piece.
183183

184184
By default `entityPk()`:
185185

186-
- returns the surrounding map `key` when present (authoritative for
187-
`Values(Scalar)`, where the cell may not carry the pk fields), then
186+
- returns the surrounding map `key` when it authoritatively addresses the
187+
cell — i.e. `parent[key] === input`, as in `Values(Scalar)` where the map
188+
key is the entity pk and the cell may not carry the pk fields — then
188189
- delegates to the bound `Entity.pk(input, parent, key, args)` static so
189-
`[Scalar]` and `Collection([Scalar])` array responses — including custom or
190-
composite Entity pks — work out of the box.
190+
`[Scalar]` and `Collection([Scalar])` array responses — including arrays
191+
nested under a parent object schema like `{ stock: [Scalar] }`, and
192+
custom or composite Entity pks — work out of the box.
191193

192194
Override `entityPk()` in a subclass only when the response uses an id field the
193195
`Entity.pk()` does not read:

packages/endpoint/src/schemas/Scalar.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ export default class Scalar implements Mergeable {
6868
/**
6969
* The bound Entity's pk for a standalone scalar cell.
7070
*
71-
* Prefers the surrounding map key (authoritative for `Values(Scalar)`),
72-
* then falls back to the bound `Entity.pk(...)`.
71+
* Prefers the surrounding map key (authoritative for `Values(Scalar)`,
72+
* where `parent[key] === input`), then falls back to the bound
73+
* `Entity.pk(...)`. Other shapes — `[Scalar]` top-level (where `key` is
74+
* `undefined`) or nested under a plain object schema like
75+
* `{ stock: [Scalar] }` (where `Array.normalize` forwards the parent
76+
* object's field name as `key`, but `parent[key]` is the enclosing array,
77+
* not the item) — must derive pk from the item itself.
7378
*
7479
* @see https://dataclient.io/rest/api/Scalar#entityPk
7580
* @param [input] the scalar cell input
@@ -83,7 +88,14 @@ export default class Scalar implements Mergeable {
8388
key: string | undefined,
8489
args: readonly any[],
8590
): string | number | undefined {
86-
if (key !== undefined) return key;
91+
// Only trust `key` when the enclosing container literally maps it to this
92+
// cell — i.e. `Values(Scalar)`, where the map key is the entity pk by
93+
// construction. `Array.normalize` forwards the *outer* object's field name
94+
// as `key` for every element (see Array.ts), so `key !== undefined` alone
95+
// would collapse every item onto the same compound pk.
96+
if (key !== undefined && parent != null && parent[key] === input) {
97+
return key;
98+
}
8799
return this.entity?.pk?.(input, parent, key, args);
88100
}
89101

packages/endpoint/src/schemas/__tests__/Scalar.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,82 @@ describe('Scalar', () => {
437437
});
438438
});
439439

440+
describe('nested array-of-records under a plain object schema (regression)', () => {
441+
// `[Scalar]` (or `Collection([Scalar])`) nested inside a plain object schema
442+
// — `{ stock: [Scalar] }` — must derive each item's entity pk from the item
443+
// itself, not from the parent's field name. Previously `Array.normalize`
444+
// forwarded its enclosing field name (e.g. `'stock'`) as `key` to each
445+
// child; `Scalar.entityPk` returned that key (since it was non-undefined),
446+
// collapsing every cell onto compound pk `Company|stock|<lens>` and
447+
// silently corrupting the data.
448+
it('keys cells by item id, not by parent field name', () => {
449+
const objSchema = { stock: [PortfolioScalar] };
450+
const state = normalize(
451+
objSchema,
452+
{
453+
stock: [
454+
{ id: '1', pct_equity: 0.7, shares: 555 },
455+
{ id: '2', pct_equity: 0.1, shares: 999 },
456+
],
457+
},
458+
[{ portfolio: 'portfolioB' }],
459+
);
460+
461+
expect(state.result).toEqual({
462+
stock: ['Company|1|portfolioB', 'Company|2|portfolioB'],
463+
});
464+
expect(state.entities['Scalar(portfolio)']).toMatchObject({
465+
'Company|1|portfolioB': {
466+
id: '1',
467+
pct_equity: 0.7,
468+
shares: 555,
469+
},
470+
'Company|2|portfolioB': {
471+
id: '2',
472+
pct_equity: 0.1,
473+
shares: 999,
474+
},
475+
});
476+
// Must not key any cell by the parent field name.
477+
expect(
478+
Object.keys(state.entities['Scalar(portfolio)'] ?? {}).some(k =>
479+
k.includes('|stock|'),
480+
),
481+
).toBe(false);
482+
});
483+
484+
it('keys cells by item id when nested as Collection([Scalar])', () => {
485+
const columns = new Collection([PortfolioScalar], {
486+
nestKey: (parent: any, key: string) => ({ portfolio: key }),
487+
});
488+
const objSchema = { stock: columns };
489+
const state = normalize(
490+
objSchema,
491+
{
492+
stock: [
493+
{ id: '1', pct_equity: 0.7, shares: 555 },
494+
{ id: '2', pct_equity: 0.1, shares: 999 },
495+
],
496+
},
497+
[{ portfolio: 'portfolioB' }],
498+
);
499+
500+
// Cells are keyed by item id, not by the parent field name.
501+
expect(state.entities['Scalar(portfolio)']).toMatchObject({
502+
'Company|1|portfolioB': {
503+
id: '1',
504+
pct_equity: 0.7,
505+
shares: 555,
506+
},
507+
'Company|2|portfolioB': {
508+
id: '2',
509+
pct_equity: 0.1,
510+
shares: 999,
511+
},
512+
});
513+
});
514+
});
515+
440516
describe('composite primary keys containing "|"', () => {
441517
class CompositeCompany extends IDEntity {
442518
type = '';

0 commit comments

Comments
 (0)