Подробная шпаргалка по GORM (Go ORM) на примере блога с сущностями User, Post, Tag + доп. пример полиморфизма (Comment).
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgrespackage main
import (
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func setupDB() *gorm.DB {
dsn := "host=localhost user=blog password=blog dbname=blog port=5432 sslmode=disable TimeZone=Europe/Moscow"
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
NamingStrategy: schema.NamingStrategy{
SingularTable: false, // posts, users, tags ...
},
DisableForeignKeyConstraintWhenMigrating: false,
SkipDefaultTransaction: true, // оптимизация
})
if err != nil {
panic(err)
}
// Настройка пула (через sql.DB)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
return db
}package models
import (
"time"
"gorm.io/gorm"
)
// Пользователь
type User struct {
ID uint `gorm:"primaryKey"`
Login string `gorm:"size:50;uniqueIndex;not null"`
FirstName string `gorm:"size:100"`
Surname string `gorm:"size:100"`
Bio string `gorm:"type:text"`
AvatarPath string `gorm:"size:255"`
Password string `gorm:"size:255;not null"`
Email string `gorm:"size:120;uniqueIndex;not null"`
IsActive bool `gorm:"index;default:true"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Posts []Post `gorm:"foreignKey:AuthorID"`
Tags []Tag `gorm:"foreignKey:AuthorID"`
}
// Пост
type Post struct {
ID uint `gorm:"primaryKey"`
Slug string `gorm:"size:180;uniqueIndex;not null"`
Title string `gorm:"size:255;index:,opclass:gin"` // пример (но gin реальнее через SQL)
Description string `gorm:"type:text"`
Content string `gorm:"type:text"`
IsPublished bool `gorm:"index;default:false"`
AuthorID uint `gorm:"index;not null"`
Author User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
Tags []Tag `gorm:"many2many:post_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// Тег
type Tag struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:120;not null"`
Slug string `gorm:"size:160;uniqueIndex;not null"`
Description string `gorm:"type:text"`
AuthorID uint `gorm:"index"`
Author User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
Posts []Post `gorm:"many2many:post_tags"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// Связующая таблица (кастомизация) - не обязательно, если дефолта хватает
type PostTag struct {
PostID uint `gorm:"primaryKey"`
TagID uint `gorm:"primaryKey"`
// Доп. поля, например порядок
Order int `gorm:"default:0"`
}
// Полиморфизм: Комментарий может относиться к Post или Tag (пример)
type Comment struct {
ID uint `gorm:"primaryKey"`
Body string `gorm:"type:text;not null"`
AuthorID uint `gorm:"index"`
Author User
CommentableID uint `gorm:"index"`
CommentableType string `gorm:"index"` // "posts" или "tags"
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// Привязка полиморфная с помощью тегов в целевых моделях:
type PostWithComments struct {
Post
Comments []Comment `gorm:"polymorphic:Commentable;"`
}
type TagWithComments struct {
Tag
Comments []Comment `gorm:"polymorphic:Commentable;"`
}func migrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Post{},
&Tag{},
&PostTag{},
&Comment{},
)
}Если нужны кастомные индексы (пример partial index, полнотекст, GIN и пр.) — делаем через Raw SQL после миграции:
db.Exec(`CREATE INDEX IF NOT EXISTS idx_posts_content_tsv ON posts USING GIN (to_tsvector('russian', content));`)user := User{Login:"john", Email:"john@example.com", Password:"hash"}
db.Create(&user)
post := Post{
Slug: "first-post",
Title: "Первый пост",
Content: "Привет мир",
AuthorID: user.ID,
}
db.Create(&post)
tags := []Tag{
{Title:"Golang", Slug:"golang", AuthorID:user.ID},
{Title:"ORM", Slug:"orm", AuthorID:user.ID},
}
db.Create(&tags)
// Привязка тегов
db.Model(&post).Association("Tags").Append(&tags)var p Post
db.First(&p, 1) // по первичному ключу
db.First(&p, "slug = ?", "first-post")
db.Take(&p, "is_published = ?", true)
db.Last(&p) // последний по PK
var posts []Post
db.Where("is_published = ?", true).Find(&posts)
db.Where(&Post{IsPublished:true, AuthorID:user.ID}).Find(&posts) // struct условие (нулевые поля игнорятся)
db.Where(map[string]interface{}{"author_id": user.ID}).Find(&posts)type MiniPost struct {
ID uint
Title string
}
var minis []MiniPost
db.Model(&Post{}).Select("id", "title").Where("is_published").Scan(&minis)db.Model(&p).Update("title", "Новый заголовок")
db.Model(&Post{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
"description": "Описание",
"is_published": true,
})
// Обновление всех изменённых полей
p.Content = "Изменён контент"
db.Save(&p)db.Delete(&p) // sets deleted_at
// Жёсткое удаление
db.Unscoped().Delete(&p)type PostWithAuthor struct {
ID uint
Title string
AuthorLogin string
}
var rows []PostWithAuthor
db.Model(&Post{}).
Select("posts.id, posts.title, users.login AS author_login").
Joins("JOIN users ON users.id = posts.author_id").
Where("users.is_active").
Scan(&rows)db.Model(&Post{}).
Joins("LEFT JOIN post_tags ON post_tags.post_id = posts.id").
Joins("LEFT JOIN tags ON tags.id = post_tags.tag_id").
Where("tags.slug = ?", "golang").
Find(&posts)var fullPost Post
db.Preload("Author").
Preload("Tags").
First(&fullPost, "slug = ?", "first-post")
// Условная предзагрузка
db.Preload("Tags", "slug IN ?", []string{"golang","orm"}).Find(&posts)
// Вложенная предзагрузка (Author каждого Tag)
db.Preload("Tags.Author").Find(&posts)
// Полиморфные (если используете PostWithComments)
var postsWithComments []PostWithComments
db.Preload("Comments.Author").
Where("is_published").
Find(&postsWithComments)// Append
db.Model(&post).Association("Tags").Append(&Tag{Title:"DB", Slug:"db"})
// Replace
db.Model(&post).Association("Tags").Replace(&tags)
// Clear
db.Model(&post).Association("Tags").Clear()
// Delete (удаляет связь из join-таблицы)
db.Model(&post).Association("Tags").Delete(&tags[0])
// Count
count := db.Model(&post).Association("Tags").Count()GORM использует паттерн: в дочерних структурах тег gorm:"polymorphic:Commentable;".
Comment содержит поля CommentableID, CommentableType.
Запись:
post := Post{Slug:"p2", Title:"Post2", AuthorID:user.ID}
db.Create(&post)
comment := Comment{
Body: "Отлично!",
AuthorID: user.ID,
CommentableID: post.ID,
CommentableType: "posts", // имя таблицы
}
db.Create(&comment)
// Получить комментарии поста
var comments []Comment
db.Where("commentable_id = ? AND commentable_type = ?", post.ID, "posts").Find(&comments)(Можно обернуть в методы-репозитории.)
err := db.Transaction(func(tx *gorm.DB) error {
u := User{Login:"tx_user", Email:"tx@example.com", Password:"hash"}
if err := tx.Create(&u).Error; err != nil {
return err
}
p := Post{Slug:"tx-post", Title:"Tx", AuthorID:u.ID}
if err := tx.Create(&p).Error; err != nil {
return err
}
// Пример отката
// return errors.New("force rollback")
return nil
})
if err != nil {
log.Println("Transaction rolled back:", err)
}Ручной контроль:
tx := db.Begin()
if err := tx.Error; err != nil { ... }
if err := tx.Create(&User{Login:"abc", Email:"a@b.c", Password:"pw"}).Error; err != nil {
tx.Rollback()
} else {
tx.Commit()
}sess := db.Session(&gorm.Session{
NewDB: true, // чистая копия без кэшей
DryRun: true, // генерирует SQL, но не исполняет
PrepareStmt: true,
SkipDefaultTransaction: true,
})
sql := sess.ToSQL(func(tx *gorm.DB) *gorm.DB {
return tx.Model(&Post{}).Where("is_published").Limit(10).Find(&[]Post{})
})
log.Println("Generated SQL:", sql)(Уже показан при инициализации).
Уровни: logger.Silent, Error, Warn, Info.
Смена на лету:
db = db.Session(&gorm.Session{
Logger: db.Logger.LogMode(logger.Warn),
})Примеры в тегах:
uniqueIndexindex- Составной:
gorm:"index:idx_posts_author_slug,unique,priority:1" - Для второго поля:
gorm:"index:idx_posts_author_slug,priority:2"
type Example struct {
A int `gorm:"index:idx_a_b,priority:1"`
B int `gorm:"index:idx_a_b,priority:2"`
}Частичные / выражения — через Raw SQL после миграции:
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_active ON users (login) WHERE is_active = true;`)Foreign key поведение — через constraint:
Author User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`Использование gorm.DeletedAt + Unscoped():
db.Delete(&post) // мягко
db.Unscoped().Find(&posts) // включает удалённые
db.Unscoped().Delete(&post) // физическое удалениеfunc Published(db *gorm.DB) *gorm.DB {
return db.Where("is_published = ?", true)
}
func WithTag(slug string) func(*gorm.DB) *gorm.DB {
return func(tx *gorm.DB) *gorm.DB {
return tx.Joins("JOIN post_tags pt ON pt.post_id = posts.id").
Joins("JOIN tags t ON t.id = pt.tag_id").
Where("t.slug = ?", slug)
}
}
var posts []Post
db.Scopes(Published, WithTag("golang")).Find(&posts)func (p *Post) BeforeCreate(tx *gorm.DB) (err error) {
if p.Slug == "" {
p.Slug = generateSlug(p.Title)
}
return
}
func (u *User) BeforeSave(tx *gorm.DB) (err error) {
if len(u.Password) < 60 { // условно: если не хэш
u.Password = hash(u.Password)
}
return
}db.Clauses(
clause.OnConflict{
Columns: []clause.Column{{Name:"slug"}},
DoUpdates: clause.AssignmentColumns([]string{"title","updated_at"}),
},
).Create(&Post{Slug:"first-post", Title:"Обновлённый"})type Stat struct {
AuthorID uint
Count int
}
var stats []Stat
db.Raw(`
SELECT author_id, COUNT(*) AS count
FROM posts
WHERE is_published = ?
GROUP BY author_id
`, true).Scan(&stats)
// Exec для безрезультатных
db.Exec("UPDATE posts SET is_published = TRUE WHERE id = ?", 10)func paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(tx *gorm.DB) *gorm.DB {
if page < 1 { page = 1 }
switch {
case pageSize > 100: pageSize = 100
case pageSize <= 0: pageSize = 10
}
offset := (page - 1) * pageSize
return tx.Offset(offset).Limit(pageSize)
}
}
var pagePosts []Post
db.Scopes(paginate(2, 20), Published).Preload("Author").Find(&pagePosts)ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
defer cancel()
db.WithContext(ctx).Find(&posts)p := Post{
Slug:"inline",
Title:"Inline create",
AuthorID:user.ID,
Tags: []Tag{
{Title:"NewTag", Slug:"new-tag", AuthorID:user.ID},
},
}
// Создаст Post и Tag и связь
db.Create(&p)db.Select("id", "title").Where("id = ?", 1).Find(&posts)
db.Omit("content", "description").Find(&posts)&gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "blog_", // blog_posts
SingularTable: false,
},
}res := db.First(&p, 999)
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
// обработать
}(Если IgnoreRecordNotFoundError: true — логгер не будет шуметь.)
PrepareStmt в Session — кэширует prepared statements (в долгоживущем сервисе ⇧ производительность).
Не забывать закрыть sqlDB при выключении приложения.
sql := db.Session(&gorm.Session{DryRun:true}).Model(&Post{}).
Where("is_published").Limit(5).Find(&[]Post{}).Statement.SQL.String()
log.Println("Generated SQL:", sql)
// EXPLAIN
var plan []map[string]interface{}
db.Raw("EXPLAIN ANALYZE " + sql).Scan(&plan)(Иногда нужно вручную подставить параметры.)
GORM сам ставит CreatedAt / UpdatedAt если поля названы именно так (или через hooks).
Для кастомных имен используйте callback или тег autoCreateTime / autoUpdateTime:
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`var bulkPosts []Post
for i := 0; i < 1000; i++ {
bulkPosts = append(bulkPosts, Post{
Slug: fmt.Sprintf("slug-%d", i),
Title: fmt.Sprintf("Title %d", i),
AuthorID: user.ID,
})
}
db.CreateInBatches(bulkPosts, 200) // пачки по 200- Preload делает отдельные запросы (N+1 → 1+1), безопаснее, проще.
- Joins в один запрос — хорошо для фильтрации по связанным таблицам, но нужно следить за дубликатами/Distinct.
db.Model(&Post{}).
Joins("Author").
Where("users.is_active").
Where("posts.is_published").
Select("posts.*").
Find(&posts)db.Model(&Post{}).
Distinct("posts.id","posts.title").
Joins("JOIN post_tags pt ON pt.post_id = posts.id").
Joins("JOIN tags t ON t.id = pt.tag_id").
Where("t.slug IN ?", []string{"golang","orm"}).
Find(&posts)Если нужна доп. логика (например поле Order в PostTag):
// Добавить связь с параметрами
pt := PostTag{PostID: post.ID, TagID: tag.ID, Order: 10}
db.Create(&pt)
// Получить теги поста (ручной JOIN)
var tagsForPost []Tag
db.Joins("JOIN post_tags pt ON pt.tag_id = tags.id").
Where("pt.post_id = ?", post.ID).
Order("pt.order ASC").
Find(&tagsForPost)type PostRepo struct {
db *gorm.DB
}
func NewPostRepo(db *gorm.DB) *PostRepo {
return &PostRepo{db:db}
}
func (r *PostRepo) FindBySlug(slug string) (*Post, error) {
var p Post
err := r.db.Preload("Author").Preload("Tags").
First(&p, "slug = ?", slug).Error
return &p, err
}Для тестов можно:
func withRollback(db *gorm.DB, test func(tx *gorm.DB)) {
tx := db.Begin()
defer tx.Rollback()
test(tx)
}33. Безопасность: Не передавайте необработанные пользовательские строки в Raw / Where без плейсхолдеров. Используйте ? или @named:
db.Where("slug = @slug AND is_published = @pub",
sql.Named("slug","first-post"),
sql.Named("pub", true),
).Find(&posts)type PostMeta struct {
ID uint `gorm:"primaryKey"`
PostID uint `gorm:"index"`
Data datatypes.JSONMap `gorm:"type:jsonb"`
}(Нужно: go get gorm.io/datatypes)
- ErrRecordNotFound
- ErrInvalidTransaction
- RowsAffected:
res := db.Delete(&post); res.RowsAffected
func (PostTag) TableName() string { return "post_tags" }var p2 Post
db.First(&p2, 5)
db.Model(&p2).Association("Tags").Find(&p2.Tags)db.Unscoped().Where("deleted_at IS NOT NULL").Find(&posts) // только удалённыеdb.Model(&Post{}).
Where("is_published = ?", false).
Updates(map[string]interface{}{
"is_published": true,
"updated_at": time.Now(),
})type CountByAuthor struct {
AuthorID uint
Cnt int
}
var cb []CountByAuthor
db.Model(&Post{}).
Select("author_id, COUNT(*) as cnt").
Where("is_published").
Group("author_id").
Having("COUNT(*) > ?", 5).
Order("cnt DESC").
Scan(&cb)- Clauses (clause.Locking{Strength:"UPDATE"}) для FOR UPDATE
- Optimistic Lock (добавить поле Version uint и BeforeUpdate + проверку)
- Embedded structs для аудита (BaseModel)
type Audit struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Post2 struct {
Audit
ID uint
// ...
}Используйте Scan в кастомные структуры (чтобы не грузить лишнее):
type PostListItem struct {
ID uint
Slug string
Title string
Tags []string `gorm:"-"`
}
var raw []struct {
ID uint
Slug string
Title string
TagTitle string
}
db.Table("posts p").
Select("p.id, p.slug, p.title, t.title as tag_title").
Joins("LEFT JOIN post_tags pt ON pt.post_id = p.id").
Joins("LEFT JOIN tags t ON t.id = pt.tag_id").
Where("p.is_published").
Scan(&raw)
// Сгруппировать вручную
m := map[uint]*PostListItem{}
for _, r := range raw {
item, ok := m[r.ID]
if !ok {
item = &PostListItem{ID:r.ID, Slug:r.Slug, Title:r.Title}
m[r.ID] = item
}
if r.TagTitle != "" {
item.Tags = append(item.Tags, r.TagTitle)
}
}
var list []PostListItem
for _, v := range m { list = append(list, *v) }err = db.Create(&Post{Slug:"first-post"}).Error
if err != nil {
// распарсить err.Error() или использовать errors.As для *pgconn.PgError
}gorm:"many2many:post_tags" — иначе имя строится по алфавиту: post_tags. Можно кастомизировать через joinForeignKey, joinReferences.
Tags []Tag `gorm:"many2many:post_tags;joinForeignKey:PostID;JoinReferences:TagID"`import "gorm.io/gorm/clause"
var lockedPost Post
db.Clauses(clause.Locking{Strength:"UPDATE"}).
First(&lockedPost, "id = ?", 10)
// далее безопасно менять в транзакцииПолучить опубликованные посты автора с фильтрацией по тегам и пагинацией:
func FindPublishedPostsByAuthorWithTags(db *gorm.DB, authorID uint, tags []string, page, size int) ([]Post, error) {
var posts []Post
tx := db.Model(&Post{}).
Where("author_id = ? AND is_published = ?", authorID, true).
Scopes(paginate(page, size)).
Preload("Author").
Preload("Tags")
if len(tags) > 0 {
tx = tx.Joins("JOIN post_tags pt ON pt.post_id = posts.id").
Joins("JOIN tags t ON t.id = pt.tag_id").
Where("t.slug IN ?", tags).
Group("posts.id")
}
err := tx.Order("posts.created_at DESC").Find(&posts).Error
return posts, err
}stmt := db.Session(&gorm.Session{DryRun:true}).Model(&Post{}).
Where("slug = ?", "first-post").Find(&Post{}).Statement
log.Println(stmt.SQL.String(), stmt.Vars)Создайте функцию:
func mapError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
// parse duplicate
return err
}Используйте DSN TimeZone=Europe/Moscow, или вручную парсинг.
Лучше хранить UTC, отображать в локальной TZ.
- Модели: поля + связи (BelongsTo через ForeignKey, Many2Many через
many2many). - Предзагрузка: Preload (условия, вложенные).
- Joins: для фильтрации по связям + Select.
- Transactions: db.Transaction(fn) или Begin/Commit/Rollback.
- Scopes: переисп. логика условий.
- Soft Delete: gorm.DeletedAt + Unscoped.
- Индексы:
index,uniqueIndex, составные через имя. - Upsert: Clauses(OnConflict).
- Associations API: Append/Replace/Clear/Delete/Count.
- Полиморфизм:
polymorphic:Commentable. - Logger: logger.New + уровни.
- Session: DryRun, NewDB, PrepareStmt.
- Raw SQL: db.Raw / db.Exec.
- Pagination: Offset / Limit / Scope.
- Locking: clause.Locking.