Skip to content

AoT-generated JDBC repository ignores @Query method return type for RowMapper, always uses entity class #2283

@cmdjulian

Description

@cmdjulian

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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions