下記のようなJSONファイルがあるとする。
{"会員名" : "文具 太郎",
"購入品" : [ "はさみ",
"ノート(A4,無地)",
"シャープペンシル",
{"取寄商品" : "替え芯"},
"クリアファイル",
{"取寄商品" : "6穴パンチ"}
]
}
予め取り出すべきデータへのパス(下の例だと."会員名"
や."購入品"[0]
など)が分かっている場合、JSONからCSVやTSVを作るのは容易だ。
しかし、それがわからないときにはどうするか?
ノードを特定する情報(パス)とその値をスペースなり、カンマなりタブなりで並べた値を各行とするファイルを作りたくなる。
例えばこんなような。
.会員名 文具 太郎
.購入品[0] はさみ
.購入品[1] ノート(A4,無地)
.購入品[2] シャープペンシル
.購入品[3].取寄商品 替え芯
.購入品[4] クリアファイル
.購入品[5].取寄商品 6穴パンチ
その上でgrep
とかcut
とかuniq
とかwc
とかで色々したいわけだ。
例えばはさみを買った会員は何人いるかな〜とかそういう作業だ。
既存の方法
Qiita内の人気記事に「jq、xmllintコマンドさようなら。俺はパイプが好きだから」というのがある(以下、本稿内では「さようなら記事」と呼ぶ)。
実を言えば例にあげているデータもここから拝借したものである。
めんどうなので、この方法の問題点には立ち入らない。
本稿で紹介する方法
「さようなら記事」によると正味69行のスクリプト(長大なawkワンライナーが一行含まれており実質400行だと思うが・・・)で、この目的を達することができるそうだが、実のところjq
では本来一行でできることだ。
jq -r -c '. as $content|paths(scalars)|. as $path|tostring|. as $ps|$content|[$ps,getpath($path)]|@tsv' <JSON_FILE>
JSON_FILEが各行に一個のJSONを格納している場合は-s
オプションをつけて欲しい。
ただし、得られる出力はこのようになる。
["会員名"] 文具 太郎
["購入品",0] はさみ
["購入品",1] ノート(A4,無地)
["購入品",2] シャープペンシル
["購入品",3,"取寄商品"] 替え芯
["購入品",4] クリアファイル
["購入品",5,"取寄商品"] 6穴パンチ
左側の配列は何かと言うと、jq Manual (development version)ではPATHS
と書かれている配列の一要素で入力内の位置を示す「パス」だ。
(PATHS
は二次元配列になっており、各要素たる配列は入力のJSONノード内での特定の位置を表している)
この配列は、getpath
・setpath
の引数、paths
の出力などに用いられている。
つまりjq
ではドキュメント内の位置を示すやり方が2つあるのだ。
- 一つは普段、コマンドラインで使う
."購入品"[0]
のような記法。(path_expression
と呼ばれる) - もう一つは上に示した形式"array representation"(配列形式パスとでも呼んでおこう)
しかし、この配列形式パスを文字列化してしまうと、もう一度jq
でパースしないとパスを表す情報として使えないし不便だ。
だから."会員名"
や."購入品"[0]
のように、jq
に与えられる形式で書き出したいものだ。
そして問題は前者から後者の変換はできるが、その逆はできないことなのだ。(Issue-1949 of jq)
少なくとも令和元年12月現在では。
「できない」と言ったが、それはjq
に最初からついているビルトインではできないというだけで、自分でjq
の関数を書けばできる。
echo '{"会員名" : "文具 太郎",
"購入品" : [ "はさみ",
"ノート(A4,無地)",
"シャープペンシル",
{"取寄商品" : "替え芯"},
"クリアファイル",
{"取寄商品" : "6穴パンチ"}
]
}' | jq -r -c 'def path2pexp($v):
$v | reduce .[] as $segment (""; . + ($segment | if type == "string" then ".\"" + . + "\"" else "[\(.)]" end));
. as $content|paths(scalars)|. as $p|path2pexp(.)|. as $pexp|$content|[$pexp,getpath($p)]|@tsv'
上記のコマンドラインは以下の出力を与える。
."会員名" 文具 太郎
."購入品"[0] はさみ
."購入品"[1] ノート(A4,無地)
."購入品"[2] シャープペンシル
."購入品"[3]."取寄商品" 替え芯
."購入品"[4] クリアファイル
."購入品"[5]."取寄商品" 6穴パンチ
タブの左側はそのままjq
に渡すことができる文字列になっている。
解説
上述のコマンドラインのうち、ここに掲げる2行が配列形式パスをpath_expressionに変換する関数である。
これは上記のIssue-1949 of jqに投稿されたコードスニペットを私が改良したものである。
1-2行目
def path2pexp($v):
$v | reduce .[] as $segment (""; . + ($segment | if type == "string" then ".\"" + . + "\"" else "[\(.)]" end));
変数$segment
に代入される配列形式パスの各要素を左から順に繋いでいく。ただし、型が文字列のときにはダブルクォートで括る
3行目
. as $content|paths(scalars)|. as $p|path2pexp(.)|. as $pexp|$content|[$pexp,getpath($p)]|@tsv'
ビルトイン関数paths
に同じくビルトインscalars
を与え、文書内すべてのスカラー(非オブジェクト、非配列)への配列形式パスを得る。
各配列形式パスを$p
に代入(. as $p
)。これをpath2pexp
によりpath_expressionに変換したものを$pexp
に代入(path2pexp(.)|. as $pexp
)
あとは$pexp
とそれに対応する位置にある値をgetpath($p)
で得て配列に格納し、TSVに変換する。([$pexp,getpath($p)]|@tsv
)
まとめに代えて
まとめに代えて以下を指摘しておく。
- そもそもJSONはその仕様上、行指向のデータとも言える。
-
jq
は入力をJSONとし、出力は整形されたJSONまたは行指向のデータ(JSON、CSV、TSV等)である。 -
jq
はC99でコンパイルできる(試したことはないがそういうことになっている) - JSONをUNIXやUNIX-likeなOSで使うなら
jq
の文法は覚えた方がいい。