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