JSON Toolkit

Фильтры JSONPath в глубину: рекурсия, срезы и совпадение по нескольким условиям

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

Средний~10 мин чтенияОпубликовано 2026-10-31Обновлено 2026-10-31
Обложка фильтров 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 существует

Три вещи, которые надо понять:

  1. $..book находит каждый узел с именем book, независимо от его глубины;
  2. Обход — в глубину, и порядок вывода зависит от того, как реализация посещает детей;
  3. .. не может использовать какой-либо индекс, поэтому его стоимость масштабируется с общим числом узлов.

В результате $..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 })

Скриптовые выражения создают три проблемы:

  1. Переносимость падает до нуля. Смена библиотеки означает переписывание каждого выражения;
  2. Граница безопасности исчезает. Когда выражения приходят от пользователей, вычисление скрипта передаёт выполнение кода;
  3. Статическая оптимизация становится невозможной. Реализация может только интерпретировать узел за узлом, что медленно и трудно для кеширования.

Если вам действительно нужно кросс-узловое сравнение, передайте порог как параметр запроса и отфильтруйте второй раз в коде приложения. Это обычно стабильнее, чем зависеть от синтаксиса расширения.

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 узлов сделайте это первым.