JSONPath フィルタ詳解:再帰、スライス、複数条件の一致
要点:再帰降下、スライス、複数条件フィルタは、JSONPath が強力になる一方で誤用される箇所です。フィルタの意味論、結果の形、ライブラリ差を扱います。

要点:既知の深さを先に特定し、$.. に手を伸ばすのではなくスライスとフィルタを足していきましょう。フィルタ内の裸のパスは存在テストにすぎないので、真偽値は @.stock == true でテストします。
$.store.book[0].titleは誰でも書けます。JSONPath を実際に壊すのは、$..をスライスや複数条件と組み合わせ、結果が多すぎたり少なすぎたり、予期しない順序になったときです。
1. サンプル文書
{
"store": {
"book": [
{ "title": "JSON 徹底ガイド", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
{ "title": "データフォーマットの歴史", "price": 12.99, "stock": false, "tags": ["history"] },
{ "title": "パーサーの仕組み", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
{ "title": "ストリーミング JSON の実践", "price": 9.5, "stock": true, "tags": [] }
],
"bicycle": { "color": "red", "price": 19.95 }
},
"updatedAt": "2026-10-31"
}
以下のすべての式はこの文書を入力とします。二つの前提を覚えておいてください。結果はノード集合で、その順序は条件を書いた順ではなく文書の順序に従います。フィルタ内の @ は現在の候補ノードだけを指します。
2. 再帰降下 $..:便利だが高コスト
.. は「現在位置から任意の深さを再帰的に探索する」ことを意味します。RFC 9535 はこれを子孫セグメントと定義し、名前、ワイルドカード、インデックス、スライス、フィルタと組み合わせられます。
$..price // 任意の深さで price という名前のすべてのメンバー
$..book[?(@.price < 10)] // 任意の深さの book を見つけ、それからフィルタ
$..[?(@.stock)] // 任意の深さで stock が存在するすべてのノード
理解すべき三つのこと:
$..bookは深さに関係なくbookという名前のすべてのノードを見つける。- 走査は深さ優先で、出力順は実装が子をどう訪れるかに依存する。
..はインデックスを使えないので、コストはノードの総数に比例する。
結果として、$..price と $.store.book[*].price は出力が自転車の価格だけ異なるかもしれませんが、コストは桁違いに違います。深さが分かっているときは常に明示的なパスを書いてください。二つのパス言語の境界だけを比較したいなら、JSONPath と JSON Pointerを参照してください。
3. 配列スライスとワイルドカード:三つの罠
スライス構文は [start:end:step] で、開始を含み終了を含まず、負数は末尾から数えます。
| 式 | 意味 | 結果(本が 4 冊のとき) |
|---|---|---|
[0:2] |
最初の 2 冊 | 1 冊目と 2 冊目 |
[1:] |
2 冊目から末尾まで | 2、3、4 冊目 |
[:2] |
最初の 2 冊 | 1 冊目と 2 冊目 |
[-2:] |
最後の 2 冊 | 3 冊目と 4 冊目 |
[::2] |
1 冊おき | 1 冊目と 3 冊目 |
[::-1] |
逆順 | 4、3、2、1 冊目 |
三つの罠:
- ステップ 0 は不正。RFC 9535 は非ゼロのステップを要求しますが、一部の実装は静かに空集合を返します。
- 負のステップは万能ではない。元の Goessner 実装は対応しますが、一部の最小実装は正のステップしか受け付けません。
- スライスの後のフィルタは範囲を広げない。
[0:2][?(@.stock)]はスライスが生んだ 2 冊だけをフィルタし、配列全体には戻りません。
ワイルドカード [*] はスライスと組み合わせられますが、[*] は「すべての子ノード」を生みます。その子がオブジェクトなら .title を足すとすべてのタイトルになります。子自体が配列なら、.title がもう一段暗黙に平坦化することはありません。展開が必要なら [*] をもう一度書いてください。暗黙の平坦化を期待しないでください。
4. 複数条件フィルタ:比較、論理、存在
フィルタ [?(...)] は四種類の構文を含められます。比較、論理演算子、存在テスト、関数です。よくある複数条件クエリはこうです。
$..book[?(@.price < 10 && @.stock)]
意味は「価格が 10 未満でかつ stock が真値」です。いくつかの意味論的詳細に注意してください。
@.stockは存在テストで、「値が true」ではありません。フィールドが存在しても値がfalseなら、条件はなお成り立ちます。真偽値をテストするには@.stock == trueと書かなければなりません。&&は||より強く結合します。混ぜるときは記憶に頼らず括弧を足してください。- 比較の両辺は同じ型でなければなりません。数値と文字列は暗黙変換されず、
@.price < "10"は厳格な実装では false かエラーです。 - 欠落フィールドは常に比較を false にします。
@.missing < 10はエラーにならず、単に何にも一致しません。
JavaScript のようにフィルタを書くときに最もつまずく箇所です。JavaScript の if (obj.stock) は真値をテストしますが、JSONPath は存在をテストします。標準の完全な構文と関数は JSONPath クエリ言語の実践を読み直してください。
5. スクリプト式:強力だが移植性が低い
元の Goessner 実装と jsonpath-plus は [?(@.price < $.max)] のようなノード間比較、さらには任意のスクリプトを許します。RFC 9535 は現在ノード @ へのアクセスだけを明示的に許し、フィルタ内の $ を禁じます。
// 古い実装では動くが、RFC 9535 では許されない
$..book[?(@.price < $.expensive)]
// 標準的な方法:呼び出し側からしきい値を渡す
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })
スクリプト式は三つの問題を生みます。
- 移植性がゼロになる。ライブラリを替えるとすべての式を書き直すことになります。
- セキュリティ境界が消える。式がユーザー由来なら、スクリプト評価はコード実行を手渡すことになります。
- 静的最適化が不可能になる。実装はノードごとに解釈するしかなく、遅く、キャッシュも難しいです。
どうしてもノード間比較が必要なら、しきい値をクエリパラメータとして渡し、アプリケーションコードで二度目のフィルタを行ってください。通常は拡張構文に依存するより安定しています。
6. 結果の形:値を受け取るつもりが、ノードを受け取る
同じ式でも、ライブラリによって少なくとも三つの形を返します。
["JSON 徹底ガイド", "パーサーの仕組み", "ストリーミング JSON の実践"] // 値の配列
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // パス + 値
[{ "pointer": "/store/book/0/title", "value": "..." }] // JSON Pointer + 値
これは三つのことに直接影響します。フロントエンドが結果をどう読むか、結果をどう重複排除するか、クエリ結果をどう書き込み操作にするかです。RFC 9535 が定義する nodelist はノード参照の集合ですが、JavaScript の実装は参照がプロセス境界を越えられないため、通常は値だけを返します。
実践的な助言:
- 単一値は常に
[0]かvalue()で読み、結果がスカラーだと仮定しない。 - 書き戻しが必要なら、JSON Pointer を返す実装を選ぶか、自分でポインタを組み立てる。
- 空の結果と
[null]の結果は異なる。真偽値ではなく長さで確認する。
7. ライブラリ差:移行前に適合スイートを実行する
| 能力 | 元の Goessner | jsonpath-plus | 厳格な RFC 9535 |
|---|---|---|---|
フィルタの括弧 ?() |
必須 | 互換 | 任意 |
ルートをまたぐ参照 $.x |
対応 | 対応 | 禁止 |
| スクリプト式 | 対応 | 対応 | 禁止 |
| 負のインデックスとステップ | 対応 | 対応 | 対応 |
| パスを返す | 設定可能 | 設定可能 | 規定なし |
length() などの関数 |
なし | 拡張 | 標準関数 |
移行前に少なくとも四つを確認します。括弧が必須か、フィルタ内で $ が現れてよいか、負のステップがどう振る舞うか、結果の構造は何か。公式の適合テストスイートはほとんどの構文差をカバーし、「ライブラリを替えたら三行が消えた」のような静かな失敗を防ぎます。
8. 性能と誤用チェックリスト
性能は一文に尽きます。JSONPath は全走査であり、.. は最も高コストな全走査です。
- ノード数が 10 万を超える文書では、まず明示的なパスで範囲を狭め、その部分集合をフィルタする。
- 式は一度コンパイルして再利用する。ループ内で文字列を再パースしない。
- 集計(合計、グループ化、並び替え)は
jqやコードに切り替える。JSONPath は選択しかできない。 - 条件はできるだけ早く置く。
$.store.book[?(@.price < 10)]は$..[?(@.price < 10)]よりはるかに少ない無関係ノードを走査する。
誤用チェックリスト:
- [ ] 存在テストを真偽値の真偽テストとして扱っている。
- [ ] 既知の深さがあるのに
$..を使っている。 - [ ] ノード間比較にスクリプト式を依存している。
- [ ] 結果が常に値の配列だと仮定している。
- [ ] ユーザー制御の式に長さとネストの制限がない。
FAQ
フィールドが false なのに @.stock が一致するのはなぜですか?
フィルタ内の裸のパスは「フィールドが存在する」を意味し、「値が true」ではありません。真偽値をテストするには @.stock == true と明示的に比較します。これが JSONPath と JavaScript の真偽値の最大の意味論的違いです。
再帰降下の順序は安定していますか?
いいえ。RFC 9535 はノード集合の意味論を定め、走査順は実装が子をどう訪れるかに依存します。固定の順序が必要なときは配列を特定してインデックスで読み取るか、アプリケーションコードで結果を並び替えてください。
スライスとフィルタは組み合わせられますか?
はい。ただし範囲を覚えておいてください。$.store.book[0:2][?(@.stock)] はスライスが生んだ 2 冊だけをフィルタします。先にフィルタしてからスライスするには $.store.book[?(@.stock)][0:2] と書き、順序を変えると結果が変わります。
標準が禁じているのに jsonpath-plus が $.expensive をサポートするのはなぜですか?
フィルタは現在ノード @ だけで定義されているからです。ルートをまたぐ参照は式を文書全体の文脈に依存させ、静的な解析と言語間の移植を難しくします。しきい値を式に埋め込むのではなく、パラメータとして渡してください。
再帰降下が無関係なノードを走査しないようにするには?
深さが分かるときは常に明示的なパスを書き、まず範囲を狭め、既知の階層の代わりに $.. を使うのではなく部分集合だけをフィルタします。条件はできるだけ早く置きます。$.store.book[?(@.price < 10)] は $..[?(@.price < 10)] よりはるかに少ない無関係ノードを走査します。ノード数が 10 万を超える文書では、まずこれを実行してください。