Elasticsearchのクエリビルダをコンポジットパターンで書いた

訳あってElasticsearchのクエリビルダを書いたので、コードを載せます。
タイトルには「コンポジットパターンで書いた」と書きましたが、
正しくは「実装したらコンポジットパターンぽくなってた」です。


# 背景

- PHPで書いているWebアプリケーションで全文検索エンジン使いたい。
- Elasticsearchの検索クエリは文字列を連結すれば簡単に作れるが、複数の条件を組み合わせたクエリになると複雑になってつらい。
- クエリビルダ探したけどちょうどいいOSSがない。[^1]
- そんなに複雑そうじゃないので自前で実装しよう。

# Elasticsearchの検索クエリ
Elasticsearchの検索には、`Query DSL`を用います。 Query DSLはElasticsearch用の検索問い合わせのための専用言語です。
関係データベースにおけるSQLと同じ役割になります。


Elasticsearchの`Query DSL`にはいくつか種類がありますが、今回は自由度の高い[`Query string query`](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html)を使用しました.


参考) 
- https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
- https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html


# コンポジットパターンとは？
`コンポジットパターン（Compositeパターン）`とは、入れ子構造をシンプルに表現できるデザインパターンです。
`ファイルシステム`における`ディレクトリ`と`ファイル`を例に挙げると、
`ディレクトリ`も`ファイル`も、両方とも`ディレクトリ`の中に入ることができるという共通の性質があります。
これをクラス化することで、ファイルとディレクトリを同一に扱うことができるので、
入れ子構造をシンプルに実装できます。

<img width="447" alt="スクリーンショット 2019-08-10 14.09.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268801/3e576790-6167-d02f-65fc-caa7d73ca179.png">
図 ディレクトリとファイルのツリー


![360px-Composite_UML_class_diagram_(fixed).svg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268801/881cdd1f-9c4e-8656-7c68-42e78afb1ecd.png)
図 コンポジットパターンのクラス図[Wikipedia](https://ja.wikipedia.org/wiki/Composite_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)より引用 

## コンポジットパターンの登場人物

コンポジットパターンの登場人物は、次の通りです。
-　`Leaf （葉）`： 中身を表す役。
-　`Composite（複合体）`： 容器を表す役。Leaf役またはComposite役を格納できる。
-　`Component` ： Leaf役とComposite役を同一視するための役。

# Query string queryとコンポジットパターン

ElasitcSearchのクエリ DSLには２種類の句が存在します。
- `Leaf query clauses(リーフクエリ句)` : 完全一致や部分一致、数値範囲の一致といった絞り込みを行う句
- `Compound query clauses(複合クエリ句)` :  `Leaf Query clauses`または`Compound query clauses`をブール演算で繋ぐ句

この２種類のクエリの関係は、ツリー構造。 コンポジットパターンの`Leaf`と`Composite`の関係になっています。

<img width="339" alt="スクリーンショット 2019-08-10 15.42.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268801/a8faeff2-06c7-714a-9569-4e770a803339.png">



参考) https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html

# コード

<img width="349" alt="スクリーンショット 2019-08-10 15.39.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268801/67cd086a-17e6-be72-0ac5-a78ce17e5b6a.png">
図 これから実装するクラスのクラス図


(この図を書いて思ったが、リーフクエリ句と複合クエリ句をそれぞれインターフェースにしておけばもっとシンプルだった)


## Component役 Expression 
`Leaf query clauses`, `Compound query clauses`の両方を同一視するための役を`Expression`というインターフェースで実装しました。
訳あって`Expression`という名前を採用してますが、
`Clauses`という名前の方がしっくりくるので、読み替えてください。

```PHP:Expression.php
interface Expression
{
    public function toStringExpression(): String;
}
```

`toStringExpression()`は、その句を文字列に変換するメソッドです。

## Leaf役　TermExpression

検索条件を指定する句は、数値の一致に使われるTermや、範囲一致のRangeなど色々ありますが、
今回は抜粋してTerm句のみ紹介します。

```PHP:TeamExpression.php
class TermExpression implements Expression
{
    /**
     * @var string
     */
    private $field;
    /**
     * @var int|null
     */
    private $boost;
    /**
     * @var StringOrValueCondition
     */
    private $condition;

    public function __construct(string $field, ?int $boost, StringOrValueCondition $condition)
    {
        $this->field = $field;
        $this->boost = $boost;
        $this->condition = $condition;
    }


    /**
     * 構文: (FIELD^BOOST: CONDITION)
     * FIELD: FieldName, N: Boost, CONDITION: String or Value Condition
     * @return String
     */
    public function toStringExpression(): String
    {
        $field = $this->field;
        $boostOption = isset($this->boost) ? "^$this->boost" : '';
        $expressionString = $this->condition->toStringCondition();
        return "($field$boostOption: $expressionString)";
    }
}

```
`StringOrValueCondition`型は、文字列か数値を表現するための独自のValueObjectです。

## Composite役 AndExpression

`Compound query clauses`にはANDのほかにOR, NOTがあります。


```PHP:AndExpression.php
class AndExpression implements Expression
{
    /**
     * @var ExpressionList
     */
    private $expressions;
    /**
     * @var int|null
     */
    private $boost;

    public function __construct(?int $boost, ExpressionList $expressions)
    {
        if ($expressions->count() <= 1) {
            throw new InvalidArgumentException();
        }

        $this->expressions = $expressions;
        $this->boost = $boost;
    }

    /**
     * 構文: (EXPRESSION1^BOOST AND EXPRESSION2^BOOST AND ... AND EXPRESSIONN^BOOST)
     * EXPRESSION : Expressions BOOST: Boost
     * @return String
     */
    public function toStringExpression(): String
    {
        $expressionsString = $this->expressions->toStringWithGlue('AND');
        $boostOption = isset($this->boost) ? "^$this->boost" : '';

        return "($expressionsString" . "$boostOption)";
    }
}
```
注目すべきは、`$this->expressions->toStringWithGlue('AND');`の部分です。
`toStringWithGlue()`メソッドでは、`Expression`リスト内のそれぞれの`Expression`に対して`toStringExpression()`を実行して、それを文字列連結で１つの文字列にしています。
たとえば、`AndExpression`の中に`AndExpression`があれば、また`toStringWithGlue（）`が呼ばれ、その`AndExpression`の中に、また`AndExpression`があれば、さらに`toStringWithGlue（）`が呼ばれることになります。
つまり、ツリーを辿って再帰的に文字列変換が行われることになります。

```PHP:ExpressionList.php

class ExpressionList extends BaseListValue
{
    public function toStringArray(): array
    {
        return $this->map(function ($expression) {
            /** @var Expression $expression */
            return $expression->toStringExpression();
        });
    }

    public function toStringWithGlue(string $glue): string
    {
        return implode(" $glue ", $this->toStringArray());
    }
}
```

## 使い方の例

３つの式をORとANDを使って合体して、一つのQueryStringを作ってみます。

```PHP
$expressionA = new TermExpression('fieldA', null /* boost */, new IntCondition(123)); //式A
$expressionB = new TermExpression('fieldB', 2 /* boost */, new StringCondition("Hello")); //式B
$expressionC = new TermExpression('fieldC', 2 /* boost */, new StringCondition("World")); //式C

$orExpression = new OrExpression(
    new ExpressionList([$expressionA, $expressionB]));
 // (式A OR 式B)を作る
$andExpression = new AndExpression(new ExpressionList([$orExpression, $expressionC])); // ((式A OR 式B) AND 式C) を作る

$queryString = $andExpression->toStringExpression();
// (((fieldA: 123) OR (fieldB^2: 'Hello')) AND (fieldC^2: 'World'))
```

こんな感じになります。

# おわりに

- クエリ句の階層構造に着目することでシンプルにクエリビルダを書くことができた。
- 今回は訳あって、クエリ文字列を自前で作成したが、基本的には、車輪の再発明を避けてライブラリを使うべきなので、よいこのみんなはライブラリを使いましょう。

---

[^1]: いい感じのクエリビルダがなかったと書いたが、実は、最初はCloudSearchを導入する予定だったのでCloudSearchのクエリビルダを探していた。「Elasticsearch Query Builder」で検索するといっぱい見つかったので、きっとその中にちょうどいいものがあるだろう。