UNION и ENUM: теговый тип и dictionary-семантика
Два типа из этого урока решают противоположные задачи. UNION нужен, когда колонка должна вмещать значения разных типов — то «целое», то «строку». ENUM нужен, когда колонка, наоборот, ограничена очень узким набором допустимых строковых значений — статус заказа из четырёх вариантов, день недели из семи. Объединяет их то, что оба часто понимают поверхностно, а у обоих есть важная внутренняя механика.
Главная мысль урока — про ENUM. На уровне SQL ENUM выглядит как «строка из ограниченного набора», и легко решить, что это просто VARCHAR с проверкой. Но на уровне хранения ENUM — это словарь (dictionary): сами строки хранятся один раз в метаданных типа, а в колонке лежат компактные целочисленные ссылки. Это даёт ENUM и компактность, и скорость, которых у VARCHAR нет.
UNION: значение одного из нескольких типов
В строго типизированной СУБД у колонки один тип. Но иногда данные по своей природе разнородны: поле «значение измерения» в одних строках число, в других — текстовая метка вроде 'N/A'; поле «идентификатор» то числовое, то строковое. UNION — теговый тип (tagged union), который позволяет колонке хранить значение одного из заранее перечисленных типов.
UNION объявляется как набор именованных вариантов (members), каждый со своим типом:
-- Колонка result может быть либо числом, либо текстом, либо булевым
CREATE TABLE measurements (
id INTEGER,
result UNION(num DOUBLE, label VARCHAR, flag BOOLEAN)
);
INSERT INTO measurements VALUES
(1, 42.5), -- сохранится в варианте num
(2, 'pending'), -- сохранится в варианте label
(3, true); -- сохранится в варианте flag
Слово «теговый» здесь буквально. Каждое значение UNION несёт не только данные, но и тег — какой именно вариант сейчас активен. Значение 42.5 хранится с тегом num, 'pending' — с тегом label. Тег — это часть значения, и по нему всегда известно, как данные интерпретировать.
Узнать активный вариант и достать значение конкретного варианта можно функциями union_tag и union_extract, а также точечной записью:
-- union_tag — какой вариант активен; точка — значение варианта
SELECT
id,
union_tag(result) AS active_member,
result.num AS as_num, -- NULL, если активен не num
result.label AS as_label
FROM measurements;
result.num достаёт значение, только если активен вариант num; иначе вернёт NULL. Это безопасно: вы спрашиваете «дай мне число, если оно тут число».
UNION отличается от простого хранения всего как VARCHAR или как JSON тем, что каждый вариант — это полноценный типизированный слот. Число внутри UNION хранится как настоящий DOUBLE, со всеми его свойствами и сжатием, а не как текст. UNION сохраняет типизацию там, где разнородность данных неизбежна, вместо того чтобы стирать её приведением всего к строке.
UNION — относительно редкий тип. Чаще всего разнородность данных стоит устранять на этапе моделирования: разнести разные типы значений по разным колонкам или нормализовать данные. UNION уместен, когда разнородность по-настоящему неустранима и присуща домену.
ENUM: ограниченный набор строк
ENUM — противоположная задача. Это тип для колонки, чьи значения берутся из небольшого, заранее известного и фиксированного множества строк. Классические примеры: статус заказа ('new', 'paid', 'shipped', 'cancelled'), приоритет ('low', 'medium', 'high'), день недели.
ENUM создаётся как именованный тип с перечислением допустимых значений:
-- Объявляем тип-перечисление
CREATE TYPE order_status AS ENUM ('new', 'paid', 'shipped', 'cancelled');
-- Используем его как тип колонки
CREATE TABLE orders (
id INTEGER,
status order_status
);
INSERT INTO orders VALUES (1, 'paid'), (2, 'new'), (3, 'shipped');
На уровне SQL ENUM-колонка ведёт себя как строковая: сравнивается со строковыми литералами, выводится как текст. Но есть жёсткое ограничение — попытка вставить значение вне набора отвергается:
-- Значение вне набора — ошибка на этапе вставки
INSERT INTO orders VALUES (4, 'refunded');
-- ОШИБКА: 'refunded' не входит в ENUM order_status
Это первая польза ENUM — валидация на уровне типа. Колонка физически не может содержать значение вне допустимого набора. Опечатка 'payed' вместо 'paid' будет отклонена базой, а не просочится в данные, как с обычным VARCHAR.
ENUM на уровне хранения: это словарь
Вот ключевая часть урока. ENUM — это не «VARCHAR с проверкой». На уровне хранения ENUM устроен как dictionary, и это меняет всё.
Когда вы создаёте CREATE TYPE order_status AS ENUM (...), DuckDB строит словарь: каждой строке набора назначается небольшое целое число — позиция в перечислении. 'new' получает 0, 'paid' — 1, 'shipped' — 2, 'cancelled' — 3. Сами строки 'new', 'paid' и так далее хранятся ровно один раз — в метаданных типа.
В самой колонке status строки не хранятся вообще. Там лежат только целочисленные ссылки в словарь. Колонка из миллиона заказов со статусами — это миллион маленьких целых, а не миллион строк.
Из dictionary-устройства следуют три практических преимущества ENUM перед VARCHAR.
Первое — компактность. Целое число-ссылка занимает 1-2 байта (ширина зависит от размера набора), а строка 'cancelled' — девять байт плюс накладные расходы. Колонка статусов в ENUM в разы меньше той же колонки в VARCHAR. Это меньше места на диске и, что важнее для аналитики, меньше данных читать с диска при сканировании.
Второе — скорость сравнения и группировки. Сравнить два ENUM-значения — это сравнить два целых числа, самую быструю операцию. GROUP BY status по ENUM-колонке группирует целые, а не строки. Это заметно быстрее, чем работа со строковой колонкой, где сравнение строк дороже.
Третье — валидация бесплатно. Проверка «значение в наборе» — это естественное следствие словаря: если строки нет в словаре, для неё просто нет номера. Валидация не требует отдельного механизма.
| Аспект | ENUM | VARCHAR |
|---|---|---|
| Что лежит в колонке | Целочисленные ссылки (1-2 байта) | Сами строки |
| Размер на диске | Маленький | Больше, зависит от длины строк |
| Сравнение и GROUP BY | Сравнение целых — быстро | Сравнение строк — дороже |
| Значение вне набора | Отвергается | Принимается любое |
| Гибкость набора | Фиксирован, расширяется через ALTER TYPE | Любое значение |
Dictionary-семантика ENUM — родственник схемы сжатия dictionary encoding, которой DuckDB сжимает обычные VARCHAR-колонки с повторами (это разбирается в модуле про компрессию). Разница в том, что для VARCHAR словарь строится автоматически на лету при сжатии сегмента, а для ENUM словарь задан явно и заранее, на уровне типа. ENUM — это, по сути, «dictionary encoding, зафиксированное в схеме».
Когда применять ENUM
ENUM оправдан, когда выполнены оба условия: множество значений мало и оно стабильно. «Мало» — десятки значений, не тысячи. «Стабильно» — набор меняется редко, на уровне изменения схемы, а не данных.
Хорошие кандидаты: статусы (заказа, платежа, задачи), категории фиксированного классификатора, дни недели, уровни (приоритет, severity лога), коды стран, если их множество в системе ограничено. Эти колонки одновременно низкокардинальны (мало уникальных значений) и часто участвуют в WHERE и GROUP BY — именно там dictionary-устройство даёт выигрыш.
Плохие кандидаты для ENUM: имена пользователей, email, произвольный текст, идентификаторы — всё, где значений много или они постоянно новые. Если набор расширяется с каждой строкой данных, ENUM не подходит: добавление значения в ENUM — это ALTER TYPE, операция уровня схемы, и делать её на каждую новую строку нельзя.
Расширение ENUM новым значением выполняется через ALTER TYPE ... ADD VALUE — это изменение определения типа, а не данных. Если домен колонки растёт непредсказуемо и часто, постоянные ALTER TYPE становятся обузой, и здесь VARCHAR (возможно, с проверкой через CHECK или со словарным сжатием, которое DuckDB применит сам) удобнее. ENUM — для действительно стабильных наборов.
Итог урока: UNION и ENUM — про противоположные ситуации. UNION принимает разнородность, когда она неустранима, сохраняя типизацию каждого варианта через тег. ENUM сужает колонку до фиксированного набора строк и за счёт dictionary-устройства делает её компактной и быстрой. Понимать, что ENUM — это словарь с целыми ссылками, а не «VARCHAR с галочкой», важно: именно из этого следуют все его преимущества.
Попробуй сам
Запустите DuckDB CLI:
CREATE TYPE order_status AS ENUM ('new', 'paid', 'shipped', 'cancelled');
CREATE TABLE orders (id INTEGER, status order_status);
INSERT INTO orders VALUES (1, 'paid'), (2, 'new'), (3, 'shipped'), (4, 'paid');
Задания:
- Сгруппируйте заказы по
statusсо счётчиком. Запрос работает со строками или с целыми числами внутри? Объясните. - Попробуйте
INSERT INTO orders VALUES (5, 'returned'). Посмотрите на ошибку и объясните, как dictionary-устройствоENUMделает эту проверку естественной. - Расширьте набор:
ALTER TYPE order_status ADD VALUE 'returned'. Теперь повторите вставку из задания 2. Объясните, почему это операция уровня схемы. - Создайте таблицу с колонкой типа
UNION(n INTEGER, s VARCHAR). Вставьте в неё одну строку с числом и одну со строкой. Черезunion_tagвыведите, какой вариант активен в каждой строке. - Объясните своими словами, почему колонка статусов в
ENUMзанимает на диске меньше места, чем та же колонка вVARCHAR.