Содержание урока · 16 разделов
- gorm проект: что мы собираем к финалу
- Схема блога: сущности, связи, таблицы
- Карта проекта: какая тема курса куда входит
- Разбор связей: belongs to, has many, m2m теги
- SQL-следы: что вы написали бы руками
- Слой Repository: интерфейс как шов
- Создание поста с тегами: одна транзакция
- Мягкое удаление в проекте: спрятать, не найти, вернуть
- Лента: скоупы published/draft и пагинация
- Тесты проекта: канон прошлого урока переезжает
- Чек-лист репозитория блога
- Финал: двадцать четыре урока под вашими пальцами
- Чем владеет выпускник курса
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gorm проект: что мы собираем к финалу#
Итоговый gorm проект начинается не с кода, а с инвентаризации: что вообще должен уметь слой данных go-приложения-блога. Список получается короткий и очень знакомый: показать ленту опубликованных постов постранично; открыть один пост вместе с автором, комментариями и тегами; создать пост и сразу приклеить к нему теги — так, чтобы половина операции не могла уцелеть отдельно от другой; спрятать пост, не стирая его с диска; вернуть спрятанное; и не обрушиваться при каждом запуске. Это и есть нижний этаж приложения: сервисы и хендлеры дёргают его через интерфейс, а он разговаривает с базой. Двадцать четыре урока дали все детали по отдельности — модели, миграции, удаление, связи, транзакции, пагинация, скоупы, тесты. Сегодня мы не переобъясняем ни одну из них: мы расставляем их по рабочим местам в одном проекте. Схема блога уложится в пять таблиц и четыре модели, Repository — в один интерфейс и одну gorm-реализацию, тесты — в тот же каркас, что вы собирали в прошлом уроке для списка задач. Каждый фрагмент кода снабжён пометкой, из какого урока он приехал: так проект работает дважды — как готовый слой данных и как шпаргалка по всему курсу. Если какая-то деталь кажется чужой — вернитесь по пометке, освежите её и возвращайтесь: финал не прощает пропусков, но и не требует ничего держать в голове насильно.
Схема блога: сущности, связи, таблицы#
Начнём со схемы блога — на бумаге, до всякого кода. Четыре сущности и одна join-таблица. Author — автор: id и имя, больше ничего, потому что профиль пользователя нам не нужен, а принадлежность поста — нужна. Post — центральная сущность: заголовок, уникальный slug-адрес, тело, флаг published для черновиков, время создания и внешний ключ author_id — он покажет связь принадлежности из одиннадцатого урока. Comment — комментарий: тело, имя комментатора и post_id, обратная сторона той же пары, но уже один-ко-многим из двенадцатого урока: у поста много комментариев, у каждого комментария один пост. Tag — тег с уникальным именем: один тег живёт у многих постов, и у каждого поста много тегов — это many-to-many из тринадцатого урока, и для него GORM заведёт пятую таблицу post_tags с колонками post_id и tag_id. И одна не-очевидная деталь, которая делает блог блогом: DeletedAt у поста. Удалённая публикация не должна исчезать физически — её возвращают из корзины, её осталось бы видно в админке — поэтому посты удаляются мягко, приёмом девятого урока, а колонка deleted_at получает индекс, потому что фильтр по ней стоит в каждом втором запросе. Обратите внимание, чего в схеме нет: отдельной таблицы «черновики» — черновик это published равный false; таблицы «просмотры» — не наш уровень; категорий — теги их заменяют. Схема блога — это всегда минус сущностей, а не плюс: каждая лишняя таблица — это лишний JOIN, лишняя миграция и лишний тест.
Карта проекта: какая тема курса куда входит#
| Тема курса | В проекте | Что делает |
|---|---|---|
| Модели и соглашения (третий урок) | Author, Post, Comment, Tag | структуры с тегами; таблицы posts, comments, tags, authors |
| Автомиграции (четвёртый урок) | AutoMigrate на старте | пять таблиц со связями за один вызов |
| Удаление и мягкое удаление (девятый) | gorm.DeletedAt в Post | Delete превращается в UPDATE, Unscoped возвращает спрятанное |
| belongs to (одиннадцатый) | Post.AuthorID + Post.Author | пост принадлежит автору; ключ на стороне поста |
| has one и has many (двенадцатый) | Post.Comments []Comment | у поста много комментариев; ключ PostID у комментария |
| many-to-many (тринадцатый) | Post.Tags many2many:post_tags | m2m теги: join-таблица post_tags со связками post_id-tag_id |
| Preload (четырнадцатый) | BySlug с Preload | пост выезжает с автором, комментариями и тегами разом |
| Транзакции (пятнадцатый) | Create с db.Transaction | пост и его теги фиксируются или откатываются вместе |
| Пагинация (семнадцатый) и скоупы (девятнадцатый) | Feed со Scopes(Published, Page) | лента постранично; черновики — отдельным скоупом Draft |
| Тестирование (двадцать четвёртый) | TestPostRepo + фабрика makePost | тот же каркас, что и у списка задач, на новой схеме |
Канон проекта: модели Author, Post, Comment, Tag
GO// Автор: минимальный владелец постов.
type Author struct {
ID uint
Name string
}
// Пост: центральная сущность блога.
type Post struct {
ID uint
Title string
Slug string `gorm:"unique"` // уникальный адрес - приём из канона репозитория
Body string
Published bool // false = черновик
CreatedAt time.Time // заполнится сам при создании
AuthorID uint // belongs to: ключ на стороне поста
Author Author // связь из одиннадцатого урока
Comments []Comment // has many из двенадцатого
Tags []Tag `gorm:"many2many:post_tags"` // m2m из тринадцатого
DeletedAt gorm.DeletedAt `gorm:"index"` // мягкое удаление из девятого
}
// Комментарий: принадлежит посту, живёт в своей таблице.
type Comment struct {
ID uint
PostID uint // без внешнего ключа has many не соберётся
Author string // имя комментатора: отдельная таблица не нужна
Body string
CreatedAt time.Time
}
// Тег: один тег живёт у многих постов, и наоборот.
type Tag struct {
ID uint
Name string `gorm:"unique"` // одно имя тега на всю базу
}Четыре структуры — и вся схема блога. Единственные теги здесь — уникальность на Slug и Name, ключ many2many для m2m тегов и индексированный DeletedAt: остальное GORM выведет из типов и имён полей. Автомиграция четвёртого урока собирает по ним все пять таблиц одним вызовом AutoMigrate.
Разбор связей: belongs to, has many, m2m теги#
Пройдём по связям, не переобъясняя механику одиннадцатого-тринадцатого уроков — только то, как она легла в схему блога. Связь принадлежности живёт там, где внешний ключ: у поста есть AuthorID, значит пост принадлежит автору, а поле Author даёт GORM понять, к какой таблице идти. Важно, что ключ объявлен явно: без AuthorID связь принадлежности была бы догадкой, а с ним — контрактом. В паре пост-комментарий ключ лежит у комментария: PostID. Со стороны поста это выглядит как has many — поле Comments описывает «у меня много комментариев», а GORM сам догадывается о таблице и колонке по именам Post и Comment. Ничего магического: если бы поле комментария называлось иначе, пришлось бы подсказать тегом — и это было в двенадцатом уроке. Наконец, m2m теги: поле Tags с указанием join-таблицы post_tags. Почему именно многие-ко-многим, а не has many с одной связкой? Потому что тег — разделяемый ресурс: «go» вешают сто постов, и хранить сто копий тега — путь к рассинхрону; join-таблица держит только пары идентификаторов, а имя тега существует в одном экземпляре. Три вида связей закрывают практически любую предметную область: принадлежность — для «чей это», один-ко-многим — для «у кого сколько», многие-ко-многим — для «что пересекается». Блог использовал все три, и ни одну не пришлось подпиливать напильником — это главный признак того, что связи спроектированы верно.
Проверка: сколько таблиц соберёт миграция
Автомиграция вызывается так: AutoMigrate(&Author{}, &Post{}, &Comment{}, &Tag{}). Сколько таблиц появится в базе и почему?
SQL-следы: что вы написали бы руками#
Свяжем проект с десятым уроком про Raw и Exec: полезно видеть, что GORM делает вместо вас, потому что однажды вам придётся писать это руками — в отчёте, в миграции данных, в запросе, который ORM выразит неуклюже. Лента — это цепочка из двух соединений: posts к authors по ключу принадлежности, плюс фильтры — опубликовано, не удалено — и сортировка с постраничными границами. Создание поста с тегами — пачка INSERT-ов: сам пост, теги, которых не было, и строки в post_tags, причём между первым и последним INSERT-ом нельзя позволить базе остаться наполовину заполненной — вот почему следующая операция уедет в транзакцию. Мягкое удаление руками — вообще не DELETE, а UPDATE с условием, что строка ещё не спрятана. Ниже — след, который оставляет наш Repository, если бы его писал человек без ORM. Сравните с кодом ниже по уроку: разница не в объёме, а в том, кто отвечает за корректность колонок, экранирование и диалект. ORM берёт рутину на себя, но цена — вы обязаны понимать, какой SQL он соберёт из ваших цепочек, иначе однажды поймаете скан таблицы там, где ждали точечный поиск по индексу. Десятый урок показал, как заглянуть под капот через Raw; здесь достаточно взгляда на текст запроса глазами.
След проекта в SQL: лента, создание, удаление
GO-- Лента: Scopes(Published, Page) + Order превратились вот в это.
SELECT p.id, p.title, p.slug, a.name AS author
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.published = 1 AND p.deleted_at IS NULL
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
-- published = 1 пишет SQLite; Postgres ждал бы true - привет двадцать третьему уроку.
-- Создание поста с тегами: пачка INSERT внутри одной транзакции.
INSERT INTO posts (title, slug, body, published, author_id)
VALUES ('Про Go', 'pro-go', 'Текст', 1, 1);
INSERT INTO tags (name) VALUES ('go');
INSERT INTO tags (name) VALUES ('orm');
INSERT INTO post_tags (post_id, tag_id) VALUES (1, 1);
INSERT INTO post_tags (post_id, tag_id) VALUES (1, 2);
COMMIT;
-- Мягкое удаление - не DELETE, а UPDATE по ещё живым строкам.
UPDATE posts SET deleted_at = '2025-03-01 12:00:00'
WHERE slug = 'pro-go' AND deleted_at IS NULL;Три следа: чтение ленты с соединением и пагинацией, создание с join-строками, мягкое удаление как UPDATE. Всё это GORM собирает из кода ниже — Compare и держите в голове, что именно уезжает в базу.
Слой Repository: интерфейс как шов#
Схема есть — теперь её рабочая обёртка. Слой Repository мы строим точно по канону прошлого урока: структура с закрытым полем *gorm.DB, конструктор, методы-операции — но с одним важным усилением, которого не было у репозитория задач из прошлого урока. Мы объявим интерфейс PostRepo отдельно от реализации: пять методов, каждый — операция блога, а не обёртка над вызовом GORM. Create принимает пост и список имён тегов — потому что «создать пост с тегами» это одна бизнес-операция, и сервис не должен знать, что внутри неё транзакция. BySlug возвращает пост со всеми связями — потому что страница поста всегда рисует автора, комментарии и теги. Feed возвращает и страницу, и общий счётчик — потому что пагинации без знания общего числа постов не бывает. SoftDelete возвращает счётчик затронутых строк — приём из репозитория задач: промах по несуществующему слагу это ноль без ошибки, и пусть решает сервис, что с ним делать. Зачем интерфейс, если реализация одна? Шов из прошлого урока: тесты сервиса получат мок этого интерфейса, тесты самого репозитория будут гонять настоящую базу, а публикация — не знать ни того ни другого. Конструктор возвращает именно интерфейс: потребители зависят от контракта, а не от конкретной структуры — это и есть слой данных go-приложения, отделённый от всего остального.
Канон: интерфейс PostRepo и конструктор
GO// PostRepo - контракт слоя данных блога для сервиса.
type PostRepo interface {
Create(post *Post, tagNames []string) error
BySlug(slug string) (*Post, error)
Feed(page, perPage int) ([]Post, int64, error)
SoftDelete(slug string) (int64, error)
Restore(slug string) error
}
// gormPostRepo - единственная gorm-реализация контракта.
type gormPostRepo struct {
db *gorm.DB
}
// NewBlogRepo принимает базу или транзакцию - это один и тот же тип *gorm.DB.
func NewBlogRepo(db *gorm.DB) PostRepo {
return &gormPostRepo{db: db}
}Пять методов — пять операций блога. Конструктор возвращает интерфейс, а не структуру: сервис зависит от контракта. Подмена базы на транзакцию из пятнадцатого урока здесь бесплатна — Begin возвращает тот же *gorm.DB.
Создание поста с тегами: одна транзакция#
Самая важная операция проекта — Create, и она же — главный аргумент за транзакции из пятнадцатого урока. Операция многошаговая: вставить пост, для каждого имени тега найти существующий или создать новый, вставить связку в post_tags. Между шагами всё, что угодно может пойти не так: слаг оказался занят, имя тега нарушило уникальность, соединение оборвалось посреди пачки. Без транзакции это оставляет мусор — пост без тегов, теги без поста, полупустые связки; с транзакцией — или всё, или ничего. Обёртка db.Transaction принимает замыкание и управляет судьбой операций за вас: вернули nil — COMMIT, вернули ошибку — Rollback, и неважно, на каком шаге вы спохватились. Внутри замыкания работаем не с r.db, а с tx — тем же типом, но связанным с транзакцией; фабрика тегов через Where и FirstOrCreate находит существующий тег или создаёт новый, а Association с Append вставляет строку в join-таблицу — механика тринадцатого урока в чистом виде. Одна честная оговорка: FirstOrCreate под конкуренцией — не атомарная операция; два запроса, создающих один новый тег одновременно, разойдутся так, что один получит ошибку уникальности — и это правильно: его транзакция откатится целиком, а клиент попробует ещё раз. Ловим это на верхнем уровне, а не ломаем транзакцию хитростями. Всё остальное — забота замыкания: оно либо доводит операцию до конца, либо стирает её следы.
Канон: Create с транзакцией и тегами
GO// Create сохраняет пост и привязывает теги - атомарно или никак.
func (r *gormPostRepo) Create(post *Post, tagNames []string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Шаг 1: сам пост. INSERT заполнит ID.
if err := tx.Create(post).Error; err != nil {
return err // откат: поста нет
}
// Шаг 2: теги - найти или создать, затем привязать.
for _, name := range tagNames {
tag := Tag{Name: name}
if err := tx.Where(Tag{Name: name}).FirstOrCreate(&tag).Error; err != nil {
return err // гонка на уникальном имени - тоже откат
}
if err := tx.Model(post).Association("Tags").Append(&tag); err != nil {
return err // join-строка не легла - откат всего
}
}
return nil // nil - транзакция фиксируется
})
}Замыкание из пятнадцатого урока: nil — COMMIT, ошибка — ROLLBACK. FirstOrCreate повторяет семантику «найди или создай», Append вставляет связку в post_tags. Любой сбой посреди пачки стирает всё созданное ранее — пост не останется без тегов.
Прогноз: что останется после отката
Прочитайте код и выберите, что он выведет:
// Обёртка Create с валидацией внутри транзакции.
func (r *gormPostRepo) CreateChecked(post *Post) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(post).Error; err != nil {
return err
}
if post.Body == "" {
return errors.New("пустое тело поста")
}
return nil
})
}
// Пост без тела: Create прошёл, валидация - нет.
_ = repo.CreateChecked(&Post{Title: "Пустышка", Slug: "empty", AuthorID: 1})
var n int64
db.Model(&Post{}).Count(&n)
fmt.Println(n)Мягкое удаление в проекте: спрятать, не найти, вернуть#
Удаление в блоге — самое коварное место, и мягкое удаление девятого урока здесь не украшение, а необходимость: публикацию может прятать редактор, а автор через час попросит её вернуть. Наш SoftDelete — это Delete по слагу, но для модели с DeletedAt GORM развернёт его в UPDATE: строка остаётся в таблице, колонка deleted_at получает отметку времени, а все обычные запросы — и наши, и чужие — перестают её видеть, потому что фильтр deleted_at IS NULL GORM добавляет к каждому чтению автоматически, без единого условия с вашей стороны. Отсюда два следствия, которые стоит проговорить. Первое: BySlug удалённого поста вернёт знакомую ошибку отсутствия записи из шестого урока — для читателя спрятанный пост неотличим от несуществующего, и это ровно то поведение, которое нужно ленте и страницам. Второе: строка никуда не делась, а значит её можно вернуть — Restore вызывает Unscoped, пропускающий автоматический фильтр, и пишет в deleted_at нулевое значение. Пара SoftDelete-Restore стоит четырёх строк и закрывает целый класс обращений «верните, пожалуйста, тот пост». А вот чего мягкое удаление не делает — не трогает чужие строки: комментарии спрятанного поста остаются в своей таблице, связки в post_tags тоже. Это не баг, а семантика операции: UPDATE одной таблицы. Что с ними делать — вопрос политики блога, и мы разберём его в ловушках ниже.
Канон: BySlug, SoftDelete, Restore
GO// BySlug выезжает со всеми связями - жадная загрузка четырнадцатого урока.
func (r *gormPostRepo) BySlug(slug string) (*Post, error) {
var post Post
err := r.db.Preload("Author").Preload("Comments").Preload("Tags").
Where("slug = ?", slug).
First(&post).Error
if err != nil {
return nil, err // в том числе отсутствие записи: поста нет или он спрятан
}
return &post, nil
}
// SoftDelete прячет пост: UPDATE deleted_at вместо DELETE.
func (r *gormPostRepo) SoftDelete(slug string) (int64, error) {
res := r.db.Where("slug = ?", slug).Delete(&Post{})
return res.RowsAffected, res.Error
}
// Restore возвращает спрятанное: Unscoped пускает к удалённым строкам.
func (r *gormPostRepo) Restore(slug string) error {
return r.db.Unscoped().
Model(&Post{}).
Where("slug = ?", slug).
Update("deleted_at", nil).Error
}Три метода — весь цикл жизни публикации. First вернёт ErrRecordNotFound и для несуществующего, и для спрятанного слага; RowsAffected различит промах и успех в SoftDelete; Restore пишет nil в deleted_at под Unscoped.
Прогноз: два счётчика после мягкого удаления
Прочитайте код и выберите, что он выведет:
// База с одним опубликованным постом "hello".
repo := NewBlogRepo(db)
if _, err := repo.SoftDelete("hello"); err != nil {
log.Fatal(err)
}
var visible int64
db.Model(&Post{}).Count(&visible)
var all int64
db.Unscoped().Model(&Post{}).Count(&all)
fmt.Println(visible, all)Проверка: уникальный слаг против мягкого удаления
Пост со Slug "golang" был мягко удалён. Пытаемся создать новый пост с тем же Slug. Что произойдёт?
Лента: скоупы published/draft и пагинация#
Лента — самое читаемое место любого блога, и соберём мы её из двух тем: скоупов девятнадцатого урока и пагинации семнадцатого. Скоуп — это именованный кусочек условия в виде функции, принимающей и возвращающей *gorm.DB: Published — только опубликованные, Draft — только черновики, Page — границы страницы с защитными ограничениями. Композиция — их сила: Scopes принимает их списком, и условия складываются в один запрос; завтра понадобится «черновики конкретного автора» — напишется третьим скоупом, а не четвёртой копией WHERE. Page как функция высшего порядка возвращает замыкание: Feed вызывает Scopes(Published, Page(page, perPage)), а замыкание уже ограничивает границы — страница меньше единицы становится первой, размер страницы больше сотни урезается до разумного. Запросы пользователя — не место для доверия. Сам Feed делает два запроса от одной базы: сначала Count по опубликованным — чтобы вернуть total, без которого пагинация не нарисует страницы; затем Find с теми же скоупами, границами и сортировкой по времени создания. Две независимые цепочки от r.db — условия не перетекают из одной в другую, это важная гигиена из урока про чтение. Мягкое удаление здесь участвует молча: DeletedAt в модели добавляет свой фильтр к обоим запросам, и спрятанные посты в ленту не попадают без единого упоминания.
Канон: скоупы Published/Draft/Page и метод Feed
GO// Published - скоуг опубликованных: публичная лента.
func Published(db *gorm.DB) *gorm.DB {
return db.Where("published = ?", true)
}
// Draft - скоуп черновиков: личная панель автора.
func Draft(db *gorm.DB) *gorm.DB {
return db.Where("published = ?", false)
}
// Page - скоуп пагинации: замыкание с защитными границами.
func Page(page, perPage int) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
return db.Limit(perPage).Offset((page - 1) * perPage)
}
}
// Feed - публичная лента: счётчик и страница одним методом.
func (r *gormPostRepo) Feed(page, perPage int) ([]Post, int64, error) {
var total int64
// Счётчик - отдельная цепочка: те же условия, без Limit/Offset.
if err := r.db.Model(&Post{}).Scopes(Published).Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []Post
err := r.db.Model(&Post{}).
Scopes(Published, Page(page, perPage)).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}Две независимые цепочки от одной базы: Count для total, Find для страницы. Скоупы складываются списком, мягкое удаление фильтрует оба запроса само. Замыкание Page страхует границы от гуляющих параметров запроса.
Соберите Feed: скоупы, границы, выборка
Восстановите метод ленты: три пропуска — скоуг для счётчика, скоуг с границами и финишер выборки.
Тесты проекта: канон прошлого урока переезжает#
Тестовый слой проекта — это ровно каркас двадцать четвёртого урока, переезжающий на новую схему без переделок: база на тест с одним соединением, фабрика с осмысленными дефолтами и модификаторами, сабтесты по таблице ожиданий. Меняются только персонажи: вместо TodoItemRepo — PostRepo, вместо makeTask — makePost, вместо одной таблицы — пять. Наша фабрика создаёт пост через сам репозиторий — это не лень, а честность: тест проверяет то, что увидит продакшн, включая транзакцию Create. Черновик или публикация — модификатором, как дедлайн у фабрики задач. Единственная новая дисциплина — слаги: они уникальны на весь прогон, поэтому каждый сабтест использует свои, а если накроете сабтесты транзакционным пузырём newTx — сможете не думать об этом вовсе, канон прошлого урока остался при вас. Что проверяем: Create с тегами — что join-строки легли и теги выгрузились вместе с постом; SoftDelete-Restore — полный цикл спрятать-не найти-вернуть; Feed — что черновики и спрятанные посты не просачиваются, счётчик сходится со страницей. Это минимальный набор, при котором рефакторинг репозитория перестаёт быть прыжком с парашютом: сломаете автоматический фильтр мягкого удаления или потеряете транзакцию — тесты это покажут раньше пользователей.
Канон: фабрика makePost и тесты репозитория
GO// makePost - фабрика по канону прошлого урока: дефолты + модификаторы.
func makePost(t *testing.T, repo PostRepo, slug string, mods ...func(*Post)) *Post {
t.Helper()
p := &Post{Title: "Пост " + slug, Slug: slug, AuthorID: 1, Body: "Текст"}
for _, mod := range mods {
mod(p)
}
if err := repo.Create(p, nil); err != nil {
t.Fatalf("фабрика не смогла создать %q: %v", slug, err)
}
return p
}
func TestPostRepo(t *testing.T) {
db := openBlogDB(t) // каркас прошлого урока: база на тест + схема пяти моделей
repo := NewBlogRepo(db)
t.Run("Create с тегами - всё или ничего", func(t *testing.T) {
post := &Post{Title: "Про Go", Slug: "pro-go", Body: "Текст",
AuthorID: 1, Published: true}
if err := repo.Create(post, []string{"go", "orm"}); err != nil {
t.Fatal(err)
}
var got Post
if err := db.Preload("Tags").Where("slug = ?", "pro-go").
First(&got).Error; err != nil {
t.Fatal(err)
}
if len(got.Tags) != 2 {
t.Fatalf("ждали два тега, получено %d", len(got.Tags))
}
})
t.Run("SoftDelete прячет, Restore возвращает", func(t *testing.T) {
makePost(t, repo, "sketch")
n, err := repo.SoftDelete("sketch")
if err != nil || n != 1 {
t.Fatalf("n=%d, err=%v", n, err)
}
if _, err := repo.BySlug("sketch"); err == nil {
t.Fatal("спрятанный пост не должен находиться")
}
if err := repo.Restore("sketch"); err != nil {
t.Fatal(err)
}
if _, err := repo.BySlug("sketch"); err != nil {
t.Fatalf("после Restore пост должен находиться: %v", err)
}
})
t.Run("Feed - только опубликованные, постранично", func(t *testing.T) {
for _, slug := range []string{"post-a", "post-b", "post-c", "post-d"} {
makePost(t, repo, slug, func(p *Post) { p.Published = true })
}
makePost(t, repo, "draft-a") // черновик в ленту не попадёт
posts, total, err := repo.Feed(1, 2)
if err != nil || total != 4 || len(posts) != 2 {
t.Fatalf("total=%d, len=%d, err=%v", total, len(posts), err)
}
})
}Три сабтеста — по строке таблицы ниже. Фабрика создаёт посты через репозиторий, поэтому транзакция Create под тестом всегда. Черновик в третьем сабтесте проверяет сразу и скоуг Published, и молчаливый фильтр мягкого удаления.
Соберите путь создания поста с тегами
Расставьте шаги операции Create в правильном порядке — от входа сервиса до фиксации транзакции.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист репозитория блога#
| Метод | Контракт | Что проверяет тест |
|---|---|---|
| Create(post, tagNames) | пост и теги атомарно | теги выгрузились с постом; сбой посреди — записей нет вообще |
| BySlug(slug) | пост со связями или ошибка отсутствия | живой находится; спрятанный неотличим от несуществующего |
| Feed(page, perPage) | страница опубликованных + total | черновики и спрятанные не просачиваются; счётчик сходится |
| SoftDelete(slug) | RowsAffected 1 или 0 | повторное удаление даёт 0 — строка уже спрятана |
| Restore(slug) | deleted_at снова nil | после Restore пост находится обычным BySlug |
| SoftDelete по чужому слагу | 0 и без ошибки | промах — не паника; решает сервис, как сообщать |
Первая: уникальный слаг против мягкого удаления. Спрятанный пост занимает свой слаг в уникальном индексе, поэтому пересоздать публикацию с тем же адресом не выйдет — нужен частичный уникальный индекс по живым строкам, тема урока про индексы. Вторая: мягкое удаление не каскадится. UPDATE не будит внешние ключи, поэтому комментарии спрятанного поста остаются в таблице, а связки в post_tags — в своей; решите политику заранее: фильтровать по родителю, чистить в той же транзакции или оставить до Restore. Третья: гонка на тегах. FirstOrCreate под конкуренцией даёт ошибку уникальности одному из двоих — это не баг, а сигнал повторить операцию; глушить её молчаливым повтором внутри замыкания не стоит, ловите на верхнем уровне.
Финал: двадцать четыре урока под вашими пальцами#
Пройдём по курсу ещё раз — теперь каждая строка этого проекта имеет адрес. Первый урок объяснил, зачем ORM и когда без него чище; второй — подключение и пул соединений, на котором держится база на тест; третий — модели и соглашения, из которых выросли наши четыре структуры; четвёртый — миграции, что собрали пять таблиц; пятый — Create с его привычками, шестой — чтение с ошибкой отсутствия записи, седьмой — Where, из которого выросли скоупы, восьмой — обновления со счётчиком строк, что вернул нам SoftDelete. Девятый дал мягкое удаление — DeletedAt в модели блога; десятый — Raw, чтобы читать SQL-следы глазами; одиннадцатый-тринадцатый — три вида связей, скелет всей схемы; четырнадцатый — Preload, что выгрузил автора, комментарии и теги одним BySlug. Пятнадцатый — транзакции, на которых стоит Create; шестнадцатый — хуки жизненного цикла, куда проект созреет вешать слагогенерацию; семнадцатый — пагинация, что стала лентой; восемнадцатый — индексы, что подскажут частичное решение для слагов; девятнадцатый — скоупы, что собрали Published и Draft; двадцатый — builder-стиль для динамических фильтров, если блогу понадобится поиск; двадцать первый — NULL и указатели, двадцать второй — пользовательские типы, когда появятся метаданные поста; двадцать третий — диалекты, чтобы проект переехал с SQLite на Postgres без переписывания; двадцать четвёртый — тесты, что сегодня прикрывали спину. Это и есть слой данных go-приложения в сборке — и ваш gorm проект как портфолио.
Чем владеет выпускник курса#
- проектировать схему из моделей: таблицы, связи, ограничения — и собирать их миграциями
- читать и писать данные через Create, First, Find, Updates, Delete — зная, какой SQL уезжает вместо каждого вызова
- собирать фильтры Where-ом, скоупами и builder-стилем, не оставляя дыр в условиях
- держать связки belongs to, has many и m2m теги и выгружать их Preload-ом без лишних запросов
- оборачивать многошаговые операции в транзакции и понимать цену уровню изоляции
- прятать и возвращать строки мягким удалением, помня про уникальные индексы
- выдавать ленты постранично со счётчиком и предсказуемой сортировкой
- работать с NULL через указатели, а с JSON-данными — через пользовательские типы
- переносить проект между диалектами, зная, где их отличия стреляют
- тестировать слой данных на in-memory-базе с фабриками и транзакционными откатами
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый gorm проект собрал слой данных блога из двадцати четырёх уроков: схема блога — пять таблиц (authors, posts, comments, tags, post_tags), Repository — интерфейс PostRepo плюс одна gorm-реализация, тесты — канон прошлого урока на новой схеме.
- Связи легли по карте: belongs to — AuthorID на посте, has many — Comments с ключом PostID, m2m теги — many2many:post_tags с join-таблицей. Join-таблицу GORM создаёт сам при миграции.
- Create — одна транзакция из пятнадцатого урока: INSERT поста, FirstOrCreate для каждого тега, Association Append для связок; nil — COMMIT, ошибка — ROLLBACK, пост не остаётся без тегов.
- Мягкое удаление — UPDATE deleted_at вместо DELETE: обычные запросы не видят спрятанное, Unscoped видит, Restore пишет nil обратно. Похороненный слаг остаётся занятым уникальным индексом.
- Лента — композиция скоупов девятнадцатого урока и пагинации семнадцатого: Published/Draft как именованные условия, Page как замыкание с защитными границами, Feed возвращает страницу и total двумя независимыми цепочками.
- SQL-следы проекта: лента — SELECT с JOIN и LIMIT/OFFSET, создание — пачка INSERT с COMMIT, мягкое удаление — UPDATE с условием. ORM пишет рутину, но понимать её текст — обязанность разработчика.
- Тесты переезжают из прошлого урока без переделок: база на тест, фабрика makePost с модификаторами, сабтесты Create/SoftDelete-Restore/Feed. Фабрика создаёт посты через сам репозиторий — транзакция под тестом всегда.
- Ловушки финала: уникальный индекс против мягкого удаления, отсутствие каскада у UPDATE (комментарии и связки остаются), гонка FirstOrCreate на тегах под конкуренцией.
Домашнее задание
Соберите проект целиком и расширьте его. Шаг первый: соберите models.go с четырьмя моделями, откройте базу AutoMigrate-ом и убедитесь, что в схеме пять таблиц, а у post_tags два внешних ключа. Шаг второй: реализуйте интерфейс PostRepo целиком — Create через db.Transaction, BySlug с Preload, Feed с двумя цепочками, SoftDelete и Restore. Шаг третий: сломайте Create нарочно — передайте имя тега, нарушающее уникальность, — и проверьте, что после отката в базе нет ни поста, ни связок; затем прогоните два горутинных Create с одним новым тегом и посмотрите, как гонка FirstOrCreate завершится ошибкой у одного. Шаг четвёртый: закройте чек-лист тестами — сабтест на каждую строку, фабрика makePost через репозиторий, база на тест по канону прошлого урока. Шаг пятый, на вкус: добавьте скоупу Draft панель черновиков автора — метод Drafts(authorID) в интерфейс и сабтест к нему; затем частично мигрируйте SoftDelete на Unscoped-админку «корзины» со списком спрятанных постов. Финал: поднимите настоящий Postgres, переключите gorm.Open одной строкой и запишите, что из проекта сдвинулось, а что осталось нетронутым — это и есть проверка двадцать третьего урока на практике.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли разнести модели и репозиторий по разным пакетам?
Да, и для боевого проекта это норма: модели живут в доменном пакете без зависимостей на базу, репозиторий — в пакете вида blogrepo, который импортирует gorm и модели. Интерфейс PostRepo при этом объявляют рядом с потребителем — сервисом, а не рядом с реализацией: так сервис не тянет за собой gorm-пакет, а подмена на мок в тестах остаётся тривиальной.
Почему Feed делает два запроса вместо одного?
Пагинации нужен total — общее число опубликованных постов, иначе клиент не нарисует страницы и не узнает, что дальше пусто. Count и Find — два разных запроса с одинаковыми условиями; делить одну цепочку опасно, поэтому их собирают независимо от r.db. Если список большой, счётчик кэшируют или считают по окну страниц — но это уже оптимизация поверх работающего контракта.
Что делать с комментариями мягко удалённого поста?
Мягкое удаление — это UPDATE одной таблицы, каскада он не будит: комментарии и связки post_tags остаются. Рабочие варианты: ничего не делать, пока их никто не видит (страница поста недоступна), чистить их в той же транзакции, если политика требует, или закрывать дочерние записи пометкой в фоновой задаче. Единственный неверный ответ — не решить это вовсе и оставить поведение на волю случая.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx