В первом модуле мы выучили: реляция требует атомарных значений. Это правило 1-й нормальной формы — никаких списков и таблиц внутри ячейки. PostgreSQL это правило демонстративно нарушает: у него есть полноценный тип ARRAY, который превращает одну ячейку в упорядоченный список значений.
В мире классической теории это «зло». В мире практики иногда это — единственный разумный выбор. Этот урок про то, как им пользоваться и когда останавливаться.
Что такое массив в Postgres
Массив — это
[] к имени типа:
TEXT[]— массив строк.INT[]— массив целых.DATE[],TIMESTAMPTZ[], дажеJSONB[]— всё работает.
Литерал записывается через ARRAY[...] или строку в фигурных скобках с явным cast: '{a,b,c}'::TEXT[].
В нашей вселенной для этого модуля у customers появилась колонка tags TEXT[] — список меток вроде ['vip', 'frequent']. Посмотрим:
Что лежит в tags — кратко обо всех клиентах:
Обрати внимание: у Аньи tags = ['vip','early_adopter'], у Гали tags = [] (пустой массив, не NULL), у Ирины — NULL. Это три разных состояния: «список с элементами», «список пустой» и «значение неизвестно». Их часто путают, и из этого вырастает половина багов с массивами.
NULL — мы не знаем теги. Пустой массив — мы знаем, что тегов нет. Непустой массив — есть конкретные теги.
Операторы массивов
Самые полезные — четыре оператора, которые работают как операции над множествами:
=— массивы равны (одинаковые элементы в одинаковом порядке).@>— левый содержит правый.[1,2,3] @> [2]истинно.<@— левый содержится в правом. Симметрично к@>.&&— массивы пересекаются (есть хотя бы один общий элемент).
Это не teория — это то, как реально фильтруют по тегам в продакшене.
Найди VIP-клиентов через оператор содержания:
Клиенты, у которых есть хотя бы один из этих тегов (пересечение):
&& особенно удобен для «или»: вместо длинной цепочки tags @> ARRAY['a'] OR tags @> ARRAY['b'] OR ... пишешь один оператор.
Доступ к элементам и слайсы
В Postgres массивы 1-индексированные (не как в Python). Первый элемент — это tags[1], не tags[0]. Выход за границу не падает — возвращает NULL.
Достать первый тег и попробовать выйти за границу:
Слайс — array[from:to], оба индекса включительны:
Первые два элемента массива:
unnest и array_agg: мост между «массивом» и «строками»
Эти две функции — главные. Они обратимы друг другу.
unnest(arr)— разворачивает массив в набор строк. Использовать вFROMилиSELECT-списке.array_agg(expr)— собирает значения колонки в один массив. Это агрегатная функция, какSUMилиCOUNT.
Развернуть теги: каждый тег — отдельная строка:
Это та самая операция, которая «возвращает» нас в 1NF. Получив развёрнутый view, можно делать GROUP BY, JOIN — всё, что обычно.
Сколько клиентов на каждый тег — мини-статистика популярности:
А обратная операция — array_agg — собирает значения обратно. Часто пара JOIN + GROUP BY + array_agg заменяет N+1 запросов в приложении:
Для каждого клиента — массив SKU, которые он покупал хотя бы раз:
Обрати внимание на array_agg(DISTINCT p.sku ORDER BY p.sku) — внутри агрегата можно сразу применить DISTINCT и ORDER BY. Это удобно: не нужно сначала собирать, потом сортировать отдельно.
array_length и cardinality — длина массива
array_length(arr, dim) возвращает длину массива по измерению dim. Postgres поддерживает многомерные массивы, поэтому dim обязателен — обычно 1.
Главный подвох: array_length от пустого массива возвращает NULL, не 0. Это исторический выбор, который ломает половину наивных запросов. Лекарство — cardinality, которая всегда возвращает INT:
Сравни array_length и cardinality на пустом массиве:
В новом коде — пиши cardinality. В легаси-коде, скорее всего, COALESCE(array_length(...), 0) — это идиома предыдущей эпохи.
Конструкторы и преобразования
Кроме литералов есть два важных способа создать массив:
ARRAY[...]— конструктор из выражений:ARRAY[1, 2, 3],ARRAY[col1, col2].ARRAY(SELECT ...)— массив из результатов подзапроса. Полезно, когда хотите собрать значения колонки в массив безGROUP BY.
ARRAY(SELECT) — собрать список SKU всех ноутбуков в один массив, без GROUP BY:
И обратное — string_to_array / array_to_string для конвертации в текст и обратно (как split / join в других языках):
Распарсим строку с разделителями в массив и обратно:
Это типичный паттерн при импорте CSV-полей с разделителями: сначала string_to_array, потом unnest, потом обычные SQL-операции.
Многомерные массивы
Postgres поддерживает массивы любой размерности: INT[][] — двумерный, и так далее. На практике используется очень редко — обычно лучше отдельная таблица или JSONB. Но они есть, и из-за их существования array_length требует параметр-размерность.
SELECT ARRAY[[1,2,3],[4,5,6]]; -- 2D, 2x3
Главное ограничение многомерных массивов: все внутренние массивы должны быть одинаковой длины. Зубчатые (jagged) массивы не поддерживаются.
Когда массив — это плохо
Каждый раз, когда ты кладёшь в ячейку список, ты нарушаешь 1NF — и теряешь часть силы реляционной модели:
- Невозможно сослаться на элемент массива через
FOREIGN KEY. Если вtagsлежит['vip','frequent'], СУБД не проверит, что эти теги существуют в справочнике. - Обновить один элемент дорого: Postgres переписывает массив целиком. Это не «изменить ячейку», это «вставить новую версию строки» с новым значением.
- Сложно агрегировать по элементам без
unnest, аunnestвсегда стоит больше, чем простойJOINпо нормальной таблице. array_lengthплохо индексируется, фильтр «у кого ровно 3 тега» обычно идёт по seq scan.
Хорошие сценарии — там, где элементы:
- Не имеют собственной идентичности (тег — это просто строка, не объект со своими полями).
- Не нужны для JOIN’ов с другими таблицами.
- Меняются всегда «целиком»: не «добавил один тег», а «перезаписал весь список».
- Известны на момент чтения: вам не нужно знать «какие клиенты имеют тег X», вам нужно «какие теги у этого клиента».
Если хотя бы один пункт не выполняется — почти всегда лучше отдельная таблица customer_tags(customer_id, tag). Это та самая 1NF-нормализация, которую мы будем разбирать в модуле 11.
Чек-лист
- Массив объявляется как
TYPE[], литерал —ARRAY[...]или'{a,b,c}'::TYPE[]. - Различай три состояния:
NULL, пустой массив, непустой массив. - Операторы
@>(содержит),<@(содержится),&&(пересечение),=(равны) — основной инструментарий. - Индексация с 1. Слайс —
arr[from:to], оба индекса включительны. unnestиarray_aggобратимы и позволяют свободно ходить между «массив» и «строки».array_length(arr, 1)на пустом массиве возвращаетNULL— пишиcardinality(arr).- Массивы оправданы, когда элементы не имеют идентичности, не нужны для JOIN, и меняются целиком. Иначе — отдельная таблица.
- Для быстрого поиска по содержимому массива ставят
GIN-индекс (об этом — в уроке 7 модуля).