Содержание урока · 10 разделов
- Срезы из коробки: что pgx умеет сам
- NULL, nil и пустой массив — три разные вещи
- pgtype.FlatArray и pgtype.Array: обёртки на все случаи
- Задача → приёмник для массивов
- Запросы: ANY, && и @>
- Массив или отдельная таблица
- Правила массивов в pgx одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Теги у товара, телефоны у контакта, права у пользователя — задачи, где одно значение превращается в несколько. Первый рефлекс — отдельная таблица, и чаще всего это правильный рефлекс: связи, уникальность, подсчёты. Но для коротких списков, которые читаются и пишутся целиком вместе со строкой, отдельная таблица — лишний JOIN и лишняя миграция. Postgres решает это колонкой-массивом: text[] держит сколько угодно строк, int[] — чисел, и весь список уезжает в одной колонке одной поездкой по сети. В Go через pgx эта история почти невидима: срез []string — полноценный аргумент и полноценный приёмник, обёртки из lib/pq в виде pq.Array тут не нужны: README pgx обещает преобразование массивов Postgres в срезы Go для целых, дробных и строк, а список обёрток в исходниках добавляет к этой тройке ещё и time.Time. Урок построен от простого к краевым случаям: срезы из коробки, обёртки pgtype для тонкостей, три разных «пустых» состояния и честный разбор, когда массив хуже таблицы. Таблица products из одиннадцатого урока продолжит служить полигоном — добавим колонку tags text[].
Срезы из коробки: что pgx умеет сам#
Список типов, для которых pgx собирает и разбирает массивы без ваших усилий, короткий и покрывает почти всё: []string, []int16, []int32, []int64, []float32, []float64, []time.Time — и те же срезы с указателями []string для NULL-элементов. Драйвер регистрирует их заранее: срез []T при кодировании согласуется с массивом _T, то есть []string едет в text[], []int32 — в int4[], []time.Time — в timestamptz[]. Запись: INSERT INTO products (title, tags) VALUES ($1, $2) с тегами в переменной — колонка получит честный массив, а не строку с запятыми. Чтение: Scan в []string заполняет срез элементами строки. Ограничение простого пути — метаданные размерностей: plain срез получает элементы, но ничего не знает о нижних границах и форме массива. Ещё одна честная оговорка — элементы: простой []string требует, чтобы все элементы были значениями; как только в колонке появляется SQL NULL среди элементов, скан падает ошибкой на конкретном индексе — механика у ошибки прозрачная, «failed to scan array element 0» и дальше причина cannot scan NULL into string. Лечится это переходом на []*string, и это следующий блок. Кстати, varchar[] и name[] обслуживаются тем же текстовым кодеком, что и text[], — отдельного типа Go для них не нужно. Многомерные массивы при этом не исключение из простого пути: срез срезов [][]string читается и пишется сам, если он не рваный — все строки одной длины; риск простого среза в этом деле один: нижние границы из чужого дампа показать негде. Для хранения самих размерностей нужна обёртка pgtype.Array — до неё дойдём после самых ходовых приёмов.
text[] туда и обратно
GOpackage main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx,
"postgres://app:secret@localhost:5432/app")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
tags := []string{"new", "sale", "red"}
var id int
err = pool.QueryRow(ctx,
"INSERT INTO products (title, tags) VALUES ($1, $2) RETURNING id",
"Кеды", tags).Scan(&id)
if err != nil {
log.Fatal(err)
}
var loaded []string
err = pool.QueryRow(ctx,
"SELECT tags FROM products WHERE id = $1", id).Scan(&loaded)
if err != nil {
log.Fatal(err)
}
fmt.Println(loaded[1], len(loaded))
}Срез tags передаётся вторым аргументом и ложится в колонку text[] как массив из трёх элементов; обратный Scan в []string создаёт срез нужной длины. Никаких pq.Array, никаких ручных склеек — pgx согласует тип среза с типом массива по своим заранее зарегистрированным соответствиям.
NULL, nil и пустой массив — три разные вещи#
Здесь спрятаны самые дорогие недопонимания, поэтому растолкуем по слоям. SQL NULL в колонке целиком — «массива нет вообще»: такой NULL сканируется в nil-срез без ошибки, и симметрично nil-срез из Go записывается как NULL — драйвер отправляет нулевые размерности, сервер видит NULL. Пустой массив '{}' — другое состояние: массив существует, элементов в нём ноль; пустой ненулевой срез []string{} записывается именно так, и Scan вернёт пустой ненулевой срез. Третье состояние — NULL среди элементов: {"new", NULL, "sale"}; простой []string его не выдержит — элемент-NULL нечем представить в строке, и скан падает на первом же таком элементе с ошибкой cannot scan NULL into string. Приёмник []string решает задачу: nil-указатель в срезе — честный NULL, запись тоже симметрична. Нужна ли вам различимость трёх состояний — вопрос модели: для тегов обычно достаточно «нет тегов = пустой массив или NULL, обработка одна», и код с len(loaded) == 0 закрывает оба случая сразу. Запутаться в этом проще всего в тестах: nil-срез и пустой срез дают разные значения в базе — начните домашнее задание с проверки обеих вставок. На стороне SQL эти три состояния различаются честными функциями: tags IS NULL отделяет NULL от всего остального, cardinality(tags) считает элементы и для NULL вернёт NULL, а array_length(tags, 1) — длину первой размерности; пары «IS NULL плюс cardinality» хватает для любых ветвлений в запросах.
NULL-элементы через []*string
GOnewPtr := "новинка"
var salePtr *string
_, err := pool.Exec(ctx,
"INSERT INTO products (title, tags) VALUES ($1, $2)",
"Шапка", []*string{&newPtr, salePtr})
if err != nil {
log.Fatal(err)
}
var loaded []*string
pool.QueryRow(ctx,
"SELECT tags FROM products WHERE title = $1", "Шапка").Scan(&loaded)
fmt.Println(loaded[0] != nil, loaded[1] == nil)Элементы-указатели переносят различимость в обе стороны: &newPtr едет значением, nil salePtr — SQL NULL внутри массива {новинка,NULL}. Обратный скан в []*string восстанавливает то же различие поэлементно: каждый элемент — отдельный приёмник, и nil в нём честно обозначает отсутствие значения. Проверка loaded[1] == nil заменяет невозможное для []string сравнение с NULL, а длина среза совпадает с длиной массива — ни один элемент не теряется и не добавляется.
pgtype.FlatArray и pgtype.Array: обёртки на все случаи#
Когда простых срезов перестаёт хватать, включаются две обёртки из pgtype. FlatArray[T] — обобщённый тип, который превращает любой срез T в массив Postgres: []netip.Addr, []uuid.UUID, собственный тип — всё, для чего элемент уже умеет кодироваться. Он одномерный и невнимательный к размерностям: лишнюю информацию о нижних границах Postgres он отбрасывает — за это и назван плоским. Array[T] — полная форма: поля Elements и Dims []ArrayDimension с длиной и нижней границей каждой размерности плюс флаг Valid — обёртка хранит и отдаёт драйверу саму форму массива, включая «массив начинается с нулевого индекса» вместо привычной единицы. Разница между нативным срезом срезов и Array[T] теперь читается точно: обе дороги перевезут многомерные данные, но срез срезов не сообщит, какой была нижняя граница, а Array[T] сохранит. В прикладном коде эти вещи нужны реже, чем кажется: многомерность обычно признак того, что данные просятся в отдельную таблицу, но читать чужие дампы и админские выгрузки Array[T] спасает регулярно. Практический расклад такой: строки, числа, время — простой срез; экзотический элементный тип — FlatArray[T]; размерности и нижние границы — Array[T]; NULL-элементы — указатели внутри любого из вариантов. Обёртки из десятого урока с полем Valid тут соседствуют: у Array[T] Valid есть, у FlatArray — нет, его роль играет nil.
Задача → приёмник для массивов#
| Колонка / задача | Приёмник или аргумент | Замечание |
|---|---|---|
| text[] | []string | Основной путь, без обёрток |
| int4[], float8[] | []int32, []float64 | Размер типа важен: int4 — int32 |
| timestamptz[] | []time.Time | Часовые пояса — как в десятом уроке |
| NULL среди элементов | []string, []int32 | nil-указатель = SQL NULL |
| Колонка целиком NULL | *[]string | Срез станет nil |
| Любой тип T | pgtype.FlatArray[T] | Одномерный, границы игнорирует |
| Многомерный, свои границы | pgtype.Array[T] | Elements, Dims, Valid |
Проверка: nil-срез на вставке
Передали переменную var tags []string как аргумент в text[]-колонку. Что окажется в базе?
Запросы: ANY, && и @>#
Силёнка массивов — SQL-операторы, и они же — причина выбрать массив вместо JOIN. Проверка вхождения: WHERE 'sale' = ANY(tags) — правый операнд колонка, левый значение; аккуратно с направлением, новички путают его с ANY-подзапросами. Передавать список значений параметром — тоже ANY: WHERE tags && $1 — оператор пересечения, вернёт строки, где массивы делят хотя бы один элемент; параметр — тот же []string из Go. Контейнмент @> спрашивает, содержит ли массив все элементы правого: WHERE tags @> $1 со срезом из двух тегов найдёт товары с обоими сразу. Всё это работает и в обратную сторону — на колонках int[] для идентификаторов. Скорость: обычное b-дерево по массиву не строится, поэтому для && и @> нужен GIN-индекс — CREATE INDEX ON products USING gin (tags); без него операторы честно сканируют таблицу, что на сотнях строк незаметно, а на сотнях тысяч — больно, и об этом расскажет план из двадцать второго урока. Развернуть массив в строки для агрегации — unnest(tags) в FROM: получили строки — посчитали, сгруппировали, вернули. У unnest есть продолжение WITH ORDINALITY: он добавляет колонку с исходной позицией элемента — то, чего массив в таблице без него не отдаёт. Направление «список идентификаторов в IN» решается тем же ANY: WHERE id = ANY($1) со срезом []int64 из Go — классика, экономящая склейку плейсхолдеров.
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
_, err := pool.Exec(ctx,
"INSERT INTO products (title, tags) VALUES ($1, $2)",
"Рюкзак", []string{"new", "new", "sale"})
if err != nil {
log.Fatal(err)
}
var tags []string
pool.QueryRow(ctx,
"SELECT tags FROM products WHERE title = $1", "Рюкзак").Scan(&tags)
fmt.Println(len(tags), tags[0], tags[1])Типичная ошибка: теги переезжают в text[] как быстрое решение, а через полгода появляется страница «популярные теги» с подсчётом, и GROUP BY по unnest на каждом запросе соседствует с невозможностью повесить внешний ключ. Что сломалось бы: отчёт, который считает теги по миллионам строк, и уникальность тегов, которую приходится стеречь в коде. Моё спорное мнение с условием: массив — для данных, которые живут и умирают вместе со строкой и читаются целиком; как только по элементам нужны запросы со счётчиками, уникальность или связи — таблица. Условие честности: маленький ограниченный список без ссылочной целостности — массив; всё, что растёт вглубь, — нет.
Массив или отдельная таблица#
Сведём критерии в один разговор, потому что выбор этот переживает не один рефакторинг. Массив выигрывает, когда список короткий и ограниченный по смыслу — телефоны, роли, флаги; когда элементы не ссылаются на другие таблицы; когда читается и пишется список всегда целиком; и когда порядок элементов — часть данных, потому что массив его сохраняет. Таблица выигрывает, когда элементов может стать много; когда нужна ссылочная целостность и каскады; когда по элементам ищут с подсчётами и сортировками; когда каждый элемент обрастает своими полями — тег с автором и датой уже не элемент, а строка. Переезд из массива в таблицу потом возможен, но стоит неприятно: unnest на миграции, переписывание всех запросов — дешевле решить до продакшена. Сама миграция пишется один раз: INSERT INTO tags SELECT product_id, unnest(tags) FROM products с последующим дропом колонки, — боль не в SQL, а в разъехавшихся вызовах по всему коду. Замечу и скорость записи: сотни однотипных массивов через цикл Exec — путь в восемнадцатый урок с Batch, а не самостоятельная проблема массивов. И последнее: массив не заменяет jsonb из одиннадцатого урока — массив держит однотипные значения без имён, jsonb — разнотипные с ключами; путаница между ними в ревью чаще всего означает, что автор просто не сформулировал структуру.
Правила массивов в pgx одним списком#
- []string, []int32, []float64, []time.Time — аргументы и приёмники без обёрток
- Размер важен: int4[] — []int32, int8[] — []int64
- nil-срез едет как SQL NULL, пустой срез — как {}
- NULL-элементы — []*string с nil-указателями, не []string
- Колонка целиком NULL: срез станет nil — проверяйте len, а не сравнение с пустым
- Экзотический тип элемента — pgtype.FlatArray[T]
- Многомерность и нижние границы — pgtype.Array[T] с Elements, Dims, Valid
- ANY, &&, @> — рабочие лошадки; под && и @> — GIN-индекс
- Срез срезов [][]string едет сам, если он не рваный; форма и нижние границы — только через pgtype.Array[T]
Проверка: NULL-элемент
В колонке tags text[] лежит {"new",NULL}. Скан в []string — что произойдёт?
Соберите запросы по массиву
Восстановите фильтры: вхождение, пересечение со срезом и индекс под него.
Соберите запись и чтение срезов
Расставьте строки: срез тегов, вставка с RETURNING, обратный скан.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: массивы Postgres
Срез в поле структуры — обычный приёмник: SELECT с колонкой tags сканируется в Products{Tags []string} тем же RowToStructByName из седьмого урока, и CollectRows соберёт страницу товаров с тегами одной строкой. Всё правило урока остаётся в силе: элементы без NULL — []string, с NULL — []*string.
Живые эксперименты — локально: песочница урока покажет тематическую программу на чистом Go, а для массивов поднимите Postgres из первого урока и выполните ALTER TABLE products ADD COLUMN tags text[]. Дальше по курсу: в одиннадцатом — jsonb, второй вид гибких колонок, где вместо порядка ключи; в восемнадцатом — вставка сотен массивов одним рейсом через Batch; в двадцать третьем — тесты, где nil и пустой срез проверяются утверждениям, а не на глаз. Классическая ошибка, которую стоит повторить дома: вставить nil-срез, удивиться NULL и вспомнить этот урок — дёшево на локальной базе, дорого на отчёте клиента.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- []string, []int32, []float64, []time.Time конвертируются в массивы и обратно без обёрток.
- nil-срез — SQL NULL, пустой срез — {}: различие переносится в базу и обратно честно.
- NULL-элементы требуют []*string; []string падает cannot scan NULL into *string.
- pgtype.FlatArray[T] — любой элементный тип, одномерный; pgtype.Array[T] — многомерность и Valid.
- ANY — вхождение, && — пересечение, @> — вхождение всех; под && и @> — GIN-индекс.
- Массив сохраняет порядок и дубликаты — это не множество и не jsonb.
- Короткие списки, живущие со строкой, — массив; счётчики, уникальность, связи — таблица.
Домашнее задание
Добавьте колонку tags text[] и наполните её тремя товарами — один с пустым срезом, один с nil, один с NULL-элементом через []*string. Прочитайте все три и напечатайте, чем отличаются срезы в Go. Напишите два запроса: пересечение && со срезом параметров и @> с требованием двух тегов сразу, добавьте GIN-индекс и сравните планы через EXPLAIN ANALYZE. В конце переишите один запрос через id = ANY($1) со срезом идентификаторов — и держите этот приём под рукой для восемнадцатого урока. Заодно добавьте колонку scores int4[] и вставьте туда []int32, а потом []int с числом больше двух миллиардов — прочитайте ошибку проверки диапазона int4 в обоих случаях.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему для int4[] берут []int32, а не []int?
pgx ведёт срезы по размеру: int4[] — []int32, int8[] — []int64, и это быстрый путь без конверсий, заявленный в списке обёрток. Срез []int тоже заработает — элементы прогоняются через reflect-обёртку с проверкой диапазона на каждый элемент, — но правило дисциплины одно: размер типа Go равен размеру типа Postgres; исключение — []string для text[] и varchar[]: их элементы — текст, и срез общий.
Можно сканировать массив прямо в поле структуры?
Да: поле Tags []string заполняется тем же кодеком, что и отдельный приёмник, — CollectRows и RowToStructByName из седьмого урока работают с массивами без оговорок. Единственное — NULL-элементы по-прежнему требуют []*string в поле, простые строки их не выдержат.
Чем массив отличается от jsonb и что выбирать?
Массив — однотипные значения без имён: теги, телефоны, идентификаторы. jsonb — разнотипный документ с ключами: свойства, настройки. Если значения однотипны и вам нужен порядок — массив, он дешевле в хранении и имеет операторы ANY и &&; если у значений есть имена и разные типы — jsonb.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup