jqで配列だったり配列じゃなかったりするものを扱う

データベースの前処理/後処理言語は今のところMongoDBのaggregateにしている。後処理はまぁ仕方無いとして、前処理はRasPiとかでもできた方が良いので jq
あたりに置き換えたい気がする。
ただ、GPS航跡とかテストレポートみたいにXMLなデータを扱いたいことがまだまだ有り、そういう場合は xq
に頼ることになるんだけどこれが地味に難しい。。

XMLをJsonに変換すると配列だったり配列じゃなかったりする問題
例えば、
<entries>
<entry>
<param code="a" />
</entry>
<entry>
<param code="b" />
<param code="c" />
</entry>
</entries>
のように要素が "0つ または それ以上" となっているような構造を持つ場合、 xq
(がバックエンドとして使用している xmltodict )では、値が配列になったり、ならなかったりする。
$ xq . test.xml | yq . --yaml-output
entries:
entry:
- param:
'@code': a
- param:
- '@code': b
- '@code': c
jq
では基本的に入力データの型が安定していることを前提にしているので、こういうデータを処理するためには配列に統一してしまうのが簡単だと思う。

Advanced features を頑張って使う
まさにコレ用の機能がドキュメントのAdvanced featuresにある。
Symbolic Binding Operator は要するにSchemeとかJavaScriptとかMongoDBにおける let
と forEach
を合わせたような機能で、後続のフィルタで値を参照できるようにする。
Destructing Alternative Operator は match
に相当する機能性を持った Alternative Operator で、Symbolic Binding Operatorと組み合わせて使う ...と思う(他の用途を思いつかない)。
これらを駆使して値を取り出す jq
式を書くと以下のようになる。
[.entries.entry[] as $it | $it as
{"param": [{"@code":$code_a}]} ?// {"param": {"@code":$code}} |
if $code_a==null then [$it.param["@code"]] else [$it.param[]["@code"]] end]
[["a"],["b","c"]]
... やりたい事はできてるけど、これちょっと複雑過ぎない。。?

Alternative Operatorだけを使って書いてみる
↑ のコードでは、単に isArray
するためだけに Destructin Alternative Operator を使っている。ただ、実際には
- オブジェクトに
@code
メンバがある → 配列でない - それ以外 → たぶん配列
として処理して良いので、その方向で書いてみると:
[.entries.entry[] as $it | $it.param["@code"]? // [$it.param[]["@code"]]]
これでも同じ結果になる。 $it.param["@code"]?
と //
の間のスペースに注意。
... そもそも最初のbindingも無くて良いか。
[.entries.entry[] | .param["@code"]? // [.param[]["@code"]]]
これも同じ結果になる。
EDIT: いやコレだと "a"
が配列にできないのか。。

type
演算子を使う
もっとstraightforwardに type
演算子を直接使ってみる。。
[.entries.entry[].param | if type=="array" then . else [.] end | [.[]["@code"]]]
まぁこれはできて当たり前だよな。。
ちなみに has
演算子はこの目的に使えない。
[.entries.entry[].param | if has("@code") then [.] else . end | [.[]["@code"]]]
jq: error (at <stdin>:21): Cannot check whether array has a string key

事前変換により配列に統一する
いちいち条件を書くのは面倒なので、 array
になっていないものをとりあえず array
に揃える方向で考えてみる。
def toarray(x):
getpath(x) | [.];
[path(.entries.entry[].param | objects)] as $tgt |
reduce $tgt[] as $e (.;setpath($e; toarray($e)))
jqは、Json中の値をパスで参照できる。これを 一旦配列に出力し、 reduce
で入力を書き換えるループを回す。
一旦配列に出力するのがミソで、
path(.entries.entry[].param | objects) as $tgt |
reduce $tgt as $e (.;setpath($e; toarray($e)))
のように $tgt
を配列ではなく多値にすると、 reduce
自体が複数回実行され、出力する値もそのぶんの数になってしまう。

集計タスクをする
これで、
<entries>
<entry id="1">
<param code="a" />
</entry>
<entry id="2">
<param code="b" />
</entry>
<entry id="3">
<param code="c" />
<param code="d" />
</entry>
<entry id="4">
<!-- no params -->
</entry>
</entries>
のようなXMLを
def toarray(x):
getpath(x) | [.];
# Make sure .param is an Array (convert null and object into array)
[path(.entries.entry[].param | objects)] as $tgt
| reduce $tgt[] as $e (.;setpath($e; toarray($e)))
| [path(.entries.entry[].param | nulls)] as $tgt
| reduce $tgt[] as $e (.;setpath($e; []))
# Generate aggregated entries
| .entries.entry
| map({id: .["@id"], count: (.param | length)})
# Sort results
| sort_by(.count)
で、 param
の多い順に並べるといった処理が可能になる。 ...XSLTとどっちがマシかな。。