Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,48 @@ export function hasManyThroughInclusionResolverAcceptance(
]);
});

it('tolerates duplicate and missing source key values when resolving inclusion', async () => {
// Regression test: the hasManyThrough inclusion resolver used to
// pass its raw, unfiltered source-id list by reference straight to
// findByForeignKeys(), which hands that same array on to the
// connector inside an `{inq: [...]}` where clause. A connector/
// query layer that sanitizes an `inq` array in place (stripping
// falsy values before running the query, as the in-memory
// connector does) then mutates that shared array out from under
// the caller, shrinking the very array the resolver still needed -
// unmodified - to zip through-results back onto each original
// entity via flattenTargetsOfOneToManyRelation. That silently
// truncated and misaligned the resolver's return value whenever
// any source entity's key was undefined (e.g. excluded by a fields
// filter) or duplicated another entity's. hasMany/hasOne already
// pass a fresh, deduplicated, filtered array (never the original
// reference) before querying; hasManyThrough now does the same.
const zelda = await customerRepo.create({name: 'Zelda'});
const zeldaCart = await customerRepo
.cartItems(zelda.id)
.create({description: 'crown'});

const resolver = customerRepo.inclusionResolvers.get('cartItems')!;
const result = await resolver(
[
zelda,
zelda, // duplicate id
{name: 'no id'} as unknown as Customer, // id is undefined
],
'cartItems',
);

// The entity with no source key has no related entities; the key
// point is that the result stays length-3 and aligned with the
// input, rather than truncated/misaligned by the in-place mutation
// this regression guards against.
expect(toJSON(result)).to.deepEqual([
[toJSON(zeldaCart)],
[toJSON(zeldaCart)],
null,
]);
});

it('returns multiple model instances including related instances', async () => {
const link = await customerRepo.create({name: 'Link'});
const sword = await customerRepo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {Entity} from '../../model';
import {EntityCrudRepository} from '../../repositories';
import {
StringKeyOf,
deduplicate,
findByForeignKeys,
flattenTargetsOfOneToManyRelation,
} from '../relation.helpers';
Expand Down Expand Up @@ -100,7 +101,24 @@ export function createHasManyThroughInclusionResolver<
const throughFound = await findByForeignKeys(
throughRepo,
throughKeyFrom,
sourceIds,
// Source ids can contain duplicates (e.g. the same source entity
// fetched more than once) and undefined/null values (e.g. when the
// source key field was excluded via a fields filter). Passing the raw
// array straight through is unsafe: `findByForeignKeys` wraps it in an
// `{inq: [...]}` where clause and hands that array to the connector
// by reference, without cloning it first. A connector/query layer
// that sanitizes an `inq` array in place (e.g. stripping falsy
// values before running the query, as the in-memory connector does)
// then mutates *this exact array* out from under us - shrinking the
// very `sourceIds` array still needed below, unmodified, to correctly
// zip through-results back onto each original entity via
// `flattenTargetsOfOneToManyRelation`. That silently misaligns or
// truncates the returned array relative to the input entities.
// Passing a fresh (deduplicated, filtered) array here - never the
// original `sourceIds` reference - avoids that aliasing entirely.
// hasMany/hasOne inclusion resolvers already do this; hasManyThrough
// didn't have any dedup/filter attempt at all.
deduplicate(sourceIds).filter(e => e),
{}, // scope will be applied at the target level
options,
);
Expand Down
Loading