Доступ к jsonb и containment
Маркетинг Brew запустил кастомизацию напитка: к заказу можно прицепить «опции» — размер, вид молока, число шотов, список добавок. Беда в том, что набор опций у каждого свой: у кого-то есть extras, у кого-то нет даже ключа milk. Это ровно тот случай, ради которого в схеме появляется jsonb: бесформенные, разреженные данные, которые не хочется раскладывать по сорока nullable-колонкам. Положить-то их легко — а вот доставать и фильтровать надо уметь.
Цель юнита — освоить четыре рабочих оператора доступа к jsonb: -> и ->> (достать значение), #>> (достать по пути) и @> (containment, «содержит»), плюс ? (есть ли ключ). Это фундамент любой работы с jsonb в приложении; глубокий jsonb_path, сборка документов и индексы — в следующих юнитах модуля.
-> против ->>: jsonb или text
Два оператора-близнеца, и путаница между ними — классическая ошибка. -> достаёт значение как jsonb: options -> 'milk' вернёт "oat" — со скобками-кавычками, потому что это всё ещё json-строка. ->> достаёт то же значение как text: options ->> 'milk' вернёт чистое oat. Когда сравниваешь, конкатенируешь или печатаешь — почти всегда нужен ->>. -> нужен, когда лезешь глубже: options -> 'meta' -> 'flags' — цепочка по вложенному объекту, на каждом шаге снова jsonb. Соответственно #> и #>> — те же «достать», но сразу по пути: options #>> '{extras,0}' спускается в массив extras и берёт нулевой элемент как text.
Containment @>: «документ содержит пару»
@> — главный оператор поиска по jsonb. options @> '{"milk":"oat"}' истинно, если в документе слева есть пара "milk":"oat" — независимо от того, что там ещё лежит. Это не равенство всего документа (его почти никогда не проверяют), а «содержит ли». Containment умеет заглядывать и внутрь массивов: options @> '{"extras":["honey"]}' найдёт заказы, где массив extras содержит honey. Один оператор покрывает и плоские поля, и вложенные структуры — и именно его ускоряет GIN-индекс (см. 06-05): на большой таблице @> без индекса — это Seq Scan, а с GIN — Bitmap Index Scan.
?: есть ли ключ
? отвечает на другой вопрос — не «какое значение», а «есть ли вообще такой ключ верхнего уровня». options ? 'extras' истинно для заказов, где extras указан, — даже если массив пуст ([]). Это важная разница с @>: пустой extras ключ имеет, но никакого значения внутри нет, поэтому @> '{"extras":[...]}' его не поймает, а ? 'extras' — поймает. Рядом живут ?| (есть хоть один из ключей) и ?& (есть все ключи) — та же логика для списка.
Что показывает наш код
Лабораторная таблица order_options_lab (в schema.sql) с пятью заказами, у которых options намеренно разношёрстны. Четыре запроса:
SELECT customer,
coalesce((options -> 'milk')::text, '∅') AS milk_jsonb, -- "oat" (jsonb)
coalesce(options ->> 'milk', '∅') AS milk_text, -- oat (text)
coalesce(options #>> '{extras,0}', '∅') AS first_extra -- по пути
FROM order_options_lab ORDER BY id; -- AccessOps
SELECT customer FROM order_options_lab WHERE options @> '{"milk":"oat"}'; -- OatMilkOrders
SELECT customer FROM order_options_lab WHERE options @> '{"extras":["honey"]}'; -- HoneyInExtras
SELECT customer FROM order_options_lab WHERE options ? 'extras'; -- HasExtrasKeycoalesce подменяет отсутствующее значение на ∅ (у Егора нет milk, у Бориса — extras) и заодно даёт sqlc тип string вместо nullable interface{}. Как и юниты 01-04/01-05, этот добавляет свой объект в схему, поэтому make db-reset накатывает его через brew.Apply (схема Brew → DDL+seed юнита → seed Brew).
Запуск
docker compose up -d
make lecture L=07-jsonb-arrays-and-search/07-01-jsonb-access-and-containment T=db-reset
make lecture L=07-jsonb-arrays-and-search/07-01-jsonb-access-and-containmentВывод:
1) Доступ к полям options (-> даёт jsonb с кавычками, ->> — text, #>> — по пути):
КЛИЕНТ -> 'milk' ->> 'milk' size shots #>> '{extras,0}'
Алиса "oat" oat L 2 cinnamon
Борис "cow" cow M 1 ∅
Карина "oat" oat S 1 honey
Дина "soy" soy L 3 ∅
Егор ∅ ∅ M 2 ∅
2) options @> '{"milk":"oat"}' — заказы на овсяном молоке:
Алиса (size L)
Карина (size S)
3) options @> '{"extras":["honey"]}' — в массиве extras есть honey:
Карина
4) options ? 'extras' — указан ключ extras (пустой массив тоже считается):
Алиса
Карина
ДинаПервая таблица показывает главный контраст: -> отдал "oat" в кавычках (это jsonb), ->> — чистое oat (это text), а #>> достал нулевой элемент массива. У Егора нет ключа milk → оба оператора вернули NULL (мы подменили на ∅). Containment @> нашёл по плоской паре (овсяное молоко) и по элементу массива (honey), а ? поймал Дину с пустым extras, которого @> бы не увидел.
Заборчик
jsonb соблазняет сложить в одну колонку всё подряд — и тут же мстит. Граница простая:
- Поле, по которому регулярно фильтруешь, считаешь или джойнишь, — это колонка, а не ключ внутри
jsonb: её проверитCHECKиNOT NULL, на ней живёт обычный B-tree, план запроса предсказуем. - Containment-поиск без GIN — это
Seq Scanпо всей таблице (см. 06-05); индекс под@>обязателен на любой заметной таблице. ?-фильтр по отсутствующему ключу легко даёт сюрпризы трёхзначной логики — её полностью разбирает 03-06.- Правило:
jsonb— для по-настоящему бесформенного и разреженного; всё, на что опирается логика приложения, выноси в колонки.
В проде размер документа тоже не бесплатен — об этом ровно следующий юнит.
Что забрать с собой
->достаёт значение какjsonb("oat"в кавычках),->>— какtext(oat);#>/#>>делают то же по пути ('{extras,0}').@>(containment) — «документ содержит пару ключ-значение», работает и для плоских полей, и внутри массивов; именно его ускоряет GIN (06-05).?— «есть ли ключ верхнего уровня» (ловит даже пустой массив);?|/?&— для списка ключей.- Отсутствующий ключ — это
NULL: оборачивайcoalesce, чтобы получить определённый тип и поведение.
Дальше — 07-02 «Когда НЕ нужен jsonb»: у гибкости есть физическая цена (write-amplification) и семантическая (нет per-field ограничений) — посмотрим на оба счёта и решим, где jsonb уместен, а где это отложенная боль.