Learning Platform
Урок 11.01 · 18 мин
Средний
ARRAYTEXT[]unnestarray_aggarray operators1NF

В первом модуле мы выучили: реляция требует атомарных значений. Это правило 1-й нормальной формы — никаких списков и таблиц внутри ячейки. PostgreSQL это правило демонстративно нарушает: у него есть полноценный тип ARRAY, который превращает одну ячейку в упорядоченный список значений.

В мире классической теории это «зло». В мире практики иногда это — единственный разумный выбор. Этот урок про то, как им пользоваться и когда останавливаться.

Что такое массив в Postgres

Массив — это

типизированный упорядоченный набор значений
того же типа. Объявляется добавлением [] к имени типа:

  • TEXT[] — массив строк.
  • INT[] — массив целых.
  • DATE[], TIMESTAMPTZ[], даже JSONB[] — всё работает.

Литерал записывается через ARRAY[...] или строку в фигурных скобках с явным cast: '{a,b,c}'::TEXT[].

В нашей вселенной для этого модуля у customers появилась колонка tags TEXT[] — список меток вроде ['vip', 'frequent']. Посмотрим:

Что лежит в tags — кратко обо всех клиентах:

PostgreSQL

Обрати внимание: у Аньи tags = ['vip','early_adopter'], у Гали tags = [] (пустой массив, не NULL), у Ирины — NULL. Это три разных состояния: «список с элементами», «список пустой» и «значение неизвестно». Их часто путают, и из этого вырастает половина багов с массивами.

Три состояния поля tags

NULL — мы не знаем теги. Пустой массив — мы знаем, что тегов нет. Непустой массив — есть конкретные теги.

tags = ARRAY[...]есть элементы
смыслмы знаем теги клиента
lengtharray_length = N
tags = ARRAY[]пустой массив
смыслклиент без тегов (известно)
lengtharray_length = NULLarray_length от пустого массива возвращает NULL — это известная странность PostgreSQL. Используй cardinality, чтобы получить 0.
tags = NULLNULL
смыслтеги неизвестны
lengthNULL

Операторы массивов

Самые полезные — четыре оператора, которые работают как операции над множествами:

  • = — массивы равны (одинаковые элементы в одинаковом порядке).
  • @> — левый содержит правый. [1,2,3] @> [2] истинно.
  • <@ — левый содержится в правом. Симметрично к @>.
  • && — массивы пересекаются (есть хотя бы один общий элемент).

Это не teория — это то, как реально фильтруют по тегам в продакшене.

Найди VIP-клиентов через оператор содержания:

PostgreSQL

Клиенты, у которых есть хотя бы один из этих тегов (пересечение):

PostgreSQL

&& особенно удобен для «или»: вместо длинной цепочки tags @> ARRAY['a'] OR tags @> ARRAY['b'] OR ... пишешь один оператор.

Доступ к элементам и слайсы

В Postgres массивы 1-индексированные (не как в Python). Первый элемент — это tags[1], не tags[0]. Выход за границу не падает — возвращает NULL.

Достать первый тег и попробовать выйти за границу:

PostgreSQL

Слайс — array[from:to], оба индекса включительны:

Первые два элемента массива:

PostgreSQL

unnest и array_agg: мост между «массивом» и «строками»

Эти две функции — главные. Они обратимы друг другу.

  • unnest(arr) — разворачивает массив в набор строк. Использовать в FROM или SELECT-списке.
  • array_agg(expr) — собирает значения колонки в один массив. Это агрегатная функция, как SUM или COUNT.

Развернуть теги: каждый тег — отдельная строка:

PostgreSQL

Это та самая операция, которая «возвращает» нас в 1NF. Получив развёрнутый view, можно делать GROUP BY, JOIN — всё, что обычно.

Сколько клиентов на каждый тег — мини-статистика популярности:

PostgreSQL

А обратная операция — array_agg — собирает значения обратно. Часто пара JOIN + GROUP BY + array_agg заменяет N+1 запросов в приложении:

Для каждого клиента — массив SKU, которые он покупал хотя бы раз:

PostgreSQL

Обрати внимание на 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 на пустом массиве:

PostgreSQL

В новом коде — пиши cardinality. В легаси-коде, скорее всего, COALESCE(array_length(...), 0) — это идиома предыдущей эпохи.

Конструкторы и преобразования

Кроме литералов есть два важных способа создать массив:

  • ARRAY[...] — конструктор из выражений: ARRAY[1, 2, 3], ARRAY[col1, col2].
  • ARRAY(SELECT ...) — массив из результатов подзапроса. Полезно, когда хотите собрать значения колонки в массив без GROUP BY.

ARRAY(SELECT) — собрать список SKU всех ноутбуков в один массив, без GROUP BY:

PostgreSQL

И обратное — string_to_array / array_to_string для конвертации в текст и обратно (как split / join в других языках):

Распарсим строку с разделителями в массив и обратно:

PostgreSQL

Это типичный паттерн при импорте 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.

Хорошие сценарии — там, где элементы:

  1. Не имеют собственной идентичности (тег — это просто строка, не объект со своими полями).
  2. Не нужны для JOIN’ов с другими таблицами.
  3. Меняются всегда «целиком»: не «добавил один тег», а «перезаписал весь список».
  4. Известны на момент чтения: вам не нужно знать «какие клиенты имеют тег X», вам нужно «какие теги у этого клиента».

Если хотя бы один пункт не выполняется — почти всегда лучше отдельная таблица customer_tags(customer_id, tag). Это та самая 1NF-нормализация, которую мы будем разбирать в модуле 11.

Проверка знанийKnowledge check
У вас в таблице events колонка attendees TEXT[] — массив email-ов участников. Появляется задача: «найди события, на которых был хотя бы один из этих 50 человек». Ваши действия?
ОтветAnswer
Используй оператор пересечения: WHERE attendees && ARRAY['[email protected]', '[email protected]', ...]. Это даст красивый запрос в одну строку. НО — если задача регулярная и таблица растёт, нужно (1) поставить GIN-индекс на attendees, иначе будет seq scan на каждый запрос; (2) задуматься о нормализации: таблица event_attendees(event_id, email) с обычным B-tree индексом будет быстрее и позволит JOIN с таблицей users для подтягивания имени. Массив хорош для «прочитать список целиком», нормализованная таблица — для «искать по элементам и джойнить».
GIN-индекс для arrays, JSONB и полнотекстового поиска LIST и MAP в DuckDB: отличия от PostgreSQL ARRAY

Чек-лист

  • Массив объявляется как TYPE[], литерал — ARRAY[...] или '{a,b,c}'::TYPE[].
  • Различай три состояния: NULL, пустой массив, непустой массив.
  • Операторы @> (содержит), <@ (содержится), && (пересечение), = (равны) — основной инструментарий.
  • Индексация с 1. Слайс — arr[from:to], оба индекса включительны.
  • unnest и array_agg обратимы и позволяют свободно ходить между «массив» и «строки».
  • array_length(arr, 1) на пустом массиве возвращает NULL — пиши cardinality(arr).
  • Массивы оправданы, когда элементы не имеют идентичности, не нужны для JOIN, и меняются целиком. Иначе — отдельная таблица.
  • Для быстрого поиска по содержимому массива ставят GIN-индекс (об этом — в уроке 7 модуля).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. В таблице customers есть колонка tags TEXT[]. Нужно найти клиентов, у которых одновременно есть теги 'vip' И 'frequent'. Какой оператор подходит?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7