Skip to content

Commit c0ffe62

Browse files
committed
Merge pull request 'Implement cursor-based pagination' (#46) from cursor-pagination into main
2 parents 225ddc1 + 69a6e6e commit c0ffe62

18 files changed

+395
-216
lines changed

pom.xml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<org.mapstruct.version>1.6.3</org.mapstruct.version>
3939
<org.mapstruct.extensions.spring.version>2.0.0</org.mapstruct.extensions.spring.version>
4040
<com.fasterxml.uuid.version>5.2.0</com.fasterxml.uuid.version>
41+
<querydsl.version>5.1.0</querydsl.version>
4142
</properties>
4243
<dependencies>
4344
<dependency>
@@ -186,6 +187,20 @@
186187
<groupId>org.springframework.boot</groupId>
187188
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
188189
</dependency>
190+
<dependency>
191+
<groupId>com.querydsl</groupId>
192+
<artifactId>querydsl-apt</artifactId>
193+
<version>${querydsl.version}</version>
194+
<classifier>jakarta</classifier>
195+
<scope>provided</scope>
196+
</dependency>
197+
<dependency>
198+
<groupId>com.querydsl</groupId>
199+
<artifactId>querydsl-jpa</artifactId>
200+
<classifier>jakarta</classifier>
201+
<version>${querydsl.version}</version>
202+
</dependency>
203+
189204
</dependencies>
190205

191206
<build>
@@ -209,6 +224,22 @@
209224
<artifactId>mapstruct-spring-extensions</artifactId>
210225
<version>${org.mapstruct.extensions.spring.version}</version>
211226
</path>
227+
<path>
228+
<groupId>com.querydsl</groupId>
229+
<artifactId>querydsl-apt</artifactId>
230+
<version>${querydsl.version}</version>
231+
<classifier>jakarta</classifier>
232+
</path>
233+
<path>
234+
<groupId>jakarta.persistence</groupId>
235+
<artifactId>jakarta.persistence-api</artifactId>
236+
<version>3.2.0</version>
237+
</path>
238+
<path>
239+
<groupId>jakarta.annotation</groupId>
240+
<artifactId>jakarta.annotation-api</artifactId>
241+
<version>3.0.0</version>
242+
</path>
212243
</annotationProcessorPaths>
213244
</configuration>
214245
</plugin>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package org.openpodcastapi.opa.config;
2+
3+
import com.querydsl.jpa.impl.JPAQueryFactory;
4+
import jakarta.persistence.EntityManager;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
/// Configuration for QueryDSL entity management
9+
@Configuration
10+
public class QuerydslConfig {
11+
12+
private final EntityManager entityManager;
13+
14+
/// All-args constructor
15+
///
16+
/// @param entityManager the autowired entity manager
17+
public QuerydslConfig(EntityManager entityManager) {
18+
this.entityManager = entityManager;
19+
}
20+
21+
/// @return a JPAQueryFactory initialized with an entity manager
22+
@Bean
23+
public JPAQueryFactory jpaQueryFactory() {
24+
return new JPAQueryFactory(entityManager);
25+
}
26+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package org.openpodcastapi.opa.pagination;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
5+
import java.util.List;
6+
import java.util.function.Function;
7+
8+
/// A generic DTO for cursor -paginated responses
9+
///
10+
/// @param <T> the type of entity
11+
/// @param data the list of entities to display
12+
/// @param nextCursor the encoded cursor representing the next page of results
13+
/// @param prevCursor the encoded cursor representing the previous page of results
14+
@JsonInclude(JsonInclude.Include.NON_NULL)
15+
public record CursorPage<T>(
16+
List<T> data,
17+
String nextCursor,
18+
String prevCursor
19+
) {
20+
/// Builder pattern for making a cursor-paginated page of results
21+
///
22+
/// @param <T> the type of entity to build a response for
23+
/// @param results the list of results
24+
/// @param limit the number of results to display
25+
/// @param cursorExtractor the function used to extract the cursor from the entity's `id` and `createdAt` fields
26+
/// @return a page of paginated results
27+
public static <T> CursorPage<T> of(
28+
List<T> results,
29+
int limit,
30+
Function<? super T, CursorPayload> cursorExtractor
31+
) {
32+
// If there are no results, just return an empty list
33+
if (results.isEmpty()) {
34+
return new CursorPage<>(List.of(), null, null);
35+
}
36+
37+
// Get the first and last result
38+
final var first = results.getFirst();
39+
final var last = results.getLast();
40+
41+
// Initialize the cursors
42+
final String prevCursor = CursorUtility.encode(cursorExtractor.apply(first));
43+
String nextCursor = null;
44+
45+
// If there is a next page, create a cursor for it
46+
if (results.size() == limit) {
47+
nextCursor = CursorUtility.encode(cursorExtractor.apply(last));
48+
}
49+
50+
return new CursorPage<>(results, nextCursor, prevCursor);
51+
}
52+
53+
/// Generic mapping function for mapping a given entity to a DTO inside a cursor page
54+
///
55+
/// @param <R> the type of DTO
56+
/// @param mapper the mapper instance to use
57+
/// @return a cursor page containing mapped entities
58+
public <R> CursorPage<R> map(java.util.function.Function<? super T, R> mapper) {
59+
List<R> mapped = data.stream().map(mapper).toList();
60+
return new CursorPage<>(mapped, nextCursor, prevCursor);
61+
}
62+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.openpodcastapi.opa.pagination;
2+
3+
import java.time.Instant;
4+
5+
/// Generic shape of pagination pagination,
6+
///
7+
/// @param createdAt the `created_at` timestamp of the entity
8+
/// @param id the database `id` of the entity
9+
public record CursorPayload(Instant createdAt, Long id) {
10+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package org.openpodcastapi.opa.pagination;
2+
3+
import com.querydsl.core.types.dsl.*;
4+
import com.querydsl.jpa.impl.JPAQueryFactory;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.time.Instant;
8+
import java.util.List;
9+
10+
/// Generic repository for returning paginated results.
11+
/// Works only with entities that implement [Cursorable].
12+
@Repository
13+
public class CursorRepository {
14+
private final JPAQueryFactory queryFactory;
15+
16+
/// All-args constructor
17+
///
18+
/// @param queryFactory the JPAQueryFactory to use
19+
public CursorRepository(JPAQueryFactory queryFactory) {
20+
this.queryFactory = queryFactory;
21+
}
22+
23+
/// Fetches a paginated set of results for an entity type with a cursor
24+
///
25+
/// @param <T> the [Cursorable] entity type
26+
/// @param <Q> the QueryDSL type of the entity
27+
/// @param qEntity the entity (as its QueryDSL type)
28+
/// @param cursor the cursor used to filter results
29+
/// @param limit the number of results to fetch
30+
/// @param additionalFilter any additional [BooleanExpression] to apply to the query
31+
/// @param forward whether to cursor forwards or backwards
32+
/// @return a paginated response
33+
public <T extends Cursorable, Q extends EntityPathBase<T>> CursorPage<T> findWithCursor(
34+
Q qEntity,
35+
CursorPayload cursor,
36+
int limit,
37+
BooleanExpression additionalFilter,
38+
boolean forward
39+
) {
40+
// Get the `createdAt` timestamp of the entity
41+
final var createdAtPath = Expressions.dateTimePath(Instant.class, qEntity, "createdAt");
42+
// Get the `id` of the entity
43+
final var idPath = Expressions.numberPath(Long.class, qEntity, "id");
44+
45+
// Create the cursor pagination predicate
46+
final var predicate = buildKeysetPredicate(createdAtPath, idPath, cursor, forward);
47+
48+
final List<T> results = queryFactory
49+
.selectFrom(qEntity)
50+
.where(additionalFilter, predicate)
51+
.orderBy(createdAtPath.desc(), idPath.desc())
52+
.limit(limit)
53+
.fetch();
54+
55+
return CursorPage.of(
56+
results,
57+
limit,
58+
e -> new CursorPayload(e.getCreatedAt(), e.getId())
59+
);
60+
}
61+
62+
/// Helper function to fetch pageable results using `id` and `createdAt` fields
63+
///
64+
/// @param createdAt the created at timestamp of the entity
65+
/// @param id the database ID of the entity
66+
/// @param cursor the cursor payload to use for pagination
67+
/// @param forward whether to search forward or backwards (older or newer records)
68+
private BooleanExpression buildKeysetPredicate(
69+
DateTimePath<Instant> createdAt,
70+
NumberPath<Long> id,
71+
CursorPayload cursor,
72+
boolean forward
73+
) {
74+
if (cursor == null) return null;
75+
76+
if (forward) {
77+
return createdAt.lt(cursor.createdAt())
78+
.or(createdAt.eq(cursor.createdAt())
79+
.and(id.lt(cursor.id())));
80+
} else {
81+
return createdAt.gt(cursor.createdAt())
82+
.or(createdAt.eq(cursor.createdAt())
83+
.and(id.gt(cursor.id())));
84+
}
85+
}
86+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.openpodcastapi.opa.pagination;
2+
3+
import tools.jackson.databind.ObjectMapper;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.Base64;
7+
8+
/// Utility class for pagination operations
9+
public final class CursorUtility {
10+
11+
/// The object mapper used to construct the JSON payload
12+
private static final ObjectMapper MAPPER = new ObjectMapper();
13+
14+
/// No-args constructor
15+
private CursorUtility() {
16+
}
17+
18+
/// Encodes a pagination payload to a string for use in database queries
19+
///
20+
/// @param payload the pagination payload to encode
21+
/// @return an encoded pagination as a String
22+
public static String encode(CursorPayload payload) {
23+
try {
24+
String json = MAPPER.writeValueAsString(payload);
25+
return Base64.getUrlEncoder()
26+
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
27+
} catch (Exception e) {
28+
throw new IllegalStateException("Failed to encode pagination", e);
29+
}
30+
}
31+
32+
/// Decodes a pagination from a String
33+
///
34+
/// @param cursor the encoded pagination value
35+
/// @return a pagination payload decoded from the provided string
36+
public static CursorPayload decode(String cursor) {
37+
try {
38+
byte[] decoded = Base64.getUrlDecoder().decode(cursor);
39+
return MAPPER.readValue(decoded, CursorPayload.class);
40+
} catch (Exception e) {
41+
throw new IllegalArgumentException("Invalid pagination", e);
42+
}
43+
}
44+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.openpodcastapi.opa.pagination;
2+
3+
import java.time.Instant;
4+
5+
/// An interface for results that can be paginated using a pagination.
6+
/// Any cursorable entity must have methods for fetching the database ID and created timestamp.
7+
public interface Cursorable {
8+
/**
9+
* @return the entity ID
10+
*/
11+
Long getId();
12+
13+
/**
14+
* @return the entity's `created_at` timestamp
15+
*/
16+
Instant getCreatedAt();
17+
}

src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import jakarta.annotation.Nullable;
66
import org.hibernate.validator.constraints.URL;
77
import org.hibernate.validator.constraints.UUID;
8-
import org.jspecify.annotations.NonNull;
9-
import org.springframework.data.domain.Page;
108

119
import java.time.Instant;
1210
import java.util.List;
@@ -51,42 +49,4 @@ public record SubscriptionFailureDTO(
5149
@JsonProperty(value = "message", required = true) String message
5250
) {
5351
}
54-
55-
/// A paginated DTO representing a list of subscriptions
56-
///
57-
/// @param subscriptions the DTO list representing the subscriptions
58-
/// @param first whether this is the first page
59-
/// @param last whether this is the last page
60-
/// @param page the current page number
61-
/// @param totalPages the total number of pages in the result set
62-
/// @param numberOfElements the number of elements in the current page
63-
/// @param totalElements the total number of elements in the result set
64-
/// @param size the size limit applied to the page
65-
public record SubscriptionPageDTO(
66-
List<UserSubscriptionDTO> subscriptions,
67-
boolean first,
68-
boolean last,
69-
int page,
70-
int totalPages,
71-
long totalElements,
72-
int numberOfElements,
73-
int size
74-
) {
75-
/// Returns a paginated response with details from a page of user subscriptions
76-
///
77-
/// @param page the paginated list of DTO items
78-
/// @return a subscription DTO with pagination details filled out
79-
public static SubscriptionPageDTO fromPage(Page<@NonNull UserSubscriptionDTO> page) {
80-
return new SubscriptionPageDTO(
81-
page.getContent(),
82-
page.isFirst(),
83-
page.isLast(),
84-
page.getNumber(),
85-
page.getTotalPages(),
86-
page.getTotalElements(),
87-
page.getNumberOfElements(),
88-
page.getSize()
89-
);
90-
}
91-
}
9252
}

src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
import jakarta.persistence.*;
44
import org.openpodcastapi.opa.feed.FeedEntity;
5+
import org.openpodcastapi.opa.pagination.Cursorable;
56
import org.openpodcastapi.opa.user.UserEntity;
67

78
import java.time.Instant;
89
import java.util.UUID;
910

1011
/// Entity representing the relationship between a user and a subscription
1112
@Entity
12-
@Table(name = "subscriptions")
13-
public class SubscriptionEntity {
13+
@Table(name = "subscriptions", indexes = {
14+
@Index(name = "subscriptions_id_created_at", columnList = "user_id, createdAt DESC, id DESC")
15+
})
16+
public class SubscriptionEntity implements Cursorable {
1417
/// The entity ID
1518
@Id
1619
@GeneratedValue(strategy = GenerationType.IDENTITY)

0 commit comments

Comments
 (0)