CLOVER🍀

That was when it all began.

Hibernate Searchと数値フィールド

Luceneでドキュメントに数値を保存する時、数値向けのフィールドとしてIntFieldやLongField、DoubleFieldなどを使うことができます。ですが、これらはTermとしてはちょっと別物になるので、普通のクエリでは検索できずNumericRangeQueryを使うことになります。

Luceneを試していた時は、最初この差を知らなくて検索できなかったり、たまにコロッと忘れてハマったりするのですが…Hibernate Search越しだとどうなるのかがちょっと気になって試してみました。

結果からいうと、Query DSLでもあまり意識することなく使えました。

まあ、見ていってみましょう。

準備

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>

データベースにはMySQL、テストコードにはJUnitとAssertJを使用します。

また、persistence.xmlはあまり今回言うところがないので端折ります。

テーブル定義とEntity

今回対象とするテーブルは、このようなものとします。

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

お題は書籍。

対応するEntity。
src/main/java/org/littlewings/hibernate/numeric/Book.java

package org.littlewings.hibernate.numeric;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

@Entity
@Table(name = "book")
@Indexed
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column
    @Field
    private String title;

    @Column
    @Field
    private Integer price;

    @Column
    @Field
    private String summary;

    public Book() { }

    public Book(String title, Integer price, String summary) {
        this.title = title;
        this.price = price;
        this.summary = summary;
    }

    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 String getSummary() { return summary; }
    public void setSummary(String summary) { this.summary = summary; }
}

今回、priceが数値型のフィールドですね。

    @Column
    @Field
    private Integer price;

ちなみに、本来数値型のフィールドには@NumericFieldアノテーションを付与するものらしいのですが

@NumericField
http://docs.jboss.org/hibernate/search/5.0/reference/en-US/html_single/#numeric-field-annotation

Hibernate Search 5からは、数値型のフィールドには自動的に付与されるような扱いになったみたいです。

Caution

Prior to Search 5, numeric field encoding was only chosen if explicitly requested via @NumericField. As of Search 5 this encoding is automatically chosen for numeric types. To avoid numeric encoding you can explicitly specify a non numeric field bridge via @Field.bridge or @FieldBridge. The package org.hibernate.search.bridge.builtin contains a set of bridges which encode numbers as strings, for example org.hibernate.search.bridge.builtin.IntegerBridge.

http://docs.jboss.org/hibernate/search/5.0/reference/en-US/html_single/#field-annotation

ですので、今回はこのままいきます。

テストコードの雛形

テストコードのために、用意した雛形メソッドなど。
src/test/java/org/littlewings/hibernate/numeric/NumericFieldTest.java

package org.littlewings.hibernate.numeric;

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

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

import org.hibernate.search.jpa.*;
import org.hibernate.search.query.dsl.*;

import org.junit.*;

public class NumericFieldTest {
    private static EntityManager em;

    @BeforeClass
    public static void setUpClass() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("search.pu");
        em = emf.createEntityManager();
    }

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

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

        tx.commit();
    }

    @AfterClass
    public static void tearDownClass() {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createNativeQuery("TRUNCATE TABLE book").executeUpdate();

        tx.commit();
    }

    private void addBooks(Book... books) {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

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

        tx.commit();
    }

    // ここに、テストを書く!
}

FullTextEntityManagerを使ったり、データ登録のヘルパーメソッドとかですね。

では、テストを書いていきます。

キーワードクエリを投げてみる

とりあえず、最もオーソドックスと思われるキーワードクエリを投げてみます。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));
        
        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("price").matching(3200).createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        List<Book> books = jpaQuery.getResultList();

        assertThat(luceneQuery)
            .isInstanceOf(org.apache.lucene.search.NumericRangeQuery.class);

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3200]");
        assertThat(books)
            .hasSize(1);
        assertThat(books.get(0).getTitle())
            .isEqualTo("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築");

結果はもう書いていますが、このようなクエリが

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("price").matching(3200).createQuery();

なんとNumericRangeQueryとして扱われます。

        assertThat(luceneQuery)
            .isInstanceOf(org.apache.lucene.search.NumericRangeQuery.class);

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3200]");

これは素晴らしい。

範囲クエリ

今度は、Rangeを使うクエリ。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));
        
        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.range().onField("price").from(3200).to(3486).createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        List<Book> books = jpaQuery.getResultList();

        assertThat(luceneQuery)
            .isInstanceOf(org.apache.lucene.search.NumericRangeQuery.class);

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3486]");
        assertThat(books)
            .hasSize(2);
        assertThat(books.get(0).getTitle())
            .isEqualTo("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築");
        assertThat(books.get(1).getTitle())
            .isEqualTo("改訂2版 パーフェクトJava");

今度は、このfromとtoが

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.range().onField("price").from(3200).to(3486).createQuery();

このようになります。

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3486]");

上記の例では、下限、上限を含む形でしたが、境界値を外す場合はexcludeLimitを使用します。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));
        
        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.range().onField("price").from(3200).to(3486).excludeLimit().createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        List<Book> books = jpaQuery.getResultList();

        assertThat(luceneQuery)
            .isInstanceOf(org.apache.lucene.search.NumericRangeQuery.class);

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3486}");
        assertThat(books)
            .hasSize(1);
        assertThat(books.get(0).getTitle())
            .isEqualTo("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築");

この例では、3486より小さい、となっています。

上限だけ決める場合。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));
        
        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.range().onField("price").below(3200).excludeLimit().createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        List<Book> books = jpaQuery.getResultList();

        assertThat(luceneQuery)
            .isInstanceOf(org.apache.lucene.search.NumericRangeQuery.class);

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[* TO 3200}");
        assertThat(books)
            .hasSize(3);
        assertThat(books.get(0).getTitle())
            .isEqualTo("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発");
        assertThat(books.get(1).getTitle())
            .isEqualTo("高速スケーラブル検索エンジン ElasticSearch Server");
        assertThat(books.get(2).getTitle())
            .isEqualTo("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]");

こちらも、普通に使えます。下限のみを決める場合は、aboveを使えばOK。excludeLimitも、もちろん使えます。

普通のNumericRangeQueryを投げる

当然といえば当然ですが、LuceneのNumericRangeQueryを直接構築してもOKです。

この説明と過去の記憶から、てっきり自分でNumericRangeQueryを構築するのかと思っていましたよ…。

short, Short, integer, Integer, long, Long, float, Float, double, Double
Are per default indexed numerically using a Trie structure. You need to use a NumericRangeQuery to search for values. See also Section 4.1.1.2, “@Field” and Section 4.1.1.3, “@NumericField”

http://docs.jboss.org/hibernate/search/5.0/reference/en-US/html_single/#section-built-in-bridges

利用例。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));
        
        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Book.class).get();

        org.apache.lucene.search.Query luceneQuery =
            org.apache.lucene.search.NumericRangeQuery.newIntRange("price", 3200, 3486, true, false);
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Book.class);

        List<Book> books = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("price:[3200 TO 3486}");
        assertThat(books)
            .hasSize(1);
        assertThat(books.get(0).getTitle())
            .isEqualTo("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築");

こちらのクエリが、

        org.apache.lucene.search.Query luceneQuery =
            org.apache.lucene.search.NumericRangeQuery.newIntRange("price", 3200, 3486, true, false);

このクエリと同じ意味になります。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.range().onField("price").from(3200).to(3486).excludeLimit().createQuery();

というわけで、NumericRangeQuery#newIntRangeの第4、5引数のboolean値で、境界値値を含むかどうかを決められます。

いやぁ、けっこう素直に使えるものなんですね〜。