diff --git a/storm-core/src/main/java/st/orm/core/template/impl/AliasMapper.java b/storm-core/src/main/java/st/orm/core/template/impl/AliasMapper.java index 97adb56c4..d6de89814 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/AliasMapper.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/AliasMapper.java @@ -225,6 +225,41 @@ public String getAlias(@Nonnull Class table, throw exceptionSupplier.get(); } + /** + * Returns the registered path for the specified table at the current nesting level, but only when the table is + * registered exactly once (after de-duplication on path). + * + *

This is used when columns originate from a sub-tree rooted at a different table than the query model root + * (for example, when {@code SELECT}ing a joined entity type from a repository whose root entity differs). In that + * case the column metamodel paths are relative to the column root, while the alias mapper has registered the + * paths relative to the query model root. The path returned here can be prepended to the column path to bridge + * the two coordinate systems.

+ * + * @param table the table to look up. + * @return the single registered path for the table, or empty if the table is missing, ambiguous, or registered + * without a path. + */ + public Optional findRegisteredPath(@Nonnull Class table) { + var entries = aliasMap.get(table); + if (entries == null || entries.isEmpty()) { + return empty(); + } + String selected = null; + for (var entry : entries) { + var entryPath = entry.path(); + if (entryPath == null) { + continue; + } + if (selected == null) { + selected = entryPath; + } else if (!selected.equals(entryPath)) { + // Multiple distinct paths — caller cannot disambiguate. + return empty(); + } + } + return Optional.ofNullable(selected); + } + public Optional findAlias(@Nonnull Class table, @Nullable String path, @Nonnull ResolveScope scope) throws SqlTemplateException { 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 ab8bd2a7e..d1e31885a 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 @@ -609,7 +609,18 @@ public ColumnExpression toColumnExpression(@Nonnull Column column) { if (isRootTable && metamodel.path().isEmpty()) { alias = table.alias(); } else { - alias = aliasMapper.findAlias(metamodel.tableType(), metamodel.path(), INNER).orElse(null); + String lookupPath = metamodel.path(); + if (!isRootTable && metamodel.root() != model.type()) { + // The column's metamodel is rooted at a different table than the query model root + // (typically because the SELECT target differs from the FROM table). The alias mapper + // has paths relative to the query model root, so prepend the registered path of the + // column root before lookup. + String rootPrefix = aliasMapper.findRegisteredPath(metamodel.root()).orElse(null); + if (rootPrefix != null && !rootPrefix.isEmpty()) { + lookupPath = lookupPath.isEmpty() ? rootPrefix : rootPrefix + "." + lookupPath; + } + } + alias = aliasMapper.findAlias(metamodel.tableType(), lookupPath, INNER).orElse(null); } if (alias == null) { alias = aliasMapper.findAlias(metamodel.tableType(), null, INNER).orElse(null); diff --git a/storm-core/src/test/java/st/orm/core/SelectTargetAliasResolutionIntegrationTest.java b/storm-core/src/test/java/st/orm/core/SelectTargetAliasResolutionIntegrationTest.java new file mode 100644 index 000000000..64e4c5297 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/SelectTargetAliasResolutionIntegrationTest.java @@ -0,0 +1,59 @@ +package st.orm.core; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import st.orm.core.model.Owner; +import st.orm.core.model.Tenant; +import st.orm.core.template.ORMTemplate; + +/** + * Regression tests for SELECT-target alias resolution when the SELECT type differs from the + * repository's root entity type. + * + *

{@link Tenant} creates a diamond join graph: {@code tenant -> owner -> address.city} and + * {@code tenant -> city} both reach {@code City}. Selecting {@link Owner} from a Tenant + * repository expands Owner's nested column tree (which includes {@code address.city}) whose + * metamodels are rooted at Owner rather than at the repository root. Before the fix, alias + * resolution looked up paths rooted at Owner against the alias map that stores Tenant-rooted + * paths, fell through to a permissive null-path lookup, and failed with + * "Multiple aliases found for: City".

+ */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = IntegrationConfig.class) +@DataJpaTest(showSql = false) +public class SelectTargetAliasResolutionIntegrationTest { + + @Autowired + private DataSource dataSource; + + @Test + public void selectJoinedEntityWithDiamondPath() { + var tenants = ORMTemplate.of(dataSource).entity(Tenant.class); + // Owner sits one level deep; City is reachable via two paths (tenant.owner.address.city + // and tenant.city). Compilation must pick the alias on the Owner branch without falling + // back to a null-path lookup. + List owners = assertDoesNotThrow(() -> tenants.select(Owner.class).getResultList()); + assertNotNull(owners); + assertEquals(2, owners.size()); + } + + @Test + public void selectJoinedEntityResolvesNestedColumns() { + var tenants = ORMTemplate.of(dataSource).entity(Tenant.class); + List owners = tenants.select(Owner.class).getResultList(); + // Verify the nested city column on the Owner branch resolves correctly (rather than + // collapsing onto the tenant.city alias). + assertEquals(1, owners.get(0).address().city().id()); + assertEquals(2, owners.get(1).address().city().id()); + } +} diff --git a/storm-core/src/test/java/st/orm/core/model/Tenant.java b/storm-core/src/test/java/st/orm/core/model/Tenant.java new file mode 100644 index 000000000..349ab0576 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/model/Tenant.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.core.model; + +import jakarta.annotation.Nonnull; +import lombok.Builder; +import st.orm.Entity; +import st.orm.FK; +import st.orm.PK; + +/** + * Test entity that creates a diamond join graph by reaching {@link City} through two independent + * paths: directly via {@link #city()} and indirectly via {@link Owner#address()}.{@code city()}. + * + *

Used to exercise alias path resolution when the {@code SELECT} target's expanded column tree + * references a table that is reachable through multiple paths from the {@code FROM} table.

+ */ +@Builder(toBuilder = true) +public record Tenant( + @PK Integer id, + @Nonnull String name, + @Nonnull @FK Owner owner, + @Nonnull @FK City city +) implements Entity { +} diff --git a/storm-core/src/test/resources/data.sql b/storm-core/src/test/resources/data.sql index a144a42f4..f406076c4 100644 --- a/storm-core/src/test/resources/data.sql +++ b/storm-core/src/test/resources/data.sql @@ -1,3 +1,4 @@ +drop table if exists tenant CASCADE; drop table if exists city CASCADE; drop table if exists owner CASCADE; drop table if exists pet CASCADE; @@ -25,6 +26,9 @@ alter table vet_specialty add constraint vet_specialty_specialty_fk foreign key alter table vet_specialty add constraint vet_specialty_vet_fk foreign key (vet_id) references vet (id); alter table visit add constraint visit_pet_fk foreign key (pet_id) references pet (id); alter table visit add constraint visit_vet_specialty_fk foreign key (vet_id, specialty_id) references vet_specialty (vet_id, specialty_id); +create table tenant (id integer auto_increment, name varchar(255), owner_id integer not null, city_id integer not null, primary key (id)); +alter table tenant add constraint tenant_owner_fk foreign key (owner_id) references owner (id); +alter table tenant add constraint tenant_city_fk foreign key (city_id) references city (id); create view owner_view as select * from owner; create view visit_view as select visit_date, description, pet_id, "timestamp" from visit; @@ -206,3 +210,7 @@ INSERT INTO char_disc_animal (dtype, name, indoor) VALUES ('C', 'Whiskers', true INSERT INTO char_disc_animal (dtype, name, indoor) VALUES ('C', 'Luna', false); INSERT INTO char_disc_animal (dtype, name, weight) VALUES ('D', 'Rex', 30); INSERT INTO char_disc_animal (dtype, name, weight) VALUES ('D', 'Max', 15); + +-- Tenant creates a diamond join graph: tenant -> owner -> city and tenant -> city. +INSERT INTO tenant (name, owner_id, city_id) VALUES ('Alpha', 1, 1); +INSERT INTO tenant (name, owner_id, city_id) VALUES ('Beta', 2, 2);