From d75c58a9f10a8f9f78e18ad9634c2d97070e3fa0 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 12 Aug 2025 14:08:32 +0200 Subject: [PATCH] Apply DTO projection through JDBC's Query by Example. Spring Data JDBC doesn't allow projections through JdbcAggregateOperations yet and so we need to apply DTO conversion. Closes #2098 --- .../FetchableFluentQueryByExample.java | 19 ++++++--- .../support/FluentQuerySupport.java | 28 ++++++++++--- .../support/SimpleJdbcRepository.java | 6 ++- .../JdbcRepositoryIntegrationTests.java | 42 +++++++++++++++++-- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FetchableFluentQueryByExample.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FetchableFluentQueryByExample.java index fe07f0758..54db9be99 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FetchableFluentQueryByExample.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FetchableFluentQueryByExample.java @@ -32,6 +32,8 @@ import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; import org.springframework.data.jdbc.core.JdbcAggregateOperations; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.query.Query; import org.springframework.data.relational.repository.query.RelationalExampleMapper; import org.springframework.util.Assert; @@ -47,19 +49,26 @@ class FetchableFluentQueryByExample extends FluentQuerySupport { private final RelationalExampleMapper exampleMapper; private final JdbcAggregateOperations entityOperations; + private final ProjectionFactory projectionFactory; + private final RelationalConverter converter; FetchableFluentQueryByExample(Example example, Class resultType, RelationalExampleMapper exampleMapper, - JdbcAggregateOperations entityOperations) { - this(example, Sort.unsorted(), 0, resultType, Collections.emptyList(), exampleMapper, entityOperations); + JdbcAggregateOperations entityOperations, RelationalConverter converter, ProjectionFactory projectionFactory) { + this(example, Sort.unsorted(), 0, resultType, Collections.emptyList(), exampleMapper, entityOperations, converter, + projectionFactory); } FetchableFluentQueryByExample(Example example, Sort sort, int limit, Class resultType, - List fieldsToInclude, RelationalExampleMapper exampleMapper, JdbcAggregateOperations entityOperations) { + List fieldsToInclude, RelationalExampleMapper exampleMapper, JdbcAggregateOperations entityOperations, + RelationalConverter converter, + ProjectionFactory projectionFactory) { - super(example, sort, limit, resultType, fieldsToInclude); + super(example, sort, limit, resultType, fieldsToInclude, projectionFactory, converter); this.exampleMapper = exampleMapper; this.entityOperations = entityOperations; + this.converter = converter; + this.projectionFactory = projectionFactory; } @Override @@ -167,6 +176,6 @@ class FetchableFluentQueryByExample extends FluentQuerySupport { List fieldsToInclude) { return new FetchableFluentQueryByExample<>(example, sort, limit, resultType, fieldsToInclude, this.exampleMapper, - this.entityOperations); + this.entityOperations, this.converter, this.projectionFactory); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FluentQuerySupport.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FluentQuerySupport.java index 1b96b3c66..1c3ea5de0 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FluentQuerySupport.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/FluentQuerySupport.java @@ -21,9 +21,12 @@ import java.util.List; import java.util.function.Function; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.convert.DtoInstantiatingConverter; import org.springframework.data.domain.Example; import org.springframework.data.domain.Sort; -import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.projection.EntityProjection; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.repository.query.FluentQuery; import org.springframework.util.Assert; @@ -41,16 +44,19 @@ abstract class FluentQuerySupport implements FluentQuery.FetchableFluentQu private final int limit; private final Class resultType; private final List fieldsToInclude; + private final ProjectionFactory projectionFactory; + private final RelationalConverter converter; - private final SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); - - FluentQuerySupport(Example example, Sort sort, int limit, Class resultType, List fieldsToInclude) { + FluentQuerySupport(Example example, Sort sort, int limit, Class resultType, List fieldsToInclude, + ProjectionFactory projectionFactory, RelationalConverter converter) { this.example = example; this.sort = sort; this.limit = limit; this.resultType = resultType; this.fieldsToInclude = fieldsToInclude; + this.projectionFactory = projectionFactory; + this.converter = converter; } @Override @@ -118,8 +124,18 @@ abstract class FluentQuerySupport implements FluentQuery.FetchableFluentQu return (Function) Function.identity(); } - if (targetType.isInterface()) { - return o -> projectionFactory.createProjection(targetType, o); + EntityProjection entityProjection = converter.introspectProjection(targetType, inputType); + + if (entityProjection.isProjection()) { + + if (targetType.isInterface()) { + return o -> projectionFactory.createProjection(targetType, o); + } + + DtoInstantiatingConverter dtoConverter = new DtoInstantiatingConverter(targetType, converter.getMappingContext(), + converter.getEntityInstantiators()); + + return o -> (R) dtoConverter.convert(o); } return o -> DefaultConversionService.getSharedInstance().convert(o, targetType); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java index 65eba4b02..9e2eefaaa 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java @@ -26,6 +26,7 @@ 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.mapping.PersistentEntity; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.relational.repository.query.RelationalExampleMapper; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.PagingAndSortingRepository; @@ -48,9 +49,11 @@ import org.springframework.util.Assert; public class SimpleJdbcRepository implements CrudRepository, PagingAndSortingRepository, QueryByExampleExecutor { + private final SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); private final JdbcAggregateOperations entityOperations; private final PersistentEntity entity; private final RelationalExampleMapper exampleMapper; + private final JdbcConverter converter; public SimpleJdbcRepository(JdbcAggregateOperations entityOperations, PersistentEntity entity, JdbcConverter converter) { @@ -60,6 +63,7 @@ public class SimpleJdbcRepository this.entityOperations = entityOperations; this.entity = entity; + this.converter = converter; this.exampleMapper = new RelationalExampleMapper(converter.getMappingContext()); } @@ -197,7 +201,7 @@ public class SimpleJdbcRepository Assert.notNull(queryFunction, "Query function must not be null"); FluentQuery.FetchableFluentQuery fluentQuery = new FetchableFluentQueryByExample<>(example, - example.getProbeType(), this.exampleMapper, this.entityOperations); + example.getProbeType(), this.exampleMapper, this.entityOperations, this.converter, this.projectionFactory); return queryFunction.apply(fluentQuery); } 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 7c854b823..e0cc272d7 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 @@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.PropertiesFactoryBean; import org.springframework.context.ApplicationListener; @@ -51,7 +52,16 @@ import org.springframework.context.annotation.Import; import org.springframework.core.io.ClassPathResource; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.annotation.Id; -import org.springframework.data.domain.*; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.jdbc.repository.query.Modifying; import org.springframework.data.jdbc.repository.query.Query; @@ -64,8 +74,8 @@ import org.springframework.data.jdbc.testing.TestConfiguration; import org.springframework.data.jdbc.testing.TestDatabaseFeatures; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.MappedCollection; -import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.mapping.Sequence; +import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.mapping.event.AbstractRelationalEvent; import org.springframework.data.relational.core.mapping.event.AfterConvertEvent; import org.springframework.data.relational.core.sql.LockMode; @@ -1187,6 +1197,32 @@ public class JdbcRepositoryIntegrationTests { assertThat(matches).isEqualTo(2); } + @Test // GH-2098 + void projectByExample() { + + String searchName = "Diego"; + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + DummyEntity entity = createEntity(); + + entity.setName(searchName); + entity.setPointInTime(now.minusSeconds(10000)); + entity = repository.save(entity); + + record DummyProjection(String name) { + + } + + Example example = Example.of(createEntity(searchName, it -> it.setBytes(null))); + + DummyProjection projection = repository.findBy(example, + p -> p.project("name").as(DummyProjection.class).firstValue()); + assertThat(projection.name()).isEqualTo(entity.name); + + projection = repository.findBy(example, p -> p.project("flag").as(DummyProjection.class).firstValue()); + assertThat(projection.name()).isNull(); + } + @Test // GH-1192 void fetchByExampleFluentOnlyInstantFirstSimple() { @@ -1888,10 +1924,10 @@ public class JdbcRepositoryIntegrationTests { static class DummyEntity { + @Id Long idProp; String name; Instant pointInTime; OffsetDateTime offsetDateTime; - @Id private Long idProp; boolean flag; AggregateReference ref; Direction direction;