From 93f3529bab9a7797824482a3a96c422f8fa62143 Mon Sep 17 00:00:00 2001 From: Artemiy Degtyarev Date: Mon, 6 Oct 2025 16:01:17 +0500 Subject: [PATCH 1/2] Scrolling API support #2149 Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: StatementFactory new mode for scroll api Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: basic keyset pagination support (without directions) Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: test with two keys Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: sorting for keys not in query Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: limit support Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv fix: more optimal pg query Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv fix: remove second compare for one-key query Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv add: me in headers! Signed-off-by: Artemiy Degtyarev Signed-off-by: Artemiy Chereshnevvv code: move to 'ReflectionUtils' Signed-off-by: Artemiy Chereshnevvv add: unit-test for two key query creation Signed-off-by: Artemiy Chereshnevvv fix: fix query generation for three or more keys Signed-off-by: Artemiy Chereshnevvv documentation Signed-off-by: Artemiy Chereshnevvv fix: query test fix Signed-off-by: Artemiy Chereshnevvv fix: invalid next scroll position building due to difference in property and column name Signed-off-by: Artemiy Chereshnevvv fix: remove unexpected sort creation when column already in sort Signed-off-by: Artemiy Chereshnevvv fix: use RelationalPersistentProperty.getName() instead of RelationalPersistentProperty.getColumnName().getReference() Signed-off-by: Artemiy Chereshnevvv fix: use RelationalPersistentProperty.getName() instead of RelationalPersistentProperty.getColumnName().getReference() Signed-off-by: Artemiy Chereshnevvv test: use property name instead of database column Signed-off-by: Artemiy Chereshnevvv fix: getColumnName.getReference -> getName Signed-off-by: Artemiy Chereshnevvv code: more beautiful query building Signed-off-by: Artemiy Chereshnevvv code: fix formatting Signed-off-by: Artemiy Chereshnevvv Fix: offset scrolling - calculate query offset by page size Signed-off-by: Artemiy Chereshnevvv Test: add tests for window keyset position after first page Signed-off-by: Artemiy Chereshnevvv --- .../jdbc/repository/aot/JdbcCodeBlocks.java | 1 - .../query/JdbcCountQueryCreator.java | 6 +- .../repository/query/JdbcQueryCreator.java | 20 +- .../repository/query/PartTreeJdbcQuery.java | 128 +++++- .../repository/query/StatementFactory.java | 121 ++++- .../JdbcRepositoryIntegrationTests.java | 423 ++++++++++++------ .../query/PartTreeJdbcQueryUnitTests.java | 199 +++++--- 7 files changed, 692 insertions(+), 206 deletions(-) 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 index 6e43d3de16..e6f1057815 100644 --- 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 @@ -30,7 +30,6 @@ 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; 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 94d374f066..31676ad8c4 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 @@ -33,6 +33,7 @@ * * @author Mark Paluch * @author Diego Krupitza + * @author Artemiy Degtyarev * @since 2.2 */ public class JdbcCountQueryCreator extends JdbcQueryCreator { @@ -44,8 +45,9 @@ public JdbcCountQueryCreator(PartTree tree, JdbcConverter converter, Dialect dia JdbcCountQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, - ReturnedType returnedType, Optional lockMode) { - super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode); + ReturnedType returnedType, Optional lockMode, boolean isScrollQuery) { + super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode, + isScrollQuery); } @Override 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 236e033476..dcc9ffc8d3 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 @@ -18,7 +18,6 @@ import java.util.Optional; import org.jspecify.annotations.Nullable; - import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.SqlGeneratorSource; @@ -47,6 +46,7 @@ * @author Jens Schauder * @author Myeonghyeon Lee * @author Diego Krupitza + * @author Artemiy Degtyarev * @since 2.0 */ public class JdbcQueryCreator extends RelationalQueryCreator { @@ -59,6 +59,7 @@ public class JdbcQueryCreator extends RelationalQueryCreator private final ReturnedType returnedType; private final Optional lockMode; private final StatementFactory statementFactory; + private final boolean isScrollQuery; /** * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, @@ -73,15 +74,15 @@ public class JdbcQueryCreator extends RelationalQueryCreator * @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}. * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}. * @deprecated use - * {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource)} + * {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource, boolean)} * instead. */ @Deprecated(since = "4.0", forRemoval = true) JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, - ReturnedType returnedType, Optional lockMode) { + ReturnedType returnedType, Optional lockMode, boolean isScrollQuery) { this(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode, - new SqlGeneratorSource(context, converter, dialect)); + new SqlGeneratorSource(context, converter, dialect), isScrollQuery); } /** @@ -99,7 +100,7 @@ public JdbcQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect, RelationalParameterAccessor accessor, ReturnedType returnedType) { this(converter.getMappingContext(), tree, converter, dialect, queryMethod.getEntityInformation(), accessor, queryMethod.isSliceQuery(), returnedType, queryMethod.lookupLockAnnotation(), - new SqlGeneratorSource(converter, dialect)); + new SqlGeneratorSource(converter, dialect), queryMethod.isScrollQuery()); } /** @@ -117,11 +118,13 @@ public JdbcQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect, * @param lockMode lock mode to be used for the query. * @param sqlGeneratorSource the source providing SqlGenerator instances for generating SQL. Must not be * {@literal null} + * @param isScrollQuery flag denoting if the query returns a {@link org.springframework.data.domain.Window}. * @since 4.0 */ public JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, - ReturnedType returnedType, Optional lockMode, SqlGeneratorSource sqlGeneratorSource) { + ReturnedType returnedType, Optional lockMode, SqlGeneratorSource sqlGeneratorSource, + boolean isScrollQuery) { super(tree, accessor); Assert.notNull(converter, "JdbcConverter must not be null"); @@ -139,6 +142,7 @@ public JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcCon this.returnedType = returnedType; this.lockMode = lockMode; this.statementFactory = new StatementFactory(converter, dialect); + this.isScrollQuery = isScrollQuery; } StatementFactory getStatementFactory() { @@ -205,6 +209,8 @@ protected ParametrizedQuery complete(@Nullable Criteria criteria, Sort sort) { selection.page(accessor.getPageable()).filter(criteria).orderBy(sort); + selection.scrollPosition(accessor.getScrollPosition()); + if (this.lockMode.isPresent()) { selection.lock(this.lockMode.get().value()); } @@ -225,6 +231,8 @@ StatementFactory.SelectionBuilder getSelection(RelationalPersistentEntity ent if (isSliceQuery) { selection = statementFactory.slice(entity); + } else if (isScrollQuery) { + selection = statementFactory.scroll(entity); } else { selection = statementFactory.select(entity); } 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 3cf0497a4c..365ce4a12e 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 @@ -17,25 +17,38 @@ import static org.springframework.data.jdbc.repository.query.JdbcQueryExecution.*; +import java.lang.reflect.Field; import java.sql.ResultSet; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.Function; +import java.util.function.IntFunction; import java.util.function.LongSupplier; import java.util.function.Supplier; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.springframework.core.convert.converter.Converter; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; 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; 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.repository.query.RelationalEntityMetadata; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; @@ -51,6 +64,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * An {@link AbstractJdbcQuery} implementation based on a {@link PartTree}. @@ -61,6 +75,7 @@ * @author Mikhail Polivakha * @author Yunyoung LEE * @author Nikita Konev + * @author Artemij Degtyarev * @since 2.0 */ public class PartTreeJdbcQuery extends AbstractJdbcQuery { @@ -191,6 +206,13 @@ private JdbcQueryExecution getQueryExecution(ResultProcessor processor, JdbcQueryExecution queryExecution = getJdbcQueryExecution(extractor, rowMapper); + if (getQueryMethod().isScrollQuery()) { + // noinspection unchecked + return new ScrollQueryExecution<>((JdbcQueryExecution>) queryExecution, + accessor.getScrollPosition(), this.tree.getMaxResults(), tree.getSort(), tree.getResultLimit(), + getQueryMethod().getEntityInformation().getTableEntity()); + } + if (getQueryMethod().isSliceQuery()) { // noinspection unchecked return new SliceQueryExecution<>((JdbcQueryExecution>) queryExecution, accessor.getPageable()); @@ -205,7 +227,8 @@ private JdbcQueryExecution getQueryExecution(ResultProcessor processor, RelationalEntityMetadata entityMetadata = getQueryMethod().getEntityInformation(); JdbcCountQueryCreator queryCreator = new JdbcCountQueryCreator(context, tree, converter, dialect, - entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation()); + entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation(), + false); ParametrizedQuery countQuery = queryCreator.createQuery(Sort.unsorted()); Object count = singleObjectQuery(new SingleColumnRowMapper<>(Number.class)).execute(countQuery.getQuery(), @@ -227,7 +250,8 @@ ParametrizedQuery createQuery(RelationalParametersParameterAccessor accessor, Re RelationalEntityMetadata entityMetadata = getQueryMethod().getEntityInformation(); JdbcQueryCreator queryCreator = new JdbcQueryCreator(context, tree, converter, dialect, entityMetadata, accessor, - getQueryMethod().isSliceQuery(), returnedType, this.getQueryMethod().lookupLockAnnotation()); + getQueryMethod().isSliceQuery(), returnedType, this.getQueryMethod().lookupLockAnnotation(), + getQueryMethod().isScrollQuery()); return queryCreator.createQuery(getDynamicSort(accessor)); } @@ -243,7 +267,7 @@ private List createDeleteQueries(RelationalParametersParamete private JdbcQueryExecution getJdbcQueryExecution(@Nullable ResultSetExtractor extractor, Supplier> rowMapper) { - if (getQueryMethod().isPageQuery() || getQueryMethod().isSliceQuery()) { + if (getQueryMethod().isPageQuery() || getQueryMethod().isSliceQuery() || getQueryMethod().isScrollQuery()) { return collectionQuery(rowMapper.get()); } else { @@ -255,6 +279,97 @@ private JdbcQueryExecution getJdbcQueryExecution(@Nullable ResultSetExtractor } } + /** + * {@link JdbcQueryExecution} returning a {@link org.springframework.data.domain.Window} + * + * @param + */ + static class ScrollQueryExecution implements JdbcQueryExecution> { + private final JdbcQueryExecution> delegate; + private final @Nullable ScrollPosition position; + private final @Nullable Integer maxResults; + private final Sort sort; + private final Limit limit; + private final RelationalPersistentEntity tableEntity; + + ScrollQueryExecution(JdbcQueryExecution> delegate, @Nullable ScrollPosition position, + @Nullable Integer maxResults, Sort sort, Limit limit, RelationalPersistentEntity tableEntity) { + this.delegate = delegate; + this.position = position; + this.maxResults = maxResults; + this.sort = sort; + this.limit = limit; + this.tableEntity = tableEntity; + } + + @Override + public @Nullable Window execute(String query, SqlParameterSource parameter) { + Collection result = delegate.execute(query, parameter); + + List resultList = result instanceof List ? (List) result : new ArrayList<>(result); + IntFunction positionFunction = null; + if (position instanceof OffsetScrollPosition) + positionFunction = ((OffsetScrollPosition) position).positionFunction(); + + if (position instanceof KeysetScrollPosition) { + Map keys = ((KeysetScrollPosition) position).getKeys(); + List orders = new ArrayList<>(keys.keySet()); + + if (orders.isEmpty()) + orders = sort.get().map(Sort.Order::getProperty).toList(); + + orders = orders.stream().map(it -> { + RelationalPersistentProperty prop = tableEntity.getPersistentProperty(it); + + if (prop == null) + return it; + + return prop.getName(); + }).toList(); + + keys = extractKeys(resultList, orders); + + Map finalKeys = keys; + positionFunction = (ignoredI) -> ScrollPosition.of(finalKeys, ((KeysetScrollPosition) position).getDirection()); + } + + if (positionFunction == null) + throw new UnsupportedOperationException("Not supported scroll type."); + + boolean hasNext; + if (maxResults != null) + hasNext = resultList.size() >= maxResults; + else if (limit.isLimited()) + hasNext = resultList.size() >= limit.max(); + else + hasNext = !resultList.isEmpty(); + + return Window.from(resultList, positionFunction, hasNext); + } + + private Map extractKeys(List resultList, List orders) { + if (resultList.isEmpty()) + return Map.of(); + + T last = resultList.get(resultList.size() - 1); + + Field[] fields = last.getClass().getDeclaredFields(); + + // noinspection DataFlowIssue + return Arrays.stream(fields).filter(it -> { + String name = it.getName(); + + RelationalPersistentProperty prop = tableEntity.getPersistentProperty(name); + if (prop != null) + name = prop.getName(); + + String finalName = name; + return orders.stream().anyMatch(order -> order.equalsIgnoreCase(finalName)); + }).peek(ReflectionUtils::makeAccessible).collect(Collectors.toMap(Field::getName, + it -> ReflectionUtils.getField(it, last), (e1, e2) -> e1, LinkedHashMap::new)); + } + } + /** * {@link JdbcQueryExecution} returning a {@link org.springframework.data.domain.Slice}. * @@ -327,8 +442,7 @@ class CachedRowMapperFactory implements Supplier> { private final Lazy> rowMapper; private final Function> rowMapperFunction; - public CachedRowMapperFactory(PartTree tree, - RowMapperFactory rowMapperFactory, RelationalConverter converter, + public CachedRowMapperFactory(PartTree tree, RowMapperFactory rowMapperFactory, RelationalConverter converter, ResultProcessor defaultResultProcessor) { this.rowMapperFunction = processor -> { @@ -338,8 +452,8 @@ public CachedRowMapperFactory(PartTree tree, } Converter resultProcessingConverter = new ResultProcessingConverter(processor, converter.getMappingContext(), converter.getEntityInstantiators()); - return new ConvertingRowMapper( - rowMapperFactory.create(processor.getReturnedType().getDomainType()), resultProcessingConverter); + return new ConvertingRowMapper(rowMapperFactory.create(processor.getReturnedType().getDomainType()), + resultProcessingConverter); }; this.rowMapper = Lazy.of(() -> this.rowMapperFunction.apply(defaultResultProcessor)); 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 index 1873a4f688..78949ab5ca 100644 --- 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 @@ -19,12 +19,17 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; - +import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.QueryMapper; @@ -33,6 +38,7 @@ 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.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; @@ -53,6 +59,7 @@ * utility and should not be used outside of the framework as it can change without deprecation notice. * * @author Mark Paluch + * @author Artemiy Degtyarev * @since 4.0 */ public class StatementFactory { @@ -103,6 +110,10 @@ public SelectionBuilder slice(RelationalPersistentEntity entity) { return new SelectionBuilder(entity, SelectionBuilder.Mode.SLICE); } + public SelectionBuilder scroll(RelationalPersistentEntity entity) { + return new SelectionBuilder(entity, SelectionBuilder.Mode.SCROLL); + } + public class SelectionBuilder { private final RelationalPersistentEntity entity; @@ -115,6 +126,7 @@ public class SelectionBuilder { private Sort sort = Sort.unsorted(); private Criteria criteria = Criteria.empty(); private List properties = new ArrayList<>(); + private @Nullable ScrollPosition scrollPosition; private SelectionBuilder(RelationalPersistentEntity entity, Mode mode) { this.entity = entity; @@ -122,6 +134,12 @@ private SelectionBuilder(RelationalPersistentEntity entity, Mode mode) { this.mode = mode; } + @Contract("_ -> this") + public SelectionBuilder scrollPosition(ScrollPosition position) { + this.scrollPosition = position; + return this; + } + @Contract("_ -> this") public SelectionBuilder project(Collection properties) { this.properties = List.copyOf(properties); @@ -199,8 +217,10 @@ 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; @@ -213,19 +233,101 @@ public String build(MapSqlParameterSource parameterSource) { return SqlRenderer.create(renderContextFactory.createRenderContext()).render(select); } + Sort applyScrollOrderBy(Sort sort, @Nullable ScrollPosition scrollPosition) { + if (!(scrollPosition instanceof KeysetScrollPosition) || scrollPosition.isInitial()) + return sort; + + Set orders = sort.get().map(Sort.Order::getProperty).map(it -> { + RelationalPersistentProperty prop = entity.getPersistentProperty(it); + if (prop == null) + return it; + + return prop.getName(); + }).collect(Collectors.toSet()); + + Set keys = ((KeysetScrollPosition) scrollPosition).getKeys().keySet(); + + Set notSorted = keys.stream().map(it -> { + RelationalPersistentProperty prop = entity.getPersistentProperty(it); + if (prop == null) + return it; + + return prop.getName(); + }).filter(it -> orders.stream().noneMatch(order -> order.equalsIgnoreCase(it))).collect(Collectors.toSet()); + + if (notSorted.isEmpty()) + return sort; + + Sort.Direction defaultSort = sort.get().map(Sort.Order::getDirection).findAny().orElse(Sort.DEFAULT_DIRECTION); + + return sort.and(Sort.by(defaultSort, notSorted.toArray(new String[0]))); + } + + Criteria applyScrollCriteria(@Nullable ScrollPosition position, Sort sort) { + if (!(position instanceof KeysetScrollPosition keyset) || position.isInitial() || keyset.getKeys().isEmpty()) { + return Criteria.empty(); + } + + Map keys = keyset.getKeys(); + List columns = new ArrayList<>(keys.keySet()); + List values = new ArrayList<>(keys.values()); + + if (columns.isEmpty() || values.isEmpty()) + return Criteria.empty(); + + Map directions = sort.stream() + .collect(Collectors.toMap(Sort.Order::getProperty, Sort.Order::getDirection)); + + Sort.Direction dir = directions.getOrDefault(columns.get(0), Sort.DEFAULT_DIRECTION); + + return buildKeysetCriteria(columns, values, keyset.scrollsForward(), dir); + } + + Criteria buildKeysetCriteria(List columns, List values, boolean isForward, Sort.Direction dir) { + if (columns.isEmpty()) + return Criteria.empty(); + + String column = columns.get(0); + RelationalPersistentProperty prop = entity.getPersistentProperty(column); + if (prop != null) + column = prop.getName(); + + Object value = values.get(0); + + boolean isAscending = isForward ^ dir.isDescending(); + + Criteria gte = isAscending ? Criteria.where(column).greaterThanOrEquals(value) + : Criteria.where(column).lessThanOrEquals(value); + + Criteria gt = isAscending ? Criteria.where(column).greaterThan(value) : Criteria.where(column).lessThan(value); + + if (columns.size() == 1) + return gt; + + Criteria nested = buildKeysetCriteria(columns.subList(1, columns.size()), values.subList(1, values.size()), + isForward, dir); + + return gte.and(gt.or(nested)); + } + SelectBuilder.SelectOrdered applyOrderBy(Sort sort, RelationalPersistentEntity entity, Table table, SelectBuilder.SelectOrdered selectOrdered) { - return sort.isSorted() ? // - selectOrdered.orderBy(queryMapper.getMappedSort(table, sort, entity)) // + Sort resultSort = applyScrollOrderBy(sort, scrollPosition); + + return resultSort.isSorted() ? // + selectOrdered.orderBy(queryMapper.getMappedSort(table, resultSort, 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)) // + Criteria resultCriteria = criteria == null ? applyScrollCriteria(scrollPosition, sort) + : criteria.and(applyScrollCriteria(scrollPosition, sort)); + + return !resultCriteria.isEmpty() + ? whereBuilder.where(queryMapper.getMappedObject(parameterSource, resultCriteria, table, entity)) // : whereBuilder; } @@ -247,6 +349,13 @@ SelectBuilder.SelectWhere applyLimitAndOffset(SelectBuilder.SelectLimitOffset li .offset(pageable.getOffset()); } + if (mode == Mode.SCROLL && scrollPosition != null && scrollPosition instanceof OffsetScrollPosition + && !scrollPosition.isInitial()) { + int pageSize = limit.isLimited() ? limit.max() : Integer.MAX_VALUE; + + limitOffsetBuilder = limitOffsetBuilder.offset(((OffsetScrollPosition) scrollPosition).getOffset() * pageSize); + } + return (SelectBuilder.SelectWhere) limitOffsetBuilder; } @@ -283,7 +392,7 @@ private SelectBuilder.SelectJoin selectBuilder(Table table) { } enum Mode { - COUNT, EXISTS, SELECT, SLICE + COUNT, EXISTS, SELECT, SLICE, SCROLL } } 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 c9c043b9a3..629546d138 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 @@ -31,6 +31,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -102,6 +103,7 @@ * @author Christopher Klein * @author Mikhail Polivakha * @author Paul Jones + * @author Artemiy Degtyarev */ @IntegrationTest public class JdbcRepositoryIntegrationTests { @@ -1461,6 +1463,159 @@ void queryByByteArray() { assertThat(result).extracting("idProp").containsExactly(two.idProp); } + @Test + void queryByWindowOffset() { + repository.save(createEntity("one")); + repository.save(createEntity("two")); + DummyEntity three = repository.save(createEntity("three")); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropAsc(position)) + .startingAt(ScrollPosition.offset()); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(three.idProp); + } + + @Test + void queryByWindowKeyset() { + repository.save(createEntity("one")); + repository.save(createEntity("two")); + DummyEntity three = repository.save(createEntity("three")); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropAsc(position)) + .startingAt(ScrollPosition.keyset()); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(three.idProp); + } + + @Test + void queryByWindowKeySetDesc() { + DummyEntity one = repository.save(createEntity("one")); + repository.save(createEntity("two")); + repository.save(createEntity("three")); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropDesc(position)) + .startingAt(ScrollPosition.keyset()); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(one.idProp); + } + + @Test + void queryByWindowKeySetDescBackward() { + repository.save(createEntity("one")); + repository.save(createEntity("two")); + repository.save(createEntity("three")); + repository.save(createEntity("four")); + DummyEntity five = repository.save(createEntity("five")); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropDesc(position)) + .startingAt(ScrollPosition.backward(Map.of("idProp", 3))); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(five.idProp); + } + + @Test + void queryByWindowKeySetAscBackward() { + DummyEntity one = repository.save(createEntity("one")); + repository.save(createEntity("two")); + repository.save(createEntity("three")); + repository.save(createEntity("four")); + repository.save(createEntity("five")); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropAsc(position)) + .startingAt(ScrollPosition.backward(Map.of("idProp", 5))); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(one.idProp); + } + + @Test + void queryByWindowKeySetEmptyDb() { + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropAsc(position)) + .startingAt(ScrollPosition.backward(Map.of("idProp", 5))); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).isEmpty(); + } + + @Test + void queryByWindowKeySetTwoKeys() { + repository.save(createEntity("one", it -> it.setPointInTime(Instant.ofEpochSecond(1000)))); + repository.save(createEntity("two", it -> it.setPointInTime(Instant.ofEpochSecond(2000)))); + repository.save(createEntity("three", it -> it.setPointInTime(Instant.ofEpochSecond(3000)))); + repository.save(createEntity("four", it -> it.setPointInTime(Instant.ofEpochSecond(4000)))); + DummyEntity five = repository.save(createEntity("five", it -> it.setPointInTime(Instant.ofEpochSecond(5000)))); + + WindowIterator iter = WindowIterator.of(position -> repository.findFirst2ByOrderByIdPropAsc(position)) + .startingAt(ScrollPosition.forward(Map.of("idProp", 1, "pointInTime", Instant.ofEpochSecond(1000)))); + + List entities = new ArrayList<>(); + while (iter.hasNext()) + entities.add(iter.next()); + + assertThat(entities).extracting("idProp").contains(five.idProp); + } + + @Test + void queryByWindowOffsetPosition() { + repository.save(createEntity("one", it -> it.setPointInTime(Instant.ofEpochSecond(1000)))); + repository.save(createEntity("two", it -> it.setPointInTime(Instant.ofEpochSecond(2000)))); + repository.save(createEntity("three", it -> it.setPointInTime(Instant.ofEpochSecond(3000)))); + + Window result = repository.findFirst2ByOrderByIdPropAsc(ScrollPosition.offset()); + assertSoftly(softAssertions -> { + softAssertions.assertThat(result.hasNext()).isTrue(); + softAssertions.assertThat(result.size()).isEqualTo(2); + + ScrollPosition position = result.positionAt(1); + softAssertions.assertThat(position.isInitial()).isFalse(); + softAssertions.assertThat(((OffsetScrollPosition) position).getOffset()).isEqualTo(1); + }); + } + + @Test + void queryByWindowKeysetPosition() { + repository.save(createEntity("one", it -> it.setPointInTime(Instant.ofEpochSecond(1000)))); + repository.save(createEntity("two", it -> it.setPointInTime(Instant.ofEpochSecond(2000)))); + repository.save(createEntity("three", it -> it.setPointInTime(Instant.ofEpochSecond(3000)))); + + Window result = repository.findFirst2ByOrderByIdPropAsc(ScrollPosition.keyset()); + assertSoftly(softAssertions -> { + softAssertions.assertThat(result.hasNext()).isTrue(); + softAssertions.assertThat(result.size()).isEqualTo(2); + + ScrollPosition position = result.positionAt(0); + softAssertions.assertThat(position.isInitial()).isFalse(); + + Map keys = ((KeysetScrollPosition) position).getKeys(); + softAssertions.assertThat(keys.containsKey("idProp")).isTrue(); + + Long idProp = (Long) keys.get("idProp"); + softAssertions.assertThat(idProp).isEqualTo(2); + }); + } + private Root createRoot(String namePrefix) { return new Root(null, namePrefix, @@ -1606,6 +1761,10 @@ public interface DummyEntityRepository @Query("SELECT * FROM DUMMY_ENTITY WHERE BYTES = :bytes") List findByBytes(byte[] bytes); + + Window findFirst2ByOrderByIdPropAsc(ScrollPosition position); + + Window findFirst2ByOrderByIdPropDesc(ScrollPosition position); } public interface RootRepository extends ListCrudRepository { @@ -1699,60 +1858,59 @@ public EvaluationContextExtension evaluationContextExtension() { } record Root(@Id Long id, String name, Intermediate intermediate, - @MappedCollection(idColumn = "ROOT_ID", keyColumn = "ROOT_KEY") List intermediates) { + @MappedCollection(idColumn = "ROOT_ID", keyColumn = "ROOT_KEY") List intermediates) { @Override public Long id() { - return this.id; - } - + return this.id; + } @Override public List intermediates() { - return this.intermediates; - } + return this.intermediates; + } - public boolean equals(final Object o) { - if (o == this) - return true; - if (!(o instanceof final Root other)) - return false; - final Object this$id = this.id(); - final Object other$id = other.id(); - if (!Objects.equals(this$id, other$id)) - return false; - final Object this$name = this.name(); - final Object other$name = other.name(); - if (!Objects.equals(this$name, other$name)) - return false; - final Object this$intermediate = this.intermediate(); - final Object other$intermediate = other.intermediate(); - if (!Objects.equals(this$intermediate, other$intermediate)) - return false; - final Object this$intermediates = this.intermediates(); - final Object other$intermediates = other.intermediates(); - return Objects.equals(this$intermediates, other$intermediates); - } + public boolean equals(final Object o) { + if (o == this) + return true; + if (!(o instanceof final Root other)) + return false; + final Object this$id = this.id(); + final Object other$id = other.id(); + if (!Objects.equals(this$id, other$id)) + return false; + final Object this$name = this.name(); + final Object other$name = other.name(); + if (!Objects.equals(this$name, other$name)) + return false; + final Object this$intermediate = this.intermediate(); + final Object other$intermediate = other.intermediate(); + if (!Objects.equals(this$intermediate, other$intermediate)) + return false; + final Object this$intermediates = this.intermediates(); + final Object other$intermediates = other.intermediates(); + return Objects.equals(this$intermediates, other$intermediates); + } - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $id = this.id(); - result = result * PRIME + ($id == null ? 43 : $id.hashCode()); - final Object $name = this.name(); - result = result * PRIME + ($name == null ? 43 : $name.hashCode()); - final Object $intermediate = this.intermediate(); - result = result * PRIME + ($intermediate == null ? 43 : $intermediate.hashCode()); - final Object $intermediates = this.intermediates(); - result = result * PRIME + ($intermediates == null ? 43 : $intermediates.hashCode()); - return result; - } + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.id(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $name = this.name(); + result = result * PRIME + ($name == null ? 43 : $name.hashCode()); + final Object $intermediate = this.intermediate(); + result = result * PRIME + ($intermediate == null ? 43 : $intermediate.hashCode()); + final Object $intermediates = this.intermediates(); + result = result * PRIME + ($intermediates == null ? 43 : $intermediates.hashCode()); + return result; + } - public String toString() { - return "JdbcRepositoryIntegrationTests.Root(id=" + this.id() + ", name=" + this.name() + ", intermediate=" - + this.intermediate() + ", intermediates=" + this.intermediates() + ")"; - } + public String toString() { + return "JdbcRepositoryIntegrationTests.Root(id=" + this.id() + ", name=" + this.name() + ", intermediate=" + + this.intermediate() + ", intermediates=" + this.intermediates() + ")"; } + } @Table("WITH_DELIMITED_COLUMN") static class WithDelimitedColumn { @@ -1786,97 +1944,95 @@ public void setType(String type) { } record Intermediate(@Id Long id, String name, Leaf leaf, - @MappedCollection(idColumn = "INTERMEDIATE_ID", keyColumn = "INTERMEDIATE_KEY") List leaves) { + @MappedCollection(idColumn = "INTERMEDIATE_ID", keyColumn = "INTERMEDIATE_KEY") List leaves) { @Override public Long id() { - return this.id; - } - + return this.id; + } @Override public List leaves() { - return this.leaves; - } + return this.leaves; + } - public boolean equals(final Object o) { - if (o == this) - return true; - if (!(o instanceof final Intermediate other)) - return false; - final Object this$id = this.id(); - final Object other$id = other.id(); - if (!Objects.equals(this$id, other$id)) - return false; - final Object this$name = this.name(); - final Object other$name = other.name(); - if (!Objects.equals(this$name, other$name)) - return false; - final Object this$leaf = this.leaf(); - final Object other$leaf = other.leaf(); - if (!Objects.equals(this$leaf, other$leaf)) - return false; - final Object this$leaves = this.leaves(); - final Object other$leaves = other.leaves(); - return Objects.equals(this$leaves, other$leaves); - } + public boolean equals(final Object o) { + if (o == this) + return true; + if (!(o instanceof final Intermediate other)) + return false; + final Object this$id = this.id(); + final Object other$id = other.id(); + if (!Objects.equals(this$id, other$id)) + return false; + final Object this$name = this.name(); + final Object other$name = other.name(); + if (!Objects.equals(this$name, other$name)) + return false; + final Object this$leaf = this.leaf(); + final Object other$leaf = other.leaf(); + if (!Objects.equals(this$leaf, other$leaf)) + return false; + final Object this$leaves = this.leaves(); + final Object other$leaves = other.leaves(); + return Objects.equals(this$leaves, other$leaves); + } - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $id = this.id(); - result = result * PRIME + ($id == null ? 43 : $id.hashCode()); - final Object $name = this.name(); - result = result * PRIME + ($name == null ? 43 : $name.hashCode()); - final Object $leaf = this.leaf(); - result = result * PRIME + ($leaf == null ? 43 : $leaf.hashCode()); - final Object $leaves = this.leaves(); - result = result * PRIME + ($leaves == null ? 43 : $leaves.hashCode()); - return result; - } + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.id(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $name = this.name(); + result = result * PRIME + ($name == null ? 43 : $name.hashCode()); + final Object $leaf = this.leaf(); + result = result * PRIME + ($leaf == null ? 43 : $leaf.hashCode()); + final Object $leaves = this.leaves(); + result = result * PRIME + ($leaves == null ? 43 : $leaves.hashCode()); + return result; + } - public String toString() { - return "JdbcRepositoryIntegrationTests.Intermediate(id=" + this.id() + ", name=" + this.name() + ", leaf=" - + this.leaf() + ", leaves=" + this.leaves() + ")"; - } + public String toString() { + return "JdbcRepositoryIntegrationTests.Intermediate(id=" + this.id() + ", name=" + this.name() + ", leaf=" + + this.leaf() + ", leaves=" + this.leaves() + ")"; } + } record Leaf(@Id Long id, String name) { @Override public Long id() { - return this.id; - } - + return this.id; + } public boolean equals(final Object o) { - if (o == this) - return true; - if (!(o instanceof final Leaf other)) - return false; - final Object this$id = this.id(); - final Object other$id = other.id(); - if (!Objects.equals(this$id, other$id)) - return false; - final Object this$name = this.name(); - final Object other$name = other.name(); - return Objects.equals(this$name, other$name); - } + if (o == this) + return true; + if (!(o instanceof final Leaf other)) + return false; + final Object this$id = this.id(); + final Object other$id = other.id(); + if (!Objects.equals(this$id, other$id)) + return false; + final Object this$name = this.name(); + final Object other$name = other.name(); + return Objects.equals(this$name, other$name); + } - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $id = this.id(); - result = result * PRIME + ($id == null ? 43 : $id.hashCode()); - final Object $name = this.name(); - result = result * PRIME + ($name == null ? 43 : $name.hashCode()); - return result; - } + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.id(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $name = this.name(); + result = result * PRIME + ($name == null ? 43 : $name.hashCode()); + return result; + } - public String toString() { - return "JdbcRepositoryIntegrationTests.Leaf(id=" + this.id() + ", name=" + this.name() + ")"; - } + public String toString() { + return "JdbcRepositoryIntegrationTests.Leaf(id=" + this.id() + ", name=" + this.name() + ")"; } + } static class MyEventListener implements ApplicationListener> { @@ -2130,29 +2286,28 @@ public AggregateReference getRef() { record DtoProjection(String name) { - public boolean equals(final Object o) { - if (o == this) - return true; - if (!(o instanceof final DtoProjection other)) - return false; - final Object this$name = this.name(); - final Object other$name = other.name(); - return Objects.equals(this$name, other$name); - } + if (o == this) + return true; + if (!(o instanceof final DtoProjection other)) + return false; + final Object this$name = this.name(); + final Object other$name = other.name(); + return Objects.equals(this$name, other$name); + } - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $name = this.name(); - result = result * PRIME + ($name == null ? 43 : $name.hashCode()); - return result; - } + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $name = this.name(); + result = result * PRIME + ($name == null ? 43 : $name.hashCode()); + return result; + } - public String toString() { - return "JdbcRepositoryIntegrationTests.DtoProjection(name=" + this.name() + ")"; - } + public String toString() { + return "JdbcRepositoryIntegrationTests.DtoProjection(name=" + this.name() + ")"; } + } static class CustomRowMapper implements RowMapper { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java index 3738e1a6c9..a1fd6cd3b4 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java @@ -23,14 +23,17 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Properties; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; - import org.springframework.data.annotation.Id; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; import org.springframework.data.jdbc.core.convert.RelationResolver; @@ -63,6 +66,7 @@ * @author Jens Schauder * @author Myeonghyeon Lee * @author Diego Krupitza + * @author Artemiy Degtyarev */ @ExtendWith(MockitoExtension.class) public class PartTreeJdbcQueryUnitTests { @@ -91,14 +95,16 @@ public class PartTreeJdbcQueryUnitTests { users.column("HOBBY_REFERENCE"), // hobby.column("NAME").as("HATED_NAME")); - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void shouldFailForQueryByReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHated", Hobby.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } - @Test // GH-922 + @Test + // GH-922 void createQueryByAggregateReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbyReference", Hobby.class); @@ -112,7 +118,8 @@ void createQueryByAggregateReference() throws Exception { .hasBindValue("hobby_reference", "twentythree"); } - @Test // GH-922 + @Test + // GH-922 void createQueryWithPessimisticWriteLock() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameAndLastName", String.class, String.class); @@ -131,7 +138,8 @@ void createQueryWithPessimisticWriteLock() throws Exception { }); } - @Test // GH-922 + @Test + // GH-922 void createQueryWithPessimisticReadLock() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameAndAge", String.class, Integer.class); @@ -151,21 +159,24 @@ void createQueryWithPessimisticReadLock() throws Exception { }); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void shouldFailForQueryByList() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbies", Object.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void shouldFailForQueryByEmbeddedList() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAnotherEmbeddedList", Object.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } - @Test // GH-922 + @Test + // GH-922 void createQueryForQueryByAggregateReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findViaReferenceByHobbyReference", AggregateReference.class); @@ -181,7 +192,8 @@ void createQueryForQueryByAggregateReference() throws Exception { }); } - @Test // GH-922 + @Test + // GH-922 void createQueryForQueryByAggregateReferenceId() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findViaIdByHobbyReference", String.class); @@ -197,7 +209,8 @@ void createQueryForQueryByAggregateReferenceId() throws Exception { }); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); @@ -208,7 +221,8 @@ void createsQueryToFindAllEntitiesByStringAttribute() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name"); } - @Test // GH-971 + @Test + // GH-971 void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception { when(returnedType.needsCustomConstruction()).thenReturn(true); @@ -222,7 +236,8 @@ void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception { + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryWithIsNullCondition() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); @@ -233,7 +248,8 @@ void createsQueryWithIsNullCondition() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" IS NULL"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryWithLimitForExistsProjection() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("existsByFirstName", String.class); @@ -244,7 +260,8 @@ void createsQueryWithLimitForExistsProjection() throws Exception { "SELECT " + TABLE + ".\"ID\" FROM " + TABLE + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameAndFirstName", String.class, String.class); @@ -256,7 +273,8 @@ void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception { .contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name AND (" + TABLE + ".\"FIRST_NAME\" = :first_name)"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameOrFirstName", String.class, String.class); @@ -268,7 +286,8 @@ void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception .contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name OR (" + TABLE + ".\"FIRST_NAME\" = :first_name)"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBetween", Date.class, Date.class); @@ -286,7 +305,8 @@ void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception { }); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThan", Integer.class); @@ -297,7 +317,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" < :age"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThanEqual", Integer.class); @@ -309,7 +330,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Excep .contains(" WHERE " + TABLE + ".\"AGE\" <= :age"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThan", Integer.class); @@ -320,7 +342,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Excepti QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" > :age"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThanEqual", Integer.class); @@ -332,7 +355,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Ex .contains(" WHERE " + TABLE + ".\"AGE\" >= :age"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthAfter", Date.class); @@ -344,7 +368,8 @@ void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception { .contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" > :date_of_birth"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBefore", Date.class); @@ -356,7 +381,8 @@ void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception { .contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" < :date_of_birth"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNull"); @@ -368,7 +394,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception { .contains(" WHERE " + TABLE + ".\"AGE\" IS NULL"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNotNull"); @@ -380,7 +407,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception .contains(" WHERE " + TABLE + ".\"AGE\" IS NOT NULL"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameLike", String.class); @@ -392,7 +420,8 @@ void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotLike", String.class); @@ -404,7 +433,8 @@ void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); @@ -416,7 +446,8 @@ void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Excepti .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); @@ -428,7 +459,8 @@ void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "Jo%"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); @@ -440,7 +472,8 @@ void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); @@ -452,7 +485,8 @@ void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws E .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); @@ -464,7 +498,8 @@ void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); @@ -476,7 +511,8 @@ void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exc .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); @@ -488,7 +524,8 @@ void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Except .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); @@ -500,7 +537,8 @@ void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameDesc", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -511,7 +549,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStri .contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" DESC"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameAsc", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -522,7 +561,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStrin .contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" ASC"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameNot", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -533,7 +573,8 @@ void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception { .contains(" WHERE " + TABLE + ".\"LAST_NAME\" != :last_name"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIn", Collection.class); @@ -546,7 +587,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception { .contains(" WHERE " + TABLE + ".\"AGE\" IN (:age)"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeNotIn", Collection.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -558,7 +600,8 @@ void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception { .contains(" WHERE " + TABLE + ".\"AGE\" NOT IN (:age)"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveTrue"); @@ -570,7 +613,8 @@ void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception { .contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveFalse"); @@ -582,7 +626,8 @@ void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception { .contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameIgnoreCase", String.class); @@ -594,7 +639,8 @@ void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Excepti .contains(" WHERE UPPER(" + TABLE + ".\"FIRST_NAME\") = UPPER(:first_name)"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByIdIgnoringCase", Long.class); @@ -604,7 +650,8 @@ void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { .isThrownBy(() -> jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { 1L }), returnedType)); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByIdIsEmpty"); @@ -614,7 +661,8 @@ void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception { .isThrownBy(() -> jdbcQuery.createQuery(getAccessor(queryMethod, new Object[0]), returnedType)); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); @@ -624,7 +672,8 @@ void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception { .isThrownBy(() -> jdbcQuery.createQuery(getAccessor(queryMethod, new Object[0]), returnedType)); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findTop3ByFirstName", String.class); @@ -636,7 +685,8 @@ void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 3"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryToFindFirstEntityByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findFirstByFirstName", String.class); @@ -648,7 +698,8 @@ void createsQueryToFindFirstEntityByStringAttribute() throws Exception { .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryByEmbeddedObject() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAddress", Address.class); @@ -670,7 +721,8 @@ void createsQueryByEmbeddedObject() throws Exception { assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("user_city")).isEqualTo("World"); } - @Test // DATAJDBC-318 + @Test + // DATAJDBC-318 void createsQueryByEmbeddedProperty() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAddressStreet", String.class); @@ -682,7 +734,8 @@ void createsQueryByEmbeddedProperty() throws Exception { .contains(" WHERE " + TABLE + ".\"USER_STREET\" = :user_street").hasBindValue("user_street", "Hello"); } - @Test // DATAJDBC-534 + @Test + // DATAJDBC-534 void createsQueryForCountProjection() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("countByFirstName", String.class); @@ -693,6 +746,50 @@ void createsQueryForCountProjection() throws Exception { .isEqualTo("SELECT COUNT(*) FROM " + TABLE + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name"); } + @Test // PR-2149 + void createQueryForKeysetTwoKeys() throws Exception { + JdbcQueryMethod queryMethod = getQueryMethod("findFirst2ByOrderByIdIdAsc", ScrollPosition.class); + PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); + Map values = new LinkedHashMap<>() { + { + put("idId", 1); + put("firstName", "John"); + } + }; + + ParametrizedQuery query = jdbcQuery.createQuery( + (getAccessor(queryMethod, new Object[] { ScrollPosition.of(values, ScrollPosition.Direction.FORWARD) })), + returnedType); + + QueryAssert + .assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE (" + TABLE + ".\"ID\" >= :id AND (" + + TABLE + ".\"ID\" > :id1 OR (" + TABLE + ".\"FIRST_NAME\" > :first_name") + .hasBindValue("id", 1L).hasBindValue("first_name", "John"); + } + + @Test // PR-2149 + void createQueryForKeysetThreeKeys() throws Exception { + JdbcQueryMethod queryMethod = getQueryMethod("findFirst2ByOrderByIdIdAsc", ScrollPosition.class); + PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); + Map values = new LinkedHashMap<>() { + { + put("idId", 1); + put("firstName", "John"); + put("age", 18); + } + }; + + ParametrizedQuery query = jdbcQuery.createQuery( + (getAccessor(queryMethod, new Object[] { ScrollPosition.of(values, ScrollPosition.Direction.FORWARD) })), + returnedType); + + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains("WHERE (" + TABLE + ".\"ID\" >= :id AND (" + TABLE + ".\"ID\" > :id1 OR (" + TABLE + + ".\"FIRST_NAME\" >= :first_name AND (" + TABLE + ".\"FIRST_NAME\" > :first_name3 OR (" + TABLE + + ".\"AGE\" > :age)") + .hasBindValue("id", 1L).hasBindValue("first_name", "John").hasBindValue("age", 18); + } + private PartTreeJdbcQuery createQuery(JdbcQueryMethod queryMethod) { return new PartTreeJdbcQuery(mappingContext, queryMethod, JdbcH2Dialect.INSTANCE, converter, mock(NamedParameterJdbcOperations.class), mock(RowMapper.class)); @@ -798,6 +895,8 @@ interface UserRepository extends Repository { User findByAnotherEmbeddedList(Object list); long countByFirstName(String name); + + Window findFirst2ByOrderByIdIdAsc(ScrollPosition position); } @Table("users") From 676ffc13b4b31d40155c9b07009b241cc5334e5d Mon Sep 17 00:00:00 2001 From: Artemiy Chereshnevvv Date: Thu, 9 Oct 2025 10:58:43 +0500 Subject: [PATCH 2/2] Polishing. Reformat keys extraction code. See #2149 Signed-off-by: Artemiy Chereshnevvv Polishing. Reformat 'applyScrollOrderBy'. See #2149 Signed-off-by: Artemiy Chereshnevvv --- .../repository/query/PartTreeJdbcQuery.java | 47 +++++++++---------- .../repository/query/StatementFactory.java | 21 ++------- 2 files changed, 26 insertions(+), 42 deletions(-) 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 365ce4a12e..ff80324eac 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 @@ -17,10 +17,8 @@ import static org.springframework.data.jdbc.repository.query.JdbcQueryExecution.*; -import java.lang.reflect.Field; import java.sql.ResultSet; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; @@ -29,7 +27,6 @@ import java.util.function.IntFunction; import java.util.function.LongSupplier; import java.util.function.Supplier; -import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.springframework.core.convert.converter.Converter; @@ -44,6 +41,7 @@ import org.springframework.data.domain.Window; import org.springframework.data.jdbc.core.JdbcAggregateOperations; import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -64,7 +62,6 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.util.Assert; -import org.springframework.util.ReflectionUtils; /** * An {@link AbstractJdbcQuery} implementation based on a {@link PartTree}. @@ -318,19 +315,17 @@ static class ScrollQueryExecution implements JdbcQueryExecution> { if (orders.isEmpty()) orders = sort.get().map(Sort.Order::getProperty).toList(); - orders = orders.stream().map(it -> { - RelationalPersistentProperty prop = tableEntity.getPersistentProperty(it); - + List properties = new ArrayList<>(); + for (String propertyName : orders) { + RelationalPersistentProperty prop = tableEntity.getPersistentProperty(propertyName); if (prop == null) - return it; - - return prop.getName(); - }).toList(); + continue; - keys = extractKeys(resultList, orders); + properties.add(prop); + } - Map finalKeys = keys; - positionFunction = (ignoredI) -> ScrollPosition.of(finalKeys, ((KeysetScrollPosition) position).getDirection()); + final Map resultKeys = extractKeys(resultList, properties); + positionFunction = (ignoredI) -> ScrollPosition.of(resultKeys, ((KeysetScrollPosition) position).getDirection()); } if (positionFunction == null) @@ -347,26 +342,26 @@ else if (limit.isLimited()) return Window.from(resultList, positionFunction, hasNext); } - private Map extractKeys(List resultList, List orders) { + private Map extractKeys(List resultList, List properties) { if (resultList.isEmpty()) return Map.of(); + Map result = new LinkedHashMap<>(); + T last = resultList.get(resultList.size() - 1); + PersistentPropertyAccessor accessor = tableEntity.getPropertyAccessor(last); - Field[] fields = last.getClass().getDeclaredFields(); + for (RelationalPersistentProperty property : properties) { + String propertyName = property.getName(); + Object propertyValue = accessor.getProperty(property); - // noinspection DataFlowIssue - return Arrays.stream(fields).filter(it -> { - String name = it.getName(); + if (propertyValue == null) + continue; - RelationalPersistentProperty prop = tableEntity.getPersistentProperty(name); - if (prop != null) - name = prop.getName(); + result.put(propertyName, propertyValue); + } - String finalName = name; - return orders.stream().anyMatch(order -> order.equalsIgnoreCase(finalName)); - }).peek(ReflectionUtils::makeAccessible).collect(Collectors.toMap(Field::getName, - it -> ReflectionUtils.getField(it, last), (e1, e2) -> e1, LinkedHashMap::new)); + return result; } } 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 index 78949ab5ca..bffe72de58 100644 --- 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 @@ -237,30 +237,19 @@ Sort applyScrollOrderBy(Sort sort, @Nullable ScrollPosition scrollPosition) { if (!(scrollPosition instanceof KeysetScrollPosition) || scrollPosition.isInitial()) return sort; - Set orders = sort.get().map(Sort.Order::getProperty).map(it -> { - RelationalPersistentProperty prop = entity.getPersistentProperty(it); - if (prop == null) - return it; - - return prop.getName(); - }).collect(Collectors.toSet()); + Set sortedProperties = sort.get().map(Sort.Order::getProperty).collect(Collectors.toSet()); Set keys = ((KeysetScrollPosition) scrollPosition).getKeys().keySet(); - Set notSorted = keys.stream().map(it -> { - RelationalPersistentProperty prop = entity.getPersistentProperty(it); - if (prop == null) - return it; - - return prop.getName(); - }).filter(it -> orders.stream().noneMatch(order -> order.equalsIgnoreCase(it))).collect(Collectors.toSet()); + Set notSortedProperties + = keys.stream().filter(it -> !sortedProperties.contains(it)).collect(Collectors.toSet()); - if (notSorted.isEmpty()) + if (notSortedProperties.isEmpty()) return sort; Sort.Direction defaultSort = sort.get().map(Sort.Order::getDirection).findAny().orElse(Sort.DEFAULT_DIRECTION); - return sort.and(Sort.by(defaultSort, notSorted.toArray(new String[0]))); + return sort.and(Sort.by(defaultSort, notSortedProperties.toArray(new String[0]))); } Criteria applyScrollCriteria(@Nullable ScrollPosition position, Sort sort) {