Unity Searchを拡張する(QueryEngine編)

# TL;DR
- Unity Searchの一部として、任意のコレクションに対するクエリを文字列を元に行うモジュールとして `QueryEngine<T>`が提供されている
- `QueryEngine<T>` 利用することで、Unity Searchのクエリ式(!=検索式)のような構文のクエリを任意の対象に対して簡単に実現できる

# はじめに

この記事は [Unity Advent Calendar 2023 シリーズ1](https://qiita.com/advent-calendar/2023/unity) の 12日目の記事です。

trapezoid(Haruto Otake)です。普段はモバイルゲームの基盤開発の仕事をしています。
先日、[Unity UI 完全に理解した勉強会](https://meetup.unity3d.jp/jp/events/1411)で[Unity Searchを拡張して高度な検索型UIを実現する](https://speakerdeck.com/trapezoid/unity-searchwokuo-zhang-sitegao-du-najian-suo-xing-uiwoshi-xian-suru)という講演をさせてもらいました。

この講演ではUnity標準のAsset検索機能、Unity Searchを独自に拡張して、高度な検索型UIを実現するための手法を紹介しました。

今回の記事では、この講演で紹介しきれなかったUnity SearchのAPI、`QueryEngine<T>`について紹介していきます。

# クエリ式

Unity Searchの基本にもなる[クエリ式](https://docs.unity3d.com/ja/current/Manual/search-query-operators.html)では、アセットの様々な属性に対する条件を記述することができるようになっています。

クエリ式内では**フィルタ**という識別子ごとに、演算子とオペランドを指定できます。
例えば、
```
path:DirName
```
のように指定した場合、構造として分解すると以下のような形になります。

- `path`フィルタ(アセットパス)を対象とする
- `:`演算子(指定したオペランドを部分文字列として含む場合にtrueとなる)
- `DirName`(オペランド)

つまり、**アセットパスに`DirName`を部分文字列として含むアセットに絞り込む**という意味になります。operatorは`:`,`=`,`!=`,`>=`,`<=`,`>`,`<`などの基本的なものを使うことができ、これらをAND/OR等の論理演算で合成して、複雑な条件をクエリ式として表すことができます。

# `QueryEngine<T>`

[クエリ式のドキュメント](https://docs.unity3d.com/ja/current/Manual/search-query-operators.html)にも記載されているように、これらのクエリ式の機能は`QueryEngine<T>`というAPIを通して実現されています。

そして、実はこの`QueryEngine<T>`は、任意の`T`型のオブジェクトのコレクション(`IEnumerable<T>`)に対して、[クエリ式](https://docs.unity3d.com/ja/current/Manual/search-query-operators.html)のような文字列指定での高度な動的フィルタ処理を可能にするAPIとして実装されているため、

**アセット以外の任意の対象に対して、Unity Search同等のクエリ機構を実現する手段**として用いることができます。

(ただし、Unity SearchのAPIなので、利用できるのはEditor内に限ります)

# `QueryEngine<T>`の使い方

`QueryEngine<T>`は、[`UnityEditor.Search.QueryEngine<T>`](https://docs.unity3d.com/ScriptReference/Search.QueryEngine.html)として提供されています。
具体的な使い方を説明します。

## Filterの定義

まず、以下のような検索対象のクラス、コレクションがあるとします。

```csharp
public class QueryEngineSample
{
    public struct Record : IEquatable<Record>
    {
        #region IEquatable<Record>
        //IEquatable<Record>の実装は省略。
        //後のテストで使うだけなので、QueryEngineの動作には不要
        #endregion
    
        public string String;
        public int Int32;
        public long Int64;
        public float Float;
        public double Double;
    }

    public static void Run ()
    {
        //検索対象のサンプルを配列として定義
        var sources = new Record[]
        {
            new Record() {String = "st1", Int32 = 1, Int64 = 101, Double = 200.1d, Float = 300.1f},
            new Record() {String = "st2", Int32 = 2, Int64 = 102, Double = 200.2d, Float = 300.2f},
            new Record() {String = "st3", Int32 = 3, Int64 = 103, Double = 200.3d, Float = 300.3f},
            new Record() {String = "st4", Int32 = 4, Int64 = 104, Double = 200.4d, Float = 300.4f},
            new Record() {String = "st5", Int32 = 5, Int64 = 105, Double = 200.5d, Float = 300.5f},
        };
    }
}
```

最低限のサンプルとして`String`フィールドの値を元にクエリを出来るようなQueryEngineを定義していきます。
```csharp
var queryEngine = new QueryEngine<Record>();
queryEngine.AddFilter(nameof(Record.String), r => r.String);
```
`Record`が検索対象の検索対象の型なので、`QueryEngine<Record>`をnewして、
`AddFilter<TFilter>`に

- **定義するフィルタ名**
- **フィルタの`Record`型からの値解決の方法**のdelegate

を渡します

`TFilter`は標準ではプリミティブ型/文字列型などをそのまま指定することができるので、ここでは単純に`String`フィールドをstringとしてそのまま返しています。

## クエリの実行

```csharp
//クエリ文字列を渡して、フィルを実際に実行するためのParsedQueryを得る
var query = queryEngine.ParseQuery("String=st3");
//ParsedQueryを使って、任意のコレクションに対して実際にフィルタをかける
var filtered = query.Apply(sources);
//結果の確認
Assert.IsTrue(filtered.SequenceEqual(sources.Where(r => r.String == "st3")));
```

クエリを行うにはまず、**クエリ文字列を`ParseQuery`メソッドに渡して`ParsedQuery<T>`を取得**します。
これに対して`Apply`を実行すると、任意のコレクションに対して実際を絞り込みをかけることができます。

## Filterの複数定義

先のコードでは`String`フィルタのみを定義しましたが、フィルタはAddFilterを複数回呼ぶことで、複数定義することができます。

```csharp
queryEngine.AddFilter(nameof(Record.Int32), r => r.Int32);
queryEngine.AddFilter(nameof(Record.Int64), r => r.Int64);
queryEngine.AddFilter(nameof(Record.Float), r => r.Float);
queryEngine.AddFilter(nameof(Record.Double), r => r.Double);
```

これで、String以外のフィールドを対象にした絞り込みを、フィルタを使って行うことができるようになります。

```csharp
{
    var query = queryEngine.ParseQuery("Int32>2");
    var filtered = query.Apply(sources);
    Assert.IsTrue(filtered.SequenceEqual(sources.Where(r => r.Int32 > 2)));
}
{
    var query = _queryEngine.ParseQuery("Int32>1 and Int64<105");
    var filtered = query.Apply(sources);
    Assert.IsTrue(filtered.SequenceEqual(sources.Where(r => r is { Int32: > 1, Int64: < 105 })));
}

```

比較を行う演算子を利用したり、論理演算で複数のフィルタを利用することもできます。


## Filterを伴わないクエリ

Unity Searchではフィルタを指定せずに任意の文字列をクエリとして入力すると、アセット名による検索が実行されますが、このFilterを伴わないクエリも同じく`QuertyEngine<T>`で実現できます。

```csharp
queryEngine.SetSearchDataCallback(r =>
{
    return new string[]
    {
        r.String, r.Int32.ToString(), r.Int64.ToString(),
        r.Float.ToString(), r.Double.ToString()
    };
});
```

`SetSearchDataCallback`に、レコードを引数として受け取り、任意の複数の文字列(`IEnumerable<T>`)を返す処理をdelegateとして渡すと、返り値の文字列を各レコードの検索対象文字列として認識します。

```csharp
var query = _queryEngine.ParseQuery("st3 or 104");
var filtered = query.Apply(sources);
Assert.IsTrue(filtered.SequenceEqual(sources.Where(r => r.String == "st3" || r.Int64 == 104)));
```
あとは、Filterのときと同様にクエリを記述すれば、部分マッチする文字列があるレコードのみに絞り込まれるようになります。

# さいごに

いかがでしたでしょうか。
`QueryEngine<T>`はUnity Searchの一部として提供されており、冒頭で紹介した講演で話したようなUnity SearchのSearchProviderを実装する際に有用なのはもちろんですが、
**Editor内でクエリによるフィルタ処理を実装したい場面**全般で活用できる、非常に便利なAPIになっています。

Unity Searchを使った検索型UIの実装についても冒頭で紹介した[スライド](https://speakerdeck.com/trapezoid/unity-searchwokuo-zhang-sitegao-du-najian-suo-xing-uiwoshi-xian-suru)及び[アーカイブ動画](https://www.youtube.com/watch?v=QF2iNTscjYA&t=1765s)をご覧いただければと思うので、興味があれば是非こちらも御覧ください!