このブログの更新は Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama

メールでのご連絡は hiyama{at}chimaira{dot}org まで。

はじめてのメールはスパムと判定されることがあります。最初は、信頼されているドメインから差し障りのない文面を送っていただけると、スパムと判定されにくいと思います。

参照用 記事

パイプライン指向JSON処理プログラミング言語 jq

jq(https://stedolan.github.io/jq/)の紹介では、「JSON処理のワンライナー〈一行野郎〉としてめちゃくちゃ便利!」とアピールするのが定番です。もちろんそれは本当で、「めちゃくちゃ便利!」です。が、実は jq は、ワンライナー記述にとどまらない、かなり本格的なプログラミング言語です。

JSON処理のためのDSL〈Domain Specific Language | 領域特化言語〉なので、汎用言語ではありません。しかし、汎用言語が備えている言語機能の一部(関数定義、モジュールシステムなど)を jq も持っています。また jq は、独特で楽しいプログラミング・パラダイム -- “パイプライン指向”に基づいて設計されています。

この記事では、ワンライナーを超えた jq の使い方と、プログラミング言語としての jq の特徴を紹介します。長い記事になってしまったので、一気に読もうとせずに何回かに分けて読むといいかも知れません。

内容:

準備

https://stedolan.github.io/jq/download/ から、プラットフォームごとの実行可能バイナリやソースコードのtarアーカイブを入手できます。実行可能バイナリをダウンロードしましょう。

jq の実行可能バイナリは単一ファイルで、DLL(.dll ファイルや .so ファイル)への依存性はないので、好きな場所に置いてパス(環境変数 PATH)を通せば、それだけで使えます。

jq のマニュアルは https://stedolan.github.io/jq/manual/ で、これに何でも書いてあります。オンライン・プレイグラウンド https://jqplay.org/ があって、とても便利です、おススメ。

VSCodeの拡張機能が3つみつかります。

  1. vscode-jq : https://marketplace.visualstudio.com/items?itemName=dandric.vscode-jq
  2. jq Syntax Highlighting : https://marketplace.visualstudio.com/items?itemName=jq-syntax-highlighting.jq-syntax-highlighting
  3. Visual Code jq playground : https://marketplace.visualstudio.com/items?itemName=davidnussio.vscode-jq-playground

3つとも入れても干渉とかはないようです。

  1. vscode-jq : JSONファイルを編集中に jq に処理させたいときに便利。
  2. jq Syntax Highlighting : 拡張子 .jq のファイルを jq ソースコードとして構文ハイライト表示してくれます。
  3. Visual Code jq playground : オンライン・プレイグラウンドと同様な機能を VSCode 内で実現します。

Visual Code jq playground は、自分で使う jq をダウンロードします。僕の環境だと、
C:\Users\m-hiyama\AppData\Roaming\Code\User\globalStorage\davidnussio.vscode-jq-playground\
に jq.exe がダウンロードされました。理屈の上では、シェルから使っている jq とバージョンが違ってしまうリスクがありますが、現実的には問題が起きそうにないので気にしなくていいでしょう。

コンソールに関する注意

昔はターミナルとコンソールは別なモノでしたが、今どき、ターミナルとコンソールを区別する必然性はないので、「ターミナル」と「コンソール」は同義語として使います。

jq は、シェルのコマンドラインから起動するコンソールアプリケーションです。入力と出力はOSの標準入力/標準出力〈stdin / stdout〉を使います。どのプラットフォームでも jq を使えますが、OS/シェルの影響をまったく受けないわけではありません。

僕は通常、Windows Powershell をシェルに使ってますが、標準入出力に関しては、けっこうトラブルが起きます*1。その原因は:

  1. 文字エンコーディング方式
  2. 先頭のBOM〈Byte Order Mark〉文字
  3. 改行文字(LF か CR+LF か)
  4. ANSIエスケープシーケンス
  5. 使用しているフォント
  6. 稀に、ファイルの末尾〈End Of File〉の改行(あるかないか)

これらは、シェル・プログラムだけでなく、シェルが使用しているコンソールウィンドウの仕様とも関係します。例えば、単独のコンソールウィンドウ内の PowerShell と、Windows Terminal のタブ内の PowerShell では挙動が違います。シェルによって、コマンドラインのクォーティングや特殊文字エスケープの構文が違うのもストレスです。

ここでは、Git for Windows に付属の bash(sh.exe) をシェルに使うことにします(「Mingw-w64/MSYS2 を入れなくても Git for Windows で間に合うみたい」参照); 僕のはちょっと古いです。現時点(2022年12月)での最新版は Git for Windows Version 2.38.1 です。

 > git --version
git version 2.35.1.windows.2

 > which git
C:\Program Files\Git\cmd\git.exe

 > which sh
C:\Program Files\Git\usr\bin\sh.exe

 > sh --version
GNU bash, version 4.4.23(2)-release (x86_64-pc-msys)
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later 

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

キーボードから ^D(CTRL+D)、^Z(CTRL+Z)を打ったときの挙動は、環境により違います。Windows では伝統的に、EOT〈End-of-Transmission | 伝送終了〉に ^D ではなくて ^Z を使います*2。^D は無視されます。PowerShell はそのような挙動をします。

Unix系OSで ^Z を打つと、SIGTSTPシグナルが発生して現在のジョブがバックグラウンドに回ります。^D はEOT〈End-of-Transmission〉ですが、ターミナルソフトウェアが横取りしてしまう可能性もあります。

Git for Windows の sh.exe は、Linux の GNU bash をシミュレートしているので、^Z はジョブ制御です*3。が、^D はEOTにならないようです。cat - > myfile.txt のような場合のキーボード入力を終了〈End-of-Transmission〉するのが難しいですね。ターミナル/コンソールを使うのは意外に大変*4

簡単な例

検索キーワード「jq JSON」とかでGoogle検索すると、jq のワンライナーでJSON処理をする例が出てくるでしょう。なので、そのテの話はここではしません

jq は、sed や AWK を置き換える目的も持つので、テキスト処理もできます。また、数値計算にも使えます。

"Hello, World." というJSON文字列から、カンマ、ピリオド、空白(番号 0x20 の文字)を取り除いて大文字化するには、次のjqコードを使います。今は、jq の構文は気にしなくていいですが、gsub は global substitution で、正規表現[,. ]にマッチする部分文字列を空文字列で置換します。ascii_upcase は「小文字→大文字」変換です。

gsub("[,. ]"; "") | ascii_upcase

jqコードをシェルのコマンドラインから実行するには次のようにします。

$ echo '"Hello, World."' | jq 'gsub("[,. ]"; "") | ascii_upcase'
"HELLOWORLD"

入力データであるJSON文字列 "Hello, World." と処理を記述するjqコード gsub("[,. ]"; "") | ascii_upcase は、シングルクォーテーションマークでクォートする必要があります。シェルのパイプ記号とjqコード内のパイプ記号が紛らわしいので注意してください。

もちろん、ファイルから入力してもかまいません。ファイル名は、2番目のコマンドライン引数として渡します。

$ echo '"Hello, World."'> hello.json
$ cat hello.json
"Hello, World."
$ jq 'gsub("[,. ]"; "") | ascii_upcase' hello.json
"HELLOWORLD"

入力は、ダブルクォートした文字列でないとエラーしますが、コマンドラインオプション -R を付けると、ダブルクォートが不要になります。

$ echo 'Hello, World.'> hello-1.json
$ cat hello-1.json
Hello, World.
$ jq -R 'gsub("[,. ]"; "") | ascii_upcase' hello-1.json
"HELLOWORLD"

-R を指定すると、入力を単なるテキスト行だと解釈します。内部でJSON文字列に変換するので、JSON処理用のプログラムがそのままテキスト処理に使えます。

出力のダブルクォーテーションを取り除きたいときは、-r オプションを付けます。

$ jq -R -r 'gsub("[,. ]"; "") | ascii_upcase' hello-1.json
HELLOWORLD

jq は普通に数値計算もできます。

$ jq '2 + 3*5'

しかし、jq は標準入力(この場合はキーボード)からの入力を待って止まってしまいます。ダミーのデータを送ってあげましょう。

$ echo 0 | jq '2 + 3*5'
17

上のようにしなくても、JSONヌル〈null〉を入力にするオプション -n があります。

$ jq -n '2 + 3*5'
17

0 から π/2 までの区間を 0.1 刻みに分けて、各点でのサイン関数(三角関数 sin)の値を求めるには次のようにします。

$ jq -n 'range(0; 0.5 * 3.1416; 0.1) | sin'
0
0.09983341664682815
0.19866933079506122
0.2955202066613396
0.3894183423086505
0.479425538604203
0.5646424733950354
0.644217687237691
0.7173560908995227
0.7833269096274833
0.8414709848078964
0.8912073600614353
0.9320390859672263
0.963558185417193
0.9854497299884603
0.9974949866040544

ここで、π/2 の近似値を手で入れました。jq では、円周率の定数 PI が定義されてないようです(たぶん)。が、逆三角関数asinの値として求まります。

$ jq -n '1 | asin | . * 2'
3.141592653589793

円周率を後々使いたいなら、次のように関数定義(後述)します。

def PI:
  1 | asin | . * 2
;

関数定義を含んだ次のようなjqスクリプトファイル sin-values.jq を準備しましょう。

# sin-values.jq

def PI:
  1 | asin | . * 2
;

range(0; 0.5 * PI; 0.1) | sin

jqスクリプトファイルは、-f で指定します。

$ cat ./sin-values.jq
# sin-values.jq

def PI:
  1 | asin | . * 2
;

range(0; 0.5 * PI; 0.1) | sin
$ jq -n -f ./sin-values.jq
0
0.09983341664682815
0.19866933079506122
0.2955202066613396
0.3894183423086505
0.479425538604203
0.5646424733950354
0.644217687237691
0.7173560908995227
0.7833269096274833
0.8414709848078964
0.8912073600614353
0.9320390859672263
0.963558185417193
0.9854497299884603
0.9974949866040544

円周率 PI の定義をスクリプトファイルに埋め込むのではなくて、ライブラリとして共有したいときはモジュールを使います(次節)。

モジュールシステム

jqプログラムコードが、コマンドライン内に書くには長くなり過ぎたら、jqスクリプトファイルを作って、それを -f オプションで指定します。プログラムが、1個のファイルに収まらないほど大きくなったらどうしましょう? あるいは、複数のスクリプトファイルで共通に使いたい関数定義を1ファイルにまとめたいときはどうしましょう? そう、モジュールシステムの出番です。

jqモジュールとは、拡張子が .jq でjqプログラムを収めたファイルです。スクリプトファイルとほぼ同じですが、モジュールには関数定義しか書けません。モジュール内に、トップレベルの実行コードがあるとエラーになります。

[補足]
実際に使ってみると、モジュールコードには実行コードが書けないのはけっこう不便です。ひとつのファイルをスクリプトとしてもモジュールとしても使いたいことがあるのです。モジュールとしてロードされたときは、トップレベルの実行コードは無視するという仕様でもよかった気がします。
[/補足]

前節の円周率 PI の定義をモジュールファイル util.jq に入れたとします。モジュールを利用する側は次のようなインポート文(import)を書きます。

# sin-values.jq
# import を使うように変更

import "util" as u;

range(0; 0.5 * u::PI; 0.1) | sin

インポート文で指定するモジュール名は、モジュールファイル名から拡張子 .jq を取り除いた名前です。as の後に、プログラム内で使う接頭辞名を指定します。この名前はモジュール名と違ってもかまいません。

モジュールで定義されている関数を呼ぶには、接頭辞::名前 の構文を使います。u::PI がその例です。

モジュールファイルはどこに置いてもかまいません。例えば ~/jqLib/ に置いたとします。モジュールファイルを置いたディレクトリ(jq ライブラリ・ディレクトリ)は、コマンドライン・オプション -L で指定します。

$ cat ./sin-values.jq
# sin-values.jq
# import を使うように変更

import "util" as u;

range(0; 0.5 * u::PI; 0.1) | sin
$ jq -n -L ~/jqLib -f ./sin-values.jq
0
0.09983341664682815
0.19866933079506122
0.2955202066613396
0.3894183423086505
0.479425538604203
0.5646424733950354
0.644217687237691
0.7173560908995227
0.7833269096274833
0.8414709848078964
0.8912073600614353
0.9320390859672263
0.963558185417193
0.9854497299884603
0.9974949866040544

毎回 -L オプションで指定するのはめんどくさいですね。JQ_LIBRARY_PATH のような環境変数はないようです。が、デフォルトのライブラリ・ディレクトリは決まっています。次の順でディレクトリをたどってモジュールを検索します。

  1. ~/.jq/
  2. $ORIGIN/../lib/jq/
  3. $ORIGIN/../lib/

ここで $ORIGIN/ は、jq の実行可能ファイルが置かれているディレクトリです。ディレクトリ ~/.jq/ にモジュールファイルを置くのが一番お手軽です。

~/.jq に関する注意

ちょっと分かりにくい仕様なんですが、~/.jq がファイルのときとディレクトリのときで扱いが変わります。

  • ~/.jq がファイルのとき : jq が実行されたときは常に、~/.jq ファイルをスクリプトファイルとして読み込む。その後で、コマンドライン埋め込み、または -f で指定されたスクリプトファイルを解釈実行する。
  • ~/.jq がディレクトリのとき : ~/.jq/ をデフォルトのモジュール・ディレクトリとして使う。

~/.jq がファイルのときは、rcファイル(初期設定をするスクリプト、run commands から)として扱うわけです。同じ名前にしないで別な名前にして欲しかった。例えば:

  • ~/.jqrc ファイル : jq のrcファイル
  • ~/.jqlib/ ディレクトリ : jq のデフォルトのライブラリ・ディレクトリ

想像するに(事実は知りません)、歴史的事情でしょう。モジュールシステムができる前は、~/.jq ファイルに共通に使う関数定義を書いていたが、同じ目的を持つライブラリ・ディレクトリ ~/.jq/ へと進化した; ただし、互換性から ~/.jq ファイルも残された。「たぶん」ですけどね。

ここでは、rcファイルとしての ~/.jq は使わずに、ライブラリ・ディレクトリ ~/.jq/ としてモジュールファイルを置くことにします。例えば、円周率 PI を定義した util.jq は ~/.jq/util.jq という名前にします。すると、-L オプションなしでインポートできます。

パイプライン指向プログラミング

パイプラインを使うプログラミングというと、シェルのコマンドライン/シェルスクリプトを思い出すでしょう。例えば、次のjqコード(入力は不要*5)を考えます。

"Hello, World." | gsub("[,. ]"; "") | ascii_upcase

同じことをするシェルコード〈コマンドライン〉は次のようです。

echo "Hello, World." | tr -d ',. ' | tr a-z A-Z
やること jq シェル
文字列を生成 "Hello, World." echo "Hello, World."
, と . と空白を削除 gsub("[,. ]"; "") tr -d ',. '
小文字を大文字に変換 ascii_upcase tr a-z A-Z

jq ではフィルター関数をパイプで繋いでますが、シェルでは、フィルターコマンドをOSのプロセスとして起動してパイプで繋いでます。フィルター、パイプ、パイプラインの概念を絵にすると次のようです。入力が特になかったり、出力を捨ててしまう(例: /dev/null へリダイレクトする)こともあります。

さてここで、シェルのパイプラインを一般化したプログラミング手法/プログラミング環境に思いを馳せてみます。

パイプラインは1本に限定せず、複数の -- ときに極めて多数のパイプラインを同時に走らせていいとします。パイプラインを流れるデータは直列ですが、フィルタープロセスはすべて並列実行されます。その様子を絵に描けば例えば次のようです。

パイプ(絵では有向ワイヤー)は、データのコピーや複合データの分解により分岐できます。複数のデータを複合データ(配列など)としてまとめることにより合流できます。フィルターは、主要な入力以外に名前付きパイプからの副次的入力を使うこともできます。絵の一番上の三角のフィルターは入力なしで出力を生成するフィルターです。絵には出てきてませんが、フィルターはパイプからの入力以外に引数〈argument〉を持ってもかまいません。

大規模なパイプ&フィルターのデータ処理ネットワークを実現するには、Erlangの仮想機械 BEAM Virtual Machine のように、1つのコンピュータ上で何十万ものマイクロプロセスを同時実行できる環境がふさわしいかも知れません。

jq は、パイプ&フィルターのデータ処理ネットワークを矮小化した(あるいは現実的にした)バージョンを実現します。jq の場合は:

  • フィルターは、永続的に動き続けるわけではなくて、ひとつの処理単位の処理が終わると消えてしまうワンショット・マイクロプロセス。ワンショット・マイクロプロセスとは、つまりは関数。
  • 何本かのパイプラインを扱えるが、同時に入力や処理が起きるわけではない。時間的に順次に、複数のパイプラインが生成されて稼働する。出力も時間的に順次に実行される。
  • パイプの分岐はできるが、合流はできないことがある。合流できるかどうかは、複数本のパイプを表すデータ(後述のシーケンス)の性質による。

jq の処理は、理想的な“パイプ&フィルターのデータ処理ネットワーク”と比較して考えると分かりやすいと思います。

ところで、僕にとってパイプ&フィルターは、特別な思い入れがある処理方式です。

jq を見て「オッ」と思ったのも、そんな(上記の参照先に書いた)事情からです。

JSONデータとは何か

jq はテキスト処理/数値計算もできますが、主たる処理対象はJSONデータです。

僕も世間も、「JSONデータ」という言葉をよく使いますが、「JSONデータ」は曖昧な言葉なので、もっと精密な用語を幾つか導入します。

JSONの仕様 RFC8259 https://datatracker.ietf.org/doc/html/rfc8259 によると、JSON値〈JSON value〉とは次のものです。

A JSON value MUST be an object, array, number, or string, or one of the following three literal names:

 false
 null
 true


JSON値とは、オブジェクト、配列、数値、文字列、または次の3つのリテラル名のどれかでなくてはならない。

 false
 null
 true

JSON値とは、JSON型のインスタンスですが、JSON型は次の下位型に細分できます。

  1. ヌル〈null〉型 : 値 null のみからなるシングルトン型。型の名前と値の名前が同じ(オーバーロード)なので注意。
  2. ブール〈boolean〉型 : 値 true と値 false からなる型。
  3. 数値〈number〉型 : 実装上は IEEE754 倍精度浮動小数点数型だが、抽象的な実数型だと考えてもよい。
  4. 配列〈array〉型 : 有限個のJSON値の順序付きの並びの型。
  5. オブジェクト〈object〉型 : 文字列キーとJSON値からなるハッシュマップ〈辞書 | 連想配列〉型。

JSON値は、JavaScriptをはじめとするプログラミング言語がメモリー上で扱うデータだと思っていいでしょう。

“JSON値”と、“JSONフォーマットのテキスト” -- つまりJSONテキスト〈JSON text〉は別物です。同じく RFC8259 によると:

A JSON text is a sequence of tokens. The set of tokens includes six structural characters, strings, numbers, and three literal names.


JSONテキストは、トークンの列である。トークンは、6つの構造文字と、文字列、数値、3つのリテラル名からなる。

JSON値もJSONテキストも同じように思えますが、JSONテキストは、あくまで文字の並びです。トークンはひとかたまりの文字のことで、6つの構造文字とは:

  • 左右のブラケット文字 '[' と ']'
  • 左右のブレイス文字 '{' と '}'
  • カンマ文字 ','
  • コロン文字 ':'

3つのリテラル名とは次です。

  • 名前 true
  • 名前 false
  • 名前 null

JSONテキストにおける「文字列、数値」は、JSON値の文字列、数値のことではなくて、「文字列表現の文法規則、数値表現の文法規則に従った文字の並び」のことです。

JavaScriptの JSON.parse 関数はJSONテキストを受け取ってJSON値を返します。一方の JSON.stringify 関数はJSON値を受け取ってJSONテキストを返します。混乱しがちなのは、“文字列JSON値”(型がstring型であるJSON値)と“文字列JSONテキスト”(文字列表現の文法規則に従った文字の並び)の区别です。

JSON.stringify("Hello") // => "\"Hello\""
JSON.parse("\"Hello\"") // => "Hello" 

JSON値 "Hello" はデータとして5文字の並びで、それをJSONテキストにすると両端のクォートを入れて7文字となり、7文字を "\"Hello\"" と文字列表現すると見た目11文字になります。

jq の入力となるデータ

jq の入力は、ファイルまたはキーボードからのJSONテキストです。それをパーズ〈構文解析〉してJSON値として、パイプラインで処理して、結果をまたJSONテキストにアンパーズ〈文字列化 | stringify | シリアライズ〉してOSの標準出力に書き出します。

実は、jq が受け付ける入力は、通常のJSONテキストより一般的です。例えば、1行に書いたJSONテキストを複数行に並べたテキストフォーマットであるJSON Lines形式のファイルも読めます。次はJSON Lines形式のファイルの例です。

"Hello, World."
3
57
{"a":1, "b":[2, 3]}

このファイル入力をパーズした結果は単一のJSON値ではなく、JSON値の配列でもありません。ここでは、JSONシーケンス〈JSON sequence〉と呼び、説明のために次の形で表記することにします。

 《"Hello, World."; 3; 57; {"a":1, "b":[2, 3]}》

[補足]
JSONシーケンスと呼ばれるフォーマットの仕様が RFC7464 にあります。

しかし、ほとんど使われてないし、今後使われるとも思えません。JSON Lines で事足りるからです。なので、RFC7464 の JSON sequence はなかったものとして扱います。

ちなみに、jq は RFC7464 の JSON sequence 形式のファイルも--seqオプションで読めるようです。
[/補足]

jq においては、単一のJSON値を処理しているのか、JSONシーケンスを処理しているのかの区别は極めて重要です。常に意識している必要があります。

例えば、. + 1 は入力されたJSON値(ドット)に 1 を足す処理です。数値のJSONシーケンスが入力されると、シーケンス項目の各数値に 1 を足します。

$ jq '. + 1'
3
4
5
6
10
11

上の例で、3, 5, 10 がキーボードからの入力で、 4, 6, 11 が jq からの出力です。配列を入力にするとエラーになります。

$ jq '. + 1'
[3, 5, 10]
jq: error (at :1): array ([3,5,7]) and number (1) cannot be added

配列の各項目に 1 を足すには map 関数を使います。

$ jq 'map(. + 1)'
[3, 5, 10]
[
  4,
  6,
  11
]

配列である入力をシーケンスにばらすには、[] を後置します。

$ jq '.[] | . + 1'
[3, 5, 10]
4
6
11

出力は単一の配列ではなくて、4つの数値からなるシーケンスです。絵に描くと次のようです。

処理の過程は次のように書くこともできます。

  1. 入力された配列(長さ1のシーケンス): 《[3, 5, 10]》
  2. それをバラしたシーケンス: 《3; 5; 10》
  3. シーケンスの各項目に 1 を足すと: 《4; 6; 11》

拡張JSON LinesフォーマットとJSONシーケンス

JSON Linesフォーマットでは、1行のテキスト行に1つのJSONテキストを書く必要があります。jq の入力フォーマットはもっと自由で、次のように書いてもちゃんとパーズ〈構文解析〉してくれます。

"Hello, World."3 57{
  "a":1, "b":
  [2, 
  3]
}

各JSONテキストは空白や改行で区切ればいいのです。差し障りがないなら、区切り文字なしでピッタリくっつけて書いても大丈夫です。このフォーマット〈テキスト構文〉を仮に拡張JSON Linesフォーマット〈extended JSON Lines format〉と呼んでおきましょう。

拡張JSON Linesフォーマットでゴシャゴシャに書かれた入力を、きれいなJSON Linesフォーマットに整形して出力するには jq -c . とします。-c は Compact Output オプションで、これを付けないと改行やインデントがたくさん入ります(プリティプリント)。

拡張JSON Linesフォーマットからの入力は、パーズされてJSONシーケンスとなってから処理されます。入力から得られた複数項目のシーケンスをひとつにまとめることはできません。しかし、処理の中間で配列から作られた複数項目シーケンスは、処理の後でまとめることができます。次は、ばらして・処理して・まとめる例です。

$ jq '[ .[] | . + 1 ]'
[3, 5, 10]
[
  4,
  6,
  11
]

まとめられないシーケンスは揮発性シーケンス〈volatile sequence〉、まとめることができるシーケンスは不揮発性シーケンス〈non-volatile sequence〉と呼ぶことにしましょう。

揮発性シーケンスでは、項目の処理が終わると、その結果はメモリーから消え去ります。後の処理の結果と組み合わせようにも失くなっているからどうにもなりません。一方、不揮発シーケンスでは、すべての項目の処理結果がメモリー内に保持されます。なので、すべての処理結果を一本の配列にまとめることもできます。

パイプラインの分岐と合流

パイプライン gsub("[,. ]"; "") | ascii_upcase で処理した結果と、結果の文字列の長さと、もとの文字列の長さをこの順で表示したいとしましょう。文字列の長さを求める関数はlengthです*6

パイプラインの絵で考えるのが得策です。次のような絵になります。

この絵をjqコードに写し取ると次のようです。グレーの箱が丸括弧になります。

( 
  (gsub("[,. ]"; "") | ascii_upcase) 
  | (., length)
)
, 
length

ドットは、その時点・場所での入力を参照する記号ですが、絵でも黒いドットを描いておきました。カンマを使うと、同じ入力に対して2つの処理(カンマの左右)をする意味になり、結果的にパイプラインが分岐します。2個のカンマがあるので、分岐が2回起きています。

入力に "Hello, World." を与えると、次の出力が得られます。

"HELLOWORLD"
10
13

分岐したパイプ〈ワイヤー〉達は不揮発性シーケンスなので、配列にまとめることができます。全体をブラケットで囲むと配列にまとまります。

[
  ( 
    (gsub("[,. ]"; "") | ascii_upcase) 
    | (., length)
  )
  , 
  length
]

出力は次のようです。

["HELLOWORLD",10,13]

パイプラインの絵とjqコードの相互翻訳は、トレーニングして慣れるしかないですね。

バイプラインを分岐させる別な方法として、変数〈variable〉を使う方法があります。jq の変数は通常のプログラミング言語の変数とはまったく別物です*7。変数への代入に見える構文は、名前付きパイプを作って、パイプラインの下流のフィルター達が名前付きパイプを参照できるようにします。

次の絵で、青いワイヤーは名前付きパイプを表します。

これをjqコードにすると次のようです。

length as $len |
[ 
  (gsub("[,. ]"; "") | ascii_upcase) 
  | (., length),
  $len
]

ここで、length as $len は、$len という名前のパイプを作って、length の値を流します。本流のデータは何もされずにそのまま下流(絵の右方向)に流れます。パイプラインの後続するフィルターは、本流(無名パイプ)のデータと名前付きパイプのデータの両方を使えます。本流をドット . で参照し、名前付きパイプは名前 $len で参照します。

今の例では、名前付きパイプ$lenは、最後にシーケンスを配列にまとめる所で使われています。絵の四角形がjqコードのブラケットに対応します。パイプラインの離れた場所に情報を送るには名前付きパイプ〈変数〉が便利です。

ドットとパス式

ドット.は、現在の入力を示す記号です。ascii_upcaselengthは、暗黙に入力を処理するのでドットを書く必要はありません。しかし、. + 1とか(., length)だとドットを明示的に書く必要があります。

ドットを関数だと思うと、入力をそのまま返す“id関数”です。名前があったほうが安心するなら、次の関数定義をしておけばいいでしょう。実際、jq コード中で、ドットをid関数と解釈したほうが分かりやすいことがあります。

def id:
  .
;

jq では、ドットが別な意味でも使われていて、これはややこしい所です。ドットの別な用途は、パス式〈path expression〉の区切り記号です。“JSONオブジェクトのプロパティ/JSON配列の項目”にアクセスするためのパス式構文には、JSONPath
JSON Pointer があります。jq のパス式は次の構文を持ちます。

  • オブジェクトのプロパティへのアクセスは、ドットとプロパティ名を使う。例: .givenName
  • 配列の項目へのアクセスは、ブラケットと項目番号〈インデックス〉を使う。例: [3]
  • オブジェクトのプロパティへのアクセスに、ブラケットとプロパティ名文字列を使ってもよい。例: ["given name"]
  • 上記のドットまたはブラケットを並べて、JSON値へのアクセス経路を書く。例: .members[3].givenName

jq のパス式に慣れるには、オンラインまたはVSCodeのプレイグラウンドで、path関数とgetpath関数を試してみるのが一番です。path関数は、jq パス式を、プロパティ名または項目番号〈インデックス〉の配列に直してくれます。

path(.) # => []
, path(.memebers) # => ["members"]
, path(.memebers[3]) # => ["members", 3]
, path(.memebers[3].givenName) # => ["members", 3, "givenName"]

getpath関数は、引数に渡したパス式により、JSON値の一部分を取り出します。ただし、生のパス式ではダメで、paht関数で事前に配列にしたデータを渡します*8。入力データであるJSON値が次のようだとしましょう。

{
  "bandName": "The Beatles",
  "members": [
    {
      "givenName": "John",
      "familyName": "Lennon"
    },
    {
      "givenName": "Paul",
      "familyName": "McCartney"
    },
    {
      "givenName": "George",
      "familyName": "Harrison"
    },
    {
      "givenName": "Ringo",
      "familyName": "Starr"
    }
  ]
}

getpath関数による部分抽出は次のようになります。

getpath(path(.members[3].givenName)) # => "Ringo"
getpath(["members",3,"givenName"]) # => "Ringo"

生のパス式を書いても結果は同じです。

.members[3].givenName # => "Ringo"

注意すべきは、先頭のドットが、現在の入力を表すドットと、パス式の先頭のドットを兼任していることです。2番目以降のドットは単にパス式の一部です。

パス式を使えば部分抽出を短く書けますが、パイプラインに分解することもできます。

.members | .[3] | .givenName # => "Ringo"

関数とそのプロファイル

関数定義の例は既に出ています。

def PI:
  1 | asin | . * 2
;
def id:
  .
;

関数は、jqコードに名前を付けたもので、フィルターとして使えます。

関数は暗黙の入力以外に、引数を取ることができます。

def increment(d):
  . + d
;

(1 | increment(2)) # => 3
, (2 | increment(1)) # => 3
, (10 | increment(-1)) # => 9

再帰的関数も書けます。次は、数値の配列の総和を求める関数です(同じ機能の組み込み関数addがあります)。

def total:
  if (length == 0) then
    0
  else
    .[0] + (.[1:] |total)
  end
;

[1,2,3,4,5] | total # => 15

reduce構文を使えば次のよう。

def total:
  reduce .[] as $i (0; . + $i)
;

[1,2,3,4,5] | total # => 15

入力をコピーして長さ2のシーケンスにする関数は:

def dup:
  (., .)
;

10 | dup # => 《10; 10》

長さ2のシーケンスではなくて、長さ2の配列にする関数なら:

def dup_array:
  [., .]
;

10 | dup_array # => [10, 10]

jq は、実行時の型エラーは起こしますが、静的に型チェックができる型システムは持っていません。ユーザーが型を定義することもできません。しかし、関数の入力・出力・引数の型を把握しておくことは重要です。コメント内に、関数のプロファイル(型の仕様)を書いておくことにします。

任意のJSON値の型を any とします。IGNORE は、入力が何であっても無視される〈捨てられる〉ことを意味します。型の書き方は詳しく説明しませんが、想像は付くでしょう。

# IGNORE -> number
def PI:
  1 | asin | . * 2
;

# any -> any
def id:
  .
;

# (number) : number -> number
def increment(d):
  . + d
;

# [number*] -> number
def total:
  if (length == 0) then
    0
  else
    .[0] + (.[1:] |total)
  end
;

# any -> 《any; any》
def dup:
  (., .)
;

# any -> [any, any]
def dup_array:
  [., .]
;

この記事で出てきた組み込み関数のプロファイルも書いておきます*9

  • gsub(string; string) : string -> string
  • ascii_upcase : string -> string
  • null : IGNORE -> null
  • sin : number -> number
  • range(number; number; number) : IGNORE -> [number*]
  • asin : number -> (number | null)
  • map(JqCode) : [any*] -> [any*]
  • (.[]) : [any*] -> 《any*》
  • length : (null | number | string | [any*] | object)-> number
  • path(PathExp) : IGNORE -> [(string | number)*]
  • getpath([(string | number)*]) : any -> any

サンプル

軽量なRDBシステムであるSQLiteのデータベース・スキーマ(テーブルスキーマの集まり)をJSON構文で記述することにします。例えば次のように。

[
  {
    "tableName": "bib",
    "columns":[
      {
        "name" : "id",
        "type" : "INTEGER",
        "notNull" : true,
        "primaryKey" : true
      },
      {
        "name" : "Title",
        "type" : "TEXT",
        "notNull" : true
      },
      {
        "name" : "Author",
        "type" : "TEXT",
        "notNull" : true
      },
      {
        "name" : "Date",
        "type" : "TEXT",
        "typeComment" : "date"
      },
      {
        "name" : "Pages",
        "type" : "TEXT"
      },
      {
        "name" : "URL",
        "type" : "TEXT",
        "typeComment" : "url"
      },
      {
        "name" : "inMyBlogPage",
        "type" : "TEXT",
        "typeComment" : "url"
      }
    ]
  }
]

この例を見れば、データベース記述のJSON構文の規則はだいたい分かると思います。が、きちんとしたデータ型定義を書いておきます。使うスキーマ言語は、Kuwataさんと僕が作って使っていたCatyスキーマです。Catyスキーマは見れば分かる構文ですが、以下の記事に解説があります。

次のCatyスキーマは、データベース・スキーマのJSON記述の型を定義するスキーマです*10

// カラムの仕様を記述するデータの型
type ColumSpec = {
  "name" : string,
  "type" : string(format = "SQL_typeName"),
  "typeComment" : string?,
  "notNull" : boolean?,
  "primaryKey" : boolean?,
  "default" : string(format="SQL_value")?
};

// テーブルの仕様を記述するデータの型
type TableSpec = {
  "tableName" : string,
  "columns" : [ColumnSpec*]
};

// データベースの仕様を記述するデータの型
type DbSepc = [TableSpec*];

JSON構文で書かれたデータベース・スキーマ記述を読み込んで、指定されたテーブルのCREATE TABLE文を生成するプログラムを jq で書いてみます。以下の jq プログラムはモジュールなので、ライブラリ・ディレクトリ(例えば ~/.jq/ )にファイルを置いて、次のように呼び出します。

$ jq -r 'import "create-table" as DB; DB::main("bib")' dbspec.json
# create-table.jq

# (name: string) : DbSpec -> (TableSpec | null)
def getTableSpecFromDbSpec(name):
  [.[] | select(.tableName == name)]
  | if (length == 0) then null
    elif (length == 1) then .[0]
    else error("more than oen table") end
;

# ColumnSpec -> string
def makeColumnItemText:
  [
    "\(.name | ascii_downcase) \(.type)",
    (if (.notNull? !=null and .notNull) then 
       "NOT NULL" 
     else 
       "" 
     end),
    (if (.primaryKey? != null and .primaryKey) then 
       "PRIMARY KEY"
     else
       "" 
     end),
    (if (.default? !=null) then 
       "DEFAULT " + .default
     else 
       "" 
     end)
  ] | join(" ")
;

# [ColumnSpec*] -> string
def makeColumnListText:
  [
    "(\n    ",
    (
      [ .[] | makeColumnItemText ]
      | join(",\n    ")
    ),
    "\n)"
  ] | join("")
;

# TableSpec -> string(format = "SQL")
def makeCreateTableFromTableSpec:
  [
    "CREATE TABLE IF NOT EXISTS \(.tableName) \n",
    (.columns | makeColumnListText),
    "\n;"
  ] | join("")
;

# (name: string) : DbSpec -> string(format = "SQL")
def main(name):
  .
  | getTableSpecFromDbSpec(name) # TableSpec or null
  | (if (. != null) then makeCreateTableFromTableSpec else "" end)
;

実行してみると:

$ jq -r 'import "create-table" as DB; DB::main("bib")' dbspec.json
CREATE TABLE IF NOT EXISTS bib
(
    id INTEGER NOT NULL PRIMARY KEY ,
    title TEXT NOT NULL  ,
    author TEXT NOT NULL  ,
    date TEXT   ,
    pages TEXT   ,
    url TEXT   ,
    inmyblogpage TEXT
)
;

この出力を sqlite3 にパイプすれば目的のテーブルが出来上がります。

$ jq -r 'import "create-table" as DB; DB::main("bib")' dbspec.json | sqlite3 bib.sqlite

おわりに

jq を実際に使う上での注意事項やコツ、紹介してない機能はまだあるのですが、原理的な部分は十分に説明したと思います。あとは、jq のマニュアル https://stedolan.github.io/jq/manual/ を読んで、プレイグラウンド https://jqplay.org/ で試してみれば、jq を使えるようになるでしょう。jq は、JSON処理における強力なツールとして威力を発揮してくれます。

*1:PowerShell の問題というより、PowerShell の基盤である .NET Framework のテキストIOの仕様(so-called バグではない)のようです。

*2:大昔のDOSでは、テキストファイルのEOF〈End Of File〉に ^Z 文字を使っていました。今はさすがにないでしょう。

*3:きちんとジョブ制御できてるかどうかはあやしい。

*4:大変なのは、Unixのコンソール/ターミナル・システムとは出自が違うWindowsだけかも知れませんが。

*5:「不要」は入力は捨てられることです。どうせ捨てられる入力なら、外部から供給する必要はないので、コマンドラインオプション -n を使います。

*6:length は、ブール値以外の任意のJSON値に対して定義されています。

*7:[追記]用途や見た感じは、普通に変数です。が、データの上書き変更をしてない点やスコーピングから、名前付きパイプと(少なくともメンタルモデルとしては)捉えるのが適切だと思います。[/追記]

*8:getpath 関数は、動的に作ったパス式(に対応する配列)による抽出に使えます。

*9:jq には、関数引数の高階関数はないので、map は組み込みの構文です。← これはウソでした、高階関数、ある程度使えます。.[] と path は組み込み構文です。

*10:構文ハイライト〈カラーリング〉は TypeScript のものを使用しましたが、けっこうちゃんと色が付いていますね。