JSONPath、ようやく仕様が定まった版
JSONPathは17年間ブログ記事でした。RFC 9535がついにその意味を定めます。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
JSONPathは2007年、Stefan Goessnerのブログ記事として始まりました。XPathに似たJSON向けの問い合わせ言語を、2画面ほどの散文とJavaScriptの参照実装でスケッチしたものです。誰もが実装するには十分よくでき、誰もが別々に実装するには十分あいまいでした。子孫演算子..はルートノード自身も対象にするのか、子だけなのか。[-1]は最後の要素なのかエラーなのか。1組の角括弧に複数のセレクタを入れられるのか。テスト対象のキーが存在しないとき、フィルタは何をするのか。ステップ0のスライスは何を選ぶのか。どの問いにも野に出回る答えが少なくとも2つあり、原文はそのいずれも決着させませんでした。一部はその場にあるeval()任せだったからです。
RFC 9535が2024年2月にこれを解決しました。IETFのProposed Standardであり、標準化トラックの最初の成熟度です。実在し、安定し、規範的な言葉で書かれた仕様であって、Internet Standardではありません。ほかのProposed Standardと同じように扱ってください。新しいコードを書く際の拠り所であり、同時に、稼働中のコードの多くはそれ以前のものだと知っておく、ということです。
対象の文書
以下はすべてこの文書に対して実行します。
{
"store": {
"name": "Corner Books",
"book": [
{ "category": "reference", "author": "Nigel Rees",
"title": "Sayings of the Century", "price": 8.95 },
{ "category": "fiction", "author": "Evelyn Waugh",
"title": "Sword of Honour", "price": 12.99 },
{ "category": "fiction", "author": "Herman Melville",
"title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
{ "category": "fiction", "author": "J.R.R. Tolkien",
"title": "The Lord of the Rings", "isbn": "0-395-19395-8" }
]
}
}
最後の本に注目してください。priceがありません。興味深い挙動のほとんどは、この不在に宿っています。
セグメントとセレクタ
クエリは$に続くセグメントの並びです。$がルート。各セグメントは、いま手元にあるすべてのノードに1つ以上のセレクタを適用し、新しいノードリストを生み出します。クエリの結果は常にノードのリストであり、含まれるノードが1つでもゼロでもそうです。JSONPathのライブラリが配列を返し、JSON Pointerのライブラリが値を返すのはこのためです。
$.store.book[0].title ドット記法。名前セレクタの短縮形
$['store']['book'][0] 角括弧記法。意味は同一
$["store"]["book"][0] 二重引用符も使える
角括弧記法は任意の飾りではありません。$.first-nameは妥当な名前セレクタではないので、ハイフン、空白、ドット、先頭が数字のキーは$['first-name']と書かなければなりません。単一引用符の角括弧形式は、RFC 9535が単一ノードに対して定める一意な識別子、すなわち正規化パスの形でもあります。$['store']['book'][0]['title']のように。
セレクタは次のとおりです。
- 名前:
'title'または"title"。オブジェクトのメンバーを選ぶ。配列に対しては何も選ばない。 - ワイルドカード
*: オブジェクトのすべてのメンバー値、配列のすべての要素。$.store.book[*]と$.store.book.*は同じクエリ。 - インデックス: 0起点の整数。負値は末尾から数えるので
[-1]は最後の要素。これは仕様として定まっており、ライブラリごとの気遣いではありません。 - スライス
開始:終了:ステップ: 半開区間で終了は含まず、負のステップは後ろ向きに進みます。ステップ0は例外を投げず何も選びません。RFCがPythonと意図的に袂を分かつ唯一の箇所です。 - フィルタ
?式: 後述します。
子セグメントにはカンマ区切りで複数のセレクタを置けます。種類が同じである必要はありません。$.store.book[0, -1]は最初と最後の本、$.store.book[0, 2:4]はインデックスとスライスの混在です。結果はセレクタの順に返るので、和は同じノードを2回返しても正当です。
子孫セグメントはドット2つで書きます。$..author、$..['author']、$..*、$..[0]。入力ノードとそのすべての子孫を訪れ、それぞれにセレクタを適用します。かつてのあいまいさは消えました。上の文書に対する$..storeは$.storeに一致します。子孫セグメントがルート自身から始まるからです。
フィルタ、問いが集まる場所
フィルタの内側では、@はいま検査中のノード、$は依然として文書全体のルートです。したがってフィルタは、文書の別の場所にある何かと値を比較できます。
裸のクエリをフィルタ式として使うと存在テストになります。そのクエリが少なくとも1つノードを選べば真です。$.store.book[[email protected]]はISBNを持つ2冊を選びます。比較演算子は==、!=、<、<=、>、>=。論理演算子は&&、||、前置の!で、グループ化には括弧を使います。式全体を囲む括弧は許されますが必須ではなくなったので、[?(@.price < 10)]も[[email protected] < 10]も妥当で、同じ意味です。
比較のオペランドには制限があります。両辺はリテラル、単一クエリ(名前セレクタとインデックスセレクタだけで構成され、選ぶノードは高々1つ)、または関数呼び出しでなければなりません。@.priceは該当します。@..priceや@.book[*].priceは該当せず、実装は推測せずにクエリを拒否すべきです。
そして人がつまずく規則です。何も選ばなかったクエリは特別な値Nothingになり、Nothingはnullでも0でもfalseでもありません。Nothingとだけ等しく、ほかの何とも等しくなく、Nothingを含む順序比較はすべて偽になります。帰結は次のとおりです。
$.store.book[[email protected] < 10] Tolkienの本を除外する(priceがない)
$.store.book[[email protected] >= 10] これも除外する
$.store.book[[email protected] == null] これも除外する。Nothingはnullではない
$.store.book[[email protected]] この本だけを選ぶ
つまり不在は存在テストを否定して調べ、== nullは「メンバーが存在し、その値がnullである」ことを調べます。鏡像のほうは本当に意外です。$.store.book[[email protected] == @.discount]はTolkienの本を選びます。両辺がNothingであり、NothingはNothingと等しいからです。
異なる型どうしの比較はエラーになりません。型をまたぐ等価性は単に偽であり、順序は数値どうしか文字列どうしでのみ定義されるので、@.price > "10"はどの本についても偽です。
関数拡張
5つが定義されており、型付きです。したがってlength(@.book[*])は実行時の驚きではなく型エラーになります。
| 関数 | 受け取るもの | 返すもの |
|---|---|---|
length() |
値 | 文字列ならUnicodeスカラー値の数、配列なら要素数、オブジェクトならメンバー数、それ以外はNothing |
count() |
ノードリスト | 選ばれたノードの個数 |
match() |
文字列と正規表現 | 正規表現が文字列全体に一致すれば真 |
search() |
文字列と正規表現 | 正規表現が文字列のどこかに一致すれば真 |
value() |
ノードリスト | リストがちょうど1ノードならその値、それ以外はNothing |
count()があるのは、単一でないクエリを比較のオペランドにできないからです。「ISBNを持つ本がちょうど2冊」はcount(@.book[[email protected]]) == 2と書きます。value()は同じ問題を反対側から解きます。$[?value(@..name) == 'Corner Books']が動くのは、value()が複数ノードのクエリを比較可能な単一の値へ畳み込むから、あるいは一致が1つでなければNothingへ畳み込むからです。
正規表現の方言はI-Regexp(RFC 9485)で、XSDの正規表現に対応づく意図的に小さな部分集合です。PCREではありません。先読みや後方参照を期待しないでください。そしてmatch(@.category, 'fic')は"fiction"に対して偽、search(@.category, 'fic')は真であることを覚えておいてください。
言語に存在しないもの
いまも手を伸ばされがちな3つが存在しません。スクリプト式、すなわち原記事の[(...)]形式は消え、それとともにeval()への依存も消えました。親演算子はありません。クエリは下方向にしか進まないので、囲んでいるオブジェクトが必要なら、そのオブジェクトを選んで子の条件でフィルタします。そして疑似プロパティlengthも消えたので、$.store.book[(@.length-1)]はクエリではありません。$.store.book[-1]と書いてください。
実例
| 式 | 結果 |
|---|---|
$.store.name |
"Corner Books" |
$.store.book[*].author |
著者4名すべて |
$..isbn |
ISBNの文字列2つ |
$.store.book[-1].title |
"The Lord of the Rings" |
$.store.book[1:3].title |
"Sword of Honour"、"Moby Dick" |
$.store.book[::2].title |
"Sayings of the Century"、"Moby Dick" |
$.store.book[0,-1].title |
"Sayings of the Century"、"The Lord of the Rings" |
$.store.book[[email protected] < 10].title |
"Sayings of the Century"、"Moby Dick" |
$.store.book[[email protected]].title |
"The Lord of the Rings" |
$.store.book[[email protected] > $.store.book[0].price].title |
"Sword of Honour"、"Moby Dick" |
$.store.book[?match(@.category, 'fic.*')].author |
Waugh、Melville、Tolkien |
$.store.book[?search(@.author, 'Mel')].title |
"Moby Dick" |
$.store.book[?length(@.title) > 16].title |
"Sayings of the Century"、"The Lord of the Rings" |
文書とこれらのいずれかをJSONPathテスターに貼り付ければ、ノードリストと各一致の正規化パスを並べて確認できます。負のインデックスや欠けたキーについて、自分のライブラリがRFCと一致しているかを調べるいちばん速い方法です。
ほかのものを使うべきとき
JSONPathはノードを選びます。仕事はそれで全部であり、ほかに3つの問い合わせ手段が重なります。
JSON Pointer(RFC 6901)はちょうど1つのノードを指し示します。ワイルドカードもフィルタもあいまいさもありません。/store/book/0/titleのように書き、リテラルのスラッシュは~1、リテラルのチルダは~0です。JSON Schemaのエラーや JSON Patchの操作が位置を指すのに使っているのがこれです。住所が分かっているならPointerを使ってください。
jqは独自の値モデル、算術、変数、出力整形を備えた本格的なストリーム処理言語です。jqは変換し、JSONPathは選ぶだけです。式が新しいオブジェクトを組み立て始めているなら、欲しいのはjqです。
JMESPathはその中間にあります。RFC 9535より古い、仕様化された問い合わせ言語で、射影と独自の関数ライブラリを持ち、構文はJSONPathに紛らわしいほど近く、壊れるほどには違います。コードベースごとにどちらか一方を選んでください。
日常的な用途、つまりペイロードを必要なフィールドまで削る作業なら、フィルタツールが式言語なしでやってくれますし、ビューアーは問い合わせ対象の形を見せてくれます。2つのペイロードの間で何が変わったかを知りたいなら、それはクエリではなく差分です。