Mit Einführung des Spring Framework 6.1 das Teil von Spring Boot 3.2 ist wurde eine neue Klasse JdbcClient
eingeführt, welche ein Wrapper um die vorhandene Klasse JdbcTemplate
ist um Datenbankoperationen mit einer Fluent-API durchzuführen.
Mit Veröffentlichung von Spring Boot 3.2 steht uns diese Klasse zu Verfügung. Wir wollen einen Blick darauf werfen wie wir damit unterschiedliche Datenbankoperationen ausführen.
Zunächst erstellen wir eine neue Applikation unter https://start.spring.io. Dabei wählen wir Spring JDBC, Postgresql Driver, Flyway Migration und Testcontainers aus. Wir müssen zum Test kein PostgreSQL installieren. Durch die Verwendung von Testcontainer wird PostgreSQL automatisch beim Test in einem separaten Container gestartet.
Starten wir mit einer Record-Klasse die Kontakte enthält
package de.saphirgmbh.jdbcexample;
import java.time.Instant;
public record Kontakte(Long id, String bezeichnung, String email, String telefon, Instant erstelltAm) { }
Damit auch eine Datenbanktabelle angelegt wird verwenden wir Flyway, um diese anzulegen
Dazu erstellen wir die Skriptdatei im Ordner src/main/resources/db/migration
create table kontakte
(
id bigserial primary key,
bezeichnung varchar not null,
email varchar,
telefon varchar,
erstellt_am timestamp
);
Nun können wir mit der Implementierung der CRUD-Operationen mithilfe der Klasse JdbcClient
beginnen.
@Repository
@Transactional(readOnly = true)
public class KontaktRepository {
private final JdbcClient jdbcClient;
public KontaktRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
...
...
Alle Kontakte können mit dem 'JdbcClient' folgendermaßen gelesen werden:
private final static String SELECT_ALL = """
SELECT id,
bezeichnung,
email,
telefon,
erstellt_am
FROM kontakte
""";
public List<Kontakt> findAll() {
return jdbcClient.sql(SELECT_ALL).query(Kontakt.class).list();
}
Die Klasse JdbcClient
erzeugt in diesem Fall dynamisch einen RowMapper
vom Typ SimplePropertyTypeRowMapper
. Dieser erledigt das Mapping der Datenbankspalten in die Attribute der Java Klasse. Dabei wird camelCase in Unterstrich umgewandelt.
Sollte das auf Grund eines vorhandenen Schemas nicht passen kann ein eigener Mapper implementiert werden
public List<Kontakt> findAll() {
return jdbcClient.sql(SELECT_ALL).query(new KontaktRowMapper()).list();
}
static class KontaktRowMapper implements RowMapper<Kontakt> {
@Override
public Kontakt mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Kontakt(rs.getLong("id")
, rs.getString("bezeichnung")
, rs.getString("email")
, rs.getString("telefon")
, rs.getTimestamp("erstellt_am").toInstant());
}
}
Ein Kontakt kann auch über die ID gelesen werden
public Optional<Kontakt> findById(Long id) {
return jdbcClient.sql(SELECT_ALL + " where id = :id")
.param("id",id)
.query(Kontakt.class)
.optional();
}
Da wir PostgreSQL verwenden können wir die INSERT INTO ... RETURNING COL1,COL2 Syntax verwenden und mit Hilfe eines `KeyHolder' den generierten Schlüssel nach dem Insert zurückzugeben.
@Transactional
public Long save(Kontakt kontakt) {
String insertSQL = """
INSERT INTO kontakte(bezeichnung,email,telefon,erstellt_am)
VALUES (:bezeichnung, :email, :telefon, :erstelltAm)
RETURNING id
""";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcClient.sql(insertSQL)
.param("bezeichnung", kontakt.bezeichnung())
.param("email", kontakt.email())
.param("telefon", kontakt.telefon())
.param("erstelltAm", Timestamp.from(kontakt.erstelltAm()))
.update(keyHolder);
return keyHolder.getKeyAs(Long.class);
}
Ein Update könnte folgendermaßen aussehen
@Transactional
public void update(Kontakt kontakt) {
String updateSQL = """
UPDATE kontakte set bezeichnung=?
,email=?
,telefon=?
WHERE id = ?
""";
final int anzahl = jdbcClient.sql(updateSQL)
.param(1, kontakt.bezeichnung())
.param(2, kontakt.email())
.param(3, kontakt.telefon())
.param(4, kontakt.id())
.update();
if(anzahl == 0) {
throw new RuntimeException(String.format("Kontaktdateneintrag mit ID %d nicht gefunden!", kontakt.id()));
}
}
In der update(..)-Methode verwenden wir den Positionsparameter (?) anstatt die Parameter zu benennen. Dies dient nur der Demonstration dieses Features. Mit dem JdbcClient
sind also beide Varianten möglich.
Löschen ist auch nicht schwierig
@Transactional
public void delete(Long id) {
String deleteSQL = """
DELETE FROM kontakte where id = ?
""";
final int anzahl = jdbcClient.sql(deleteSQL).param(1, id).update();
if(anzahl == 0) {
throw new RuntimeException(String.format("Kontaktdateneintrag mit ID %d nicht gefunden!",id));
}
}
Für die Tests sorgen wir dafür das die Datenbank sich immer in einem sauberen bekannten Zustand befindet. Dafür erstellen wir eine Datei src/test/resources/test-data.sql mit dem folgenden Inhalt
TRUNCATE TABLE kontakte;
ALTER SEQUENCE kontakte_id_seq RESTART WITH 1;
INSERT INTO kontakte (bezeichnung, email, telefon, erstellt_am) values('Wolfgang', 'wolfgang.klaus@muellmail.com', '4711-424242', CURRENT_TIMESTAMP);
Diese Datei fügen wir machen wir mit der Annotation @Sql("/test-data.sql") der Testklasse bekannt. Dieses wird nun vor jedem Test ausgeführt.
package de.saphirgmbh.jdbcexample.domaene;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.jdbc.Sql;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@JdbcTest(properties = {
"spring.test.database.replace=none",
"spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db"
})
@Sql("/test-data.sql")
public class KontaktRepositoryTest {
@Autowired
private JdbcClient jdbcClient;
KontaktRepository kontaktRepository;
@BeforeEach
public void setUp() {
kontaktRepository = new KontaktRepository(jdbcClient);
}
@Test
public void sucheAlleKontaktdaten() {
final List<Kontakt> kontakte = kontaktRepository.findAll();
Assertions.assertThat(kontakte).isNotEmpty();
Assertions.assertThat(kontakte).hasSize(1);
}
@Test
public void erstelleKontakt() {
Kontakt kontakt = new Kontakt(null, "wkl", "wkl@muellmail.com", "4242", Instant.now());
final Long id = kontaktRepository.save(kontakt);
assertThat(id).isNotNull();
}
@Test
public void ermittleKontaktMitId() {
Kontakt kontakt = new Kontakt(null, "wkl2", "wkl2@muellmail.com", "4242", Instant.now());
Long id = kontaktRepository.save(kontakt);
final Optional<Kontakt> kontaktById = kontaktRepository.findById(id);
assertThat(kontaktById).isPresent();
assertThat(kontaktById.get().id()).isEqualTo(id);
assertThat(kontaktById.get().bezeichnung()).isEqualTo("wkl2");
}
@Test
public void keineDatenWennNichtVorhanden() {
final Optional<Kontakt> id = kontaktRepository.findById(-9999L);
assertThat(id).isNotPresent();
}
@Test
public void aendereKontakt() {
Kontakt kontakt = new Kontakt(null, "wkl3", "wkl3@muellmail.com", "4242", Instant.now());
final Long id = kontaktRepository.save(kontakt);
Kontakt geandert= new Kontakt(id, "wkl3", "wkl3@muellmail.com", "4343", kontakt.erstelltAm());
kontaktRepository.update(geandert);
final Kontakt geanderterKontakt = kontaktRepository.findById(id).orElseThrow();
assertThat(geanderterKontakt.telefon()).isEqualTo(geandert.telefon());
}
@Test
public void loescheKontakt() {
Kontakt kontakt = new Kontakt(null, "wkl4", "wkl4@muellmail.com", "4444", Instant.now());
final Long id = kontaktRepository.save(kontakt);
kontaktRepository.delete(id);
final Optional<Kontakt> nichtVorhanden = kontaktRepository.findById(id);
assertThat(nichtVorhanden).isNotPresent();
}
}
Wir benutzen als JDBC-URL eine spezielle URL um eine PostgreSQL-Datenbank in einem Container zu starten und die Tests gegen diese DB auszuführen.
Die neue Klasse JdbcClient
bietet eine schöne Fluent-API um Datenbankzugriffe mit JDBC zu implementieren. Die Benutzung des allseits bekannten JdbcTemplate
ist auch weiterhin möglich. Ein Blick auf den neuen JdbcClient
lohnt dennoch um sauberen leserelichen Code zu erhalten.