Suchen mit Spring Data JPA Specification

“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

Schreibe einen Kommentar