# Criteria API

Mit Repositories lassen sich schon jede Menge Abfragen umsetzen. Werden diese jedoch komplizierter und sollen vielleicht auch mehrere Bedingungen kombiniert werden, stößt man da schnell an die Grenzen. JPA bietet aus diesem Grund die Criteria API an, mittels welcher man einzelne Bedingungen (Criteria) formulieren und auch miteinander verknüpfen kann.

# Dependencies

Theoretisch sind diese beiden Dependencies optional, da JPA und Hibernate schon alles Nötige mitbringen, um die Specification-API zu benutzen. Jedoch helfen sie beim Vermeiden von Boilerplate-Code und Refactoring-Fehlern.

# hibernate-jpamodelgen

Genau wie bei den Konfigurations-Metadaten kommt ein Annotation-Processor zum Einsatz. Er wird in build.gradle.kts konfiguriert:

plugins {
  // ...
  kotlin("kapt") version "1.4.30"
}

dependencies {
  // ...
  kapt("org.hibernate:hibernate-jpamodelgen")
}

Dieser erzeugt für jede Entity-Klasse eine zugehörige Klasse (die genauso heißt, nur mit Unterstrich endet), welche die Namen und Typen der Entity-Properties als Felder enthält.

Zu dieser beispielhaften User Entity:

@Entity
class User {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long = 0

  var name: String? = null

  var deleted: Boolean = false

  @OneToOne(/* ... */)
  var primaryGroup: Group? = null

  @OneToMany(/* ... */)
  var groups: MutableList<Group> = mutableListOf()
}

Wird von hibernate-jpamodelgen automatisch in etwa folgender Code erzeugt:

public abstract class User_ {
  public static final String ID = "id";
  public static final String NAME = "name";
  public static final String DELETED = "deleted";
  public static final String PRIMARY_GROUP = "primaryGroup";
  public static final String GROUPS = "groups";
  
  public static SingularAttribute<User, Long> id;
  public static SingularAttribute<User, String> name;
  public static SingularAttribute<User, Boolean> deleted;
  public static SingularAttribute<User, Group> primaryGroup;
  public static ListAttribute<User, Group> groups;
}

Diese Klasse kann nicht instanziiert werden, sie dient lediglich als Ablageort der Metainformationen zu den Entity-Properties.

Die String Felder enthalten die Namen der Properties, wie sie auch im Code heißen. Das klingt vielleicht redundant, hat aber den Sinn, dass z. B. beim Umbenennen der name Property in username schon beim Kompilieren sämtliche Stellen auffallen, die noch NAME benutzen. Diese Stellen würden dann erst zur Laufzeit einen Fehler werfen, sobald sie benutzt werden. Um das zu vermeiden, muss aber natürlich überall anstatt "name" die sicherere Variante User_.NAME benutzt werden.

Die generisch-typisierten *Attribute Felder verfolgen einerseits denselben Zweck (Feldumbenennungen absichern). Andererseits liefern sie aber auch Typinformationen, die automatisch benutzt werden, um bestimmte Abfragen korrekt aufzubauen und nur korrekte Auto-Complete Vorschläge beim Programmieren zu liefern. So wird anstatt .joinList<User, Group>("groups") die Variante .join(User_.groups) benutzt. Hiermit weiß JPA automatisch, dass es sich um ein List-Join handelt. Außerdem sind auch die Quell- und Ziel-Entity-Typen klar.

# kotlin-jpa-specification-dsl

Hierfür ist im Buildfile das JCenter Repository nötig:

repositories {
  // ...
  jcenter()
}

Die Dependency sieht wie folgt aus (ggf. neuere Version einsetzen):

// https://bintray.com/beta/#/consoleau/kotlin/kotlin-jpa-specification-dsl?tab=overview
implementation("au.com.console:kotlin-jpa-specification-dsl:2.0.0")

Diese Bibliothek liefert Hilfsmethoden, um einzelne Specifications mittels and / or zu verbinden. Es können damit aber auch typ- und refactoring-sichere Specifications erstellt werden. Die GitHub Seite (opens new window) des Projekts liefert ein paar Beispiele.

# Entity-Specs

Die Grundbausteine sind einzelne Bedingungen, wie z. B. "Löschflag darf nicht gesetzt sein" oder "Name des Benutzers enthält ___". Diese Bedingungen können Parameter enthalten (Name), manche kommen aber auch ohne aus (Löschflag).

Da die Bedingungen immer auf ein Entity zugeschnitten sind, bietet es sich an diese auch dort zu hinterlegen. Somit findet man sie schnell und sieht auf einen Blick, welche es gibt. Dafür bietet sich ein Singleton object innerhalb der Entity-Klasse an. Darin werden die Bedingungen als Methoden hinterlegt, die ggf. Parameter entgegennehmen.

Das sieht dann so aus:

@Entity
class User {
  // ...
  
  object Spec {
    fun isNotDeleted() =
      User::deleted.equal(false)
    
    fun nameContains(name: String) =
      User::name.like("%$name%")
  }
}

Dies sind die einfachsten Bedingungen, die man erstellen kann. Bei diesem Beispiel zu beachten ist, dass like case-sensitive vergleicht und Platzhalter nicht escaped.

WARNING

Die kurze Entity::property Syntax wird durch kotlin-jpa-specification-dsl ermöglicht. Ohne würde das so aussehen:

fun isNotDeleted(): Specification<User> =
  Specification { root, query, criteriaBuilder ->
    criteriaBuilder.equal(root.get<Boolean>("deleted"), false)
  }

WARNING

Mit nur hibernate-jpamodelgen wird es zumindest ein wenig kurzer und sicherer:

fun isNotDeleted(): Specification<User> =
  Specification { root, query, criteriaBuilder ->
    criteriaBuilder.equal(root.get(User_.deleted), false)
  }

# JpaSpecificationExecutor

Um die Specifications anzuwenden, lässt man das entsprechende Repository zusätzlich org.springframework.data.jpa.repository.JpaSpecificationExecutor implementieren:

interface UserRepository : JpaRepository<User, Long>, JpaSpecificationExecutor<User>

Dadurch erhält es automatisch verschiedene Methoden, welche einen Parameter vom Typ Specification<T> entgegennehmen, so u. a.:

fun findOne(spec: Specification<T>): Optional<T>
fun findAll(spec: Specification<T>, pageable: Pageable): Page<T>

Diese Methoden sind flexibler als die selbst benannten wie findAllByFirstNameContains, da hier zur Laufzeit dynamisch beliebige Kriterien übergeben werden können.

# Aufruf

Die neuen Repository-Methoden können dann beispielsweise im zugehörigen Service aufgerufen werden. Die Entity-Specs können dabei benutzt und kombiniert werden:

@Service
class UserService(
  private val userRepository: UserRepository
) {
  fun findUndeletedUserContaining(namePart: String) =
    userRepository.findOne(
      and(
        User.Spec.isNotDeleted(),
        User.Spec.nameContains(namePart)
      )
    )
}

Die Methode and wird von kotlin-jpa-specification-dsl zur Verfügung gestellt – es gibt auch noch or. Diese können sogar verschachtelt werden.

# Query-Objekt

Häufen sich die Filter (also Specifications), die man festlegen kann, bietet sich ein Query-Objekt an. Das ist eine data class, welche die möglichen Filter enthält und vom Aufrufer passend gefüllt wird. Diese kann dann immer problemlos um weitere Filter erweitert werden.

class UserQuery(
  val addNotDeleted: Boolean = true,
  val nameContains: String? = null
)

Diese Klasse erhält dann noch eine Extension-Methode, die private beim Service definiert wird.

private fun UserQuery.toAndSpec() =
  and(
    if (addNotDeleted) User.Spec.isNotDeleted() else null,
    if (!nameContains.isNullOrEmpty()) User.Spec.nameContains(nameContains) else null
  )

Dort wird geprüft, welche Filter gesetzt wurden. Diese werden dann entsprechend (hier mit and) kombiniert. Der Service wird dann um eine entsprechende Methode ergänzt, welche einen Parameter vom Typ UserQuery entgegennimmt.

@Service
class UserService(
  private val userRepository: UserRepository
) {
  fun findUser(query: UserQuery) = userRepository.findOne(query.toAndSpec())
}

Der Aufruf erfolgt dann beispielsweise in API-Controllern mit den gewünschten Filtern:

userService.findUser(UserQuery(
  addNotDeleted = false,
  nameContains = "something"
))

Neue Filter sollten der data class als nullable mit Standardwert null hinzugefügt werden, damit sich bereits vorhandene Aufrufe nicht ändern. In der Extension-Methode werden alle nicht gesetzten Filter als null Specification kombiniert, die von and etc. ignoriert werden.

# Komplexe Kriterien

Manchmal muss man komplexere Dinge abfragen, für die es keine kurzen Hilfsmethoden gibt. Hierfür kann where von kotlin-jpa-specification-dsl verwendet werden.

Hier ist ein Beispiel einer Device Entity, welche eine @OneToMany Relation namens features zu Feature hat. Ein Device besteht also aus mehreren Features. Diese verschiedenen Features sind wiederum mittels @Inheritance als von Feature abgeleitete Entities implementiert. Nun soll eine Abfrage als Kriterium implementiert werden, um Devices zu selektieren, die mindestens ein Feature einer bestimmten Art besitzen. Hierzu kann folgende Methode im object Spec von Device hinzugefügt werden:

fun hasFeatureOfType(featureClass: Class<out Feature>) =
  where<Device> {
    equal(it.join(Device_.features).type(), featureClass)
  }

Die Funktion where wird von kotlin-jpa-specification-dsl bereitgestellt. Innerhalb davon ist this vom Typ CriteriaBuilder. Als Parameter (it) bekommt die Closure ein Root<Device> übergeben. Damit stehen alle Bausteine zur Verfügung, um direkt mit den JPA Criteria APIs zu arbeiten.

WARNING

Dieselbe Methode direkt mit JPA implementiert sieht so aus:

fun hasFeatureOfType(featureClass: Class<out Feature>) =
  Specification<Device> { root, query, criteriaBuilder ->
    criteriaBuilder.equal(
      root.join(Device_.features).type(),
      featureClass
    )
  }

# Funktionen

Dank der JPA Criteria API können auch Standard Datenbankfunktionen wie LOWER() usw. benutzt werden, ohne dialektspezifische SQLs zu schreiben. Innerhalb von where können diese direkt als lower() usw. aufgerufen werden. Im Manual von ObjectDB (opens new window) findet sich eine Liste der verfügbaren Funktionen.

TIP

like unterscheidet zwischen Groß- und Kleinschreibung. Möchte man dies nicht, kann man dies z. B. mit lower umgehen. Aber Achtung, das funktioniert nicht mit allen Zeichensätzen / Zeichen.

where {
  like(lower(it.get(User_.NAME)), lower(literal("%$namePart%")))
}