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);