From 4830471c75ed4922aac0fd83e2f7a0a8f543e976 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 17 Jul 2026 18:12:24 +0200 Subject: [PATCH] fix: resolve entity-typed primary keys in id-based where expressions An id-based lookup (findAllById, whereId) on an entity whose primary key is itself an entity resolved the key values by type. When the key entity's table appears more than once in the root's join graph (a junction row whose second foreign key reaches the same table again), the type-based lookup is ambiguous and the query failed with "Cannot uniquely identify object in expression". The write set's AndFetch variants hit this on every fetch-back of such a junction. Object-expression resolution now falls back to the root's primary key path when the value type matches the primary key type, mirroring the existing primary-key fallback for scalar values. --- .../core/template/impl/QueryModelImpl.java | 6 ++++ .../core/EntityRepositoryIntegrationTest.java | 31 +++++++++++++++++++ .../st/orm/core/WriteSetIntegrationTest.java | 14 +++++++++ .../st/orm/core/model/OwnerPrimaryPet.java | 23 ++++++++++++++ storm-core/src/test/resources/data.sql | 4 +++ 5 files changed, 78 insertions(+) create mode 100644 storm-core/src/test/java/st/orm/core/model/OwnerPrimaryPet.java diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java index d1e31885a..ae94b08e7 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java @@ -189,6 +189,12 @@ public List getColumns(@Nonnull Class table, @ return modelBuilder.build(type, true).getPrimaryKeyMetamodel().orElseThrow(); } } + if (type == model.primaryKeyType()) { + // Entity-typed primary key: the object is the root's primary key value (e.g., a junction + // table keyed by an entity whose table also appears elsewhere in the join graph). Mirrors + // the primary-key fallback for scalar values below. + return model.getPrimaryKeyMetamodel().orElseThrow(); + } throw new SqlTemplateException("Cannot uniquely identify object in expression: multiple matches found for type %s. Ensure the expression resolves to a single, unambiguous metamodel path.".formatted(type.getSimpleName())); } if (isPrimaryKeyValue(object, model.primaryKeyType())) { diff --git a/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java b/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java index bbfd17ba2..ae4544bee 100644 --- a/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java @@ -11,6 +11,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -30,8 +31,12 @@ import st.orm.PK; import st.orm.PersistenceException; import st.orm.Ref; +import st.orm.core.model.Address; import st.orm.core.model.City; import st.orm.core.model.Owner; +import st.orm.core.model.OwnerPrimaryPet; +import st.orm.core.model.Pet; +import st.orm.core.model.PetType; import st.orm.core.template.ORMTemplate; /** @@ -980,4 +985,30 @@ public void testUuidScalarResultStream() { assertTrue(ids.contains(SECONDARY_KEY_ID)); } } + + // Entity-typed primary keys. + + @Test + public void testFindAllByIdWithEntityTypedPkWhoseTableIsReachedTwice() { + var orm = ORMTemplate.of(dataSource); + var owner = orm.entity(Owner.class).insertAndFetch(Owner.builder() + .firstName("EntityPk") + .lastName("FindAllById") + .address(Address.builder().address("110 W. Liberty St.").city(City.builder().id(1).name("Sun Paririe").build()).build()) + .telephone("6085551023") + .build()); + var pet = orm.entity(Pet.class).insertAndFetch(Pet.builder() + .name("EntityPkFindPet") + .birthDate(LocalDate.of(2024, 1, 1)) + .type(Ref.of(PetType.class, 1)) + .owner(owner) + .build()); + var repository = orm.entity(OwnerPrimaryPet.class); + repository.insert(new OwnerPrimaryPet(owner, pet)); + // The junction's join graph reaches the owner table twice (owner and pet.owner); the id lookup must + // resolve against the primary-key path rather than by type. + var result = repository.findAllById(List.of(owner)); + assertEquals(1, result.size()); + assertEquals(owner.id(), result.getFirst().owner().id()); + } } diff --git a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java index 0c7988f3e..32867092f 100644 --- a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java @@ -27,6 +27,7 @@ import st.orm.core.model.Address; import st.orm.core.model.City; import st.orm.core.model.Owner; +import st.orm.core.model.OwnerPrimaryPet; import st.orm.core.model.Pet; import st.orm.core.model.PetOwnerRef; import st.orm.core.model.PetType; @@ -287,6 +288,19 @@ public void testInsertAndFetchJunctionReturnsCompleteKey() { assertEquals(3, fetched.id().specialtyId()); } + @Test + public void testInsertAndFetchJunctionWithEntityTypedPrimaryKey() { + var orm = orm(); + var owner = newOwner("EntityPk", "Junction"); + var pet = Pet.builder().name("EntityPkJunctionPet").birthDate(LocalDate.of(2024, 4, 4)).type(dogType()).owner(owner).build(); + // The junction's primary key is the owner entity itself, and its join graph reaches the owner table + // twice (owner and pet.owner); the fetch-back must resolve the propagated ids against the primary-key + // path rather than by type. + var fetched = orm.writeSet().insertAndFetch(new OwnerPrimaryPet(owner, pet)); + assertNotEquals(0, (int) fetched.owner().id()); + assertEquals(fetched.owner().id(), fetched.pet().owner().id()); + } + @DbTable("vet_badge") public record VetBadge( @PK Integer id, diff --git a/storm-core/src/test/java/st/orm/core/model/OwnerPrimaryPet.java b/storm-core/src/test/java/st/orm/core/model/OwnerPrimaryPet.java new file mode 100644 index 000000000..fedfa141a --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/OwnerPrimaryPet.java @@ -0,0 +1,23 @@ +package st.orm.core.model; + +import static st.orm.GenerationStrategy.NONE; + +import jakarta.annotation.Nonnull; +import lombok.Builder; +import st.orm.Entity; +import st.orm.FK; +import st.orm.PK; + +/** + * Junction-style entity whose primary key is an entity-typed foreign key to {@link Owner}, while its second + * foreign key reaches the owner table again through {@code pet.owner}. + * + *

This models the pattern where the root's join graph contains the primary-key entity's table more than once + * (e.g., a user-to-primary-session junction where the session also references the user). Id-based operations on + * this entity must resolve against the primary-key path; type-based resolution is ambiguous.

+ */ +@Builder(toBuilder = true) +public record OwnerPrimaryPet( + @Nonnull @PK(generation = NONE) @FK("owner_id") Owner owner, + @Nonnull @FK("pet_id") Pet pet +) implements Entity {} diff --git a/storm-core/src/test/resources/data.sql b/storm-core/src/test/resources/data.sql index 197c6a07d..ddaf0251c 100644 --- a/storm-core/src/test/resources/data.sql +++ b/storm-core/src/test/resources/data.sql @@ -1,6 +1,7 @@ drop table if exists tenant CASCADE; drop table if exists city CASCADE; drop table if exists owner CASCADE; +drop table if exists owner_primary_pet CASCADE; drop table if exists pet CASCADE; drop table if exists pet_extension CASCADE; drop table if exists pet_type CASCADE; @@ -20,8 +21,11 @@ create table vet_badge (id integer auto_increment, label varchar(255), vet_id in create table vet_specialty (vet_id integer, specialty_id integer not null, primary key (vet_id, specialty_id)); create table visit (id integer auto_increment, visit_date date, description varchar(255), vet_id integer null, specialty_id integer null, pet_id integer not null, "timestamp" timestamp default CURRENT_TIMESTAMP, primary key (id)); create table pet_extension (pet_id integer not null, notes varchar(255), primary key (pet_id)); +create table owner_primary_pet (owner_id integer not null, pet_id integer not null, primary key (owner_id)); alter table owner add constraint owner_city_fk foreign key (city_id) references city (id); alter table pet_extension add constraint pet_extension_pet_fk foreign key (pet_id) references pet (id); +alter table owner_primary_pet add constraint owner_primary_pet_owner_fk foreign key (owner_id) references owner (id); +alter table owner_primary_pet add constraint owner_primary_pet_pet_fk foreign key (pet_id) references pet (id); alter table pet add constraint pet_owner_fk foreign key (owner_id) references owner (id); alter table pet add constraint pet_pet_type_fk foreign key (type_id) references pet_type (id); alter table vet_badge add constraint vet_badge_vet_fk foreign key (vet_id) references vet (id);