“Die Lösung ist immer einfach, man muss sie nur finden.”
Alexander Solschenizyn
Durch den Einsatz von Spring Data JPA in eigenen Projekten können Datenbankaktionen sehr einfach mit Hilfe von Repository
Interfaces umgesetzt werden. Alle Zugriffe auf die Entitäten erfolgen über Methoden dieser Schnittstellen.
public interface AncestorRepository extends JpaRepository<Ancestor, Long> { List <Ancestor> findByLastNameLikeAndBirthBetween(String lastName, LocalDate start, LocalDate end, Pageable pageable); }
In diesem Beispiel wird eine Schnittstelle AncestorRepository
definiert, das von JpaRespository
erbt und als Entität die Klasse Ancestor
und als ID die Klasse Long
nutzt.
Diese Schnittstelle definiert auch eine Methode findByLastNameLikeAndBirthBetween
mit der alle Ancestor
gefunden werden, deren Nachname den Parameter lastName
gleicht und deren Geburtsdatum zwischen dem Datum start
und end
liegt. Das Ergebnis wird in einer Liste zurückgegeben, die eine maximale Anzahl von Elementen enthält.
All diese Informationen entnimmt Spring Data JPA der Methodensignatur und generiert im Hintergrund eine entsprechende Implementierung.
Da das AncestorRepository
aber nicht direkt die Schnittstelle Repository erweitert, erhält sie auch alle Zugriffsmethoden aller anderen erweiterten Schnittstellen. In diesem Fall von den Schnittstellen CrudRepository
(save
, saveAll
, delete
, deleteAll
, deleteById
, deleteAllById
, findById
, findAll
, findAllById
, existsById
, count
), PagingAndSortingRepository
(findAll
mit Pageable
und Sort
) und JpaRepository
(saveAndFlush
, deleteInBatch
, findAll
mit Example
und Sort
, …).
Mit diesen vordefinierten Zugriffsmethoden und der Möglichkeit eigene Zugriffsmethoden zu deklarieren ist ein Großteil der üblichen Anwendungen abgedeckt. Einziger Nachteil dieser Methoden ist ihre feste Definition. Für jede Anfrage mit einem etwas anderen Satz von Suchparametern muss eine neue Methode definiert werden.
List<Ancestor> ancestors = repository.findByLastNameAndFirstNameAndMiddleNameAndTitleLikeAndBirthPlaceLikeAndBirthBetween(String lastName, String firstName, String middleName, String title, String birthPlace, LocalDate start, LocalDate end, Pageable pageable);
In diesem Beispiel wird nach Titel, Vor-, Mittel- und Nachnamen, Geburtsort und Geburtsdatum gesucht. Schon bei diesen wenigen Suchparametern wird klar, dass dieser Ansatz zu einer großen Zahl von schlecht verständlichen Methoden führt.
Ein alternativer Ansatz, um die Suchen dynamisch zu generieren, verwendet die Schnittstelle Specification
.
interface Specification<T> { Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb); }
Mit Hilfe dieser Schnittstelle werden die einzelnen Komponenten einer Suche definiert und können über die Methoden and
, or
und not
verknüpft werden. Über den Root
Parameter kann innerhalb der Specification
auf Attribute der Entität zugegriffen werden und der CriteriaBuilder
liefert eine lange Liste von Methoden um Vergleiche zu erstellen, Ausdrücke zu verknüpfen und vieles mehr.
Damit das eigene AncestorRepository
mit den Specification
umgehen kann, muss es die Schnittstelle JpaSpecificationExcutor
erweitern. Damit stehen einige find
Methoden zur Verfügung, die einen Parameter vom Typ Specification
besitzen.
public interface AncestorRepository extends JpaRepository<Ancestor, Long>, JpaSpecificationExecutor<Ancestor> { @NonNull static Specification<Ancestor> gender(Gender search) { return (ancestor, cq, cb) -> cb.equal(ancestor.<Gender>get("gender"), search); } @NonNull static Specification<Ancestor> lastName(String search) { return (ancestor, cq, cb) -> cb.like(cb.lower(ancestor.get("lastname")), "%" + search.toLowerCase() + "%"); } }
Dieses AncestorRepository
erweitert JpaSpecificationExcutor
und kann deshalb mit Specification
umgehen. Die beiden Convenience Methoden gender
und lastName
erzeugen eine Specification
, die auf dem jeweiligen Eingabeparameter search
basiert. Bei gender
wird ein Vergleich mit dem Attribute gender vorgenommen. Bei lastname
wird geprüft, ob der Parameter im Attribut lastname
vorkommt. Dabei wird die Groß-/Kleinschreibung nicht beachtet.
Im folgendem Bespiel wird eine Suche nach männlichen Vorfahren ohne den Nachnamen Kaiser durchgeführt. Die erste Variante nutzt keine statischen Importe und ist deshalb schwieriger zu lesen und zu verstehen. Die zweite Variante ist, dank der fluent API, sehr einfach zu verstehen.
List<Ancestor> ancestors = repository.findAll( Specification.where(AncestorRepository.gender(Gender.MALE)) .and(Specification.not(AncestorRepository.lastName("Kaiser")))), pageable); List<Ancestor> ancestors = repository.findAll(where(gender(Gender.MALE)).and(not(lastName("Kaiser")))), pageable);
Das Beispiel zeigt jedoch noch immer eine feste Suchanfrage. Im folgenden ein dynamisches Beispiel bei dem nach Geburtsort, Geburtsdatum, Vor-, Mittel- und Nachnamen gesucht werden kann. Im Gegensatz zum Beispiel am Anfang des Beitrags sind hier alle Suchparameter optional. Ist für ein Attribut kein Wert angegeben, so wird dieses Attribut nicht in die Suche einbezogen.
Specification<Ancestor> specification = (ancestor, cq, cb) -> { Predicate[] predicates = Stream.of( firstName.map(x -> cb.like(ancestor.get("firstName"), "%" + x + "%")), middleName.map(x -> cb.like(ancestor.get("middleName"), "%" + x + "%")), lastName.map(x -> cb.like(ancestor.get("lastName"), "%" + x + "%")), place.map(x -> cb.like(ancestor.get("birthPlace"), "%" + x + "%")), end.map(x -> cb.lessThanOrEqualTo(ancestor.<LocalDate>get("birth"), x)), start.map(x -> cb.greaterThanOrEqualTo(ancestor.<LocalDate>get("birth"), x)) ).map(Optional::isPresent).map(Optional::get).toArray(Predicate[]::new); return cb.length == 0 ? null : cb.and(predicates); }; return repository.findAll(specification, pageable);
Die Specification
erzeugt aus den Suchparametern einen Stream
von Optional<Predicate>
Instanzen. Leere Instanzen werden herausgefiltert und aus den übriggebliebenen Predicate
Instanzen ein Array erzeugt. Ist das Array leer dann liefert die Specification
null zurück, ansonsten ein Predicate
, dass alle Instanzen aus dem Array mit \land verknüpft.
Da in diesem Beispiel start
und end
unabhängig voneinander sind, können in der Suche auch offene Intervalle betrachtet werden. Beispielsweise kann nach allen Geburtsdaten vor 1800 oder nach 1600 gesucht werden, wenn der betreffende Wert gesetzt wird und der jeweils andere undefiniert bleibt.
Damit ist dieser Beitrag über Spring Data JPA Specifications auch schon wieder an sein Ende gekommen. Aber bei alledem was möglich ist, sollte man sich immer das folgende Zitat von Pablo Picasso vergegenwärtigen. Denn häufig hilft keine verbesserte Suche, wenn der Anwender nichts hilfreiches finden kann.
“Das Geheimnis der Kunst liegt darin, dass man nicht sucht, sondern findet.”
Pablo Picasso