Skip to content

Conversation

mp911de
Copy link
Member

@mp911de mp911de commented Aug 21, 2025

We now provide AOT support to generate repository implementations during build-time for JDBC repository query methods.

Supported Features

  • Derived query methods, @Query and named query methods
  • @Modifying methods returning void, int, and long
  • Pagination, Slice, Stream, and Optional return types
  • DTO and Interface Projections
  • Value Expressions

Limitations

  • Methods accepting ScrollPosition (e.g. Keyset pagination) are not yet supported

Excluded methods

  • CrudRepository, Querydsl, Query by Example, and other base interface methods as their implementation is provided by the base class respective fragments
  • Methods whose implementation would be overly complex
    ** Methods accepting ScrollPosition (e.g. Keyset pagination)

Example repository:

interface UserRepository extends CrudRepository<User, Integer> {

	User findByFirstname(String name);

	Stream<User> streamByAgeGreaterThan(int age);

	@Query("SELECT * FROM MY_USER WHERE firstname = :name")
	User findByFirstnameAnnotated(String name);

	@Modifying
	@Query("delete from MY_USER where firstname = :firstname")
	void deleteWithoutResult(String firstname);

}

Generated fragment:

/**
 * AOT generated JDBC repository implementation for {@link UserRepository}.
 */
public class UserRepositoryImpl__AotRepository extends AotRepositoryFragmentSupport {
  private final RepositoryFactoryBeanSupport.FragmentCreationContext context;

  private final RowMapperFactory rowMapperFactory;

  private final JdbcAggregateOperations operations;

  public UserRepositoryImpl__AotRepository(RowMapperFactory rowMapperFactory,
      JdbcAggregateOperations operations,
      RepositoryFactoryBeanSupport.FragmentCreationContext context) {
    super(rowMapperFactory, operations, context);
    this.rowMapperFactory = rowMapperFactory;
    this.operations = operations;
    this.context = context;
  }

  public User findByFirstname(String name) {
    Criteria criteria = Criteria.where("firstname").is(name);
    StatementFactory.Selection selection = getStatementFactory().select(User.class);
    selection.filter(criteria);
    MapSqlParameterSource rawParameterSource = new MapSqlParameterSource();
    String query = selection.build(rawParameterSource);
    SqlParameterSource parameterSource = escapingParameterSource(rawParameterSource);

    RowMapper rowMapper = rowMapperFactory.create(User.class);
    Object result = queryForObject(query, parameterSource, rowMapper);
    return (User) convertOne(result, User.class);
  }

  public Stream<User> streamByAgeGreaterThan(int age) {
    Criteria criteria = Criteria.where("age").greaterThan(age);
    StatementFactory.Selection selection = getStatementFactory().select(User.class);
    selection.filter(criteria);
    MapSqlParameterSource rawParameterSource = new MapSqlParameterSource();
    String query = selection.build(rawParameterSource);
    SqlParameterSource parameterSource = escapingParameterSource(rawParameterSource);

    RowMapper rowMapper = rowMapperFactory.create(User.class);
    Stream result = getJdbcOperations().queryForStream(query, parameterSource, rowMapper);
    return (Stream<User>) convertMany(result, User.class);
  }

  public User findByFirstnameAnnotated(String name) {
    class ExpressionMarker{};
    String query = "SELECT * FROM MY_USER WHERE firstname = :name";
    MapSqlParameterSource parameterSource = new MapSqlParameterSource();
    getBindableValue(ExpressionMarker.class.getEnclosingMethod(), name, 0).bind("name", parameterSource);

    RowMapper rowMapper = rowMapperFactory.create(User.class);
    Object result = queryForObject(query, parameterSource, rowMapper);
    return (User) convertOne(result, User.class);
  }

  public void deleteWithoutResult(String firstname) {
    class ExpressionMarker{};
    String query = "delete from MY_USER where firstname = :firstname";
    MapSqlParameterSource parameterSource = new MapSqlParameterSource();
    getBindableValue(ExpressionMarker.class.getEnclosingMethod(), firstname, 0).bind("firstname", parameterSource);

    getJdbcOperations().update(query, parameterSource);
  }
}

Metadata (truncated):

{
  "name": "org.springframework.data.jdbc.repository.aot.UserRepository",
  "module": "JDBC",
  "type": "IMPERATIVE",
  "methods": [
    {
      "name": "findByFirstname",
      "signature": "public abstract org.springframework.data.jdbc.repository.aot.User org.springframework.data.jdbc.repository.aot.UserRepository.findByFirstname(java.lang.String)",
      "query": {
        "query": "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" = :firstname"
      }
    },
    {
      "name": "deleteWithoutResult",
      "signature": "public abstract void org.springframework.data.jdbc.repository.aot.UserRepository.deleteWithoutResult(java.lang.String)",
      "query": {
        "query": "delete from MY_USER where firstname = :firstname"
      }
    },
    {
      "name": "saveAll",
      "signature": "public abstract <S extends T> java.lang.Iterable<S> org.springframework.data.repository.CrudRepository.saveAll(java.lang.Iterable<S>)",
      "fragment": {
        "interface": "org.springframework.data.jdbc.repository.support.SimpleJdbcRepository",
        "fragment": "org.springframework.data.jdbc.repository.support.SimpleJdbcRepository"
      }
    }
  ]
}

@mp911de mp911de added type: enhancement A general enhancement theme: aot An issue related to Ahead-Of-Time processing labels Aug 21, 2025
@mp911de mp911de linked an issue Aug 21, 2025 that may be closed by this pull request
Copy link
Contributor

@schauder schauder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some minor complaints.

In general this looks good to me.

This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start.

The resulting AOT repository fragment follows the naming scheme of `<Repository FQCN>Impl__Aot` and is placed in the same package as the repository interface.
You can find all queries in their String form for generated repository query methods.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess there is a "in the implementation" missing or something to that effect.


Assert.notNull(tableEntity, "Table entity must not be null");

this.type = (Class) tableEntity.getType();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we call the other constructor, so we have a single place where initialization takes place?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling this(…) potentially incurs a NullPointerException if tableEntity is null.

* @author Mark Paluch
* @since 4.0
*/
public class ParameterBinding {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect a ParameterBinding to be the binding of a value to a parameter. But this seems to be just a BindParameter: a parameter to which a value can be bound.

Correct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the ParameterBinding is intended as command to bind the parameter and not do this optionally. We have a similar naming setup in other modules. We could dive deeper into naming nuances if we wanted to. Feel free to create a ticket at the commons level.

}

@Override
public T mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we missing a @Nullable annotation here, or is the object != null check superfluous?

Comment on lines +104 to +112
String parameterNames = StringUtils.collectionToDelimitedString(context.getAllParameterNames(), ", ");

if (StringUtils.hasText(parameterNames)) {
this.parameterNames = ", " + parameterNames;
} else {
this.parameterNames = "";
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it is confusing to have something called parameterNames that contains a starting colon which influences the method called when being used in the CodeBlock via $L later on.

expressionString = "#{" + expressionString + "}";
}

builder.addStatement("evaluate($L, $S$L).bind($S, $L)", context.getExpressionMarker().enclosingMethod(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$S$L magic method call switch via parameter names mentioned already above.

Comment on lines +669 to +671
builder.addStatement("return ($T) convertMany($L, %s)".formatted(dynamicProjection ? "$L" : "$T.class"),
context.getReturnTypeName(), result, queryResultTypeRef);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use string formatting to bypass more expressive use of the builder API? Puts additional cognitive load on the reader having to deal with 2 placeholder patterns and a dynamic result type ref.


if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) {
builder.addStatement(
"return ($1T) $1T.ofNullable(convertOne($2L, %s))".formatted(dynamicProjection ? "$3L" : "$3T.class"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need that ($1T) cast to do satisfy the compiler? (Optional) Optional.ofNullable(...) looks strange though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a bit of general dynamic in row mappers as we can have user-specified row mappers that do not necessarily comply with the RowMapper<ReturnType> declaration and we can end up with slightly different typing. Therefore, we broadly cast types to make it work now and refine typing later on, especially once we have a fluent API that would remove the need for broad casting.

builder.addStatement("$T $L = getRowMapperFactory().create($T.class)", RowMapper.class, rowMapper,
context.getRepositoryInformation().getDomainType());

builder.addStatement("$T $L = ($T) getJdbcOperations().query($L, $L, new $T<>($L))", List.class, result,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indexed placeholders maybe?

We now provide AOT support to generate repository implementations during build-time for JDBC repository query methods.

Supported Features

Derived query methods, @query and named query methods
* @Modifying methods returning void, int, and long
* Pagination, Slice, Stream, and Optional return types
* DTO and Interface Projections
* Value Expressions

Excluded methods
* CrudRepository, Querydsl, Query by Example, and other base interface methods as their implementation is provided by the base class respective fragments
* Methods whose implementation would be overly complex
* Methods accepting ScrollPosition (e.g. Keyset pagination)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
theme: aot An issue related to Ahead-Of-Time processing type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Explore support for Ahead of Time Repositories
3 participants