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

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

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

Урок 10 из 25 ~20 мин +50 XP

Массивы и JSON в Postgres

sqlc jsonb и массивы — тема, которую трекер вынашивал с четвёртого урока: у задач нужен набор тегов и свободное место для ссылок и чек-листов, а в схеме до сих пор жили только скаляры. Разбираем обещанное в девятом уроке продолжение: во что sqlc превращает PostgreSQL-массивы (подсказка — в срезы), как фильтровать списком через = ANY($1::int[]), почему jsonb-колонка при pgx/v5 приезжает []byte и что с ней делать дальше: касты ::jsonb и ::text, jsonb-операторы ->> и @> в аннотированных запросах и отдача jsonb напрямую как готового JSON-ответа. Обещание «массивы бывают отсутствующими и пустыми, и это разные вещи» выполним по буквам, а в финале честно пройдёмся по ограничениям: у MySQL нет ни массивов, ни ANY-трюка, и его json живёт в json.RawMessage. Row-структуры и формы :one/:many — контракт [седьмого урока](/courses/sqlc/lessons/07-select/), nullable-мир обёрток и указателей — [девятого](/courses/sqlc/lessons/09-null/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

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

Девятый урок закрыл вопрос «значения может не быть»: обёртки с Valid, указатели с nil и правила вывода разобраны до основы. Но у трекера зрело второе желание — коллекции. Задаче нужен набор тегов, к ней прикладываются ссылки на документы и шаги чек-листа, и заводить по этому поводу две новые таблицы с JOIN-ами хочется не всегда. В PostgreSQL для таких случаев есть два родных инструмента: массивы и jsonb — и оба сегодня доедут до Go.

Десятый урок отвечает на четыре вопроса. Первый: во что sqlc превращает колонку text[] и почему nullability массива не влияет на тип поля. Второй: как передать в запрос список значений и отфильтровать по нему через = ANY($1::int[]) — классический приём «покажи задачи по этим id». Третий: как живёт jsonb-колонка — от генерации []byte до jsonb-операторов ->> и @> в WHERE и отдачи документа клиенту без единого маршалинга. Четвёртый: где проходит граница трюка — касты, nil против пустого среза и мир за пределами Postgres, где MySQL не знает ни массивов, ни ANY, а json-колонка генерируется json.RawMessage. Всё — на знакомой схеме todos и сгенерированном коде, как его печатает генератор.

sqlc jsonb и массивы: колонки-коллекции трекера#

Колонка-коллекция — это колонка, в которой лежит не значение, а набор: массив элементов одного типа или JSON-документ произвольной формы. Для трекера напрашиваются две вещи: tags text[] — теги задачи, и metadata jsonb — свободное место для вложений, ссылок и шагов. Обе добавляются знакомым циклом правки схемы из второго урока: миграция, sqlc generate, волна правок в вызовах. Ничего нового в конфиг не пишется: генератор читает типы из каталога схемы тем же механизмом, что и для скаляров в четвёртом уроке, — просто типы теперь составные.

Запрос «массивы postgres sqlc» — это ровно про карту этого урока, и построим мы её от простого к сложному. Сначала отображение колонок: text[] даёт []string, jsonb даёт []byte, и оба правила не зависят от nullability — тут сюрприз, который мы честно разберём. Затем фильтрация: запросы с ANY и одним параметром генерируют сигнатуры со срезами — документированное поведение sqlc, которое экономит и циклы, и поездки в базу. Потом jsonb-жизнь: операторы ->> и @> в WHERE, касты на параметрах и в SELECT и выдача документа клиенту как есть. В финале — таблица соответствий по всем трём движкам и границы: где массивы и jsonb уместны, а где дешевле обычные колонки и FK.

Схема трекера после миграции 006: у todos появляются Tags []string и Metadata []byte — и оба поля живут по правилам этого урока, а не по nullable-правилам девятого. Почему — следующий раздел.

Миграция 006: tags и metadata

GO
-- db/migrations/006_collections.sql: колонки-коллекции
ALTER TABLE todos ADD COLUMN tags text[];
ALTER TABLE todos ADD COLUMN metadata jsonb;

-- db/queries/todos.sql — GetTodo из третьего урока расширяется
-- name: GetTodo :one
SELECT id, title, tags, metadata
FROM todos
WHERE id = $1 AND deleted_at IS NULL;

// db/sqlc/models.go — сгенерировано (pgx/v5): два новых поля
type Todo struct {
        ID       int64
        Title    string
        // ... done, priority, created_at, deadline, cost, deleted_at, note
        Tags     []string // text[]: срез строк, обёрток и указателей нет
        Metadata []byte   // jsonb: сырые байты документа, NULL — nil
}

Обе колонки добавлены без NOT NULL и без DEFAULT — это осознанный выбор для разговора о NULL и пустых значениях, к которому мы вернёмся, а каноничной альтернативой будет NOT NULL DEFAULT '{}'. Сгенерированная структура даёт два факта сразу. Массив стал срезом []string — без всяких обёрток, несмотря на nullable. jsonb стал []byte — сырыми байтами, а не строкой и не готовой структурой: разбор документа — ваша работа в Go. Оба факта не зависят от опций emit_* из прошлых уроков, и это не совпадение, а правило, к которому мы вернёмся дважды.

text[] в Go: срезы, nullability и два измерения#

Правило отображения массивов документировано одной фразой: PostgreSQL-массивы материализуются в Go-срезы. text[] — []string, bigint[] — []int64, int[] — []int32, boolean[] — []bool: тип элемента вычисляется по обычным правилам четвёртого урока, а снаружи добавляется пара квадратных скобок. Массивы бывают и многомерными: text[][] сгенерирует [][]string — генератор рисует по паре скобок на каждое измерение. На практике многомерные редки, но читать их сигнатуры теперь вы умеете.

Настоящий сюрприз — nullability. Для скаляров девятый урок выучил железное правило: nullable-колонка живёт в обёртке или указателе. У массивов правило другое: nullability массива в тип поля не входит вообще. Колонка tags text[] с NOT NULL и без него дадут одно и то же поле []string: для массивов не существует pgtype-обёрток, а emit_pointers_for_null_types: true не породит []*string — эта опция про nullable-скаляры, и массивы её не видят. Причина лежит в самой кодогенерации: колонка-массив помечается в генераторе как NOT NULL независимо от схемы, и дальше работает обычная таблица соответствий.

Но куда девается SQL NULL? Он живёт в самом срезе — и здесь выполняется обещание девятого урока. Читая NULL-массив, pgx возвращает nil-срез; читая литерал '{}' — пустой массив — возвращает срез нулевой длины, который nil не является. Отсутствующий и пустой — разные состояния, и оба выражаются без обёрток: nil есть nil, а len(...) == 0 при непустом nil-проверке — пустой набор. Практическое следствие: если семантика «тегов нет» и «тегов ноль» для вас одно и то же — ставьте в миграции NOT NULL DEFAULT '{}', как в девятом уроке ставили NOT NULL + DEFAULT для обязательных колонок, и nil-состояние исчезает из схемы целиком.

Предскажите вывод: NULL-массив против пустого

Прочитайте код и выберите, что он выведет:

// строка 1 вставлена без tags (колонка nullable) — SQL NULL
// строка 2 вставлена с tags = '{}' — литерал пустого массива
a, _ := queries.GetTodoTags(ctx, 1)
b, _ := queries.GetTodoTags(ctx, 2)
fmt.Println(a == nil, len(a))
fmt.Println(b == nil, len(b))

Проверка: nullable-массив

Колонка tags text[] без NOT NULL, конфиг pgx/v5 с emit_pointers_for_null_types: true. Какое поле в сгенерированной структуре?

= ANY($1::int[]): фильтр по списку#

Самый частый вопрос про массивы в sqlc звучит не про колонки, а про параметры: как отфильтровать задачи по списку id, который пришёл из Go. Голый IN с одним плейсхолдером не сработает: WHERE id IN ($1) — это сравнение с одним значением, а не со списком. Решение PostgreSQL — оператор ANY: запрос WHERE id = ANY($1::bigint[]) читается «id равен хотя бы одному элементу массива из параметра». Документация sqlc описывает поведение прямо: запросы с ANY и одним параметром генерируют сигнатуры со срезами в аргументах; в касте пишите postgres-типы — int, varchar и так далее.

Каст ::bigint[] здесь не украшение, а несущая конструкция: по нему генератор выводит тип параметра. Уберёте каст — тип не определён, и генерация упадёт с сообщением о неопределённом типе, ровно как падала вставка unnest без кастов в пятом уроке. Имя параметра выводится как dollar_1 — выражение ANY не даёт генератору имени; хотите говорящее поле — назовите сами через sqlc.arg('ids')::bigint[], и Params-структура получит IDs []int64: механика именованных параметров из восьмого урока здесь работает без оговорок.

Сигнатуры и передача различаются по sql_package, и это уже знакомый сюжет. С pgx/v5 срез едет в базу нативно одним параметром-массивом — никаких обёрток в вызове. С database/sql и lib/pq сгенерированный код сам заворачивает аргумент в pq.Array — sqlc делает за вас то, что в пятом уроке мы делали руками. Обратная дорога — массив к строкам — это unnest из пятого урока: для INSERT-пачек; для фильтров в чтении ANY каноничнее. Наконец, пустой срез: = ANY('{}') ложно для всех строк, поэтому пустой аргумент даёт пустой ответ без ошибки — запомните контраст, в разделе про MySQL он выстрелит.

Выборки :many возвращают срез Row-структур — контракт седьмого урока; ANY лишь добавляет в этот контракт аргумент-срез.

GetTodosByIDs: запрос, сигнатура, вызов

GO
-- db/queries/todos.sql
-- name: GetTodosByIDs :many
SELECT id, title
FROM todos
WHERE id = ANY($1::bigint[])
  AND deleted_at IS NULL;

// db/sqlc/todos.sql.go — сгенерировано (pgx/v5)
func (q *Queries) GetTodosByIDs(ctx context.Context, dollar_1 []int64) ([]GetTodosByIDsRow, error) {
        rows, err := q.db.Query(ctx, getTodosByIDs, dollar_1) // срез едет одним массивом-параметром
        // ... скан строк в срез Row-структур, как в седьмом уроке
}

// main.go: сколько бы id ни пришло — один вызов и одна поездка в базу
rows, err := queries.GetTodosByIDs(ctx, []int64{1, 7, 42})
rows, err = queries.GetTodosByIDs(ctx, []int64{}) // пустой срез — пустой ответ, не ошибка

-- именованный вариант: говорящее поле вместо dollar_1
-- name: GetTodosByIDsNamed :many
SELECT id, title FROM todos
WHERE id = ANY(sqlc.arg('ids')::bigint[]) AND deleted_at IS NULL;

// db/sqlc/todos.sql.go — сгенерировано (pgx/v5)
type GetTodosByIDsNamedParams struct {
        IDs []int64
}

Сигнатура подтверждает документированное правило: ANY с одним параметром — аргумент-срез, тип []int64 взят из каста bigint[]. pgx передаёт срез нативно, без pq.Array — в database/sql-варианте сгенерированный вызов выглядел бы как pq.Array(dollar_1). dollar_1 — честное имя по умолчанию: ANY не сообщает генератору ничего о смысле параметра, и лечится это sqlc.arg, а не уговорами. Пустой срез легален: ANY по пустому массиву не находит ничего, и метод вернёт пустой срез строк — ветка «ничего не нашли» у вас уже написана как проверка len.

Предскажите тип параметра: @> с массивом

Прочитайте код и выберите, что он выведет:

-- name: TodosWithAnyOfTags :many
SELECT id, title
FROM todos
WHERE tags @> $1::text[]
  AND deleted_at IS NULL;

Проверка: ANY и каст

Запрос WHERE id = ANY($1) — без каста. Что произойдёт при sqlc generate?

Касты ::jsonb и ::text: подсказки типов#

Касты на параметрах мы впервые увидели в восьмом уроке — @filter::bool в CASE-выражении; сегодня у них две новые роли. Первая: кастуете параметр в коллекцию — получаете коллекцию в Go. $2::jsonb даёт аргумент []byte, $2::text[] даёт []string, $1::bigint[] даёт []int64. Это единое правило вывода: тип выражения в SQL — тип аргумента в сигнатуре, и ANY с @> подчиняются ему так же, как SET и VALUES. Вторая роль: каст меняет тип результата в SELECT. Выражение metadata::text имеет тип text — в Row приедет строковое поле, а не байты: jsonb сериализуется в текст по правилам PostgreSQL, и читать такой результат удобнее для логов и отладки.

Две оговорки делают касты честными. Первая — nullability едет сквозь каст: metadata у нас nullable, поэтому голый metadata::text даст nullable-текст и обёртку из девятого урока. Хотите string — снимите NULL ещё в SQL: coalesce(metadata::text, '{}') гарантированно NOT NULL, и поле выйдет string — то же правило вывода, что и в девятом уроке, вплоть до обязательного алиаса AS. Вторая оговорка — обратное направление: строковый параметр в jsonb-контексте кастуйте явно. Два корректных пути — $1::jsonb, когда байты уже JSON, и to_jsonb($1), когда это обычное значение, которое надо упаковать; второй способ часто встречается в сгенерированных тестах sqlc как to_jsonb(sqlc.arg('data')::text).

Из Go запись выглядит зеркально: json.Marshal собирает []byte, и он же уезжает параметром с кастом ::jsonb. Никаких промежуточных строк — маршалинг один, на границе Go и базы.

Касты на записи и на чтении

GO
-- запись документа: параметр кастуется в jsonb
-- name: ReplaceTodoMetadata :exec
UPDATE todos
SET metadata = $2::jsonb
WHERE id = $1 AND deleted_at IS NULL;

-- запись тегов: параметр-массив с кастом
-- name: SetTodoTags :exec
UPDATE todos
SET tags = $2::text[]
WHERE id = $1;

-- чтение: каст + coalesce дают NOT NULL текст
-- name: TodoMetadataText :many
SELECT id, coalesce(metadata::text, '{}') AS meta_text
FROM todos
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id;

// db/sqlc/todos.sql.go — сгенерировано (pgx/v5)
func (q *Queries) ReplaceTodoMetadata(ctx context.Context, id int64, metadata []byte) error

type TodoMetadataTextRow struct {
        ID       int64
        MetaText string // coalesce с константой — NOT NULL: string, а не обёртка
}

// main.go: сборка документа и запись одним маршалингом
out, err := json.Marshal(map[string]any{"url": "https://example.com/file.pdf"})
_, err = queries.ReplaceTodoMetadata(ctx, 42, out)

Три запроса — три проявления одного правила вывода. ReplaceTodoMetadata: параметр с кастом ::jsonb генерируется []byte, и json.Marshal даёт эти байты напрямую. SetTodoTags: каст ::text[] даёт []string — массив тегов уезжает одним параметром. TodoMetadataText: на чтении сработали сразу два правила — ::text превращает jsonb в текстовый тип, а coalesce с константой снимает nullability, поэтому поле MetaText — голый string; без coalesce поле было бы обёрткой из девятого урока. Алиасы AS стоят у вычисляемой колонки: без них поле звалось бы по имени функции.

jsonb-операторы: ->> и @> в запросах#

jsonb-операторы — обычный SQL, и sqlc переваривает их как любую функцию: смотрит на тип результата выражения. Словарь минимальный. Одинарная стрелка -> достаёт поле как jsonb: metadata->'attachments' — снова документ, в Go — []byte. Двойная стрелка ->> достаёт поле как text: metadata->>'link' — строка, в Go — текстовый тип. Путаница стрелок — самый дешёвый способ сломать себе типы: не тот оператор — не тот тип поля, и ошибка всплывёт на Scan или на сравнении. Оператор вхождения @> проверяет «документ слева содержит документ справа»: metadata @> '{"status": "blocked"}'::jsonb находит задачи, у которых внутри есть такое поле; у массивов он же означает «содержит все элементы». Обратный <@ — «содержится в».

С параметрами работают те же правила кастов из прошлого раздела: metadata @> $1::jsonb — параметр []byte; tags @> $1::text[] — параметр []string, вы это уже предсказали. А неименованный параметр в сравнении с ->> получает тип по выражению слева: double arrow даёт text, nullability выражения генератору неизвестна — и параметр генерируется nullable-текстом из девятого урока, обёрткой pgtype.Text. Имя параметр берёт у колонки в левой части выражения — генератор naming-а по колонкам из седьмого урока здесь не отключался.

Практический довесок: семейство функций jsonb_extract_path, jsonb_array_elements и jsonb_agg — тоже обычные функции, их можно свободно мешать с операторами; сгенерированные типы приедут по типам выражений. И помните про индексы: фильтры с @> на горячих путях делают с GIN-индексом на колонке — это уже забота миграций, а не sqlc, но проверять план стоит раньше, чем масштабировать сервер.

Стрелка и вхождение в аннотированных запросах

GO
-- db/queries/todos.sql
-- ->> возвращает text: параметр сравнения — текстовый
-- name: FindTodoByLink :one
SELECT id, title
FROM todos
WHERE metadata->>'link' = $1
  AND deleted_at IS NULL
LIMIT 1;

// db/sqlc/todos.sql.go — сгенерировано (pgx/v5)
func (q *Queries) FindTodoByLink(ctx context.Context, metadata pgtype.Text) (FindTodoByLinkRow, error) {
        // параметр звался metadata: имя взято у колонки слева от стрелки
        // тип pgtype.Text: ->> даёт text, nullability выражения неизвестна
}

-- @> проверяет вхождение документа в документ
-- name: TodosWithAttachment :many
SELECT id, title
FROM todos
WHERE metadata @> sqlc.arg('filter')::jsonb
  AND deleted_at IS NULL;

// вызов: []byte собран json.Marshal'ом — ноль ручной склейки
filter, _ := json.Marshal(map[string]any{"attachments": []string{"report.pdf"}})
rows, err := queries.TodosWithAttachment(ctx, filter)

FindTodoByLink — точная копия формы из тестов генератора: колонка jsonb, сравнение с ->> — и параметр генерируется pgtype.Text: тип text от двойной стрелки, nullable — потому что выражение может быть NULL. Никакого противоречия с []byte-колонкой Metadata нет: колонка в Row — []byte, а параметр сравнения — text. TodosWithAttachment показывает вхождение с именованным параметром: каст ::jsonb даёт []byte, и json.Marshal строит документ прямо в вызове. Оба запроса для генератора — обычные выражения со своим выводом типов.

Проверка: одна стрелка против двух

SELECT metadata->'link' (без второй угловой скобки) из todos при pgx/v5. Какого типа поле в Row-структуре?

jsonb как готовый JSON-ответ#

Вот где []byte окупается полностью: jsonb-колонка — это уже JSON-документ, и при pgx/v5 в Go приезжают его сырые байты. Значит, для API-эндпоинта «отдай метаданные задачи» маршалинг не нужен вовсе: заголовок Content-Type: application/json, w.Write(raw) — и документ уехал клиенту ровно в том виде, в каком его хранит база. Когда документ — часть большего ответа, байты встраиваются полем типа json.RawMessage: это тот же []byte с методом, который говорит encoding/json «меня уже не кодируют — вставь как есть». Ни двойного маршалинга, ни потерь порядка ключей, ни лишних аллокаций на пути база-клиент.

Когда доступ нужен типизированный — документ разворачивается руками: json.Unmarshal в свою структуру, и дальше обычные поля Go. А если jsonb-колонка в проекте имеет строгую схему и читается многими сервисами, четвёртый урок уже показывал путь: column-override подключает к колонке вашу структуру, и pgx сам упаковывает и распаковывает её в обе стороны. Моё живое правило такое: отдаю jsonb как есть, когда документ принадлежит фронту и бэкенду безразлично его содержимое; разворачиваю в структуру или подключаю override, когда бэкенд обязан валидировать содержимое. Передумал бы, если jsonb начинает участвовать в бизнес-правилах чаще, чем читается целиком: условия по ключам в каждом втором запросе — сигнал, что документу тесно, и дешевле завести нормальные колонки и FK, чем растить витрину в одном поле.

Запись зеркальна чтению: json.Marshal собирает байты, параметр с кастом ::jsonb доставляет их в базу. Один маршалинг на весь путь — то, ради чего связка jsonb и []byte и существует.

Три выдачи одного документа

GO
// вариант 1: байты как есть — ноль маршалинга
func handleTodoMetadata(w http.ResponseWriter, r *http.Request) {
        raw, err := queries.GetTodoMetadata(ctx, id) // []byte из jsonb
        if err != nil {
                http.Error(w, "не найдено", http.StatusNotFound)
                return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write(raw) // jsonb уже валидный JSON
}

// вариант 2: документ как поле большего ответа
type TodoResponse struct {
        ID       int64           `json:"id"`
        Title    string          `json:"title"`
        Metadata json.RawMessage `json:"metadata,omitempty"`
}
resp := TodoResponse{ID: 42, Title: "сдать отчёт", Metadata: json.RawMessage(raw)}
json.NewEncoder(w).Encode(resp)

// вариант 3: типизированный доступ — разворачиваем сами
type TaskMeta struct {
        URL         string   `json:"url"`
        Attachments []string `json:"attachments"`
}
var meta TaskMeta
err = json.Unmarshal(raw, &meta)

Три жанра одной колонки. В первом Content-Type и w.Write — документ уходит как есть, вплоть до пробелов и порядка ключей, которые база хранит для jsonb. Во втором json.RawMessage встраивает байты в ответ без перекодирования: encoding/json вставит их как готовый фрагмент. В третьем Unmarshal даёт типизированный доступ — и это единственный вариант, где ошибка в содержимом документа станет ошибкой Go. Вариант 1 — самый быстрый и самый безответственный: валидацию документа вы отдали базе.

MySQL и SQLite: мир без массивов#

Граница темы проходит по движкам, и начинается она с MySQL: колонок-массивов там не существует вовсе. text[] не скомпилируется, ANY($1::int[]) не с чем применять, и списки значений в MySQL-мире хранят либо в json-колонке, либо в отдельной таблице-справочнике. А как же фильтр по списку id? Для этого у sqlc есть макрос sqlc.slice: он разворачивает Go-срез в список плейсхолдеров IN — но делает это на этапе выполнения, пересобирая текст запроса под длину среза. Из этого следуют два жёстких ограничения из документации: макрос несовместим с prepared statements и требует непустой срез — nil или пустой срез дают ошибку выполнения, а не панику. В Postgres всё это не нужно: срез уезжает одним параметром-массивом, и sqlc.slice там не используется.

С json-колонкой ответ тоже другой. Проверка по кодогенерации MySQL даёт однозначный вердикт: json-колонка генерируется json.RawMessage — и это верно и для NOT NULL, и для nullable, отдельной обёртки не существует. Не []byte, как у pgx, и не string: json.RawMessage — тип из encoding/json, по устройству те же байты, но с JSON-методами. SQLite ведёт себя так же: json и jsonb там генерируются json.RawMessage. Значит, трюк «отдай байты как есть» переносится на оба движка почти дословно — меняется имя типа в поле, а смысл остаётся: сырой документ в Go.

Вывод для практики короткий: прежде чем переносить запросы между движками, сверьте карту типов. []string у массивов и []byte у jsonb — привилегия PostgreSQL с pgx; везде остальное — json.RawMessage и sqlc.slice вместо ANY. Универсального слоя не бывает, и sqlc честно не притворяется, что он есть.

Шпаргалка: коллекции в Go по движкам#

Колонка или выражениеМирЧто генерируется
tags text[] (NOT NULL и nullable)PostgreSQL, pgx/v5[]string — nullability массива в тип не входит
ids bigint[] в ANY($1::bigint[])PostgreSQL, pgx/v5аргумент []int64, срез едет нативно
ids bigint[] в ANY($1::bigint[])PostgreSQL, database/sql[]int64, вызов оборачивается в pq.Array
tags text[][] — два измеренияPostgreSQL, pgx/v5[][]string — пара скобок на измерение
metadata jsonb NOT NULLPostgreSQL, pgx/v5[]byte
metadata jsonb nullablePostgreSQL, pgx/v5[]byte, NULL приезжает nil
metadata jsonbPostgreSQL, database/sqljson.RawMessage (nullable — pqtype.NullRawMessage)
metadata jsonMySQLjson.RawMessage — для NOT NULL и nullable одинаково
metadata json / jsonbSQLitejson.RawMessage
список id для INMySQL и SQLitesqlc.slice: разворачивает срез на этапе выполнения

Шесть правил коллекций#

  • Массив — это срез: text[] даёт []string; nullability массива генератором игнорируется, обёрток и указателей для массивов не бывает.
  • Каждый параметр-массив несите с кастом: ANY($1::bigint[]), $2::text[], $2::jsonb — без каста генерация упадёт на неопределённом типе.
  • NULL-массив и '{}' — разные состояния: nil-срез и пустой срез; различие важно — проверяйте на nil, не важно — NOT NULL DEFAULT '{}' в миграции.
  • jsonb при pgx/v5 — []byte: готовый документ для w.Write и json.RawMessage; типизированный доступ — json.Unmarshal или column-override.
  • Стрелки различайте наизусть: ->> даёт text (строку), -> даёт jsonb ([]byte); параметр для @> кастуйте явно.
  • MySQL живёт без массивов: json — json.RawMessage, списки для IN — sqlc.slice, пустой срез там ошибка; в Postgres пустой срез — пустой ответ.

Восстановите фильтр по списку и чтение тегов

Три пропуска: оператор, тип среза в сигнатуре и проверка отсутствующего массива.

-- db/queries/todos.sql -- name: GetTodosByIDs :many SELECT id, title FROM todos WHERE id = ($1::int[]) AND deleted_at IS NULL; // db/sqlc/todos.sql.go — сгенерировано (pgx/v5) func (q *Queries) GetTodosByIDs(ctx context.Context, ids ) ([]GetTodosByIDsRow, error) { // main.go: NULL-массив отличаем от пустого if task.Tags == { fmt.Println("тегов нет совсем — SQL NULL") }

Соберите путь фильтра по списку id

Порядок: аннотация, SELECT, условие, генерация, вызов, проверка результата.

Строки

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

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

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

Термин

text[] в Go

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

Срез: Tags []string. Массивы PostgreSQL материализуются срезами; nullability массива в тип не входит (генератор считает массив NOT NULL), emit_pointers_for_null_types на массивы не действует. Многомерные — [][]string по паре скобок на измерение.

1 / 6

Что дальше#

Колонки-коллекции больше не сюрприз: text[] — это []string без обёрток, jsonb — это []byte с готовым документом внутри, ANY фильтрует срезом за одну поездку, касты говорят генератору типы на обеих сторонах, а ->> и @> работают в аннотированных запросах как обычный SQL. Различие «отсутствующий против пустого» вы теперь читаете в данных по буквам, и знаете, где карта ломается: MySQL без массивов, json.RawMessage вместо []byte, sqlc.slice вместо ANY.

Следующая остановка — одиннадцатый урок «Транзакции: QueriesWithTx»: PrepareTx, передача *sql.Tx и паттерн withTx. Шестой урок дал привычку «несколько записей — одна транзакция», дальше будет полный разбор: откат по ошибкам, вложенные вызовы и границы паттерна. Если понадобится освежить nullable-мир обёрток и указателей — он живёт в девятом уроке; весь план — на странице курса sqlc.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Массивы PostgreSQL материализуются в Go-срезы: text[] — []string, bigint[] — []int64, text[][] — [][]string; nullability массива в тип не входит, обёрток и указателей для массивов нет.
  • SQL NULL у массива — nil-срез, литерал '{}' — пустой срез нулевой длины: отсутствующий и пустой — разные состояния; если различие не нужно — NOT NULL DEFAULT '{}'.
  • Фильтр по списку — = ANY($1::bigint[]) с обязательным кастом: сигнатура получает []int64; pgx передаёт срез нативно, database/sql — через pq.Array; пустой срез даёт пустой ответ без ошибки.
  • jsonb при pgx/v5 — []byte при любой nullability: NULL приезжает nil; json.RawMessage — мир database/sql, MySQL и SQLite; отдача документа клиенту — Content-Type и w.Write без маршалинга.
  • Касты — язык вывода типов: $2::jsonb даёт []byte, $2::text[] — []string, metadata::text в SELECT — текст; nullability едет сквозь каст, coalesce с константой возвращает NOT NULL.
  • Стрелки jsonb: ->> возвращает text (строку), -> возвращает jsonb ([]byte); @> проверяет вхождение документа или элементов; параметры для операторов кастуйте явно, имя берётся у колонки слева.
  • MySQL не знает массивов: json-колонка — json.RawMessage, списки для IN — sqlc.slice с ошибкой на пустом срезе и запретом prepared statements; SQLite — json.RawMessage.

Домашнее задание

Проведите трекер через коллекции. Первое: миграцией 006 добавьте tags text[] и metadata jsonb, запустите sqlc generate и найдите Tags и Metadata в models.go — сверьте типы с уроком. Второе: напишите GetTodosByIDs с = ANY($1::bigint[]) и убедитесь, что аргумент — []int64; вызовите метод с пустым срезом и объясните себе, почему ответ пустой, а ошибки нет. Третье: вставьте две задачи — одну без тегов, вторую с '{}' — и прочитайте обе: проверьте nil и len на каждом, добейтесь, чтобы вывод различал состояния. Четвёртое: напишите FindTodoByLink с metadata->>'link' = $1 и сверьте сигнатуру — параметр должен приехать pgtype.Text; затем ReplaceTodoMetadata с $2::jsonb и запишите документ через json.Marshal. Пятое: соберите эндпоинт, который отдаёт Metadata как готовый JSON-ответ, и второй вариант — с json.Unmarshal в свою структуру; засеките, где вам понадобился валидационный доступ, а где байты как есть.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Массивы и JSON в Postgres)

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

Чем json.RawMessage отличается от []byte и что выбирать?

По устройству это один и тот же срез байт: json.RawMessage — именованный тип из encoding/json, который сообщает кодировщику «меня не перекодируй, вставь как есть». При pgx/v5 sqlc генерирует []byte, при database/sql, MySQL и SQLite — json.RawMessage; выбор между ними свободен только в overrides: для API-ответов удобнее RawMessage в полях структур, для ручного Unmarshal хватит []byte.

Почему для text[] нет nullable-обёртки и указателей?

Потому что генератор помечает колонку-массив как NOT NULL независимо от схемы: nullability массива не входит в тип поля, pgtype-обёрток для массивов не существует, а emit_pointers_for_null_types на них не действует. SQL NULL при этом не теряется: при pgx он читается nil-срезом, а литерал '{}' — пустым срезом нулевой длины. Если различие не нужно — ставьте NOT NULL DEFAULT '{}' и nil-состояние исчезает.

Как отфильтровать по списку значений без ANY?

В PostgreSQL альтернатива — unnest: JOIN с развёрнутым массивом-параметром, как в вставке-пачке пятого урока; для простых сравнений ANY каноничнее. В MySQL и SQLite параметр-срез разворачивает макрос sqlc.slice('ids') внутри IN (...): он пересобирает запрос под длину среза, поэтому несовместим с prepared statements и требует непустого среза. Склеивать список значений строками нельзя — это путь к инъекциям.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила