# Repositories

Repositories in Spring sind die Schnittstelle zwischen den Entities im Code und den Daten in der Datenbank. Über sie laufen die Datenbank-Operationen wie Speichern, Laden, Suchen, Löschen. Ein Repository wird intern als ein Bean registriert und kann somit überall angefordert werden. Für jeden Entity-Typ gibt es ein eigenes Repository.

Das Besondere an Spring Data Repositories ist, dass man selbst keine handgeschriebene SQL zusammenbasteln muss, sondern Spring anhand von Methodennamen automatisch im Hintergrund die passende SQL erzeugt. Magic!

Man definiert ein Repository, indem man ein Interface anlegt, dass von org.springframework.data.repository.Repository abgeleitet ist. Repository ist ebenfalls ein Interface, enthält aber keine Methoden – es dient lediglich zur Markierung der Repositories, damit Spring diese finden und passend verdrahten kann. Es müssen zwei Typ-Parameter (<T, ID>) festgelegt werden: Typ des Entities (T) und Typ der Property, welche die ID enthält (ID).

Da Repository an sich keine Methoden enthält, müsste man Sachen wie "Entity speichern", "Entity anhand ID laden" und "Entity löschen" erst selbst deklarieren. Praktischerweise liefert Spring schon ein paar vorkonfigurierte Repositories mit. Eines davon ist das JpaRepository. Es ist von anderen Repository-Interfaces abgeleitet und vereint daher viele Features:

  • JpaRepository: JPA spezifische Sachen wie flush() und deleteInBatch(entities: Iterable<T>) sowie getOne(id: ID): T
    • abgeleitet von PagingAndSortingRepository: unterstützt Sortierung und Paginierung, z. B. via findAll(sort: Sort): Iterable<T> und findAll(pageable: Pageable): Page<T>
      • abgeleitet von CrudRepository: liefert Standardmethoden wie save(entity: T), findById(id: ID): Optional<T>, count(): Long und delete(entity: T)
    • abgeleitet von QueryByExampleExecutor: für Query by Example (QBE) Unterstützung, z. B. via findOne(example: Example<T>): Optional<T>

Im einfachsten Fall sieht ein Repository also so aus (es müssen keine eigenen Methoden definiert werden):

interface UserRepo : JpaRepository<User, Long>

Dieses Repository kann dann einfach überall als Bean angefragt werden:

@Service
class UserService(
  private val userRepo: UserRepo
)
Die weiteren Beispiele beziehen sich auf dieses simple Datenmodell:
@Entity
class User(
  var firstName: String?,
  var lastName: String?
) {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long = 0
}

# Speichern

Um ein einzelnes Entity zu speichern (egal ob Neuanlegen oder Update) wird save(Entity) benutzt. Mehrere Entities auf einmal können mit save(Iterable<Entity>) gespeichert werden:

userRepo.save(user)
userRepo.saveAll(listOf(user1, user2))

WARNING

Zu beachten ist, dass die alte(n) Instanz(en), die man save(All) übergibt, danach nicht mehr verwendet werden dürfen. Das hat den Hintergrund, dass bestimmte Properties aktualisiert oder sogar eine komplett neue Instanz des Entities erzeugt wird.

Möchte man also beispielsweise die ID eines neu erzeugten Entities ausgeben, geht das so:

val newUser = User("my", "user")
val savedUser = userRepo.save(newUser)
logger.info { "saved user with id ${savedUser.id}" }

# Löschen

Ein einzelnes Entity kann man mit deleteById(ID) löschen. Um alle Entities in der Datenbanktabelle zu löschen, kann deleteAll() benutzt werden.

WARNING

Es gibt auch Methoden, die nicht die ID, sondern direkt eines oder mehrere Entities entgegennehmen, wie z. B. delete(Entity) und deleteAll(Iterable<Entity>). Diese Methoden sollten aber vermieden und stattdessen deleteById(ID) benutzt werden.

Das Problem stellt sich dar, wenn man eine nicht mehr aktuelle Entity-Instanz übergibt. Dort kennt JPA / Hibernate evtl. noch nicht alle verknüpften Datensätze und lösche diese demzufolge auch nicht (wenn cascade entsprechend angegeben ist). Das führt dann zu ConstraintViolationExceptions.

# Existenz, Zählen

Um nur zu prüfen, ob es eine Entity mit einer bestimmten ID in der Datenbank gibt, wird existsById(ID) aufgerufen. Die Anzahl der Entities in der Datenbank ermittelt count().

# Suchen

findById(ID) und findByIdOrNull(ID) versuchen ein Entity mit der angegebenen ID zu laden. Gibt es dieses nicht in der Datenbank, liefert findByIdOrNull null zurück. findById gibt immer ein Java 8 Optional zurück, welches dann entsprechend mit isPresent(), get(), orElse(Entity) etc. abgefragt werden muss.

Um alle Entities ohne Filterung zu laden, kann findAll() verwendet werden.

# Sortieren

Manche Methoden nehmen einen Parameter vom Typ Sort entgegen. Dort kann dann die gewünschte Sortierung der Ergebnisse angegeben werden, auch über mehrere Properties / Spalten hinweg. Es muss der Name des Properties, nicht der Tabellenspalte (falls diese mit @Column anders benannt wurde) angegeben werden. Ein Beispiel ist findAll(Sort):

val users = userRepo.findAll(Sort.by("lastName", "firstName"))

Hier würden die Ergebnisse erst nach lastName aufsteigend und dann nach firstName aufsteigend sortiert werden. Dasselbe, nur beides absteigend sieht so aus:

val users = userRepo.findAll(Sort.by(Sort.Direction.DESC, "lastName", "firstName"))

Natürlich können auch unterschiedliche Richtungen für die Properties angegeben werden. Dabei gibt es mehrere Möglichkeiten.

val users = userRepo.findAll(Sort.by(
  Sort.Order.asc("lastName"),
  Sort.Order(Sort.Direction.DESC, "firstName"))
)

WARNING

Es gibt zwar noch die Möglichkeit ein NullHandling anzugeben (also wo sich NULLs einsortieren sollen), das muss aber von allen beteiligten Komponenten (Store, Treiber, Datenbank) unterstützt werden, was bei den meisten nicht der Fall ist. Daher sollte man sich nicht auf NullHandling verlassen.

# Paginierung

Listenansichten sind normalerweise in ihrer Länge begrenzt und können sich so über mehrere Seiten erstrecken. Um eine passende Navigation (vor, zurück, zu bestimmter Seite) zu erstellen, werden Informationen über die aktuelle Seite, benachbarte Seiten und Gesamtanzahl der Ergebnisse benötigt.

Repository-Methoden können einen Parameter vom Typ Pageable entgegennehmen, mit dem die gewünschte Ergebnisanzahl und Seitennummer festgelegt werden. Ein Pageable wird mit PageRequest.of erzeugt. Im folgenden Beispiel wird mit dem Parameter page (zero-based!) nach der sechsten Seite gefragt, wobei eine Seite maximal 3 Elemente (size) enthalten soll:

val page = userRepo.findAll(PageRequest.of(5/*page*/, 3/*size*/))

Eine Sortierung kann zusätzlich übergeben werden:

val page = userRepo.findAll(PageRequest.of(5, 3, Sort.Direction.DESC, "lastName"))
val page = userRepo.findAll(PageRequest.of(5, 3, Sort.by(/* ... siehe oben ... */)))

Der Rückgabewert (hier page) ist vom Typ Page. In diesem Objekt stehen diverse Properties zur Verfügung:

  • number: die aktuelle Seite (0-based), entspricht page beim übergebenen PageRequest
  • size: die maximale Länge einer Seite, entspricht size beim übergebenen PageRequest
  • totalPages: wie viele Seiten es insgesamt gibt
  • numberOfElements: wie viele Ergebnisse es auf der aktuellen Seite gibt
  • totalElements: wie viele Ergebnisse es insgesamt gibt, über alle Seiten

Auch Methoden gibt es einige nützliche:

  • isEmpty(): sind keine Ergebnisse enthalten?
  • isFirst(): ist dies die erste Seite?
  • isLast(): ist dies die letzte Seite?
  • hasPrevious(): gibt es eine vorhergehende Seite?
  • hasNext(): gibt es eine nachfolgende Seite?

Es gibt auch Hilfsmethoden, um Pageable-Parameter für die vorherige oder nächste Seite zu erstellen:

  • previousOrFirstPageable(): Pageable für die vorherige Seite, oder die erste, falls man schon bei der ersten ist
  • nextOrLastPageable(): Pageable für die nächste Seite, oder die letzte, falls man schon bei der letzten ist

Über Page kommt man auch an die originalen Pageable (page.pageable) und Sort (page.sort, page.pageable.sort) Parameter.

Die Ergebnisse der aktuellen Seite enthält die Page natürlich auch:

  • content: gibt eine List der Entities zurück
  • stream(): gibt ein Stream der Entities zurück

# Eigene Abfragen

Hier kommt die Magie ins Spiel. Spring Data erzeugt automatisch passende SQL-Statements anhand von Methodennamen. So lassen sich auf leichteste Art einfache Abfragen mit wenigen Worten beschreiben.

Im Methodenname kann man verschiedene Dinge formulieren, die Operation (Suchen, Zählen, Löschen, Existenz), Anzahl Ergebnisse, Sortierung und zu filternde Properties. Dies muss in einem bestimmten Schema erfolgen. Die Dokumentation (opens new window) enthält alle Details, hier ist das wichtigste erklärt. Ein Beispiel mit ein paar Möglichkeiten zum Überblick:

interface UserRepo : JpaRepository<User, Long> {
  fun findAllByFirstNameContains(firstName: String): Collection<User>
  fun findFirst2ByFirstNameOrderByIdDesc(firstName: String): Collection<User>
  fun countByLastNameEndingWith(lastName: String): Long
  fun deleteAllByIdBetween(from: Long, to: Long)
  fun existsByLastNameAndFirstNameIsNotLike(last: String, first: String): Boolean
}

Der Methodennamen folgt einer gewissen Struktur. Nicht alle Teile sind immer zwingend anzugeben, wie man oben schon an den vorgefertigten Methoden wie findAll() und existsById(ID) gesehen hat.

  • Operation: Soll gelesen (find), gezählt (count), gelöscht (delete) oder geprüft (exists) werden? Eine Liste der möglichen Keywords findet man in der Doku (opens new window).
  • Limitierung: Nur die ersten <number> Ergebnisse zurückgeben (First<number>), oder auch All.
  • Filter: Diese sind in der Form <field><predicate> und können sogar mit And / Or kombiniert werden. Die Liste der möglichen Keywords steht in der Doku (opens new window). Manche Abfragen werden allerdings nicht von allen Datenbanken unterstützt.
  • Sortierung: Falls die Ergebnisse speziell sortiert werden sollen, kann OrderBy<field><direction> verwendet werden.

Die spezifizierten Filter werden einfach als Argumente in der passenden Reihenfolge übergeben. Der Argumentname ist egal. Nach diesen Argumenten können noch spezielle Argumente mit bestimmten Typen folgen:

  • Sort: Hiermit kann der Aufrufer die Sortierung selbst festlegen (s. o.), beispielsweise findByLastName(lastName: String, sort: Sort)
  • Pageable: Hiermit kann der Aufrufer eine Paginierung anfordern (inklusive eventueller Sortierung). Damit Meta-Informationen wie Seitenanzahl etc. zurückgegeben werden, muss der Rückgabetyp Page<Entity> sein, also z. B. findByLastName(lastName: String, pageable: Pageable): Page<User>

Es können sogar verschachtelte Felder (von Embeddables oder verknüpften Entities) gefiltert werden. Alle Details zur Benennung und was sonst noch zu beachten ist, lassen sich genau in der Doku (opens new window) nachlesen. Beispielweise muss bei einem Rückgabetyp von Stream<Entity> darauf geachtet werden, dass die aufrufende Methode mit @Transaction markiert ist, damit der Stream korrekt wieder aufgeräumt werden kann.

Ebenfalls entscheidend ist der Rückgabewert der Methode. Dieser entscheidet, ob z. B. nur ein Ergebnis (Entity) oder mehrere Ergebnisse (Collection<Entity>) zurückgegeben werden. Außerdem sind simple Typen wie Long bei count und Boolean bei exists möglich. Es werden noch diverse andere Typen unterstützt, sieh dazu die Doku (opens new window).

TIP

Ist der Rückgabetyp Page<Entity> muss ein zusätzliches Query ausgeführt werden, um die Gesamtzahl der Ergebnisse herauszufinden:

fun findAllByLastName(lastName: String, pageable: Pageable): Page<User>
select user0_.`id` as id1_0_, ... from `user` user0_ where user0_.`last_name`=? limit ?

select count(user0_.`id`) as col_0_0_ from `user` user0_ where user0_.`last_name`=?

Das kostet natürlich etwas Performance. Falls also die Information, wie viele Ergebnisse es insgesamt gibt, nicht benötigt wird, kann als Rückgabetyp auch einfach List<Entity> angegeben werden, dann wird das zweite Query nicht ausgeführt.

fun findAllByLastName(lastName: String, pageable: Pageable): List<User>

# @Query

Bei vielen zu filternden Feldern, vielen Optionen oder langen Feldnamen kann ein Methodenname schnell unübersichtlich werden. Daher können auch selbst Queries geschrieben werden, der Methodenname wird dann nicht durch Spring automatisch ausgewertet (zumindest die Operation am Anfang des Namens kann man aber einheitlich benannt lassen). Dazu muss die Methode im Repository mit einer @Query Annotation ausgestattet sein, in der man das Query festlegt.

@Query("SELECT u FROM User u WHERE u.lastName = :myFilter")
fun findRelevantUsers(myFilter: String): Collection<User>

Das Query wird dabei in der JPQL (Jakarta Persistence Query Language) Syntax angegeben, welche anderen SQL-Dialekten sehr ähnlich ist. Details zur Syntax findet man in der Spezifikation (opens new window). Bei ObjectDB gibt es auch ein übersichtliches Tutorial (opens new window).

Es können SELECT, UPDATE und DELETE Queries geschrieben werden. Als "Tabelle" bei FROM wird die Entity angegeben (Groß-/Kleinschreibung wie bei der class). Funktionen wie JOIN, GROUP BY und ORDER BY lassen sich auch angeben.

Im Beispiel wurde ein benannter Parameter :myFilter in der JPQL benutzt. Dies sind Platzhalter, die mit dem entsprechenden Methodenparameter befüllt werden – dabei werden Steuerzeichen automatisch kodiert, um SQL-Injection-Attacken abzuwehren (wie bei java.sql.PreparedStatement).

WARNING

Nebst benannten Parametern gibt es noch indizierte Parameter. ?1 entspricht dabei dem ersten Methodenparameter etc.

@Query("SELECT u FROM User u WHERE u.lastName = ?1")
fun findRelevantUsers(myFilter: String): Collection<User>

Von dieser Syntax rate ich aber ab, da es z. B. beim Umordnen der Methodenparameter zu Fehlern kommen kann, sodass die Indizes nicht mehr stimmen. Außerdem müsste man sonst immer erst schauen, welches der gemeinte Parameter ist, das wird bei ein paar mehr Parametern lästig.

# Projections

Manchmal reicht es, anstatt aller Felder einer Entity nur einen Teil davon aus der Datenbank auszulesen. Das spart Ressourcen und steigert die Performance. Für diesen Fall gibt es Projections. Man kann diese sowohl bei den Methoden verwenden, die von Spring automatisch anhand des Methodennamens die Abfrage erzeugen, als auch bei selbst geschriebenen JPQLs via @Query.

Projections können sowohl als interface als auch als class definiert werden.

interface FirstNameOnlyAsInterface {
  val firstName: String?
}

class FirstNameOnlyAsClass(
  val firstName: String?
)

Im Falle der Methoden ohne @Query kann man die Projections einfach im Rückgabetyp verwenden.

// interface Variante
fun findByLastName(lastName: String): Collection<FirstNameOnlyAsInterface>

// class Variante
fun findByLastName(lastName: String): Collection<FirstNameOnlyAsClass>

Bei Methoden, die eine selbstgeschriebene JPQL mittels @Query enthalten, wird es etwas expliziter, da man im SELECT die Felder der Projection manuell auflisten muss. Allerdings hat man dadurch auch die Möglichkeit, Properties in der Projection anders zu benennen, als in der Entity.

// interface Variante
@Query("SELECT u.firstName AS firstName " +
  "FROM User u WHERE u.lastName = :lastName")
fun findByLastName(lastName: String): Collection<FirstNameOnlyAsInterface>

// class Variante
@Query("SELECT NEW com.example.demo.FirstNameOnlyAsClass(u.firstName) " +
  "FROM User u WHERE u.lastName = :lastName")
fun findByLastName(lastName: String): Collection<FirstNameOnlyAsClass>

TIP

Ich empfehle die interface Variante, weil man den vollqualifizierten Klassenname für SELECT NEW spart. Außerdem können so nicht aus Versehen selbst Instanzen der Projection erzeugt werden.

Projections können übrigens als Propertytypen auch andere Projections, Embeddables oder Entities enthalten.

# Dynamic Projections

Es ist möglich die Projection nicht hart im Rückgabewert zu codieren, sondern diese vom Aufrufer übergeben zu lassen. Somit kann dieser entscheiden, welche Daten geholt werden sollen.

// Repository
fun <T> findByLastName(lastName: String, type: Class<T>): Collection<T>

@Query("SELECT u.firstName AS firstName " +
  "FROM User u WHERE u.lastName = :lastName")
fun <T> findByLastName(lastName: String, type: Class<T>): Collection<T>

// Aufruf
val result = userRepo.findByLastName("example", FirstNameOnlyAsInterface::class.java)

Bei Verwendung von @Query muss natürlich darauf geachtet werden, dass die passenden Felder für die möglichen Projections zurückgegeben werden.

# Architektur

Es ist vielleicht verlockend z. B. direkt in einem @RestController ein Repository zu benutzen. Sobald das Projekt etwas größer wird, sollte allerdings ein @Service dazwischen geschaltet werden:

API --> Service --> Repository

Der Service stellt dann alle Methoden bereit, die nötig sind, um mit dem entsprechenden Entity zu arbeiten. Das beinhaltet auch Business-Logik, bei Geldkonten beispielsweise der Transfer von einem Konto auf das andere (diese Logik hat in der API-Schicht nichts verloren).

Dies erhöht die Modularität und somit sind nachträgliche Änderungen leichter umzusetzen. Beispielsweise kann dann eine Validierung beim Neuanlegen an einer zentralen Stelle angepasst werden, da ja vielleicht nicht nur die REST-API User anlegt, sondern auch andere Komponenten.