巨大JSONも怖くない！欲しいキーのパスを再帰的に検索できるツールを作ってみた

# TL; DR

[jq](https://stedolan.github.io/jq/) を使って、スキーマ内のキーを再帰的に検索するコマンドを作った

https://github.com/Syuparn/jq-searchkey

```bash
# JSON内から "name" のキーを再帰的に検索
$ cat sample.json | jq-searchkey name
.name
.friends[0].name
.friends[1].name
.company.name
```

# はじめに

大きなJSONを扱っていると、よく「**この情報どこに格納されているんだろう？**」と探し回ることがあります。
その場限りで値を抜き出すだけなら以下のコマンドで見つかります [^1] が、今後も参照するのであればパスも知っておきたいです。

```bash
# 会社名が知りたいから、nameキーを探せばよい？？

# とりあえず取れた！（...結局キーはどこにあったんだろう...まあいいか）
$ cat test/sample.json | jq '.. | .name? | values'
"Taro"
"Hanako"
"John"
"Hogehoge industry"

# さらに力業、grepで抜き出す！（完全にやっつけ仕事）
$ cat test/sample.json | grep name
  "name": "Taro",
      "name": "Hanako",
      "name": "John",
    "name": "Hogehoge industry",
```

そこで、JSONスキーマからキーを再帰的に名前検索するコマンドを作ってみました。

# 作ったもの

https://github.com/Syuparn/jq-searchkey

「はじめに」のJSONの全文はこちらです。nameが入れ子のオブジェクト内部にもあります。

```json:sample.json
{
  "name": "Taro",
  "age": 22,
  "country": "Japan",
  "friends": [
    {
      "name": "Hanako",
      "country": "Japan"
    },
    {
      "name": "John",
      "country": "USA"
    }
  ],
  "company": {
    "name": "Hogehoge industry",
    "country": "Japan"
  }
}
```

`jq-searchkey` を使えば、これらすべてのパスを一覧表示できます。

```bash
# JSON内から "name" のキーを再帰的に検索
$ cat sample.json | jq-searchkey name
.name
.friends[0].name
.friends[1].name
.company.name
```

次回以降はこのパスを使えばほしい情報が一発で取得できます。もう大きなJSONをブラックボックスと思う必要はありません！

```bash
# 欲しい情報が会社名だった場合
$ cat sample.json | jq .company.name
"Hogehoge industry"
```

# しくみ

実行ファイルの中身はbashファイルで、名前の通り内部でjqを実行しています。

```bash:jq-searchkey
#!/bin/bash

# jqのモジュールは相対パスで指定されるので, 実行前にこのbashファイルと同じディレクトリへ移動
cd $(dirname $0)

# jqのプログラム実行
# `cat -` で標準入力を受け取り、 `--arg key $1` でコマンドライン引数（キー名）を変数 $key に代入
# `-r` で邪魔な "" を外して見やすくしている
cat - | jq --arg key $1 -r -f main.jq
```

プログラムは2つの要素で構成されています。

1. キーを再帰的に探索
2. キーのパスを読みやすい形に整形

```jq:main.jq
import "search_keys" as sk;

. | sk::search_keys_by($key) | .[] | sk::humanize_key
```

## 1. キーを再帰的に探索

組み込み関数 `path` を使うことで、引数に該当するパスを配列形式で取得可能です（[公式リファレンス `path(path_expression)`](https://stedolan.github.io/jq/manual/)）。

```bash
$ cat test/sample.json | jq 'path(.friends[0])'
[
  "friends",
  0
]
```

再帰下降（Recursive Descent）`..` を使えば、全パスを再帰的に取得できるので、ここから欲しいキー名を含むものだけ絞り込めばキー一覧が取得できます。

```bash
$ cat test/sample.json | jq -c 'path(..)'
[]
["name"]
["age"]
["country"]
["friends"]
["friends",0]
["friends",0,"name"]
["friends",0,"country"]
["friends",1]
["friends",1,"name"]
["friends",1,"country"]
["company"]
["company","name"]
["company","country"]

# キー名で絞り込み（配列にcontainsを使う場合、引数も[]で囲む必要があるので注意！）
$ cat test/sample.json | jq -c 'path(..) | select(contains(["name"]))'
["name"]
["friends",0,"name"]
["friends",1,"name"]
["company","name"]
```

あとはこれを関数化します。

```jq:search_key.jq
# プログラムで使っている関数
def search_keys_by($name):
  [path(..) | select(contains([$name]))];
```

## 2. キーのパスを読みやすい形に整形

続いて、得られた配列をパス文字列の形式に変換します。こちらは組み込み関数ではできないようです。

```jq:search_key.jq
def humanize_key:
  . as $key |
  map(if type == "string" then ".\(.)" else "[\(.)]" end) |
  join("");
```

以下の記事を参考にさせていただきました。

https://qiita.com/dakusui/items/873f8c7bc39b0369eaeb

# 作ってみてはまったところ

~~ここからは完全に余談です~~

## 引数の扱い

jqの関数は、入力と通常の引数を区別する（**第一引数が入力にはならない！**）ので注意が必要です。

```jq:間違い！
# 入力を受け取りたい？
def my_func(arg):
  "this is input: \(arg)";

.a | my_func
```

```bash
$ echo '{"a": 1}' | jq -f foo.jq
jq: error: my_func/0 is not defined at <top-level>, line 4:
.a | my_func
jq: 1 compile error
```

```jq:正しい書き方
# 入力は引数ではなく `.` で参照
def my_func:
  "this is input: \(.)";

.a | my_func
```

```bash
$ echo '{"a": 1}' | jq -f foo.jq
"this is input: 1"
```

また、**引数の区切り文字は `,` ではなくて `;`** です。

```jq
def my_func(a; b; c):
  a + b + c;

my_func(1; 2; 3)
```

## モジュールのパス指定

モジュールは相対パス指定になるので、同じディレクトリであれば単にファイル名で `import` できました。

```jq:main.jq
# search_keys.jq を import
# (Python同様、asでモジュール名を変更可能)
import "search_keys" as sk;

. | sk::search_keys_by($key) | .[] | sk::humanize_key
```

また、`import` の代わりに `include` を使うとモジュールのprefixを省略できます。

```jq:main.jq
include "search_keys";

. | search_keys_by($key) | .[] | humanize_key
```

## ユニットテスト

jqに組み込みのテストライブラリは無かったので（探せばある？）、テストファイルを手書きしました。
テーブルユニットテストの形式で書いています。

```jq:search_keys_test.jq
def test_humanize_key:
  [
    {
      title: "top level key",
      key: ["foo"],
      expected: ".foo"
    },
    {
      title: "top level array element",
      key: [2],
      expected: "[2]"
    },
    {
      title: "nested key",
      key: ["foo", "bar", "baz"],
      expected: ".foo.bar.baz"
    },
    {
      title: "key inside array",
      key: ["foo", 1, "baz"],
      expected: ".foo[1].baz"
    }
  ] as $tests |
  $tests[] as $t | (
    $t.key | humanize_key | to_be($t.expected; "title '\($t.title)': wrong output")
  );

# テスト実行（忘れずに！）
test_humanize_key
```

関数に複数行の文を入れられないため、すべてパイプで数珠つなぎにする必要があります。
ローカル変数を作成する場合はパイプの途中で `as $var` を利用しています。

また、GitHub Actions等で使うことを考えると、テストが失敗したら0以外の終了ステータスを吐いてほしいです。そこで、テスト関数に `halt_error(1)` を指定し、落ちた場合 `exit 1` するようにしています。

```jq:util_test.jq
# 一致比較するテスト関数
def to_be($expected; $msg):
  . as $actual |
  # 一致しない場合、メッセージを出して exit 1
  if $actual != $expected then (
    [
      $msg,
      "expected: `\($expected)`",
      "actual  : `\($actual)`",
      "" # 改行用
    ] | join("\n") |
    halt_error(1) # 入力（エラーメッセージ）を表示してただちに exit 1
  ) else empty end; # 一致したら何もしない
```

jqとは関係ありませんが、テストを流すためのMakefileでも終了ステータスを拾えるように細工しています。

```makefile:Makefile
# ユニットテスト実行
.PHONY: test/unit
test/unit:
#	for文内の終了ステータスは拾えないため、jqが異常終了した場合 `|| exit 1` でMakefile自体も異常終了するようにしている
	@for f in $$(find *_test.jq); do \
		echo $$f; \
		jq -n -r -f $$f || exit 1; \
		echo; \
	done
```

参考にさせていただいた記事

http://panda.holy.jp/2015/08/340/

## インストーラ作成

jqはスクリプト言語なのでシングルバイナリへコンパイルされません。実行にはリポジトリ丸ごとダウンロードする必要があります。
そこで、少しでも導入が楽になるようインストーラを作成しました（bashが使えるLinuxなら動くはずです）。

```bash
# インストールはこれだけ！
curl -L https://raw.githubusercontent.com/Syuparn/jq-searchkey/main/install.sh | bash
```

中身はこんな感じです。

```bash:install.sh
#!/bin/bash

# 安全に実行するためのおまじない
# -e: コマンドの終了ステータスが0でないなら即時終了
# -u: 代入されていない変数を参照したらエラー
# -x: 実行したコマンドとその引数を表示.
set -eux

LOCATION=~/.jq-searchkey
CONFIG_FILE=~/.bashrc

mkdir -p $LOCATION
# 実行に必要な諸々のファイルをダウンロードするため、リポジトリをまるごとクローン
git -C $LOCATION clone https://github.com/Syuparn/jq-searchkey.git
chmod +x $LOCATION/jq-searchkey/jq-searchkey

# パスを通す
cat <<EOS >> $CONFIG_FILE
# https://github.com/Syuparn/jq-searchkey
export PATH=$LOCATION/jq-searchkey:\$PATH
EOS
source $CONFIG_FILE
```

 `git clone` の `-C` オプションでディレクトリを指定できることを初めて知りました...

 (参考にさせていただいた記事)

https://stackoverflow.com/questions/651038/how-do-i-clone-a-git-repository-into-a-specific-folder

# おわりに

以上、JSONのキーを再帰的に探すツールの紹介でした。

業務で使いたかったのでちゃちゃっと作る予定でしたが、欲をかいてテストやインストーラも作成したら意外と時間がかかってしまいました（~~jq力は少しだけ上がった気がします~~）。

少しダウンロードが面倒なので、今後シングルバイナリ化にも挑戦してみたいと思います。ここまでお読みいただきありがとうございました！

[^1]: 仕組みとしては、（１）再帰下降（Recursive Descent） `..` で入れ子のオブジェクト、配列の中身を再帰的に取りだし、（２）`.name?` で `name` キーを抽出（なければ `null`）し、（３） `values` で `null` を除外しています  ([参考](https://stedolan.github.io/jq/manual/))。