-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Description
Description
After upgrading to Spring Boot 4.0 (Spring Data JPA 4.0, Hibernate 7), interface-based projections using the documented flattened property naming convention fail with AliasCollisionException when the projection traverses multiple relationships that share property names (e.g., id, name).
This is a regression from Spring Boot 3.x where this feature worked correctly.
The root cause: Spring Data JPA generates SQL aliases using only the last segment of the property path (e.g., id) instead of the full flattened property name derived from the getter (e.g., customerId). Hibernate 7 now correctly enforces SQL standards and rejects duplicate aliases, exposing this issue.
Reproduction Repository
spring-data-jpa-alias-collision-bug
git clone https://github.com/oualidbouh/spring-data-jpa-alias-collision-bug.git
cd spring-data-jpa-alias-collision-bug
./mvnw testVersions
| Dependency | Version |
|---|---|
| Spring Boot | 4.0.0 |
| Spring Data JPA | 4.0.x |
| Hibernate | 7.0.x |
| Java | 21 |
Minimal Example
Entities
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
@ManyToOne
private Customer customer;
@ManyToOne
private Product product;
@ManyToOne
private Shipment shipment;
}
@Entity
public class Customer {
@Id
private Long id;
private String name;
}
@Entity
public class Product {
@Id
private Long id;
private String name;
}
@Entity
public class Shipment {
@Id
private Long id;
}Interface Projection (following documented naming convention)
public interface OrderSummaryProjection {
Long getId();
Long getCustomerId(); // → should map to customer.id
Long getProductId(); // → should map to product.id
Long getShipmentId(); // → should map to shipment.id
String getCustomerName(); // → should map to customer.name
String getProductName(); // → should map to product.name
}Repository
public interface OrderRepository extends JpaRepository {
OrderSummaryProjection findProjectionById(Long id);
}Expected Behavior
Spring Data JPA should generate a query with unique aliases matching the flattened property names:
SELECT
o.id AS id,
c.id AS customerId,
p.id AS productId,
s.id AS shipmentId,
c.name AS customerName,
p.name AS productName
FROM Order o
LEFT JOIN o.customer c
LEFT JOIN o.product p
LEFT JOIN o.shipment s
WHERE o.id = ?Actual Behavior
Spring Data JPA generates duplicate aliases using only the last path segment:
SELECT
o.id AS id,
c.id AS id, -- Duplicate alias
p.id AS id, -- Duplicate alias
s.id AS id, -- Duplicate alias
c.name AS name, -- Duplicate alias
p.name AS name -- Duplicate alias
FROM Order o
LEFT JOIN o.customer c
LEFT JOIN o.product p
LEFT JOIN o.shipment s
WHERE o.id = ?Exception
org.springframework.data.jpa.repository.query.BadJpqlGrammarException:
org.hibernate.query.sqm.AliasCollisionException: Duplicate alias 'id' at position 1 in 'select' clause;
Bad JPQL grammar [SELECT o.id id, c.id id, p.id id, s.id id, c.name name, p.name name
FROM Order o LEFT JOIN o.customer c LEFT JOIN o.product p LEFT JOIN o.shipment s WHERE o.id = :id]
Root Cause Analysis
| Getter | Property Path | Current Alias | Expected Alias |
|---|---|---|---|
getId() |
id |
id ✅ |
id ✅ |
getCustomerId() |
customer.id |
id ❌ |
customerId ✅ |
getProductId() |
product.id |
id ❌ |
productId ✅ |
getShipmentId() |
shipment.id |
id ❌ |
shipmentId ✅ |
getCustomerName() |
customer.name |
name ❌ |
customerName ✅ |
getProductName() |
product.name |
name ❌ |
productName ✅ |
This worked silently in Hibernate 5/6 which tolerated duplicate aliases. Hibernate 7 correctly validates aliases per SQL standards and rejects duplicates via AliasCollisionException.
Debugging Analysis
Step 1: Path Expressions Are Built Correctly
In JpaQueryCreator.doSelect(), the paths collection contains correctly resolved path expressions:
| path | origin |
|---|---|
Order.id |
Entity[entity=Order, alias=o] |
Customer.id |
Join[source=Entity[Order], joinType=LEFT JOIN, path=customer] |
Product.id |
Join[source=Entity[Order], joinType=LEFT JOIN, path=product] |
Shipment.id |
Join[source=Entity[Order], joinType=LEFT JOIN, path=shipment] |
Customer.name |
Join[source=Entity[Order], joinType=LEFT JOIN, path=customer] |
Product.name |
Join[source=Entity[Order], joinType=LEFT JOIN, path=product] |
Step 2: The Problem Occurs During Rendering
In JpqlQueryBuilder.Multiselect.render():
record Multiselect(Origin source, Collection paths) implements Selection {
@Override
public String render(RenderContext context) {
StringBuilder builder = new StringBuilder();
for (Expression path : paths) {
if (!builder.isEmpty()) {
builder.append(", ");
}
builder.append(path.render(context));
if (!context.isConstructorContext() && path instanceof AliasedExpression ae) {
builder.append(" ").append(ae.getAlias()); // Returns "id" not "customerId"
}
}
return builder.toString();
}
}The ae.getAlias() returns only the column name (id, name) instead of the full flattened property name (customerId, customerName).
Step 3: Generated Query Shows Duplicate Aliases
SELECT o.id id, c.id id, p.id id, s.id id, c.name name, p.name name FROM Order o LEFT JOIN ...Selection breakdown: ".id id, .id id, .id id, .id id, .name name, .name name"
Unit Test for JpaQueryCreatorTests
private static final TestMetaModel ORDER_WITH_RELATIONS = TestMetaModel.hibernateModel(
OrderWithRelations.class, Customer.class, Supplier.class);
@Test // GH-XXXX
void interfaceProjectionWithMultipleJoinsShouldGenerateUniqueAliases() {
queryCreator(ORDER_WITH_RELATIONS) //
.forTree(OrderWithRelations.class, "findProjectionById") //
.returning(OrderSummaryProjection.class) //
.withParameters(1L) //
.as(QueryCreatorTester::create) //
.expectJpql(
"SELECT o.id id, c.id customerId, s.id supplierId, c.name customerName, s.name supplierName FROM %s o LEFT JOIN o.customer c LEFT JOIN o.supplier s WHERE o.id = ?1",
DefaultJpaEntityMetadata.unqualify(OrderWithRelations.class)) //
.validateQuery();
}
@jakarta.persistence.Entity
static class OrderWithRelations {
@Id Long id;
@ManyToOne Customer customer;
@ManyToOne Supplier supplier;
}
@jakarta.persistence.Entity
static class Customer {
@Id Long id;
String name;
}
@jakarta.persistence.Entity
static class Supplier {
@Id Long id;
String name;
}
interface OrderSummaryProjection {
Long getId();
Long getCustomerId(); // → customer.id
Long getSupplierId(); // → supplier.id
String getCustomerName(); // → customer.name
String getSupplierName(); // → supplier.name
}Proposed Fix
In JpaQueryCreator.doSelect(), explicitly set the alias using the selection variable which contains the correct flattened property name:
Current code (broken):
for (String selection : requiredSelection) {
paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
PropertyPath.from(selection, returnedType.getDomainType()), true));
}Fixed code:
for (String selection : requiredSelection) {
JpqlQueryBuilder.Expression expr = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
PropertyPath.from(selection, returnedType.getDomainType()), true);
paths.add(expr.as(selection)); // ← Explicitly set alias to "customerId", "productId", etc.
}The selection variable already contains the correct alias (customerId, productId, customerName, etc.) — it just needs to be explicitly set on the expression via .as(selection).
References
I have submitted a pull request with the proposed fix and unit test.