Skip to content

Commit 93f3529

Browse files
Artemiy Degtyarevchrshnv
authored andcommitted
Scrolling API support #2149
Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: StatementFactory new mode for scroll api Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: basic keyset pagination support (without directions) Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: test with two keys Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: sorting for keys not in query Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: limit support Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: more optimal pg query Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: remove second compare for one-key query Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: me in headers! Signed-off-by: Artemiy Degtyarev <[email protected]> Signed-off-by: Artemiy Chereshnevvv <[email protected]> code: move to 'ReflectionUtils' Signed-off-by: Artemiy Chereshnevvv <[email protected]> add: unit-test for two key query creation Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: fix query generation for three or more keys Signed-off-by: Artemiy Chereshnevvv <[email protected]> documentation Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: query test fix Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: invalid next scroll position building due to difference in property and column name Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: remove unexpected sort creation when column already in sort Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: use RelationalPersistentProperty.getName() instead of RelationalPersistentProperty.getColumnName().getReference() Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: use RelationalPersistentProperty.getName() instead of RelationalPersistentProperty.getColumnName().getReference() Signed-off-by: Artemiy Chereshnevvv <[email protected]> test: use property name instead of database column Signed-off-by: Artemiy Chereshnevvv <[email protected]> fix: getColumnName.getReference -> getName Signed-off-by: Artemiy Chereshnevvv <[email protected]> code: more beautiful query building Signed-off-by: Artemiy Chereshnevvv <[email protected]> code: fix formatting Signed-off-by: Artemiy Chereshnevvv <[email protected]> Fix: offset scrolling - calculate query offset by page size Signed-off-by: Artemiy Chereshnevvv <[email protected]> Test: add tests for window keyset position after first page Signed-off-by: Artemiy Chereshnevvv <[email protected]>
1 parent f3256b2 commit 93f3529

File tree

7 files changed

+692
-206
lines changed

7 files changed

+692
-206
lines changed

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/aot/JdbcCodeBlocks.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import java.util.stream.Stream;
3131

3232
import org.jspecify.annotations.Nullable;
33-
3433
import org.springframework.core.annotation.MergedAnnotation;
3534
import org.springframework.data.domain.SliceImpl;
3635
import org.springframework.data.domain.Sort;

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
*
3434
* @author Mark Paluch
3535
* @author Diego Krupitza
36+
* @author Artemiy Degtyarev
3637
* @since 2.2
3738
*/
3839
public class JdbcCountQueryCreator extends JdbcQueryCreator {
@@ -44,8 +45,9 @@ public JdbcCountQueryCreator(PartTree tree, JdbcConverter converter, Dialect dia
4445

4546
JdbcCountQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
4647
RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
47-
ReturnedType returnedType, Optional<Lock> lockMode) {
48-
super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode);
48+
ReturnedType returnedType, Optional<Lock> lockMode, boolean isScrollQuery) {
49+
super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode,
50+
isScrollQuery);
4951
}
5052

5153
@Override

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import java.util.Optional;
1919

2020
import org.jspecify.annotations.Nullable;
21-
2221
import org.springframework.data.domain.Sort;
2322
import org.springframework.data.jdbc.core.convert.JdbcConverter;
2423
import org.springframework.data.jdbc.core.convert.SqlGeneratorSource;
@@ -47,6 +46,7 @@
4746
* @author Jens Schauder
4847
* @author Myeonghyeon Lee
4948
* @author Diego Krupitza
49+
* @author Artemiy Degtyarev
5050
* @since 2.0
5151
*/
5252
public class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
@@ -59,6 +59,7 @@ public class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery>
5959
private final ReturnedType returnedType;
6060
private final Optional<Lock> lockMode;
6161
private final StatementFactory statementFactory;
62+
private final boolean isScrollQuery;
6263

6364
/**
6465
* Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect},
@@ -73,15 +74,15 @@ public class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery>
7374
* @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}.
7475
* @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}.
7576
* @deprecated use
76-
* {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource)}
77+
* {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource, boolean)}
7778
* instead.
7879
*/
7980
@Deprecated(since = "4.0", forRemoval = true)
8081
JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
8182
RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
82-
ReturnedType returnedType, Optional<Lock> lockMode) {
83+
ReturnedType returnedType, Optional<Lock> lockMode, boolean isScrollQuery) {
8384
this(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode,
84-
new SqlGeneratorSource(context, converter, dialect));
85+
new SqlGeneratorSource(context, converter, dialect), isScrollQuery);
8586
}
8687

8788
/**
@@ -99,7 +100,7 @@ public JdbcQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect,
99100
RelationalParameterAccessor accessor, ReturnedType returnedType) {
100101
this(converter.getMappingContext(), tree, converter, dialect, queryMethod.getEntityInformation(), accessor,
101102
queryMethod.isSliceQuery(), returnedType, queryMethod.lookupLockAnnotation(),
102-
new SqlGeneratorSource(converter, dialect));
103+
new SqlGeneratorSource(converter, dialect), queryMethod.isScrollQuery());
103104
}
104105

105106
/**
@@ -117,11 +118,13 @@ public JdbcQueryCreator(PartTree tree, JdbcConverter converter, Dialect dialect,
117118
* @param lockMode lock mode to be used for the query.
118119
* @param sqlGeneratorSource the source providing SqlGenerator instances for generating SQL. Must not be
119120
* {@literal null}
121+
* @param isScrollQuery flag denoting if the query returns a {@link org.springframework.data.domain.Window}.
120122
* @since 4.0
121123
*/
122124
public JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
123125
RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
124-
ReturnedType returnedType, Optional<Lock> lockMode, SqlGeneratorSource sqlGeneratorSource) {
126+
ReturnedType returnedType, Optional<Lock> lockMode, SqlGeneratorSource sqlGeneratorSource,
127+
boolean isScrollQuery) {
125128
super(tree, accessor);
126129

127130
Assert.notNull(converter, "JdbcConverter must not be null");
@@ -139,6 +142,7 @@ public JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcCon
139142
this.returnedType = returnedType;
140143
this.lockMode = lockMode;
141144
this.statementFactory = new StatementFactory(converter, dialect);
145+
this.isScrollQuery = isScrollQuery;
142146
}
143147

144148
StatementFactory getStatementFactory() {
@@ -205,6 +209,8 @@ protected ParametrizedQuery complete(@Nullable Criteria criteria, Sort sort) {
205209

206210
selection.page(accessor.getPageable()).filter(criteria).orderBy(sort);
207211

212+
selection.scrollPosition(accessor.getScrollPosition());
213+
208214
if (this.lockMode.isPresent()) {
209215
selection.lock(this.lockMode.get().value());
210216
}
@@ -225,6 +231,8 @@ StatementFactory.SelectionBuilder getSelection(RelationalPersistentEntity<?> ent
225231

226232
if (isSliceQuery) {
227233
selection = statementFactory.slice(entity);
234+
} else if (isScrollQuery) {
235+
selection = statementFactory.scroll(entity);
228236
} else {
229237
selection = statementFactory.select(entity);
230238
}

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,38 @@
1717

1818
import static org.springframework.data.jdbc.repository.query.JdbcQueryExecution.*;
1919

20+
import java.lang.reflect.Field;
2021
import java.sql.ResultSet;
2122
import java.util.ArrayList;
23+
import java.util.Arrays;
2224
import java.util.Collection;
25+
import java.util.LinkedHashMap;
2326
import java.util.List;
27+
import java.util.Map;
2428
import java.util.function.Function;
29+
import java.util.function.IntFunction;
2530
import java.util.function.LongSupplier;
2631
import java.util.function.Supplier;
32+
import java.util.stream.Collectors;
2733

2834
import org.jspecify.annotations.Nullable;
2935
import org.springframework.core.convert.converter.Converter;
36+
import org.springframework.data.domain.KeysetScrollPosition;
37+
import org.springframework.data.domain.Limit;
38+
import org.springframework.data.domain.OffsetScrollPosition;
3039
import org.springframework.data.domain.Pageable;
40+
import org.springframework.data.domain.ScrollPosition;
3141
import org.springframework.data.domain.Slice;
3242
import org.springframework.data.domain.SliceImpl;
3343
import org.springframework.data.domain.Sort;
44+
import org.springframework.data.domain.Window;
3445
import org.springframework.data.jdbc.core.JdbcAggregateOperations;
3546
import org.springframework.data.jdbc.core.convert.JdbcConverter;
3647
import org.springframework.data.relational.core.conversion.RelationalConverter;
3748
import org.springframework.data.relational.core.dialect.Dialect;
3849
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
50+
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
51+
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
3952
import org.springframework.data.relational.repository.query.RelationalEntityMetadata;
4053
import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
4154
import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor;
@@ -51,6 +64,7 @@
5164
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
5265
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
5366
import org.springframework.util.Assert;
67+
import org.springframework.util.ReflectionUtils;
5468

5569
/**
5670
* An {@link AbstractJdbcQuery} implementation based on a {@link PartTree}.
@@ -61,6 +75,7 @@
6175
* @author Mikhail Polivakha
6276
* @author Yunyoung LEE
6377
* @author Nikita Konev
78+
* @author Artemij Degtyarev
6479
* @since 2.0
6580
*/
6681
public class PartTreeJdbcQuery extends AbstractJdbcQuery {
@@ -191,6 +206,13 @@ private JdbcQueryExecution<?> getQueryExecution(ResultProcessor processor,
191206

192207
JdbcQueryExecution<?> queryExecution = getJdbcQueryExecution(extractor, rowMapper);
193208

209+
if (getQueryMethod().isScrollQuery()) {
210+
// noinspection unchecked
211+
return new ScrollQueryExecution<>((JdbcQueryExecution<Collection<Object>>) queryExecution,
212+
accessor.getScrollPosition(), this.tree.getMaxResults(), tree.getSort(), tree.getResultLimit(),
213+
getQueryMethod().getEntityInformation().getTableEntity());
214+
}
215+
194216
if (getQueryMethod().isSliceQuery()) {
195217
// noinspection unchecked
196218
return new SliceQueryExecution<>((JdbcQueryExecution<Collection<Object>>) queryExecution, accessor.getPageable());
@@ -205,7 +227,8 @@ private JdbcQueryExecution<?> getQueryExecution(ResultProcessor processor,
205227
RelationalEntityMetadata<?> entityMetadata = getQueryMethod().getEntityInformation();
206228

207229
JdbcCountQueryCreator queryCreator = new JdbcCountQueryCreator(context, tree, converter, dialect,
208-
entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation());
230+
entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation(),
231+
false);
209232

210233
ParametrizedQuery countQuery = queryCreator.createQuery(Sort.unsorted());
211234
Object count = singleObjectQuery(new SingleColumnRowMapper<>(Number.class)).execute(countQuery.getQuery(),
@@ -227,7 +250,8 @@ ParametrizedQuery createQuery(RelationalParametersParameterAccessor accessor, Re
227250
RelationalEntityMetadata<?> entityMetadata = getQueryMethod().getEntityInformation();
228251

229252
JdbcQueryCreator queryCreator = new JdbcQueryCreator(context, tree, converter, dialect, entityMetadata, accessor,
230-
getQueryMethod().isSliceQuery(), returnedType, this.getQueryMethod().lookupLockAnnotation());
253+
getQueryMethod().isSliceQuery(), returnedType, this.getQueryMethod().lookupLockAnnotation(),
254+
getQueryMethod().isScrollQuery());
231255
return queryCreator.createQuery(getDynamicSort(accessor));
232256
}
233257

@@ -243,7 +267,7 @@ private List<ParametrizedQuery> createDeleteQueries(RelationalParametersParamete
243267
private JdbcQueryExecution<?> getJdbcQueryExecution(@Nullable ResultSetExtractor<Boolean> extractor,
244268
Supplier<RowMapper<?>> rowMapper) {
245269

246-
if (getQueryMethod().isPageQuery() || getQueryMethod().isSliceQuery()) {
270+
if (getQueryMethod().isPageQuery() || getQueryMethod().isSliceQuery() || getQueryMethod().isScrollQuery()) {
247271
return collectionQuery(rowMapper.get());
248272
} else {
249273

@@ -255,6 +279,97 @@ private JdbcQueryExecution<?> getJdbcQueryExecution(@Nullable ResultSetExtractor
255279
}
256280
}
257281

282+
/**
283+
* {@link JdbcQueryExecution} returning a {@link org.springframework.data.domain.Window}
284+
*
285+
* @param <T>
286+
*/
287+
static class ScrollQueryExecution<T> implements JdbcQueryExecution<Window<T>> {
288+
private final JdbcQueryExecution<? extends Collection<T>> delegate;
289+
private final @Nullable ScrollPosition position;
290+
private final @Nullable Integer maxResults;
291+
private final Sort sort;
292+
private final Limit limit;
293+
private final RelationalPersistentEntity<?> tableEntity;
294+
295+
ScrollQueryExecution(JdbcQueryExecution<? extends Collection<T>> delegate, @Nullable ScrollPosition position,
296+
@Nullable Integer maxResults, Sort sort, Limit limit, RelationalPersistentEntity<?> tableEntity) {
297+
this.delegate = delegate;
298+
this.position = position;
299+
this.maxResults = maxResults;
300+
this.sort = sort;
301+
this.limit = limit;
302+
this.tableEntity = tableEntity;
303+
}
304+
305+
@Override
306+
public @Nullable Window<T> execute(String query, SqlParameterSource parameter) {
307+
Collection<T> result = delegate.execute(query, parameter);
308+
309+
List<T> resultList = result instanceof List ? (List<T>) result : new ArrayList<>(result);
310+
IntFunction<? extends ScrollPosition> positionFunction = null;
311+
if (position instanceof OffsetScrollPosition)
312+
positionFunction = ((OffsetScrollPosition) position).positionFunction();
313+
314+
if (position instanceof KeysetScrollPosition) {
315+
Map<String, Object> keys = ((KeysetScrollPosition) position).getKeys();
316+
List<String> orders = new ArrayList<>(keys.keySet());
317+
318+
if (orders.isEmpty())
319+
orders = sort.get().map(Sort.Order::getProperty).toList();
320+
321+
orders = orders.stream().map(it -> {
322+
RelationalPersistentProperty prop = tableEntity.getPersistentProperty(it);
323+
324+
if (prop == null)
325+
return it;
326+
327+
return prop.getName();
328+
}).toList();
329+
330+
keys = extractKeys(resultList, orders);
331+
332+
Map<String, Object> finalKeys = keys;
333+
positionFunction = (ignoredI) -> ScrollPosition.of(finalKeys, ((KeysetScrollPosition) position).getDirection());
334+
}
335+
336+
if (positionFunction == null)
337+
throw new UnsupportedOperationException("Not supported scroll type.");
338+
339+
boolean hasNext;
340+
if (maxResults != null)
341+
hasNext = resultList.size() >= maxResults;
342+
else if (limit.isLimited())
343+
hasNext = resultList.size() >= limit.max();
344+
else
345+
hasNext = !resultList.isEmpty();
346+
347+
return Window.from(resultList, positionFunction, hasNext);
348+
}
349+
350+
private Map<String, Object> extractKeys(List<T> resultList, List<String> orders) {
351+
if (resultList.isEmpty())
352+
return Map.of();
353+
354+
T last = resultList.get(resultList.size() - 1);
355+
356+
Field[] fields = last.getClass().getDeclaredFields();
357+
358+
// noinspection DataFlowIssue
359+
return Arrays.stream(fields).filter(it -> {
360+
String name = it.getName();
361+
362+
RelationalPersistentProperty prop = tableEntity.getPersistentProperty(name);
363+
if (prop != null)
364+
name = prop.getName();
365+
366+
String finalName = name;
367+
return orders.stream().anyMatch(order -> order.equalsIgnoreCase(finalName));
368+
}).peek(ReflectionUtils::makeAccessible).collect(Collectors.toMap(Field::getName,
369+
it -> ReflectionUtils.getField(it, last), (e1, e2) -> e1, LinkedHashMap::new));
370+
}
371+
}
372+
258373
/**
259374
* {@link JdbcQueryExecution} returning a {@link org.springframework.data.domain.Slice}.
260375
*
@@ -327,8 +442,7 @@ class CachedRowMapperFactory implements Supplier<RowMapper<?>> {
327442
private final Lazy<RowMapper<?>> rowMapper;
328443
private final Function<ResultProcessor, RowMapper<?>> rowMapperFunction;
329444

330-
public CachedRowMapperFactory(PartTree tree,
331-
RowMapperFactory rowMapperFactory, RelationalConverter converter,
445+
public CachedRowMapperFactory(PartTree tree, RowMapperFactory rowMapperFactory, RelationalConverter converter,
332446
ResultProcessor defaultResultProcessor) {
333447

334448
this.rowMapperFunction = processor -> {
@@ -338,8 +452,8 @@ public CachedRowMapperFactory(PartTree tree,
338452
}
339453
Converter<Object, Object> resultProcessingConverter = new ResultProcessingConverter(processor,
340454
converter.getMappingContext(), converter.getEntityInstantiators());
341-
return new ConvertingRowMapper(
342-
rowMapperFactory.create(processor.getReturnedType().getDomainType()), resultProcessingConverter);
455+
return new ConvertingRowMapper(rowMapperFactory.create(processor.getReturnedType().getDomainType()),
456+
resultProcessingConverter);
343457
};
344458

345459
this.rowMapper = Lazy.of(() -> this.rowMapperFunction.apply(defaultResultProcessor));

0 commit comments

Comments
 (0)