Jak paginować i sortować wyniki w Spring Data?

You are currently viewing Jak paginować i sortować wyniki w Spring Data?

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:

  • Pageable przechowuje informacje o numerze strony, jej rozmiarze oraz opcjonalnie o sortowaniu.
  • Sort natomiast 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 price maleją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ć:

  1. Indeksowanie od zera – Pamiętaj, że strony są numerowane od zera. Pierwsza strona to page=0, a nie page=1. To częste źródło pomyłek.
  2. 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)
  3. 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.
  4. 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łąd 400 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.

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze