diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts index 7227b97e87a6..57afa5d83d0f 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts @@ -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 diff --git a/packages/repository/src/relations/has-many/has-many-through.inclusion-resolver.ts b/packages/repository/src/relations/has-many/has-many-through.inclusion-resolver.ts index b89c1f2dd4a8..2b5d5800bdb3 100644 --- a/packages/repository/src/relations/has-many/has-many-through.inclusion-resolver.ts +++ b/packages/repository/src/relations/has-many/has-many-through.inclusion-resolver.ts @@ -12,6 +12,7 @@ import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories'; import { StringKeyOf, + deduplicate, findByForeignKeys, flattenTargetsOfOneToManyRelation, } from '../relation.helpers'; @@ -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, );