Фильтры JSONPath в глубину: рекурсия, срезы и совпадение по нескольким условиям
Главное: Рекурсивный спуск, срезы и фильтры по нескольким условиям — вот где JSONPath становится мощным и неправильно используемым. Рассматривает семантику фильтров, формы результатов и различия библиотек.

Главное: сначала локализуйте известную глубину, затем добавляйте срезы и фильтры вместо обращения к $..; голый путь в фильтре проверяет только существование, поэтому проверяйте булево через @.stock == true.
Любой может написать
$.store.book[0].title. Настоящая поломка JSONPath — это сочетание$..со срезами и несколькими условиями, затем получение слишком многих результатов, слишком немногих или порядка, которого вы не ожидали.
1. Образец документа
{
"store": {
"book": [
{ "title": "JSON: The Definitive Guide", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
{ "title": "A History of Data Formats", "price": 12.99, "stock": false, "tags": ["history"] },
{ "title": "How Parsers Work", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
{ "title": "Streaming JSON in Practice", "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], включая начало и исключая конец, с отрицательными числами, считающими от хвоста:
| Выражение | Значение | Результат (с четырьмя книгами) |
|---|---|---|
[0:2] |
Первые две книги | Книги 1 и 2 |
[1:] |
Со второй книги до конца | Книги 2, 3 и 4 |
[:2] |
Первые две книги | Книги 1 и 2 |
[-2:] |
Последние две книги | Книги 3 и 4 |
[::2] |
Каждая вторая книга | Книги 1 и 3 |
[::-1] |
Обратный порядок | Книги 4, 3, 2 и 1 |
Три ловушки:
- Шаг нуля невалиден. RFC 9535 требует ненулевой шаг, тогда как некоторые реализации молча возвращают пустое множество;
- Отрицательные шаги поддерживаются не везде. Оригинальная реализация Goessner поддерживает их, но некоторые минимальные реализации принимают только положительные шаги;
- Фильтрация после среза не расширяет область.
[0:2][?(@.stock)]фильтрует только две книги, созданные срезом; он не возвращается к всему массиву.
Подстановочный знак [*] можно комбинировать со срезами, но [*] даёт «все дочерние узлы». Если эти дети — объекты, добавление .title даёт все заголовки. Если ребёнок сам является массивом, .title не выровняет неявно ещё один уровень. Пишите [*] снова, когда нужно расширить; не ожидайте неявного выравнивания.
4. Фильтры по нескольким условиям: сравнения, логика и существование
Фильтр [?(...)] может содержать четыре вида конструкций: сравнения, логические операторы, проверки существования и функции. Частый запрос по нескольким условиям выглядит так:
$..book[?(@.price < 10 && @.stock)]
Это означает «цена ниже 10 и stock истинно». Обратите внимание на несколько семантических деталей:
@.stock— проверка существования, а не «значение истинно». Если поле существует, но его значениеfalse, условие всё равно выполняется. Чтобы проверить булево значение, вы должны написать@.stock == true;&&связывает крепче, чем||. Добавляйте скобки при их смешивании вместо того, чтобы полагаться на память;- Обе стороны сравнения должны иметь один тип. Числа и строки не преобразуются неявно;
@.price < "10"ложно или ошибка в строгих реализациях; - Отсутствующее поле всегда делает сравнение ложным.
@.missing < 10не поднимает ошибку; оно просто ничего не совпадает.
Это самое лёгкое место для спотыкания при написании фильтров, как будто они JavaScript: if (obj.stock) проверяет истинность в JavaScript, тогда как JSONPath проверяет существование. Полный стандартный синтаксис и функции см. в Языке запросов JSONPath на практике.
5. Скриптовые выражения: больше силы, меньше переносимости
Оригинальная реализация Goessner и jsonpath-plus допускают кросс-узловые сравнения вроде [?(@.price < $.max)] и даже произвольные скрипты. RFC 9535 явно разрешает только доступ к текущему узлу @ и запрещает $ внутри фильтра.
// Работает в старых реализациях; не допускается RFC 9535
$..book[?(@.price < $.expensive)]
// Стандартный подход: передать порог от вызывающего
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })
Скриптовые выражения создают три проблемы:
- Переносимость падает до нуля. Смена библиотеки означает переписывание каждого выражения;
- Граница безопасности исчезает. Когда выражения приходят от пользователей, вычисление скрипта передаёт выполнение кода;
- Статическая оптимизация становится невозможной. Реализация может только интерпретировать узел за узлом, что медленно и трудно для кеширования.
Если вам действительно нужно кросс-узловое сравнение, передайте порог как параметр запроса и отфильтруйте второй раз в коде приложения. Это обычно стабильнее, чем зависеть от синтаксиса расширения.
6. Формы результатов: вы думаете, что получаете значения, но получаете узлы
Одно и то же выражение может вернуть как минимум три формы в разных библиотеках:
["JSON: The Definitive Guide", "How Parsers Work", "Streaming JSON in Practice"] // массив значений
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // путь + значение
[{ "pointer": "/store/book/0/title", "value": "..." }] // JSON Pointer + значение
Это напрямую влияет на три вещи: как фронтенд читает результаты, как результаты дедуплицируются и как результаты запроса становятся операциями записи. Nodelist, определённый RFC 9535, — набор ссылок на узлы, но реализации JavaScript обычно возвращают только значения, потому что ссылки не могут пересекать границы процессов.
Практический совет:
- Всегда читайте одно значение через
[0]илиvalue(); никогда не предполагайте, что результат — скаляр; - Когда нужно записать обратно, выберите реализацию, возвращающую JSON Pointers, или постройте pointer сами;
- Пустой результат и результат
[null]различны; проверяйте длину, а не истинность.
7. Различия библиотек: прогоняйте набор соответствия перед миграцией
| Возможность | Оригинал Goessner | jsonpath-plus | Строгий RFC 9535 |
|---|---|---|---|
Скобки фильтра ?() |
Обязательны | Совместимы | Необязательны |
Кросс-корневая ссылка $.x |
Поддерживается | Поддерживается | Запрещена |
| Скриптовые выражения | Поддерживаются | Поддерживаются | Запрещены |
| Отрицательные индексы и шаги | Поддерживаются | Поддерживаются | Поддерживаются |
| Возврат путей | Настраиваемый | Настраиваемый | Не указан |
Функции вроде length() |
Нет | Расширения | Стандартные функции |
Перед миграцией проверьте как минимум четыре вещи: обязательны ли скобки, может ли $ появляться внутри фильтра, как ведут себя отрицательные шаги и структура результатов. Официальный набор тестов соответствия покрывает большинство синтаксических различий и предотвращает тихие сбои вроде «три строки исчезли после смены библиотеки».
8. Производительность и контрольный список неправильного использования
Производительность сводится к одному предложению: JSONPath — полный обход, а .. — самый дорогой вид полного обхода.
- Для документов с более чем 100,000 узлов сначала сузьте область явным путём, затем фильтруйте подмножество;
- Компилируйте выражение один раз и переиспользуйте; не переразбирайте строку внутри цикла;
- Переходите на
jqили код для агрегации (суммирование, группировка, сортировка); JSONPath только выбирает; - Помещайте условия как можно раньше:
$.store.book[?(@.price < 10)]сканирует гораздо меньше нерелевантных узлов, чем$..[?(@.price < 10)].
Контрольный список неправильного использования:
- [ ] Трактовка проверки существования как проверки булевой истинности;
- [ ] Использование
$..вместо известной глубины; - [ ] Зависимость от скриптовых выражений для кросс-узлового сравнения;
- [ ] Предположение, что результат всегда массив значений;
- [ ] Разрешение контролируемых пользователем выражений без ограничений длины и вложенности.
Вопросы и ответы
Почему @.stock всё равно совпадает, когда поле равно false?
Потому что голый путь внутри фильтра означает «поле существует», а не «значение истинно». Чтобы проверить булево, сравнивайте явно с @.stock == true. Это самое большое семантическое различие между JSONPath и истинностью JavaScript.
Стабилен ли порядок рекурсивного спуска?
Нет. RFC 9535 задаёт семантику набора узлов, и порядок обхода зависит от того, как реализация посещает детей. Когда нужен фиксированный порядок, локализуйте массив и читайте по индексу или сортируйте результаты в коде приложения.
Можно ли комбинировать срезы и фильтры?
Да, но помните область. $.store.book[0:2][?(@.stock)] фильтрует только две книги, созданные срезом. Чтобы сначала фильтровать, а затем срезать, пишите $.store.book[?(@.stock)][0:2]; другой порядок даёт другой результат.
Почему jsonpath-plus поддерживает $.expensive, когда стандарт это запрещает?
Потому что фильтры определяются только в терминах текущего узла @. Кросс-корневая ссылка делает выражение зависимым от контекста всего документа, что трудно анализировать статически и переносить между языками. Передайте порог как параметр вместо встраивания его в выражение.
Как не дать рекурсивному спуску сканировать нерелевантные узлы?
Пишите явный путь всякий раз, когда знаете глубину, сначала сузьте область и фильтруйте только подмножество вместо использования $.. на месте известного уровня. Помещайте условия как можно раньше: $.store.book[?(@.price < 10)] сканирует гораздо меньше нерелевантных узлов, чем $..[?(@.price < 10)]. Для документов с более чем 100,000 узлов сделайте это первым.