diff --git a/pom.xml b/pom.xml index 98f945a6b2..9bc185587c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index b3c39e64c3..79db32548e 100644 --- a/spring-data-jdbc-distribution/pom.xml +++ b/spring-data-jdbc-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index e61fd64020..4dc9d0b2a7 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT @@ -167,6 +167,18 @@ + + org.springframework + spring-test + test + + + + org.springframework + spring-core-test + test + + org.awaitility awaitility @@ -194,6 +206,19 @@ test + + net.javacrumbs.json-unit + json-unit-assertj + 4.1.0 + test + + + + com.fasterxml.jackson.core + jackson-databind + test + + com.tngtech.archunit archunit diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/aot/JdbcRuntimeHints.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/aot/JdbcRuntimeHints.java index 3a5eb3a73e..8f539fdfe5 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/aot/JdbcRuntimeHints.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/aot/JdbcRuntimeHints.java @@ -57,10 +57,10 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) TypeReference.of("org.springframework.core.DecoratingProxy")); hints.reflection().registerType(TypeReference.of("org.postgresql.jdbc.TypeInfoCache"), - MemberCategory.PUBLIC_CLASSES); + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); for (Class simpleType : JdbcPostgresDialect.INSTANCE.simpleTypes()) { - hints.reflection().registerType(TypeReference.of(simpleType), MemberCategory.PUBLIC_CLASSES); + hints.reflection().registerType(TypeReference.of(simpleType), MemberCategory.INVOKE_DECLARED_METHODS); } } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java index 8b9dbd6f33..75e68eb80c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java @@ -27,6 +27,7 @@ import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.relational.core.query.Query; +import org.springframework.jdbc.core.RowMapper; import org.springframework.lang.Nullable; /** @@ -342,4 +343,16 @@ public interface JdbcAggregateOperations { */ DataAccessStrategy getDataAccessStrategy(); + /** + * Return a {@link RowMapper} that can map rows of a {@link java.sql.ResultSet} to instances of the specified + * {@link Class type}. The row mapper supports entity callbacks and lifecycle events if enabled and configured on the + * underlying template instance. + * + * @param type type of the entity to map. + * @return a row mapper for the given type. + * @param + * @since 4.0 + */ + RowMapper getRowMapper(Class type); + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index 9f5dd08d6b..c178ddfb3f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -15,6 +15,8 @@ */ package org.springframework.data.jdbc.core; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -36,7 +38,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.DataAccessStrategy; +import org.springframework.data.jdbc.core.convert.EntityRowMapper; import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.relational.core.EntityLifecycleEventDelegate; @@ -55,6 +59,7 @@ import org.springframework.data.relational.core.mapping.event.*; import org.springframework.data.relational.core.query.Query; import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.jdbc.core.RowMapper; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -83,6 +88,7 @@ public class JdbcAggregateTemplate implements JdbcAggregateOperations, Applicati private final JdbcConverter converter; private @Nullable EntityCallbacks entityCallbacks; + private QueryMappingConfiguration queryMappingConfiguration = QueryMappingConfiguration.EMPTY; /** * Creates a new {@link JdbcAggregateTemplate} given {@link RelationalMappingContext} and {@link DataAccessStrategy}. @@ -186,6 +192,23 @@ public void setEntityLifecycleEventsEnabled(boolean enabled) { this.eventDelegate.setEventsEnabled(enabled); } + /** + * Return a {@link RowMapper} to map results for {@link Class type}. + * + * @param type must not be {@literal null}. + * @return a row mapper to map results for {@link Class type}. + * @param return type. + * @since 4.0 + */ + @Override + @SuppressWarnings("unchecked") + public RowMapper getRowMapper(Class type) { + + RelationalPersistentEntity entity = context.getRequiredPersistentEntity(type); + + return new LifecycleEntityRowMapper((RelationalPersistentEntity) entity); + } + @Override public T save(T instance) { @@ -741,4 +764,29 @@ private interface AggregateChangeCreator { RootAggregateChange createAggregateChange(T instance); } + class LifecycleEntityRowMapper extends EntityRowMapper { + + public LifecycleEntityRowMapper(RelationalPersistentEntity entity) { + super(entity, converter); + } + + @Override + public T mapRow(ResultSet resultSet, int rowNumber) throws SQLException { + + T object = super.mapRow(resultSet, rowNumber); + + if (object != null) { + + eventDelegate.publishEvent(() -> new AfterConvertEvent<>(object)); + + if (entityCallbacks != null) { + return entityCallbacks.callback(AfterConvertCallback.class, object); + } + } + + return object; + } + + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/EntityRowMapper.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/EntityRowMapper.java index 8ecaa161b3..e9094cfe74 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/EntityRowMapper.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/EntityRowMapper.java @@ -41,13 +41,6 @@ public class EntityRowMapper implements RowMapper { private final JdbcConverter converter; private final Identifier identifier; - private EntityRowMapper(TypeInformation typeInformation, JdbcConverter converter, Identifier identifier) { - - this.typeInformation = typeInformation; - this.converter = converter; - this.identifier = identifier; - } - @SuppressWarnings("unchecked") public EntityRowMapper(AggregatePath path, JdbcConverter converter, Identifier identifier) { this(((RelationalPersistentEntity) path.getRequiredLeafEntity()).getTypeInformation(), converter, identifier); @@ -57,6 +50,13 @@ public EntityRowMapper(RelationalPersistentEntity entity, JdbcConverter conve this(entity.getTypeInformation(), converter, Identifier.empty()); } + private EntityRowMapper(TypeInformation typeInformation, JdbcConverter converter, Identifier identifier) { + + this.typeInformation = typeInformation; + this.converter = converter; + this.identifier = identifier; + } + @Override public T mapRow(ResultSet resultSet, int rowNumber) throws SQLException { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMappingConfiguration.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMappingConfiguration.java index 91becb7fb7..4fef3d9f53 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMappingConfiguration.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMappingConfiguration.java @@ -28,10 +28,14 @@ */ public interface QueryMappingConfiguration { - @Nullable - default RowMapper getRowMapper(Class type) { - return null; - } + /** + * Returns the {@link RowMapper} to be used for the given type or {@literal null} if no specific mapper is configured. + * + * @param type + * @return + * @param + */ + @Nullable RowMapper getRowMapper(Class type); /** * An immutable empty instance that will return {@literal null} for all arguments. @@ -39,7 +43,7 @@ default RowMapper getRowMapper(Class type) { QueryMappingConfiguration EMPTY = new QueryMappingConfiguration() { @Override - public RowMapper getRowMapper(Class type) { + public @Nullable RowMapper getRowMapper(Class type) { return null; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQueries.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQueries.java new file mode 100644 index 0000000000..892948a897 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQueries.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.repository.aot.generate.QueryMetadata; + +/** + * Value object capturing queries used for repository query methods. + * + * @author Mark Paluch + * @since 4.0 + */ +record AotQueries(AotQuery result, @Nullable AotQuery count) { + + /** + * Factory method to create an {@link AotQueries} instance with a single query. + * + * @param query + * @return + */ + public static AotQueries create(AotQuery query) { + return new AotQueries(query, null); + } + + /** + * Factory method to create an {@link AotQueries} instance with an entity- and a count query. + * + * @param query + * @param count + * @return + */ + public static AotQueries create(AotQuery query, AotQuery count) { + return new AotQueries(query, count); + } + + public QueryMetadata toMetadata() { + return new AotQueryMetadata(); + } + + /** + * String and Named Query-based {@link QueryMetadata}. + */ + private class AotQueryMetadata implements QueryMetadata { + + @Override + public Map serialize() { + + Map serialized = new LinkedHashMap<>(); + + if (result() instanceof StringAotQuery sq) { + serialized.put("query", sq.getQueryString()); + } + + if (result() instanceof StringAotQuery.NamedStringAotQuery nsq) { + serialized.put("name", nsq.getQueryName()); + } + + if (count() instanceof StringAotQuery sq) { + serialized.put("count-query", sq.getQueryString()); + } + + if (count() instanceof StringAotQuery.NamedStringAotQuery nsq) { + serialized.put("count-name", nsq.getQueryName()); + } + + return serialized; + } + + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQuery.java new file mode 100644 index 0000000000..b1b039ea4e --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotQuery.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.data.jdbc.repository.query.ParameterBinding; + +/** + * AOT query value object along with its parameter bindings. + * + * @author Mark Paluch + * @since 4.0 + */ +abstract class AotQuery { + + private final List parameterBindings; + + AotQuery(List parameterBindings) { + this.parameterBindings = parameterBindings; + } + + /** + * @return the list of parameter bindings. + */ + public List getParameterBindings() { + return parameterBindings; + } + + /** + * @return the preliminary query limit. + */ + public Limit getLimit() { + return Limit.unlimited(); + } + + /** + * @return whether the query is limited (e.g. {@code findTop10By}). + */ + public boolean isLimited() { + return getLimit().isLimited(); + } + + /** + * @return whether the query a delete query. + */ + public boolean isDelete() { + return false; + } + + /** + * @return whether the query is a count query. + */ + public boolean isCount() { + return false; + } + + /** + * @return whether the query is an exists query. + */ + public boolean isExists() { + return false; + } + + /** + * @return {@literal true} if the query uses value expressions. + */ + public boolean hasExpression() { + + for (ParameterBinding parameterBinding : parameterBindings) { + if (parameterBinding.getOrigin().isExpression()) { + return true; + } + } + + return false; + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotRepositoryFragmentSupport.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotRepositoryFragmentSupport.java new file mode 100644 index 0000000000..e9f26b9cb5 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/AotRepositoryFragmentSupport.java @@ -0,0 +1,287 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.lang.reflect.Method; +import java.sql.SQLType; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.data.convert.DtoInstantiatingConverter; +import org.springframework.data.domain.Slice; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.convert.JdbcColumnTypes; +import org.springframework.data.jdbc.core.mapping.JdbcValue; +import org.springframework.data.jdbc.repository.query.EscapingParameterSource; +import org.springframework.data.jdbc.repository.query.JdbcParameters; +import org.springframework.data.jdbc.repository.query.RowMapperFactory; +import org.springframework.data.jdbc.repository.query.StatementFactory; +import org.springframework.data.jdbc.repository.query.StringValueUtil; +import org.springframework.data.jdbc.support.JdbcUtil; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ParametersSource; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.data.util.Lazy; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.util.ConcurrentLruCache; + +/** + * Support class for JDBC AOT repository fragments. + * + * @author Mark Paluch + * @since 4.0 + */ +public class AotRepositoryFragmentSupport { + + private static final ConversionService CONVERSION_SERVICE; + + static { + + ConfigurableConversionService conversionService = new DefaultConversionService(); + + conversionService.removeConvertible(Collection.class, Object.class); + conversionService.removeConvertible(Object.class, Optional.class); + + CONVERSION_SERVICE = conversionService; + } + + private final RowMapperFactory rowMapperFactory; + + private final JdbcAggregateOperations operations; + + private final StatementFactory statementFactory; + + private final ProjectionFactory projectionFactory; + + private final Lazy> expressions; + + private final Lazy> parameters; + + private final Lazy> contextProviders; + + protected AotRepositoryFragmentSupport(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, + RepositoryFactoryBeanSupport.FragmentCreationContext context) { + this(operations, rowMapperFactory, context.getRepositoryMetadata(), context.getValueExpressionDelegate(), + context.getProjectionFactory()); + } + + protected AotRepositoryFragmentSupport(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, + RepositoryMetadata repositoryMetadata, ValueExpressionDelegate valueExpressions, + ProjectionFactory projectionFactory) { + + this.rowMapperFactory = rowMapperFactory; + this.operations = operations; + this.statementFactory = new StatementFactory(operations.getConverter(), + operations.getDataAccessStrategy().getDialect()); + this.projectionFactory = projectionFactory; + this.expressions = Lazy.of(() -> new ConcurrentLruCache<>(32, valueExpressions::parse)); + this.parameters = Lazy + .of(() -> new ConcurrentLruCache<>(32, it -> new JdbcParameters(ParametersSource.of(repositoryMetadata, it)))); + this.contextProviders = Lazy.of(() -> new ConcurrentLruCache<>(32, + it -> valueExpressions.createValueContextProvider(parameters.get().get(it)))); + } + + protected RowMapperFactory getRowMapperFactory() { + return rowMapperFactory; + } + + protected StatementFactory getStatementFactory() { + return statementFactory; + } + + protected Dialect getDialect() { + return operations.getDataAccessStrategy().getDialect(); + } + + protected JdbcAggregateOperations getOperations() { + return operations; + } + + protected NamedParameterJdbcOperations getJdbcOperations() { + return this.operations.getDataAccessStrategy().getJdbcOperations(); + } + + protected T queryForObject(String sql, SqlParameterSource paramSource, + RowMapper rowMapper) throws DataAccessException { + + List results = getJdbcOperations().query(sql, paramSource, rowMapper); + return DataAccessUtils.uniqueResult(results); + } + + protected SqlParameterSource escapingParameterSource(SqlParameterSource parameterSource) { + return new EscapingParameterSource(parameterSource, getDialect().getLikeEscaper()); + } + + protected @Nullable Object escape(@Nullable Object value) { + + if (value == null) { + return value; + } + + return getDialect().getLikeEscaper().escape(value.toString()); + } + + protected BindValue getBindableValue(Method method, @Nullable Object value, String parameterReference) { + return getBindableValue(parameters.get().get(method).getParameter(parameterReference), value); + } + + protected BindValue getBindableValue(Method method, @Nullable Object value, int parameterIndex) { + return getBindableValue(parameters.get().get(method).getParameter(parameterIndex), value); + } + + private BindValue getBindableValue(JdbcParameters.JdbcParameter parameter, @Nullable Object value) { + + JdbcValue jdbcValue = StringValueUtil.getBindValue(operations.getConverter(), value, + parameter.getTypeInformation(), parameter.getSqlType(), parameter.getActualSqlType()); + SQLType jdbcType = jdbcValue.getJdbcType(); + + return (parameterName, parameterSource) -> { + if (jdbcType == null) { + parameterSource.addValue(parameterName, jdbcValue.getValue()); + } else { + parameterSource.addValue(parameterName, jdbcValue.getValue(), jdbcType.getVendorTypeNumber()); + } + }; + } + + protected BindValue evaluate(Method method, String expressionString, Object... args) { + + ValueExpression expression = this.expressions.get().get(expressionString); + ValueEvaluationContextProvider contextProvider = this.contextProviders.get().get(method); + + ValueEvaluationContext context = contextProvider.getEvaluationContext(args, expression.getExpressionDependencies()); + Object evaluatedValue = expression.evaluate(context); + Class valueType = expression.getValueType(context); + + SQLType sqlType; + + if (valueType == null) { + if (evaluatedValue != null) { + sqlType = getSqlType(evaluatedValue.getClass()); + } else { + sqlType = null; + } + } else { + sqlType = getSqlType(valueType); + } + + return (parameterName, parameterSource) -> { + if (sqlType != null) { + parameterSource.addValue(parameterName, evaluatedValue, sqlType.getVendorTypeNumber()); + } else { + parameterSource.addValue(parameterName, evaluatedValue); + } + }; + } + + private static SQLType getSqlType(Class valueType) { + return JdbcUtil.targetSqlTypeFor(JdbcColumnTypes.INSTANCE.resolvePrimitiveType(valueType)); + } + + protected @Nullable T convertOne(@Nullable Object result, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return projection.cast(result); + } + + if (CONVERSION_SERVICE.canConvert(result.getClass(), projection)) { + return CONVERSION_SERVICE.convert(result, projection); + } + + if (!projection.isInterface()) { + + RelationalMappingContext mappingContext = operations.getConverter().getMappingContext(); + DtoInstantiatingConverter converter = new DtoInstantiatingConverter(projection, mappingContext, + operations.getConverter().getEntityInstantiators()); + return (T) converter.convert(result); + } + + return projectionFactory.createProjection(projection, result); + } + + protected @Nullable Object convertMany(@Nullable Object result, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return result; + } + + if (result instanceof Stream stream) { + return stream.map(it -> convertOne(it, projection)); + } + + if (result instanceof Slice slice) { + return slice.map(it -> convertOne(it, projection)); + } + + if (result instanceof Collection collection) { + + Collection<@Nullable Object> target = CollectionFactory.createCollection(collection.getClass(), + collection.size()); + for (Object o : collection) { + target.add(convertOne(o, projection)); + } + + return target; + } + + throw new UnsupportedOperationException("Cannot create projection for %s".formatted(result)); + } + + /** + * Interface for binding values to a {@link MapSqlParameterSource}. + */ + protected interface BindValue { + + /** + * Bind the value to the given {@link MapSqlParameterSource} using the given parameter name. Can apply further value + * customization such as providing SQL types or type names. + * + * @param parameterName + * @param parameterSource + */ + void bind(String parameterName, MapSqlParameterSource parameterSource); + + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/DerivedAotQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/DerivedAotQuery.java new file mode 100644 index 0000000000..2ff5ec562c --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/DerivedAotQuery.java @@ -0,0 +1,77 @@ +package org.springframework.data.jdbc.repository.aot; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.repository.query.ParametrizedQuery; +import org.springframework.data.relational.core.query.CriteriaDefinition; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * PartTree (derived) Query with a limit associated. + * + * @author Mark Paluch + */ +class DerivedAotQuery extends StringAotQuery { + + private final String queryString; + private final CriteriaDefinition criteria; + private final Sort sort; + private final Limit limit; + private final boolean delete; + private final boolean count; + private final boolean exists; + + DerivedAotQuery(String queryString, CriteriaDefinition criteria, Sort sort, + Limit limit, boolean delete, boolean count, boolean exists) { + super(List.of()); + this.queryString = queryString; + this.criteria = criteria; + this.sort = sort; + this.limit = limit; + this.delete = delete; + this.count = count; + this.exists = exists; + } + + DerivedAotQuery(ParametrizedQuery query, PartTree partTree, boolean countQuery) { + + this(query.getQuery(), query.getCriteria(), partTree.getSort(), partTree.getResultLimit(), partTree.isDelete(), + countQuery, partTree.isExistsProjection()); + + } + + @Override + public String getQueryString() { + return queryString; + } + + @Override + public Limit getLimit() { + return limit; + } + + @Override + public boolean isDelete() { + return delete; + } + + @Override + public boolean isCount() { + return count; + } + + @Override + public boolean isExists() { + return exists; + } + + public CriteriaDefinition getCriteria() { + return criteria; + } + + public Sort getSort() { + return sort; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcCodeBlocks.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcCodeBlocks.java new file mode 100644 index 0000000000..c72a02c0a5 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcCodeBlocks.java @@ -0,0 +1,772 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.lang.reflect.Type; +import java.sql.ResultSet; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; +import org.springframework.data.jdbc.repository.query.Modifying; +import org.springframework.data.jdbc.repository.query.ParameterBinding; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.jdbc.repository.query.StatementFactory; +import org.springframework.data.relational.core.query.Criteria; +import org.springframework.data.relational.core.query.CriteriaDefinition; +import org.springframework.data.relational.core.sql.LockMode; +import org.springframework.data.relational.repository.Lock; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.data.util.Pair; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.CodeBlock.Builder; +import org.springframework.javapoet.TypeName; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.RowMapperResultSetExtractor; +import org.springframework.jdbc.core.SingleColumnRowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Common code blocks for JDBC AOT Fragment generation. + * + * @author Mark Paluch + * @since 4.0 + */ +class JdbcCodeBlocks { + + /** + * @return new {@link QueryBlockBuilder}. + */ + public static QueryBlockBuilder queryBuilder(AotQueryMethodGenerationContext context, JdbcQueryMethod queryMethod) { + return new QueryBlockBuilder(context, queryMethod); + } + + /** + * @return new {@link QueryExecutionBlockBuilder}. + */ + static QueryExecutionBlockBuilder executionBuilder(AotQueryMethodGenerationContext context, + JdbcQueryMethod queryMethod) { + return new QueryExecutionBlockBuilder(context, queryMethod); + } + + /** + * Builder for the actual query code block. + */ + static class QueryBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final JdbcQueryMethod queryMethod; + private final String parameterNames; + private String queryVariableName = "undefined"; + private String parameterSourceVariableName = "undefined"; + private @Nullable AotQueries queries; + private MergedAnnotation lock = MergedAnnotation.missing(); + + private QueryBlockBuilder(AotQueryMethodGenerationContext context, JdbcQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + + String parameterNames = StringUtils.collectionToDelimitedString(context.getAllParameterNames(), ", "); + + if (StringUtils.hasText(parameterNames)) { + this.parameterNames = ", " + parameterNames; + } else { + this.parameterNames = ""; + } + } + + public QueryBlockBuilder usingQueryVariableName(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + public QueryBlockBuilder parameterSource(String parameterSource) { + + this.parameterSourceVariableName = parameterSource; + return this; + } + + public QueryBlockBuilder filter(AotQueries query) { + this.queries = query; + return this; + } + + public QueryBlockBuilder lock(MergedAnnotation lock) { + this.lock = lock; + return this; + } + + /** + * Build the query block. + * + * @return + */ + public CodeBlock build() { + + Assert.notNull(queries, "Queries must not be null"); + + if (queries.result() instanceof DerivedAotQuery derivedQuery) { + return createDerivedQuery(derivedQuery, + queries.count() instanceof DerivedAotQuery derivedCountQuery ? derivedCountQuery : null); + } + + if (queries.result() instanceof StringAotQuery stringQuery) { + return createStringQuery(queryVariableName, parameterSourceVariableName, stringQuery); + } + + throw new IllegalArgumentException("Unsupported AOT query type: " + queries.result()); + } + + private CodeBlock createDerivedQuery(DerivedAotQuery entityQuery, @Nullable DerivedAotQuery countQuery) { + + CriteriaDefinition criteria = entityQuery.getCriteria(); + + Builder builder = CodeBlock.builder(); + + if (!criteria.isEmpty()) { + builder.add(buildCriteria(criteria, (criteriaDefinition, b) -> { + b.add("$[$1T $2L = $1T.where($3S)", Criteria.class, context.localVariable("criteria"), + criteriaDefinition.getColumn().getReference()); + }, b -> b.add(";\n$]"))); + } + + builder.add(buildQuery(false, entityQuery, criteria, this.parameterSourceVariableName, this.queryVariableName)); + + if (countQuery != null) { + + Builder countAll = CodeBlock.builder(); + + countAll.beginControlFlow("$T $L = () ->", LongSupplier.class, context.localVariable("countAll")); + + String parameterSourceVariableName = context + .localVariable("count" + StringUtils.capitalize(this.parameterSourceVariableName)); + String queryVariableName = context.localVariable("count" + StringUtils.capitalize(this.queryVariableName)); + + countAll.add(buildQuery(true, countQuery, criteria, parameterSourceVariableName, queryVariableName)); + + countAll.addStatement("$1T $2L = queryForObject($3L, $4L, new $5T<>($1T.class))", Number.class, + context.localVariable("count"), queryVariableName, parameterSourceVariableName, + SingleColumnRowMapper.class); + + countAll.addStatement("return $1L != null ? $1L.longValue() : 0L", context.localVariable("count")); + + // end control flow does not work well with lambdas + countAll.unindent(); + countAll.add("};\n"); + + builder.add("\n"); + builder.add(countAll.build()); + } + + return builder.build(); + } + + private CodeBlock buildQuery(boolean count, DerivedAotQuery aotQuery, CriteriaDefinition criteria, + String parameterSourceVariableName, String queryVariableName) { + + Builder builder = CodeBlock.builder(); + String selection = context.localVariable(count ? "countSelection" : "selection"); + String rawParameterSource = context.localVariable(count ? "countRawParameterSource" : "rawParameterSource"); + + String method; + if (aotQuery.isCount()) { + method = "count($T.class)"; + } else if (aotQuery.isExists()) { + method = "exists($T.class)"; + } else if (queryMethod.isSliceQuery()) { + method = "slice($T.class)"; + } else { + method = "select($T.class)"; + } + + builder.addStatement("$T $L = getStatementFactory()." + method, StatementFactory.Selection.class, selection, + context.getRepositoryInformation().getDomainType()); + + if (!aotQuery.isCount() && !aotQuery.isExists()) { + + if (aotQuery.isLimited()) { + builder.addStatement("$1L.limit($2L)", selection, aotQuery.getLimit().max()); + } else if (StringUtils.hasText(context.getLimitParameterName())) { + builder.addStatement("$L.limit($L)", selection, context.getLimitParameterName()); + } + + if (StringUtils.hasText(context.getPageableParameterName())) { + builder.addStatement("$L.page($L)", selection, context.getPageableParameterName()); + } + + Sort sort = aotQuery.getSort(); + if (sort.isSorted()) { + builder.addStatement("$L.orderBy($L)", selection, buildSort(sort)); + } + + if (StringUtils.hasText(context.getSortParameterName())) { + builder.addStatement("$L.orderBy($L)", selection, context.getSortParameterName()); + } + } + + if (lock.isPresent()) { + builder.addStatement("$L.lock($T.$L)", selection, LockMode.class, lock.getEnum("value", LockMode.class).name()); + } + + if (!criteria.isEmpty()) { + builder.addStatement("$L.filter($L)", selection, context.localVariable("criteria")); + } + + // TODO Projections + + builder.addStatement("$1T $2L = new $1T()", MapSqlParameterSource.class, rawParameterSource); + builder.addStatement("$T $L = $L.build($L)", String.class, queryVariableName, selection, rawParameterSource); + builder.addStatement("$1T $2L = escapingParameterSource($3L)", SqlParameterSource.class, + parameterSourceVariableName, rawParameterSource); + + return builder.build(); + } + + private static CodeBlock buildSort(Sort sort) { + + Builder sortBuilder = CodeBlock.builder(); + sortBuilder.add("$T.by(", Sort.class); + + boolean first = true; + for (Sort.Order order : sort) { + + sortBuilder.add("$T.$L($S)", Sort.Order.class, order.isAscending() ? "asc" : "desc", order.getProperty()); + if (order.isIgnoreCase()) { + sortBuilder.add(".ignoreCase()"); + } + + if (first) { + first = false; + } else { + sortBuilder.add(", "); + } + } + + sortBuilder.add(")"); + + return sortBuilder.build(); + } + + private CodeBlock buildCriteria(CriteriaDefinition criteria, BiConsumer preamble, + Consumer end) { + + CriteriaDefinition current = criteria; + + // reverse unroll criteria chain + Map forwardChain = new HashMap<>(); + + Builder builder = CodeBlock.builder(); + + while (current.hasPrevious()) { + forwardChain.put(current.getPrevious(), current); + current = current.getPrevious(); + } + + preamble.accept(current, builder); + appendCriteria(current, builder); + + while ((current = forwardChain.get(current)) != null) { + + if (current.isEmpty()) { + continue; + } + + if (current.isGroup()) { + + String suffix; + + builder.add(".$L(", current.getCombinator().name().toLowerCase(Locale.ROOT)); + + if (current.getGroup().size() == 1) { + suffix = ")"; + + } else { + suffix = "))"; + builder.add("$T.of(", List.class); + } + + for (CriteriaDefinition nested : current.getGroup()) { + + builder.add(buildCriteria(nested, (criteriaDefinition, start) -> start.add("$T.where($S)", Criteria.class, + criteriaDefinition.getColumn().getReference()), b -> {})); + } + + builder.add(suffix); + continue; + } + + builder.add(".$L($S)", builder.add(".$L(", current.getCombinator().name().toLowerCase(Locale.ROOT)), + current.getColumn().getReference()); + + appendCriteria(current, builder); + } + + end.accept(builder); + + return builder.build(); + } + + private void appendCriteria(CriteriaDefinition current, Builder builder) { + + Object value = current.getValue(); + + switch (current.getComparator()) { + case INITIAL -> {} + case EQ -> builder.add(".is($L)", renderPlaceholder(value)); + case NEQ -> builder.add(".not($L)", renderPlaceholder(value)); + case BETWEEN -> builder.add(".between($L, $L)", renderPlaceholder(value, 0), renderPlaceholder(value, 1)); + case NOT_BETWEEN -> + builder.add(".notBetween($L, $L)", renderPlaceholder(value, 0), renderPlaceholder(value, 1)); + case LT -> builder.add(".lessThan($L)", renderPlaceholder(value)); + case LTE -> builder.add(".lessThanOrEquals($L)", renderPlaceholder(value)); + case GT -> builder.add(".greaterThan($L)", renderPlaceholder(value)); + case GTE -> builder.add(".greaterThanEquals($L)", renderPlaceholder(value)); + case IS_NULL -> builder.add(".isNull()"); + case IS_NOT_NULL -> builder.add(".isNotNull()"); + case LIKE -> applyLike(builder, "like", value); + case NOT_LIKE -> applyLike(builder, "notLike", value); + case NOT_IN -> builder.add(".notIn($L)", renderPlaceholder(value)); + case IN -> builder.add(".in($L)", renderPlaceholder(value)); + case IS_TRUE -> builder.add(".isTrue()"); + case IS_FALSE -> builder.add(".isFalse()"); + } + + if (current.isIgnoreCase()) { + builder.addStatement(".ignoreCase(true)"); + } + } + + private void applyLike(Builder builder, String method, @Nullable Object value) { + + PlaceholderAccessor.CapturingJdbcValue captured = PlaceholderAccessor.unwrap(value); + + String likeValue = "$L"; + if (captured.getBinding() instanceof ParameterBinding.LikeParameterBinding lpb) { + + if (lpb.getType() == Part.Type.CONTAINING) { + likeValue = "\"%\" + escape($L) + \"%\""; + } else if (lpb.getType() == Part.Type.STARTING_WITH) { + likeValue = "escape($L) + \"%\""; + } else if (lpb.getType() == Part.Type.ENDING_WITH) { + likeValue = "\"%\" + escape($L)"; + } + } + + builder.add(".$L(" + likeValue + ")", method, renderPlaceholder(value)); + } + + private @Nullable String renderPlaceholder(@Nullable Object value) { + + PlaceholderAccessor.CapturingJdbcValue captured = PlaceholderAccessor.unwrap(value); + + ParameterBinding binding = captured.getBinding(); + ParameterBinding.MethodInvocationArgument argument = (ParameterBinding.MethodInvocationArgument) binding + .getOrigin(); + ParameterBinding.BindingIdentifier identifier = argument.identifier(); + + return identifier.hasName() ? identifier.getName() : context.getParameterName(identifier.getPosition()); + } + + private Object renderPlaceholder(@Nullable Object value, int index) { + return renderPlaceholder(index == 0 ? ((Pair) value).getFirst() : ((Pair) value).getSecond()); + } + + private CodeBlock createStringQuery(String queryVariableName, String parameterSourceName, StringAotQuery query) { + + Builder builder = CodeBlock.builder(); + + builder.addStatement("$T $L = $S", String.class, queryVariableName, query.getQueryString()); + builder.addStatement("$1T $2L = new $1T()", MapSqlParameterSource.class, parameterSourceName); + + for (ParameterBinding binding : query.getParameterBindings()) { + + String parameterIdentifier = getParameterName(binding.getIdentifier()); + builder.add(bindValue(parameterIdentifier, binding.getOrigin())); + } + + return builder.build(); + } + + private String getParameterName(ParameterBinding.BindingIdentifier identifier) { + return identifier.hasName() ? identifier.getName() : Integer.toString(identifier.getPosition()); + } + + private CodeBlock bindValue(String parameterName, ParameterBinding.ParameterOrigin origin) { + + if (origin.isMethodArgument() && origin instanceof ParameterBinding.MethodInvocationArgument mia) { + + String parameterReference; + String identifier; + + if (mia.identifier().hasPosition()) { + parameterReference = context.getRequiredBindableParameterName(mia.identifier().getPosition() - 1); + identifier = Integer.toString(mia.identifier().getPosition() - 1); + } else { + parameterReference = context.getRequiredBindableParameterName(mia.identifier().getName()); + identifier = mia.identifier().getName(); + } + + Builder builder = CodeBlock.builder(); + builder.addStatement("getBindableValue($L, $L, $L).bind($S, $L)", + context.getExpressionMarker().enclosingMethod(), parameterReference, identifier, parameterName, + parameterSourceVariableName); + return builder.build(); + } + + if (origin.isExpression() && origin instanceof ParameterBinding.Expression expr) { + + Builder builder = CodeBlock.builder(); + + String expressionString = expr.expression().getExpressionString(); + // re-wrap expression + if (!expressionString.startsWith("$")) { + expressionString = "#{" + expressionString + "}"; + } + + builder.addStatement("evaluate($L, $S$L).bind($S, $L)", context.getExpressionMarker().enclosingMethod(), + expressionString, parameterNames, parameterName, parameterSourceVariableName); + return builder.build(); + } + + throw new UnsupportedOperationException("Not supported yet for: " + origin); + } + + } + + static class QueryExecutionBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final JdbcQueryMethod queryMethod; + private @Nullable AotQuery aotQuery; + private String queryVariableName = "undefined"; + private String parameterSourceVariableName = "undefined"; + private @Nullable String rowMapperRef; + private @Nullable Class rowMapperClass; + private @Nullable String resultSetExtractorRef; + private @Nullable Class resultSetExtractorClass; + private MergedAnnotation modifying = MergedAnnotation.missing(); + + private QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, JdbcQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + public QueryExecutionBlockBuilder queryAnnotation(MergedAnnotation query) { + + if (query.isPresent()) { + rowMapper(query.getClass("rowMapperClass")); + rowMapper(query.getString("rowMapperRef")); + resultSetExtractor(query.getClass("resultSetExtractorClass")); + resultSetExtractor(query.getString("resultSetExtractorRef")); + } + + return this; + } + + public QueryExecutionBlockBuilder rowMapper(String rowMapperRef) { + this.rowMapperRef = rowMapperRef; + return this; + } + + public QueryExecutionBlockBuilder rowMapper(Class rowMapperClass) { + this.rowMapperClass = rowMapperClass == RowMapper.class ? null : rowMapperClass; + return this; + } + + public QueryExecutionBlockBuilder resultSetExtractor(String resultSetExtractorRef) { + this.resultSetExtractorRef = resultSetExtractorRef; + return this; + } + + public QueryExecutionBlockBuilder resultSetExtractor(Class resultSetExtractorClass) { + this.resultSetExtractorClass = resultSetExtractorClass == ResultSetExtractor.class ? null + : resultSetExtractorClass; + return this; + } + + public QueryExecutionBlockBuilder usingQueryVariableName(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + public QueryExecutionBlockBuilder parameterSource(String parameterSourceVariableName) { + + this.parameterSourceVariableName = parameterSourceVariableName; + return this; + } + + public QueryExecutionBlockBuilder queries(AotQueries aotQueries) { + + this.aotQuery = aotQueries.result(); + return this; + } + + public QueryExecutionBlockBuilder modifying(MergedAnnotation modifying) { + + this.modifying = modifying; + return this; + } + + public CodeBlock build() { + + Assert.state(aotQuery != null, "AOT Query must not be null"); + + Builder builder = CodeBlock.builder(); + + boolean isProjecting = !ObjectUtils.nullSafeEquals( + TypeName.get(context.getRepositoryInformation().getDomainType()), context.getActualReturnType()); + Type actualReturnType = isProjecting ? context.getActualReturnType().getType() + : context.getRepositoryInformation().getDomainType(); + builder.add("\n"); + + Class returnType = context.getMethod().getReturnType(); + TypeName queryResultType = TypeName.get(context.getActualReturnType().toClass()); + String result = context.localVariable("result"); + String rowMapper = context.localVariable("rowMapper"); + + if (modifying.isPresent()) { + return update(builder, returnType); + } else if (aotQuery.isCount()) { + return count(builder, result, returnType, queryResultType); + } else if (aotQuery.isExists()) { + return exists(builder, queryResultType); + } else if (aotQuery.isDelete()) { + return delete(builder, rowMapper, result, queryResultType, returnType, actualReturnType); + } else { + + String resultSetExtractor = null; + + if (rowMapperClass != null) { + builder.addStatement("$T $L = new $T()", RowMapper.class, rowMapper, + rowMapperClass); + } else if (StringUtils.hasText(rowMapperRef)) { + builder.addStatement("$T $L = getRowMapperFactory().getRowMapper($S)", RowMapper.class, rowMapper, + rowMapperRef); + } else if (resultSetExtractorClass == null) { + + Type typeToRead; + + if (isProjecting) { + typeToRead = context.getReturnedType().getDomainType(); + } else { + typeToRead = context.getActualReturnType().getType(); + } + + builder.addStatement("$T $L = getRowMapperFactory().create($T.class)", RowMapper.class, rowMapper, + typeToRead); + } + + if (StringUtils.hasText(resultSetExtractorRef) || resultSetExtractorClass != null) { + + resultSetExtractor = context.localVariable("resultSetExtractor"); + + if (resultSetExtractorClass != null && (rowMapperClass != null || StringUtils.hasText(rowMapperRef))) { + builder.addStatement("$T $L = new $T($L)", ResultSetExtractor.class, resultSetExtractor, + resultSetExtractorClass, rowMapper); + } else if (resultSetExtractorClass != null) { + builder.addStatement("$T $L = new $T()", ResultSetExtractor.class, resultSetExtractor, + resultSetExtractorClass); + } else if (StringUtils.hasText(resultSetExtractorRef)) { + builder.addStatement("$T $L = getRowMapperFactory().getResultSetExtractor($S)", ResultSetExtractor.class, + resultSetExtractor, resultSetExtractorRef); + } + } + + if (StringUtils.hasText(resultSetExtractor)) { + + builder.addStatement("return ($T) getJdbcOperations().query($L, $L, $L)", queryResultType, queryVariableName, + parameterSourceVariableName, resultSetExtractor); + + return builder.build(); + } + + boolean dynamicProjection = StringUtils.hasText(context.getDynamicProjectionParameterName()); + Object queryResultTypeRef = dynamicProjection ? context.getDynamicProjectionParameterName() : queryResultType; + + if (queryMethod.isCollectionQuery() || queryMethod.isSliceQuery() || queryMethod.isPageQuery()) { + + builder.addStatement("$1T $2L = ($1T) getJdbcOperations().query($3L, $4L, new $5T<>($6L))", List.class, + result, queryVariableName, parameterSourceVariableName, RowMapperResultSetExtractor.class, rowMapper); + + if (queryMethod.isSliceQuery() || queryMethod.isPageQuery()) { + + String pageable = context.getPageableParameterName(); + + builder.addStatement( + "$1T $2L = ($1T) convertMany($3L, %s)".formatted(dynamicProjection ? "$4L" : "$4T.class"), List.class, + context.localVariable("converted"), result, queryResultTypeRef); + + if (queryMethod.isPageQuery()) { + + builder.addStatement("return $1T.getPage($2L, $3L, $4L)", PageableExecutionUtils.class, + context.localVariable("converted"), pageable, context.localVariable("countAll")); + } else { + + builder.addStatement("boolean $1L = $2L.isPaged() && $3L.size() > $2L.getPageSize()", + context.localVariable("hasNext"), pageable, context.localVariable("converted")); + + builder.addStatement("return new $1T($2L ? $3L.subList(0, $4L.getPageSize()) : $3L, $4L, $2L)", + SliceImpl.class, context.localVariable("hasNext"), context.localVariable("converted"), pageable); + } + + return builder.build(); + } + + builder.addStatement("return ($T) convertMany($L, %s)".formatted(dynamicProjection ? "$L" : "$T.class"), + context.getReturnTypeName(), result, queryResultTypeRef); + } else if (queryMethod.isStreamQuery()) { + + builder.addStatement("$1T $2L = getJdbcOperations().queryForStream($3L, $4L, $5L)", Stream.class, result, + queryVariableName, parameterSourceVariableName, rowMapper); + builder.addStatement("return ($T) convertMany($L, $T.class)", context.getReturnTypeName(), result, + queryResultTypeRef); + } else { + + builder.addStatement("$T $L = queryForObject($L, $L, $L)", Object.class, result, queryVariableName, + parameterSourceVariableName, rowMapper); + + if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) { + builder.addStatement( + "return ($1T) $1T.ofNullable(convertOne($2L, %s))".formatted(dynamicProjection ? "$3L" : "$3T.class"), + Optional.class, result, queryResultTypeRef); + } else { + builder.addStatement("return ($T) convertOne($L, %s)".formatted(dynamicProjection ? "$L" : "$T.class"), + context.getReturnTypeName(), result, queryResultTypeRef); + } + } + } + + return builder.build(); + } + + private CodeBlock update(Builder builder, Class returnType) { + + String result = context.localVariable("result"); + + if (returnType == Void.TYPE || returnType == Void.class) { + + builder.addStatement("getJdbcOperations().update($L, $L)", queryVariableName, parameterSourceVariableName); + + if (returnType == Void.class) { + builder.addStatement("return null"); + } + + return builder.build(); + } + + builder.addStatement("int $L = getJdbcOperations().update($L, $L)", result, queryVariableName, + parameterSourceVariableName); + + if (returnType == boolean.class || returnType == Boolean.class) { + builder.addStatement("return $L != 0", result); + } else if (returnType == Long.class) { + builder.addStatement("return (long) $L", result); + } else { + builder.addStatement("return $L", result); + } + + return builder.build(); + } + + private CodeBlock delete(Builder builder, String rowMapper, String result, TypeName queryResultType, + Class returnType, Type actualReturnType) { + + 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, + List.class, queryVariableName, parameterSourceVariableName, RowMapperResultSetExtractor.class, rowMapper); + + builder.addStatement("$L.forEach(getOperations()::delete)", result); + + if (Collection.class.isAssignableFrom(context.getReturnType().toClass())) { + builder.addStatement("return ($T) convertMany($L, $T.class)", context.getReturnTypeName(), result, + queryResultType); + } else if (returnType == context.getRepositoryInformation().getDomainType()) { + builder.addStatement("return ($1T) ($2L.isEmpty() ? null : $2L.iterator().next())", actualReturnType, result); + } else if (returnType == boolean.class || returnType == Boolean.class) { + builder.addStatement("return !$L.isEmpty()", result); + } else if (returnType == Long.class) { + builder.addStatement("return (long) $L.size()", result); + } else { + builder.addStatement("return $L.size()", result); + } + + return builder.build(); + } + + private CodeBlock count(Builder builder, String result, Class returnType, TypeName queryResultType) { + + builder.addStatement("$1T $2L = queryForObject($3L, $4L, new $5T<>($1T.class))", Number.class, result, + queryVariableName, parameterSourceVariableName, SingleColumnRowMapper.class); + + if (returnType == Long.class) { + builder.addStatement("return $1L != null ? $1L.longValue() : null", result); + } else if (returnType == Integer.class) { + builder.addStatement("return $1L != null ? $1L.intValue() : null", result); + } else if (returnType == Long.TYPE) { + builder.addStatement("return $1L != null ? $1L.longValue() : 0L", result); + } else if (returnType == Integer.TYPE) { + builder.addStatement("return $1L != null ? $1L.intValue() : 0", result); + } else { + builder.addStatement("return ($T) convertOne($L, $T.class)", context.getReturnTypeName(), result, + queryResultType); + } + + return builder.build(); + } + + private CodeBlock exists(Builder builder, TypeName queryResultType) { + + builder.addStatement("return ($T) getJdbcOperations().query($L, $L, $T::next)", queryResultType, + queryVariableName, parameterSourceVariableName, ResultSet.class); + + return builder.build(); + } + + public static boolean returnsModifying(Class returnType) { + + return returnType == int.class || returnType == long.class || returnType == Integer.class + || returnType == Long.class; + } + + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributor.java new file mode 100644 index 0000000000..ba780fdf4a --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributor.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.config.RuntimeBeanReference; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; +import org.springframework.data.jdbc.repository.query.Modifying; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.jdbc.repository.query.RowMapperFactory; +import org.springframework.data.jdbc.repository.support.BeanFactoryAwareRowMapperFactory; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.repository.Lock; +import org.springframework.data.repository.aot.generate.AotRepositoryClassBuilder; +import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; +import org.springframework.data.repository.aot.generate.MethodContributor; +import org.springframework.data.repository.aot.generate.RepositoryContributor; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.data.util.TypeInformation; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.TypeName; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * JDBC-specific {@link RepositoryContributor} contributing an AOT repository fragment. + * + * @author Mark Paluch + * @since 4.0 + */ +public class JdbcRepositoryContributor extends RepositoryContributor { + + private final RelationalMappingContext mappingContext; + private final QueriesFactory queriesFactory; + private final @Nullable String jdbcAggregateOperationsRef; + + public JdbcRepositoryContributor(AotRepositoryContext repositoryContext, JdbcDialect dialect, + JdbcConverter converter) { + + super(repositoryContext); + + this.mappingContext = converter.getMappingContext(); + + RepositoryConfigurationSource configurationSource = repositoryContext.getConfigurationSource(); + + this.queriesFactory = new QueriesFactory(configurationSource, converter, dialect, + repositoryContext.getRequiredClassLoader(), ValueExpressionDelegate.create()); + + jdbcAggregateOperationsRef = configurationSource.getAttribute("jdbcAggregateOperationsRef").orElse(null); + } + + @Override + protected void customizeClass(AotRepositoryClassBuilder classBuilder) { + classBuilder.customize(builder -> builder.superclass(TypeName.get(AotRepositoryFragmentSupport.class))); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + constructorBuilder.addParameter("operations", JdbcAggregateOperations.class, customizer -> { + + customizer.origin(StringUtils.hasText(jdbcAggregateOperationsRef) + ? new RuntimeBeanReference(jdbcAggregateOperationsRef, JdbcAggregateOperations.class) + : new RuntimeBeanReference(JdbcAggregateOperations.class)); + }); + + constructorBuilder.addParameter("rowMapperFactory", RowMapperFactory.class, customizer -> { + + customizer.origin(ctx -> { + + String rowMapperFactory = ctx.localVariable("rowMapperFactory"); + String operations = ctx.localVariable("operations"); + CodeBlock.Builder builder = CodeBlock.builder(); + builder.addStatement("$1T $2L = new $1T($4L, $3L, $4L.getBeanProvider($5T.class).getIfUnique(() -> $5T.EMPTY))", + BeanFactoryAwareRowMapperFactory.class, rowMapperFactory, operations, ctx.beanFactory(), + QueryMappingConfiguration.class); + + return AotRepositoryConstructorBuilder.ParameterOrigin.of(rowMapperFactory, builder.build()); + }); + }); + + constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class, false); + } + + @Override + protected @Nullable MethodContributor contributeQueryMethod(Method method) { + + JdbcQueryMethod queryMethod = new JdbcQueryMethod(method, getRepositoryInformation(), getProjectionFactory(), + queriesFactory.getNamedQueries(), mappingContext); + + ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType(); + + MergedAnnotation query = MergedAnnotations.from(method).get(Query.class); + + AotQueries aotQueries = queriesFactory.createQueries(getRepositoryInformation(), returnedType, query, queryMethod); + + if (queryMethod.isModifyingQuery()) { + + TypeInformation returnType = getRepositoryInformation().getReturnType(method); + + boolean returnsCount = JdbcCodeBlocks.QueryExecutionBlockBuilder.returnsModifying(returnType.getType()); + boolean isVoid = ClassUtils.isVoidType(returnType.getType()); + + if (!returnsCount && !isVoid) { + return MethodContributor.forQueryMethod(queryMethod).metadataOnly(aotQueries.toMetadata()); + } + } + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(aotQueries.toMetadata()).contribute(context -> { + + CodeBlock.Builder body = CodeBlock.builder(); + + MergedAnnotation modifying = context.getAnnotation(Modifying.class); + MergedAnnotation lock = context.getAnnotation(Lock.class); + + String queryVariable = context.localVariable("query"); + String parameterSourceVariable = context.localVariable("parameterSource"); + + body.add(JdbcCodeBlocks.queryBuilder(context, queryMethod).filter(aotQueries) + .usingQueryVariableName(queryVariable).parameterSource(parameterSourceVariable).lock(lock).build()); + + body.add(JdbcCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying) + .usingQueryVariableName(queryVariable).parameterSource(parameterSourceVariable).queries(aotQueries) + .queryAnnotation(query).build()); + + return body.build(); + }); + + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/PlaceholderAccessor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/PlaceholderAccessor.java new file mode 100644 index 0000000000..f30b4a9f1e --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/PlaceholderAccessor.java @@ -0,0 +1,217 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.sql.JDBCType; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.mapping.JdbcValue; +import org.springframework.data.jdbc.repository.query.JdbcParameters; +import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; +import org.springframework.data.jdbc.repository.query.ParameterBinding; +import org.springframework.data.relational.repository.query.ParameterMetadataProvider; +import org.springframework.data.relational.repository.query.RelationalParameterAccessor; +import org.springframework.data.relational.repository.query.RelationalParameters; +import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.util.Assert; + +/** + * Utility to access placeholders in AOT processing. + * + * @author Mark Paluch + * @since 4.0 + */ +class PlaceholderAccessor { + + /** + * Create a new {@link CapturingJdbcValue} with the given binding. + * + * @param binding + * @return + */ + public static CapturingJdbcValue boundParameter(ParameterBinding binding) { + return boundParameter(null, binding); + } + + /** + * Create a new {@link CapturingJdbcValue} with the given value and binding. + * + * @param value + * @param binding + * @return + */ + public static CapturingJdbcValue boundParameter(@Nullable Object value, ParameterBinding binding) { + return new CapturingJdbcValue(value, binding); + } + + /** + * Unwrap a {@link CapturingJdbcValue} from the given value. + * + * @param value + * @return + */ + public static CapturingJdbcValue unwrap(@Nullable Object value) { + + if (!(value instanceof CapturingJdbcValue) && value instanceof JdbcValue jv) { + value = jv.getValue(); + } + + if (value instanceof CapturingJdbcValue cp) { + return cp; + } + + throw new IllegalArgumentException("Cannot unwrap value: '%s' to CapturingJdbcValue".formatted(value)); + } + + /** + * Create a {@link ParameterMetadataProvider} that enhances {@link ParameterBinding} based on + * {@link org.springframework.data.repository.query.parser.PartTree} details for each parameter. + * + * @param accessor + * @return + */ + public static ParameterMetadataProvider metadata(RelationalParameterAccessor accessor) { + return new CapturingParameterMetadataProvider(accessor); + } + + /** + * Create a {@link RelationalParametersParameterAccessor} that captures the {@link ParameterBinding} for bindable + * parameters. + * + * @param queryMethod + * @param parameterValues + * @param parameters + * @param bindable + * @return + */ + public static RelationalParametersParameterAccessor capture(JdbcQueryMethod queryMethod, Object[] parameterValues, + JdbcParameters parameters, RelationalParameters bindable) { + return new CapturingParameterAccessor(queryMethod, parameterValues, parameters, bindable); + } + + /** + * Extension to {@link ParameterMetadataProvider} that captures the {@link ParameterBinding} for each parameter along + * with its post-processed value. + * + * @author Mark Paluch + * @since 4.0 + */ + static class CapturingParameterMetadataProvider extends ParameterMetadataProvider { + + public CapturingParameterMetadataProvider(RelationalParameterAccessor accessor) { + super(accessor); + } + + @Nullable + @Override + protected Object prepareParameterValue(@Nullable Object value, Class valueType, Part.Type partType) { + + // apply double-wrapping as JdbcConverter unwraps JdbcValue; We don't want our placeholders to be converted. + CapturingJdbcValue capturingJdbcValue = (CapturingJdbcValue) value; + + if (partType == Part.Type.STARTING_WITH || partType == Part.Type.ENDING_WITH || partType == Part.Type.CONTAINING + || partType == Part.Type.NOT_CONTAINING) { + return JdbcValue.of( + capturingJdbcValue.withBinding(ParameterBinding.like(capturingJdbcValue.getBinding(), partType)), + JDBCType.OTHER); + } + + return JdbcValue.of(capturingJdbcValue.withValue(super.prepareParameterValue(value, valueType, partType)), + JDBCType.OTHER); + } + + } + + /** + * {@link JdbcValue} that captures the {@link ParameterBinding} along with the value. + */ + static class CapturingJdbcValue extends JdbcValue { + + private final ParameterBinding binding; + + private CapturingJdbcValue(@Nullable Object value, ParameterBinding binding) { + super(value, null); + Assert.notNull(binding, "Parameter binding must not be null"); + this.binding = binding; + } + + public ParameterBinding getBinding() { + return binding; + } + + public CapturingJdbcValue withValue(@Nullable Object value) { + + if (value == this) { + return this; + } + + return new CapturingJdbcValue(value, binding); + } + + public CapturingJdbcValue withBinding(ParameterBinding binding) { + return new CapturingJdbcValue(getValue(), binding); + } + } + + static class CapturingParameterAccessor extends RelationalParametersParameterAccessor { + + private final JdbcParameters parameters; + private final RelationalParameters bindable; + + public CapturingParameterAccessor(JdbcQueryMethod queryMethod, Object[] parameterValues, JdbcParameters parameters, + RelationalParameters bindable) { + super(queryMethod, parameterValues); + this.parameters = parameters; + this.bindable = bindable; + } + + @Override + public Sort getSort() { + return Sort.unsorted(); + } + + @Override + public Pageable getPageable() { + return Pageable.unpaged(); + } + + @Override + public Object[] getValues() { + return super.getValues(); + } + + @Override + protected @Nullable T getValue(int index) { + return (T) capture(parameters.getParameter(index)); + } + + @Override + public @Nullable Object getBindableValue(int index) { + return capture(bindable.getParameter(index)); + } + + private CapturingJdbcValue capture(RelationalParameters.RelationalParameter parameter) { + return boundParameter(ParameterBinding.named(parameter.getRequiredName(), + ParameterBinding.ParameterOrigin.ofParameter(parameter.getIndex()))); + } + + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java new file mode 100644 index 0000000000..a4f8f488e9 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/QueriesFactory.java @@ -0,0 +1,249 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Properties; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.jdbc.repository.config.JdbcRepositoryConfigExtension; +import org.springframework.data.jdbc.repository.query.JdbcCountQueryCreator; +import org.springframework.data.jdbc.repository.query.JdbcParameters; +import org.springframework.data.jdbc.repository.query.JdbcQueryCreator; +import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; +import org.springframework.data.jdbc.repository.query.ParameterBinding; +import org.springframework.data.jdbc.repository.query.ParametrizedQuery; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.relational.repository.query.ParameterMetadataProvider; +import org.springframework.data.relational.repository.query.RelationalParameterAccessor; +import org.springframework.data.relational.repository.query.RelationalParameters; +import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.config.PropertiesBasedNamedQueriesFactoryBean; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.data.repository.query.ValueExpressionQueryRewriter; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.util.StringUtils; + +/** + * Factory for {@link AotQueries}. + * + * @author Mark Paluch + * @since 4.0 + */ +class QueriesFactory { + + private final JdbcConverter converter; + private final JdbcDialect dialect; + private final NamedQueries namedQueries; + private final ValueExpressionDelegate delegate; + + public QueriesFactory(RepositoryConfigurationSource configurationSource, JdbcConverter converter, JdbcDialect dialect, + ClassLoader classLoader, ValueExpressionDelegate delegate) { + + this.namedQueries = getNamedQueries(configurationSource, classLoader); + this.converter = converter; + this.dialect = dialect; + this.delegate = delegate; + } + + public NamedQueries getNamedQueries() { + return namedQueries; + } + + private NamedQueries getNamedQueries(@Nullable RepositoryConfigurationSource configSource, ClassLoader classLoader) { + + String location = configSource != null ? configSource.getNamedQueryLocation().orElse(null) : null; + + if (location == null) { + location = new JdbcRepositoryConfigExtension().getDefaultNamedQueryLocation(); + } + + if (StringUtils.hasText(location)) { + + try { + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader); + + PropertiesBasedNamedQueriesFactoryBean factoryBean = new PropertiesBasedNamedQueriesFactoryBean(); + factoryBean.setLocations(resolver.getResources(location)); + factoryBean.afterPropertiesSet(); + return Objects.requireNonNull(factoryBean.getObject()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return new PropertiesBasedNamedQueries(new Properties()); + } + + /** + * Creates the {@link AotQueries} used within a specific {@link JdbcQueryMethod}. + * + * @param repositoryInformation + * @param returnedType + * @param query + * @param queryMethod + * @return + */ + public AotQueries createQueries(RepositoryInformation repositoryInformation, ReturnedType returnedType, + MergedAnnotation query, JdbcQueryMethod queryMethod) { + + if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { + return buildStringQuery(query.getString("value"), queryMethod); + } + + String queryName = queryMethod.getNamedQueryName(); + if (hasNamedQuery(queryName)) { + return buildNamedQuery(queryName, queryMethod); + } + + return buildPartTreeQuery(repositoryInformation, returnedType, queryMethod); + } + + private boolean hasNamedQuery(String queryName) { + return namedQueries.hasQuery(queryName); + } + + private AotQueries buildStringQuery(String queryString, JdbcQueryMethod queryMethod) { + + ValueExpressionQueryRewriter.ParsedQuery parsedQuery = parseQuery(queryString); + + List bindings = getBindings(parsedQuery, queryMethod); + StringAotQuery aotStringQuery = StringAotQuery.of(parsedQuery.getQueryString(), bindings); + + return AotQueries.create(aotStringQuery); + } + + private AotQueries buildNamedQuery(String queryName, JdbcQueryMethod queryMethod) { + + String queryString = namedQueries.getQuery(queryName); + ValueExpressionQueryRewriter.ParsedQuery parsedQuery = parseQuery(queryString); + + return AotQueries.create(StringAotQuery.named(queryName, queryString, getBindings(parsedQuery, queryMethod))); + } + + private AotQueries buildPartTreeQuery(RepositoryInformation repositoryInformation, ReturnedType returnedType, + JdbcQueryMethod queryMethod) { + + PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod); + + JdbcQueryCreator queryCreator = new JdbcQueryCreator(partTree, converter, dialect, queryMethod, accessor, + returnedType) { + + @Override + protected ParameterMetadataProvider getParameterMetadataProvider(RelationalParameterAccessor accessor) { + return new PlaceholderAccessor.CapturingParameterMetadataProvider(accessor); + } + }; + + ParametrizedQuery query = queryCreator.createQuery(Sort.unsorted()); + DerivedAotQuery aotQuery = new DerivedAotQuery(query, partTree, partTree.isCountProjection()); + + if (queryMethod.isPageQuery()) { + + JdbcQueryCreator countQueryCreator = new JdbcCountQueryCreator(partTree, converter, dialect, queryMethod, + accessor, returnedType) { + + @Override + protected ParameterMetadataProvider getParameterMetadataProvider(RelationalParameterAccessor accessor) { + return PlaceholderAccessor.metadata(accessor); + } + }; + + ParametrizedQuery countQuery = countQueryCreator.createQuery(Sort.unsorted()); + DerivedAotQuery aotCountQuery = new DerivedAotQuery(countQuery, partTree, true); + + return AotQueries.create(aotQuery, aotCountQuery); + } + + return AotQueries.create(aotQuery); + } + + private RelationalParametersParameterAccessor getAccessor(JdbcQueryMethod queryMethod) { + + JdbcParameters parameters = queryMethod.getParameters(); + Object[] parameterValues = new Object[parameters.getNumberOfParameters()]; + + RelationalParameters bindable = parameters.getBindableParameters(); + return PlaceholderAccessor.capture(queryMethod, parameterValues, parameters, bindable); + } + + public static @Nullable Class getQueryReturnType(AotQuery query, ReturnedType returnedType, + AotQueryMethodGenerationContext context) { + + Method method = context.getMethod(); + RepositoryInformation repositoryInformation = context.getRepositoryInformation(); + + Class methodReturnType = repositoryInformation.getReturnedDomainClass(method); + boolean queryForEntity = repositoryInformation.getDomainType().isAssignableFrom(methodReturnType); + + Class result = queryForEntity ? returnedType.getDomainType() : null; + + if (returnedType.isProjecting()) { + + if (returnedType.getReturnedType().isInterface()) { + + return result; + } + + return returnedType.getReturnedType(); + } + + return result; + } + + private ValueExpressionQueryRewriter.ParsedQuery parseQuery(String queryString) { + + ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(delegate, + (counter, expression) -> String.format("__$synthetic$__%d", counter + 1), String::concat); + + return rewriter.parse(queryString); + } + + private List getBindings(ValueExpressionQueryRewriter.ParsedQuery parsedQuery, + JdbcQueryMethod queryMethod) { + + List bindings = new ArrayList<>(); + + queryMethod.getParameters().getBindableParameters().forEach(parameter -> { + bindings.add(ParameterBinding.of(parameter)); + }); + + parsedQuery.getParameterMap().forEach((name, expression) -> { + bindings.add(ParameterBinding.named(name, ParameterBinding.ParameterOrigin.ofExpression(expression))); + }); + + return bindings; + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/StringAotQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/StringAotQuery.java new file mode 100644 index 0000000000..cd25bcd679 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/StringAotQuery.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.util.List; + +import org.springframework.data.jdbc.repository.query.ParameterBinding; + +/** + * An AOT query represented by a string. + * + * @author Mark Paluch + * @since 4.0 + */ +abstract class StringAotQuery extends AotQuery { + + StringAotQuery(List parameterBindings) { + super(parameterBindings); + } + + static StringAotQuery of(String query, List parameterBindings) { + return new DeclaredAotQuery(query, parameterBindings); + } + + static StringAotQuery named(String queryName, String query, List parameterBindings) { + return new NamedStringAotQuery(queryName, query, parameterBindings); + } + + public abstract String getQueryString(); + + @Override + public String toString() { + return getQueryString(); + } + + /** + * @author Christoph Strobl + * @author Mark Paluch + */ + private static class DeclaredAotQuery extends StringAotQuery { + + private final String query; + + DeclaredAotQuery(String query, List parameterBindings) { + super(parameterBindings); + this.query = query; + } + + @Override + public String getQueryString() { + return query; + } + + } + + static class NamedStringAotQuery extends DeclaredAotQuery { + + private final String queryName; + + NamedStringAotQuery(String queryName, String query, List parameterBindings) { + super(query, parameterBindings); + this.queryName = queryName; + } + + public String getQueryName() { + return queryName; + } + + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/package-info.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/package-info.java new file mode 100644 index 0000000000..b41a2f489f --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/package-info.java @@ -0,0 +1,5 @@ +/** + * Ahead-of-Time (AOT) generation for Spring Data JDBC repositories. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.data.jdbc.repository.aot; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/JdbcRepositoryConfigExtension.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/JdbcRepositoryConfigExtension.java index cd65bfdf5f..3525f68f07 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/JdbcRepositoryConfigExtension.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/JdbcRepositoryConfigExtension.java @@ -21,17 +21,27 @@ import java.util.Locale; import java.util.Optional; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.config.RuntimeBeanReference; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.BeanDefinitionValidationException; import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.repository.aot.JdbcRepositoryContributor; import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactoryBean; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; +import org.springframework.data.util.TypeContributor; import org.springframework.util.StringUtils; /** @@ -109,6 +119,11 @@ public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSo } } + @Override + public Class getRepositoryAotProcessor() { + return JdbcRepositoryRegistrationAotProcessor.class; + } + /** * In strict mode only domain types having a {@link Table} annotation get a repository. */ @@ -116,4 +131,36 @@ public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSo protected Collection> getIdentifyingAnnotations() { return Collections.singleton(Table.class); } + + /** + * A {@link RepositoryRegistrationAotProcessor} implementation that maintains aot repository setup. + * + * @since 3.0 + */ + public static class JdbcRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor { + + private static final String MODULE_NAME = "jdbc"; + + protected @Nullable JdbcRepositoryContributor contribute(AotRepositoryContext repositoryContext, + GenerationContext generationContext) { + + // do some custom type registration here + super.contribute(repositoryContext, generationContext); + + repositoryContext.getResolvedTypes().stream().forEach(type -> { + TypeContributor.contribute(type, it -> true, generationContext); + }); + + if (!repositoryContext.isGeneratedRepositoriesEnabled(MODULE_NAME)) { + return null; + } + + ConfigurableListableBeanFactory beanFactory = repositoryContext.getBeanFactory(); + JdbcDialect dialect = beanFactory.getBean(JdbcDialect.class); + JdbcConverter converter = beanFactory.getBean(JdbcConverter.class); + + return new JdbcRepositoryContributor(repositoryContext, dialect, converter); + } + + } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/AbstractJdbcQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/AbstractJdbcQuery.java index 0ef27d3f63..dc7097ccef 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/AbstractJdbcQuery.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/AbstractJdbcQuery.java @@ -19,7 +19,6 @@ import java.util.function.Supplier; import java.util.stream.Stream; -import org.springframework.core.convert.converter.Converter; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; @@ -147,29 +146,5 @@ private JdbcQueryExecution createSingleReadingQueryExecution(ResultSetExt return (query, parameters) -> operations.query(query, parameters, resultSetExtractor); } - /** - * Factory to create a {@link RowMapper} for a given class. - * - * @since 2.3 - * @deprecated Use {@link org.springframework.data.jdbc.repository.query.RowMapperFactory} instead - */ - @Deprecated(forRemoval = true, since = "3.4.4") - public interface RowMapperFactory extends org.springframework.data.jdbc.repository.query.RowMapperFactory {} - /** - * Delegating {@link RowMapper} that reads a row into {@code T} and converts it afterwards into {@code Object}. - * - * @param - * @since 2.3 - * @deprecated use {@link org.springframework.data.jdbc.repository.query.ConvertingRowMapper} instead - */ - @Deprecated(forRemoval = true, since = "3.4.4") - protected static class ConvertingRowMapper - extends org.springframework.data.jdbc.repository.query.ConvertingRowMapper { - - @SuppressWarnings("unchecked") - public ConvertingRowMapper(RowMapper delegate, Converter converter) { - super((RowMapper) delegate, converter); - } - } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/CallbackCapableRowMapper.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/CallbackCapableRowMapper.java deleted file mode 100644 index 3b43091ff9..0000000000 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/CallbackCapableRowMapper.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2025 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 - * - * https://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 org.springframework.data.jdbc.repository.query; - -import java.sql.ResultSet; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.mapping.callback.EntityCallbacks; -import org.springframework.data.relational.core.mapping.event.AfterConvertCallback; -import org.springframework.data.relational.core.mapping.event.AfterConvertEvent; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.lang.Nullable; - -/** - * Delegating {@link RowMapper} implementation that applies post-processing logic after the - * {@link RowMapper#mapRow(ResultSet, int)}. In particular, it emits the {@link AfterConvertEvent} event and invokes the - * {@link AfterConvertCallback} callbacks. - * - * @author Mark Paluch - * @author Mikhail Polivakha - * @since 4.0 - */ -public class CallbackCapableRowMapper extends AbstractDelegatingRowMapper { - - private final ApplicationEventPublisher publisher; - private final @Nullable EntityCallbacks callbacks; - - public CallbackCapableRowMapper(RowMapper delegate, ApplicationEventPublisher publisher, - @Nullable EntityCallbacks callbacks) { - - super(delegate); - - this.publisher = publisher; - this.callbacks = callbacks; - } - - @Override - @Nullable - public T postProcessMapping(@Nullable T object) { - - if (object != null) { - - publisher.publishEvent(new AfterConvertEvent<>(object)); - - if (callbacks != null) { - return callbacks.callback(AfterConvertCallback.class, object); - } - - } - return object; - } -} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ConvertingRowMapper.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ConvertingRowMapper.java index 0efc660e0c..e8cc20a6ba 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ConvertingRowMapper.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ConvertingRowMapper.java @@ -26,7 +26,7 @@ * @author Mikhail Polivakha * @since 4.0 */ -public class ConvertingRowMapper extends AbstractDelegatingRowMapper { +class ConvertingRowMapper extends AbstractDelegatingRowMapper { private final Converter converter; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/EscapingParameterSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/EscapingParameterSource.java index b8f4031556..945413276c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/EscapingParameterSource.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/EscapingParameterSource.java @@ -26,7 +26,7 @@ * @author Jens Schauder * @since 3.2 */ -class EscapingParameterSource implements SqlParameterSource { +public class EscapingParameterSource implements SqlParameterSource { private final SqlParameterSource parameterSource; private final Escaper escaper; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java index 5995352b5f..5b716d1392 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java @@ -15,24 +15,19 @@ */ package org.springframework.data.jdbc.repository.query; +import java.util.Optional; + import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; -import org.springframework.data.relational.core.sql.Expressions; -import org.springframework.data.relational.core.sql.Functions; -import org.springframework.data.relational.core.sql.Select; -import org.springframework.data.relational.core.sql.SelectBuilder; -import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.repository.Lock; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.parser.PartTree; -import java.util.Optional; - /** * {@link JdbcQueryCreator} that creates {@code COUNT(*)} queries without applying limit/offset and {@link Sort}. * @@ -40,7 +35,12 @@ * @author Diego Krupitza * @since 2.2 */ -class JdbcCountQueryCreator extends JdbcQueryCreator { +public class JdbcCountQueryCreator extends JdbcQueryCreator { + + public JdbcCountQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect, JdbcQueryMethod queryMethod, + RelationalParameterAccessor accessor, ReturnedType returnedType) { + super(tree, converter, dialect, queryMethod, accessor, returnedType); + } JdbcCountQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, @@ -49,18 +49,7 @@ class JdbcCountQueryCreator extends JdbcQueryCreator { } @Override - SelectBuilder.SelectOrdered applyOrderBy(Sort sort, RelationalPersistentEntity entity, Table table, - SelectBuilder.SelectOrdered selectOrdered) { - return selectOrdered; - } - - @Override - SelectBuilder.SelectWhere applyLimitAndOffset(SelectBuilder.SelectLimitOffset limitOffsetBuilder) { - return (SelectBuilder.SelectWhere) limitOffsetBuilder; - } - - @Override - SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity entity, Table table) { - return Select.builder().select(Functions.count(Expressions.asterisk())).from(table); + StatementFactory.Selection getSelection(RelationalPersistentEntity entity) { + return getStatementFactory().count(entity); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java index d3968b18a0..fd1e5e86ba 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java @@ -122,7 +122,7 @@ protected List complete(@Nullable Criteria criteria, Sort sor List queries = new ArrayList<>(deleteChain.size()); for (Delete d : deleteChain) { - queries.add(new ParametrizedQuery(renderer.render(d), parameterSource)); + queries.add(new ParametrizedQuery(renderer.render(d), parameterSource, criteria)); } return queries; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcParameters.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcParameters.java index ddfb1e7431..227b0e92c4 100755 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcParameters.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcParameters.java @@ -17,6 +17,7 @@ import java.sql.SQLType; import java.util.List; +import java.util.NoSuchElementException; import org.springframework.core.MethodParameter; import org.springframework.data.jdbc.core.convert.JdbcColumnTypes; @@ -55,6 +56,18 @@ public JdbcParameter getParameter(int index) { return (JdbcParameter) super.getParameter(index); } + public JdbcParameter getParameter(String parameterName) { + + for (RelationalParameter relationalParameter : this) { + + if (relationalParameter.getName().equals(parameterName)) { + return (JdbcParameter) relationalParameter; + } + } + + throw new NoSuchElementException("Invalid parameter name"); + } + @Override @SuppressWarnings({ "rawtypes", "unchecked" }) protected JdbcParameters createFrom(List parameters) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java index fa7202a4a8..7a7d026376 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java @@ -16,28 +16,17 @@ package org.springframework.data.jdbc.repository.query; import java.util.Optional; -import java.util.function.Predicate; -import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; -import org.springframework.data.jdbc.core.convert.QueryMapper; import org.springframework.data.jdbc.core.convert.SqlGeneratorSource; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; -import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; -import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Expressions; -import org.springframework.data.relational.core.sql.Functions; -import org.springframework.data.relational.core.sql.Select; -import org.springframework.data.relational.core.sql.SelectBuilder; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.Lock; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; @@ -59,18 +48,16 @@ * @author Diego Krupitza * @since 2.0 */ -class JdbcQueryCreator extends RelationalQueryCreator { +public class JdbcQueryCreator extends RelationalQueryCreator { private final RelationalMappingContext context; private final PartTree tree; private final RelationalParameterAccessor accessor; - private final QueryMapper queryMapper; private final RelationalEntityMetadata entityMetadata; - private final RenderContextFactory renderContextFactory; private final boolean isSliceQuery; private final ReturnedType returnedType; private final Optional lockMode; - private final SqlGeneratorSource sqlGeneratorSource; + private final StatementFactory statementFactory; /** * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, @@ -96,6 +83,24 @@ class JdbcQueryCreator extends RelationalQueryCreator { new SqlGeneratorSource(context, converter, dialect)); } + /** + * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, + * {@link JdbcQueryMethod} and {@link RelationalParameterAccessor}. + * + * @param tree part tree, must not be {@literal null}. + * @param converter must not be {@literal null}. + * @param dialect must not be {@literal null}. + * @param accessor parameter metadata provider, must not be {@literal null}. + * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}. + * @since 4.0 + */ + public JdbcQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect, JdbcQueryMethod queryMethod, + RelationalParameterAccessor accessor, ReturnedType returnedType) { + this(converter.getMappingContext(), tree, converter, dialect, queryMethod.getEntityInformation(), accessor, + queryMethod.isSliceQuery(), returnedType, queryMethod.lookupLockAnnotation(), + new SqlGeneratorSource(converter.getMappingContext(), converter, dialect)); + } + /** * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, * {@link RelationalEntityMetadata} and {@link RelationalParameterAccessor}. @@ -113,7 +118,7 @@ class JdbcQueryCreator extends RelationalQueryCreator { * {@literal null} * @since 4.0 */ - JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, + public JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, ReturnedType returnedType, Optional lockMode, SqlGeneratorSource sqlGeneratorSource) { super(tree, accessor); @@ -129,12 +134,14 @@ class JdbcQueryCreator extends RelationalQueryCreator { this.accessor = accessor; this.entityMetadata = entityMetadata; - this.queryMapper = new QueryMapper(converter); - this.renderContextFactory = new RenderContextFactory(dialect); this.isSliceQuery = isSliceQuery; this.returnedType = returnedType; this.lockMode = lockMode; - this.sqlGeneratorSource = sqlGeneratorSource; + this.statementFactory = new StatementFactory(converter, dialect); + } + + StatementFactory getStatementFactory() { + return statementFactory; } /** @@ -191,86 +198,45 @@ private static void validateProperty(AggregatePath path) { protected ParametrizedQuery complete(@Nullable Criteria criteria, Sort sort) { RelationalPersistentEntity entity = entityMetadata.getTableEntity(); - Table table = Table.create(entityMetadata.getTableName()); MapSqlParameterSource parameterSource = new MapSqlParameterSource(); - SelectBuilder.SelectLimitOffset limitOffsetBuilder = createSelectClause(entity, table); - SelectBuilder.SelectWhere whereBuilder = applyLimitAndOffset(limitOffsetBuilder); - SelectBuilder.SelectOrdered selectOrderBuilder = applyCriteria(criteria, entity, table, parameterSource, - whereBuilder); - selectOrderBuilder = applyOrderBy(sort, entity, table, selectOrderBuilder); + StatementFactory.Selection selection = getSelection(entity); + + selection.page(accessor.getPageable()).filter(criteria).orderBy(sort); - SelectBuilder.BuildSelect completedBuildSelect = selectOrderBuilder; if (this.lockMode.isPresent()) { - completedBuildSelect = selectOrderBuilder.lock(this.lockMode.get().value()); + selection.lock(this.lockMode.get().value()); } - Select select = completedBuildSelect.build(); - - String sql = SqlRenderer.create(renderContextFactory.createRenderContext()).render(select); - - return new ParametrizedQuery(sql, parameterSource); - } - - SelectBuilder.SelectOrdered applyOrderBy(Sort sort, RelationalPersistentEntity entity, Table table, - SelectBuilder.SelectOrdered selectOrdered) { - - return sort.isSorted() ? // - selectOrdered.orderBy(queryMapper.getMappedSort(table, sort, entity)) // - : selectOrdered; - } - - SelectBuilder.SelectOrdered applyCriteria(@Nullable Criteria criteria, RelationalPersistentEntity entity, - Table table, MapSqlParameterSource parameterSource, SelectBuilder.SelectWhere whereBuilder) { - - return criteria != null // - ? whereBuilder.where(queryMapper.getMappedObject(parameterSource, criteria, table, entity)) // - : whereBuilder; + String sql = selection.build(parameterSource); + return new ParametrizedQuery(sql, parameterSource, criteria != null ? criteria : Criteria.empty()); } - SelectBuilder.SelectWhere applyLimitAndOffset(SelectBuilder.SelectLimitOffset limitOffsetBuilder) { + StatementFactory.Selection getSelection(RelationalPersistentEntity entity) { if (tree.isExistsProjection()) { - limitOffsetBuilder = limitOffsetBuilder.limit(1); - } else if (tree.isLimiting()) { - limitOffsetBuilder = limitOffsetBuilder.limit(tree.getMaxResults()); - } - - Pageable pageable = accessor.getPageable(); - if (pageable.isPaged()) { - limitOffsetBuilder = limitOffsetBuilder.limit(isSliceQuery ? pageable.getPageSize() + 1 : pageable.getPageSize()) - .offset(pageable.getOffset()); + return statementFactory.exists(entity); + } else if (tree.isCountProjection()) { + return statementFactory.count(entity); } - return (SelectBuilder.SelectWhere) limitOffsetBuilder; - } - - SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity entity, Table table) { + StatementFactory.Selection selection; - SelectBuilder.SelectJoin builder; - if (tree.isExistsProjection()) { - - AggregatePath.ColumnInfo anyIdColumnInfo = context.getAggregatePath(entity).getTableInfo().idColumnInfos().any(); - Column idColumn = table.column(anyIdColumnInfo.name()); - builder = Select.builder().select(idColumn).from(table); - } else if (tree.isCountProjection()) { - builder = Select.builder().select(Functions.count(Expressions.asterisk())).from(table); + if (isSliceQuery) { + selection = statementFactory.slice(entity); } else { - builder = selectBuilder(table); + selection = statementFactory.select(entity); } - return (SelectBuilder.SelectLimitOffset) builder; - } - - private SelectBuilder.SelectJoin selectBuilder(Table table) { - - RelationalPersistentEntity entity = entityMetadata.getTableEntity(); + if (returnedType.needsCustomConstruction()) { + selection.project(returnedType.getInputProperties()); + } - Predicate filter = ap -> returnedType.needsCustomConstruction() - && !returnedType.getInputProperties().contains(ap.getRequiredBaseProperty().getName()); + if (tree.isLimiting()) { + selection.limit(tree.getResultLimit()); + } - return (SelectBuilder.SelectJoin) sqlGeneratorSource.getSqlGenerator(entity.getType()).createSelectBuilder(table, - filter); + return selection; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParameterBinding.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParameterBinding.java new file mode 100644 index 0000000000..9abdb8c605 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParameterBinding.java @@ -0,0 +1,650 @@ +/* + * Copyright 2023-2025 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 + * + * https://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 org.springframework.data.jdbc.repository.query; + +import static org.springframework.util.ObjectUtils.*; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.parser.Part.Type; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A generic parameter binding with name or position information. + * + * @author Mark Paluch + * @since 4.0 + */ +public class ParameterBinding { + + private final BindingIdentifier identifier; + private final ParameterOrigin origin; + + /** + * Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin. + * + * @param identifier of the parameter, must not be {@literal null}. + * @param origin the origin of the parameter (expression or method argument) + */ + ParameterBinding(BindingIdentifier identifier, ParameterOrigin origin) { + + Assert.notNull(identifier, "BindingIdentifier must not be null"); + Assert.notNull(origin, "ParameterOrigin must not be null"); + + this.identifier = identifier; + this.origin = origin; + } + + /** + * Creates a new {@link ParameterBinding} for the parameter with the given name and origin. + * + * @param parameter + * @return + */ + public static ParameterBinding of(Parameter parameter) { + return named(parameter.getRequiredName(), ParameterOrigin.ofParameter(parameter)); + } + + /** + * Creates a new {@link ParameterBinding} for the named parameter with the given name and origin. + * + * @param name + * @param origin + * @return + */ + public static ParameterBinding named(String name, ParameterOrigin origin) { + return new ParameterBinding(BindingIdentifier.of(name), origin); + } + + /** + * Creates a new {@code LIKE} {@link ParameterBinding} for the given {@link ParameterBinding} applying the part + * {@code Type}. + * + * @param binding + * @param partType + * @return + */ + + public static ParameterBinding like(ParameterBinding binding, Type partType) { + return new LikeParameterBinding(binding.getIdentifier(), binding.getOrigin(), partType); + } + + public BindingIdentifier getIdentifier() { + return identifier; + } + + public ParameterOrigin getOrigin() { + return origin; + } + + /** + * @return the name if available or {@literal null}. + */ + public @Nullable String getName() { + return identifier.hasName() ? identifier.getName() : null; + } + + /** + * @return {@literal true} if the binding identifier is associated with a name. + * @since 4.0 + */ + boolean hasName() { + return identifier.hasName(); + } + + /** + * @return the name + * @throws IllegalStateException if the name is not available. + * @since 2.0 + */ + String getRequiredName() throws IllegalStateException { + + String name = getName(); + + if (name != null) { + return name; + } + + throw new IllegalStateException(String.format("Required name for %s not available", this)); + } + + /** + * @return the position if available or {@literal null}. + */ + @Nullable + Integer getPosition() { + return identifier.hasPosition() ? identifier.getPosition() : null; + } + + /** + * @return the position + * @throws IllegalStateException if the position is not available. + * @since 2.0 + */ + int getRequiredPosition() throws IllegalStateException { + + Integer position = getPosition(); + + if (position != null) { + return position; + } + + throw new IllegalStateException(String.format("Required position for %s not available", this)); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + ParameterBinding that = (ParameterBinding) o; + + if (!nullSafeEquals(identifier, that.identifier)) { + return false; + } + return nullSafeEquals(origin, that.origin); + } + + @Override + public int hashCode() { + int result = nullSafeHashCode(identifier); + result = 31 * result + nullSafeHashCode(origin); + return result; + } + + @Override + public String toString() { + return String.format("ParameterBinding [identifier: %s, origin: %s]", identifier, origin); + } + + + /** + * Represents a parameter binding in a JDBC query augmented with instructions of how to apply a parameter as LIKE + * parameter. + */ + public static class LikeParameterBinding extends ParameterBinding { + + private static final List SUPPORTED_TYPES = Arrays.asList(Type.CONTAINING, Type.STARTING_WITH, + Type.ENDING_WITH, Type.LIKE); + + private final Type type; + + /** + * Creates a new {@link LikeParameterBinding} for the parameter with the given name and {@link Type} and parameter + * binding input. + * + * @param identifier must not be {@literal null} or empty. + * @param type must not be {@literal null}. + */ + LikeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, Type type) { + + super(identifier, origin); + + Assert.notNull(type, "Type must not be null"); + + Assert.isTrue(SUPPORTED_TYPES.contains(type), + String.format("Type must be one of %s", StringUtils.collectionToCommaDelimitedString(SUPPORTED_TYPES))); + + this.type = type; + } + + /** + * Returns the {@link Type} of the binding. + * + * @return the type + */ + public Type getType() { + return type; + } + + @Override + public boolean equals(Object obj) { + + if (!(obj instanceof LikeParameterBinding that)) { + return false; + } + + return super.equals(obj) && this.type.equals(that.type); + } + + @Override + public int hashCode() { + + int result = super.hashCode(); + + result += nullSafeHashCode(this.type); + + return result; + } + + @Override + public String toString() { + return String.format("LikeBinding [identifier: %s, origin: %s, type: %s]", getIdentifier(), getOrigin(), + getType()); + } + + /** + * Extracts the like {@link Type} from the given like expression. + * + * @param expression must not be {@literal null} or empty. + */ + static Type getLikeTypeFrom(String expression) { + + Assert.hasText(expression, "Expression must not be null or empty"); + + if (expression.startsWith("%")) { + return expression.endsWith("%") ? Type.CONTAINING : Type.ENDING_WITH; + } + + if (expression.endsWith("%")) { + return Type.STARTING_WITH; + } + + return Type.LIKE; + } + } + + /** + * Identifies a binding parameter by name, position or both. Used to bind parameters to a query or to describe a + * {@link MethodInvocationArgument} origin. + * + * @author Mark Paluch + * @since 3.1.2 + */ + public sealed interface BindingIdentifier permits Named, ParameterBinding.Indexed, NamedAndIndexed { + + /** + * Creates an identifier for the given {@code name}. + * + * @param name + * @return + */ + static BindingIdentifier of(String name) { + + Assert.hasText(name, "Name must not be empty"); + + return new Named(name); + } + + /** + * Creates an identifier for the given {@code position}. + * + * @param position 1-based index. + * @return + */ + static BindingIdentifier of(int position) { + + Assert.isTrue(position > -1, "Index position must be greater zero"); + + return new Indexed(position); + } + + /** + * Creates an identifier for the given {@code name} and {@code position}. + * + * @param name + * @return + */ + static BindingIdentifier of(String name, int position) { + + Assert.hasText(name, "Name must not be empty"); + + return new NamedAndIndexed(name, position); + } + + /** + * @return {@code true} if the binding is associated with a name. + */ + default boolean hasName() { + return false; + } + + /** + * @return {@code true} if the binding is associated with a position index. + */ + default boolean hasPosition() { + return false; + } + + /** + * Returns the binding name {@link #hasName() if present} or throw {@link IllegalStateException} if no name + * associated. + * + * @return the binding name. + */ + default String getName() { + throw new IllegalStateException("No name associated"); + } + + /** + * Returns the binding name {@link #hasPosition() if present} or throw {@link IllegalStateException} if no position + * associated. + * + * @return the binding position. + */ + default int getPosition() { + throw new IllegalStateException("No position associated"); + } + + /** + * Map the name of the binding to a new name using the given {@link Function} if the binding has a name. If the + * binding is not associated with a name, then the binding is returned unchanged. + * + * @param nameMapper must not be {@literal null}. + * @return the transformed {@link BindingIdentifier} if the binding has a name, otherwise the binding itself. + * @since 4.0 + */ + BindingIdentifier mapName(Function nameMapper); + + /** + * Associate a position with the binding. + * + * @param position + * @return the new binding identifier with the position. + * @since 4.0 + */ + BindingIdentifier withPosition(int position); + + } + + private record Named(String name) implements BindingIdentifier { + + @Override + public boolean hasName() { + return true; + } + + @Override + public String getName() { + return name(); + } + + @Override + public String toString() { + return name(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new Named(nameMapper.apply(name())); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } + } + + private record Indexed(int position) implements BindingIdentifier { + + @Override + public boolean hasPosition() { + return true; + } + + @Override + public int getPosition() { + return position(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return this; + } + + @Override + public BindingIdentifier withPosition(int position) { + return new Indexed(position); + } + + @Override + public String toString() { + return "[" + position() + "]"; + } + } + + private record NamedAndIndexed(String name, int position) implements BindingIdentifier { + + @Override + public boolean hasName() { + return true; + } + + @Override + public String getName() { + return name(); + } + + @Override + public boolean hasPosition() { + return true; + } + + @Override + public int getPosition() { + return position(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new NamedAndIndexed(nameMapper.apply(name), position); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } + + @Override + public String toString() { + return "[" + name() + ", " + position() + "]"; + } + } + + /** + * Value type hierarchy to describe where a binding parameter comes from, either method call or an expression. + * + * @author Mark Paluch + * @since 3.1.2 + */ + public sealed interface ParameterOrigin permits Expression, MethodInvocationArgument, Synthetic { + + /** + * Creates a {@link Expression} for the given {@code expression}. + * + * @param expression must not be {@literal null}. + * @return {@link Expression} for the given {@code expression}. + */ + static Expression ofExpression(ValueExpression expression) { + return new Expression(expression); + } + + /** + * Creates a {@link Expression} for the given {@code expression} string. + * + * @param value the captured value. + * @param source source from which this value is derived. + * @return {@link Synthetic} for the given {@code value}. + */ + static Synthetic synthetic(@Nullable Object value, Object source) { + return new Synthetic(value, source); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code name} + * + * @param name the parameter name from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code name}. + */ + static MethodInvocationArgument ofParameter(String name) { + return ofParameter(name, null); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code name} and {@code position}. Either the name or the + * position must be given. + * + * @param name the parameter name from the method invocation, can be {@literal null}. + * @param position the parameter position (1-based) from the method invocation, can be {@literal null}. + * @return {@link MethodInvocationArgument} object for {@code name} and {@code position}. + */ + static MethodInvocationArgument ofParameter(@Nullable String name, @Nullable Integer position) { + + BindingIdentifier identifier; + if (!ObjectUtils.isEmpty(name) && position != null) { + identifier = BindingIdentifier.of(name, position); + } else if (!ObjectUtils.isEmpty(name)) { + identifier = BindingIdentifier.of(name); + } else if (position != null) { + identifier = BindingIdentifier.of(position); + } else { + throw new IllegalStateException("Neither name nor position available for binding"); + } + + return ofParameter(identifier); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code position}. + * + * @param parameter the parameter from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code position}. + */ + static MethodInvocationArgument ofParameter(Parameter parameter) { + return ofParameter(parameter.getIndex() + 1); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code position}. + * + * @param position the parameter position (1-based) from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code position}. + */ + static MethodInvocationArgument ofParameter(int position) { + return ofParameter(BindingIdentifier.of(position)); + } + + /** + * Creates a {@link MethodInvocationArgument} using {@link BindingIdentifier}. + * + * @param identifier must not be {@literal null}. + * @return {@link MethodInvocationArgument} for {@link BindingIdentifier}. + */ + static MethodInvocationArgument ofParameter(BindingIdentifier identifier) { + return new MethodInvocationArgument(identifier); + } + + /** + * @return {@code true} if the origin is a method argument reference. + */ + boolean isMethodArgument(); + + /** + * @return {@code true} if the origin is an expression. + */ + boolean isExpression(); + + /** + * @return {@code true} if the origin is synthetic (contributed by e.g. KeysetPagination) + */ + boolean isSynthetic(); + } + + /** + * Value object capturing the expression of which a binding parameter originates. + * + * @param expression + * @author Mark Paluch + * @since 3.1.2 + */ + public record Expression(ValueExpression expression) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return false; + } + + @Override + public boolean isExpression() { + return true; + } + + @Override + public boolean isSynthetic() { + return true; + } + } + + /** + * Value object capturing the expression of which a binding parameter originates. + * + * @param value + * @param source + * @author Mark Paluch + */ + public record Synthetic(@Nullable Object value, Object source) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return false; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public boolean isSynthetic() { + return true; + } + } + + /** + * Value object capturing the method invocation parameter reference. + * + * @param identifier + * @author Mark Paluch + * @since 3.1.2 + */ + public record MethodInvocationArgument(BindingIdentifier identifier) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return true; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public boolean isSynthetic() { + return false; + } + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParametrizedQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParametrizedQuery.java index 22bcb8d53d..8175b4de4f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParametrizedQuery.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/ParametrizedQuery.java @@ -16,6 +16,7 @@ package org.springframework.data.jdbc.repository.query; import org.springframework.data.relational.core.dialect.Escaper; +import org.springframework.data.relational.core.query.Criteria; import org.springframework.jdbc.core.namedparam.SqlParameterSource; /** @@ -26,21 +27,31 @@ * @author Jens Schauder * @since 2.0 */ -class ParametrizedQuery { +public class ParametrizedQuery { private final String query; private final SqlParameterSource parameterSource; + private final Criteria criteria; - ParametrizedQuery(String query, SqlParameterSource parameterSource) { + ParametrizedQuery(String query, SqlParameterSource parameterSource, Criteria criteria) { this.query = query; this.parameterSource = parameterSource; + this.criteria = criteria; } - String getQuery() { + public SqlParameterSource getParameterSource() { + return parameterSource; + } + + public String getQuery() { return query; } + public Criteria getCriteria() { + return criteria; + } + SqlParameterSource getParameterSource(Escaper escaper) { return new EscapingParameterSource(parameterSource, escaper); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java index 6edd6cdc67..7723d4a606 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java @@ -30,6 +30,7 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.dialect.Dialect; @@ -45,6 +46,7 @@ import org.springframework.data.util.Lazy; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SingleColumnRowMapper; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.lang.Nullable; @@ -70,6 +72,20 @@ public class PartTreeJdbcQuery extends AbstractJdbcQuery { private final CachedRowMapperFactory cachedRowMapperFactory; private final PartTree tree; + /** + * Creates a new {@link PartTreeJdbcQuery}. + * + * @param queryMethod must not be {@literal null}. + * @param operations must not be {@literal null}. + * @param rowMapperFactory must not be {@literal null}. + * @since 4.0 + */ + public PartTreeJdbcQuery(JdbcQueryMethod queryMethod, JdbcAggregateOperations operations, + org.springframework.data.jdbc.repository.query.RowMapperFactory rowMapperFactory) { + this(operations.getConverter().getMappingContext(), queryMethod, operations.getDataAccessStrategy().getDialect(), + operations.getConverter(), operations.getDataAccessStrategy().getJdbcOperations(), rowMapperFactory); + } + /** * Creates a new {@link PartTreeJdbcQuery}. * @@ -192,7 +208,7 @@ private JdbcQueryExecution getQueryExecution(ResultProcessor processor, entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation()); ParametrizedQuery countQuery = queryCreator.createQuery(Sort.unsorted()); - Object count = singleObjectQuery((rs, i) -> rs.getLong(1)).execute(countQuery.getQuery(), + Object count = singleObjectQuery(new SingleColumnRowMapper<>(Number.class)).execute(countQuery.getQuery(), countQuery.getParameterSource(dialect.getLikeEscaper())); return converter.getConversionService().convert(count, Long.class); @@ -308,7 +324,7 @@ class CachedRowMapperFactory implements Supplier> { private final Function> rowMapperFunction; public CachedRowMapperFactory(PartTree tree, - org.springframework.data.jdbc.repository.query.RowMapperFactory rowMapperFactory, RelationalConverter converter, + RowMapperFactory rowMapperFactory, RelationalConverter converter, ResultProcessor defaultResultProcessor) { this.rowMapperFunction = processor -> { @@ -318,7 +334,7 @@ public CachedRowMapperFactory(PartTree tree, } Converter resultProcessingConverter = new ResultProcessingConverter(processor, converter.getMappingContext(), converter.getEntityInstantiators()); - return new org.springframework.data.jdbc.repository.query.ConvertingRowMapper( + return new ConvertingRowMapper( rowMapperFactory.create(processor.getReturnedType().getDomainType()), resultProcessingConverter); }; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StatementFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StatementFactory.java new file mode 100644 index 0000000000..c9a0080896 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StatementFactory.java @@ -0,0 +1,252 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.query; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.QueryMapper; +import org.springframework.data.jdbc.core.convert.SqlGeneratorSource; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.query.Criteria; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.data.relational.core.sql.Functions; +import org.springframework.data.relational.core.sql.LockMode; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.SqlRenderer; +import org.springframework.data.util.Predicates; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.lang.Nullable; + +/** + * @author Mark Paluch + */ +public class StatementFactory { + + private final JdbcConverter converter; + private final Dialect dialect; + private final RenderContextFactory renderContextFactory; + private final QueryMapper queryMapper; + private final SqlGeneratorSource sqlGeneratorSource; + + public StatementFactory(JdbcConverter converter, Dialect dialect) { + this.dialect = dialect; + this.renderContextFactory = new RenderContextFactory(dialect); + this.converter = converter; + this.queryMapper = new QueryMapper(converter); + this.sqlGeneratorSource = new SqlGeneratorSource(converter, dialect); + } + + public Selection select(Class entity) { + return select(converter.getMappingContext().getRequiredPersistentEntity(entity)); + } + + public Selection select(RelationalPersistentEntity entity) { + return new Selection(entity, Selection.Mode.SELECT); + } + + public Selection count(Class entity) { + return count(converter.getMappingContext().getRequiredPersistentEntity(entity)); + } + + public Selection count(RelationalPersistentEntity entity) { + return new Selection(entity, Selection.Mode.COUNT); + } + + public Selection exists(Class entity) { + return exists(converter.getMappingContext().getRequiredPersistentEntity(entity)); + } + + public Selection exists(RelationalPersistentEntity entity) { + return new Selection(entity, Selection.Mode.EXISTS); + } + + public Selection slice(Class entity) { + return slice(converter.getMappingContext().getRequiredPersistentEntity(entity)); + } + + public Selection slice(RelationalPersistentEntity entity) { + return new Selection(entity, Selection.Mode.SLICE); + } + + public class Selection { + + private final RelationalPersistentEntity entity; + private final Table table; + private final Mode mode; + + private @org.jspecify.annotations.Nullable LockMode lockMode; + private Limit limit = Limit.unlimited(); + private Pageable pageable = Pageable.unpaged(); + private Sort sort = Sort.unsorted(); + private Criteria criteria = Criteria.empty(); + private List properties = new ArrayList<>(); + + protected Selection(RelationalPersistentEntity entity, Mode mode) { + this.entity = entity; + this.table = Table.create(entity.getTableName()); + this.mode = mode; + } + + public Selection project(Collection properties) { + this.properties = List.copyOf(properties); + return this; + } + + public Selection project(String... properties) { + this.properties = Arrays.asList(properties); + return this; + } + + public Selection orderBy(Sort sort) { + this.sort = this.sort.and(sort); + return this; + } + + public Selection page(@org.jspecify.annotations.Nullable Pageable pageable) { + + if (pageable != null) { + this.pageable = pageable; + orderBy(pageable.getSort()); + } + return this; + } + + public Selection limit(int limit) { + this.limit = Limit.of(limit); + return this; + } + + public Selection limit(Limit limit) { + this.limit = limit; + return this; + } + + public Selection filter(@Nullable Criteria criteria) { + this.criteria = criteria == null ? Criteria.empty() : criteria; + return this; + } + + public Selection lock(LockMode lockMode) { + this.lockMode = lockMode; + return this; + } + + public String build(MapSqlParameterSource parameterSource) { + + SelectBuilder.SelectLimitOffset limitOffsetBuilder = createSelectClause(entity, table); + SelectBuilder.SelectWhere whereBuilder = applyLimitAndOffset(limitOffsetBuilder); + SelectBuilder.SelectOrdered selectOrderBuilder = applyCriteria(criteria, entity, table, parameterSource, + whereBuilder); + selectOrderBuilder = applyOrderBy(sort, entity, table, selectOrderBuilder); + + SelectBuilder.BuildSelect completedBuildSelect = selectOrderBuilder; + if (this.lockMode != null) { + completedBuildSelect = selectOrderBuilder.lock(this.lockMode); + } + + Select select = completedBuildSelect.build(); + + return SqlRenderer.create(renderContextFactory.createRenderContext()).render(select); + } + + SelectBuilder.SelectOrdered applyOrderBy(Sort sort, RelationalPersistentEntity entity, Table table, + SelectBuilder.SelectOrdered selectOrdered) { + + return sort.isSorted() ? // + selectOrdered.orderBy(queryMapper.getMappedSort(table, sort, entity)) // + : selectOrdered; + } + + SelectBuilder.SelectOrdered applyCriteria(@Nullable Criteria criteria, RelationalPersistentEntity entity, + Table table, MapSqlParameterSource parameterSource, SelectBuilder.SelectWhere whereBuilder) { + + return criteria != null && !criteria.isEmpty() // + ? whereBuilder.where(queryMapper.getMappedObject(parameterSource, criteria, table, entity)) // + : whereBuilder; + } + + SelectBuilder.SelectWhere applyLimitAndOffset(SelectBuilder.SelectLimitOffset limitOffsetBuilder) { + + if (mode == Mode.COUNT) { + return (SelectBuilder.SelectWhere) limitOffsetBuilder; + } + + if (mode == Mode.EXISTS) { + limitOffsetBuilder = limitOffsetBuilder.limit(1); + } else if (limit.isLimited()) { + limitOffsetBuilder = limitOffsetBuilder.limit(limit.max()); + } + + if (pageable.isPaged()) { + limitOffsetBuilder = limitOffsetBuilder + .limit(mode == Mode.SLICE ? pageable.getPageSize() + 1 : pageable.getPageSize()) + .offset(pageable.getOffset()); + } + + return (SelectBuilder.SelectWhere) limitOffsetBuilder; + } + + SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity entity, Table table) { + + SelectBuilder.SelectJoin builder; + + if (mode == Mode.EXISTS) { + AggregatePath.ColumnInfo anyIdColumnInfo = converter.getMappingContext().getAggregatePath(entity).getTableInfo() + .idColumnInfos().any(); + Column idColumn = table.column(anyIdColumnInfo.name()); + builder = Select.builder().select(idColumn).from(table); + } else if (mode == Mode.COUNT) { + builder = Select.builder().select(Functions.count(Expressions.asterisk())).from(table); + } else { + builder = selectBuilder(table); + } + + return (SelectBuilder.SelectLimitOffset) builder; + } + + private SelectBuilder.SelectJoin selectBuilder(Table table) { + + Predicate filter; + + if (properties.isEmpty()) { + filter = Predicates.isFalse(); + } else { + filter = ap -> !properties.contains(ap.getRequiredBaseProperty().getName()); + } + + return (SelectBuilder.SelectJoin) sqlGeneratorSource.getSqlGenerator(entity.getType()).createSelectBuilder(table, + filter); + } + + enum Mode { + COUNT, EXISTS, SELECT, SLICE + } + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java index 814daac7d0..3d4d03f98a 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringBasedJdbcQuery.java @@ -17,13 +17,9 @@ import static org.springframework.data.jdbc.repository.query.JdbcQueryExecution.*; -import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.sql.SQLType; -import java.util.ArrayList; -import java.util.Collection; import java.util.LinkedHashMap; -import java.util.List; import java.util.function.Function; import java.util.function.Supplier; @@ -32,6 +28,7 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.data.expression.ValueEvaluationContext; import org.springframework.data.expression.ValueExpression; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.JdbcColumnTypes; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.mapping.JdbcValue; @@ -45,7 +42,6 @@ import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.repository.query.ValueExpressionQueryRewriter; import org.springframework.data.util.Lazy; -import org.springframework.data.util.TypeInformation; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; @@ -116,7 +112,9 @@ public StringBasedJdbcQuery(JdbcQueryMethod queryMethod, NamedParameterJdbcOpera public StringBasedJdbcQuery(String query, JdbcQueryMethod queryMethod, NamedParameterJdbcOperations operations, org.springframework.data.jdbc.repository.query.RowMapperFactory rowMapperFactory, JdbcConverter converter, ValueExpressionDelegate delegate) { + super(queryMethod, operations); + Assert.hasText(query, "Query must not be null or empty"); Assert.notNull(rowMapperFactory, "RowMapperFactory must not be null"); @@ -155,6 +153,23 @@ public StringBasedJdbcQuery(String query, JdbcQueryMethod queryMethod, NamedPara this.delegate = delegate; } + /** + * Creates a new {@link StringBasedJdbcQuery} for the given {@link JdbcQueryMethod}, {@link JdbcAggregateOperations} + * and {@link RowMapperFactory}. + * + * @param query must not be {@literal null} or empty. + * @param queryMethod must not be {@literal null}. + * @param operations must not be {@literal null}. + * @param rowMapperFactory must not be {@literal null}. + * @param delegate must not be {@literal null}. + * @since 4.0 + */ + public StringBasedJdbcQuery(String query, JdbcQueryMethod queryMethod, JdbcAggregateOperations operations, + RowMapperFactory rowMapperFactory, ValueExpressionDelegate delegate) { + this(query, queryMethod, operations.getDataAccessStrategy().getJdbcOperations(), rowMapperFactory, + operations.getConverter(), delegate); + } + @Override public Object execute(Object[] objects) { @@ -242,8 +257,8 @@ private MapSqlParameterSource bindParameters(RelationalParameterAccessor accesso JdbcParameters.JdbcParameter parameter = getQueryMethod().getParameters() .getParameter(bindableParameter.getIndex()); - JdbcValue jdbcValue = writeValue(value, parameter.getTypeInformation(), - parameter); + JdbcValue jdbcValue = StringValueUtil.getBindValue(converter, value, parameter.getTypeInformation(), + parameter.getSqlType(), parameter.getActualSqlType()); SQLType jdbcType = jdbcValue.getJdbcType(); if (jdbcType == null) { @@ -256,89 +271,6 @@ private MapSqlParameterSource bindParameters(RelationalParameterAccessor accesso return parameters; } - private JdbcValue writeValue(@Nullable Object value, TypeInformation typeInformation, - JdbcParameters.JdbcParameter parameter) { - - if (value == null) { - return JdbcValue.of(value, parameter.getSqlType()); - } - - if (typeInformation.isCollectionLike() && value instanceof Collection collection) { - - TypeInformation actualType = typeInformation.getActualType(); - - // allow tuple-binding for collection of byte arrays to be used as BINARY, - // we do not want to convert to column arrays. - if (actualType != null && actualType.getType().isArray() && !actualType.getType().equals(byte[].class)) { - - TypeInformation nestedElementType = actualType.getRequiredActualType(); - return writeCollection(collection, parameter.getActualSqlType(), - array -> writeArrayValue(parameter, array, nestedElementType)); - } - - // parameter expansion - return writeCollection(collection, parameter.getActualSqlType(), - it -> converter.writeJdbcValue(it, typeInformation.getRequiredActualType(), parameter.getActualSqlType())); - } - - SQLType sqlType = parameter.getSqlType(); - return converter.writeJdbcValue(value, typeInformation, sqlType); - } - - private JdbcValue writeCollection(Collection value, SQLType defaultType, Function mapper) { - - if (value.isEmpty()) { - return JdbcValue.of(value, defaultType); - } - - JdbcValue jdbcValue; - List mapped = new ArrayList<>(value.size()); - SQLType jdbcType = null; - - for (Object o : value) { - - Object mappedValue = mapper.apply(o); - - if (mappedValue instanceof JdbcValue jv) { - if (jdbcType == null) { - jdbcType = jv.getJdbcType(); - } - mappedValue = jv.getValue(); - } - - mapped.add(mappedValue); - } - - jdbcValue = JdbcValue.of(mapped, jdbcType == null ? defaultType : jdbcType); - - return jdbcValue; - } - - private JdbcValue writeArrayValue(JdbcParameters.JdbcParameter parameter, Object array, - TypeInformation nestedElementType) { - - int length = Array.getLength(array); - Object[] mappedArray = new Object[length]; - SQLType sqlType = null; - - for (int i = 0; i < length; i++) { - - Object element = Array.get(array, i); - JdbcValue converted = converter.writeJdbcValue(element, nestedElementType, parameter.getActualSqlType()); - - if (sqlType == null && converted.getJdbcType() != null) { - sqlType = converted.getJdbcType(); - } - mappedArray[i] = converted.getValue(); - } - - if (sqlType == null) { - sqlType = JdbcUtil.targetSqlTypeFor(JdbcColumnTypes.INSTANCE.resolvePrimitiveType(nestedElementType.getType())); - } - - return JdbcValue.of(mappedArray, sqlType); - } - RowMapper determineRowMapper(ResultProcessor resultProcessor, boolean hasDynamicProjection) { if (cachedRowMapperFactory.isConfiguredRowMapper()) { @@ -351,7 +283,7 @@ RowMapper determineRowMapper(ResultProcessor resultProcessor, boolean ha ResultProcessingConverter converter = new ResultProcessingConverter(resultProcessor, this.converter.getMappingContext(), this.converter.getEntityInstantiators()); - return new org.springframework.data.jdbc.repository.query.ConvertingRowMapper(rowMapperToUse, converter); + return new ConvertingRowMapper(rowMapperToUse, converter); } return cachedRowMapperFactory.getRowMapper(); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringValueUtil.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringValueUtil.java new file mode 100644 index 0000000000..17a2b3e3f1 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/StringValueUtil.java @@ -0,0 +1,124 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.query; + +import java.lang.reflect.Array; +import java.sql.SQLType; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.jdbc.core.convert.JdbcColumnTypes; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.mapping.JdbcValue; +import org.springframework.data.jdbc.support.JdbcUtil; +import org.springframework.data.util.TypeInformation; + +/** + * Utility to obtain {@link JdbcValue} instances for string values, collections, and arrays for string-based query + * usage. + * + * @author Mark Paluch + * @since 4.0 + */ +public class StringValueUtil { + + public static JdbcValue getBindValue(JdbcConverter converter, @Nullable Object value, + TypeInformation typeInformation, SQLType sqlType, SQLType actualSqlType) { + + if (value == null) { + return JdbcValue.of(value, sqlType); + } + + if (typeInformation.isCollectionLike() && value instanceof Collection collection) { + + TypeInformation actualType = typeInformation.getActualType(); + + // allow tuple-binding for collection of byte arrays to be used as BINARY, + // we do not want to convert to column arrays. + if (actualType != null && actualType.getType().isArray() && !actualType.getType().equals(byte[].class)) { + + TypeInformation nestedElementType = actualType.getRequiredActualType(); + return writeCollection(converter, collection, actualSqlType, + array -> writeArrayValue(converter, actualSqlType, array, nestedElementType)); + } + + // parameter expansion + return writeCollection(converter, collection, actualSqlType, + it -> converter.writeJdbcValue(it, typeInformation.getRequiredActualType(), actualSqlType)); + } + + return converter.writeJdbcValue(value, typeInformation, sqlType); + } + + private static JdbcValue writeCollection(JdbcConverter converter, Collection value, SQLType defaultType, + Function mapper) { + + if (value.isEmpty()) { + return JdbcValue.of(value, defaultType); + } + + JdbcValue jdbcValue; + List mapped = new ArrayList<>(value.size()); + SQLType jdbcType = null; + + for (Object o : value) { + + Object mappedValue = mapper.apply(o); + + if (mappedValue instanceof JdbcValue jv) { + if (jdbcType == null) { + jdbcType = jv.getJdbcType(); + } + mappedValue = jv.getValue(); + } + + mapped.add(mappedValue); + } + + jdbcValue = JdbcValue.of(mapped, jdbcType == null ? defaultType : jdbcType); + + return jdbcValue; + } + + private static JdbcValue writeArrayValue(JdbcConverter converter, SQLType actualSqlType, Object array, + TypeInformation nestedElementType) { + + int length = Array.getLength(array); + Object[] mappedArray = new Object[length]; + SQLType sqlType = null; + + for (int i = 0; i < length; i++) { + + Object element = Array.get(array, i); + JdbcValue converted = converter.writeJdbcValue(element, nestedElementType, actualSqlType); + + if (sqlType == null && converted.getJdbcType() != null) { + sqlType = converted.getJdbcType(); + } + mappedArray[i] = converted.getValue(); + } + + if (sqlType == null) { + sqlType = JdbcUtil.targetSqlTypeFor(JdbcColumnTypes.INSTANCE.resolvePrimitiveType(nestedElementType.getType())); + } + + return JdbcValue.of(mappedArray, sqlType); + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/BeanFactoryAwareRowMapperFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/BeanFactoryAwareRowMapperFactory.java index b5199fe6e3..fec3b3ef2a 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/BeanFactoryAwareRowMapperFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/BeanFactoryAwareRowMapperFactory.java @@ -16,16 +16,11 @@ package org.springframework.data.jdbc.repository.support; import org.springframework.beans.factory.BeanFactory; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; -import org.springframework.data.jdbc.repository.query.DefaultRowMapperFactory; import org.springframework.data.jdbc.repository.query.RowMapperFactory; -import org.springframework.data.mapping.callback.EntityCallbacks; -import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; -import org.springframework.lang.Nullable; /** * This {@link RowMapperFactory} implementation extends the {@link DefaultRowMapperFactory} by adding the capabilities @@ -38,44 +33,32 @@ @SuppressWarnings("unchecked") public class BeanFactoryAwareRowMapperFactory extends DefaultRowMapperFactory { - private final @Nullable BeanFactory beanFactory; + private final BeanFactory beanFactory; - BeanFactoryAwareRowMapperFactory(JdbcConverter converter, QueryMappingConfiguration queryMappingConfiguration, - EntityCallbacks entityCallbacks, ApplicationEventPublisher publisher, @Nullable BeanFactory beanFactory) { + /** + * Create a {@code BeanFactoryAwareRowMapperFactory} instance using the given {@link BeanFactory}, + * {@link JdbcAggregateOperations} and {@link QueryMappingConfiguration}. + * + * @param beanFactory + * @param operations + * @param queryMappingConfiguration + */ + public BeanFactoryAwareRowMapperFactory(BeanFactory beanFactory, JdbcAggregateOperations operations, + QueryMappingConfiguration queryMappingConfiguration) { - super(converter, queryMappingConfiguration, entityCallbacks, publisher); - - this.beanFactory = beanFactory; - } - - public BeanFactoryAwareRowMapperFactory(RelationalMappingContext context, JdbcConverter converter, - QueryMappingConfiguration queryMappingConfiguration, EntityCallbacks entityCallbacks, - ApplicationEventPublisher publisher, @Nullable BeanFactory beanFactory) { - - super(context, converter, queryMappingConfiguration, entityCallbacks, publisher); + super(operations, queryMappingConfiguration); this.beanFactory = beanFactory; } @Override public RowMapper getRowMapper(String reference) { - - if (beanFactory == null) { - throw new IllegalStateException( - "Cannot resolve RowMapper bean reference '" + reference + "'; BeanFactory is not configured."); - } - return beanFactory.getBean(reference, RowMapper.class); } @Override public ResultSetExtractor getResultSetExtractor(String reference) { - - if (beanFactory == null) { - throw new IllegalStateException( - "Cannot resolve ResultSetExtractor bean reference '" + reference + "'; BeanFactory is not configured."); - } - return beanFactory.getBean(reference, ResultSetExtractor.class); } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/DefaultRowMapperFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/DefaultRowMapperFactory.java similarity index 53% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/DefaultRowMapperFactory.java rename to spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/DefaultRowMapperFactory.java index f1f91ec4bc..8a86659cc4 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/DefaultRowMapperFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/DefaultRowMapperFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2025 the original author or authors. + * Copyright 2025 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. @@ -13,18 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jdbc.repository.query; +package org.springframework.data.jdbc.repository.support; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.jdbc.core.convert.EntityRowMapper; -import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; -import org.springframework.data.mapping.callback.EntityCallbacks; -import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.jdbc.repository.query.RowMapperFactory; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.SingleColumnRowMapper; +import org.springframework.util.Assert; /** * Default implementation of {@link RowMapperFactory}. Honors the custom mappings defined in @@ -39,43 +37,30 @@ */ public class DefaultRowMapperFactory implements RowMapperFactory { - private final RelationalMappingContext context; - private final JdbcConverter converter; + private final JdbcAggregateOperations operations; private final QueryMappingConfiguration queryMappingConfiguration; - private final EntityCallbacks entityCallbacks; - private final ApplicationEventPublisher publisher; - public DefaultRowMapperFactory(JdbcConverter converter, QueryMappingConfiguration queryMappingConfiguration, - EntityCallbacks entityCallbacks, ApplicationEventPublisher publisher) { + public DefaultRowMapperFactory(JdbcAggregateOperations operations, + QueryMappingConfiguration queryMappingConfiguration) { - this.context = converter.getMappingContext(); - this.converter = converter; - this.queryMappingConfiguration = queryMappingConfiguration; - this.entityCallbacks = entityCallbacks; - this.publisher = publisher; - } - - public DefaultRowMapperFactory(RelationalMappingContext context, JdbcConverter converter, - QueryMappingConfiguration queryMappingConfiguration, EntityCallbacks entityCallbacks, - ApplicationEventPublisher publisher) { + Assert.notNull(operations, "JdbcAggregateOperations must not be null"); + Assert.notNull(queryMappingConfiguration, "QueryMappingConfiguration must not be null"); - this.context = context; - this.converter = converter; + this.operations = operations; this.queryMappingConfiguration = queryMappingConfiguration; - this.entityCallbacks = entityCallbacks; - this.publisher = publisher; } @Override @SuppressWarnings("unchecked") public RowMapper create(Class returnedObjectType) { - RelationalPersistentEntity persistentEntity = context.getPersistentEntity(returnedObjectType); + RelationalPersistentEntity persistentEntity = operations.getConverter().getMappingContext() + .getPersistentEntity(returnedObjectType); if (persistentEntity == null) { return (RowMapper) SingleColumnRowMapper.newInstance(returnedObjectType, - converter.getConversionService()); + operations.getConverter().getConversionService()); } return (RowMapper) determineDefaultMapper(returnedObjectType); @@ -89,11 +74,7 @@ private RowMapper determineDefaultMapper(Class returnedObjectType) { return configuredQueryMapper; } - EntityRowMapper defaultEntityRowMapper = new EntityRowMapper<>( // - context.getRequiredPersistentEntity(returnedObjectType), // - converter // - ); - return new CallbackCapableRowMapper<>(defaultEntityRowMapper, publisher, entityCallbacks); + return operations.getRowMapper(returnedObjectType); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java index d5652d8bab..86ca10c666 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategy.java @@ -20,25 +20,18 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.springframework.beans.factory.BeanFactory; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.jdbc.core.convert.JdbcConverter; -import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; -import org.springframework.data.jdbc.repository.query.DefaultRowMapperFactory; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.repository.query.JdbcQueryMethod; import org.springframework.data.jdbc.repository.query.PartTreeJdbcQuery; import org.springframework.data.jdbc.repository.query.RowMapperFactory; import org.springframework.data.jdbc.repository.query.StringBasedJdbcQuery; -import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.projection.ProjectionFactory; -import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.repository.support.RelationalQueryLookupStrategy; import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -60,30 +53,19 @@ abstract class JdbcQueryLookupStrategy extends RelationalQueryLookupStrategy { private static final Log LOG = LogFactory.getLog(JdbcQueryLookupStrategy.class); - private final ApplicationEventPublisher publisher; - private final @Nullable EntityCallbacks callbacks; - private final JdbcConverter converter; - private final QueryMappingConfiguration queryMappingConfiguration; - private final NamedParameterJdbcOperations operations; - protected final ValueExpressionDelegate delegate; + final JdbcAggregateOperations operations; + final RowMapperFactory rowMapperFactory; + final ValueExpressionDelegate delegate; - JdbcQueryLookupStrategy(ApplicationEventPublisher publisher, @Nullable EntityCallbacks callbacks, - JdbcConverter converter, Dialect dialect, - QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, + JdbcQueryLookupStrategy(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, ValueExpressionDelegate delegate) { - super(converter.getMappingContext(), dialect); + super(operations.getConverter().getMappingContext(), operations.getDataAccessStrategy().getDialect()); - Assert.notNull(publisher, "ApplicationEventPublisher must not be null"); - Assert.notNull(converter, "JdbcConverter must not be null"); - Assert.notNull(queryMappingConfiguration, "QueryMappingConfiguration must not be null"); - Assert.notNull(operations, "NamedParameterJdbcOperations must not be null"); + Assert.notNull(rowMapperFactory, "RowMapperFactory must not be null"); Assert.notNull(delegate, "ValueExpressionDelegate must not be null"); - this.publisher = publisher; - this.callbacks = callbacks; - this.converter = converter; - this.queryMappingConfiguration = queryMappingConfiguration; + this.rowMapperFactory = rowMapperFactory; this.operations = operations; this.delegate = delegate; } @@ -96,17 +78,10 @@ abstract class JdbcQueryLookupStrategy extends RelationalQueryLookupStrategy { */ static class CreateQueryLookupStrategy extends JdbcQueryLookupStrategy { - private final RowMapperFactory rowMapperFactory; - - CreateQueryLookupStrategy(ApplicationEventPublisher publisher, @Nullable EntityCallbacks callbacks, - JdbcConverter converter, Dialect dialect, - QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, + CreateQueryLookupStrategy(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, ValueExpressionDelegate delegate) { - super(publisher, callbacks, converter, dialect, queryMappingConfiguration, operations, delegate); - - this.rowMapperFactory = new DefaultRowMapperFactory(getConverter(), - getQueryMappingConfiguration(), getCallbacks(), getPublisher()); + super(operations, rowMapperFactory, delegate); } @Override @@ -115,8 +90,7 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repository JdbcQueryMethod queryMethod = getJdbcQueryMethod(method, repositoryMetadata, projectionFactory, namedQueries); - return new PartTreeJdbcQuery(queryMethod, getDialect(), getConverter(), getOperations(), - rowMapperFactory); + return new PartTreeJdbcQuery(queryMethod, operations, rowMapperFactory); } } @@ -129,16 +103,9 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repository */ static class DeclaredQueryLookupStrategy extends JdbcQueryLookupStrategy { - private final RowMapperFactory rowMapperFactory; - - DeclaredQueryLookupStrategy(ApplicationEventPublisher publisher, @Nullable EntityCallbacks callbacks, - JdbcConverter converter, Dialect dialect, - QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, - @Nullable BeanFactory beanfactory, ValueExpressionDelegate delegate) { - super(publisher, callbacks, converter, dialect, queryMappingConfiguration, operations, delegate); - - this.rowMapperFactory = new BeanFactoryAwareRowMapperFactory(converter, queryMappingConfiguration, - callbacks, publisher, beanfactory); + DeclaredQueryLookupStrategy(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, + ValueExpressionDelegate delegate) { + super(operations, rowMapperFactory, delegate); } @Override @@ -156,8 +123,7 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repository String queryString = evaluateTableExpressions(repositoryMetadata, queryMethod.getRequiredQuery()); - return new StringBasedJdbcQuery(queryString, queryMethod, getOperations(), rowMapperFactory, getConverter(), - delegate); + return new StringBasedJdbcQuery(queryString, queryMethod, operations, rowMapperFactory, delegate); } throw new IllegalStateException( @@ -184,13 +150,11 @@ static class CreateIfNotFoundQueryLookupStrategy extends JdbcQueryLookupStrategy * @param createStrategy must not be {@literal null}. * @param lookupStrategy must not be {@literal null}. */ - CreateIfNotFoundQueryLookupStrategy(ApplicationEventPublisher publisher, @Nullable EntityCallbacks callbacks, - JdbcConverter converter, Dialect dialect, - QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, + CreateIfNotFoundQueryLookupStrategy(JdbcAggregateOperations operations, RowMapperFactory rowMapperFactory, CreateQueryLookupStrategy createStrategy, DeclaredQueryLookupStrategy lookupStrategy, ValueExpressionDelegate delegate) { - super(publisher, callbacks, converter, dialect, queryMappingConfiguration, operations, delegate); + super(operations, rowMapperFactory, delegate); Assert.notNull(createStrategy, "CreateQueryLookupStrategy must not be null"); Assert.notNull(lookupStrategy, "DeclaredQueryLookupStrategy must not be null"); @@ -223,33 +187,21 @@ JdbcQueryMethod getJdbcQueryMethod(Method method, RepositoryMetadata repositoryM * Creates a {@link QueryLookupStrategy} based on the provided * {@link org.springframework.data.repository.query.QueryLookupStrategy.Key}. * - * @param key the key that decides what {@link QueryLookupStrategy} shozld be used. - * @param publisher must not be {@literal null} - * @param callbacks may be {@literal null} - * @param context must not be {@literal null} - * @param converter must not be {@literal null} - * @param dialect must not be {@literal null} - * @param queryMappingConfiguration must not be {@literal null} + * @param key the key that decides what {@link QueryLookupStrategy} should be used. * @param operations must not be {@literal null} - * @param beanFactory may be {@literal null} */ - public static QueryLookupStrategy create(@Nullable Key key, ApplicationEventPublisher publisher, - @Nullable EntityCallbacks callbacks, JdbcConverter converter, Dialect dialect, - QueryMappingConfiguration queryMappingConfiguration, NamedParameterJdbcOperations operations, - @Nullable BeanFactory beanFactory, ValueExpressionDelegate delegate) { - - Assert.notNull(publisher, "ApplicationEventPublisher must not be null"); - Assert.notNull(converter, "JdbcConverter must not be null"); - Assert.notNull(dialect, "Dialect must not be null"); - Assert.notNull(queryMappingConfiguration, "QueryMappingConfiguration must not be null"); - Assert.notNull(operations, "NamedParameterJdbcOperations must not be null"); + public static QueryLookupStrategy create(@Nullable Key key, JdbcAggregateOperations operations, + RowMapperFactory rowMapperFactory, ValueExpressionDelegate delegate) { + + Assert.notNull(operations, "JdbcAggregateOperations must not be null"); + Assert.notNull(rowMapperFactory, "RowMapperFactory must not be null"); Assert.notNull(delegate, "ValueExpressionDelegate must not be null"); - CreateQueryLookupStrategy createQueryLookupStrategy = new CreateQueryLookupStrategy(publisher, callbacks, - converter, dialect, queryMappingConfiguration, operations, delegate); + CreateQueryLookupStrategy createQueryLookupStrategy = new CreateQueryLookupStrategy(operations, rowMapperFactory, + delegate); - DeclaredQueryLookupStrategy declaredQueryLookupStrategy = new DeclaredQueryLookupStrategy(publisher, callbacks, - converter, dialect, queryMappingConfiguration, operations, beanFactory, delegate); + DeclaredQueryLookupStrategy declaredQueryLookupStrategy = new DeclaredQueryLookupStrategy(operations, + rowMapperFactory, delegate); Key keyToUse = key != null ? key : Key.CREATE_IF_NOT_FOUND; @@ -258,29 +210,9 @@ public static QueryLookupStrategy create(@Nullable Key key, ApplicationEventPubl return switch (keyToUse) { case CREATE -> createQueryLookupStrategy; case USE_DECLARED_QUERY -> declaredQueryLookupStrategy; - case CREATE_IF_NOT_FOUND -> - new CreateIfNotFoundQueryLookupStrategy(publisher, callbacks, converter, dialect, - queryMappingConfiguration, operations, createQueryLookupStrategy, declaredQueryLookupStrategy, delegate); + case CREATE_IF_NOT_FOUND -> new CreateIfNotFoundQueryLookupStrategy(operations, rowMapperFactory, + createQueryLookupStrategy, declaredQueryLookupStrategy, delegate); }; } - JdbcConverter getConverter() { - return converter; - } - - NamedParameterJdbcOperations getOperations() { - return operations; - } - - QueryMappingConfiguration getQueryMappingConfiguration() { - return queryMappingConfiguration; - } - - EntityCallbacks getCallbacks() { - return callbacks; - } - - ApplicationEventPublisher getPublisher() { - return publisher; - } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactory.java index da27a3a16a..becbded0b3 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactory.java @@ -25,6 +25,7 @@ import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; +import org.springframework.data.jdbc.repository.query.RowMapperFactory; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -57,11 +58,9 @@ public class JdbcRepositoryFactory extends RepositoryFactorySupport implements ApplicationEventPublisherAware { private final JdbcAggregateOperations operations; - private final NamedParameterJdbcOperations jdbcOperations; - private EntityCallbacks entityCallbacks = EntityCallbacks.create(); - private ApplicationEventPublisher publisher = event -> {}; private @Nullable BeanFactory beanFactory; + private QueryMappingConfiguration queryMappingConfiguration = QueryMappingConfiguration.EMPTY; /** @@ -75,7 +74,6 @@ public JdbcRepositoryFactory(JdbcAggregateOperations operations) { Assert.notNull(operations, "JdbcAggregateOperations must not be null"); this.operations = operations; - this.jdbcOperations = operations.getDataAccessStrategy().getJdbcOperations(); } /** @@ -88,7 +86,9 @@ public JdbcRepositoryFactory(JdbcAggregateOperations operations) { * @param dialect must not be {@literal null}. * @param publisher must not be {@literal null}. * @param jdbcOperations must not be {@literal null}. + * @deprecated use {@link #JdbcRepositoryFactory(JdbcAggregateOperations)} for consistent configuration instead. */ + @Deprecated(since = "4.0", forRemoval = true) public JdbcRepositoryFactory(DataAccessStrategy dataAccessStrategy, RelationalMappingContext context, JdbcConverter converter, Dialect dialect, ApplicationEventPublisher publisher, NamedParameterJdbcOperations jdbcOperations) { @@ -100,27 +100,27 @@ public JdbcRepositoryFactory(DataAccessStrategy dataAccessStrategy, RelationalMa Assert.notNull(jdbcOperations, "NamedParameterJdbcOperations must not be null"); this.operations = new JdbcAggregateTemplate(publisher, context, converter, dataAccessStrategy); - this.jdbcOperations = jdbcOperations; - this.publisher = publisher; } + /** + * @param publisher event publisher to be used by this object. + * @deprecated no longer used nor supported. Use {@link #JdbcRepositoryFactory(JdbcAggregateOperations)} instead. + */ @Override + @Deprecated(since = "4.0", forRemoval = true) public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { - - Assert.notNull(publisher, "ApplicationEventPublisher must not be null"); - - this.publisher = publisher; + // no-op } /** * @param entityCallbacks * @since 1.1 + * @deprecated no longer used nor supported. Use {@link #JdbcRepositoryFactory(JdbcAggregateOperations)} instead. */ + @Deprecated(since = "4.0", forRemoval = true) public void setEntityCallbacks(EntityCallbacks entityCallbacks) { Assert.notNull(entityCallbacks, "EntityCallbacks must not be null"); - - this.entityCallbacks = entityCallbacks; } /** @@ -128,12 +128,7 @@ public void setEntityCallbacks(EntityCallbacks entityCallbacks) { * {@link org.springframework.jdbc.core.ResultSetExtractor} beans. */ public void setBeanFactory(@Nullable BeanFactory beanFactory) { - this.beanFactory = beanFactory; - - if (entityCallbacks == null && beanFactory != null) { - setEntityCallbacks(EntityCallbacks.create(beanFactory)); - } } /** @@ -178,11 +173,11 @@ protected Class getRepositoryBaseClass(RepositoryMetadata repositoryMetadata) protected Optional getQueryLookupStrategy(@Nullable QueryLookupStrategy.Key key, ValueExpressionDelegate valueExpressionDelegate) { - DataAccessStrategy strategy = operations.getDataAccessStrategy(); - JdbcConverter converter = operations.getConverter(); + RowMapperFactory rowMapperFactory = beanFactory != null + ? new BeanFactoryAwareRowMapperFactory(beanFactory, operations, queryMappingConfiguration) + : new DefaultRowMapperFactory(operations, queryMappingConfiguration); - return Optional.of(JdbcQueryLookupStrategy.create(key, publisher, entityCallbacks, converter, strategy.getDialect(), - queryMappingConfiguration, jdbcOperations, beanFactory, + return Optional.of(JdbcQueryLookupStrategy.create(key, operations, rowMapperFactory, new CachingValueExpressionDelegate(valueExpressionDelegate))); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactoryBean.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactoryBean.java index 3c23a2cdd8..8b36927911 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactoryBean.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/JdbcRepositoryFactoryBean.java @@ -23,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.JdbcAggregateTemplate; import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.DataAccessStrategyFactory; import org.springframework.data.jdbc.core.convert.JdbcConverter; @@ -193,13 +194,11 @@ protected RepositoryFactorySupport doCreateRepositoryFactory() { } else { Assert.state(this.dataAccessStrategy != null, "DataAccessStrategy is required and must not be null"); - Assert.state(this.mappingContext != null, "MappingContext is required and must not be null"); Assert.state(this.converter != null, "RelationalConverter is required and must not be null"); - Assert.state(this.dialect != null, "Dialect is required and must not be null"); - Assert.state(this.jdbcOperations != null, "NamedParameterJdbcOperations is required and must not be null"); - repositoryFactory = new JdbcRepositoryFactory(this.dataAccessStrategy, this.mappingContext, this.converter, - this.dialect, this.publisher, this.jdbcOperations); + JdbcAggregateOperations operations = new JdbcAggregateTemplate(converter, dataAccessStrategy); + + repositoryFactory = new JdbcRepositoryFactory(operations); repositoryFactory.setEntityCallbacks(entityCallbacks); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/AotJdbcRepositoryIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/AotJdbcRepositoryIntegrationTests.java new file mode 100644 index 0000000000..7fb014c679 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/AotJdbcRepositoryIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; +import org.springframework.data.jdbc.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.jdbc.repository.aot.UserRepository; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; +import org.springframework.data.jdbc.testing.DatabaseType; +import org.springframework.data.jdbc.testing.EnabledOnDatabase; +import org.springframework.data.jdbc.testing.IntegrationTest; +import org.springframework.data.jdbc.testing.TestClass; +import org.springframework.data.repository.core.support.RepositoryComposition; + +/** + * Integration test for {@link DummyEntityRepository} using JavaConfig with mounted AOT-generated repository methods. + * + * @author Mark Paluch + */ +@IntegrationTest +@EnabledOnDatabase(DatabaseType.H2) +class AotJdbcRepositoryIntegrationTests extends JdbcRepositoryIntegrationTests { + + @Configuration + @EnableJdbcRepositories(includeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = AotJdbcRepositoryIntegrationTests.class) }) + static class AotConfig extends Config { + + @Autowired ApplicationContext context; + + @Bean + TestClass testClass() { + return TestClass.of(JdbcRepositoryIntegrationTests.class); + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(UserRepository.class, JdbcH2Dialect.INSTANCE, AotConfig.class, + false); + } + + @Bean + @Override + DummyEntityRepository dummyEntityRepository() { + + RepositoryComposition.RepositoryFragments fragments = RepositoryComposition.RepositoryFragments + .just(context.getBean("fragment")); + + return factory.getRepository(DummyEntityRepository.class, fragments); + } + + } + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java index 10bd3eced8..0adedf3809 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java @@ -1515,11 +1515,12 @@ interface DummyProjectExample { String getName(); } - interface ProvidedIdEntityRepository extends CrudRepository { + public interface ProvidedIdEntityRepository extends CrudRepository { } - interface DummyEntityRepository extends CrudRepository, QueryByExampleExecutor { + public interface DummyEntityRepository + extends CrudRepository, QueryByExampleExecutor { @Lock(LockMode.PESSIMISTIC_WRITE) List findAllByName(String name); @@ -1607,15 +1608,15 @@ interface DummyEntityRepository extends CrudRepository, Query List findByBytes(byte[] bytes); } - interface RootRepository extends ListCrudRepository { + public interface RootRepository extends ListCrudRepository { List findAllByOrderByIdAsc(); } - interface WithDelimitedColumnRepository extends CrudRepository {} + public interface WithDelimitedColumnRepository extends CrudRepository {} - interface EntityWithSequenceRepository extends CrudRepository {} + public interface EntityWithSequenceRepository extends CrudRepository {} - interface ExpressionSqlTypePropagationRepository extends CrudRepository { + public interface ExpressionSqlTypePropagationRepository extends CrudRepository { // language=sql @Modifying @@ -1626,7 +1627,7 @@ INSERT INTO EXPRESSION_SQL_TYPE_PROPAGATION(identifier, enum_class) void saveWithSpel(@Param("expressionSqlTypePropagation") ExpressionSqlTypePropagation expressionSqlTypePropagation); } - interface DummyProjection { + public interface DummyProjection { String getName(); } @@ -2029,7 +2030,7 @@ public boolean isNew() { } } - static class DummyEntity { + public static class DummyEntity { @Id Long idProp; String name; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/AotFragmentTestConfigurationSupport.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/AotFragmentTestConfigurationSupport.java new file mode 100644 index 0000000000..1ac0667468 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,198 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.mockito.Mockito; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.RuntimeBeanReference; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.DefaultBeanNameGenerator; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; +import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; +import org.springframework.data.jdbc.repository.query.RowMapperFactory; +import org.springframework.data.jdbc.repository.support.BeanFactoryAwareRowMapperFactory; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.util.ReflectionUtils; + +/** + * Test Configuration Support Class for generated AOT Repository Fragments based on a Repository Interface. + *

+ * This configuration generates the AOT repository, compiles sources and configures a BeanFactory to contain the AOT + * fragment. Additionally, the fragment is exposed through a {@code repositoryInterface} JDK proxy forwarding method + * invocations to the backing AOT fragment. Note that {@code repositoryInterface} is not a repository proxy. + * + * @author Mark Paluch + */ +public class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor, ApplicationContextAware { + + private final Class repositoryInterface; + private final JdbcDialect dialect; + private final boolean registerFragmentFacade; + private final TestJdbcAotRepositoryContext repositoryContext; + private ApplicationContext applicationContext; + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, JdbcDialect dialect, Class configClass) { + this(repositoryInterface, dialect, configClass, true); + } + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, JdbcDialect dialect, Class configClass, + boolean registerFragmentFacade, Class... additionalFragments) { + + this.repositoryInterface = repositoryInterface; + this.dialect = dialect; + + RepositoryComposition composition = RepositoryComposition + .of((List) Arrays.stream(additionalFragments).map(RepositoryFragment::structural).toList()); + this.repositoryContext = new TestJdbcAotRepositoryContext<>(repositoryInterface, composition, + new AnnotationRepositoryConfigurationSource(AnnotationMetadata.introspect(configClass), + EnableJdbcRepositories.class, new DefaultResourceLoader(), new StandardEnvironment(), + Mockito.mock(BeanDefinitionRegistry.class), DefaultBeanNameGenerator.INSTANCE)); + this.registerFragmentFacade = registerFragmentFacade; + } + + @Bean + BeanFactoryAwareRowMapperFactory rowMapperFactory(ApplicationContext context, + JdbcAggregateOperations aggregateOperations, Optional queryMappingConfiguration) { + return new BeanFactoryAwareRowMapperFactory(context, aggregateOperations, + queryMappingConfiguration.orElse(QueryMappingConfiguration.EMPTY)); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + TestGenerationContext generationContext = new TestGenerationContext(repositoryInterface); + + repositoryContext.setBeanFactory(beanFactory); + + JdbcRepositoryContributor jdbcRepositoryContributor = new JdbcRepositoryContributor(repositoryContext, dialect, + new MappingJdbcConverter(new JdbcMappingContext(), (identifier, path) -> null)); + jdbcRepositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition(repositoryInterface.getName() + "Impl__AotRepository") + .addConstructorArgValue(new RuntimeBeanReference(JdbcAggregateOperations.class)) + .addConstructorArgValue(new RuntimeBeanReference(RowMapperFactory.class)) + .addConstructorArgValue( + getCreationContext(repositoryContext, beanFactory.getBean(Environment.class), beanFactory)) + .getBeanDefinition(); + + generationContext.writeGeneratedContent(); + + TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> { + beanFactory.setBeanClassLoader(compiled.getClassLoader()); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository); + }); + + if (registerFragmentFacade) { + + BeanDefinition fragmentFacade = BeanDefinitionBuilder.rootBeanDefinition((Class) repositoryInterface, () -> { + + Object fragment = beanFactory.getBean("fragment"); + Object proxy = getFragmentFacadeProxy(fragment); + + return repositoryInterface.cast(proxy); + }).getBeanDefinition(); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragmentFacade", fragmentFacade); + } + } + + private Object getFragmentFacadeProxy(Object fragment) { + + return Proxy.newProxyInstance(repositoryInterface.getClassLoader(), new Class[] { repositoryInterface }, + (p, method, args) -> { + + Method target = ReflectionUtils.findMethod(fragment.getClass(), method.getName(), method.getParameterTypes()); + + if (target == null) { + throw new NoSuchMethodException("Method [%s] is not implemented by [%s]".formatted(method, target)); + } + + try { + return target.invoke(fragment, args); + } catch (ReflectiveOperationException e) { + ReflectionUtils.handleReflectionException(e); + } + + return null; + }); + } + + private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext( + TestJdbcAotRepositoryContext repositoryContext, Environment environment, ListableBeanFactory beanFactory) { + + RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() { + @Override + public RepositoryMetadata getRepositoryMetadata() { + return repositoryContext.getRepositoryInformation(); + } + + @Override + public ValueExpressionDelegate getValueExpressionDelegate() { + + QueryMethodValueEvaluationContextAccessor accessor = new QueryMethodValueEvaluationContextAccessor(environment, + beanFactory); + return new ValueExpressionDelegate(accessor, ValueExpressionParser.create()); + } + + @Override + public ProjectionFactory getProjectionFactory() { + return new SpelAwareProxyProjectionFactory(); + } + }; + + return creationContext; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java new file mode 100644 index 0000000000..a894545705 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryContributorIntegrationTests.java @@ -0,0 +1,355 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; +import org.springframework.data.jdbc.testing.DatabaseType; +import org.springframework.data.jdbc.testing.EnabledOnDatabase; +import org.springframework.data.jdbc.testing.IntegrationTest; +import org.springframework.data.jdbc.testing.TestClass; +import org.springframework.data.jdbc.testing.TestConfiguration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for AOT processing via {@link JdbcRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = JdbcRepositoryContributorIntegrationTests.JdbcRepositoryContributorConfiguration.class) +@IntegrationTest +@EnabledOnDatabase(DatabaseType.H2) +class JdbcRepositoryContributorIntegrationTests { + + @Autowired UserRepository fragment; + @Autowired JdbcAggregateOperations operations; + + @Configuration + @EnableJdbcRepositories(jdbcAggregateOperationsRef = "jdbcAggregateOperations", considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = String.class) }) + @Import(TestConfiguration.class) + static class JdbcRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public JdbcRepositoryContributorConfiguration() { + super(UserRepository.class, JdbcH2Dialect.INSTANCE, JdbcRepositoryContributorConfiguration.class); + } + + @Bean + TestClass testClass() { + return TestClass.of(JdbcRepositoryContributorIntegrationTests.class); + } + + @Bean + MyRowMapper myRowMapper() { + return new MyRowMapper(); + } + + @Bean + SimpleResultSetExtractor simpleResultSetExtractor() { + return new SimpleResultSetExtractor(); + } + + } + + @BeforeEach + void beforeEach() { + + operations.deleteAll(User.class); + + operations.insert(new User("Walter", 52)); + operations.insert(new User("Skyler", 40)); + operations.insert(new User("Flynn", 16)); + operations.insert(new User("Mike", 62)); + operations.insert(new User("Gustavo", 51)); + operations.insert(new User("Hector", 83)); + } + + @Test // GH-2121 + void shouldFindByFirstname() { + + User walter = fragment.findByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldFindOptionalByFirstname() { + + assertThat(fragment.findOptionalByFirstname("Walter")).isPresent(); + assertThat(fragment.findOptionalByFirstname("Hank")).isEmpty(); + } + + @Test // GH-2121 + void shouldFindByFirstnameLike() { + + User walter = fragment.findByFirstnameLike("%alter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldFindByFirstnameStartingWith() { + + User walter = fragment.findByFirstnameStartingWith("Wa"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + + walter = fragment.findByFirstnameStartingWith("Wa%"); + + assertThat(walter).isNull(); // % is escaped + } + + @Test // GH-2121 + void shouldFindByFirstnameEndingWith() { + + User walter = fragment.findByFirstnameEndingWith("lter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + + walter = fragment.findByFirstnameEndingWith("$lter"); + + assertThat(walter).isNull(); // % is escaped + } + + @Test // GH-2121 + void shouldFindBetween() { + + List users = fragment.findAllByAgeBetween(40, 51); + + assertThat(users).hasSize(2); + } + + @Test // GH-2121 + void streamByAgeGreaterThan() { + assertThat(fragment.streamByAgeGreaterThan(20)).hasSize(5); + } + + @Test // GH-2121 + void shouldReturnSlice() { + + Slice slice = fragment.findSliceByAgeGreaterThan(Pageable.ofSize(4), 10); + + assertThat(slice).hasSize(4); + + assertThat(slice.hasNext()).isTrue(); + slice = fragment.findSliceByAgeGreaterThan(Pageable.ofSize(6), 10); + + assertThat(slice).hasSize(6); + assertThat(slice.hasNext()).isFalse(); + } + + @Test // GH-2121 + void shouldReturnPage() { + + Page page = fragment.findPageByAgeGreaterThan(PageRequest.of(0, 4, Sort.by("age")), 10); + + assertThat(page).hasSize(4); + + assertThat(page.hasNext()).isTrue(); + page = fragment.findPageByAgeGreaterThan(page.nextPageable(), 10); + + assertThat(page).hasSize(2); + assertThat(page.hasNext()).isFalse(); + } + + @Test // GH-2121 + void countByAgeLessThan() { + + long count = fragment.countByAgeLessThan(20); + + assertThat(count).isOne(); + } + + @Test // GH-2121 + void countShortByAgeLessThan() { + + short count = fragment.countShortByAgeLessThan(20); + + assertThat(count).isOne(); + } + + @Test // GH-2121 + void existsByAgeLessThan() { + + assertThat(fragment.existsByAgeLessThan(20)).isTrue(); + assertThat(fragment.existsByAgeLessThan(5)).isFalse(); + } + + @Test // GH-2121 + void listWithLimit() { + + List users = fragment.findTop5ByOrderByAge(); + + assertThat(users).hasSize(5).extracting(User::getFirstname).containsSequence("Flynn", "Skyler", "Gustavo", "Walter", + "Mike"); + } + + @Test // GH-2121 + void shouldFindAnnotatedByFirstname() { + + User walter = fragment.findByFirstnameAnnotated("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldFindAnnotatedByFirstnameExpression() { + + User walter = fragment.findByFirstnameExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldFindUsingRowMapper() { + + User walter = fragment.findUsingRowMapper("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Row: 0"); + } + + @Test // GH-2121 + void shouldFindUsingRowMapperRef() { + + User walter = fragment.findUsingRowMapperRef("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Row: 0"); + } + + @Test // GH-2121 + void shouldFindUsingResultSetExtractor() { + + int result = fragment.findUsingAndResultSetExtractor("Walter"); + + assertThat(result).isOne(); + } + + @Test // GH-2121 + void shouldFindUsingResultSetExtractorRef() { + + int result = fragment.findUsingAndResultSetExtractorRef("Walter"); + + assertThat(result).isOne(); + } + + @Test // GH-2121 + void shouldProjectOneToDto() { + + UserDto dto = fragment.findOneDtoByFirstname("Walter"); + + assertThat(dto).isNotNull(); + assertThat(dto.firstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldProjectListToDto() { + + List dtos = fragment.findDtoByFirstname("Walter"); + + assertThat(dtos).hasSize(1).extracting(UserDto::firstname).containsOnly("Walter"); + } + + @Test // GH-2121 + void shouldProjectOneToInterface() { + + UserProjection projection = fragment.findOneInterfaceByFirstname("Walter"); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-2121 + void shouldProjectListToInterface() { + + List projections = fragment.findInterfaceByFirstname("Walter"); + + assertThat(projections).hasSize(1).extracting(UserProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-2121 + void shouldProjectDynamically() { + + List dtos = fragment.findDynamicProjectionByFirstname("Walter", UserDto.class); + assertThat(dtos).hasSize(1).extracting(UserDto::firstname).containsOnly("Walter"); + + List projections = fragment.findDynamicProjectionByFirstname("Walter", UserProjection.class); + assertThat(projections).hasSize(1).extracting(UserProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-2121 + void shouldDeleteByName() { + + assertThat(fragment.deleteByFirstname("Walter")).isTrue(); + assertThat(fragment.deleteByFirstname("Walter")).isFalse(); + } + + @Test // GH-2121 + void shouldDeleteCountByName() { + + assertThat(fragment.deleteCountByFirstname("Walter")).isOne(); + assertThat(fragment.deleteCountByFirstname("Walter")).isZero(); + } + + @Test // GH-2121 + void shouldDeleteAnnotated() { + + assertThat(fragment.deleteAnnotatedQuery("Walter")).isOne(); + assertThat(fragment.deleteAnnotatedQuery("Walter")).isZero(); + } + + @Test // GH-2121 + void shouldDeleteWithoutResult() { + + fragment.deleteWithoutResult("Walter"); + + assertThat(fragment.findByFirstname("Walter")).isNull(); + } + + @Test // GH-2121 + void shouldDeleteAndReturnByName() { + + assertThat(fragment.deleteOneByFirstname("Walter")).isNotNull(); + assertThat(fragment.deleteOneByFirstname("Walter")).isNull(); + } + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java new file mode 100644 index 0000000000..f1b0c66003 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/JdbcRepositoryMetadataIntegrationTests.java @@ -0,0 +1,250 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.JdbcAggregateTemplate; +import org.springframework.data.jdbc.core.convert.DataAccessStrategy; +import org.springframework.data.jdbc.core.convert.Identifier; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; +import org.springframework.data.jdbc.core.convert.RelationResolver; +import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for the {@link UserRepository} JSON metadata via {@link JdbcRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = JdbcRepositoryMetadataIntegrationTests.JdbcRepositoryContributorConfiguration.class) +class JdbcRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + @EnableJdbcRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = String.class) }) + static class JdbcRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + public JdbcRepositoryContributorConfiguration() { + super(UserRepository.class, JdbcH2Dialect.INSTANCE, JdbcRepositoryContributorConfiguration.class); + } + + @Bean + RelationalMappingContext mappingContext() { + return new RelationalMappingContext(); + } + + @Bean + JdbcConverter converter(RelationalMappingContext mappingContext) { + return new MappingJdbcConverter(mappingContext, new RelationResolver() { + @Override + public Iterable findAllByPath(Identifier identifier, + PersistentPropertyPath path) { + return null; + } + }); + } + + @Bean + JdbcAggregateOperations operations(JdbcConverter converter) { + + DataAccessStrategy strategy = mock(DataAccessStrategy.class); + when(strategy.getDialect()).thenReturn(JdbcH2Dialect.INSTANCE); + return new JdbcAggregateTemplate(converter, strategy); + } + + } + + @Test // GH-2121 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", UserRepository.class.getName()) // + .containsEntry("module", "JDBC") // + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-2121 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findByFirstname')].query").isArray().first().isObject() + .containsEntry("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"); + } + + @Test // GH-2121 + void shouldDocumentDerivedQueryWithParam() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json) + .inPath("$.methods[?(@.name == 'findWithParameterNameByFirstnameStartingWithOrFirstnameEndingWith')].query") + .isArray().first().isObject().containsEntry("query", + "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" LIKE :firstname OR (\"MY_USER\".\"FIRSTNAME\" LIKE :firstname1)"); + } + + @Test // GH-2121 + void shouldDocumentPagedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findPageByAgeGreaterThan')].query").isArray().element(0) + .isObject() + .containsEntry("query", + "SELECT \"MY_USER\".\"ID\" AS \"ID\", \"MY_USER\".\"AGE\" AS \"AGE\", \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"AGE\" > :age") + .containsEntry("count-query", "SELECT COUNT(*) FROM \"MY_USER\" WHERE \"MY_USER\".\"AGE\" > :age"); + } + + @Test // GH-2121 + void shouldDocumentQueryWithExpression() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findByFirstnameExpression')].query").isArray().first() + .isObject().containsEntry("query", "SELECT * FROM MY_USER WHERE firstname = :__$synthetic$__1"); + } + + @Test // GH-2121 + void shouldDocumentAnnotatedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findByFirstnameAnnotated')].query").isArray().first().isObject() + .containsEntry("query", "SELECT * FROM MY_USER WHERE firstname = :name"); + } + + @Test // GH-2121 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findByNamedQuery')].query").isArray().first().isObject() + .containsEntry("name", "User.findByNamedQuery").containsEntry("query", "SELECT * FROM USER WHERE NAME = :name"); + } + + @Test // GH-2121 + void shouldDocumentExplicitlyNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findByAnnotatedNamedQuery')].query").isArray().first() + .isObject().containsEntry("name", "User.findBySomeAnnotatedNamedQuery") + .containsEntry("query", "SELECT ANNOTATED FROM USER WHERE NAME = :name"); + } + + @Test // GH-2121 + void shouldDocumentInterfaceProjection() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findInterfaceByFirstname')].query").isArray().first().isObject() + .containsEntry("query", + "SELECT \"MY_USER\".\"FIRSTNAME\" AS \"FIRSTNAME\" FROM \"MY_USER\" WHERE \"MY_USER\".\"FIRSTNAME\" = :firstname"); + } + + @Test // GH-2121 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.jdbc.repository.support.SimpleJdbcRepository"); + } + + private Resource getResource() { + + String location = UserRepository.class.getPackageName().replace('.', '/') + "/" + + UserRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyResultSetExtractor.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyResultSetExtractor.java new file mode 100644 index 0000000000..88fa2afe95 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyResultSetExtractor.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; + +/** + * @author Mark Paluch + */ +public class MyResultSetExtractor implements ResultSetExtractor { + + private final RowMapper rowMapper; + + public MyResultSetExtractor(RowMapper rowMapper) { + this.rowMapper = rowMapper; + } + + @Override + public User extractData(ResultSet rs) throws SQLException, DataAccessException { + return rs.next() ? rowMapper.mapRow(rs, 0) : null; + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyRowMapper.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyRowMapper.java new file mode 100644 index 0000000000..d10d027ad9 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/MyRowMapper.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.sql.ResultSet; + +import org.springframework.jdbc.core.RowMapper; + +/** + * @author Mark Paluch + */ +public class MyRowMapper implements RowMapper { + + @Override + public User mapRow(ResultSet rs, int rowNum) { + return new User("Row: " + rowNum, rowNum); + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/SimpleResultSetExtractor.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/SimpleResultSetExtractor.java new file mode 100644 index 0000000000..060a6fb8d4 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/SimpleResultSetExtractor.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.ResultSetExtractor; + +/** + * @author Mark Paluch + */ +public class SimpleResultSetExtractor implements ResultSetExtractor { + + @Override + public Integer extractData(ResultSet rs) throws SQLException, DataAccessException { + return rs.next() ? 1 : 0; + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/TestJdbcAotRepositoryContext.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/TestJdbcAotRepositoryContext.java new file mode 100644 index 0000000000..67162f4684 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/TestJdbcAotRepositoryContext.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.jdbc.repository.support.SimpleJdbcRepository; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.AotRepositoryInformation; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AnnotationRepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; + +/** + * Test {@link AotRepositoryContext} implementation for JDBC repositories. + * + * @author Mark Paluch + */ +public class TestJdbcAotRepositoryContext implements AotRepositoryContext { + + private final AotRepositoryInformation repositoryInformation; + private final Class repositoryInterface; + private final RepositoryConfigurationSource configurationSource; + private @Nullable ConfigurableListableBeanFactory beanFactory; + + public TestJdbcAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition, + RepositoryConfigurationSource configurationSource) { + this.repositoryInterface = repositoryInterface; + this.configurationSource = configurationSource; + + RepositoryMetadata metadata = AnnotationRepositoryMetadata.getMetadata(repositoryInterface); + + RepositoryComposition.RepositoryFragments fragments = RepositoryComposition.RepositoryFragments.empty(); + + this.repositoryInformation = new AotRepositoryInformation(metadata, SimpleJdbcRepository.class, + composition.append(fragments).getFragments().stream().toList()); + } + + public Class getRepositoryInterface() { + return repositoryInterface; + } + + @Override + public ConfigurableListableBeanFactory getBeanFactory() { + return beanFactory; + } + + @Override + public Environment getEnvironment() { + return new StandardEnvironment(); + } + + @Override + public TypeIntrospector introspectType(String typeName) { + return null; + } + + @Override + public IntrospectedBeanDefinition introspectBeanDefinition(String beanName) { + return null; + } + + @Override + public String getBeanName() { + return "dummyRepository"; + } + + @Override + public String getModuleName() { + return "JDBC"; + } + + @Override + public RepositoryConfigurationSource getConfigurationSource() { + return configurationSource; + } + + @Override + public Set getBasePackages() { + return Set.of("org.springframework.data.dummy.repository.aot"); + } + + @Override + public Set> getIdentifyingAnnotations() { + return Set.of(Table.class); + } + + @Override + public RepositoryInformation getRepositoryInformation() { + return repositoryInformation; + } + + @Override + public Set> getResolvedAnnotations() { + return Set.of(); + } + + @Override + public Set> getResolvedTypes() { + return Set.of(User.class); + } + + public void setBeanFactory(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java new file mode 100644 index 0000000000..7497b6af26 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/User.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +/** + * @author Mark Paluch + */ +@Table("MY_USER") +public class User { + + private @Id long id; + private String firstname; + private int age; + + public User(String firstname, int age) { + this.firstname = firstname; + this.age = age; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserDto.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserDto.java new file mode 100644 index 0000000000..d2e12b27bc --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserDto.java @@ -0,0 +1,22 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +/** + * @author Mark Paluch + */ +public record UserDto(String firstname) { +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserProjection.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserProjection.java new file mode 100644 index 0000000000..9d0e3b1b48 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserProjection.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +/** + * @author Mark Paluch + */ +public interface UserProjection { + + String getFirstname(); +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java new file mode 100644 index 0000000000..9362a44d87 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/aot/UserRepository.java @@ -0,0 +1,144 @@ +/* + * Copyright 2025 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 + * + * https://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 org.springframework.data.jdbc.repository.aot; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jdbc.repository.query.Modifying; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +public interface UserRepository extends CrudRepository { + + // ------------------------------------------------------------------------- + // Derived Queries + // ------------------------------------------------------------------------- + + User findByFirstname(String name); + + User findByFirstnameLike(String name); + + User findByFirstnameStartingWith(String name); + + User findByFirstnameEndingWith(String name); + + List findAllByAgeBetween(int start, int end); + + Optional findOptionalByFirstname(String name); + + Stream streamByAgeGreaterThan(int age); + + long countByAgeLessThan(int age); + + short countShortByAgeLessThan(int age); + + boolean existsByAgeLessThan(int age); + + List findTop5ByOrderByAge(); + + Slice findSliceByAgeGreaterThan(Pageable pageable, int age); + + Page findPageByAgeGreaterThan(Pageable pageable, int age); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + @Query("SELECT * FROM MY_USER WHERE firstname = :name") + User findByFirstnameAnnotated(String name); + + @Query("SELECT * FROM MY_USER WHERE firstname = :#{#name}") + User findByFirstnameExpression(String name); + + @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", rowMapperClass = MyRowMapper.class) + User findUsingRowMapper(String name); + + @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", rowMapperClass = MyRowMapper.class, + resultSetExtractorClass = MyResultSetExtractor.class) + User findUsingRowMapperAndResultSetExtractor(String name); + + @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", rowMapperRef = "myRowMapper") + User findUsingRowMapperRef(String name); + + @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", + resultSetExtractorClass = SimpleResultSetExtractor.class) + int findUsingAndResultSetExtractor(String name); + + @Query(value = "SELECT * FROM MY_USER WHERE firstname = :name", resultSetExtractorRef = "simpleResultSetExtractor") + int findUsingAndResultSetExtractorRef(String name); + + // ------------------------------------------------------------------------- + // Parameter naming + // ------------------------------------------------------------------------- + + @Query("select u from User u where u.lastname like :name or u.lastname like :name ORDER BY u.lastname") + List findAnnotatedWithParameterNameQuery(@Param("name") String lastname); + + List findWithParameterNameByFirstnameStartingWithOrFirstnameEndingWith(@Param("l1") String l1, + @Param("l2") String l2); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + User findByNamedQuery(String name); + + @Query(name = "User.findBySomeAnnotatedNamedQuery") + User findByAnnotatedNamedQuery(String name); + + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + UserDto findOneDtoByFirstname(String name); + + List findDtoByFirstname(String name); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + UserProjection findOneInterfaceByFirstname(String name); + + List findInterfaceByFirstname(String name); + + List findDynamicProjectionByFirstname(String name, Class type); + + // ------------------------------------------------------------------------- + // Modifying + // ------------------------------------------------------------------------- + + boolean deleteByFirstname(String name); + + int deleteCountByFirstname(String name); + + User deleteOneByFirstname(String name); + + @Modifying + @Query("delete from MY_USER where firstname = :firstname") + int deleteAnnotatedQuery(String firstname); + + @Modifying + @Query("delete from MY_USER where firstname = :firstname") + void deleteWithoutResult(String firstname); + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategyUnitTests.java index 3dbc83ae6c..0085339af9 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategyUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/support/JdbcQueryLookupStrategyUnitTests.java @@ -20,7 +20,6 @@ import static org.mockito.Mockito.*; import java.lang.reflect.Method; -import java.text.NumberFormat; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; @@ -28,13 +27,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.context.ApplicationEventPublisher; + +import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.QueryMappingConfiguration; import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; import org.springframework.data.jdbc.repository.config.DefaultQueryMappingConfiguration; import org.springframework.data.jdbc.repository.query.Query; -import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.repository.core.NamedQueries; @@ -63,25 +63,29 @@ */ class JdbcQueryLookupStrategyUnitTests { - private ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); - private EntityCallbacks callbacks = mock(EntityCallbacks.class); - private RelationalMappingContext mappingContext = mock(RelationalMappingContext.class, RETURNS_DEEP_STUBS); + private JdbcAggregateOperations operations = mock(JdbcAggregateOperations.class); private JdbcConverter converter = mock(JdbcConverter.class); private ProjectionFactory projectionFactory = mock(ProjectionFactory.class); private RepositoryMetadata metadata; private NamedQueries namedQueries = mock(NamedQueries.class); - private NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class); + private NamedParameterJdbcOperations jdbcOperations = mock(NamedParameterJdbcOperations.class); + private DataAccessStrategy strategy = mock(DataAccessStrategy.class); @BeforeEach void setup() { this.metadata = mock(RepositoryMetadata.class); - when(converter.getMappingContext()).thenReturn(mappingContext); + when(strategy.getJdbcOperations()).thenReturn(jdbcOperations); + when(strategy.getDialect()).thenReturn(JdbcH2Dialect.INSTANCE); + when(converter.getMappingContext()).thenReturn(new RelationalMappingContext()); + when(operations.getConverter()).thenReturn(converter); + when(operations.getDataAccessStrategy()).thenReturn(strategy); doReturn(NumberFormat.class).when(metadata).getReturnedDomainClass(any(Method.class)); doReturn(TypeInformation.of(NumberFormat.class)).when(metadata).getReturnType(any(Method.class)); doReturn(TypeInformation.of(NumberFormat.class)).when(metadata).getDomainTypeInformation(); + doReturn(NumberFormat.class).when(metadata).getDomainType(); } @Test // DATAJDBC-166 @@ -97,7 +101,7 @@ void typeBasedRowMapperGetsUsedForQuery() { repositoryQuery.execute(new Object[] {}); - verify(operations).queryForObject(anyString(), any(SqlParameterSource.class), any(RowMapper.class)); + verify(jdbcOperations).queryForObject(anyString(), any(SqlParameterSource.class), any(RowMapper.class)); } @Test // GH-1061 @@ -112,7 +116,7 @@ void prefersDeclaredQuery() { repositoryQuery.execute(new Object[] {}); - verify(operations).queryForObject(eq("some SQL"), any(SqlParameterSource.class), any(RowMapper.class)); + verify(jdbcOperations).queryForObject(eq("some SQL"), any(SqlParameterSource.class), any(RowMapper.class)); } @Test // GH-1043 @@ -137,8 +141,8 @@ void correctLookUpStrategyForKey(QueryLookupStrategy.Key key, Class expectedClas QueryMappingConfiguration mappingConfiguration = new DefaultQueryMappingConfiguration() .registerRowMapper(NumberFormat.class, numberFormatMapper); - QueryLookupStrategy queryLookupStrategy = JdbcQueryLookupStrategy.create(key, publisher, callbacks, - converter, JdbcH2Dialect.INSTANCE, mappingConfiguration, operations, null, ValueExpressionDelegate.create()); + QueryLookupStrategy queryLookupStrategy = JdbcQueryLookupStrategy.create(key, operations, + new DefaultRowMapperFactory(operations, mappingConfiguration), ValueExpressionDelegate.create()); assertThat(queryLookupStrategy).isInstanceOf(expectedClass); } @@ -157,8 +161,8 @@ private static Stream correctLookUpStrategyForKeySource() { private RepositoryQuery getRepositoryQuery(QueryLookupStrategy.Key key, String name, QueryMappingConfiguration mappingConfiguration) { - QueryLookupStrategy queryLookupStrategy = JdbcQueryLookupStrategy.create(key, publisher, callbacks, - converter, JdbcH2Dialect.INSTANCE, mappingConfiguration, operations, null, ValueExpressionDelegate.create()); + QueryLookupStrategy queryLookupStrategy = JdbcQueryLookupStrategy.create(key, operations, + new DefaultRowMapperFactory(operations, mappingConfiguration), ValueExpressionDelegate.create()); Method method = ReflectionUtils.findMethod(MyRepository.class, name); return queryLookupStrategy.resolveQuery(method, metadata, projectionFactory, namedQueries); @@ -166,7 +170,7 @@ private RepositoryQuery getRepositoryQuery(QueryLookupStrategy.Key key, String n interface MyRepository { - // NumberFormat is just used as an arbitrary non simple type. + // NumberFormat is just used as an arbitrary non-simple type. @Query("some SQL") NumberFormat returningNumberFormat(); @@ -175,4 +179,8 @@ interface MyRepository { NumberFormat findByName(); } + + record NumberFormat(String numberFormatString) { + + } } diff --git a/spring-data-jdbc/src/test/resources/META-INF/jdbc-named-queries.properties b/spring-data-jdbc/src/test/resources/META-INF/jdbc-named-queries.properties index 217ad9e1a4..d6a6912cea 100644 --- a/spring-data-jdbc/src/test/resources/META-INF/jdbc-named-queries.properties +++ b/spring-data-jdbc/src/test/resources/META-INF/jdbc-named-queries.properties @@ -1,2 +1,4 @@ DummyEntity.findAllByNamedQuery=SELECT * FROM DUMMY_ENTITY -DummyEntity.customQuery=SELECT * FROM DUMMY_ENTITY \ No newline at end of file +DummyEntity.customQuery=SELECT * FROM DUMMY_ENTITY +User.findByNamedQuery=SELECT * FROM USER WHERE NAME = :name +User.findBySomeAnnotatedNamedQuery=SELECT ANNOTATED FROM USER WHERE NAME = :name diff --git a/spring-data-jdbc/src/test/resources/logback.xml b/spring-data-jdbc/src/test/resources/logback.xml index 67cda4afc6..5cc5ea3fe8 100644 --- a/spring-data-jdbc/src/test/resources/logback.xml +++ b/spring-data-jdbc/src/test/resources/logback.xml @@ -10,6 +10,7 @@ + diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql new file mode 100644 index 0000000000..ef373c709c --- /dev/null +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.aot/JdbcRepositoryContributorIntegrationTests-h2.sql @@ -0,0 +1,6 @@ +CREATE TABLE MY_USER +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ), + firstname VARCHAR(255), + age INT +); diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index 64ff1ebcb3..dc864de59e 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 8fd6d7a6f0..f3f81a5abd 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-GH-2121-SNAPSHOT diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/ParameterMetadataProvider.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/ParameterMetadataProvider.java index 35a3ee92b3..a9e302aca1 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/ParameterMetadataProvider.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/ParameterMetadataProvider.java @@ -34,7 +34,7 @@ * @author Mark Paluch * @since 2.0 */ -class ParameterMetadataProvider implements Iterable { +public class ParameterMetadataProvider implements Iterable { private static final Object VALUE_PLACEHOLDER = new Object(); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalQueryCreator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalQueryCreator.java index 577138d24f..3214e8ff09 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalQueryCreator.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/RelationalQueryCreator.java @@ -50,7 +50,11 @@ public RelationalQueryCreator(PartTree tree, RelationalParameterAccessor accesso super(tree); Assert.notNull(accessor, "RelationalParameterAccessor must not be null"); - this.criteriaFactory = new CriteriaFactory(new ParameterMetadataProvider(accessor)); + this.criteriaFactory = new CriteriaFactory(getParameterMetadataProvider(accessor)); + } + + protected ParameterMetadataProvider getParameterMetadataProvider(RelationalParameterAccessor accessor) { + return new ParameterMetadataProvider(accessor); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/SimpleRelationalEntityMetadata.java b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/SimpleRelationalEntityMetadata.java index 092dedac6a..fea1037ccc 100755 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/SimpleRelationalEntityMetadata.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/repository/query/SimpleRelationalEntityMetadata.java @@ -29,6 +29,20 @@ public class SimpleRelationalEntityMetadata implements RelationalEntityMetada private final Class type; private final RelationalPersistentEntity tableEntity; + /** + * Creates a new {@link SimpleRelationalEntityMetadata} using the given {@link RelationalPersistentEntity} to use for + * table lookups. + * + * @param tableEntity must not be {@literal null}. + */ + public SimpleRelationalEntityMetadata(RelationalPersistentEntity tableEntity) { + + Assert.notNull(tableEntity, "Table entity must not be null"); + + this.type = (Class) tableEntity.getType(); + this.tableEntity = tableEntity; + } + /** * Creates a new {@link SimpleRelationalEntityMetadata} using the given type and {@link RelationalPersistentEntity} to * use for table lookups. diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/CriteriaFactoryUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/CriteriaFactoryUnitTests.java index 6461423e57..a5c49cf396 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/CriteriaFactoryUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/repository/query/CriteriaFactoryUnitTests.java @@ -17,12 +17,12 @@ import static org.assertj.core.api.Assertions.*; - import java.lang.reflect.Method; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; + import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.repository.Repository; diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index b139edc8fc..f63a4c08b5 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -32,6 +32,7 @@ ** xref:jdbc/auditing.adoc[] ** xref:jdbc/transactions.adoc[] ** xref:jdbc/schema-support.adoc[] +** xref:jdbc/aot.adoc[] * xref:r2dbc.adoc[] ** xref:r2dbc/getting-started.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/jdbc/aot.adoc b/src/main/antora/modules/ROOT/pages/jdbc/aot.adoc new file mode 100644 index 0000000000..a7c1477d59 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/jdbc/aot.adoc @@ -0,0 +1,185 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.bestpractices]] +== Best Practices + +=== Annotate your Domain Types + +During application startup, Spring scans the classpath for domain classes for early processing of entities. +By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints. +Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time. +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 `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. + +NOTE: Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable JDBC AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.jdbc.repositories.enabled=false` property to disable only JDBC AOT repositories. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. + +NOTE: Please provide a `JdbcDialect` to avoid early database access caused by dialect detection. + +=== Eligible Methods + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**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) + +[[aot.repositories.json]] +== Repository Metadata + +AOT processing introspects query methods and collects metadata about repository queries. +Spring Data JDBC stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package). +Repository JSON Metadata contains details about queries and fragments. +An example for the following repository is shown below: + +==== +[source,java] +---- +interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); <1> + + Page findPageOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select * from User u where username = ?1") + User findAnnotatedQueryByEmailAddress(String username); <3> + + User findByEmailAddress(String emailAddress); <4> +} +---- + +<1> Derived query without arguments. +<2> Derived query using pagination. +<3> Annotated query. +<4> Named query. +While stored procedure methods are included in JSON metadata, their method code blocks are not generated in AOT repositories. +==== + +[source,json] +---- +{ + "name": "com.acme.UserRepository", + "module": "JDBC", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List com.acme.UserRepository.findUserNoArgumentsBy()", + "query": { + "query": "SELECT * FROM User" + } + }, + { + "name": "findPageOfUsersByLastnameStartingWith", + "signature": "public abstract org.springframework.data.domain.Page com.acme.UserRepository.findPageOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)", + "query": { + "query": "SELECT * FROM User u WHERE lastname LIKE :lastname", + "count-query": "SELECT COUNT(*) FROM User WHERE lastname LIKE :lastname" + } + }, + { + "name": "findAnnotatedQueryByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)", + "query": { + "query": "select * from User where emailAddress = ?1" + } + }, + { + "name": "findByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)", + "query": { + "name": "User.findByEmailAddress", + "query": "SELECT * FROM User WHERE emailAddress = ?1" + } + }, + { + "name": "count", + "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()", + "fragment": { + "fragment": "org.springframework.data.jdbc.repository.support.SimpleJdbcRepository" + } + } + ] +} +---- + +Queries may contain the following fields: + +* `query`: Query descriptor if the method is a query method. +** `name`: Name of the named query if the query is a named one. +** `query` the query used to obtain the query method result from `EntityManager` +** `count-name`: Name of the named count query if the count query is a named one. +** `count-query`: The count query used to obtain the count for query methods using pagination. +* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment. +Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface). + +[NOTE] +.Normalized Query Form +==== +Static analysis of queries allows only a limited representation of runtime query behavior. +Queries are represented in their normalized (pre-parsed and rewritten) form: + +* Value Expressions are replaced with bind markers. +* Query Metadata does not reflect bind-value processing. +`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value. +* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time. +====