Сложные типы: ARRAY, MAP, ROW, JSON
Реляционная модель в чистом виде говорит: ячейка таблицы — это одно скалярное значение. Но данные в data lake выглядят иначе. Событие из Kafka — это вложенный JSON: список товаров в заказе, словарь атрибутов, объект пользователя внутри объекта события. Parquet и ORC умеют хранить такую вложенность нативно. И Trino, чтобы работать с этими источниками честно, поддерживает сложные (контейнерные) типы: ARRAY, MAP, ROW, плюс полуструктурированный JSON.
Этот урок — про то, что это за типы, как они представлены в памяти, как к ним обращаться и в чём принципиальная разница между типизированными контейнерами и нетипизированным JSON.
Три контейнера и одна карта
Сложные типы — это типы, чьё значение содержит другие значения. Их три, и они покрывают три разные формы вложенности.
| Тип | Форма | Аналогия | Пример объявления |
|---|---|---|---|
ARRAY | упорядоченный список однотипных элементов | список / массив | ARRAY(INTEGER) |
MAP | набор пар ключ-значение | словарь / хэш-таблица | MAP(VARCHAR, INTEGER) |
ROW | фиксированный набор именованных типизированных полей | структура / запись / кортеж | ROW(name VARCHAR, age INTEGER) |
Принципиальное различие между ними — в том, что фиксировано. У ARRAY фиксирован тип элемента, но не длина: элементов может быть ноль или миллион, все одного типа. У MAP фиксированы типы ключа и значения, но не набор ключей: ключи произвольны и определяются данными. У ROW фиксирована вся схема — имена и типы полей заданы в типе, как у строки таблицы; нельзя «добавить поле» в значение, не сменив тип.
ARRAY: упорядоченный список
ARRAY — список однотипных элементов с сохранением порядка. Литерал — ARRAY[...], доступ к элементу — квадратные скобки.
SELECT ARRAY[10, 20, 30] AS arr,
ARRAY[10, 20, 30][2] AS second,
cardinality(ARRAY[10, 20, 30]) AS len;
arr | second | len
--------------+--------+-----
[10, 20, 30] | 20 | 3
Критически важная деталь: индексация массивов в Trino начинается с 1, а не с 0. Это требование SQL-стандарта. arr[1] — первый элемент; arr[0] — ошибка. Программист, пришедший из Python или Java, спотыкается тут регулярно — зафиксируйте сразу.
Для извлечения элементов в строки служит UNNEST — он разворачивает массив в набор строк, по строке на элемент, и применяется в FROM как источник:
SELECT id, tag
FROM (VALUES (1, ARRAY['a', 'b', 'c'])) AS t(id, tags)
CROSS JOIN UNNEST(tags) AS u(tag);
id | tag
----+-----
1 | a
1 | b
1 | c
Одна строка с массивом из трёх элементов превратилась в три строки. UNNEST — основной мост между вложенными данными и плоской реляционной формой; полноценная работа с массивами через функции высшего порядка — тема четвёртого урока модуля.
MAP: пары ключ-значение
MAP — набор ассоциаций ключ-значение, аналог словаря. Литерал собирается функцией MAP(...) из двух массивов — ключей и значений; доступ к значению — по ключу в квадратных скобках.
SELECT m, m['b'], cardinality(m)
FROM (VALUES MAP(ARRAY['a','b','c'], ARRAY[1,2,3])) AS t(m);
m | _col1 | _col2
----------------+-------+-------
{a=1, b=2, c=3}| 2 | 3
Тонкость доступа: обращение m['нет_такого_ключа'] к отсутствующему ключу возвращает ошибку. Чтобы безопасно достать значение с подстановкой по умолчанию, используют element_at(map, key) — он возвращает NULL, если ключа нет, вместо падения запроса. Различие между «бросить ошибку» и «вернуть NULL» — повторяющийся мотив в работе со сложными типами: для контролируемых данных годится прямой доступ, для грязных внешних — безопасные функции.
MAP тоже разворачивается через UNNEST — но в две колонки, ключ и значение:
SELECT k, v
FROM (VALUES MAP(ARRAY['x','y'], ARRAY[10,20])) AS t(m)
CROSS JOIN UNNEST(m) AS u(k, v);
k | v
---+----
x | 10
y | 20
ROW: именованная структура
ROW — фиксированный набор именованных типизированных полей, ближайший аналог структуры или записи. Это естественное представление вложенного объекта: «пользователь» внутри «события».
SELECT u,
u.name AS by_name,
u[2] AS by_position
FROM (VALUES CAST(ROW('Анна', 30) AS ROW(name VARCHAR, age INTEGER))) AS t(u);
u | by_name | by_position
----------------+---------+-------------
{Анна, 30} | Анна | 30
К полю ROW обращаются двумя способами: по имени через точку (u.name) или по позиции в квадратных скобках (u[2], нумерация с 1). Доступ по имени читаемее и устойчивее к перестановке полей — предпочитайте его.
Почему ROW важен с точки зрения «до железа». Когда таблица Parquet содержит вложенный столбец-структуру, и запрос обращается лишь к одному полю этой структуры, Trino умеет прочитать из файла только это поле, не материализуя весь объект. Эта оптимизация называется dereference pushdown — она тема пятого урока восьмого модуля, но связь зафиксируем здесь: типизированный ROW со статически известной схемой делает её возможной. Движок знает раскладку полей и может попросить у коннектора ровно нужное.
JSON: полуструктурированный тип-исключение
JSON стоит особняком. ARRAY, MAP, ROW — типизированы: их структура известна движку статически, на этапе планирования. JSON — нетипизированный контейнер: значение типа JSON хранит произвольный JSON-документ, чья форма заранее не известна. Это полуструктурированные данные в чистом виде.
Отсюда вытекает практическое различие. Из JSON значения извлекаются в рантайме, специальными функциями навигации по пути:
json_extract(json, path)— достаёт фрагмент по JSONPath, результат сноваJSON.json_extract_scalar(json, path)— достаёт скалярное значение какVARCHAR.json_query/json_value— SQL-стандартные функции навигации;json_valueизвлекает скаляр,json_query— фрагмент.json_parse/json_format— разбор строки вJSONи обратная сериализация.
SELECT json_extract_scalar(j, '$.user.city') AS city,
json_value(j, 'lax $.items[0].price') AS first_price
FROM (VALUES JSON '{"user":{"city":"Берлин"},"items":[{"price":42}]}') AS t(j);
city | first_price
--------+-------------
Берлин | 42
Когда что брать. Если форма данных стабильна и известна — задавайте типизированные ARRAY/MAP/ROW: движок проверит типы на этапе планирования, применит pushdown полей, и читается это эффективнее. Если форма данных динамична, разрежена или меняется от записи к записи — JSON честнее: он не навязывает несуществующую схему, а навигация по пути разбирает документ в рантайме.
В релизе 480-481 у Trino есть и третий путь для полуструктурированных данных — тип VARIANT коннектора Iceberg v3. VARIANT — это эффективное двоичное представление полуструктурированных данных с разбором без полной материализации, более производительное, чем хранение и парсинг сырого JSON. Это перспективное направление для лейкхауса на Iceberg. В рамках этого урока удерживайте базовое различие: типизированные контейнеры (ARRAY/MAP/ROW) — со статической схемой, JSON — нетипизированный, разбирается в рантайме.
Вложенность складывается
Сложные типы комбинируются произвольно: ARRAY(ROW(...)), MAP(VARCHAR, ARRAY(INTEGER)), ROW с полем-ARRAY из ROW. Так выглядит реальная схема Parquet-таблицы из data lake — массив товаров, где каждый товар структура, а у структуры есть поле-словарь атрибутов.
SELECT order_id, item.sku, item.qty
FROM (VALUES (
1001,
ARRAY[
CAST(ROW('SKU-1', 2) AS ROW(sku VARCHAR, qty INTEGER)),
CAST(ROW('SKU-2', 5) AS ROW(sku VARCHAR, qty INTEGER))
]
)) AS t(order_id, items)
CROSS JOIN UNNEST(items) AS u(item);
order_id | sku | qty
----------+-------+-----
1001 | SKU-1 | 2
1001 | SKU-2 | 5
Здесь ARRAY(ROW(...)) сначала развёрнут UNNEST в строки, затем у каждой структуры через точку взяты поля. Это типовой паттерн распаковки вложенных данных лейкхауса: UNNEST снимает уровень массива, dereference через точку — уровень структуры. Складывая их, разбирают вложенность любой глубины.
Попробуй сам
На песочнице курса (Trino 481):
-
Выполните
SELECT ARRAY[5,10,15][1], ARRAY[5,10,15][3];и затемSELECT ARRAY[5,10,15][0];. Объясните, почему второй запрос — ошибка, и сформулируйте правило индексации массивов в Trino одной фразой. -
Сравните прямой доступ и безопасный: выполните
SELECT element_at(MAP(ARRAY['a'],ARRAY[1]), 'нет');, затемSELECT MAP(ARRAY['a'],ARRAY[1])['нет'];. Опишите разницу в поведении и укажите, какой вариант выбрать для разбора грязных внешних данных и почему. -
Рассуждение в двух абзацах. Дано: одна таблица — события с полем
attributes, где набор ключей у каждого события свой и заранее не известен; другая — события с полемgeo, всегда содержащим ровноlatitudeиlongitude. Для какого поля разуменJSON, а для какого — типизированныйROW? В ответе свяжите выбор со статической схемой и с тем, на каком этапе (планирование или рантайм) разбирается структура.