Java Stream API で JPA - JPAstreamer の使い方


JPAstreamer とは

Speedment 社が提供している ORM ツールキットに speedment があります。 speedment は、データベース操作を Java Stream API を介して行える ORM ツールキットです。

speedment.com

speedment の ORM 部分に JPA を使うようにしたものが JPAstreamer です。 Java Stream API を介したデータベース操作を JPA 上で実現するライブラリになります。

jpastreamer.org


JPAstreamer でのクエリ操作は以下のようになります。

jpaStreamer.stream(Film.class)
    .filter(Film$.rating.equal("G"))
    .sorted(Film$.length.reversed().thenComparing(Film$.title.comparator())) 
    .skip(10)
    .limit(5)
    .forEach(System.out::println);

アノテーションプロセッサで Entity に応じたメタモデルを生成。 メタモデルを元に Stream API から JPQL を生成して JPA 上で実行 という流れになります。


アプローチとしては Querydsl と同じ感じですが、Querydsl は Java Stream 風な API を提供するのに対して、JPAstreamer では Java Stream API そのものを使ってクエリ操作が可能です。

Java Stream API そのものを使うため、例えば(現実世界では割と必要になることが多い)サブクエリが使えなかったり、機能面では Querydsl に一日の長があります。

JPAstreamer のバックエンドには JPA が居るため、複雑なクエリは JPA を直接使えば良いという割り切りさえできれば、JPAstreamer は選択肢の一つになるでしょう。 ただし、現時点で JPAstreamer は、名前空間 jakarta.persistence には未対応であり、旧 javax.persistence の JPAでしか利用できないので、注意が必要です。 Issue としては Add Support for JPA 3として挙げられています。


同様のライブラリとしては jpa-fluent-query などもあります。まぁ、こちらは開発途中なのですが。


JPAstreamer の始め方

JPAstreamer を利用するには、アノテーションプロセッサも含めて、以下の依存が必要です。

dependencies {
    implementation("com.speedment.jpastreamer:jpastreamer-core:1.1.3")
    annotationProcessor("com.speedment.jpastreamer:fieldgenerator-standard:1.1.3")
}

バックエンドに Hibernate、 データベースに H2 を使う場合は以下のようになるでしょう。

dependencies {

    implementation("com.speedment.jpastreamer:jpastreamer-core:1.1.3")
    annotationProcessor("com.speedment.jpastreamer:fieldgenerator-standard:1.1.3")

    compileOnly("javax.persistence:javax.persistence-api:2.2")
    runtimeOnly("org.hibernate:hibernate-core:5.6.15.Final")
    runtimeOnly("org.glassfish.jaxb:jaxb-runtime:2.3.8")
    runtimeOnly("com.h2database:h2:2.1.214")

}

JPA を直接利用する場合には annotationProcessor("org.hibernate:hibernate-jpamodelgen:5.6.15.Final") も追加しておくと良いでしょう。

あとは、JPA を利用できるようにするだけです。


JPA の利用準備

サンプルとして BookAuthor エンティティを作成します。

package example;
import javax.persistence.*;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String isbn;

    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private Author author;

    protected Book() {}

    public Book(String name, String isbn, Author author) {
        this.name = name;
        this.isbn = isbn;
        this.author = author;
    }

    // getter ...
}
package example;
import javax.persistence.*;

@Entity
public class Author {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String fullName;

    protected Author() {}

    public Author(String fullName) {
        this.fullName = fullName;
    }
    // getter ...
}

resources/META-INF/persistence.xml を作成します。

<persistence version="2.2"
             xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
             http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">

    <persistence-unit name="testUnit">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>example.Book</class>
        <class>example.Author</class>

        <properties>
            <property name="javax.persistence.schema-generation.database.action" value="create"/>
            <property name="javax.persistence.schema-generation.create-source" value="metadata"/>
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"/>

            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
            <property name="hibernate.show_sql" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

ビルドすれば JPAstreamer 用のメタモデルとして Book$Author$ が生成されます。

Book$ を例に見てみると、以下のようになっています。

public final class Book$ {
    
    public static final StringField<Book> name = StringField.create(
        Book.class, "name", Book::getName, false);

    public static final ReferenceField<Book, Author> author = ReferenceField.create(
        Book.class, "author", Book::getAuthor, false);

    public static final ComparableField<Book, Long> id = ComparableField.create(
        Book.class, "id", Book::getId, false);

    public static final StringField<Book> isbn = StringField.create(
        Book.class, "isbn", Book::getIsbn, false);
}

このメタモデルを .filter(Book$.name.equal("...")) のように使うことで、Java Stream API 上での条件指定を行います。


クエリの実行

事前にクエリするデータを登録しておきます。

EntityManagerFactory emf = Persistence.createEntityManagerFactory("testUnit");
EntityManager em = emf.createEntityManager();

em.getTransaction().begin();
em.persist(new Book("book1", "00", new Author("author1")));
em.persist(new Book("book2", "01", new Author("author1")));
em.persist(new Book("book3", "02", new Author("author2")));
em.getTransaction().commit();

JPAStreamer によるクエリは以下のように実行できます。

JPAStreamer jpaStreamer = JPAStreamer.of("testUnit");

jpaStreamer.stream(Book.class)
    .filter(Book$.name.startsWithIgnoreCase("bo"))
    .sorted(Book$.name.reversed())
    .map(Book::getName)
    .forEach(System.out::println);

JPAStreamer は EntityManagerFactory から JPAStreamer.of(emf) のように取得することもできます。

以下のような出力が得られます。

book3
book2
book1

この時のSQLは以下が発行されます。

select book0_.id as id1_1_, book0_.author_id as author_i4_1_, book0_.isbn as isbn2_1_, book0_.name as name3_1_
from Book book0_
where lower(book0_.name) like ?
order by book0_.name desc

JPAStreamer は、Stream 処理をストリーム・レンダラーにより解析し、等価なJPQLを生成します。


フィルタ条件にメタモデルを使用しない場合、全てのレコードが取得されるため注意してください。

例えば以下のように、book.getName() を直接使用した場合、

jpaStreamer.stream(Book.class)
    .filter(book -> book.getName().startsWith("bo"))
    .sorted(Book$.name.reversed())
    .map(Book::getName)
    .forEach(System.out::println);

SQLは以下となり、フィルタリングは取得したデータ全件に対して Stream API を介して処理されます。

select ...
from Book book0_
order by book0_.name desc


Stream API と SQL の対応

JPAStreamer では、Stream 処理をストリーム・レンダラーにより解析し、等価なJPQLを生成します。

この時の対応は以下のようになります。

Stream API SQL
stream() FROM
filter() (before collecting) WHERE
sorted() ORDER BY
skip() OFFSET
limit() LIMIT
count() COUNT
collect(groupingBY()) GROUP BY
filter() (after collecting) HAVING
distinct() DISTINCT
map() SELECT
concat(s0, s1).distinct() UNION
flatmap() JOIN


Join 処理

JPAStreamer でJOINを処理する場合は、では、StreamConfiguration を使用する必要があります。

JPAStreamer jpaStreamer = JPAStreamer.of("testUnit");

StreamConfiguration<Book> conf = StreamConfiguration.of(Book.class)
    .joining(Book$.author);

jpaStreamer.stream(conf)
    .filter(Book$.name.startsWithIgnoreCase("bo"))
    .sorted(Book$.name.reversed())
    .forEach(System.out::println);

以下のような出力となります。

Book{id=1, name='book1', isbn='00', author=Author{id=1, fullName='author1'}}
Book{id=2, name='book2', isbn='01', author=Author{id=2, fullName='author1'}}
Book{id=3, name='book3', isbn='02', author=Author{id=3, fullName='author2'}}

SQLは以下のように Author が JOIN されていることが分かります。

select book0_.id as id1_1_0_, author1_.id as id1_0_1_, book0_.author_id as author_i4_1_0_, book0_.isbn as isbn2_1_0_, book0_.name as name3_1_0_, author1_.fullName as fullname2_0_1_ 
from Book book0_ left outer join Author author1_ on book0_.author_id=author1_.id
where lower(book0_.name) like ?

これにより N+1 SELECT 問題を回避することができます。


JPAStreamer でのクエリは、リレーションの FetchType により挙動が異なりますので注意が必要です。

ここでの Book.author は、FetchType.LAZY と定義しています。

以下のようにクエリ時に book.getAuthor() とアクセスした場合はエラーとなります。

jpaStreamer.stream(Book.class)
    .filter(Book$.name.startsWithIgnoreCase("bo"))
    .forEach(book -> System.out.println(book.getAuthor()));

Book.author を、FetchType.EAGER とすれば、エラーは解消されますが、N+1 SELECTとなります。

StreamConfiguration で JOIN を定義すれば何れの場合でも上手くいきます。


CDI 統合

CDI 環境で JPAStreamer を利用する場合は以下の依存を追加します。

dependencies {
    implementation("com.speedment.jpastreamer.integration.cdi:cdi-jpastreamer:1.1.3")
}

以下のように JPAStreamer のインジェクトが有効になります。

@ApplicationScoped
public class DummyService {

    private final JPAStreamer jpaStreamer;

    @Inject
    public DummyService(final JPAStreamer jpaStreamer) {
        this.jpaStreamer = jpaStreamer;
    }
}

Spring 環境でも同様に、インテグレーションが可能です。

dependencies {
    implementation("com.speedment.jpastreamer.integration.spring:spring-boot-jpastreamer-autoconfigure:1.1.3")
}

@Autowired でのインジェクトが可能です。

@Service
public class DummyService {

    private final JPAStreamer jpaStreamer;

    @Autowired
    public DummyService(final JPAStreamer jpaStreamer) {
        this.jpaStreamer = jpaStreamer;
    }
}


まとめ

Java Stream API で JPA を操作できる JPAstreamer の使い方について紹介しました。

Jakarta Persistence の easy to use が話題になることが多い昨今、潔い割り切りで使い易さを提供する JPAstreamer は、Jakarta EE コミュニティに対しても影響を与えるのではないでしょうか。

まぁ、JPAstreamer は今のところ JPA 2.2 までしか対応してないので、プロジェクトで使うかと言えば、使わないのですが。