Содержание урока · 16 разделов
- Ядро и диалекты: кто на самом деле пишет SQL
- DSN-шпаргалка трёх драйверов
- Clause-конвейер: где диалектор вмешивается в SQL
- RETURNING против LastInsertId: возврат ключей
- gorm диалекты: карта регистра, цитат и плейсхолдеров
- jsonb, json и просто текст: JSON на стороне базы
- DataTypeOf: как одна модель даёт три таблицы
- Одна модель — три таблицы: переводы DataTypeOf
- ON CONFLICT против ON DUPLICATE KEY
- Ошибки дублей: 1062, 23505 и нормализация
- LIMIT-синтаксис: где LIMIT вообще живёт
- SQLite in-memory: лаборатория без сервера
- Что унести в двадцать четвёртый урок
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Ядро и диалекты: кто на самом деле пишет SQL#
GORM разделена на ядро и драйверы, и это разделение — не украшение архитектуры, а точка, где принимается всё диалектное. Ядро собирает запрос не как строку, а как Statement со списком clause-ов: INSERT, VALUES, ON CONFLICT, WHERE, ORDER BY, LIMIT — и каждый clause умеет писать себя в буфер. Кто ведёт этот буфер в текст конкретной базы? Dialector. Это интерфейс из gorm.go с обязательными методами Name() (имя диалектора: postgres, mysql, sqlite), Initialize (поднять соединение и зарегистрировать колбэки), DataTypeOf (перевод типа поля модели в тип колонки), BindVarTo (как рисуется плейсхолдер), QuoteTo (как цитируются имена), Explain (красивый SQL-след для лога) — плюс необязательные расширения: ClauseBuilders (подмена поведения отдельных clause-ов) и Translate из интерфейса ErrorTranslator (нормализация ошибок, до неё дойдём в середине урока). Три официальных диалектора живут в трёх модулях: gorm.io/driver/postgres построен поверх pgx от Джека Кристенсена — того самого драйвера, чью семью pgtype мы упоминали в разговоре о массивах; gorm.io/driver/mysql — поверх классического go-sql-driver/mysql; gorm.io/driver/sqlite — поверх mattn/go-sqlite3, а это байндинг к C-библиотеке, то есть честное требование CGO. Каждый диалектор при Initialize дописывает свой набор колбэков: именно там PostgreSQL включает RETURNING, SQLite — своё, MySQL — своё. Отсюда рабочее правило урока: ваш прикладной код обращается к gorm.DB, диалектное — спрятано в Dialector, и перенос базы — это замена одного аргумента Open плюс аудит того, чем вы всё-таки воспользовались из знаний конкретной СУБД.
Канон: один код — три базы
GO// Один и тот же прикладной код - меняется только первый аргумент gorm.Open.
import (
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
switch kind {
case "postgres":
// DSN из README драйвера: пары ключ=значение через пробел, поверх pgx
dsnPG := "host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai"
db, err = gorm.Open(postgres.Open(dsnPG), &gorm.Config{})
case "mysql":
// DSN из README: порт внутри tcp(...), parseTime=True обязателен для time.Time
dsnMy := "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local"
db, err = gorm.Open(mysql.Open(dsnMy), &gorm.Config{})
case "sqlite":
// SQLite - это файл, сервера нет; README: db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
db, err = gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
}
// Дальше - код курса: db.Create, db.Where, db.First -
// работают во всех трёх мирах одинаково.Три импорта диалекторов в одном файле допустимы: выбирает не код, а конфигурация. Формат DSN у каждой базы свой, разбор строк подключения — материал второго урока; здесь важно другое: после Open весь прикладной код одинаков, а диалектные отличия сидят внутри Dialector.
DSN-шпаргалка трёх драйверов#
| Диалектор | Поверх чего | DSN из README | На что смотреть |
|---|---|---|---|
| gorm.io/driver/postgres | pgx (jackc/pgx) | host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai | пары ключ=значение через пробел |
| gorm.io/driver/mysql | go-sql-driver/mysql | gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local | без parseTime=True time.Time не отсканируется |
| gorm.io/driver/sqlite | mattn/go-sqlite3 (нужен CGO) | sqlite.Open("gorm.db") — просто путь к файлу | можно file::memory:?cache=shared — база в памяти |
Clause-конвейер: где диалектор вмешивается в SQL#
Чтобы спорить о диалектах предметно, посмотрим, чем диалекторы отличаются в исходниках. В ядре, в callbacks/callbacks.go, объявлены базовые clause-списки: createClauses = []string{"INSERT", "VALUES", "ON CONFLICT"}, updateClauses = []string{"UPDATE", "SET", "WHERE"}, deleteClauses = []string{"DELETE", "FROM", "WHERE"} — из них колбэки собирают каждый запрос. Диалектор получает право дописать или подменить этот список при регистрации колбэков, и вот что делают трое. PostgreSQL-диалектор в postgres.go добавляет "RETURNING" ко всем трём спискам: CreateClauses, UpdateClauses и DeleteClauses — то есть RETURNING есть не только у вставки, но и у UPDATE и DELETE, где Postgres умеет возвращать старые и новые значения колонок. SQLite-диалектор просто объявляет свои списки с RETURNING на борту: CreateClauses: []string{"INSERT", "VALUES", "ON CONFLICT", "RETURNING"}, а в UpdateClauses у него даже появляется FROM — SQLite с версии 3.33 умеет UPDATE ... FROM, и драйвер этим пользуется. MySQL-диалектор делает противоположное: он расширяет UpdateClauses и DeleteClauses парой "ORDER BY", "LIMIT" — записями вроде UpdateClauses = []string{"UPDATE", "SET", "WHERE", "ORDER BY", "LIMIT"} из mysql.go. Это не прихоть, а документированная способность MySQL: у него ORDER BY и LIMIT допустимы прямо в UPDATE и DELETE. Читая эти списки, вы читаете фактическую карту возможностей: что диалектор дописал — то база умеет, чего не дописал — того не обещано. Пятый урок уже показал, как clause.OnConflict превращается в SQL; сегодня смотрим на конвейер шире — как на таблицу отличий, записанную прямо в коде драйверов.
Проверка: что говорит расширение clause-списков
В mysql.go объявлено UpdateClauses = []string{"UPDATE", "SET", "WHERE", "ORDER BY", "LIMIT"}, тогда как в ядре GORM updateClauses = []string{"UPDATE", "SET", "WHERE"}. Что это сообщает про MySQL?
RETURNING против LastInsertId: возврат ключей#
Мост из пятого урока: ID свежевставленной строки возвращается в структуру двумя механизмами — урок о Create разбирал этот выбор с прикладной стороны, а здесь смотрим, кто его делает. Решает не вы, а диалектор. PostgreSQL добавляет RETURNING к clause-спискам всегда — строка postgres.go «callbackConfig.CreateClauses = append(callbackConfig.CreateClauses, "RETURNING")» — и заодно получает назад значения полей с default-выражениями: база прислала их в той же команде. SQLite-диалектор держит RETURNING в своих списках сразу: отдельная СУБД-версия не опрашивается, а сам SQLite поддерживает RETURNING начиная с 3.35, и современные байндинги его дают. MySQL — самая интересная история, потому что у классического MySQL RETURNING нет вовсе, и mysql.go включает его условно: в Initialize драйвер выполняет SELECT VERSION(), и только если в ответе обнаружена MariaDB версии 10.5 и новее — условие checkVersion(dialector.ServerVersion, "10.5") внутри ветки с "MariaDB" — к спискам добавляется RETURNING. Две оговорки исходников: опция DisableWithReturning: true отключает механизм принудительно, а SkipInitializeWithVersion: true пропускает запрос версии — и тогда withReturning остаётся false даже на MariaDB, молча. На классическом MySQL остаётся второй путь: GORM читает result.LastInsertId() у драйвера и раздаёт значения; на срезах — первый ключ плюс шаг автоинкремента. Практическая цена та же, что в пятом уроке: поле с default-выражением после Create на MySQL останется нулём в структуре — RETURNING читать некому, и никаких Translate-подмен это не лечит. Проверяйте версию — драйвер делает это за вас, но только если вы ему не запретили.
gorm диалекты: карта регистра, цитат и плейсхолдеров#
Второй слой отличий — то, что видно в каждой строке SQL-следа. Плейсхолдеры: метод BindVarTo у postgres-диалектора пишет '$' и номер — $1, $2, $3; у mysql- и sqlite-диалекторов он умещается в одну строку — writer.WriteByte('?'). Поэтому SQL-след с нумерованными долларами выдаёт PostgreSQL с pgx мгновенно. Цитирование имён: QuoteTo postgres-диалектора оборачивает идентификаторы в двойные кавычки — это стандарт SQL; mysql-диалектор пишет бэктики; а вот sqlite-диалектор GORM, вопреки ожиданию, тоже пишет бэктики — и это легально, потому что SQLite принимает бэктики ради совместимости с MySQL. Регистр: здесь GORM не помощник, он передаёт условие базе как есть — седьмой урок уже разбирал ILIKE как регистрозависимую компенсацию Like-а у Postgres, а это материал седьмого урока; MySQL при коллациях *_ci ищет без регистра сам, SQLite не различает регистр для ASCII. То же с датами и булевыми: timestamptz у Postgres, datetime с точностью у MySQL, у которого parseTime=True в DSN не опция, а условие чтения time.Time. Никакой магии в этих отличиях нет — каждое сидит в конкретном методе конкретного диалектора, и когда «на другом диалекте сломался поиск по подстроке», ответ ищите не в логах GORM, а в этой карте: чем пользователь воспользовался — ILIKE, бэктиками или коллацией — и есть ли аналог там, куда переезжают.
Проверка: узнать диалектор по следу
В SQL-следе вы видите WHERE slug = $1. Какой диалектор собрал запрос?
jsonb, json и просто текст: JSON на стороне базы#
Пришло время отдать долг прошлого урока: jsonb, GIN и оператор @> были названы знанием диалекта — теперь разбираем их целиком. PostgreSQL хранит jsonb в распарсенном бинарном виде: ключи дедуплицированы, порядок не сохраняется, зато доступны операторы — прежде всего @> «содержит», который проверяет вложенность документа. На jsonb строится GIN-индекс, и план запроса с WHERE data @> '{"city":"Пенза"}' показывает Bitmap Index Scan по GIN: выборка мгновенная, потому что индекс организован под поиск по элементам, а не по строке. Индексы — материал восемнадцатого урока: он показывал, как GORM строит их тегами, но GIN на jsonb-колонке — уже SQL-знание: CREATE INDEX ... USING gin (data), исполняемый миграцией или db.Exec. MySQL отвечает своим типом json (с версии 5.7.8): тоже распарсенное хранение, но оператора @> нет — поиск по элементу идёт функциями, а индекс строят функциональным: CREATE INDEX ... ((CAST(data->'$.city' AS CHAR(64)))) по выражению пути. SQLite — третий мир: «JSON-колонка» у него обычный TEXT, разбора на стороне хранилища нет, поиск — функциями вроде json_extract из семейства json1, а индекс, если нужен, — тоже по выражению. Отсюда честная иерархия для переезда: jsonb+GIN+@> — максимум скорости поиска и максимум привязки к диалекту; json с функциями — середина; текст с сериализацией на границе модели — медленный на фильтре, но переносимый везде. Один тег type:jsonb в модели — и вы уже в первом мире со всеми его преимуществами и ограничениями.
JSON-фильтр в трёх диалектах
GO// Модель одна, колонка - по диалекту. type:jsonb даёт Postgres-тип.
type Meta struct {
ID uint
Data string `gorm:"type:jsonb"` // сериализация структур - материал прошлого урока
}
// PostgreSQL: GIN-индекс и оператор @> "содержит"
db.Exec(`CREATE INDEX idx_meta_data ON metas USING gin (data)`)
db.Where("data @> ?", `{"city":"Пенза"}`).Find(&rows)
// план: Bitmap Index Scan по idx_meta_data
// MySQL: тип json, поиск функцией, индекс - функциональный по выражению
db.Where("data -> '$.city' = ?", "Пенза").Find(&rows)
db.Exec(`CREATE INDEX idx_city ON metas ((CAST(data->'$.city' AS CHAR(64))))`)
// SQLite: колонка текстовая, поиск - функцией json_extract
db.Where("json_extract(data, '$.city') = ?", "Пенза").Find(&rows)
// без индекса это полный проход; индекс по выражению помогает лишь частичноПрикладная форма запроса меняется от базы к базе: это уже не GORM-код, а SQL-знание, спрятанное в строках условий. GORM честно передаёт условие базе как есть — диалект-операторы в строках условий не переводятся. Тег type:jsonb на MySQL соберёт невалидный тип колонки — это ловушка ниже.
Прогноз: одна модель — три колонки
Прочитайте код и выберите, что он выведет:
type TodoItem struct {
ID uint
Title string
Tags []string `gorm:"serializer:json"`
}
// AutoMigrate исполняется на PostgreSQL, MySQL и SQLite.
// Какой тип колонки tags получится в каждой из трёх баз?DataTypeOf: как одна модель даёт три таблицы#
Прогноз выше опирается на метод, который стоит разобрать до строчек. DataTypeOf — это перевод типа поля модели в SQL-тип колонки, и у каждого диалектора он свой. Разберём три показательные ветки. Строка: postgres.go при Size 0 возвращает text, а при заданном размере varchar(n); mysql.go без размера берёт DefaultStringSize — конфиг-поле со значением 256 по умолчанию — и делает varchar(256), но в ветке есть ещё одна деталь с комментарием «// utf8mb4»: если поле ключевое, имеет default или под ним индекс, размер ужимается до 191 — классическое наследие ограничения длины индексного ключа в старых utf8mb4-индексах MySQL. SQLite пишет text всегда — в SQLite VARCHAR(255) это просто тип с аффинностью TEXT. Булев тип: Postgres даёт честный boolean, MySQL тоже пишет boolean, хотя хранит его как tinyint(1); sqlite-диалектор возвращает numeric — и таблица создаётся с numeric-колонкой, которую SQLite, благодаря аффинностям, держит как хочет. Автоинкремент: bigserial у Postgres, int auto_increment у MySQL, а sqlite.go возвращает целую формулу — «integer PRIMARY KEY AUTOINCREMENT». Время: timestamptz против datetime(3) — mysql-диалектор подставляет DefaultDatetimePrecision, равное трём знакам после запятой. Вывод для практики: миграция из четвёртого урока не «создаёт одну и ту же таблицу в разных базах» — она создаёт три разных таблицы из одной модели, и сравнение d в psql с SHOW CREATE TABLE в MySQL — лучший способ увидеть диалект вживую. Отсюда и правило: не полагайтесь на дефолтные типы там, где важна точность, — задавайте size и type явно.
Одна модель — три таблицы: переводы DataTypeOf#
| Поле модели | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Status bool | boolean | boolean (хранится как tinyint(1)) | numeric |
| ID uint, автоинкремент | bigserial | int auto_increment | integer PRIMARY KEY AUTOINCREMENT |
| Title string без size | text | varchar(256), под индексом 191 | text |
| DoneAt time.Time | timestamptz | datetime(3) | datetime |
| Payload []byte | bytea | blob | blob |
ON CONFLICT против ON DUPLICATE KEY#
Три режима clause.OnConflict из пятого урока вы уже знаете как готовый приём; сейчас интересна механика, как один clause живёт в разных базах. Имя clause-а всегда «ON CONFLICT» — его возвращает метод Name() структуры clause.OnConflict, и в clause-списках вставки у всех диалекторов стоит именно ON CONFLICT, унаследованный от ядра. Но перед записью в буфер каждый диалектор имеет право перехватить clause через ClauseBuilders — карту подмен, которую Initialize передаёт ядру. MySQL этим правом пользуется полноценно: в mysql.go карта содержит ClauseOnConflict, и её функция пишет в буфер не стандартную форму, а строку builder.WriteString("ON DUPLICATE KEY UPDATE ") — дальше идут присваивания, которые GORM собрала из полей clause-а: перечисленные колонки или обновление всех сразу. Дока формулирует совместимость дословно: GORM provides compatible Upsert support for different databases — совместимый upsert для разных баз, то есть API один, а SQL подставляется по диалекту. Самая неочевидная деталь — DoNothing на MySQL: у MySQL нет DO NOTHING, поэтому диалектор в той же функции превращает «ничего не делать» в фиктивное присваивание ключа самому себе — INSERT ... ON DUPLICATE KEY UPDATE id=id: строка-дубль найдена, ничего не изменено, RowsAffected ноль. Второе отличие — цель конфликта: PostgreSQL требует сказать, по какому ограничению конфликт, а MySQL цели не принимает вовсе — Columns игнорируются, потому что ON DUPLICATE KEY срабатывает по любому уникальному ключу. SQL Server, упомянутый в доке, решает задачу совсем другим statement-ом — MERGE INTO; но он за рамками тройки урока. Мораль: clause.OnConflict — это абстракция над тремя разными SQL-мирами, и при отладке смотрите итоговый SQL, а не API.
Проверка: DoNothing на MySQL
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&user) исполняется на MySQL. Что уйдёт в SQL?
Ошибки дублей: 1062, 23505 и нормализация#
Пятый урок показал, что вставка с уникальным Slug рано или поздно упрётся в констрейнт, и назвал коды: 23505 у Postgres, 1062 у MySQL. Теперь смотрим, как с этим работать на трёх базах одним кодом. Без помощи GORM вы классифицируете тексты и типы драйверов: доки советуют switch по mysql.MySQLError с веткой case 1062 — «MySQL code for duplicate entry»; у Postgres ошибка — pgconn.PgError с полем Code; SQLite отвечает текстом «UNIQUE constraint failed», а численно — расширенными кодами 1555 и 2067 (нарушение первичного ключа и уникального индекса). Три базы — три разных типа — три switch-а в вашем коде. GORM предлагает второй путь: в errors.go объявлены gorm.ErrDuplicatedKey = errors.New("duplicated key not allowed") и gorm.ErrForeignKeyViolated, а конфиг gorm.Open принимает TranslateError: true — доки объясняют: когда TranslateError включён, GORM converts database-specific errors into its own generalized errors — и добавляют: By enabling TranslateError, GORM provides a more unified way of handling errors across different databases. Механика честно локализована в драйверах: у каждого свой файл error_translator.go с картой кодов — postgres.go-фамилия кодов: «23505»: gorm.ErrDuplicatedKey, «23503»: ErrForeignKeyViolated, «42703»: ErrInvalidField, «23514»: check-constraint; mysql-карта — 1062: gorm.ErrDuplicatedKey и пара 1451/1452 на внешние ключи; sqlite-карта — 1555, 2067 и 787. Метод Translate сверяет код и оборачивает: postgres-версия делает fmt.Errorf("%w: %w", translatedErr, pgErr) — двойное заворачивание, поэтому errors.Is находит и gorm-ошибку, и оригинал драйвера. После этого один if errors.Is(err, gorm.ErrDuplicatedKey) работает на трёх базах — то, что в ent-курсе вы называли UniqueConstraintError, здесь носит имя ErrDuplicatedKey, а при выключенном TranslateError не сработает никогда.
Прогноз: что вернёт errors.Is
Прочитайте код и выберите, что он выведет:
db, _ := gorm.Open(postgres.Open(dsn),
&gorm.Config{TranslateError: true})
// В таблице уже есть строка со slug "hleb",
// на Slug висит уникальный индекс.
err := db.Create(&TodoItem{Slug: "hleb"}).Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
fmt.Println("дубль")
} else {
fmt.Println("не распознано")
}
// Что напечатает программа?LIMIT-синтаксис: где LIMIT вообще живёт#
Семнадцатый урок научил вас Limit и Offset как методам GORM; на диалектном уровне выясняется, что LIMIT — не везде такой одинаковый, каким кажется. В SELECT тройка урока совпадает: LIMIT n OFFSET m понимают PostgreSQL, MySQL и SQLite, так что пагинация — самая переносимая часть языка. Методы GORM собирают именно эту форму, и сюрпризов здесь нет. Дальше начинается интересное: LIMIT и ORDER BY в модифицирующих командах. MySQL разрешает их прямо в UPDATE и DELETE — и мы уже видели доказательство в исходниках: mysql.go объявляет UpdateClauses и DeleteClauses с "ORDER BY", "LIMIT" на хвосте, то есть диалектор честно разрешит db.Limit(10).Delete(&TodoItem{}), и это будет легальный MySQL-запрос «удалить не больше десяти». PostgreSQL в UPDATE и DELETE прямого LIMIT не имеет: чтобы ограничить затронутые строки, пишут подзапрос по ctid или по первичному ключу — либо оставляют задачу WHERE-условию. SQLite поддерживает UPDATE/DELETE с LIMIT только как опцию сборки — флаг SQLITE_ENABLE_UPDATE_DELETE_LIMIT при компиляции библиотеки; в стандартных сборках go-sqlite3 этой опции нет, и запрос упадёт с синтаксической ошибкой. Практический вывод двоякий. Первый: пакетная обработка через «LIMIT в DELETE» пишется только под MySQL — под две другие базы делайте это SELECT-ом по ключам и DELETE WHERE id IN. Второй: GORM пропустит .Limit() в запросе изменения на любом диалекторе — проверка синтаксиса происходит в базе, и ошибка приедет из неё, а не из библиотеки. Диалект-знание экономит отладку: вы знаете, чья это поддержка, ещё до запуска.
SQLite in-memory: лаборатория без сервера#
Из трёх диалекторов SQLite единственный не требует сервера, и именно поэтому он стал любимцем тестов: база живёт в памяти процесса, поднимается за миллисекунды, умирает вместе с ним, не оставляя мусора. DSN-правила приносит mattn/go-sqlite3: классический «:memory:» создаёт частную базу на каждое соединение, а строка file:test.db?cache=shared&mode=memory из README — общую именованную память. Ловушка, о которую бьются все: GORM по умолчанию держит пул соединений, и второе соединение к «:memory:» увидит пустую базу — отсюда классический вопрос «почему no such table, ведь таблица создавалась». Лечится двумя способами: DSN с cache=shared — тогда все соединения пула смотрят на одну память — или db.DB() и SetMaxOpenConns(1), что честнее для теста: одна память, один писатель, никаких сюрпризов блокировок. Вторая особенность — сборка: официальный sqlite-драйвер основан на C-библиотеке и требует CGO, о чём README говорит прямо, а рядом перечисляет чисто-Go альтернативы — glebarez/sqlite и другие — с фразой Usage is identical — simply swap the import path: импорт меняется, диалектный код нет. В cross-compilation без CGO официальный драйвер не соберётся вовсе, и альтернатива — не роскошь, а необходимость. В sqlc-курсе SQLite-для-тестов посвящён отдельный урок; здесь зафиксируем границу: in-memory — это про скорость и изоляцию лаборатории, а инструменты вокруг тестов — транзакционные тесты, фабрики данных — тема двадцать четвёртого урока, который начинается ровно с этого моста. Память — не уменьшенная база, а настоящая SQLite: все диалектные отличия этого урока в ней присутствуют целиком.
Канон: полигон на in-memory, код без диалекта
GO// Лаборатория: SQLite в памяти, ошибки - в нормализованном виде.
func openLab() (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"),
&gorm.Config{TranslateError: true})
if err != nil {
return nil, err // CGO-сборка обязательна: это C-библиотека
}
// пул соединений - одна память на всех; альтернатива - SetMaxOpenConns(1)
return db, nil
}
// Прикладной код, который не знает, какая база под ним:
item := TodoItem{Title: "Купить хлеб", Slug: "hleb"}
if err := db.Create(&item).Error; err != nil {
// RETURNING или LastInsertId - решил диалектор, item.ID заполнен в обоих мирах
return err
}
var got TodoItem
db.Where("slug = ?", "hleb").First(&got) // плейсхолдер ? - у mysql и sqlite
// этот же код с postgres.Open на живом сервере работает без правокКанон мультибазовости: диалект-знание локализовано в двух местах — выбор Dialector при Open и осознанные SQL-строки условий. Внутри прикладного кода ни одного упоминания базы нет: и RETURNING, и плейсхолдеры, и возврат ключа решены диалектором. TranslateError: true включает нормализацию ошибок на всех базах.
Сборка: in-memory лаборатория с нормализацией ошибок
Соберите функцию лаборатории на SQLite: общая память для пула, включённый перевод ошибок и честная ловля дубля. Пропуски ___ отмечают места для кода.
Путь INSERT-а через диалектор
Соберите жизненный цикл вставки: как один db.Create доезжает до трёх разных SQL-миров.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: тег type:jsonb на MySQL — DataTypeOf пропустит его как кастомный тип в DDL, а CREATE TABLE упадёт: jsonb существует только в PostgreSQL, MySQL ждёт json. Вторая: «:memory:» без cache=shared — у каждого соединения пула своя пустая база, тест ловит «no such table» на втором запросе; либо cache=shared, либо SetMaxOpenConns(1). Третья: без TranslateError: true проверка errors.Is(err, gorm.ErrDuplicatedKey) не сработает никогда — приедет сырая ошибка драйвера, и switch придётся писать по трём типам: mysql.MySQLError, pgconn.PgError и текст SQLite.
Что унести в двадцать четвёртый урок#
Соберём правило портативности, которое урок прошёл по всем слоям. Уровень первый — код: db.Create, db.Where, ассоциации, транзакции одинаковы на трёх базах, потому что диалект-знание спрятано в Dialector — вы платите одним аргументом Open. Уровень второй — типы колонок: DataTypeOf переводит одну модель в три таблицы, и varchar(256) против text против numeric — это не ошибки, а переводы; проверяйте их SHOW CREATE TABLE-ом и задавайте size/type явно, когда точность важна. Уровень третий — SQL в строках условий: регистрозависимый ILIKE-поиск, jsonb-оператор @>, json_extract, бэктики — вы выехали за границу GORM, и переносить такое придётся руками; держите эти строки в скопах и репозиториях запросов, чтобы их было видно. Уровень четвёртый — ошибки: TranslateError превращает три карты кодов в один errors.Is, и это дешёвая страховка при переезде. Куда двигаться дальше: двадцать четвёртый урок — тестирование с GORM, и он начинается с того, что этот урок оставил в наследство — SQLite in-memory как полигон, на котором все диалектные правила всё ещё честные. Возьмите канон этого урока и прогоните свой домашний проект на всех трёх базах: список упавших мест — это и есть ваш личный портрет диалект-знания, и лучше узнать его до продакшена, чем после.
Карточки: мультибазовость и диалекты
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Мультибазовость в GORM устроена через Dialector: ядро собирает запрос из clause-ов, а диалектор решает всё диалектное — плейсхолдеры (BindVarTo: $n против ?), цитаты (QuoteTo: двойные кавычки против бэктиков), типы колонок (DataTypeOf) и подмены clause-ов (ClauseBuilders).
- Три официальных драйвера: gorm.io/driver/postgres поверх pgx, gorm.io/driver/mysql поверх go-sql-driver/mysql, gorm.io/driver/sqlite поверх mattn/go-sqlite3 с требованием CGO; pure-Go альтернативы (glebarez/sqlite) меняют только импорт.
- RETURNING: Postgres дописывает его ко всем clause-спискам, SQLite держит сразу, MySQL — только на MariaDB ≥ 10.5 после SELECT VERSION(); классический MySQL читает LastInsertId, и default-выражения после Create остаются нулями.
- Один serializer — три колонки: text (Postgres при Size 0), varchar(256) с DefaultStringSize (MySQL, под индексом 191 из-за utf8mb4), text (SQLite); boolean на SQLite — numeric, автоинкремент — bigserial, int auto_increment, integer PRIMARY KEY AUTOINCREMENT.
- jsonb+GIN+@> — Postgres-only знание: MySQL отвечает типом json и функциональными индексами, SQLite — TEXT и json_extract; операторы диалектов в строках условий GORM не переводит — переносить руками.
- clause.OnConflict один для всех баз: MySQL через ClauseBuilders пишет ON DUPLICATE KEY UPDATE, эмулирует DoNothing присваиванием `id`=`id` и игнорирует Columns; PostgreSQL требует цель конфликта.
- Ошибки дублей: 23505 (Postgres), 1062 (MySQL), 1555/2067 (SQLite); с TranslateError: true каждый error_translator.go мапит коды в gorm.ErrDuplicatedKey с %w-обёрткой — один errors.Is на три базы, без настройки — три разных switch-а.
- LIMIT/OFFSET в SELECT одинаков во всех трёх; ORDER BY и LIMIT в UPDATE/DELETE — MySQL-умение (видно по UpdateClauses mysql.go), Postgres требует подзапрос, SQLite — флаг сборки SQLITE_ENABLE_UPDATE_DELETE_LIMIT.
- SQLite in-memory — лаборатория без сервера: «:memory:» приватна на соединение (ловушка «no such table» в пуле), лечится cache=shared или SetMaxOpenConns(1); инструменты тестов вокруг неё — тема следующего урока.
Домашнее задание
Соберите лабораторию мультибазовости поверх канона с todo_items. Шаг первый: поднимите PostgreSQL и MySQL локально, напишите переключатель конфигурации с тремя DSN из урока и убедитесь, что прикладной код — Create, Where, First — работает без правок на всех трёх базах; SQLite подключите через file::memory:?cache=shared и прогоните тот же код. Шаг второй: создайте модель с полем Tags []string и serializer:json, выполните миграции на трёх базах и сравните фактические типы колонок — d в psql, SHOW CREATE TABLE в MySQL, PRAGMA table_info в SQLite; выпишите три результата и объясните каждый через DataTypeOf. Шаг третий: на PostgreSQL добавьте jsonb-колонку с GIN-индексом и прогоните фильтр @> с EXPLAIN ANALYZE; на MySQL повторите функциональным индексом по выражению пути; на SQLite — json_extract-ом; сравните планы и запишите, где поиск мгновенный, а где полный проход. Шаг четвёртый: вставьте дубль Slug на трёх базах с TranslateError: true и без — проверьте, что errors.Is(err, gorm.ErrDuplicatedKey) срабатывает только с настройкой; затем временно отключите TranslateError и напишите три классификации руками: *mysql.MySQLError с кодом 1062, *pgconn.PgError с кодом 23505 и текст SQLite. Шаг пятый: попробуйте db.Limit(10).Delete(&TodoItem{}) на MySQL и на PostgreSQL — один запрос пройдёт, второй упадёт; поймите по исходникам mysql.go, почему. Финал: в тесте на «:memory:» снимите cache=shared и воспроизведите «no such table» на втором соединении пула, затем почините — и зафиксируйте в заметках проекта, какие диалект-строки в вашем коде потребуют внимания при переезде.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько безопасно переезжать между PostgreSQL, MySQL и SQLite с одним и тем же кодом GORM?
Ядро кода — Create, Find, Where, ассоциации, транзакции — переносится заменой одного аргумента gorm.Open, потому что всё диалектное спрятано в Dialector. Разрушаются три вещи: SQL в строках условий (диалектные операторы вроде @> и json_extract — GORM их не переводит), типы колонок (DataTypeOf даёт text против varchar(256) против numeric — сверяйте фактические схемы) и возвращение сгенерированных значений (default-выражения на MySQL после Create остаются нулями). Если вы использовали диалект-операторы или type:jsonb, при переезде каждый такой случай требует ручной замены.
Почему GORM.ErrDuplicatedKey не ловится, хотя дубль точно есть?
Потому что нормализация ошибок выключена по умолчанию. Она включается конфигом gorm.Config{TranslateError: true} при gorm.Open и работает через метод Translate диалектора: файл error_translator.go каждого драйвера держит карту кодов — 23505 у Postgres, 1062 у MySQL, 1555 и 2067 у SQLite — и заворачивает ошибку драйвера в gorm.ErrDuplicatedKey через %w. Без настройки приедет сырая ошибка: *mysql.MySQLError, *pgconn.PgError или текст SQLite, и один errors.Is на три базы не собрать.
Чем ':memory:' отличается от 'file::memory:?cache=shared'?
«:memory:» создаёт отдельную частную базу на каждое соединение: у GORM с пулом соединений второе соединение увидит пустую базу, и тест упадёт с «no such table». Строка file::memory:?cache=shared из README драйвера именует общую память: все соединения пула работают с одной и той же базой. Альтернатива — ограничить пул одним соединением через SetMaxOpenConns(1). И помните: официальный драйвер требует CGO, для сборок без CGO есть glebarez/sqlite с тем же API.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx