Class 10: Pagination & Filtering
Duration: 30 minutes Difficulty: Intermediate Prerequisites: Completed Classes 1-9
What You'll Learn
By the end of this class, you will:
- Implement offset-based pagination (simple)
- Implement cursor-based pagination (scalable)
- Use the Connection pattern from Relay
- Add flexible filtering with multiple criteria
- Implement sorting options
Why Pagination Matters
Without pagination, a simple query could return millions of records:
query {
movies { # Returns 1,000,000 movies = crashed browser
title
}
}
┌─────────────────────────────────────────────────────────────────────┐
│ PAGINATION STRATEGIES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ OFFSET PAGINATION CURSOR PAGINATION │
│ ───────────────────────────────────────────────────────────────── │
│ Page 1: OFFSET 0, LIMIT 10 First 10: AFTER null │
│ Page 2: OFFSET 10, LIMIT 10 Next 10: AFTER "cursor_10" │
│ Page 5: OFFSET 40, LIMIT 10 Next 10: AFTER "cursor_20" │
│ │
│ Pros: Pros: │
│ - Simple to implement - Stable (handles inserts/deletes)│
│ - Easy random page access - Efficient for large datasets │
│ - Familiar to users - Works with real-time data │
│ │
│ Cons: Cons: │
│ - Slow for large offsets - No random page access │
│ - Data shifts cause problems - More complex implementation │
│ - Duplicates/skips on changes - Cursor must be opaque │
│ │
└─────────────────────────────────────────────────────────────────────┘
Step 1: Simple Offset Pagination
Let's start with the simpler approach:
📁 Update src/main/resources/graphql/schema.graphqls:
type Query {
# ... existing queries
"Get movies with pagination"
moviesPage(
page: Int = 0
size: Int = 10
genre: Genre
sortBy: MovieSortField = TITLE
sortOrder: SortOrder = ASC
): MoviePage!
}
type MoviePage {
"The list of movies for this page"
content: [Movie!]!
"Total number of movies (across all pages)"
totalElements: Int!
"Total number of pages"
totalPages: Int!
"Current page number (0-indexed)"
currentPage: Int!
"Number of items per page"
pageSize: Int!
"Is this the first page?"
isFirst: Boolean!
"Is this the last page?"
isLast: Boolean!
"Does a next page exist?"
hasNext: Boolean!
"Does a previous page exist?"
hasPrevious: Boolean!
}
enum MovieSortField {
TITLE
RELEASE_YEAR
RATING
RUNTIME
}
enum SortOrder {
ASC
DESC
}
📁 Create src/main/java/com/example/moviedb/dto/MoviePage.java:
package com.example.moviedb.dto;
import com.example.moviedb.model.Movie;
import java.util.List;
public class MoviePage {
private List<Movie> content;
private int totalElements;
private int totalPages;
private int currentPage;
private int pageSize;
public MoviePage(List<Movie> content, int totalElements, int currentPage, int pageSize) {
this.content = content;
this.totalElements = totalElements;
this.currentPage = currentPage;
this.pageSize = pageSize;
this.totalPages = (int) Math.ceil((double) totalElements / pageSize);
}
public List<Movie> getContent() { return content; }
public int getTotalElements() { return totalElements; }
public int getTotalPages() { return totalPages; }
public int getCurrentPage() { return currentPage; }
public int getPageSize() { return pageSize; }
public boolean isFirst() { return currentPage == 0; }
public boolean isLast() { return currentPage >= totalPages - 1; }
public boolean isHasNext() { return currentPage < totalPages - 1; }
public boolean isHasPrevious() { return currentPage > 0; }
}
📁 Add to MovieRepository:
public List<Movie> findAllPaged(int page, int size, Genre genre,
MovieSortField sortBy, SortOrder sortOrder) {
var stream = movies.stream();
// Filter by genre if specified
if (genre != null) {
stream = stream.filter(m -> m.getGenre() == genre);
}
// Sort
Comparator<Movie> comparator = getComparator(sortBy);
if (sortOrder == SortOrder.DESC) {
comparator = comparator.reversed();
}
return stream
.sorted(comparator)
.skip((long) page * size)
.limit(size)
.toList();
}
public int countAll(Genre genre) {
if (genre != null) {
return (int) movies.stream().filter(m -> m.getGenre() == genre).count();
}
return movies.size();
}
private Comparator<Movie> getComparator(MovieSortField sortBy) {
return switch (sortBy) {
case TITLE -> Comparator.comparing(Movie::getTitle);
case RELEASE_YEAR -> Comparator.comparing(Movie::getReleaseYear);
case RATING -> Comparator.comparing(m -> m.getRating() != null ? m.getRating() : 0.0);
case RUNTIME -> Comparator.comparing(m -> m.getRuntime() != null ? m.getRuntime() : 0);
};
}
📁 Add enums:
// MovieSortField.java
package com.example.moviedb.model;
public enum MovieSortField { TITLE, RELEASE_YEAR, RATING, RUNTIME }
// SortOrder.java
package com.example.moviedb.model;
public enum SortOrder { ASC, DESC }
📁 Add controller method:
@QueryMapping
public MoviePage moviesPage(@Argument Integer page,
@Argument Integer size,
@Argument Genre genre,
@Argument MovieSortField sortBy,
@Argument SortOrder sortOrder) {
int pageNum = page != null ? page : 0;
int pageSize = Math.min(size != null ? size : 10, 100); // Max 100
MovieSortField sort = sortBy != null ? sortBy : MovieSortField.TITLE;
SortOrder order = sortOrder != null ? sortOrder : SortOrder.ASC;
List<Movie> content = movieRepository.findAllPaged(pageNum, pageSize, genre, sort, order);
int totalCount = movieRepository.countAll(genre);
return new MoviePage(content, totalCount, pageNum, pageSize);
}
Test Offset Pagination
query {
moviesPage(page: 0, size: 3, sortBy: RATING, sortOrder: DESC) {
content {
title
rating
}
totalElements
totalPages
hasNext
hasPrevious
}
}
Step 2: Cursor-Based Pagination (Connection Pattern)
The Connection pattern is the standard for cursor-based pagination:
📁 Add to schema:
type Query {
# ... existing queries
"Get movies using cursor-based pagination (Relay-style)"
moviesConnection(
first: Int
after: String
last: Int
before: String
filter: MovieFilter
): MovieConnection!
}
input MovieFilter {
genre: Genre
minRating: Float
maxRating: Float
minYear: Int
maxYear: Int
titleContains: String
inTheaters: Boolean
}
type MovieConnection {
"The list of edges (movie + cursor)"
edges: [MovieEdge!]!
"Pagination info"
pageInfo: PageInfo!
"Total count (optional, can be expensive)"
totalCount: Int!
}
type MovieEdge {
"The movie"
node: Movie!
"Cursor for this movie (use in after/before)"
cursor: String!
}
type PageInfo {
"Are there more items after the last edge?"
hasNextPage: Boolean!
"Are there more items before the first edge?"
hasPreviousPage: Boolean!
"Cursor of the first edge"
startCursor: String
"Cursor of the last edge"
endCursor: String
}
📁 Create connection DTOs:
// MovieConnection.java
package com.example.moviedb.dto;
import java.util.List;
public class MovieConnection {
private List<MovieEdge> edges;
private PageInfo pageInfo;
private int totalCount;
public MovieConnection(List<MovieEdge> edges, PageInfo pageInfo, int totalCount) {
this.edges = edges;
this.pageInfo = pageInfo;
this.totalCount = totalCount;
}
public List<MovieEdge> getEdges() { return edges; }
public PageInfo getPageInfo() { return pageInfo; }
public int getTotalCount() { return totalCount; }
}
// MovieEdge.java
package com.example.moviedb.dto;
import com.example.moviedb.model.Movie;
public class MovieEdge {
private Movie node;
private String cursor;
public MovieEdge(Movie node, String cursor) {
this.node = node;
this.cursor = cursor;
}
public Movie getNode() { return node; }
public String getCursor() { return cursor; }
}
// PageInfo.java
package com.example.moviedb.dto;
public class PageInfo {
private boolean hasNextPage;
private boolean hasPreviousPage;
private String startCursor;
private String endCursor;
public PageInfo(boolean hasNextPage, boolean hasPreviousPage,
String startCursor, String endCursor) {
this.hasNextPage = hasNextPage;
this.hasPreviousPage = hasPreviousPage;
this.startCursor = startCursor;
this.endCursor = endCursor;
}
public boolean isHasNextPage() { return hasNextPage; }
public boolean isHasPreviousPage() { return hasPreviousPage; }
public String getStartCursor() { return startCursor; }
public String getEndCursor() { return endCursor; }
}
// MovieFilter.java
package com.example.moviedb.dto;
import com.example.moviedb.model.Genre;
public class MovieFilter {
private Genre genre;
private Double minRating;
private Double maxRating;
private Integer minYear;
private Integer maxYear;
private String titleContains;
private Boolean inTheaters;
// Getters and setters
public Genre getGenre() { return genre; }
public void setGenre(Genre genre) { this.genre = genre; }
public Double getMinRating() { return minRating; }
public void setMinRating(Double minRating) { this.minRating = minRating; }
public Double getMaxRating() { return maxRating; }
public void setMaxRating(Double maxRating) { this.maxRating = maxRating; }
public Integer getMinYear() { return minYear; }
public void setMinYear(Integer minYear) { this.minYear = minYear; }
public Integer getMaxYear() { return maxYear; }
public void setMaxYear(Integer maxYear) { this.maxYear = maxYear; }
public String getTitleContains() { return titleContains; }
public void setTitleContains(String titleContains) { this.titleContains = titleContains; }
public Boolean getInTheaters() { return inTheaters; }
public void setInTheaters(Boolean inTheaters) { this.inTheaters = inTheaters; }
}
📁 Create cursor utility:
// CursorUtils.java
package com.example.moviedb.util;
import java.util.Base64;
public class CursorUtils {
public static String encode(String id) {
return Base64.getEncoder().encodeToString(("cursor:" + id).getBytes());
}
public static String decode(String cursor) {
if (cursor == null) return null;
try {
String decoded = new String(Base64.getDecoder().decode(cursor));
return decoded.replace("cursor:", "");
} catch (Exception e) {
throw new IllegalArgumentException("Invalid cursor: " + cursor);
}
}
}
📁 Add repository method:
public List<Movie> findWithFilter(MovieFilter filter) {
var stream = movies.stream();
if (filter != null) {
if (filter.getGenre() != null) {
stream = stream.filter(m -> m.getGenre() == filter.getGenre());
}
if (filter.getMinRating() != null) {
stream = stream.filter(m -> m.getRating() != null && m.getRating() >= filter.getMinRating());
}
if (filter.getMaxRating() != null) {
stream = stream.filter(m -> m.getRating() != null && m.getRating() <= filter.getMaxRating());
}
if (filter.getMinYear() != null) {
stream = stream.filter(m -> m.getReleaseYear() >= filter.getMinYear());
}
if (filter.getMaxYear() != null) {
stream = stream.filter(m -> m.getReleaseYear() <= filter.getMaxYear());
}
if (filter.getTitleContains() != null) {
String lower = filter.getTitleContains().toLowerCase();
stream = stream.filter(m -> m.getTitle().toLowerCase().contains(lower));
}
if (filter.getInTheaters() != null) {
stream = stream.filter(m -> m.isInTheaters() == filter.getInTheaters());
}
}
return stream.sorted(Comparator.comparing(Movie::getId)).toList();
}
📁 Add controller method:
@QueryMapping
public MovieConnection moviesConnection(@Argument Integer first,
@Argument String after,
@Argument Integer last,
@Argument String before,
@Argument MovieFilter filter) {
// Get all filtered movies
List<Movie> allMovies = movieRepository.findWithFilter(filter);
int totalCount = allMovies.size();
// Handle cursors
int startIndex = 0;
int endIndex = allMovies.size();
if (after != null) {
String afterId = CursorUtils.decode(after);
for (int i = 0; i < allMovies.size(); i++) {
if (allMovies.get(i).getId().equals(afterId)) {
startIndex = i + 1;
break;
}
}
}
if (before != null) {
String beforeId = CursorUtils.decode(before);
for (int i = 0; i < allMovies.size(); i++) {
if (allMovies.get(i).getId().equals(beforeId)) {
endIndex = i;
break;
}
}
}
// Apply limits
List<Movie> slice = allMovies.subList(
Math.min(startIndex, allMovies.size()),
Math.min(endIndex, allMovies.size())
);
if (first != null && first < slice.size()) {
slice = slice.subList(0, first);
}
if (last != null && last < slice.size()) {
slice = slice.subList(slice.size() - last, slice.size());
}
// Build edges
List<MovieEdge> edges = slice.stream()
.map(m -> new MovieEdge(m, CursorUtils.encode(m.getId())))
.toList();
// Build page info
boolean hasNextPage = endIndex < allMovies.size() ||
(first != null && startIndex + first < endIndex);
boolean hasPreviousPage = startIndex > 0 ||
(last != null && startIndex + slice.size() < endIndex);
PageInfo pageInfo = new PageInfo(
hasNextPage,
hasPreviousPage,
edges.isEmpty() ? null : edges.get(0).getCursor(),
edges.isEmpty() ? null : edges.get(edges.size() - 1).getCursor()
);
return new MovieConnection(edges, pageInfo, totalCount);
}
Test Cursor Pagination
# First page
query {
moviesConnection(first: 3) {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# Next page (use endCursor from previous response)
query {
moviesConnection(first: 3, after: "Y3Vyc29yOjM=") {
edges {
cursor
node {
title
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
}
}
# With filter
query {
moviesConnection(first: 5, filter: {
minRating: 8.5
minYear: 2000
}) {
edges {
node {
title
rating
releaseYear
}
}
totalCount
}
}
Understanding Cursor Flow
┌─────────────────────────────────────────────────────────────────────┐
│ CURSOR PAGINATION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Request: first: 3 │
│ │
│ Database: [M1, M2, M3, M4, M5, M6, M7] │
│ ↓ ↓ ↓ │
│ Response: [M1, M2, M3] │
│ startCursor: "cursor:1" │
│ endCursor: "cursor:3" │
│ hasNextPage: true │
│ │
│ Request: first: 3, after: "cursor:3" │
│ │
│ Database: [M1, M2, M3, M4, M5, M6, M7] │
│ ↓ ↓ ↓ │
│ Response: [M4, M5, M6] │
│ startCursor: "cursor:4" │
│ endCursor: "cursor:6" │
│ hasNextPage: true │
│ hasPreviousPage: true │
│ │
└─────────────────────────────────────────────────────────────────────┘
Exercises
Exercise 1: Add Director Connection
Implement directorsConnection with cursor-based pagination.
Exercise 2: Backwards Pagination
Test and verify that last and before work correctly for backwards navigation.
Exercise 3: Complex Filter
Add a filter for movies by actor name:
input MovieFilter {
# ... existing fields
actorName: String # Filter movies by actor name
}
Summary
In this class, you learned:
✅ Offset pagination (simple, familiar) ✅ Cursor-based pagination (stable, scalable) ✅ The Connection/Edge/Node pattern ✅ Implementing flexible filtering ✅ Sorting with multiple fields ✅ Base64 cursor encoding
What's Next?
In Class 11: File Uploads & Custom Scalars, we'll learn:
- Handling file uploads in GraphQL
- Creating custom scalar types (Date, DateTime, etc.)
- Validating custom types
Your API is almost production-ready!