Go: GORM

Теория: CRUD-операции

После того как схема базы синхронизирована с моделями, начинается самое главное — повседневная работа с данными. Любое приложение снова и снова делает одни и те же шаги: добавляет новые записи, читает существующие, меняет поля и удаляет устаревшую информацию. Эти четыре действия объединяют под общим названием CRUD: Create(), Read(), Update(), Delete(). GORM предлагает для каждого шага свои методы и берёт на себя превращение вызовов на Go в конкретные SQL-запросы. Код остаётся на уровне структур и логики, а таблицы и операторы остаются за кулисами.

GORM ведёт себя как переводчик между миром структур и миром SQL. Когда программа вызывает db.Create, библиотека разбирает структуру, читает теги и значения полей, строит INSERT, подставляет параметры и отправляет запрос в базу. Методы First(), Find() и Where() превращаются в SELECT с нужными условиями и ограничениями. Updates() и Update() становятся UPDATE, Delete() — либо DELETE, либо UPDATE с заполнением поля DeletedAt при мягком удалении. В ответ GORM получает данные и аккуратно раскладывает их обратно по полям структур Go, сохраняя типы и значения.

Создание записей: Create

Создание данных начинается с формирования значения структуры. Программа описывает новую сущность, а GORM берёт на себя запись в таблицу. В SQL для этого используют INSERT INTO, но в GORM достаточно одного вызова Create(), который принимает указатель на структуру или срез структур. Библиотека сама определяет, какие поля участвуют во вставке, какие заполняются значениями по умолчанию, и получает автоинкрементный идентификатор, если он есть.

Простейший пример показывает этот путь от структуры до строки в таблице:

type User struct {
	ID        uint      // первичный ключ
	Name      string    // имя пользователя
	Email     string    // электронная почта
	CreatedAt time.Time // время создания записи
	UpdatedAt time.Time // время обновления записи
}

func createUser(db *gorm.DB) error {
	// Формируется новая структура без ID — ключ задаст база.
	user := User{
		Name:  "Анна",
		Email: "anna@mail.com",
	}

	// Create строит INSERT и выполняет его.
	result := db.Create(&user)

	// Проверка ошибки вставки.
	if result.Error != nil {
		return result.Error
	}

	// После вставки база вернёт ID, GORM запишет его в user.ID.
	log.Println("Создан пользователь с ID:", user.ID)

	// Через RowsAffected можно узнать, сколько строк добавлено.
	log.Println("Добавлено строк:", result.RowsAffected)

	return nil
}

Под капотом GORM превращает этот вызов в команду INSERT с перечислением колонок name, email, created_at, updated_at и значениями, подставленными из структуры. Если в модели есть служебные поля CreatedAt и UpdatedAt, библиотека сама устанавливает в них текущее время. После выполнения запроса драйвер возвращает сгенерированный идентификатор, и GORM записывает его обратно в поле ID.

При массовой вставке срез структур превращается в один батчевый запрос. Программа создаёт набор значений, а GORM формирует INSERT с несколькими строками:

func createManyUsers(db *gorm.DB) error {
	users := []User{
		{Name: "Игорь", Email: "igor@mail.com"},
		{Name: "Мария", Email: "maria@mail.com"},
	}

	// Один вызов Create для среза создаст несколько строк за один SQL-запрос.
	if err := db.Create(&users).Error; err != nil {
		return err
	}

	// После вставки у каждого элемента среза появится свой ID.
	for _, u := range users {
		log.Println("Пользователь:", u.Name, "ID:", u.ID)
	}

	return nil
}

Такой подход снижает количество запросов и ускоряет добавление большого количества данных. При необходимости программа может ограничить список полей, которые попадут в INSERT, через Select() или исключить отдельные колонки через Omit(). GORM сформирует SQL под эту выборку и проигнорирует остальные значения структуры.

Чтение данных: First, Find, Where

После вставки данных встаёт задача читать их в разных разрезах. Иногда нужно получить одну запись по первичному ключу, иногда — список всех сущностей, иногда — выборку по сложному условию. В чистом SQL это серия SELECT-запросов. В GORM основной набор методов для чтения состоит из First(), Find() и Where(). Эти методы комбинируются между собой и позволяют строить выборки шаг за шагом.

Метод First() ориентирован на получение одной записи. Если передать только структуру, GORM выберет первую строку по возрастанию первичного ключа:

func firstUser(db *gorm.DB) (User, error) {
	var user User

	// Без условий: первая запись в таблице users.
	result := db.First(&user)

	if result.Error != nil {
		return User{}, result.Error
	}

	log.Println("Первый пользователь:", user.ID, user.Name)
	return user, nil
}

Если программе известен первичный ключ, его можно передать вторым аргументом. В этом случае GORM сформирует запрос с WHERE id = ? и LIMIT 1:

func findUserByID(db *gorm.DB, id uint) (User, error) {
	var user User

	// Выборка по первичному ключу.
	result := db.First(&user, id)

	if errors.Is(result.Error, gorm.ErrRecordNotFound) {
		log.Println("Пользователь с таким ID не найден")
		return User{}, result.Error
	}

	if result.Error != nil {
		return User{}, result.Error
	}

	return user, nil
}

Для выборки нескольких строк используется Find(). Этот метод заполняет срез структур и может комбинироваться с фильтрами и сортировкой:

func listUsers(db *gorm.DB) ([]User, error) {
	var users []User

	// Получение всех записей без условий.
	if err := db.Find(&users).Error; err != nil {
		return nil, err
	}

	log.Println("Найдено пользователей:", len(users))
	return users, nil
}

Фильтрация добавляется методом Where(). Он может принимать SQL-условия с подстановками, структуру или карту. Пример выборки по возрасту и почте показывает работу с текстовым условием:

func findAdultsWithMail(db *gorm.DB) ([]User, error) {
	var users []User

	// Условие age > 25 и домен почты *@mail.com.
	query := db.Where("age > ?", 25).Where("email LIKE ?", "%@mail.com")

	if err := query.Find(&users).Error; err != nil {
		return nil, err
	}

	return users, nil
}

GORM превращает цепочку Where() в одно выражение WHERE с объединёнными условиями. Для выборки конкретных колонок используется Select(). Тогда в структуру будут загружены только запрошенные поля, а остальные останутся нулевыми значениями:

func findNamesAndEmails(db *gorm.DB) ([]User, error) {
	var users []User

	// Загрузка только name и email. Остальные поля не читаются из базы.
	if err := db.Select("name", "email").Find(&users).Error; err != nil {
		return nil, err
	}

	return users, nil
}

Во всех этих случаях GORM формирует объект Statement с именем таблицы, списком полей, фильтрами, сортировкой и ограничениями. Затем ORM строит SQL под конкретную СУБД, выполняет запрос и сканирует строки результата в структуры Go, автоматически приводя типы.

Обновление данных: Save, Update, Updates

Когда сущность уже существует в базе, её состояние постепенно меняется. Имя пользователя обновляется, почта переходит на новый домен, количество заказов растёт. В SQL эти изменения описываются командой UPDATE. В GORM для обновления служат два основных подхода: Save() для сохранения всей структуры и Update() / Updates() для частичных изменений.

Метод Save() воспринимает структуру как снимок всей записи. Если у объекта заполнен первичный ключ, GORM считает, что строка уже существует, и выполняет UPDATE. Если ключ пустой, ORM воспринимает структуру как новую запись и делает INSERT. Такой подход делает Save() универсальным, но иногда слишком широким: он обновляет все поля, включая нулевые.

func updateUserWithSave(db *gorm.DB, id uint) error {
	var user User

	// Сначала выбирается запись по ID.
	if err := db.First(&user, id).Error; err != nil {
		return err
	}

	// Изменение полей структуры в памяти.
	user.Name = "Анна Петрова"
	user.Email = "new@mail.com"

	// Save обновляет все поля записи в базе.
	if err := db.Save(&user).Error; err != nil {
		return err
	}

	return nil
}

Метод Updates() ориентирован на частичные изменения. Он принимает либо структуру, либо карту и модифицирует только указанные поля. Остальные колонки остаются без изменений. При передаче структуры нулевые значения пропускаются, а при передаче map обновляются все перечисленные поля, даже если они равны нулю.

func partialUpdateUser(db *gorm.DB, id uint) error {
	var user User

	if err := db.First(&user, id).Error; err != nil {
		return err
	}

	// Обновление нескольких полей через структуру:
	// нулевые значения будут проигнорированы.
	if err := db.Model(&user).Updates(User{
		Name: "Анна",
		Age:  30,
	}).Error; err != nil {
		return err
	}

	// Обновление конкретного поля через Update.
	if err := db.Model(&user).Update("Email", "updated@mail.com").Error; err != nil {
		return err
	}

	return nil
}

Когда важно записать и нулевые значения, программа передаёт карту:

func resetUserAge(db *gorm.DB, id uint) error {
	var user User

	if err := db.First(&user, id).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return fmt.Errorf("find user %d: %w", id, ErrNotFound)
		}
		return fmt.Errorf("find user %d: %w", id, err)
	}

	data := map[string]any{"age": 0}

	if err := db.Model(&user).Updates(data).Error; err != nil {
		return fmt.Errorf("update user %d age: %w", id, err)
	}

	return nil
}

Во всех этих сценариях GORM строит UPDATE для таблицы users, подставляет в SET только перечисленные колонки и добавляет условие WHERE по первичному ключу. Если в модели есть поле UpdatedAt, ORM автоматически обновляет его текущим временем, чтобы отражать момент последнего изменения записи.

Удаление данных: Delete и мягкое удаление

Удаление закрывает цикл CRUD. В какой-то момент записи перестают быть нужными и должны либо исчезнуть из системы, либо перейти в разряд архивных. В SQL для этого существует DELETE. В GORM удаление может быть двух видов: физическое удаление строки и мягкое удаление, когда запись остаётся в таблице, но помечается как скрытая с помощью DeletedAt.

Если модель не содержит DeletedAt, Delete() превращается в прямой DELETE:

func removeUserHard(db *gorm.DB, id uint) error {
	// Удаление по ID без предварительной загрузки структуры.
	result := db.Delete(&User{}, id)

	if result.Error != nil {
		return result.Error
	}

	log.Println("Удалено строк:", result.RowsAffected)
	return nil
}

Когда структура включает поле DeletedAt типа gorm.DeletedAt, GORM пер��ключается на мягкое удаление. Вместо уничтожения строки ORM устанавливает в DeletedAt текущее время, а при обычных выборках такие записи автоматически игнорируются:

type SoftUser struct {
	ID        uint
	Name      string
	Email     string
	DeletedAt gorm.DeletedAt `gorm:"index"`
}

func softDeleteUser(db *gorm.DB, id uint) error {
	var user SoftUser

	if err := db.First(&user, id).Error; err != nil {
		return err
	}

	// Мягкое удаление: обновление поля deleted_at.
	if err := db.Delete(&user).Error; err != nil {
		return err
	}

	return nil
}

При последующих вызовах Find() и First() GORM добавляет в запрос условие deleted_at IS NULL и исключает помеченные строки. Чтобы увидеть все записи, включая мягко удалённые, программа использует Unscoped(). Тот же метод применяется для окончательного удаления:

func hardDeleteSoftUser(db *gorm.DB, id uint) error {
	var user SoftUser

	if err := db.Unscoped().First(&user, id).Error; err != nil {
		return err
	}

	// Полное удаление строки из таблицы.
	if err := db.Unscoped().Delete(&user).Error; err != nil {
		return err
	}

	return nil
}

Такой механизм позволяет сначала безопасно скрывать данные, а затем, при необходимости, выполнять физическую очистку базы в отдельном процессе или по отдельному сценарию.

Работа с первичным ключом

Первичный ключ остаётся центральной точкой для всех CRUD-операций. GORM автоматически использует поле ID, если оно присутствует в модели, и строит по нему условия для First(), Save(), Updates() и Delete(). При создании записи ORM получает значение ключа от базы и записывает его в структуру. При выборке по ключу GORM принимает значение вторым аргументом и превращает его в условие WHERE.

Простой сценарий показывает полный цикл работы с первичным ключом:

func fullUserLifecycle(db *gorm.DB) error {
	// Создание новой записи.
	user := User{Name: "Игорь", Email: "igor@mail.com", Age: 30}
	if err := db.Create(&user).Error; err != nil {
		return err
	}

	// В этот момент user.ID заполнен значением из базы.

	// Чтение по первичному ключу.
	var loaded User
	if err := db.First(&loaded, user.ID).Error; err != nil {
		return err
	}

	// Обновление по тому же ключу.
	if err := db.Model(&loaded).Update("Age", 31).Error; err != nil {
		return err
	}

	// Удаление по ключу.
	if err := db.Delete(&loaded).Error; err != nil {
		return err
	}

	return nil
}

Если модель использует другой тип ключа или составной ключ, GORM подстраивает под него условия и не привязывается жестко к полю ID. Главное, чтобы структура однозначно описывала, по каким полям поиск и обновление должны находить нужную строку.

Подведем итоги

CRUD-операции составляют основной рабочий цикл GORM. Создание через Create(), чтение через First(), Find() и Where(), обновление через Save() и Updates(), удаление через Delete() и Unscoped() позволяют работать с базой данных в терминах структур Go. Под капотом библиотека строит SQL-запросы, управляет первичными ключами, заполняет служебные поля и обрабатывает ошибки. Логгер делает этот процесс прозрачным: при необходимости можно увидеть каждый запрос и его параметры. Понимание того, как GORM переводит CRUD в SQL, помогает писать предсказуемый код и избегать неожиданных нагрузок на базу.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845