From 8f43d10ed2c102f18013feb28d24cd7f48aaf170 Mon Sep 17 00:00:00 2001 From: Karth Date: Tue, 22 Sep 2026 13:55:42 -0400 Subject: [PATCH 1/2] fix(repository): dedupe/filter source ids before querying in hasManyThrough inclusion resolver hasManyThrough's inclusion resolver passed its raw, unfiltered sourceIds array by reference straight into findByForeignKeys(), which wraps it in an {inq: [...]} where clause and hands that same array to the connector 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 that exact array out from under the caller - shrinking the very sourceIds array the resolver still needs below, unmodified, to correctly zip through-results back onto each original entity via flattenTargetsOfOneToManyRelation(). That silently misaligns or truncates the returned array whenever any source entity's key was undefined (e.g. excluded by a fields filter) or duplicated another entity's. belongsTo and referencesMany inclusion resolvers already pass a fresh, deduplicated, filtered array (never the original reference) before querying; hasManyThrough had no dedup/filter attempt at all. Added a regression test to has-many-through-inclusion-resolver.acceptance.ts: a duplicate source entity plus one with an undefined key, confirming the result stays length-3 and aligned with the input. Verified the test actually catches the bug by temporarily reverting the source fix and re-running - reproduces the exact truncation (length 2 instead of 3) this fix addresses. Co-authored-by: Claude Sonnet 5 Signed-off-by: Karth --- ...y-through-inclusion-resolver.acceptance.ts | 42 +++++++++++++++++++ .../has-many-through.inclusion-resolver.ts | 7 +++- 2 files changed, 48 insertions(+), 1 deletion(-) 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..f5d831305ff3 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,11 @@ export function createHasManyThroughInclusionResolver< const throughFound = await findByForeignKeys( throughRepo, throughKeyFrom, - sourceIds, + // Dedup/filter before querying: findByForeignKeys() passes this array + // by reference into an `inq` filter, and some connectors mutate it in + // place, corrupting the `sourceIds` this resolver still needs below. + // See the PR description for the full failure mode. + deduplicate(sourceIds).filter(e => e), {}, // scope will be applied at the target level options, ); From b579d3010fb06e2252c6d6a29aac94b301f9aff0 Mon Sep 17 00:00:00 2001 From: Karth Date: Tue, 22 Sep 2026 13:56:17 -0400 Subject: [PATCH 2/2] fix(repository): dedupe/filter source ids before querying in hasMany and hasOne inclusion resolvers Both resolvers had the same aliasing bug just fixed in hasManyThrough: they passed their raw, unfiltered source-id array by reference straight to findByForeignKeys(), which wraps it in an {inq: [...]} where clause and hands that same array on to the connector. 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 that shared array out from under the caller - shrinking the very array each resolver still needed, unmodified, to zip results back onto each original entity. 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. belongsTo and referencesMany already pass a fresh, deduplicated, filtered array (never the original reference) before querying; hasMany and hasOne now do the same. Confirmed via a standalone script against the real compiled resolvers and an in-memory DataSource: reverting just this fix reproduces the truncated/misaligned result (length 2 instead of 3 for a 3-entity batch with one undefined key); with the fix, the result is correctly length 3 with undefined in the right position. Includes a test fix for the same regression tests: toJSON() serializes an undefined array element to null (JSON has no undefined), so the memory-connector acceptance run must assert null, not undefined, for that slot. Co-authored-by: Claude Sonnet 5 Signed-off-by: Karth --- ...-inclusion-resolver.relation.acceptance.ts | 41 +++++++++++++++++ .../has-one.inclusion-resolver.acceptance.ts | 44 +++++++++++++++++++ .../has-many/has-many.inclusion-resolver.ts | 7 ++- .../has-one/has-one.inclusion-resolver.ts | 7 ++- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts index a6f3a3157a07..7ccbde417af6 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts @@ -372,6 +372,47 @@ export function hasManyInclusionResolverAcceptance( ); }); + it('tolerates duplicate and missing source key values when resolving inclusion', async () => { + // Regression test: the hasMany 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 + // results back onto each original entity. 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. belongsTo/referencesMany already pass + // a fresh, deduplicated, filtered array (never the original + // reference) before querying; hasMany now does the same. + const thor = await customerRepo.create({name: 'Thor'}); + const thorOrder = await orderRepo.create({ + customerId: thor.id, + description: "Thor's Mjolnir", + }); + + const resolver = customerRepo.inclusionResolvers.get('orders')!; + const result = await resolver( + [ + thor, + thor, // duplicate id + {name: 'no id'} as unknown as Customer, // id is undefined + ], + 'orders', + ); + + // The entity with no source key has no related entities; after JSON + // serialization that slot is `null` (JSON has no `undefined`). The key + // point is that the result stays length-3 and aligned with the input. + expect(toJSON(result)).to.deepEqual([ + [toJSON(thorOrder)], + [toJSON(thorOrder)], + null, + ]); + }); + it('throws error if the target repository does not have the registered resolver', async () => { const customer = await customerRepo.create({name: 'customer'}); await orderRepo.create({ diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts index 9381d7e565f3..a5bc7b08e5ac 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts @@ -160,6 +160,50 @@ export function hasOneInclusionResolverAcceptance( expect(toJSON(result)).to.deepEqual(toJSON(expected)); }); + it('tolerates duplicate and missing source key values when resolving inclusion', async () => { + // Regression test: the hasOne 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 + // results back onto each original entity. 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. belongsTo/referencesMany already pass + // a fresh, deduplicated, filtered array (never the original + // reference) before querying; hasOne now does the same. + const thor = await customerRepo.create({name: 'Thor'}); + const thorAddress = await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: thor.id, + }); + + const resolver = customerRepo.inclusionResolvers.get('address')!; + const result = await resolver( + [ + thor, + thor, // duplicate id + {name: 'no id'} as unknown as Customer, // id is undefined + ], + 'address', + ); + + // The entity with no source key has no related entity; after JSON + // serialization that slot is `null` (JSON has no `undefined`). The key + // point is that the result stays length-3 and aligned with the input. + expect(toJSON(result)).to.deepEqual([ + toJSON(thorAddress), + toJSON(thorAddress), + null, + ]); + }); + it('throws error if the target repository does not have the registered resolver', async () => { const customer = await customerRepo.create({name: 'customer'}); await addressRepo.create({ diff --git a/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts index 5c41091a1243..b0e2331080a9 100644 --- a/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts +++ b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts @@ -9,6 +9,7 @@ import {AnyObject, Options} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories'; import { + deduplicate, findByForeignKeys, flattenTargetsOfOneToManyRelation, StringKeyOf, @@ -69,7 +70,11 @@ export function createHasManyInclusionResolver< const targetsFound = await findByForeignKeys( targetRepo, targetKey, - sourceIds, + // Dedup/filter before querying: findByForeignKeys() passes this array + // by reference into an `inq` filter, and some connectors mutate it in + // place, corrupting the `sourceIds` this resolver still needs below. + // See the PR description for the full failure mode. + deduplicate(sourceIds).filter(e => e), scope, options, ); diff --git a/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts index a74640205fca..d836afeed1a1 100644 --- a/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts +++ b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts @@ -9,6 +9,7 @@ import {AnyObject, Options} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories'; import { + deduplicate, findByForeignKeys, flattenTargetsOfOneToOneRelation, StringKeyOf, @@ -110,7 +111,11 @@ export function createHasOneInclusionResolver< const targetsFound = await findByForeignKeys( targetRepo, targetKey, - sourceIdsCategorized[k], + // Dedup/filter before querying: findByForeignKeys() passes this + // array by reference into an `inq` filter, and some connectors + // mutate it in place, corrupting the array this resolver still + // needs below. See the PR description for the full failure mode. + deduplicate(sourceIdsCategorized[k]).filter(e => e), scope, {...options, polymorphicType: k}, );