Привет, Хабр!

До PostgreSQL 17 работа с JSON‑массивами внутри JSONB‑столбца выглядела примерно так: jsonb_array_elements для развёртывания, затем цепочка ->> для извлечения полей, ::numeric для приведения типов, и обязательный LATERAL JOIN, чтобы это всё склеить. Читаемость на уровне регулярных выражений: автор понимает, что написал, остальные гадают.

PostgreSQL 17 добавил JSON_TABLE — стандартную SQL‑функцию (SQL/JSON, часть стандарта SQL:2016), которая превращает JSON‑документ в виртуальную таблицу. Один вызов вместо цепочки функций. С типизацией, обработкой ошибок и вложенными путями. Разберём, что умеет и чем отличается от привычных подходов.

Проблема: JSON‑массив внутри столбца

Таблица orders, столбец data типа jsonb. Внутри — информация о заказе, включая массив товаров:

{
  "order_id": 1001,
  "customer": "Иванов",
  "items": [
    {"product": "Ноутбук", "qty": 1, "price": 75000},
    {"product": "Мышь", "qty": 2, "price": 1500},
    {"product": "Клавиатура", "qty": 1, "price": 3500}
  ],
  "status": "shipped"
}

Задача: получить плоскую таблицу «заказ — товар — количество — цена» для аналитики.

Способ до PostgreSQL 17:

SELECT 
    (data->>'order_id')::int AS order_id,
    data->>'customer' AS customer,
    item->>'product' AS product,
    (item->>'qty')::int AS qty,
    (item->>'price')::numeric AS price
FROM orders,
     LATERAL jsonb_array_elements(data->'items') AS item;

Работает, но каждое поле — отдельное выражение ->> с кастом. Типизация ручная. Если qty вдруг не число — ::int бросит ошибку на всю выборку. Обработка null и ошибок на совести.

Способ с JSON_TABLE (PostgreSQL 17):

SELECT jt.*
FROM orders,
     JSON_TABLE(
         data,
         '$.items[*]' COLUMNS (
             product     VARCHAR(200)  PATH '$.product',
             qty         INT           PATH '$.qty',
             price       NUMERIC(12,2) PATH '$.price'
         )
     ) AS jt;

Одна функция. Путь к массиву — '$.items[*]', каждый столбец — имя, тип, jsonpath до значения. PostgreSQL сам разворачивает массив, извлекает поля и приводит к указанным типам.

Синтаксис подробно

JSON_TABLE(
    json_expression,          -- JSONB-столбец или выражение
    json_path                 -- JSONPath к корню итерации
    COLUMNS (
        column_name type PATH json_path    -- Обычный столбец
            [DEFAULT expr ON EMPTY]        -- Значение, если путь не найден
            [DEFAULT expr ON ERROR],       -- Значение, если ошибка приведения типа
        
        column_name FOR ORDINALITY,        -- Порядковый номер (1, 2, 3...)
        
        NESTED PATH json_path COLUMNS (    -- Вложенный массив
            ...
        )
    )
)

Разберём по частям.

  1. PATH — jsonpath‑выражение относительно текущего элемента массива. '$.product' — поле product текущего элемента.

  2. DEFAULT... ON EMPTY — что вернуть, если путь не найден в документе (поле отсутствует). Без этого — NULL.

  3. DEFAULT... ON ERROR — что вернуть, если значение не конвертируется в указанный тип (например, строка "abc" в INT). Без этого будет ошибка.

SELECT jt.*
FROM orders,
     JSON_TABLE(
         data,
         '$.items[*]' COLUMNS (
             row_num     FOR ORDINALITY,
             product     VARCHAR(200)  PATH '$.product',
             qty         INT           PATH '$.qty'    DEFAULT 0 ON ERROR,
             price       NUMERIC(12,2) PATH '$.price'  DEFAULT 0.00 ON EMPTY,
             discount    NUMERIC(5,2)  PATH '$.discount' DEFAULT 0.00 ON EMPTY
         )
     ) AS jt;

FOR ORDINALITY — столбец с порядковым номером элемента в массиве (1, 2, 3...). Применимо, когда порядок товаров в заказе имеет значение.

DEFAULT 0 ON ERROR — если qty содержит не число (например, "два"), вместо ошибки вернётся 0.

Вложенные массивы: NESTED PATH

JSON‑документ может содержать массивы внутри массивов. Например, товар в заказе имеет список опций:

{
  "items": [
    {
      "product": "Ноутбук",
      "options": [
        {"name": "RAM", "value": "16GB"},
        {"name": "SSD", "value": "512GB"}
      ]
    }
  ]
}

NESTED PATH разворачивает вложенный массив с сохранением связи с родительским элементом:

SELECT jt.*
FROM orders,
     JSON_TABLE(
         data,
         '$.items[*]' COLUMNS (
             product VARCHAR(200) PATH '$.product',
             NESTED PATH '$.options[*]' COLUMNS (
                 option_name  VARCHAR(50) PATH '$.name',
                 option_value VARCHAR(50) PATH '$.value'
             )
         )
     ) AS jt;

Результат:

 product  | option_name | option_value
----------+-------------+--------------
 Ноутбук  | RAM         | 16GB
 Ноутбук  | SSD         | 512GB

Без NESTED PATH это потребовало бы двойного jsonb_array_elements с LATERAL и ручным JOIN. JSON_TABLE делает это декларативно.

Фильтрация внутри JSON_TABLE

JSONPath в JSON_TABLE поддерживает фильтры:

-- Только товары дороже 5000
SELECT jt.*
FROM orders,
     JSON_TABLE(
         data,
         '$.items[*] ? (@.price > 5000)' COLUMNS (
             product VARCHAR(200) PATH '$.product',
             price   NUMERIC     PATH '$.price'
         )
     ) AS jt;

Фильтр ? (@.price > 5000) — часть JSONPath, выполняется до формирования строк. PostgreSQL не разворачивает весь массив, а сразу отсекает неподходящие элементы.

JSON_TABLE + JOIN с реляционными данными

Главная сила JSON_TABLE — бесшовная интеграция с обычным SQL:

SELECT 
    o.id AS order_id,
    c.name AS customer_name,
    jt.product,
    jt.qty,
    jt.price,
    jt.qty * jt.price AS line_total,
    p.stock AS current_stock
FROM orders o
JOIN customers c ON c.id = o.customer_id
CROSS JOIN LATERAL JSON_TABLE(
    o.data,
    '$.items[*]' COLUMNS (
        product     VARCHAR(200) PATH '$.product',
        qty         INT          PATH '$.qty',
        price       NUMERIC      PATH '$.price',
        product_sku VARCHAR(50)  PATH '$.sku'
    )
) AS jt
LEFT JOIN products p ON p.sku = jt.product_sku
WHERE jt.price > 1000
ORDER BY line_total DESC;

JSON‑данные развёрнуты в строки, тут же соединены с реляционными таблицами customers и products, отфильтрованы и отсортированы. Один запрос, который раньше потребовал бы CTE, подзапрос и пару LATERAL JOIN.

Производительность: что учитывать

Под капотом PostgreSQL разворачивает JSON‑массив в строки, аналогично jsonb_array_elements. Разница лишь в удобстве, типизации и обработке ошибок, а не в фундаментальной скорости.

А вот что реально влияет на производительность:

GIN‑индекс на JSONB‑столбце не ускоряет JSON_TABLE напрямую. Индекс помогает отфильтровать строки таблицы до развёртывания. Поэтому важен WHERE‑фильтр на уровне основной таблицы:

-- Медленно: развернуть ВСЕ заказы, потом фильтровать
SELECT jt.* FROM orders, JSON_TABLE(...) jt WHERE jt.price > 1000;

-- Быстрее: сначала отфильтровать заказы, потом развернуть
SELECT jt.* FROM orders
WHERE data @> '{"status": "shipped"}'  -- GIN-индекс работает здесь
CROSS JOIN LATERAL JSON_TABLE(...) jt
WHERE jt.price > 1000;

Размер JSON‑массива у нас работает как прямой множитель. Если в каждом заказе 3 товара — всё быстро. Если 3000 — развёртывание будет дорогим. Для аналитики по огромным массивам рассмотрите материализацию: ETL‑процесс, который разворачивает JSON в отдельную реляционную таблицу и обновляет её по расписанию.

Миграция с jsonb_array_elements

Если у вас уже есть запросы на jsonb_array_elements, переход на JSON_TABLE — рефакторинг, а не переписывание. Логика та же, меняется синтаксис. Основной мотив перехода — читаемость и обработка ошибок (DEFAULT ON ERROR вместо CASE WHEN обёрток).

Если проект ещё на PostgreSQL 16 или ниже — JSON_TABLE недоступен, и jsonb_array_elements остаётся единственным вариантом. Миграция на PostgreSQL 17 стоит того по многим причинам (vacuum improvements, WAL optimizations, parallel GIN builds), и JSON_TABLE — хорошая плюшка сверху.

Пока работа с PostgreSQL ограничивается простыми таблицами и типовыми запросами, глубина не так критична. Но как только появляются JSON-данные, сложная логика выборок, вопросы производительности и разбор инцидентов, нужен уже другой уровень понимания базы. Курс «Администрирование PostgreSQL. Экспертный уровень» — для тех, кто хочет разбираться в таких сценариях не на уровне догадок.

А если хотите сначала точечно посмотреть на смежные темы в открытом формате, можно обратить внимание на несколько открытых уроков:

  • 21 апреля, 20:00 «Функции в SQL: вычисления и переиспользование кода». Записаться

  • 5 мая, 20:00 «Postgres + JSON: реляционная мощь, документная гибкость». Записаться