💻

JSON Linesの平坦化(ネスト解除)

2024/07/01に公開

はじめに

JSON Linesデータの平坦化 (flattening) またはネスト解除 (unnesting) と呼ばれる変換処理について整理したのでまとめておく。以下2つのツールの場合を例として取り上げる。

  • jq 1.7
  • Nushell 0.93
    • ただし use std formats * を実行していることを前提とする [1]

なお以下のツールでも実現できないか試したのだけれど難しそうだったので取り上げない。

平坦化とは

ここではJSON Linesにおける平坦化を次のような変換処理と定義する。

値が配列またはオブジェクトであるプロパティを指定して、その値を取り出すことでオブジェクトの構造を平坦 (flat) にする変換

配列の場合とオブジェクトの場合とで処理内容は違うのだけれど、基本的に平坦化処理は同じように書ける(jqでもNushellでも)。ただエッジケースの挙動などで細かい違いがあるので、別々に書いていく。

配列の平坦化

一般論

値が配列であるプロパティを指定したJSON Linesの平坦化は、次のような変換処理だと説明できる。

  1. 1つのオブジェクトに対して、値が配列であるようなプロパティを指定して実行する。(指定したプロパティをP、それに設定された配列をAとする)
  2. 平坦化されたオブジェクトは、指定されたプロパティの値である配列Aの長さと同じ数のオブジェクトに置き換わる。
  3. 変換後の各オブジェクトのプロパティPには、配列Aの要素が順番に割り当てられる。
  4. 生成されるオブジェクトのP以外のプロパティは、もともとのオブジェクトのプロパティをそのまま引き継ぐ。

具体例を出すと、次のようなJSON Linesに対してプロパティbを指定した平坦化を行うと:

example_a.jsonl
{"a": 1, "b": [10, 100]}
{"a": 2, "b": [20, 200]}

次のような変換結果になる。

期待される平坦化の結果
{"a": 1, "b": 10}
{"a": 1, "b": 100}
{"a": 2, "b": 20}
{"a": 2, "b": 200}

jqの場合

jqで「値が配列であるプロパティを指定した平坦化」を汎用的に行うには次の考えで処理すれば良い。

  1. del関数で値が配列であるプロパティを削除し、
  2. 値が配列であるプロパティをArray/Object Value Iterator (.[])で行に展開し、
  3. 両者をAddition operator (+)で結合する。

具体的には次のような感じになる。

$ cat example_a.jsonl | jq -c 'del(.b) as $x | $x + {b: .b[]}'
{"a":1,"b":10}
{"a":1,"b":100}
{"a":2,"b":20}
{"a":2,"b":200}

個人的には「$x{b: .b[]は行数が違うのにナンデ結合できるの?」と感じてしまい気持ち悪いのだけれど、まあその気持ちは横に置いておく。また、事前にプロパティのラインナップが分かっていて、さらにプロパティの数も少ないようならアドホックに {a: .a, b: .b[]}とやる手もある。これは、もっと短く{a, b: .b[]}と書くこともできる。

このパターンの処理については以前に書いた記事があるので参考までにリンクしておく。

https://zenn.dev/sgryjp/articles/unnest-json-lines-with-jq

Nushell の場合

Nushellで配列のオブジェクトを平坦化するにはflattenコマンドを使えば良い。

> open example_a.jsonl | flatten b | to jsonl
{"a":1,"b":10}
{"a":1,"b":100}
{"a":2,"b":20}
{"a":2,"b":200}

簡単明解。

オブジェクトの平坦化

一般論

値がオブジェクトであるプロパティを指定したJSON Linesの平坦化は次のような変換処理だと説明できる。

  1. 1つのオブジェクトに対して、値がオブジェクトであるようなプロパティを指定して実行される。
  2. 指定したプロパティに格納されている子オブジェクトのプロパティが、そのオブジェクトを格納している親オブジェクトのプロパティと併合(merge)される。
  3. 指定したプロパティが削除される。

具体例を出すと、次のようなJSON Linesに対してプロパティbを指定した平坦化を行うと:

example_b.jsonl
{"a": 1, "b": {"x": 10, "y": 100}}
{"a": 2, "b": {"x": 20, "y": 200}}

次のような変換結果になる。

期待される平坦化の結果
{"a": 1, "x": 10, "y": 100}
{"a": 2, "x": 20, "y": 200}

もし名前衝突が起こる、つまり子オブジェクトのプロパティと同じ名前のプロパティが親オブジェクトにある場合、親子どちらの値が優先されるのかは処理系や処理の書き方によって違ってくる。また子オブジェクトのプロパティをリネームしてから併合すれば名前衝突を避けられる(可能性が高い)。そのため、名前衝突が起こった場合の動作や、名前衝突を避けるためのリネームについても触れつつ説明していく。

jq の場合

jqで「値がオブジェクトであるプロパティを指定した平坦化」を行う方法は、配列のオブジェクトを平坦化する場合と同じになる。つまり親オブジェクトから子オブジェクトが設定されたプロパティを削除して、子オブジェクトを親オブジェクトにAddition operator (+)で併合すれば良い。

$ cat example_b.jsonl | jq -c 'del(.b) as $x | $x + .b'
{"a":1,"x":10,"y":100}
{"a":2,"x":20,"y":200}

平坦化で名前衝突が起こるとjqは一方で他方の値を上書きするのだけれど、+演算子は右辺に来た方のプロパティを優先する仕様となっている:

$ echo '{"a":1,"b":{"a":10}}' | jq -c 'del(.b) as $x | $x + .b'
{"a":10}  # 子オブジェクトの値が採用される
$ echo '{"a":1,"b":{"a":10}}' | jq -c 'del(.b) as $x | .b + $x'
{"a":1}  # 親オブジェクトの値が採用される

この仕様があるため、親子のうち優先したい方を+の右辺に書くことで好きな方を優先させることができる。

平坦化と同時に子オブジェクトのプロパティ名を一括リネームするにはwith_entries関数を使うと良い。例えばb_という接頭辞を付ける場合は次のようになる:

$ echo '{"a":1,"b":{"x":10,"y":100},"x":9}' |
    jq -Sc 'del(.b) as $x | $x + (.b | with_entries(.key = "b_" + .key))'
{"a":1,"b_x":10,"b_y":100,"x":9}

Nushell の場合

Nushellで「値がオブジェクトであるプロパティを指定した平坦化」を行うには、やはりflattenコマンドを使えば良い。

$ open example_b.jsonl | flatten b | to jsonl
{"a":1,"x":10,"y":100}
{"a":2,"x":20,"y":200}

平坦化で名前衝突が起こるとNushellは少し気持ち悪い動作をする。まず平坦化対象の列よりも前に名前衝突するプロパティがあると、自動的に子オブジェクトの列をリネームして衝突を回避してくれる。ただしリネームのパターンは{親の列名}_{子の列名}に固定となっており、もしリネーム後の名前までもが衝突してしまった場合は自動リネームが行われず親オブジェクト値が優先される(なんだか中途半端だ)。続いて、平坦化対象の列よりも後に名前衝突するプロパティがあると、親オブジェクトの値が優先される。先に説明したような列名の自動変更は、列順に依存して行われたり行われなかったりするようだ(バグっぽいのでIssueを作っておいた)。なお、もし子オブジェクトの値を優先させたければrejectコマンド等で親オブジェクトから該当プロパティを削除しておけば良い。実際の動作例は次のような感じ。

$ '{"a":1,"b":{"a":10}}' | from json -o | flatten b | to jsonl
{"a":1,"b_a":10}  # 自動的にリネームして衝突回避してくれる
$ '{"b":{"a":10},"a":1}' | from json -o | flatten b | to jsonl
{"a":1}  # 問答無用で親オブジェクトの値が優先される

平坦化と同時に子オブジェクトのプロパティ名を一括リネームするには、jqの場合と同様に親オブジェクトの平坦化したいプロパティを削除したものに、リネームした子オブジェクトを併合すれば良い。少しタイプ量が増えてワンライナーっぽくなくなってくるのだけれど、次のように書ける。

$ '{"a":1,"b":{"x":10,"y":100},"x":9}' |
    from json -o |
    do {
        let parent = $in;
        let child = ($parent.b | rename --block {"b_" + $in});
        ($parent | reject b) | merge $child
    } |
    to jsonl
{"a":1,"x":9,"b_x":10,"b_y":100}

なお先に記した通り列の並び順次第ではflattenコマンドが勝手にリネームしてくれる。しかし列順次第でリネームされたりされなかったりするので、アドホックな分析であれば良いけれど、大量データを処理するならキチンとリネーム処理を書いた方が良さそうだ。

以上。

脚注
  1. Nushell 0.93.0以降、JSON Lines (NDJSON)を扱うのに便利な関数が標準ライブラリとして同梱されている(from jsonlfrom ndjsonto jsonlto ndjsonの4つ)。これらはuse std formats *を実行すると使えるようになる。 ↩︎

Discussion