Open7

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

okuokuokuoku

データベースの前処理/後処理言語は今のところMongoDBのaggregateにしている。後処理はまぁ仕方無いとして、前処理はRasPiとかでもできた方が良いので jq あたりに置き換えたい気がする。

ただ、GPS航跡とかテストレポートみたいにXMLなデータを扱いたいことがまだまだ有り、そういう場合は xq に頼ることになるんだけどこれが地味に難しい。。

okuokuokuoku

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 では基本的に入力データの型が安定していることを前提にしているので、こういうデータを処理するためには配列に統一してしまうのが簡単だと思う。

okuokuokuoku

Advanced features を頑張って使う

まさにコレ用の機能がドキュメントのAdvanced featuresにある。

Symbolic Binding Operator は要するにSchemeとかJavaScriptとかMongoDBにおける letforEach を合わせたような機能で、後続のフィルタで値を参照できるようにする。

Destructing Alternative Operatormatch に相当する機能性を持った 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"]]

https://jqplay.org/s/deZ9cA0VHoA

... やりたい事はできてるけど、これちょっと複雑過ぎない。。?

okuokuokuoku

Alternative Operatorだけを使って書いてみる

↑ のコードでは、単に isArray するためだけに Destructin Alternative Operator を使っている。ただ、実際には

  • オブジェクトに @code メンバがある → 配列でない
  • それ以外 → たぶん配列

として処理して良いので、その方向で書いてみると:

[.entries.entry[] as $it | $it.param["@code"]? // [$it.param[]["@code"]]]

https://jqplay.org/s/ZewZs7kdRgz

これでも同じ結果になる。 $it.param["@code"]?// の間のスペースに注意。

... そもそも最初のbindingも無くて良いか。

[.entries.entry[] | .param["@code"]? // [.param[]["@code"]]]

https://jqplay.org/s/9s-a_tETyrZ

これも同じ結果になる。

EDIT: いやコレだと "a" が配列にできないのか。。

okuokuokuoku

type 演算子を使う

もっとstraightforwardに type 演算子を直接使ってみる。。

[.entries.entry[].param | if type=="array" then . else [.] end | [.[]["@code"]]]

https://jqplay.org/s/Bae6kCZAjh6

まぁこれはできて当たり前だよな。。

ちなみに has 演算子はこの目的に使えない。

[.entries.entry[].param | if has("@code") then [.] else . end | [.[]["@code"]]]
jq: error (at <stdin>:21): Cannot check whether array has a string key
okuokuokuoku

事前変換により配列に統一する

いちいち条件を書くのは面倒なので、 array になっていないものをとりあえず array に揃える方向で考えてみる。

def toarray(x):
  getpath(x) | [.];

[path(.entries.entry[].param | objects)] as $tgt | 
reduce $tgt[] as $e (.;setpath($e; toarray($e)))

https://jqplay.org/s/gsSRO1NHoFf

jqは、Json中の値をパスで参照できる。これを 一旦配列に出力し、 reduce で入力を書き換えるループを回す。

一旦配列に出力するのがミソで、

path(.entries.entry[].param | objects) as $tgt | 
reduce $tgt as $e (.;setpath($e; toarray($e)))

のように $tgt を配列ではなく多値にすると、 reduce 自体が複数回実行され、出力する値もそのぶんの数になってしまう。

okuokuokuoku

集計タスクをする

これで、

<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)

https://jqplay.org/s/eGMTjL62FLb

で、 param の多い順に並べるといった処理が可能になる。 ...XSLTとどっちがマシかな。。