Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~18 мин +20 XP

Массивы Postgres: []string и pgtype.Array

Массивы Postgres — колонка, в которой лежит много значений одного типа, и pgx конвертирует text[] в []string без единой обёртки: срез едет аргументом и приезжает обратно как срез. Разберём, что работает из коробки, где нужны pgtype.FlatArray и pgtype.Array, чем nil отличается от пустого массива и когда массив честнее отдельной таблицы.

Содержание урока · 10 разделов

Теги у товара, телефоны у контакта, права у пользователя — задачи, где одно значение превращается в несколько. Первый рефлекс — отдельная таблица, и чаще всего это правильный рефлекс: связи, уникальность, подсчёты. Но для коротких списков, которые читаются и пишутся целиком вместе со строкой, отдельная таблица — лишний 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[] туда и обратно

GO
package 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

GO
newPtr := "новинка"
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, []int32nil-указатель = SQL NULL
Колонка целиком NULL*[]stringСрез станет nil
Любой тип Tpgtype.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])

Массив или отдельная таблица#

Сведём критерии в один разговор, потому что выбор этот переживает не один рефакторинг. Массив выигрывает, когда список короткий и ограниченный по смыслу — телефоны, роли, флаги; когда элементы не ссылаются на другие таблицы; когда читается и пишется список всегда целиком; и когда порядок элементов — часть данных, потому что массив его сохраняет. Таблица выигрывает, когда элементов может стать много; когда нужна ссылочная целостность и каскады; когда по элементам ищут с подсчётами и сортировками; когда каждый элемент обрастает своими полями — тег с автором и датой уже не элемент, а строка. Переезд из массива в таблицу потом возможен, но стоит неприятно: 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 — что произойдёт?

Соберите запросы по массиву

Восстановите фильтры: вхождение, пересечение со срезом и индекс под него.

rows, err := pool.Query(ctx, "SELECT title FROM products WHERE $1 (tags)", []string{"sale", "new"}) rows2, err := pool.Query(ctx, "SELECT title FROM products WHERE 'red' = (tags)") _, err = pool.Exec(ctx, "CREATE INDEX ON products USING (tags)")

Соберите запись и чтение срезов

Расставьте строки: срез тегов, вставка с RETURNING, обратный скан.

Строки

Ваша программа

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

Карточки: массивы Postgres

Термин

text[] и []string

нажмите, чтобы перевернуть
Определение

Пара работает без обёрток: срез едет аргументом, приёмником и внутри структур CollectRows. Размер типа важен: int4[] — []int32.

1 / 6

Живые эксперименты — локально: песочница урока покажет тематическую программу на чистом 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 в обоих случаях.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Массивы Postgres: []string и pgtype.Array)

Вопросы и ответы по уроку

Почему для 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.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup