Spring Data JPA - Ciekawy przypadek

Spring Data JPA opisujemy szeroko w kursach dostępnych na naszym portalu, na przykład w rozdziale Spring Data JPA - Zapytania wbudowane (Built-in Queries). Tam opisujemy jednak głównie podstawowe przypadki użycia. Teraz chcemy natomiast przedstawić bardziej złożony przykład. Polega on na przedstawieniu jednego zapytania, składającego się z kilku różnych typów selektorów (również tych mniej lubianych przez programistów), co w efekcie ma doprowadzić do zwrócenia oczekiwanego zbioru danych.

Encje i relacje

Na początek kilka słów o relacjach. Mamy dwie encje: Item i Category, które są powiązane relacją @ManyToMany. Encja Item zawiera kolekcję aliasów dla nazw - @ElementCollection. Dodatkowo mamy też trzecią encję User, w ramach której interesuje nas pole firstName.
@Entity
@Table(name = "appa_items")
public class Item extends AbstractEntity<Long> implements Comparable<Item> {

    @Column(length = 255)
    private String name;

    @Column(name = "date_from")
    private Date dateTimeFrom;

    @ManyToOne
    @JoinColumn(name = "creator_id")
    private User creator;

    @ElementCollection
    @CollectionTable(name = "item_name_aliases",
                     joinColumns = @JoinColumn(name = "item_id"))
    @Column(name = "name_alias", updatable = false)
    private Set<String> nameAliases = new HashSet<>();

    @ManyToMany(cascade = { CascadeType.MERGE })
    @JoinTable(name = "appa_categories_items", joinColumns = { @JoinColumn(name = "items_id") }, 
    inverseJoinColumns = {@JoinColumn(name = "categories_id") })
    private List<Category> appaCategories = new ArrayList<Category>();
    
    ...
}

@Entity
@Table(name = "appa_categories")
public class Category extends AbstractEntity<Long> implements Comparable<Category> {

    @Column(length = 255)
    private String name;

    @Column(length = 255)
    private String code;

    @Column(length = 4000)
    private String description;

    @ManyToMany(cascade = { CascadeType.MERGE })
    @JoinTable(name = "appa_categories_items", joinColumns = {
            @JoinColumn(name = "categories_id") }, inverseJoinColumns = { @JoinColumn(name = "items_id") })
    private Set<Item> appaItems = new HashSet<Item>();
    
    ...
}
@Entity
@Table(name = "users")
public class User extends AbstractEntity<Long> implements Comparable<Category> {
    
    private String firstName;
    
    ...
}

Spring Data JPA - Zapytanie wbudowane

Następnie mamy zdefiniowane zapytanie Spring Data JPA (wbudowane) operujące na encjach:
List<Item> 
findByNameLikeAndDateTimeFromBeforeAndNameAliasesContainingAndAppaCategories_idEqualsOrCreator_firstNameEquals
                        (String name, Date dateTimeFrom, String nameAlias, Long categoryId, String firstName);
Zapytanie jest dość długie dlatego przyjrzyjmy się co dokładnie robi:
  • findBy - wyszukuje encje Item po odpowiednio zdefiniowanych polach, łącząc kolejne warunki za pomocą And i Or
  • NameLike - wybiera encje, których pole name jest zgodne z parametrem name metody
  • DateTimeFromBefore - wybiera encje, których data z pola dateTimeFrom jest wcześniejsza, niż ta z parametru dateTimeFrom w metodzie
  • NameAliasesContaining - wybiera encje, które w liście aliasów nameAliases zawierają alias o nazwie zgodnej z parametrem nameAlias w metodzie
  • AppaCategories_idEquals - wybiera encje, które w liście kategorii mają kategorię o id równym parametrowi categoryId w metodzie
  • Creator_firstNameEquals - wybiera encje, które posiadają encję User, której pole firstName równe jest parametrowi firstName w metodzie
Od razu widać, że zapytanie nie należy do najprostszych. Tworzenie zapytań Spring Data JPA na podstawie wbudowanych słów kluczowych często przysparza problemy, jeśli chcemy porównywać parametry metod z własnościami powiązanych obiektów, a w szczególności kolekcji. Dlatego warto raz jeszcze zwrócić uwagę na sposób wyszukiwania powiązania do id kategorii, zaszytego wśród obiektów należących do kolekcji w encji:
nazwaPolaKolekcji_nazwaPolaSłowoKluczowe
To jednak nie wszystko. Zwróćmy uwagę na to jak zostanie potraktowane And i Or w zapytaniu. Oczywiście zwrócone zostaną encje spełniające wszystkie kolejne warunki przed i po każdym And lub ten jeden ostatni warunek występujący po Or.

Zmiana wymagań klienta

Wszystko będzie dobrze jeśli takie jest nasze zamierzenie i faktycznie chcemy doprowadzić do takiego porównania. Co by jednak było gdybyśmy chcieli w tym zapytaniu sprawdzić wszystkie warunki przed i po każdym And, aż do ostatniego, a ten ostatni And potraktować jako sprawdzenie dwóch warunków rozdzielonych za pomocą Or? A więc chcielibyśmy wykonać coś na wzór takiego pseudozapytania:
List<Item> 
findByNameLikeAndDateTimeFromBeforeAndNameAliasesContainingAnd(AppaCategories_idEqualsOrCreator_firstNameEquals)
                        (String name, Date dateTimeFrom, String nameAlias, Long categoryId, String firstName);
Wtedy sprawa się komplikuje. Niestety nie można stworzyć dokładnie takiego zapytania jak powyższe. Można ewentualnie próbować rozbijać to zapytanie na kilka zapytań i później dodatkowo agregować, bądź tworzyć rozbudowaną hybrydę dwóch zapytań w jednym, gdzie jedno będzie miało zapewnioną zgodność z kategoriami, a drugie z imieniem - oba połączone słowem Or. Niemniej takie coś nawet bez głębszej analizy wydaje się rozwiązaniem absurdalnym.
W tym miejscu można się jeszcze ewentualnie zastanowić, czy naprawdę gdzieś w realnym systemie może zaistnieć sytuacja, aby trzeba było budować takie zapytanie? Otóż każdy doświadczony programista pewnie potwierdzi, że jeśli coś w systemach informatycznych wydaje się prawie nierealne do wykonania (popularne nie da się), to właśnie takie coś będziemy zmuszeni napisać, aby spełnić wymagania biznesowe.
A teraz spójrzmy na to z jeszcze innej strony. Mamy pierwotne zapytanie, które działa i wykonuje swoje zadanie przez pierwsze pół roku pracy systemu. Niemniej klient chciałby w tej materii drobnego usprawnienia - żeby zamiast warunku "a" i "b" lub "c" warunek wyglądał tak jak napisaliśmy - "a" i ("b" lub "c").
Tak więc stajemy oko w oko z zadaniem przerobienia kodu, aby spełniał nowe wymaganie. Myślimy więc... "zaadaptuję lekko zapytanie i po sprawie". Patrzymy do kodu i wtedy zaczyna się... Jakoś tak na siłę próbujemy nadal zrobić coś zapytaniem wbudowanym czego się tym zapytaniem sensownie zrobić nie da. Internet już przeszukany, czas stracony. No nie da się. Co wtedy?

Spring Data JPA Custom Query - Zapytanie własne

Wtedy wystarczy zmienić koncepcję. Co prawda radykalne metody zwykle odkładamy w czasie na bliżej nieokreśloną przyszłość, ale w tym przypadku naprawdę się to opłaca. Tworzymy zatem zapytanie za pomocą interfejsu Query i problem rozwiązany:
@Query("select i from Item i join i.appaCategories ac "
        + "where i.name like :name "
        + "and i.dateTimeFrom < :dateTimeFrom "
        + "and :nameAlias member of i.nameAliases "
        + "and (ac.id = :categoryId or i.creator.firstName = :firstName) ")
List<Item> findByNameAndDateTimeFromAndAliasAndCategoryIdOrFirstName(@Param("name") String name,
        @Param("dateTimeFrom") Date dateTimeFrom, @Param("nameAlias") String nameAlias, 
        @Param("categoryId") Long categoryId, @Param("firstName") String firstName);
Przyjrzyjmy się dokładnie temu co napisaliśmy. Wyjdzie nam z tego, że powyższe zapytanie robie dokładnie to o co nam chodziło.
  • from Item i join i.appaCategories ac - wiąże encje Item z encjami Category (pole nazywa się appaCategories w encji) i używając słowa select wybiera rekordy spełniające określone warunki, występujące po where (podobnie jak w SQL)
  • i.name like :name - wybiera encje, których pole name jest zgodne z parametrem name metody
  • i.dateTimeFrom < :dateTimeFrom - wybiera encje, których data z pola dateTimeFrom jest wcześniejsza, niż ta z parametru dateTimeFrom w metodzie
  • :nameAlias member of i.nameAliases - wybiera encje, które w liście aliasów nameAliases zawierają alias o nazwie zgodnej z parametrem nameAlias w metodzie
  • ac.id = :categoryId - wybiera encje, które w liście kategorii mają kategorię o id równym parametrowi categoryId w metodzie
  • i.creator.firstName = :firstName - wybiera encje, które posiadają encję User, której pole firstName równe jest parametrowi firstName w metodzie
  • and (...or...) - dwa ostatnie warunki traktuje wspólnie (jeden z nich musi być spełniony aby całość była spełniona)
No dobrze. Pytanie powinno jakie powinno być postawione w tym momencie polega na tym, dlaczego od początku nie używamy zapytań z interfejsem Query. I to wydaje się być dobrze postawione pytanie. Sami preferujemy właśnie to rozwiązanie, gdyż jest ono najbardziej elastyczne (może poza zapytaniami natywnymi, ale to już inna historia).

Czasami jednak w zadaniach, które szczególnie na początku wydają sie mało skomplikowane, jak np. pobieranie użytkownika po adresie email, łatwiej jest użyć zapytań opartych o wbudowane słowa kluczowe. Jest to szybsze i bardziej czytelne. Problem pojawia się wtedy, gdy system się rozwija i nawet najprostsze zapytania rosną w tempie ekspresowym. I właśnie na taką sytuację możemy natrafić w projekcie, do którego właśnie dołączyliśmy. Stąd też warto wiedzieć jak to samo zrobić na kilka sposobów. Nigdy nie wiadomo co się nam przyda.

Na koniec zobaczmy jak można podejść do problemu z nieco innej strony. Zaimplementujemy zapytanie w sposób typowo programistyczny. Użyjemy Spring Data JPA Specification.

Spring Data JPA - Interfejs Specification

Na samym początku tworzymy obiekt specyfikacji:
public Specification<Item> getItemsSpecification(String name, Date dateTimeFrom, String nameAlias, 
                                                            Long categoryId, String firstName) {

    return (root, query, criteriaBuilder) -> {
    
        Predicate p1 = criteriaBuilder.like(root.get("name"), name);
        Predicate p2 = criteriaBuilder.lessThan(root.get("dateTimeFrom"), date);
        Predicate p3 = criteriaBuilder.isMember(nameAlias, root.get("nameAliases"));
        
        ListJoin<Item, Category> categories = root.joinList("appaCategories", JoinType.INNER);
        Predicate p4 = criteriaBuilder.equal(categories.get("id"), categoryId);
        Predicate p5 = criteriaBuilder.equal(root.get("creator").get("firstName"), firstName);
        Predicate p4OrP5 = criteriaBuilder.or(p4, p5);
        
        return criteriaBuilder.and(p1, p2, p3, p4OrP5);
    };
}
W ramach specyfikacji tworzymy predykaty, które muszą być spełnione, aby encja mogła zostać zwrócona przez zapytanie. Przykład jest o tyle fajny, że od razu prezentuje nam kilka różnych rodzajów predykatów:
  • like - pole name encji jest zgodne z parametrem name metody
  • lessThan - wartość w polu dateTimeFrom encji jest mniejsza, niż wartość parametru dateTimeFrom w metodzie
  • isMember - wartość parametru nameAlias metody zawiera się w kolekcji nameAliases w encji
  • joinList + equal - najpierw wskazujemy jaki rodzaj złączenia ma być użyty (INNER), a następnie określamy predykat tak, by wartość parametru categoryId była równa wartości id jednej z kategorii (spośród kategorii dołączonych do encji)
  • get zagnieżdżonego obiektu + get pola - najpierw pobieramy (po nazwie pola) zagnieżdżoną encję User, a następnie określamy predykat tak, by wartość parametru firstName była równa wartości firstName z zagnieżdżonego obiektu
  • or(p4, p5) - predykat polega na wyborze jednej z opcji
Ostatecznie mamy wiec sześć predykatów, które są budowane za pomocą criteriaBuilder. Wszystkie te predykaty określają sposób w jaki chcemy wyciągać dane. Dodatkowo na końcu łączymy wszystko w jeden "ostateczny" predykat, który polega na wykonaniu operacji and. Oznacza to, że wszystkie predykaty muszą wystąpić w tym samym czasie i tylko wtedy encja będzie zwrócona przez zapytanie. Dzięki możliwości łączenia warunków pięknie poradziliśmy sobie z zadaniem obsłużenia warunku "a" i ("b" lub "c").

Jak wykonać zapytanie z tak przygotowaną specyfikacją? Wystarczy, że interfejs naszego repository (ItemRepository) będzie rozszerzał interfejs JpaSpecificationExecutor<Item>. Wówczas uzyskamy dostęp do kilku metod używających interfejsu Specification. Możemy wtedy nasze zapytanie wywołać na przykład tak:
List<Item> items = itemRepository
                .findAll(getItemsSpecification("Appa 6", new Date(), "Appa Alias 6", Long.valueOf(6), "Jan"));
Zapytanie Spring Data JPA oparte o interfejs Specification umożliwia - podobnie jak poprzednie konstrukcje - wykonanie takiego zapytania zarówno z obsługą stronicowania, jak i samego sortowania.

Autor: Jarek Klimas
Data: 12 stycznia 2019
Labele:Backend, Spring, Spring Data JPA, Poziom średniozaawansowany

Stale się rozwijamy, a więc bądź na bieżąco!
Na ten adres będziemy przesyłać informacje o ważniejszych aktualizacjach, a także o nowych materiałach pojawiających się na stronie.
Polub nas na Facebooku:
Nasi partnerzy:
Javappa to również profesjonalne usługi programistyczne oparte o technologie JAVA. Jeśli chesz nawiązać z nami kontakt w celu uzyskania doradztwa bądź stworzenia aplikacji webowej powinieneś poznać nasze doświadczenia.
Kliknij O nas .