Spring Data und Queries – Elegantes Suchen und Filtern mit Spring und JPA

Da ich mich derzeit recht intensiv mit dem Thema Spring und JPA (und damit Gott sei Dank auch mit dem Thema Spring-Data) auseinandersetze, möchte ich hier für mich ein wenig dokumentieren, wie das doch nicht ganz einfach zu verstehende Konzept von Filtern und Suchen funktioniert.

Das Thema Spring-Data wird übrigens auch in meinem (im Entstehen begriffenen) Buch intensiv behandelt.

Das Projekt aufsetzen

Folgende pom deklariert alle notwendigen Dependencies inklusive Spring, Spring-Data, Hibernate und HsqlDB.

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation=
"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org</groupId>
<artifactId>rest</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<spring.version>3.1.0.RELEASE</spring.version>
<hibernate.version>3.5.6-Final</hibernate.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.0.3.RELEASE</version>
</dependency>

<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>

<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>${hibernate.version}</version>
</dependency>

<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>2.2.6</version>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.8.2</version>
</dependency>

</dependencies>

<build>
<finalName>rest</finalName>

<plugins>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>1.6</source>
<target>1.6</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>

</plugins>

</build>

</project>

Für die Spring-Konfiguration benötigen wir folgende Konfigurationsklasse.
Diese Klasse definiert den notwendige EntityManagerFactoryBean, den TransactionManager und den JpaVendorAdapter.
Leider brauchen wir für die Verwendung von spring-data immer noch eine Xml-Konfigurationsdatei (siehe hier)

@Configuration
@ImportResource(value = "classpath:de/effective/spring-contextt.xml")
@ComponentScan(basePackages = "de.effective")
public class ApplicationConfig {

@Bean
public LocalContainerEntityManagerFactoryBean emf(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(dataSource);
emf.setJpaDialect(new HibernateJpaDialect());
emf.setJpaVendorAdapter(jpaVendorAdapter());
emf.setPersistenceUnitManager(null);
emf.setPackagesToScan("de.effective");
return emf;
}

@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter jpaVendorAdapter = new HibernateJpaVendorAdapter();
jpaVendorAdapter.setShowSql(true);
jpaVendorAdapter.setDatabase(Database.HSQL);
jpaVendorAdapter.setGenerateDdl(true);
return jpaVendorAdapter;
}

@Bean
public JpaTransactionManager transactionManager(EntityManagerFactory emf){
return new JpaTransactionManager(emf);
}
}

Folgende XML-Konfigurationsdatei definiert eine embedded-hsql-db sowie das Bootstrapping für Spring-Data.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
xmlns:jpa="http://www.springframework.org/schema/data/jpa"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa-1.0.xsd">
<jdbc:embedded-database id="embeddedDS" type="HSQL"/>
<jpa:repositories base-package="de.effective"/>
</beans>

Über die folgende Zeile

<jpa:repositories base-package="de.effective"/>

werden alle Spring-Data Repositories automatisch als Spring-Beans gewired.

Damit wir die Funktionalität von Spring-Data verwenden, definieren wir eine einfache Entity (als Beispiel implementieren
wir den bereits 100-fach implementieren BookStore:).

@Entity
public class Book implements Serializable {

@Id
@Column(name="isbn")
private String isbn;
@Column(name="title")
private String title;
@Column(name="prize")
private double prize;

public Book(){}

public Book(String title, String isbn, double prize) {
this.title = title;
this.isbn = isbn;
this.prize = prize;
}

public String getTitle() {
return title;
}

public String getIsbn() {
return isbn;
}

public double getPrize() {
return prize;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

Book book = (Book) o;

if (Double.compare(book.prize, prize) != 0) return false;
if (isbn != null ? !isbn.equals(book.isbn) : book.isbn != null) return false;
if (title != null ? !title.equals(book.title) : book.title != null) return false;

return true;
}

@Override
public int hashCode() {
int result;
long temp;
result = title != null ? title.hashCode() : 0;
result = 31 * result + (isbn != null ? isbn.hashCode() : 0);
temp = prize != +0.0d ? Double.doubleToLongBits(prize) : 0L;
result = 31 * result + (int) (temp ^ (temp >>> 32));
return result;
}
}

Und natürlich ein passenden Spring-Data-Jpa-Repository:

public interface BookStoreRepository extends JpaRepository {
}

Zuletzt definieren wir einen Testdall, der die Spring-Konfiguration testet.

@ContextConfiguration(classes = {ApplicationConfig.class})
public class TestBootsTrap extends AbstractJUnit4SpringContextTests {

@Resource
private BookStoreRepository repository;

@Test
public void testBootstrap(){
assertNotNull(repository);
}
}

Damit haben wir ein voll funktionsfähigen Beispiel, um mit Spring-Data loszulegen.

Spring Data Einführung

Das Ziel des Spring-Data Projektes ist es, die Arbeit mit JPA möglichst stark zu vereinfachen und den kompletten Boiler-Plate-Code zu vermeiden.

Für die Definition eines Repositories mit Spring-Data reicht es völlig aus, folgendes Interface zu definieren:

public interface BookStoreRepository extends JpaRepository {
}

In der xml-Konfiguration hatten wir folgende Zeile definiert:

<jpa:repositories base-package="de.effective"/>

Alle Interfaces die eines der Spring-Data Interfaces erweitern (beispielsweise hier JpaRepository) werden automatisch
als Spring-Beans bereitgestellt und bieten u.a. bereits folgende Methoden, die automatisch funktionieren.

repository.delete(Book);
repository.delete(String bookId);
repository.findAll();
repository.save(Book);
repository.count();
repository.exists(String bookId);
repository.findOne(String bookId)

Damit lassen sich typische CRUD-Applikationen Out-of-the-Box umsetzen.

Testen wir das Ganze in unserem Testfall am beispiel von save / delete:

@Test
public void saveAndDeleteBook(){
assertTrue(repository.findAll().isEmpty());
Book book = new Book("Domain Driven Design","abcdef",39.95);
repository.save(book);
assertFalse(repository.findAll().isEmpty());
assertTrue(repository.exists(book.getIsbn()));
repository.delete(book);
assertTrue(repository.findAll().isEmpty());
}

Der Test ist direkt grün und funktioniert:).

Suchen mit Spring-Data und Queries

Oft möchte man auf spezielle Datensätze filtern.
Hierzu gibt es diverse Möglichkeiten, die einfachste (die aber meiner Ansicht nach eher unpraktikabel ist), ist die Definition von folgender Methode im Repository (wenn wir beispielsweise nach ISBN suchen möchten).

public Book findByIsbn(String isbn)

Es ist keine eigene Implementierung nötig, Spring Data erkennt bereits aus der Methodensignatur was gemacht werden soll.
Das Ganze testen wir in einem Testfall:

@Test
    public void findByIsbn(){
        Book book = new Book("Domain Driven Design", "abcdef", 39.95);
        repository.save(book);
        assertNull(repository.findByIsbn("unknown_isbn"));
        assertEquals(book, repository.findByIsbn(book.getIsbn()));
    }

Auch dieser Testfall ist direkt grün.

Diese Funktionalität hat übrigens zur Laufzeit kaum Auswirkung, da Spring-Data beim Aufbau des Spring-Kontextes NamedQueries aufbaut und somit zur Laufzeit alles schön schnell ist.

Es ist übrigens Vorsicht geboten, denn SPring-Data ist hier ein wenig extrik, denn es reagiert auf Camel-Case. Der folgende Fall zeigt zum Einen auf, dass Spring-Data die ganze Arbeit bereits beim Aufbau des Spring-Kontextes macht (auch das Parsen aller Methoden und Aufbau der Queries) als auch die Case-Sensitivität.

Hierzu benenne ich die Methode im Repository einfach um in findByIsBn(String isbn).


@Test
public void findByIsbn() {
if (true)
throw new RuntimeException("Wird nicht auftreten, da schon in der setup Methode Exceptions fliegen");
Book book = new Book("Domain Driven Design", "abcdef", 39.95);
repository.save(book);
assertNull(repository.findByIsBn("unknown_isbn"));
assertEquals(book, repository.findByIsBn(book.getIsbn()));
}

Schon in der 2. Zeile im Test würde eine Exception fliegen. Spring-Data bricht aber bereits früher ab, da bereits beim Aufbauen des Spring-Kontextes die entsprechende Exception fliegt.


Caused by: java.lang.IllegalArgumentException: No property is found for type class de.effective.Book

Da Case-Sensitivität für mich hier etwas seltsam anmutet, bevorzuge ich die manuelle Definition von Queries (das ist natürlich rein subjektiv).

Folgendes Beispiel ist vollkommen äquivalent:


@Query(value = "SELECT p from Book p where p.isbn=:isbn")
public Book manuallyFindByIsbn(@Param(value = "isbn") String isbn);

Mit @Query kann man über jede beliebige Methode einen JPQL-Ausdruck setzen, mit @Param kann mit auf Named-Parameters zugreifen. Leider kann Spring-Data das nicht automatisch machen, da dies schon von der JVM nicht unterstützt wird:).

Hier ist der äquivalente Testfall:


@Test
public void manuallyFindByIsbn() {
Book book = new Book("Domain Driven Design", "abcdef", 39.95);
repository.save(book);
assertNull(repository.manuallyFindByIsbn("unknown_isbn"));
assertEquals(book, repository.manuallyFindByIsbn(book.getIsbn()));
}

Und wie sollte es anders sein, auch dieser Test ist sofort grün.

Paging

Bisher habe ich es nicht gebraucht, aber das Feature ist nett, deswegen will ich es hier kurz erwähnen. Pagination lässt sich extrem einfach realisieren.


@Test
public void findInPages(){

for(int i = 20; i>0; i--){
repository.save(new Book("A Test Book",""+ i , i));
}
assertEquals(20, repository.findAll().size());

Page page = repository.findAll(new PageRequest(1, 3));
List books = page.getContent();
assertEquals(3, books.size());
}

Der Test sollte recht einfach zu lesen sein, wir speichern 20 Bücher, wollen aber davon nur jeweils 3 laden.

Specifications

Der eigentliche Titel dieses Artikels lautat ja nicht Spring-Data (hierzu gibt es ja auch weiß Gott schon genügend Tutorials), sondern elegantes SUchen und Filtern mit Spring-Data.

Was Spring Data bietet ist das Konzept der Specifications.

public interface Specification<T> {
	Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

Das Konzept der Specifications finde ich ehrlich gesagt nicht besonders intuitiv (ich persönlich mag aber auch die JPA Criteria API überhaupt nicht).

Wer sich die Criteria-API von JPA2 anschaut wird früher oder später (eher früher) über genau die 3 Typen stossen, die die Specification in ihrer toPredicate-Methode übergeben bekommt.

  • Root -Das Root ist quasi die Wurzel einer Query, analog der Defintion „Book b“ in der JPQL Abfrage „select from Book b“, die Identifikationsvariable.
  • CriteriaQuery – Entspricht der Query die aufgebaut wird
  • CriteriaBuilder -Wird verwendet, um eine Query selbst zu erzeugen, als auch die einzelnen Bestandteile der Query (where-Clause)

(Übrigens normalerweise bekommt man den CriteriaBuilder über den EntityManager mit der Methode „getCriteriaBuilder“ und die CriteriaQuery über den CriteriaBuilder und die Methode „newQuery“. Es ist nicht immer so komfortabel wie hier in Spring-Data:), die CriteriaQuery wird dann an die createQuery-Methode des EntityManagers übergeben, um diese auszuführen).

Als Beispiel hier mal ein Testcase, der die Verwendung mit der Criteria-API zeigt.


@Resource
private EntityManagerFactory em;

@Test
public void simpleCriteriaAPITest() {

Book ddd = new Book("Domain Driven Design", "1", 39.95);
//neu
Book tdd = new Book("Feature Driven Development", "2", 29.95);
//gebraucht mit anderer isbn:)
Book fdd = new Book("Feature Driven Development","3", 19.95);
repository.save(ddd);
repository.save(tdd);

EntityManager manager = em.createEntityManager();
CriteriaBuilder builder = manager.getCriteriaBuilder();
CriteriaQuery query = builder.createQuery(Book.class);
Root<Book> bookRoot = query.from(Book.class);
Predicate title = builder.equal(bookRoot.get("title"), "Feature Driven Development");

assertEquals(2, manager.createQuery(query).getResultList().size());

Predicate price = builder.greaterThan(bookRoot.get("prize").as(Double.class), 20d);

//override
query.where(title, price);
assertEquals(1, manager.createQuery(query).getResultList().size());

}

Kommen wir zurück zum Thema

Specifications und Spring-Data.

Spring-Data kapselt die Verwendung der Criteria API in sogenannte Specifications (das Konzept ist im Buch DDD von Eric Evans erklärt).

Um mit Specifications arbeiten zu können muss unser Interface zusätzlich das Interface SpecificaionExecutor implementieren.


public interface BookStoreRepository extends JpaRepository<Book, String>, JpaSpecificationExecutor<Book> {

public Book findByIsbn(String isbn);

@Query(value = "SELECT p from Book p where p.isbn=:isbn")
public Book manuallyFindByIsbn(@Param(value = "isbn") String isbn);
}

Hierdurch erhält unser Repository folgende neuen Methoden, mit denen wir arbeiten können.

T findOne(Specification<T> spec);
List<T> findAll(Specification<T> spec);
Page<T> findAll(Specification<T> spec, Pageable pageable);
List<T> findAll(Specification<T> spec, Sort sort);
long count(Specification<T> spec);

Eine Specification kapselt nochmals viel vom ganzen Boilerplate-Code der JPA Criteria-API weg, im Prinzip kann man sich vorstellen, dass die Specifiation eine Prüfung darstellt und für jedes Buch hier im Beispiel entweder TRUE (=gehört in den Ergebnisraum) oder FALSE (=gehört nicht in den Ergebnisraum) liefert.

Das Ergebnis einer Specification sind alle Entities, die TRUE liefern.

Im Folgenden sieht man als Beispiel eine IsbnSpecification, die alle Bücher mit einer bestimmten ISBN filtert.


public class IsbnSpecification implements Specification<Book> {

private String isbn;

public IsbnSpecification(String isbn){
this.isbn = isbn;
}

@Override
public Predicate toPredicate(Root<Book> bookRoot, CriteriaQuery<?> cq, CriteriaBuilder cb) {
return cb.equal(bookRoot.get("isbn"),isbn);
}
}

Und hier der Testcase dazu:


@Test
public void findByIsbnWithSpecification() {
Book ddd = new Book("Domain Driven Design", "abcdef", 39.95);
Book tdd = new Book("Test-Driven-Development", "abcdefg", 29.95);
repository.save(ddd);
repository.save(tdd);
IsbnSpecification specification = new IsbnSpecification(ddd.getIsbn());
List<Book> result = repository.findAll(specification);
assertEquals(1, result.size());
assertEquals(ddd, result.get(0));
}

Einige Codebeispiele

Anbei folgen noch einige Code-Beispiele, die ich einfach nützlich finde und eine gute Referenz sind (aber gar nicht unbedingt was mit Spring-Data zu tun haben müssen):

Join-Table

Definieren wir eine Kategorie für Bücher.

@Entity
@Table(name="CATEGORY")
public class Category implements Serializable {

    @Id
    @Column(name="CATEGORY")
    private String category;

    public Category(String category){
        this.category = category;
    }

    public Category(){}

    public String getCategory() {
        return category;
    }

    public void setCategory(String category) {
        this.category = category;
    }
}

Jedes Buch kann einer oder mehreren Kategorien zugeordnet sein.

@OneToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "BOOK_CATEGORY", joinColumns = @JoinColumn(name="ISBN"),
            inverseJoinColumns = @JoinColumn(name="CATEGORY"))
    private List<Category> categories;


    public Book(){}

    public Book(String title, String isbn, double prize) {
        this(title, isbn, prize, new ArrayList<Category>());
    }

    public Book(String title, String isbn, double prize, List<Category> categories){
        this.title = title;
        this.isbn = isbn;
        this.prize = prize;
        this.categories = categories;
    }

Das passende Mapping hierzu ist @JoinTable, da die Kategorien nichts von der Book-Entity wissen sollen.

Zusätzlich definieren wir uns ein CategoryRepository, um Kategorien speichern und laden zu können.

public interface CategoryRepository extends JpaRepository<Category, String> {
}

Der passende Testcase hierzu sieht so aus:

@Test
    public void saveWithSeveralCategories(){
        Category cat1 = new Category("Krimi");
        Category cat2 = new Category(("Sci-Fi"));

        categoryRepository.save(cat1);
        categoryRepository.save(cat2);

        Book book = new Book("Analysis Patterns","abc", 39.95, Arrays.asList(new Category[]{cat1, cat2}));
        repository.save(book);

        Book persistendBook = repository.findByIsbn("abc");
        assertEquals(2, persistendBook.getCategories().size());

    }

Was ich jetzt machen möchte ist, nach einem Buch mit einer bestimmten Kategorie zu suchen:
Nichts einfacher als das, und zwar im BookStoreRepository:

public List<Book> findByCategories(Category cat);

Und hier der passende Testcase dazu:

@Test
    public void findByCategory(){
        Category cat1 = new Category("Krimi");
        Category cat2 = new Category("Sci-Fi");
        Category cat3 = new Category("Romanze");

        categoryRepository.save(cat1);
        categoryRepository.save(cat2);
        categoryRepository.save(cat3);

        Book book = new Book("Analysis Patterns","abc", 39.95, Arrays.asList(new Category[]{cat1, cat2}));
        repository.save(book);

        List<Book> krimis = repository.findByCategories(new Category("Krimi"));
        assertEquals(1, krimis.size());

        List<Book> romanzen = repository.findByCategories(new Category("Romanze"));
        assertTrue(romanzen.isEmpty());

    }

Ok, wie aber würde das mit einer manuell generierten JPQL aussehen?

  @Query(value = "select p from Book p JOIN p.categories c where c=:cat ")
    public List<Book> manuallyFindByCategory(@Param(value = "cat")Category cat);

Und den Test erweitern wir einfach:

@Test
    public void findByCategory(){
        Category cat1 = new Category("Krimi");
        Category cat2 = new Category("Sci-Fi");
        Category cat3 = new Category("Romanze");

        categoryRepository.save(cat1);
        categoryRepository.save(cat2);
        categoryRepository.save(cat3);

        Book book = new Book("Analysis Patterns","abc", 39.95, Arrays.asList(new Category[]{cat1, cat2}));
        repository.save(book);
        List<Book> krimis = repository.findByCategories(new Category("Krimi"));
        assertEquals(1, krimis.size());

        List<Book> manualKrimis = repository.manuallyFindByCategory(new Category("Krimi")) ;
        assertEquals(1, manualKrimis.size());
      
        List<Book> romanzen = repository.findByCategories(new Category("Romanze"));
        assertTrue(romanzen.isEmpty());

        List<Book> manualRomanzen = repository.manuallyFindByCategory(new Category("Romanze"));
        assertTrue(manualRomanzen.isEmpty());

    }

Das generierte SQL ist identisch:

Hibernate: select book0_.isbn as isbn0_, book0_.prize as prize0_, book0_.title as title0_ from Book book0_ inner join BOOK_CATEGORY categories1_ on book0_.isbn=categories1_.ISBN inner join CATEGORY category2_ on categories1_.CATEGORY=category2_.CATEGORY where category2_.CATEGORY=?

und

Hibernate: select book0_.isbn as isbn0_, book0_.prize as prize0_, book0_.title as title0_ from Book book0_ inner join BOOK_CATEGORY categories1_ on book0_.isbn=categories1_.ISBN inner join CATEGORY category2_ on categories1_.CATEGORY=category2_.CATEGORY where category2_.CATEGORY=?

Der Sourcecode ist natürlich auf Github gehostet und zwar hier.

Sobald ich Zeit finde, werde ich dieses kleine Beispiel erweitern.

Anbei noch einige Links, die das Thema ebenfalls behandeln:

Spring-Data Tutorial zum Thema Queries

Referenz-Dokumentation zu Spring-Data

Spring Data API

Specifications in Spring Data

Spring-Data Java Konfiguration

Criteria-API Turorial


War dieser Blogeintrag für Sie interessant? Evtl. kann ich noch mehr für Sie tun.

Trainings & Know-How aus der Praxis zu

  • Apache Wicket 1.4.x, 1.5.x, 1.6.x
  • GIT – Best Practices, Einsatz, Methoden
  • Spring
  • Java
  • Scrum & Kanban
  • Agiles Arbeiten
Consulting & Softwareentwicklung

  • Requirements Engineering
  • Qualitätssicherung
  • Software-Entwicklung
  • Architektur
  • Scrum & Kanban
Advertisements

2 Gedanken zu „Spring Data und Queries – Elegantes Suchen und Filtern mit Spring und JPA

    1. madi Autor

      Hallo Christoph,

      sorry für die späte Antwort.
      Das Problem ist bekannt:).

      Wenn ich dazu komme reiche ich ein Beispiel nach. Aber wahrscheinlich habt ihr das Problem mittlerweile schon gelöst!

      Antwort

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s