訳あって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.html
- https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html
コンポジットパターンとは?
コンポジットパターン(Compositeパターン)
とは、入れ子構造をシンプルに表現できるデザインパターンです。
ファイルシステム
におけるディレクトリ
とファイル
を例に挙げると、
ディレクトリ
もファイル
も、両方ともディレクトリ
の中に入ることができるという共通の性質があります。
これをクラス化することで、ファイルとディレクトリを同一に扱うことができるので、
入れ子構造をシンプルに実装できます。

図 コンポジットパターンのクラス図Wikipediaより引用
コンポジットパターンの登場人物
コンポジットパターンの登場人物は、次の通りです。
- Leaf (葉)
: 中身を表す役。
- Composite(複合体)
: 容器を表す役。Leaf役またはComposite役を格納できる。
- Component
: Leaf役とComposite役を同一視するための役。
Query string queryとコンポジットパターン
ElasitcSearchのクエリ DSLには2種類の句が存在します。
-
Leaf query clauses(リーフクエリ句)
: 完全一致や部分一致、数値範囲の一致といった絞り込みを行う句 -
Compound query clauses(複合クエリ句)
:Leaf Query clauses
またはCompound query clauses
をブール演算で繋ぐ句
この2種類のクエリの関係は、ツリー構造。 コンポジットパターンのLeaf
とComposite
の関係になっています。

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

(この図を書いて思ったが、リーフクエリ句と複合クエリ句をそれぞれインターフェースにしておけばもっとシンプルだった)
Component役 Expression
Leaf query clauses
, Compound query clauses
の両方を同一視するための役をExpression
というインターフェースで実装しました。
訳あってExpression
という名前を採用してますが、
Clauses
という名前の方がしっくりくるので、読み替えてください。
interface Expression
{
public function toStringExpression(): String;
}
toStringExpression()
は、その句を文字列に変換するメソッドです。
Leaf役 TermExpression
検索条件を指定する句は、数値の一致に使われるTermや、範囲一致のRangeなど色々ありますが、
今回は抜粋してTerm句のみ紹介します。
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があります。
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()
を実行して、それを文字列連結で1つの文字列にしています。
たとえば、AndExpression
の中にAndExpression
があれば、またtoStringWithGlue()
が呼ばれ、そのAndExpression
の中に、またAndExpression
があれば、さらにtoStringWithGlue()
が呼ばれることになります。
つまり、ツリーを辿って再帰的に文字列変換が行われることになります。
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());
}
}
使い方の例
3つの式をORとANDを使って合体して、一つのQueryStringを作ってみます。
$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'))
こんな感じになります。
おわりに
- クエリ句の階層構造に着目することでシンプルにクエリビルダを書くことができた。
- 今回は訳あって、クエリ文字列を自前で作成したが、基本的には、車輪の再発明を避けてライブラリを使うべきなので、よいこのみんなはライブラリを使いましょう。
-
いい感じのクエリビルダがなかったと書いたが、実は、最初はCloudSearchを導入する予定だったのでCloudSearchのクエリビルダを探していた。「Elasticsearch Query Builder」で検索するといっぱい見つかったので、きっとその中にちょうどいいものがあるだろう。 ↩