本文へスキップ
jsonbeautifiers
日本語

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[?@.isbn]はISBNを持つ2冊を選びます。比較演算子は==!=<<=>>=。論理演算子は&&||、前置の!で、グループ化には括弧を使います。式全体を囲む括弧は許されますが必須ではなくなったので、[?(@.price < 10)][?@.price < 10]も妥当で、同じ意味です。

比較のオペランドには制限があります。両辺はリテラル、単一クエリ(名前セレクタとインデックスセレクタだけで構成され、選ぶノードは高々1つ)、または関数呼び出しでなければなりません。@.priceは該当します。@..price@.book[*].priceは該当せず、実装は推測せずにクエリを拒否すべきです。

そして人がつまずく規則です。何も選ばなかったクエリは特別な値Nothingになり、Nothingはnullでも0でもfalseでもありません。Nothingとだけ等しく、ほかの何とも等しくなく、Nothingを含む順序比較はすべて偽になります。帰結は次のとおりです。

$.store.book[?@.price < 10]     Tolkienの本を除外する(priceがない)
$.store.book[?@.price >= 10]    これも除外する
$.store.book[?@.price == null]  これも除外する。Nothingはnullではない
$.store.book[?!@.price]         この本だけを選ぶ

つまり不在は存在テストを否定して調べ、== nullは「メンバーが存在し、その値がnullである」ことを調べます。鏡像のほうは本当に意外です。$.store.book[?@.price == @.discount]はTolkienの本を選びます。両辺がNothingであり、NothingはNothingと等しいからです。

異なる型どうしの比較はエラーになりません。型をまたぐ等価性は単に偽であり、順序は数値どうしか文字列どうしでのみ定義されるので、@.price > "10"はどの本についても偽です。

関数拡張

5つが定義されており、型付きです。したがってlength(@.book[*])は実行時の驚きではなく型エラーになります。

関数 受け取るもの 返すもの
length() 文字列ならUnicodeスカラー値の数、配列なら要素数、オブジェクトならメンバー数、それ以外はNothing
count() ノードリスト 選ばれたノードの個数
match() 文字列と正規表現 正規表現が文字列全体に一致すれば真
search() 文字列と正規表現 正規表現が文字列のどこかに一致すれば真
value() ノードリスト リストがちょうど1ノードならその値、それ以外はNothing

count()があるのは、単一でないクエリを比較のオペランドにできないからです。「ISBNを持つ本がちょうど2冊」はcount(@.book[?@.isbn]) == 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[?@.price < 10].title "Sayings of the Century""Moby Dick"
$.store.book[?!@.price].title "The Lord of the Rings"
$.store.book[?@.price > $.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つのペイロードの間で何が変わったかを知りたいなら、それはクエリではなく差分です。