From 463cd85527e4adb805325665bfce1edb5d46dece Mon Sep 17 00:00:00 2001 From: Muhammad Aaqil Date: Sun, 8 Feb 2026 14:40:59 +0500 Subject: [PATCH] feat: support non-primary reference keys of source and target models for hasManyThrough Signed-off-by: Muhammad Aaqil --- .../resolve-has-many-through-metadata.unit.ts | 172 ++++++++++++++++++ .../has-many/has-many-through.helpers.ts | 14 +- .../src/relations/relation.types.ts | 7 + 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-many-through-metadata.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-many-through-metadata.unit.ts index 6984efa83f8c..8d4cc518ec72 100644 --- a/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-many-through-metadata.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-many-through-metadata.unit.ts @@ -148,6 +148,63 @@ describe('HasManyThroughHelpers', () => { }); }); + context('createTargetConstraintFromThroughWithCustomReferenceKeys', () => { + it('creates constraint for searching target models', () => { + const through1 = createFriend({ + id: 1, + userId: 'user@gmail.com', + friendId: 'friendUser@gmail.com', + }); + const through2 = createFriend({ + id: 2, + userId: 'user1@gmail.com', + friendId: 'friendUser1@gmail.com', + }); + + // single through model + let result = createTargetConstraintFromThrough( + relationMetaDataWithCustomReferenceKeys, + [through1], + ); + expect(result).to.containEql({email: 'friendUser@gmail.com'}); + // multiple through models + result = createTargetConstraintFromThrough( + relationMetaDataWithCustomReferenceKeys, + [through1, through2], + ); + expect(result).to.containEql({ + email: {inq: ['friendUser@gmail.com', 'friendUser1@gmail.com']}, + }); + }); + }); + + context('getTargetIdsFromTargetModelsForCustomReferenceKeys', () => { + it('returns an empty array if the given target array is empty', () => { + const result = getTargetIdsFromTargetModels( + relationMetaDataWithCustomReferenceKeys, + [], + ); + expect(result).to.containDeep([]); + }); + it('creates constraint with a given fk', () => { + const result = getTargetIdsFromTargetModels( + relationMetaDataWithCustomReferenceKeys, + [createUser({id: 1, email: 'user@gmail.com'})], + ); + expect(result).to.containDeep(['user@gmail.com']); + }); + it('creates constraint with given fks', () => { + const result = getTargetIdsFromTargetModels( + relationMetaDataWithCustomReferenceKeys, + [ + createUser({id: 1, email: 'user@gmail.com'}), + createUser({id: 2, email: 'user1@gmail.com'}), + ], + ); + expect(result).to.containDeep(['user@gmail.com', 'user1@gmail.com']); + }); + }); + context('getTargetIdsFromTargetModels', () => { it('returns an empty array if the given target array is empty', () => { const result = getTargetIdsFromTargetModels(relationMetaData, []); @@ -195,6 +252,33 @@ describe('HasManyThroughHelpers', () => { }); }); + context('createThroughConstraintFromTargetWithCustomReferenceKeys', () => { + it('creates constraint with a given fk', () => { + const result = createThroughConstraintFromTarget( + relationMetaDataWithCustomReferenceKeys, + ['user@gmail.com'], + ); + expect(result).to.containEql({friendId: 'user@gmail.com'}); + }); + it('creates constraint with given fks', () => { + const result = createThroughConstraintFromTarget( + relationMetaDataWithCustomReferenceKeys, + ['user@gmail.com', 'user1@gmail.com'], + ); + expect(result).to.containEql({ + friendId: {inq: ['user@gmail.com', 'user1@gmail.com']}, + }); + }); + it('throws if fkValue is undefined', () => { + expect(() => + createThroughConstraintFromTarget( + relationMetaDataWithCustomReferenceKeys, + [], + ), + ).to.throw(/"fkValue" must be provided/); + }); + }); + context('createThroughConstraintFromTarget', () => { it('creates constraint with a given fk', () => { const result = createThroughConstraintFromTarget(relationMetaData, [1]); @@ -431,6 +515,41 @@ describe('HasManyThroughHelpers', () => { }); }); }); + + context('resolveHasManyThroughMetadataWithCustomReferenceKeys', () => { + it('throws if the wrong metadata type is used', async () => { + const metadata = { + name: 'friends', + type: 'hasMany', + targetsMany: true, + source: User, + customReferenceKeyFrom: 'email', + customReferenceKeyTo: 'email', + keyFrom: 'id', + target: () => User, + keyTo: 'id', + through: { + model: () => Friend, + keyFrom: 'userId', + keyTo: 'friendId', + polymorphic: false, + }, + }; + + const result = resolveHasManyThroughMetadata( + metadata as HasManyDefinition, + ); + + expect(result).to.containEql({ + name: 'friends', + type: 'hasMany', + customReferenceKeyFrom: 'email', + customReferenceKeyTo: 'email', + }); + + expect(result).containDeep({through: {keyTo: 'friendId'}}); + }); + }); /****** HELPERS *******/ @model() @@ -525,6 +644,35 @@ describe('HasManyThroughHelpers', () => { } } + @model() + class User extends Entity { + @property({id: true}) + id: number; + + @property({}) + email: string; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class Friend extends Entity { + @property({id: true}) + id: number; + + @property({}) + userId: string; + + @property({}) + friendId: string; + + constructor(data: Partial) { + super(data); + } + } + const relationMetaData = { name: 'products', type: 'hasMany', @@ -573,6 +721,24 @@ describe('HasManyThroughHelpers', () => { }, } as HasManyThroughResolvedDefinition; + const relationMetaDataWithCustomReferenceKeys = { + name: 'friends', + type: 'hasMany', + targetsMany: true, + source: User, + customReferenceKeyFrom: 'email', + customReferenceKeyTo: 'email', + keyFrom: 'id', + target: () => User, + keyTo: 'id', + through: { + model: () => Friend, + keyFrom: 'userId', + keyTo: 'friendId', + polymorphic: false, + }, + } as HasManyThroughResolvedDefinition; + class InvalidThrough extends Entity {} InvalidThrough.definition = new ModelDefinition('InvalidThrough') .addProperty('id', { @@ -593,9 +759,15 @@ describe('HasManyThroughHelpers', () => { // lack through.keyTo .addProperty('categoryId', {type: 'number'}); + function createFriend(properties: Partial) { + return new Friend(properties); + } function createCategoryProductLink(properties: Partial) { return new CategoryProductLink(properties); } + function createUser(properties: Partial) { + return new User(properties); + } function createProduct(properties: Partial) { return new Product(properties); } diff --git a/packages/repository/src/relations/has-many/has-many-through.helpers.ts b/packages/repository/src/relations/has-many/has-many-through.helpers.ts index 54fc350678c3..8fbf26c75bdf 100644 --- a/packages/repository/src/relations/has-many/has-many-through.helpers.ts +++ b/packages/repository/src/relations/has-many/has-many-through.helpers.ts @@ -78,7 +78,8 @@ export function createTargetConstraintFromThrough< relationMeta, throughInstances, ); - const targetPrimaryKey = relationMeta.keyTo; + const targetPrimaryKey = + relationMeta.customReferenceKeyTo || relationMeta.keyTo; // eslint-disable-next-line @typescript-eslint/no-explicit-any const constraint: any = { @@ -216,7 +217,7 @@ export function getTargetIdsFromTargetModels( relationMeta: HasManyThroughResolvedDefinition, targetInstances: Target[], ): TargetID[] { - const targetId = relationMeta.keyTo; + const targetId = relationMeta.customReferenceKeyTo || relationMeta.keyTo; // eslint-disable-next-line @typescript-eslint/no-explicit-any let ids = [] as any; ids = targetInstances.map( @@ -308,6 +309,8 @@ export function resolveHasManyThroughMetadata( throughModelProperties[relationMeta.through.keyTo] && relationMeta.through.keyFrom && throughModelProperties[relationMeta.through.keyFrom] && + relationMeta.customReferenceKeyTo && + targetModelProperties[relationMeta.customReferenceKeyTo] && relationMeta.keyTo && targetModelProperties[relationMeta.keyTo] && (relationMeta.through.polymorphic === false || @@ -346,8 +349,11 @@ export function resolveHasManyThroughMetadata( throw new InvalidRelationError(reason, relationMeta); } - const targetPrimaryKey = + let targetPrimaryKey = relationMeta.keyTo ?? targetModel.definition.idProperties()[0]; + if (relationMeta.customReferenceKeyTo) { + targetPrimaryKey = relationMeta.customReferenceKeyTo; + } if (!targetPrimaryKey || !targetModelProperties[targetPrimaryKey]) { const reason = `target model ${targetModel.modelName} does not have any primary key (id property)`; throw new InvalidRelationError(reason, relationMeta); @@ -376,7 +382,7 @@ export function resolveHasManyThroughMetadata( return Object.assign(relationMeta, { keyTo: targetPrimaryKey, - keyFrom: relationMeta.keyFrom!, + keyFrom: relationMeta.customReferenceKeyFrom || relationMeta.keyFrom!, through: { ...relationMeta.through, keyTo: targetFkName, diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index b19f8d64ab0a..3cc135d21f3e 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -74,6 +74,13 @@ export interface HasManyDefinition extends RelationDefinitionBase { keyTo?: string; keyFrom?: string; + /** + * customReferenceKeyTo: The custom reference key of target model + * customReferenceKeyFrom: The custom reference key of source model + */ + customReferenceKeyTo?: string; + customReferenceKeyFrom?: string; + /** * With current architecture design, polymorphic type cannot be supported without through * Consider using Source-hasMany->Through->hasOne->Target(polymorphic) for one-to-many relations