0
Help us understand the problem. What are the problem?

posted at

updated at

jqのむだづかいーTSV・CSV篇

JSON処理のコマンドラインツールであるjqは、JSONテキストを1行でちゃくっと解析、変換するときに使うものです。複雑な分岐や制御が絡んできたら、sedawkなど他の文字列処理ユーティリティと組み合わせるのが通例です。単体で複雑なことはしません。

とは言え、jqにも変数定義、forifなどの制御構造、関数定義などプログラミング言語らしき機能が備わっているので、「やればできんじゃねぇ」と野望を募らせてしまうこともあります。

ここではTSV(Tab-Separated Values)およびCSV(Comma-Separated values)データの解析に挑戦します。具体的には、ファイル先頭行のヘッダ(見出し)をキーに、以降のデータ行をそれぞれ値としたオブジェクトを生成します。

たとえば、次のTSVファイル

X	Y	Z
0001	0002	0003
0004	0005	0006

を、次のようなオブジェクトの配列にします。

[
	{"X": "0001", "Y":"0002", "Z":"0003"},
	{"X": "0004", "Y":"0005", "Z":"0006"},
]

これで行位置とキー名から一意に値を取得できるようになります。たとえば、(0からカウントして)1行目のXの値は次の要領で取得します。

$ echo '[{"X":"0004", "Y":0005, "Z":0006}, {"X":"0004", "Y":0005, "Z":0006}]' | jq '.[1]["X"]'
"0004"

オリジナルのお題はarc279氏の「tsv を 1行1件ずつの json にする」です。彼の解法は、ヘッダ部分を読むのにbashのビルトインコマンドreadを使い、得られたヘッダを$ENVを用いてjq内で参照します。Unixらしいツールの連携です。

TSV(タブ区切り)がCSV(カンマ区切り)という以外、本ページとほとんど同じことはshigekimono氏が「[jq] jqでcsvをjsonへ変換」で1年前にやっていました…。というわけで、タブでもカンマでも区切れるように、splitで正規表現を用いるよう変更しました。

しかし、ここはjqをむだづかいする場所です。強引は承知ですべてをjqで記述します。

コード

コード(フィルタファイル)はtsv.jqで、次のGithubから取得できます(短いのでコピペでも十分ですが)。

https://github.com/stoyosawa/jqDoc-public/

フィルタファイルの改行はUnixスタイルのLFだけでなければならないので、保存時に注意してください。Windows流にCRLFだと次のエラーが報告されます。

jq: error: syntax error, unexpected INVALID_CHARACTER (Unix shell quoting issues?)
  at <top-level>, line 1:

フィルタファイルの使い方

これらファイルをjqから呼び出すときは、次のオプションを指定します。

  • -fオプション(--from-file)ーフィルタファイルを読み込みます。
  • -Rオプション(--raw-input)ー入力行をJSONテキストではなく、単一の文字列として読み込みます。
  • -sオプション(-slurp)ー入力JSONテキストをまとめて処理します。これを-Rと組み合わせることで、入力が大きな1文字列として扱われます。

まとめると、次のようになります。

$ jq -sRf tsv.jq

TSVのオブジェクト配列化

入力データはオリジナルのお題に倣って、printfseqpasteから生成します。

$ { printf "c1\nc2\nc3\n"; seq -f '%04g' 30; } | paste - - -
c1      c2      c3
0001    0002    0003
0004    0005    0006
0007    0008    0009
⋮

CSVにするには、paste-d,オプションを加えます。

コードは次の通りです。

split("\n") |
map(select(. != "")) |
map(split("[\\t,]"; "g")) |
.[0] as $header |
[
	.[1:][] |
	[$header, .] |
	transpose |
	map({(.[0]):.[1]}) |
	add
]

行順に説明します(以下、TSVデータはファイルaに収容されているものとします)。

0 -sRオプションで入力されたTSVは次のような1文字列になります。ひとまとめにしないと、個々の行がそれぞれ独立したJSONテキストとして扱われてしまうからです(行をまたいだ構造化がされない)。

$ cat a | jq -sR '.'
"c1\tc2\tc3\n0001\t0002\t0003\n0004\t0005\t0006\n0007\t0008\t0009\n...

1 splitを用いて行単位に分解し、それぞれ要素とした配列を生成します。

$ cat a | jq -sR 'split("\n")'
[
  "c1\tc2\tc3",
  "0001\t0002\t0003",
  "0004\t0005\t0006",
  ⋮
  ""
]

2 余計な空行があるので、それをselectで削除します。配列が入力されるので、その個々の要素を順次処理するのにmapを用います。

$ cat a | jq -sR 'split("\n") | map(select(. != ""))'
[
  "c1\tc2\tc3",
  "0001\t0002\t0003",
  "0004\t0005\t0006",
  ⋮
  "0028\t0029\t0030"
]

3 タブまたはカンマ区切りを、正規表現を用いたsplitでばらします。ここも配列要素単位の処理なので、mapを併用します。

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("[\\t,]"; "g"))'
[
  [
    "c1",
    "c2",
    "c3"
  ],
  [
    "0001",
    "0002",
    "0003"
  ],
  ⋮
]

4 ヘッダは上記配列の0番目の要素なので、.[0]に格納されています。これを変数$headerに退避します。変数定義の... as $xxxはパイプライン処理に影響を与えません。

5 以降で出力されるオブジェクト(たとえば{"c1":"0001", "c2":"0002", "c3":"0003"})を配列に収容するために、配列化の[]で処理全体をくくります。

6 配列スライス.[1:]で1行目以降のデータを取り出します。続く[]でそれぞれの要素をイテレートします。

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("\t")) |  
	.[0] as $header | [ .[1:][] ]'
[
  [
    "0001",
    "0002",
    "0003"
  ],
  [
    "0004",
    "0005",
    "0006"
  ],
  ⋮
]

7 $headerに退避したヘッダ行(の配列)とその時点のデータ要素(.)で構成された配列の配列を生成します。

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("\t")) | 
	.[0] as $header | [ .[1:][] | [$header, .] ]'
[
  [
    [
      "c1",
      "c2",
      "c3"
    ],
    [
      "0001",
      "0002",
      "0003"
    ]
  ],
  ⋮
]

8 transposeを用いて、2行3列の配列の配列を3行2列に変換します(これは考え付かなかった!!)

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("\t")) | 
	.[0] as $header | [ .[1:][] | [$header, .] | transpose]'
[
  [
    [
      "c1",
      "0001"
    ],
    [
      "c2",
      "0002"
    ],
    [
      "c3",
      "0003"
    ]
  ],
  ⋮
]

9 mapを使って、[ヘッダ, 値]の配列からオブジェクトを生成します。外側の{}がオブジェクト化を意味します。キー側の.[0]はカッコ(())でくくらなければなりません。

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("\t")) |
	.[0] as $header | [ .[1:][] | [$header, .] | transpose | map({(.[0]):.[1]}) ]'
[
  [
    {
      "c1": "0001"
    },
    {
      "c2": "0002"
    },
    {
      "c3": "0003"
    }
  ],
  ⋮
]

10 あとは、これらオブジェクトをひとつにまとめます。

$ cat a | jq -sR 'split("\n") | map(select(. != "")) | map(split("\t")) |
	.[0] as $header | [ .[1:][] | [$header, .] | transpose | map({(.[0]):.[1]}) | add ]'
[
  {
    "c1": "0001",
    "c2": "0002",
    "c3": "0003"
  },
  {
    "c1": "0004",
    "c2": "0005",
    "c3": "0006"
  },
  ⋮
]

できあがりです。全体を流します。

$ { printf "c1\nc2\nc3\n"; seq -f '%04g' 30; } | paste - - - | jq -sRf tsv.jq
[
  {
    "c1": "0001",
    "c2": "0002",
    "c3": "0003"
  },
  {
    "c1": "0004",
    "c2": "0005",
    "c3": "0006"
  },
  {
    "c1": "0007",
    "c2": "0008",
    "c3": "0009"
  },
  {
    "c1": "0010",
    "c2": "0011",
    "c3": "0012"
  },
  {
    "c1": "0013",
    "c2": "0014",
    "c3": "0015"
  },
  {
    "c1": "0016",
    "c2": "0017",
    "c3": "0018"
  },
  {
    "c1": "0019",
    "c2": "0020",
    "c3": "0021"
  },
  {
    "c1": "0022",
    "c2": "0023",
    "c3": "0024"
  },
  {
    "c1": "0025",
    "c2": "0026",
    "c3": "0027"
  },
  {
    "c1": "0028",
    "c2": "0029",
    "c3": "0030"
  }
]

おわりに

Unixユーティリティを使えば、コードはもっと見やすくなります(分割統治というやつです)。でも、jq魂がうずいてしまったものは仕方ありません。よいお題を提供してくださったarc279氏に感謝。

参考

image.png

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
0
Help us understand the problem. What are the problem?