先ほど、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&characterEncoding=utf-8&characterSetResults=utf-8&useServerPrepStmts=true&useLocalSessionState=true&elideSetAutoCommits=true&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でちょっとハマりました…。関連をよく理解していないみたいなので、このあたりは勉強しないとですね。