💻

JSON Lines を jq で unnest する

2022/08/06に公開

JSON Linesデータをjqでunnestする方法を調べた。unnest対象のプロパティ名がfooであるとき次のようなフィルタを指定すれば良い:

del(.foo) as $x | $x + {foo: .foo[]}

unnestという操作

ここでは以下のような操作をunnestと呼ぶ。

あるプロパティの値が配列になっているデータに対して、その配列の要素それぞれが1行になるよう展開する操作

正直、日本語で説明するのが難しいので例を見た方が良いと思う。具体例としては、次のようなJSON (JSON Lines)データを:

before
{"user":"Alice","sns":[{"type":"twitter","id":"@alice"}]}
{"user":"Bob",  "sns":[{"type":"github","id":"bob"},{"type":"twitter","id":"@bob"}]}

次のように変換する処理をunnestと呼ぶ:

after
{"user":"Alice","sns":{"type":"twitter","id":"@alice"}}
{"user":"Bob",  "sns":{"type":"github", "id":"bob"}}
{"user":"Bob",  "sns":{"type":"twitter","id":"@bob"}}
  • 上の例ではオブジェクトの配列をunnestしているけれど、数値の配列や文字列の配列(スカラーの配列)であっても同様に処理できる
  • snsの階層は不要なので中身を引き上げたい(type, iduserと同じ階層のプロパティにしたい)というケースもあると思うけれど、データによってはプロパティ名が重複して問題になるかもしれないため、ここでは汎用的に使える方法を書いている
    • もっともunnest後にsnsの中身を引き上げるのは簡単なので、もう一度jqでフィルタしても良いのではと思う

動作確認環境

詳細説明

まず、unnestは次のような処理だと考える:

  1. unnestしたいプロパティの値(配列)の各要素を取り出して1行1行に展開する
  2. その結果に、その他のプロパティを結合する

少し複雑なので、例のデータを使いつつ順を追って解いていく。まずステップ1については、そのための機能である.[] (Array/Object Value Iterator)が使える:

$ cat data.json | jq -c '.sns[]'
{"type":"twitter","id":"@alice"}
{"type":"github","id":"bob"}
{"type":"twitter","id":"@bob"}

ただし.[]は要素を取り出すため「unnest対象のプロパティの値」という形ではなくなっている。これでは後で結合するときに都合が悪いため{} (Object Construction)で元の構造を再現しておく:

$ cat data.json | jq -c '{sns: .sns[]}'
{"sns":{"type":"twitter","id":"@alice"}}
{"sns":{"type":"github","id":"bob"}}
{"sns":{"type":"twitter","id":"@bob"}}

続いてステップ2。まず「その他のプロパティ」だけのオブジェクトを作るため、unnest対象のプロパティをdel関数で削除した結果を変数に格納する:

$ cat data.json | jq -c 'del(.sns) as $x | $x'
{"user":"Alice"}
{"user":"Bob"}

これとステップ1の結果を、+ (Addition Operator)で結合する:

$ cat data.json | jq -c 'del(.sns) as $x | $x + {sns: .sns[]}'
{"user":"Alice","sns":{"type":"twitter","id":"@alice"}}
{"user":"Bob","sns":{"type":"github","id":"bob"}}
{"user":"Bob","sns":{"type":"twitter","id":"@bob"}}

以上で完成。

参考:数値や文字列値の配列を展開する場合

まったく同じ考え方で処理できる。たとえば次のようなデータがあったとき:

before
{"user":"Alice","interests":["user-interface", "interactive-design"]}
{"user":"Bob",  "interests":["programming", "data-science"]}

次のように処理できる:

cat data.json | jq -c 'del(.interests) as $x | $x + {interests: .interests[]}'
{"user":"Alice","interests":"user-interface"}
{"user":"Alice","interests":"interactive-design"}
{"user":"Bob","interests":"programming"}
{"user":"Bob","interests":"data-science"}

補足:空配列を unnest すると、その行は消える

unnestする配列に要素が一つも無い、つまり空配列である場合には注意が必要。例えば次のようなデータ (Aliceのsnsが空配列) があったとき:

empty_array
{"user":"Alice","sns":[]}
{"user":"Bob",  "sns":[{"type":"github","id":"bob"},{"type":"twitter","id":"@bob"}]}

プロパティsnsを対象にunnestをすると次のように「Aliceの行が無い」結果になる:

$ cat data.json | jq -c 'del(.sns) as $x | $x + {sns: .sns[]}'
{"user":"Bob","sns":{"type":"github","id":"bob"}}
{"user":"Bob","sns":{"type":"twitter","id":"@bob"}}

配列の要素を行に展開するということは、1つのオブジェクト(1行)から配列の要素と同じ数のオブジェクトが作られるということ。つまり要素数が0である空配列の場合その行が消えてしまう。この連絡先データの例で言うとAliceの連絡先データが消えてしまったワケだけれど、おそらくそれが許されるケースの方が少ないのではないかと思う。

対策。このようにunnestによって行が消失することを防ぎたければ、length関数などで空配列かどうか確認して処理を場合分けするif-then-else-endを書く必要がある。対策例を2つ、次に書いておく。

  1. unnest結果の該当プロパティの値をnullにする

    $ cat contacts.jsonl |
        jq -c 'del(.sns) as $x | $x + {sns: (if .sns | length > 0 then .sns[] else null end)}'
    {"user":"Alice","sns":null}
    {"user":"Bob","sns":{"type":"github","id":"bob"}}
    {"user":"Bob","sns":{"type":"twitter","id":"@bob"}}
    
  2. unnest結果のオブジェクトに該当プロパティが存在しないようにする

    $ cat contacts.jsonl |
        jq -c 'del(.sns) as $x | $x + if .sns | length > 0 then {sns: .sns[]} else {} end'
    {"user":"Alice"}
    {"user":"Bob","sns":{"type":"github","id":"bob"}}
    {"user":"Bob","sns":{"type":"twitter","id":"@bob"}}
    

Discussion