CLOVER🍀

That was when it all began.

Hibernate Searchでファセットを使う - 関連Entity編

先ほど、Hibernate Searchを使ったファセットの基本的な使い方について書きました。その続きになります。

Hibernate Searchでファセットを使う - 単一Entity編
http://d.hatena.ne.jp/Kazuhira/20150125/1422187821

このうち、Discreteを書籍のタグの例で示しましたが、この時は「tag」という単一のカラムに押し込めました。でも、タグなんて複数付けるものでしょう。

というわけで、JPAの関連を使ってこれを実現してみましょう。

準備

Maven依存関係は、先ほどと同じ。

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-entitymanager</artifactId>
      <version>4.3.8.Final</version>
    </dependency>
    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-search-orm</artifactId>
      <version>5.0.1.Final</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-kuromoji</artifactId>
      <version>4.10.3</version>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.34</version>
      <scope>runtime</scope>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.12</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.assertj</groupId>
      <artifactId>assertj-core</artifactId>
      <version>1.7.1</version>
      <scope>test</scope>
    </dependency>

テーブル定義は、このようにしました。

CREATE TABLE book(
    id INT AUTO_INCREMENT,
    title VARCHAR(255),
    price INT,
    tag VARCHAR(255),
    PRIMARY KEY(id) 
);

CREATE TABLE tag(
    name VARCHAR(100),
    PRIMARY KEY(name)
);

CREATE TABLE book_tag(
    book_id INT,
    tag_name VARCHAR(100),
    PRIMARY KEY(book_id, tag_name),
    FOREIGN KEY(book_id) REFERENCES book(id),
    FOREIGN KEY(tag_name) REFERENCES tag(name)
);

「tag」というテーブルにタグを分割し、関連テーブル「book_tag」を別途定義します。

persistence.xmlもそう変わりなく。
src/test/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence 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_1.xsd"
             version="2.1">
  <persistence-unit name="search.pu" transaction-type="RESOURCE_LOCAL">
    <provider>org.hibernate.ejb.HibernatePersistence</provider>
    <class>org.littlewings.hibernate.facet.Book</class>
    <class>org.littlewings.hibernate.facet.Tag</class>
    <properties>
      <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/practice?useUnicode=true&amp;characterEncoding=utf-8&amp;characterSetResults=utf-8&amp;useServerPrepStmts=true&amp;useLocalSessionState=true&amp;elideSetAutoCommits=true&amp;alwaysSendSetIsolation=false" />
      <property name="javax.persistence.jdbc.user" value="kazuhira" />
      <property name="javax.persistence.jdbc.password" value="password" />

      <!-- Hibernate -->
      <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5InnoDBDialect" />
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.format_sql" value="true" />

      <!-- Hibernate Search -->
      <property name="hibernate.search.default.directory_provider" value="ram" />
      <property name="hibernate.search.analyzer" value="org.apache.lucene.analysis.ja.JapaneseAnalyzer" />
      <property name="hibernate.search.lucene_version" value="LUCENE_4_10_3" />
    </properties>
  </persistence-unit>
</persistence>

Entityの定義を追加したくらいです。

Entityクラス

それでは、bookおよびtagテーブルに対するEntityクラスを作成します。

まずは、bookテーブル側から。
src/main/java/org/littlewings/hibernate/facet/Book.java

package org.littlewings.hibernate.facet;

import java.io.*;
import java.util.*;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

@Entity
@Table(name = "book")
@Indexed
public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

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

    @Column
    @Field
    private String title;

    @Column
    @Field
    private Integer price;

    @ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.MERGE, CascadeType.REFRESH, CascadeType.DETACH})
    @JoinTable(name = "book_tag",
               joinColumns = @JoinColumn(name = "book_id"),
               inverseJoinColumns = @JoinColumn(name = "tag_name"))
    @IndexedEmbedded
    private Set<Tag> tags;

    public Book() { }

    public Book(String title, Integer price, Set<Tag> tags) {
        this.title = title;
        this.price = price;
        this.tags = tags;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public Integer getPrice() { return price; }
    public void setPrice(Integer price) { this.price = price; }
    public Set<Tag> getTags() { return tags; }
    public void setTags(Set<Tag> tags) { this.tags = tags; }
}

こういうケースの場合は@ManyToManyになると思いますが、bookとtagは生存期間が違うので、cascadeの設定はこういう定義に。

    @ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.MERGE, CascadeType.REFRESH, CascadeType.DETACH})
    @JoinTable(name = "book_tag",
               joinColumns = @JoinColumn(name = "book_id"),
               inverseJoinColumns = @JoinColumn(name = "tag_name"))
    @IndexedEmbedded
    private Set<Tag> tags;

Hibernate Searchを使った場合の特記事項は、@IndexedEmbeddedアノテーションの利用ですね。関連を貼ったEntityのコレクションに、このアノテーションを付与しておきます。

続いて、tag側。
src/main/java/org/littlewings/hibernate/facet/Tag.java

package org.littlewings.hibernate.facet;

import java.io.*;
import java.util.*;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

@Entity
@Table(name = "tag")
public class Tag implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    @DocumentId(name = "id")
    @Field(analyze = Analyze.NO)
    private String name;

    @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY)
    private List<Book> books;

    public Tag() { }

    public Tag(String name) { this.name = name; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

このクラス単体をインデックスに保存することはしないので、@Indexedアノテーションは付与しません。代わりに、nameフィールドに@Fieldアノテーションを「analyze = Analyze.NO」で付与しています。今回は、このフィールドはファセットのみで使用するものとします。

また、@DocumentIdアノテーションが付いていますが、これは@Id付きのフィールドに@Fieldアノテーションを付与したからで、「id」以外の名前に@Idを付けて、さらに@Fieldなりインデックス対象なりにすると、「id以外の名前はNGだ」とHibernate Searchに怒られるからです。別途自動採番の主キーを定義すると、これは解消すると思いますが、今回はこの定義とします。

使ってみる

実際にHibernate SearchのAPIを使う前に、テストコードの雛形を。
src/test/java/org/littlewings/hibernate/facet/FacetTest.java

package org.littlewings.hibernate.facet;

import static org.assertj.core.api.Assertions.*;

import java.util.*;
import java.util.stream.*;
import javax.persistence.*;

import org.hibernate.search.jpa.*;
import org.hibernate.search.query.dsl.*;
import org.hibernate.search.query.facet.*;
import org.hibernate.search.query.engine.spi.*;

import org.junit.*;

public class FacetTest {
    private static EntityManager createEntityManager() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("search.pu");
        return emf.createEntityManager();
    }

    @After
    public void tearDown() {
        EntityManager em = createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createQuery("DELETE FROM Book").executeUpdate();

        tx.commit();
    }

    private void addBooks(EntityManager em, List<Book> books) {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        books.stream().forEach(em::persist);

        tx.commit();
    }

    private void addTags(EntityManager em, Set<Tag> tags) {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        tags
            .stream()
            .filter(tag -> em.find(Tag.class, tag.getName()) == null)
            .forEach(em::persist);

        tx.commit();
    }

    private Set<Tag> toTags(String... tags) {
        return Arrays
            .asList(tags)
            .stream()
            .map(tag -> new Tag(tag))
            .collect(Collectors.toSet());
    }

    private List<Book> createBooks() {
        return Arrays.asList(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                                      2700,
                                      toTags("Java", "Spring")),
                             new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                                      3024,
                                      toTags("Elasticsearch", "全文検索", "Java", "Lucene")),
                             new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                                      3888,
                                      toTags("Solr", "全文検索", "Java", "Lucene")),
                             new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                                      3200,
                                      toTags("Lucene", "全文検索", "Java")),
                             new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                                      2138,
                                      toTags("Java")),
                             new Book("改訂2版 パーフェクトJava",
                                      3486,
                                      toTags("Java")));
    }

    // ここに、テストコードを書く
}

先ほどのテストコードの雛形より、ちょっと長くなりました。先にtagを永続化しておく必要があるからですね。

では、進めます。

EntityManagerを取得し、タグ⇒本の順番でデータを登録します。

        EntityManager em = createEntityManager();
        addTags(em, toTags("Java", "Spring", "全文検索", "Lucene", "Solr", "Elasticsearch"));
        addBooks(em, createBooks());

FullTextEntityManagerとQueryBuilderの取得。

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

ファセットリクエストの作成。

        FacetingRequest facetingRequest =
            queryBuilder
            .facet()
            .name("tagFaceting")
            .onField("tags.name")
            .discrete()
            .orderedBy(FacetSortOrder.COUNT_DESC)
            .includeZeroCounts(false)
            .createFacetingRequest();

onFieldの指定値が、「tags.name」となっているところがポイントです。

あとは、通常通りLuceneクエリ、FulTextQueryを作成して、Facetを有効化します。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.all().createQuery();
        FullTextQuery fullTextQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        FacetManager facetManager = fullTextQuery.getFacetManager();
        facetManager.enableFaceting(facetingRequest);

検索および結果確認。

        List<Book> books = fullTextQuery.getResultList();
        assertThat(books)
            .hasSize(6);

        List<Facet> facets = facetManager.getFacets("tagFaceting");

        assertThat(facets)
            .hasSize(6);
        assertThat(facets.get(0).getValue())
            .isEqualTo("Java");
        assertThat(facets.get(0).getCount())
            .isEqualTo(6);
        assertThat(facets.get(1).getValue())
            .isEqualTo("Lucene");
        assertThat(facets.get(1).getCount())
            .isEqualTo(3);
        assertThat(facets.get(2).getValue())
            .isEqualTo("全文検索");
        assertThat(facets.get(2).getCount())
            .isEqualTo(3);
        assertThat(facets.get(3).getValue())
            .isEqualTo("Elasticsearch");
        assertThat(facets.get(3).getCount())
            .isEqualTo(1);
        assertThat(facets.get(4).getValue())
            .isEqualTo("Solr");
        assertThat(facets.get(4).getCount())
            .isEqualTo(1);
        assertThat(facets.get(5).getValue())
            .isEqualTo("Spring");
        assertThat(facets.get(5).getCount())
            .isEqualTo(1);

ちゃんと動いてそうですね。

Hibernate Search自体のAPIにはそんなに困りませんでしたが、JPAでちょっとハマりました…。関連をよく理解していないみたいなので、このあたりは勉強しないとですね。