Elasticsearch 7.2.1 で join datatype を使って親子関係を実現した際に、子データが無い親を検索する

Elasticsearch で親子関係のデータをインデクシングした際に、子データが無い親のデータを検索したい時があります。

手元の環境は以下になります。

* Ubuntu 18.04 LTS
* Elasticsearch 7.2.1

最初にインデックスの設定とマッピングを定義します。( [こちら](https://qiita.com/nettle0010/items/e3374ff825d896a3863e)の記事と同じものです。)

次に、以下のようなデータで json ファイルを作成します。

```
{"index": {"_index": "shop", "_type": "_doc","_id": "goods_id.1"}}
{"type": "goods","name": "三岳","text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。屋久島は水が美味しく、その水を使って作られています。ロックで飲むことをオススメします。","my_join_field": "goods"}
{"index": {"_index": "shop", "_type": "_doc","_id": "review_id.1", "routing": "goods_id.1"}}
{"type": "review","user": "nettle0010","text": "何度も購入させていただいてます。以前はレア物でなかなか飲めませんでしたが、こちらで購入できるようになって晩酌で楽しんでます。","my_join_field": {"name": "review", "parent": "goods_id.1"}}
{"index": {"_index": "shop", "_type": "_doc","_id": "review_id.2", "routing": "goods_id.1"}}
{"type": "review","user": "udfhsudadb","text": "注文してから２〜３日で届きました。ありがとうございます！","my_join_field": {"name": "review", "parent": "goods_id.1"}}
{"index": {"_index": "shop", "_type": "_doc","_id": "goods_id.2"}}
{"type": "goods","name": "屋久の島","text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。三岳と違ってあまりメジャーではありませんが、なかなか美味しいです。ロックがオススメです。","my_join_field": "goods"}
{"index": {"_index": "shop", "_type": "_doc","_id": "review_id.3", "routing": "goods_id.2"}}
{"type": "review","user": "okodshywegfej","text": "昨年購入しました。両親からも喜ばれています。","my_join_field": {"name": "review", "parent": "goods_id.2"}}
{"index": {"_index": "shop", "_type": "_doc","_id": "goods_id.3"}}
{"type": "goods","name": "原酒三岳","text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。三岳の原酒です。ロックがオススメです。","my_join_field": "goods"}
```

そして、json ファイルを使ってインデクシングします。

```
$ curl -H "Content-type: application/x-ndjson" -X POST http://localhost:9200/_bulk?refresh=false --data-binary @request_bulk.json | jq
$ curl -X POST 'localhost:9200/shop/_refresh' | jq
```

子データが2つのもの、1つのもの、ひとつも無いもの、の3つの親データを作成しています。

子データが無い親を検索してみます。

```
$ curl -X POST 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d'
{
    "from": 0,
    "size": 10,
    "query": {
        "bool": {
            "filter": [
                { "term": { "type": "goods" } }
            ],
            "must_not": {
                "has_child": {
                    "type": "review",
                    "query": {
                        "match_all": {}
                    }
                }
            }
        }
    }
}
' | jq
```

以下のような結果が返ってきます。

```
{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0,
    "hits": [
      {
        "_index": "shop",
        "_type": "_doc",
        "_id": "goods_id.3",
        "_score": 0,
        "_source": {
          "type": "goods",
          "name": "原酒三岳",
          "text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。三岳の原酒です。ロックがオススメです。",
          "my_join_field": "goods"
        }
      }
    ]
  }
}
```

ポイントは以下になります。

* must_not に全件取得の has_child を指定する。

なお、以下のようなクエリを実行すると0件も含めた親ごとの子データの数を取得できます。

```
$ curl -X POST 'localhost:9200/shop/_search' -H 'Content-Type: application/json' -d'
{
    "from": 0,
    "size": 10,
    "query": {
        "bool": {
            "should": [
                {
                    "has_child": {
                        "type": "review",
                        "score_mode": "sum",
                        "query": {
                            "match_all": {}
                        }
                    }
                },
                {
                    "bool": {
                        "filter": [
                            { "term": { "type": "goods" } }
                        ],
                        "must_not": {
                            "has_child": {
                                "type": "review",
                                "score_mode": "sum",
                                "query": {
                                    "match_all": {}
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}
' | jq

```

以下のような結果が返ってきます。

```
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 2,
    "hits": [
      {
        "_index": "shop",
        "_type": "_doc",
        "_id": "goods_id.1",
        "_score": 2,
        "_source": {
          "type": "goods",
          "name": "三岳",
          "text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。屋久島は水が美味しく、その水を使って作られています。ロックで飲むことをオススメします。",
          "my_join_field": "goods"
        }
      },
      {
        "_index": "shop",
        "_type": "_doc",
        "_id": "goods_id.2",
        "_score": 1,
        "_source": {
          "type": "goods",
          "name": "屋久の島",
          "text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。三岳と違ってあまりメジャーではありませんが、なかなか美味しいです。ロックがオススメです。",
          "my_join_field": "goods"
        }
      },
      {
        "_index": "shop",
        "_type": "_doc",
        "_id": "goods_id.3",
        "_score": 0,
        "_source": {
          "type": "goods",
          "name": "原酒三岳",
          "text": "もののけ姫のモデルにもなった、鹿児島県屋久島の焼酎です。三岳の原酒です。ロックがオススメです。",
          "my_join_field": "goods"
        }
      }
    ]
  }
}
```

ポイントは以下になります。

* score_mode に sum を指定する。（全件取得のクエリなので、それぞれの子データのスコアが 1 になり、sum を指定することで結果的に子データの数になる）

### 参考になった記事
[Has child query](https://www.elastic.co/guide/en/elasticsearch/reference/7.2/query-dsl-has-child-query.html)
[Elasticsearch get all parents with no children](https://stackoverflow.com/questions/41283486/elasticsearch-get-all-parents-with-no-children)
[How to sort parents by number of children in Elasticsearch](https://stackoverflow.com/questions/29607136/how-to-sort-parents-by-number-of-children-in-elasticsearch)