LIST и MAP: отличия и выбор
STRUCT из предыдущего урока хранит фиксированный набор именованных полей — запись известной формы. Но иерархичные данные бывают и другими. Иногда в одной ячейке нужно хранить не запись, а коллекцию однотипных значений переменной длины: теги статьи, цены в корзине, координаты маршрута. Иногда нужен словарь — набор пар ключ-значение, где сами ключи заранее неизвестны.
Для этих двух случаев у DuckDB есть LIST и MAP. Они похожи тем, что оба — коллекции переменного размера, и оба часто путают. Но устроены и предназначены они по-разному. В этом уроке разберём оба типа, их физическое хранение и — главное — однозначное правило, когда что выбирать.
LIST: упорядоченная коллекция одного типа
LIST — это упорядоченная последовательность значений одного типа, переменной длины. Аналог — массив или список в языке программирования. Тип записывается как тип_элемента[]:
-- Колонка tags — список строк, scores — список целых
CREATE TABLE articles (
id INTEGER,
tags VARCHAR[],
scores INTEGER[]
);
INSERT INTO articles VALUES
(1, ['sql', 'duckdb', 'olap'], [5, 4, 5]),
(2, ['python'], [3]),
(3, [], []);
Три определяющих свойства LIST. Первое — однородность: все элементы списка одного типа. VARCHAR[] — только строки, INTEGER[] — только целые. Список разнотипных значений невозможен. Второе — упорядоченность: у элементов есть позиции, tags[1] — первый, tags[2] — второй; порядок значим и сохраняется. Третье — переменная длина: один список может содержать три элемента, другой — один, третий — ноль; длина не фиксирована.
LIST подходит, когда в ячейке логически лежит «несколько однотипных значений, порядок которых имеет значение или хотя бы стабилен»: список тегов, последовательность измерений, набор идентификаторов.
Как LIST хранится: значения плюс смещения
Физическое устройство LIST элегантно. Наивно список можно было бы хранить так: каждая ячейка — отдельный кусок памяти переменного размера. Но это плохо для колоночного движка — куски разного размера, нет сплошного массива.
DuckDB хранит колонку LIST двумя массивами. Первый — child array: все элементы всех списков колонки, склеенные в один сплошной массив. Если в трёх строках лежат списки [a,b,c], [d], [], то child array — это [a,b,c,d], пять… нет, четыре значения подряд. Второй массив — offsets: для каждой строки он хранит, где в child array начинается её список и какой он длины.
Преимущество такого устройства: child array — это обычная плотная колонка значений одного типа, к ней применимы все колоночные оптимизации и схемы сжатия. Список строки восстанавливается как срез child array по смещению и длине. Длинные списки и короткие хранятся одинаково эффективно — разница лишь в числах в offsets.
Над LIST работает богатый набор функций: len, list_contains, list_sort, list_transform, list_filter, агрегат list (собрать столбец в список). Достать элемент — индексацией tags[1] (нумерация с 1), достать подсписок — срезом tags[2:3] (это разбиралось в модуле про friendly SQL). Развернуть список в строки — функцией UNNEST:
-- UNNEST: одна строка со списком -> несколько строк
SELECT id, UNNEST(tags) AS tag FROM articles;
-- строка с tags=['sql','duckdb','olap'] даст 3 строки
MAP: словарь ключ-значение
MAP — это коллекция пар ключ-значение. Аналог — словарь, hash map, ассоциативный массив. Тип записывается как MAP(тип_ключа, тип_значения):
-- Колонка attributes — словарь строка -> целое
CREATE TABLE products (
id INTEGER,
attributes MAP(VARCHAR, INTEGER)
);
INSERT INTO products VALUES
(1, MAP {'weight': 500, 'width': 20}),
(2, MAP {'weight': 300, 'height': 15, 'depth': 8});
Ключевое отличие MAP от STRUCT — набор ключей не фиксирован и может различаться от строки к строке. У продукта 1 ключи weight, width; у продукта 2 — weight, height, depth. Это невозможно для STRUCT, где схема одна на всю колонку. MAP нужен ровно тогда, когда множество ключей заранее неизвестно или варьируется: атрибуты товаров из разных категорий, произвольные пользовательские настройки, разреженные характеристики.
Доступ к значению MAP — по ключу, в квадратных скобках. Важно: результат всегда LIST значений (на случай повторяющихся ключей), даже если ключ один:
-- Доступ по ключу возвращает LIST
SELECT attributes['weight'] AS w FROM products;
-- w = [500] для продукта 1 — это список, не скаляр
-- Достать скаляр — взять первый элемент
SELECT attributes['weight'][1] AS w FROM products;
Над MAP работают функции map_keys (список ключей), map_values (список значений), map_contains (есть ли ключ), cardinality (число пар).
Доступ к несуществующему ключу MAP возвращает не ошибку, а пустой список. attributes['nonexistent'] для строки без такого ключа даст []. Это значит, что attributes['nonexistent'][1] вернёт NULL, а не упадёт. Учитывайте это: «ключа нет» и «значение по ключу равно NULL» в MAP дают похожий результат при индексации первым элементом.
STRUCT, LIST, MAP: главное правило выбора
Три вложенных типа закрывают три разные формы данных. Путаница между ними — частая ошибка моделирования. Однозначное правило:
| Форма данных | Тип | Признак |
|---|---|---|
| Фиксированный набор именованных полей разных типов | STRUCT | Поля известны заранее, схема одна на колонку |
| Коллекция однотипных значений, порядок значим | LIST | Один тип, переменная длина, есть индексы |
| Пары ключ-значение, ключи варьируются по строкам | MAP | Набор ключей заранее неизвестен или разный |
Развилка STRUCT против MAP — самая важная и самая частая. Оба похожи на «объект с полями», но: STRUCT — фиксированная схема, поля как часть типа, доступ через точку, поля могут быть разных типов. MAP — динамический набор ключей, ключи как данные, доступ по ключу-значению, все значения одного типа, все ключи одного типа.
Практический ориентир: если вы можете перечислить все возможные «поля» при проектировании таблицы и они одинаковы для всех строк — это STRUCT. Если поля приходят из данных, их множество открыто или разное у разных строк — это MAP. Хранить в MAP то, что на самом деле фиксировано, — значит терять типизацию (STRUCT проверяет поля, MAP нет) и читаемость. Хранить в STRUCT то, что варьируется, — невозможно в принципе.
Вложенные типы свободно комбинируются: LIST от STRUCT (список заказов, каждый — структура), STRUCT с полем-LIST, MAP со значениями-STRUCT. Это позволяет смоделировать почти любую иерархию прямо в одной колонке.
Если данные совсем бесформенны — структура произвольна, типы значений не предсказуемы, — ни STRUCT, ни MAP не подходят: STRUCT требует схему, MAP требует единый тип ключей и единый тип значений. Для по-настоящему произвольных полуструктурированных данных в DuckDB 1.5 появился тип VARIANT — он разбирается в отдельном уроке этого модуля.
Попробуй сам
Создайте таблицы:
CREATE TABLE articles AS
SELECT * FROM (VALUES
(1, ['sql', 'duckdb', 'olap']),
(2, ['python', 'sql']),
) t(id, tags);
CREATE TABLE products AS
SELECT * FROM (VALUES
(1, MAP {'weight': 500, 'width': 20}),
(2, MAP {'weight': 300, 'height': 15}),
) t(id, attributes);
Задания:
- Через
UNNESTразвернитеtagsв отдельные строки. Посчитайте, сколько раз встречается каждый тег по всем статьям. - Достаньте первый тег каждой статьи индексацией
tags[1]и проверьте черезlist_contains, есть ли в списке тег'sql'. - Для каждого продукта выведите
map_keys(attributes)иmap_values(attributes). Обратите внимание, что наборы ключей у строк разные. - Достаньте
attributes['weight']— посмотрите, что результат этоLIST, а не скаляр. Затем достаньте скаляр черезattributes['weight'][1]. - Запросите несуществующий ключ
attributes['nonexistent']иattributes['nonexistent'][1]. Объясните, почему первое даёт пустой список, а второеNULL.