Bug Description
The AoT code generator (JdbcRepositoryContributor) ignores the return type of @Query-annotated repository methods when determining the RowMapper. It always generates getRowMapperFactory().create(EntityType.class) regardless of the method's actual return type. This causes failures for projection queries that return a DTO type different from the entity.
The runtime code path (StringBasedJdbcQuery) does not have this bug — it correctly uses getResultProcessor().getReturnedType().getReturnedType() to determine the appropriate RowMapper.
Versions
| Dependency |
Version |
| Spring Boot |
4.0.5 |
| Spring Data JDBC |
(managed by Spring Boot 4.0.5) |
| Kotlin |
2.3.0 |
| GraalVM Build Tools |
0.11.5 |
| Java |
25 |
Reproducer
https://github.com/cmdjulian/spring-data-jdbc-aot-projection-bug
Setup
An entity with non-null Kotlin parameters:
@Table("ORDERS")
data class Order(
@Id val id: Long? = null,
val customerId: String,
val product: String,
val amount: BigDecimal,
val status: String,
)
A projection DTO:
data class OrderSummary(
val id: Long,
val status: String,
)
A repository with a @Query method returning the projection:
interface OrderRepository : CrudRepository<Order, Long> {
@Query("SELECT id, status FROM ORDERS WHERE status = :status")
fun findSummariesByStatus(status: String): List<OrderSummary>
}
Without AoT — works correctly
./gradlew clean test
# BUILD SUCCESSFUL
With AoT — fails
./gradlew clean test -Dspring.aot.enabled=true
# MappingInstantiationException / NullPointerException
Generated AoT code (the bug)
After ./gradlew processAot, the generated OrderRepositoryImpl__AotRepository.java contains:
public List<OrderSummary> findSummariesByStatus(String status) {
// ...
RowMapper rowMapper = getRowMapperFactory().create(Order.class); // BUG: should use return type
List result = (List) getJdbcOperations().query(query, parameterSource, new RowMapperResultSetExtractor<>(rowMapper));
return (List<OrderSummary>) convertMany(result, OrderSummary.class);
}
Line getRowMapperFactory().create(Order.class) creates an EntityRowMapper<Order> which tries to construct a full Order from a result set that only contains id and status. The unmapped columns (customerId, product, amount) are null, violating Kotlin's non-null constraints.
Error
org.springframework.data.mapping.model.MappingInstantiationException:
Failed to instantiate Order using constructor with arguments <id>,null,null,null,SHIPPED
Caused by: NullPointerException:
Parameter specified as non-null is null: method Order.<init>, parameter customerId
Root Cause
In JdbcRepositoryContributor, when generating AoT code for @Query methods without an explicit rowMapperClass, the code generator always uses getRowMapperFactory().create(EntityType.class). It does not consider the method's return type.
The runtime equivalent in StringBasedJdbcQuery handles this correctly by calling getResultProcessor().getReturnedType().getReturnedType() to resolve the target type for the RowMapper.
Impact
- Kotlin entities:
NullPointerException on non-null parameters (hard failure)
- Java entities: Silent data corruption — the
EntityRowMapper constructs the full entity with null fields, and the convertMany call then tries to convert it, resulting in wrong types or null fields the caller doesn't expect
Workaround
Specifying rowMapperClass explicitly on the @Query annotation makes the AoT code generator use the custom RowMapper correctly:
@Query(
"SELECT id, status FROM ORDERS WHERE status = :status",
rowMapperClass = OrderSummaryRowMapper::class,
)
fun findSummariesByStatus(status: String): List<OrderSummary>
When rowMapperClass is specified, the generated code correctly uses:
RowMapper rowMapper = new OrderSummaryRowMapper(); // CORRECT
Expected Behavior
The AoT code generator should use the method's return type to determine the RowMapper when no explicit rowMapperClass is specified — consistent with the runtime behavior in StringBasedJdbcQuery.
Bug Description
The AoT code generator (
JdbcRepositoryContributor) ignores the return type of@Query-annotated repository methods when determining theRowMapper. It always generatesgetRowMapperFactory().create(EntityType.class)regardless of the method's actual return type. This causes failures for projection queries that return a DTO type different from the entity.The runtime code path (
StringBasedJdbcQuery) does not have this bug — it correctly usesgetResultProcessor().getReturnedType().getReturnedType()to determine the appropriateRowMapper.Versions
Reproducer
https://github.com/cmdjulian/spring-data-jdbc-aot-projection-bug
Setup
An entity with non-null Kotlin parameters:
A projection DTO:
A repository with a
@Querymethod returning the projection:Without AoT — works correctly
With AoT — fails
Generated AoT code (the bug)
After
./gradlew processAot, the generatedOrderRepositoryImpl__AotRepository.javacontains:Line
getRowMapperFactory().create(Order.class)creates anEntityRowMapper<Order>which tries to construct a fullOrderfrom a result set that only containsidandstatus. The unmapped columns (customerId,product,amount) are null, violating Kotlin's non-null constraints.Error
Root Cause
In
JdbcRepositoryContributor, when generating AoT code for@Querymethods without an explicitrowMapperClass, the code generator always usesgetRowMapperFactory().create(EntityType.class). It does not consider the method's return type.The runtime equivalent in
StringBasedJdbcQueryhandles this correctly by callinggetResultProcessor().getReturnedType().getReturnedType()to resolve the target type for theRowMapper.Impact
NullPointerExceptionon non-null parameters (hard failure)EntityRowMapperconstructs the full entity with null fields, and theconvertManycall then tries to convert it, resulting in wrong types or null fields the caller doesn't expectWorkaround
Specifying
rowMapperClassexplicitly on the@Queryannotation makes the AoT code generator use the custom RowMapper correctly:@Query( "SELECT id, status FROM ORDERS WHERE status = :status", rowMapperClass = OrderSummaryRowMapper::class, ) fun findSummariesByStatus(status: String): List<OrderSummary>When
rowMapperClassis specified, the generated code correctly uses:Expected Behavior
The AoT code generator should use the method's return type to determine the
RowMapperwhen no explicitrowMapperClassis specified — consistent with the runtime behavior inStringBasedJdbcQuery.