Wstęp
Pobieranie danych z bazy to codzienność w większości projektów. Problem zaczyna się wtedy, gdy nasza tabela liczy sobie tysiące albo miliony rekordów. Zaciąganie wszystkiego naraz? Prosta droga do OutOfMemoryError, zapchania aplikacji i cierpliwości użytkownika.
Na szczęście Spring Data JPA ma na to gotowe i bardzo wygodne rozwiązanie. Wbudowany mechanizm paginacji i sortowania pozwala pobierać dane w porcjach (stronach) i dynamicznie je sortować – bez potrzeby pisania własnych zapytań SQL czy kombinowania z limitem i offsetem.
W tym „Szybkim Strzale” pokażę Ci, jak krok po kroku ogarnąć paginację i sortowanie w Spring Data JPA – od podstaw, przez praktyczne przykłady, aż po dobre praktyki. Przejdziemy przez PagingAndSortingRepository, Pageable, integrację z kontrolerem REST, testowanie oraz to, jak stworzyć własną odpowiedź PagedResponse<T>, która lepiej dogada się z frontendem. Na koniec zerkniemy na kilka przykład z praktyki.
Podstawy – PagingAndSortingRepository i Pageable
Za paginację i sortowanie w Spring Data odpowiada specjalny interfejs – PagingAndSortingRepository. To rozszerzenie klasycznego Repository, które daje nam możliwość wygodnego pobierania danych strona po stronie i w ustalonej kolejności.
Załóżmy, że mamy prostą encję Product:
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private LocalDate createdAt;
// konstruktory, gettery i settery
}
Aby włączyć dla niej mechanizm paginacji, wystarczy, że nasze repozytorium będzie dziedziczyć po PagingAndSortingRepository:
import org.springframework.data.repository.PagingAndSortingRepository;
public interface ProductRepository extends PagingAndSortingRepository<Product, Long> {
}
To wszystko! W ten sposób dostajemy „za darmo” nową wersję metody findAll(), która przyjmuje specjalny obiekt Pageable.
Pageable i Sort to dwa kluczowe interfejsy:
Pageableprzechowuje informacje o numerze strony, jej rozmiarze oraz opcjonalnie o sortowaniu.Sortnatomiast definiuje, po których polach i w jakiej kolejności (rosnąco/malejąco) mają być posortowane wyniki.
Paginacja i sortowanie w praktyce
Zobaczmy, jak użyć Pageable w naszym repozytorium. Chcemy pobrać wszystkie produkty, ale w formie stron.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Page<Product> getProducts(int page, int size, String sortBy, String direction) {
Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(pageable);
}
public Page<Product> findAll(Pageable pageable) {
return productRepository.findAll(pageable);
}
}
Metoda findAll(pageable) zwraca obiekt typu Page<T>. To znacznie więcej niż zwykła lista. Zawiera on nie tylko porcję danych dla wybranej strony, ale także metadane o całej kolekcji:
getContent()– zwraca listę obiektów (List<Product>) na bieżącej stronie.getTotalElements()– zwraca całkowitą liczbę wszystkich rekordów w bazie.getTotalPages()– zwraca całkowitą liczbę dostępnych stron.isFirst()/isLast()– informuje, czy jesteśmy na pierwszej/ostatniej stronie.
Dzięki temu frontend może łatwo zbudować komponent do nawigacji po stronach.
Paginacja w kontrolerze REST
Największą zaletą tego mechanizmu jest jego genialna integracja ze Spring MVC. Nie musimy ręcznie tworzyć obiektu Pageable. Wystarczy, że dodamy go jako parametr metody w kontrolerze.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public Page<Product> getProducts(Pageable pageable) {
return productService.findAll(pageable);
}
}
Spring automatycznie zmapuje parametry z zapytania HTTP na obiekt Pageable. Wystarczy, że wyślemy żądanie GET pod adres: /api/products?page=0&size=10&sort=price,desc
- page=0 – numer strony (indeksowanie od zera!).
- size=10 – liczba elementów na stronie.
- sort=price,desc – sortowanie po polu
pricemalejąco. Możemy też sortować po wielu polach, np.sort=createdAt,desc&sort=name,asc.
Jak testować paginację i sortowanie?
Każda logika w aplikacji powinna być przetestowana, a paginacja nie jest wyjątkiem. Aby mieć pewność, że wszystko działa jak należy, warto przetestować każdą warstwę: repozytorium, serwis i kontroler.
Jako przykład posłużą nam przykłady z poprzedniego punktu, gdzie tworzyliśmy: ProductRepository, ProductService oraz ProductController.
Testowanie repozytorium
Najlepszym sposobem na sprawdzenie, czy nasze repozytorium poprawnie paginuje i sortuje dane, są testy integracyjne. Adnotacja @DataJpaTest od Spring Boot jest do tego idealna. Uruchamia ona tylko warstwę JPA (wraz z wbudowaną bazą danych H2), dzięki czemu testy są szybkie i odizolowane. W ten sposób weryfikujemy, że nasze zapytanie do bazy danych jest poprawne i zwraca oczekiwane, posortowane porcje danych.
// importy
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
// Helper: przygotowanie danych testowych
productRepository.saveAll(List.of(
new Product("Laptop", new BigDecimal("4500.00")),
new Product("Mysz", new BigDecimal("150.00")),
new Product("Klawiatura", new BigDecimal("300.00")),
new Product("Monitor", new BigDecimal("1200.00"))
));
}
@Test
void shouldReturnPaginatedAndSortedProducts() {
// Given - tworzymy obiekt Pageable
Pageable pageable = PageRequest.of(0, 2, Sort.by("price").descending());
// When - wywołujemy metodę repozytorium
Page<Product> resultPage = productRepository.findAll(pageable);
// Then - sprawdzamy wyniki
assertThat(resultPage.getTotalElements()).isEqualTo(4);
assertThat(resultPage.getTotalPages()).isEqualTo(2);
assertThat(resultPage.getContent()).hasSize(2);
assertThat(resultPage.getContent().get(0).getName()).isEqualTo("Laptop"); // Najdroższy
assertThat(resultPage.getContent().get(1).getName()).isEqualTo("Monitor");
}
}
Testowanie serwisu
Serwis zazwyczaj zawiera logikę biznesową, ale w naszym prostym przypadku jest tylko pośrednikiem. Testując go, nie chcemy łączyć się z bazą danych. Stosujemy więc testy jednostkowe i mockujemy (@Mock) zależność do repozytorium. Celem jest sprawdzenie, czy serwis poprawnie przekazuje obiekt Pageable do niższej warstwy.
// importy
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@InjectMocks
private ProductService productService;
@Test
void shouldPassPageableToRepository() {
// Given
Pageable pageable = PageRequest.of(0, 10);
Page<Product> expectedPage = new PageImpl<>(Collections.emptyList());
when(productRepository.findAll(pageable)).thenReturn(expectedPage);
// When
productService.findAll(pageable);
// Then
verify(productRepository).findAll(pageable); // Weryfikujemy, czy metoda repozytorium została wywołana z naszym Pageable
}
}
Testowanie kontrolera
Do testowania kontrolera również używamy testów typu „slice” z adnotacją @WebMvcTest (więcej o testach slice pisałem tutaj). Skupia się ona wyłącznie na warstwie webowej (kontrolerach, filtrach, serializacji JSON), bez ładowania serwisów i repozytoriów. Serwis (ProductService) jest w tym przypadku mockowany za pomocą @MockBean. Dzięki temu możemy zasymulować żądanie HTTP i sprawdzić, czy odpowiedź JSON ma prawidłową strukturę.
// importy
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void shouldReturnPaginatedResponse() throws Exception {
// Given
Product product = new Product("Laptop", new BigDecimal("4500.00"));
Page<Product> productPage = new PageImpl<>(List.of(product), PageRequest.of(0, 1), 1);
when(productService.findAll(any(Pageable.class))).thenReturn(productPage);
// When & Then
mockMvc.perform(get("/api/products?page=0&size=1&sort=name,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("Laptop"))
.andExpect(jsonPath("$.totalElements").value(1));
}
}
Lepsze API – Tworzenie własnego PagedResponse<T>
Standardowy obiekt Page<T> jest świetny, ale często zwraca więcej informacji, niż potrzebuje klient API (np. nasza aplikacja frontendowa). Co więcej, jego struktura jest ściśle powiązana z biblioteką Spring Data, co nie jest idealne dla publicznego API.
Dlatego dobrą praktyką jest opakowanie wyniku we własny, dedykowany obiekt PagedResponse<T>. Daje nam to pełną kontrolę nad formatem JSON i pozwala dostarczyć frontendowi (np. aplikacji w Angularze) tylko te dane, których faktycznie potrzebuje.
Przykładowa implementacja może wyglądać tak:
// Nasz własny wrapper na paginowane dane
public class PagedResponse<T> {
private List<T> content;
private PageMetadata metadata;
// konstruktor, gettery
}
// Metadane, które przekażemy do API
public class PageMetadata {
private int page;
private int size;
private long totalElements;
private int totalPages;
// konstruktor, gettery
}
Teraz wystarczy w serwisie lub kontrolerze dokonać prostej transformacji z Page<T> na PagedResponse<T>:
// W serwisie
public PagedResponse<Product> getProductsPaginated(Pageable pageable) {
Page<Product> productPage = productRepository.findAll(pageable);
PageMetadata metadata = new PageMetadata(
productPage.getNumber(),
productPage.getSize(),
productPage.getTotalElements(),
productPage.getTotalPages()
);
return new PagedResponse<>(productPage.getContent(), metadata);
}
Dobre praktyki i typowe pułapki
Na koniec kilka wskazówek z praktyki, o których warto pamiętać:
- Indeksowanie od zera – Pamiętaj, że strony są numerowane od zera. Pierwsza strona to
page=0, a niepage=1. To częste źródło pomyłek. - Domyślne wartości – Co jeśli użytkownik nie poda parametrów? Warto ustawić domyślne wartości w kontrolerze za pomocą adnotacji:
getProducts(@PageableDefault(size = 20, sort = "name") Pageable pageable) - Uważaj na duży
size– Zabezpiecz swoje API przed próbą pobrania zbyt dużej liczby rekordów na raz (np.size=10000). Warto dodać walidację i narzucić maksymalny dopuszczalny rozmiar strony. - Nieprawidłowe pola sortowania – Jeśli użytkownik poda nazwę pola, które nie istnieje w encji, Spring rzuci wyjątkiem. Warto to obsłużyć globalnym
ExceptionHandler, aby zwrócić klientowi czytelny błąd400 Bad Request.
Podsumowanie
Paginacja i sortowanie w Spring Data JPA to jedno z tych rozwiązań, które po prostu warto znać. Prosto się tego używa, a efekty są od razu odczuwalne – mniejsze obciążenie bazy, lepsza wydajność i bardziej ogarnięty frontend.
Kilka rzeczy warto mieć zawsze z tyłu głowy: strony zaczynamy liczyć od zera, dobrze ustawić sensowne wartości domyślne (żeby ktoś nie strzelił size=10000), a parametry wejściowe warto walidować. Jeśli chcesz, żeby Twoje API wyglądało porządnie, pomyśl też o własnym formacie odpowiedzi – frontend to doceni. I na koniec: testy. Bez nich nawet najładniejszy kod może zawieść w najmniej spodziewanym momencie.
Z tym podejściem Twoje API będzie nie tylko działać, ale będzie też gotowe na duży ruch i kolejne wymagania.