Клієнт-серверна архітектура

Лекція 13. Взаємодія з реляційними СУБД та технології ORM

Підключення до PostgreSQL, робота з чистим SQL через pgx, транзакції, міграції та об

🎯 Мета лекції

  • Зрозуміти роль реляційних баз даних у клієнт-серверних застосунках та принципи роботи з PostgreSQL.
  • Опанувати стандартний інтерфейс database/sql та високопродуктивний драйвер pgx для виконання SQL-запитів.
  • Навчитися працювати з транзакціями та забезпечувати гарантії ACID.
  • Освоїти інструменти міграцій для версіонування схеми бази даних.
  • Познайомитися з ORM-підходом на прикладі бібліотеки GORM та зрозуміти компроміси між чистим SQL та об'єктно-реляційним відображенням.

🔑 Ключові терміни

  • СУБД (Система Управління Базами Даних): програмний комплекс для створення, збереження та маніпулювання даними.
  • PostgreSQL: вільна об'єктно-реляційна СУБД з потужними можливостями та відповідністю стандартам SQL.
  • Driver (драйвер БД): бібліотека, що реалізує низькорівневу комунікацію з конкретною СУБД.
  • Connection Pool (пул з'єднань): механізм багаторазового використання підключень для підвищення продуктивності.
  • ORM (Object-Relational Mapping): об'єктно-реляційне відображення, технологія автоматичного зв'язування структур Go із таблицями БД.

Роль баз даних у клієнт-серверних системах

На попередніх лекціях ми навчилися створювати HTTP-сервери, обробляти запити, валідувати дані та повертати відповіді. Але всі наші дані зберігалися лише у пам'яті сервера (in-memory), що має критичні обмеження:

  1. Втрата даних при перезапуску: кожен перезапуск сервера призводить до втрати всієї інформації.
  2. Неможливість масштабування: якщо запустити кілька екземплярів сервера, кожен матиме власний, ізольований стан.
  3. Обмеження обсягу: розмір даних обмежений доступною оперативною пам'яттю.
  4. Відсутність складних запитів: у пам'яті складно виконувати агрегацію, сортування, фільтрацію та з'єднання великих наборів даних.

Реляційна база даних (Relational Database) розв'язує всі ці проблеми. Вона забезпечує:

  • Персистентність даних (Persistence): інформація зберігається на диску та переживає перезапуски.
  • Конкурентний доступ (Concurrent Access): тисячі клієнтів можуть одночасно читати та змінювати дані без конфліктів.
  • Транзакційність (ACID Properties): гарантії атомарності, узгодженості, ізольованості та довговічності операцій.
  • Мову запитів SQL (Structured Query Language): стандартизований, декларативний спосіб отримання та маніпулювання даними.
У цій лекції ми будемо працювати з PostgreSQL — однією з найпотужніших та найпопулярніших вільних реляційних СУБД. PostgreSQL (часто скорочено Postgres) відома своєю сумісністю зі стандартами SQL, підтримкою складних типів даних (JSON, масиви, геоданих), розширюваністю та надійністю. Альтернативами є MySQL, SQLite, Microsoft SQL Server та Oracle Database.

Архітектурний контекст: де живе база даних

У типовій клієнт-серверній архітектурі база даних є окремим процесом (або навіть окремою машиною), до якого сервер застосунку під'єднується через мережеве з'єднання.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

actor "Клієнт (Desktop/Mobile)" as Client #DBEAFE
participant "Go HTTP Сервер" as Server #E2E8F0
database "PostgreSQL СУБД" as DB #DCFCE7

Client -> Server : HTTP POST /api/v1/users\n(JSON: {email, password})
activate Server

Server -> Server : Валідація DTO
Server -> DB : INSERT INTO users (email, password_hash)\nVALUES ($1, $2)
activate DB
DB --> Server : Response: INSERT 0 1
deactivate DB

Server -> DB : SELECT id, email FROM users WHERE id = $1
activate DB
DB --> Server : Response: Row {id: 42, email: "..."}
deactivate DB

Server --> Client : HTTP 201 Created\n{id: 42, email: "..."}
deactivate Server

@enduml

Як це працює:

  1. Клієнт надсилає HTTP-запит до серверного застосунку.
  2. Сервер валідує дані, потім відкриває з'єднання з базою даних PostgreSQL.
  3. Сервер виконує SQL-команди (INSERT, SELECT, UPDATE, DELETE) через це з'єднання.
  4. База даних обробляє запити, записує/читає дані з диска та повертає результати серверу.
  5. Сервер перетворює результати у JSON та повертає клієнту.
База даних — це окремий процес, який слухає на порті (зазвичай 5432 для PostgreSQL). Сервер не включає базу даних у себе, а під'єднується до неї через TCP-сокет, подібно до того, як клієнт під'єднується до сервера.

Стандартний інтерфейс database/sql у Go

На відміну від мов з динамічною типізацією (Python, JavaScript), де існують специфічні для кожної СУБД бібліотеки (psycopg2, pg), мова Go пропонує уніфікований інтерфейс для роботи з будь-якою реляційною базою даних: пакет database/sql.

Концепція двошарової архітектури драйверів

Пакет database/sql визначає набір абстракцій (інтерфейсів):

  • sql.DB — представляє пул з'єднань до бази даних.
  • sql.Conn — одне фізичне з'єднання.
  • sql.Tx — транзакцію.
  • sql.Rows — результат запиту SELECT (курсор).
  • sql.Result — результат команд INSERT/UPDATE/DELETE.

Сам пакет database/sql не містить коду для роботи з конкретною СУБД. Замість цього він очікує, що ви імпортуєте драйвер (driver) — бібліотеку, яка реалізує низькорівневу комунікацію з PostgreSQL, MySQL чи іншою базою.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Ваш застосунок (main.go)" #F1F5F9 {
    [Код застосунку] as App
}

package "Стандартна бібліотека Go" #DBEAFE {
    [database/sql] as SQL
}

package "Драйвери (третя сторона)" #DCFCE7 {
    [pgx (PostgreSQL)] as PGX
    [mysql (MySQL)] as MySQL
    [sqlite3] as SQLite
}

database "PostgreSQL СУБД" as DB #FEF3C7

App --> SQL : Викликає sql.DB.Query()
SQL --> PGX : Делегує виклик драйверу
PGX --> DB : Низькорівнева комунікація\n(PostgreSQL Wire Protocol)
DB --> PGX : Бінарна відповідь
PGX --> SQL : Перетворює у sql.Rows
SQL --> App : Повертає результат

@enduml

Переваги цього підходу:

  • Портативність коду: ви можете змінити СУБД, замінивши лише драйвер та рядок підключення (connection string).
  • Стандартизація API: всі операції виконуються однаково, незалежно від СУБД.
  • Оптимізація драйверами: кожен драйвер може використовувати специфічні для СУБД протоколи та можливості (бінарний формат, підготовлені команди).
Найпопулярніші драйвери та бібліотеки для PostgreSQL:
  1. pgx (github.com/jackc/pgx) — нативний Go-драйвер, найшвидший, підтримує всі можливості PostgreSQL, може працювати як через database/sql, так і через власний API.
  2. sqlx (github.com/jmoiron/sqlx) — обгортка над database/sql, що спрощує роботу з результатами запитів через автоматичне сканування у структури. Не є драйвером, а додатковим шаром зручності.
  3. pq (github.com/lib/pq) — старіший драйвер, режим підтримки, використовується у легаcі-проєктах.
Ми розглянемо pgx (як драйвер) та sqlx (як інструмент спрощення роботи з SQL).

Підключення до PostgreSQL: перші кроки

Перш ніж писати код, потрібно запустити екземпляр PostgreSQL. Найпростіший спосіб — використати Docker:

# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:16-alpine
    container_name: myapp_postgres
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secretpass
      POSTGRES_DB: myapp_db
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Запуск:

docker compose up -d

Після запуску СУБД вона слухатиме на порту 5432. Тепер можна під'єднатися з Go.

Встановлення драйвера pgx

go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/stdlib

Чому два пакети?

  • github.com/jackc/pgx/v5 — нативний API pgx (більш потужний, але не сумісний з database/sql).
  • github.com/jackc/pgx/v5/stdlib — адаптер для використання pgx через стандартний інтерфейс database/sql.

На цій лекції ми будемо використовувати стандартний інтерфейс database/sql, щоб ви могли легко перейти на будь-яку іншу СУБД у майбутньому.

Структура підключення: рядок DSN

Для підключення до PostgreSQL використовується DSN (Data Source Name — ім'я джерела даних), також відомий як connection string (рядок підключення).

Формат DSN для PostgreSQL:

postgres://username:password@hostname:port/database?параметри

Приклад:

postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable

Розбір компонентів:

  • appuser — ім'я користувача бази даних.
  • secretpass — пароль.
  • localhost:5432 — адреса та порт сервера PostgreSQL.
  • myapp_db — назва бази даних.
  • sslmode=disable — вимкнути шифрування SSL (лише для локальної розробки!).
У промисловому середовищі ніколи не вимикайте SSL (sslmode=require або sslmode=verify-full). Також ніколи не зберігайте паролі в коді — використовуйте змінні оточення:
dsn := os.Getenv("DATABASE_URL")

Відкриття з'єднання та пул підключень

Розглянемо базовий код підключення до PostgreSQL:

package main

import (
    "database/sql"
    "fmt"
    "log"
    
    _ "github.com/jackc/pgx/v5/stdlib" // Реєстрація драйвера
)

func main() {
    // Рядок підключення (DSN)
    dsn := "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable"
    
    // Відкриття пулу з'єднань
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        log.Fatalf("Не вдалося відкрити базу даних: %v", err)
    }
    defer db.Close()
    
    // Перевірка з'єднання
    if err := db.Ping(); err != nil {
        log.Fatalf("Не вдалося під'єднатися до бази даних: %v", err)
    }
    
    fmt.Println("✅ Успішне підключення до PostgreSQL")
}

Важливі деталі:

  1. Імпорт драйвера з _ (blank identifier):
    _ "github.com/jackc/pgx/v5/stdlib"
    

    Цей імпорт не надає жодних функцій напряму. Він лише реєструє драйвер pgx у глобальному реєстрі database/sql під ім'ям "pgx".
  2. sql.Open() не відкриває з'єднання:
    Виклик sql.Open("pgx", dsn) лише створює об'єкт *sql.DB та ініціалізує пул з'єднань, але не встановлює фізичне з'єднання з базою даних. Фактичне підключення відбувається при першому запиті.
  3. db.Ping() — перевірка доступності:
    Метод Ping() примушує пул створити реальне з'єднання та виконати тривіальний запит до бази. Якщо база недоступна, Ping() поверне помилку.
  4. defer db.Close():
    Об'єкт sql.DB зберігає пул з'єднань. При завершенні роботи програми потрібно закрити всі з'єднання, викликавши Close().
Що таке Connection Pool (пул з'єднань)?Створення нового TCP-з'єднання до бази даних — це дорога операція (рукостискання, автентифікація, ініціалізація сесії). Замість створення нового з'єднання для кожного запиту, sql.DB підтримує пул з'єднань: кілька відкритих з'єднань, які багаторазово використовуються різними горутинами.Коли горутина виконує запит, вона отримує вільне з'єднання з пулу. Після завершення запиту з'єднання повертається у пул для подальшого використання.

Налаштування параметрів пулу з'єднань

За замовчуванням sql.DB створює необмежену кількість з'єднань (unlimited). Це може призвести до виснаження ресурсів бази даних. Тому важливо налаштувати розмір пулу:

// Максимальна кількість відкритих з'єднань (включно з активними та неактивними)
db.SetMaxOpenConns(25)

// Максимальна кількість неактивних (idle) з'єднань у пулі
db.SetMaxIdleConns(10)

// Максимальний час життя з'єднання (після закінчення з'єднання буде закрите)
db.SetConnMaxLifetime(5 * time.Minute)

// Максимальний час простою з'єднання (неактивні з'єднання будуть закриті)
db.SetConnMaxIdleTime(2 * time.Minute)

Рекомендації для промислового коду:

  • SetMaxOpenConns(25-100) — залежить від навантаження та обмежень PostgreSQL (max_connections).
  • SetMaxIdleConns(5-10) — достатньо для утримання з'єднань між сплесками трафіку.
  • SetConnMaxLifetime(5-30 хв) — запобігає накопиченню застарілих з'єднань.
Якщо ваш сервер обробляє 1000 одночасних запитів, але MaxOpenConns = 25, то горутини чекатимуть на звільнення з'єднання. Це нормальна поведінка — база даних не може обробляти необмежену кількість паралельних запитів. Налаштовуйте пул відповідно до можливостей вашої СУБД.

Виконання SQL-запитів: базові операції CRUD

Тепер, коли ми маємо підключення до бази, можемо виконувати SQL-запити. Розглянемо чотири базові операції CRUD (Create, Read, Update, Delete).

Створення таблиці (DDL)

Перш ніж зберігати дані, потрібно створити таблицю. Виконаємо DDL-команду (Data Definition Language):

func createTable(db *sql.DB) error {
    query := `
    CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        password_hash VARCHAR(255) NOT NULL,
        full_name VARCHAR(255) NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    `
    
    _, err := db.Exec(query)
    if err != nil {
        return fmt.Errorf("не вдалося створити таблицю: %w", err)
    }
    
    fmt.Println("✅ Таблиця users створена")
    return nil
}

Метод Exec() використовується для виконання команд, які не повертають рядки: CREATE, INSERT, UPDATE, DELETE.

Повернене значення sql.Result:

result, err := db.Exec("INSERT INTO users ...")
if err != nil {
    return err
}

lastID, _ := result.LastInsertId() // Останній згенерований ID (не для всіх СУБД)
rowsAffected, _ := result.RowsAffected() // Кількість змінених рядків
Метод LastInsertId() не підтримується PostgreSQL напряму через database/sql. Для отримання згенерованого ID використовуйте RETURNING:
INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id;

Додавання даних (INSERT) з поверненням ID

Розглянемо функцію, яка створює нового користувача та повертає згенерований ID:

package repository

import (
    "context"
    "database/sql"
    "fmt"
)

type User struct {
    ID           int
    Email        string
    PasswordHash string
    FullName     string
}

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *User) error {
    query := `
        INSERT INTO users (email, password_hash, full_name)
        VALUES ($1, $2, $3)
        RETURNING id
    `
    
    // QueryRow виконує запит та очікує рівно один рядок результату
    err := r.db.QueryRowContext(ctx, query,
        user.Email,
        user.PasswordHash,
        user.FullName,
    ).Scan(&user.ID)
    
    if err != nil {
        return fmt.Errorf("помилка створення користувача: %w", err)
    }
    
    return nil
}

Розбір коду:

  1. Плейсхолдери $1, $2, $3:
    PostgreSQL використовує позиційні параметри у форматі $N. Це захищає від SQL-ін'єкцій — значення передаються окремо від тексту запиту та автоматично екрануються драйвером.
  2. QueryRowContext(ctx, query, args...):
    Виконує запит, який повертає рівно один рядок. Якщо результат порожній або містить більше одного рядка, метод Scan() поверне помилку.
  3. Scan(&user.ID):
    Зчитує значення з рядка результату у змінну. Оператор RETURNING id повертає згенерований первинний ключ, який присвоюється полю user.ID.
  4. Context як перший параметр:
    Передача context.Context дозволяє контролювати таймаути та скасування операцій. Якщо HTTP-запит скасовано, контекст автоматично завершить запит до бази.
Ніколи не використовуйте конкатенацію рядків для побудови SQL-запитів:
// ❌ НЕБЕЗПЕЧНО! Вразливо до SQL-ін'єкції
query := "INSERT INTO users (email) VALUES ('" + email + "')"
db.Exec(query)
Якщо email містить '; DROP TABLE users; --, виконається команда видалення таблиці.Завжди використовуйте параметризовані запити:
// ✅ БЕЗПЕЧНО
db.Exec("INSERT INTO users (email) VALUES ($1)", email)

Читання даних (SELECT) — один рядок

Для отримання одного користувача за ID використовуємо QueryRowContext:

func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
    query := `
        SELECT id, email, password_hash, full_name
        FROM users
        WHERE id = $1
    `
    
    user := &User{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.Email,
        &user.PasswordHash,
        &user.FullName,
    )
    
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("користувача з ID %d не знайдено", id)
    }
    if err != nil {
        return nil, fmt.Errorf("помилка читання користувача: %w", err)
    }
    
    return user, nil
}

Важливо:

  • sql.ErrNoRows — спеціальна помилка, яка сигналізує, що запит не повернув жодного рядка. Це нормальна ситуація (наприклад, користувач не існує), а не критична помилка.
  • Порядок аргументів у Scan() має точно відповідати порядку колонок у SELECT.

Читання даних (SELECT) — кілька рядків

Для вибірки списку користувачів використовуємо QueryContext, який повертає курсор *sql.Rows:

func (r *UserRepository) GetAll(ctx context.Context) ([]*User, error) {
    query := `SELECT id, email, password_hash, full_name FROM users ORDER BY id`
    
    rows, err := r.db.QueryContext(ctx, query)
    if err != nil {
        return nil, fmt.Errorf("помилка виконання запиту: %w", err)
    }
    defer rows.Close() // Обов'язково закрити курсор!
    
    var users []*User
    for rows.Next() {
        user := &User{}
        if err := rows.Scan(&user.ID, &user.Email, &user.PasswordHash, &user.FullName); err != nil {
            return nil, fmt.Errorf("помилка сканування рядка: %w", err)
        }
        users = append(users, user)
    }
    
    // Перевірка помилок, які могли виникнути під час ітерації
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("помилка ітерації рядків: %w", err)
    }
    
    return users, nil
}

Критичні моменти:

  1. defer rows.Close():
    Курсор *sql.Rows утримує з'єднання з бази. Якщо не закрити курсор, з'єднання залишиться зайнятим, і пул вичерпається. Завжди закривайте rows через defer відразу після отримання.
  2. Цикл for rows.Next():
    Курсор рухається по рядках результату. Метод Next() повертає true, поки є рядки. Всередині циклу викликається Scan() для зчитування поточного рядка.
  3. rows.Err():
    Після завершення циклу потрібно перевірити, чи не виникла помилка під час ітерації (наприклад, обрив з'єднання).
Паттерн безпечної роботи з sql.Rows:
rows, err := db.Query(query)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    // Scan та обробка рядків
}

return rows.Err() // Перевірка помилок ітерації

Оновлення даних (UPDATE)

Оновлення даних виконується через ExecContext:

func (r *UserRepository) Update(ctx context.Context, user *User) error {
    query := `
        UPDATE users
        SET email = $1, full_name = $2
        WHERE id = $3
    `
    
    result, err := r.db.ExecContext(ctx, query,
        user.Email,
        user.FullName,
        user.ID,
    )
    if err != nil {
        return fmt.Errorf("помилка оновлення користувача: %w", err)
    }
    
    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("помилка отримання кількості змінених рядків: %w", err)
    }
    
    if rowsAffected == 0 {
        return fmt.Errorf("користувача з ID %d не знайдено", user.ID)
    }
    
    return nil
}

Перевірка rowsAffected:
Якщо UPDATE не змінив жодного рядка, це означає, що запис з таким ID не існує. Це дозволяє відрізнити успішне оновлення від випадку відсутнього запису.

Видалення даних (DELETE)

Видалення виконується аналогічно до оновлення:

func (r *UserRepository) Delete(ctx context.Context, id int) error {
    query := `DELETE FROM users WHERE id = $1`
    
    result, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return fmt.Errorf("помилка видалення користувача: %w", err)
    }
    
    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("помилка отримання кількості змінених рядків: %w", err)
    }
    
    if rowsAffected == 0 {
        return fmt.Errorf("користувача з ID %d не знайдено", id)
    }
    
    return nil
}

Спрощення роботи з SQL: бібліотека sqlx

Хоча стандартний пакет database/sql надає всі необхідні можливості, він вимагає багато шаблонного коду для сканування результатів у структури. sqlx — це розширення database/sql, яке додає зручні методи для автоматичного відображення рядків БД на Go-структури.

Встановлення sqlx

go get github.com/jmoiron/sqlx
go get github.com/jackc/pgx/v5/stdlib

Підключення через sqlx

package main

import (
    "log"
    
    "github.com/jmoiron/sqlx"
    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    dsn := "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable"
    
    // sqlx.Open повертає *sqlx.DB замість *sql.DB
    db, err := sqlx.Open("pgx", dsn)
    if err != nil {
        log.Fatalf("Помилка підключення: %v", err)
    }
    defer db.Close()
    
    if err := db.Ping(); err != nil {
        log.Fatalf("База недоступна: %v", err)
    }
    
    log.Println("✅ Підключення через sqlx успішне")
}

Автоматичне сканування через теги db

Головна перевага sqlx — автоматичне відображення колонок на поля структури через тег db:

type User struct {
    ID           int       `db:"id"`
    Email        string    `db:"email"`
    PasswordHash string    `db:"password_hash"`
    FullName     string    `db:"full_name"`
    CreatedAt    time.Time `db:"created_at"`
}

Тег db вказує, яка колонка БД відповідає полю структури. Це особливо корисно, коли назви полів у Go відрізняються від назв колонок (наприклад, FullName → full_name).

Методи Get та Select

Get() — отримання одного рядка:

func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
    user := &User{}
    query := `SELECT id, email, password_hash, full_name, created_at FROM users WHERE id = $1`
    
    // Автоматичне сканування у структуру
    err := r.db.GetContext(ctx, user, query, id)
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("користувача не знайдено")
    }
    if err != nil {
        return nil, fmt.Errorf("помилка запиту: %w", err)
    }
    
    return user, nil
}

Порівняння з чистим database/sql:

// database/sql — потрібно явно вказати кожне поле
err := db.QueryRow(query, id).Scan(
    &user.ID,
    &user.Email,
    &user.PasswordHash,
    &user.FullName,
    &user.CreatedAt,
)

// sqlx — автоматичне відображення за тегами db
err := db.Get(user, query, id)

Select() — отримання списку рядків:

func (r *UserRepository) GetAll(ctx context.Context) ([]*User, error) {
    var users []*User
    query := `SELECT id, email, password_hash, full_name, created_at FROM users ORDER BY id`
    
    // Автоматичне заповнення слайсу
    if err := r.db.SelectContext(ctx, &users, query); err != nil {
        return nil, fmt.Errorf("помилка запиту: %w", err)
    }
    
    return users, nil
}
Коли використовувати sqlx?
  • Коли ви пишете багато SQL-запитів вручну та хочете уникнути шаблонного коду сканування.
  • Коли потрібен повний контроль над SQL, але зручність відображення результатів.
  • Як компроміс між чистим database/sql та повноцінною ORM.
sqlx НЕ є заміною ORM — вона не генерує SQL автоматично, не відстежує зв'язки та не надає міграцій. Це лише зручна обгортка для спрощення роботи з результатами запитів.

Іменовані параметри

sqlx підтримує іменовані параметри через NamedExec та NamedQuery:

type UserFilter struct {
    MinAge int    `db:"min_age"`
    City   string `db:"city"`
}

filter := UserFilter{MinAge: 18, City: "Київ"}

var users []User
query := `SELECT * FROM users WHERE age >= :min_age AND city = :city`

// Іменовані параметри замість $1, $2
err := db.NamedQuery(query, filter)

Це робить запити з багатьма параметрами більш читабельними.


Транзакції: гарантії ACID

Уявімо ситуацію: користувач переводить гроші з одного рахунку на інший. Операція складається з двох кроків:

  1. Зняти гроші з рахунку А.
  2. Додати гроші на рахунок Б.

Що станеться, якщо після першого кроку сервер аварійно завершиться? Гроші знімуться з рахунку А, але не додадуться до Б — вони зникнуть. Це катастрофа.

Саме для розв'язання таких проблем існують транзакції (Transactions) — механізм групування кількох операцій в одну атомарну (atomic) одиницю роботи.

Властивості ACID

Транзакції забезпечують чотири фундаментальні гарантії, відомі як ACID:

  1. Atomicity (Атомарність):
    Всі операції у транзакції виконуються повністю або не виконуються зовсім. Не існує проміжного стану «частково виконано».
  2. Consistency (Узгодженість):
    Транзакція переводить базу даних з одного узгодженого стану в інший. Всі обмеження (constraints), тригери та правила залишаються дійсними.
  3. Isolation (Ізольованість):
    Паралельні транзакції не впливають одна на одну. Зміни, внесені транзакцією, невидимі іншим, поки вона не завершиться.
  4. Durability (Довговічність):
    Після підтвердження транзакції (commit) зміни гарантовано зберігаються на диску, навіть у разі збою.
PostgreSQL підтримує всі властивості ACID із конфігурованими рівнями ізоляції (Read Committed, Repeatable Read, Serializable). За замовчуванням використовується Read Committed.

Синтаксис транзакцій у database/sql

Транзакція створюється методом BeginTx() та завершується викликом Commit() (підтвердження) або Rollback() (відкат):

func (r *UserRepository) TransferBalance(ctx context.Context, fromID, toID int, amount float64) error {
    // Початок транзакції
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("не вдалося почати транзакцію: %w", err)
    }
    
    // Відкат транзакції у разі помилки
    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p) // Повторно викинути panic після відкату
        } else if err != nil {
            tx.Rollback()
        }
    }()
    
    // Крок 1: Зняти гроші з рахунку fromID
    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance - $1 WHERE user_id = $2",
        amount, fromID,
    )
    if err != nil {
        return fmt.Errorf("помилка зняття коштів: %w", err)
    }
    
    // Крок 2: Додати гроші на рахунок toID
    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance + $1 WHERE user_id = $2",
        amount, toID,
    )
    if err != nil {
        return fmt.Errorf("помилка додавання коштів: %w", err)
    }
    
    // Підтвердження транзакції
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("помилка підтвердження транзакції: %w", err)
    }
    
    return nil
}

Як це працює:

  1. BeginTx(ctx, nil) створює об'єкт *sql.Tx (транзакцію).
  2. Всі операції виконуються через tx.ExecContext(), tx.QueryContext() замість db.ExecContext().
  3. Якщо всі операції успішні, викликається tx.Commit() — зміни стають видимими іншим транзакціям.
  4. Якщо будь-яка операція завершилася помилкою, викликається tx.Rollback() — всі зміни скасовуються.
Критично важливо викликати Rollback() або Commit() для кожної транзакції!Якщо ви забудете завершити транзакцію, з'єднання залишиться заблокованим, утримуючи блокування на рядках таблиці. Це призведе до дедлоків (deadlocks) та виснаження пулу з'єднань.Використовуйте defer для гарантії відкату у разі помилки або паніки.

Патерн безпечної роботи з транзакціями

Типовий шаблон для роботи з транзакціями:

func (r *Repository) DoSomethingTransactional(ctx context.Context) (err error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    
    // Відкочуємо транзакцію у разі помилки
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    
    // Виконуємо операції через tx
    if err = r.step1(ctx, tx); err != nil {
        return err
    }
    
    if err = r.step2(ctx, tx); err != nil {
        return err
    }
    
    // Підтверджуємо транзакцію
    return tx.Commit()
}

Переваги цього підходу:

  • defer автоматично відкотить транзакцію, якщо функція повернула помилку.
  • Якщо Commit() успішний, err == nil, і Rollback() не викликається (але виклик Rollback() після Commit() безпечний — це no-op).
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

[*] --> Begin : db.BeginTx()

Begin --> Executing : Операції через tx

Executing --> Executing : tx.Exec(), tx.Query()

Executing --> Commit : Всі операції успішні\ntx.Commit()
Executing --> Rollback : Помилка\ntx.Rollback()

Commit --> [*] : Зміни збережені
Rollback --> [*] : Зміни скасовані

note right of Commit #DCFCE7
  Зміни стають видимими
  іншим транзакціям
end note

note right of Rollback #FEF3C7
  База даних повертається
  до стану до початку
  транзакції
end note

@enduml
Коли використовувати транзакції?
  • Коли кілька операцій мають виконатися атомарно (наприклад, перевід коштів, створення замовлення з товарами).
  • Коли потрібна узгодженість даних між таблицями (наприклад, додавання користувача та його профілю).
  • Коли операції взаємозалежні та не можуть виконуватися окремо.
Коли НЕ використовувати транзакції?
  • Для одиночних операцій (INSERT, UPDATE, DELETE) — вони атомарні за замовчуванням.
  • Для довготривалих операцій — транзакції блокують рядки, що знижує конкурентність.

Версіонування схеми БД: інструмент golang-migrate

У процесі розробки структура бази даних постійно змінюється: додаються нові таблиці, колонки, індекси, змінюються обмеження. Як керувати цими змінами та синхронізувати їх між розробниками та середовищами (розробка, тестування, продакшн)?

Міграції (Migrations) — це версіоновані SQL-скрипти, які описують зміни схеми бази даних. Кожна міграція має:

  1. UP-скрипт — застосовує зміну (наприклад, створює таблицю).
  2. DOWN-скрипт — відкочує зміну (видаляє таблицю).

Інструмент golang-migrate автоматизує застосування та відкат міграцій.

Встановлення golang-migrate

brew install golang-migrate

Створення міграції

Створімо першу міграцію для таблиці users:

migrate create -ext sql -dir migrations -seq create_users_table

Параметри:

  • -ext sql — розширення файлів (SQL).
  • -dir migrations — каталог для зберігання міграцій.
  • -seq — послідовна нумерація (альтернатива: -timestamp для міток часу).

Команда створить два файли:

migrations/
├── 000001_create_users_table.up.sql
└── 000001_create_users_table.down.sql

Файл 000001_create_users_table.up.sql (застосування):

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    full_name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

Файл 000001_create_users_table.down.sql (відкат):

DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;

Застосування міграцій

Для накату міграцій виконайте:

migrate -path migrations \
  -database "postgres://appuser:secretpass@localhost:5432/myapp_db?sslmode=disable" \
  up

Вивід:

000001_create_users_table.up.sql ... OK (25ms)

Інструмент створює службову таблицю schema_migrations, яка відстежує, які міграції вже застосовано:

SELECT * FROM schema_migrations;
versiondirty
1false

Відкат міграції

Для відкату останньої міграції:

migrate -path migrations \
  -database "..." \
  down 1

Для скидання всіх міграцій:

migrate -path migrations -database "..." down
Ніколи не редагуйте вже застосовані міграції!Якщо міграція вже накачена у продакшн, створіть нову міграцію для виправлення. Редагування старих міграцій порушить версіонування та призведе до розбіжностей між середовищами.

Міграції у коді Go

Можна вбудувати міграції у бінарний файл застосунку:

package main

import (
    "database/sql"
    "embed"
    "log"
    
    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/postgres"
    "github.com/golang-migrate/migrate/v4/source/iofs"
    _ "github.com/jackc/pgx/v5/stdlib"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

func runMigrations(db *sql.DB) error {
    driver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        return err
    }
    
    source, err := iofs.New(migrationsFS, "migrations")
    if err != nil {
        return err
    }
    
    m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
    if err != nil {
        return err
    }
    
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }
    
    log.Println("✅ Міграції застосовано")
    return nil
}

Тепер міграції автоматично застосовуються при старті застосунку.


Об'єктно-реляційне відображення: технологія ORM

Досі ми писали SQL-запити вручну. Це надає повний контроль, але має недоліки:

  1. Багато шаблонного коду: кожна операція вимагає написання SQL, передачі параметрів та сканування результатів.
  2. Помилки у SQL не виявляються на етапі компіляції: друкарська помилка у SELECT * FORM users спричинить runtime-помилку.
  3. Складність підтримки зв'язків: робота з foreign keys (зовнішніми ключами), вкладеними об'єктами та з'єднаннями (JOIN) вимагає значних зусиль.

ORM (Object-Relational Mapping — об'єктно-реляційне відображення) — це підхід, при якому таблиці бази даних автоматично відображаються на структури Go, а операції виконуються через методи цих структур замість написання SQL.

Концепція ORM на прикладі

Без ORM (чистий SQL):

query := "INSERT INTO users (email, full_name) VALUES ($1, $2) RETURNING id"
err := db.QueryRow(query, user.Email, user.FullName).Scan(&user.ID)

З ORM (GORM):

db.Create(&user) // Автоматично генерує INSERT та присвоює ID

ORM автоматично:

  • Формує SQL-запити на основі структури.
  • Відображає поля структури на колонки таблиці.
  • Обробляє зв'язки між таблицями (1:1, 1:N, N:M).
Найпопулярніші ORM для Go:
  1. GORM (gorm.io) — найпоширеніша бібліотека, багато функцій, активна підтримка.
  2. ent (entgo.io) — кодогенерація на основі схем, type-safe API, розроблена Facebook.
  3. Bun (bun.uptrace.dev) — швидка, мінімалістична ORM.
  4. sqlc (sqlc.dev) — генерує type-safe код Go із чистих SQL-запитів (гібридний підхід).
Ми розглянемо GORM, оскільки це промисловий стандарт де-факто у Go-екосистемі.

Робота з GORM: основи

Встановлення GORM та драйвера PostgreSQL

go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

Підключення до PostgreSQL через GORM

package main

import (
    "log"
    
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    dsn := "host=localhost user=appuser password=secretpass dbname=myapp_db port=5432 sslmode=disable"
    
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatalf("Не вдалося підключитися до бази даних: %v", err)
    }
    
    log.Println("✅ Підключення до PostgreSQL через GORM успішне")
}

Відмінність від database/sql:

  • GORM повертає об'єкт *gorm.DB, а не *sql.DB.
  • gorm.DB обгортає стандартний *sql.DB та надає високорівневий API.

Визначення моделей (структур)

Модель у GORM — це звичайна структура Go з тегами, що описують відображення на таблицю:

package model

import (
    "time"
    "gorm.io/gorm"
)

type User struct {
    ID           uint           `gorm:"primaryKey"`
    Email        string         `gorm:"uniqueIndex;not null"`
    PasswordHash string         `gorm:"not null"`
    FullName     string         `gorm:"not null"`
    CreatedAt    time.Time
    UpdatedAt    time.Time
    DeletedAt    gorm.DeletedAt `gorm:"index"` // Soft Delete
}

// TableName перевизначає назву таблиці (за замовчуванням: "users")
func (User) TableName() string {
    return "users"
}

Теги GORM:

  • primaryKey — первинний ключ.
  • uniqueIndex — унікальний індекс.
  • not null — поле не може бути NULL.
  • index — створити звичайний індекс.
  • default:'value' — значення за замовчуванням.

Спеціальні поля:

  • CreatedAt, UpdatedAt — автоматично заповнюються при створенні/оновленні.
  • DeletedAt — реалізує soft delete (запис не видаляється, а помічається видаленим).

Автоматична міграція

GORM може автоматично створювати або змінювати таблиці на основі структур:

func main() {
    db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    
    // Автоматично створити/оновити таблицю users
    if err := db.AutoMigrate(&model.User{}); err != nil {
        log.Fatalf("Помилка міграції: %v", err)
    }
    
    log.Println("✅ Міграція виконана")
}
AutoMigrate безпечна лише для додавання нових колонок. Вона НЕ видаляє колонки, не змінює типи даних та не видаляє таблиці.У продакшн-середовищі краще використовувати golang-migrate для контрольованих змін схеми.

Базові операції CRUD у GORM

Створення запису (Create)

user := model.User{
    Email:        "alice@example.com",
    PasswordHash: "hashed_password",
    FullName:     "Alice Johnson",
}

result := db.Create(&user)

if result.Error != nil {
    log.Printf("Помилка створення користувача: %v", result.Error)
} else {
    log.Printf("✅ Створено користувача з ID: %d", user.ID)
}

Що відбувається:

  • GORM генерує запит INSERT INTO users (...) VALUES (...) RETURNING id.
  • Після виконання поле user.ID автоматично заповнюється згенерованим первинним ключем.
  • Поля CreatedAt та UpdatedAt також заповнюються поточним часом.

Читання запису (Read)

Отримання одного запису за первинним ключем:

var user model.User
result := db.First(&user, 1) // WHERE id = 1

if result.Error == gorm.ErrRecordNotFound {
    log.Println("Користувача не знайдено")
} else if result.Error != nil {
    log.Printf("Помилка запиту: %v", result.Error)
} else {
    log.Printf("Знайдено: %s (%s)", user.FullName, user.Email)
}

Отримання за умовою:

var user model.User
db.Where("email = ?", "alice@example.com").First(&user)

Отримання всіх записів:

var users []model.User
db.Find(&users)

for _, u := range users {
    log.Printf("ID: %d, Email: %s", u.ID, u.Email)
}

Фільтрація та сортування:

var users []model.User
db.Where("created_at > ?", time.Now().AddDate(0, -1, 0)).
   Order("created_at DESC").
   Limit(10).
   Find(&users)

Оновлення запису (Update)

Оновлення всіх полів:

user.FullName = "Alice Smith"
db.Save(&user) // UPDATE users SET full_name = ..., updated_at = ... WHERE id = ...

Оновлення окремих полів:

db.Model(&user).Update("full_name", "Alice Cooper")

// Або кілька полів:
db.Model(&user).Updates(map[string]interface{}{
    "full_name": "Alice Cooper",
    "email":     "alice.cooper@example.com",
})
Використовуйте Updates() замість Save(), щоб оновити лише вказані поля. Save() оновлює всі поля, включаючи ті, що мають нульові значення.

Видалення запису (Delete)

Фізичне видалення:

db.Delete(&user) // DELETE FROM users WHERE id = ...

М'яке видалення (Soft Delete):

Якщо модель містить поле DeletedAt, GORM автоматично використовує м'яке видалення:

db.Delete(&user) // UPDATE users SET deleted_at = NOW() WHERE id = ...

Запити за замовчуванням ігноруватимуть видалені записи:

db.Find(&users) // Не включає записи з непустим deleted_at

Для отримання видалених записів:

db.Unscoped().Find(&users) // Включає видалені

Зв'язки між таблицями (Associations)

GORM підтримує автоматичне завантаження пов'язаних даних.

Приклад: Користувач та Профіль (1:1):

type User struct {
    ID      uint
    Email   string
    Profile Profile // Зв'язок 1:1
}

type Profile struct {
    ID      uint
    UserID  uint   // Зовнішній ключ
    Bio     string
    AvatarURL string
}

// Завантаження користувача з профілем:
var user User
db.Preload("Profile").First(&user, 1)
log.Println(user.Profile.Bio)

Приклад: Користувач та Пости (1:N):

type User struct {
    ID    uint
    Email string
    Posts []Post // Зв'язок 1:N
}

type Post struct {
    ID     uint
    UserID uint
    Title  string
    Body   string
}

// Завантаження користувача з усіма постами:
var user User
db.Preload("Posts").First(&user, 1)

for _, post := range user.Posts {
    log.Println(post.Title)
}
Метод Preload() виконує окремий запит для завантаження пов'язаних даних (аналог JOIN). Це називається eager loading (жадібне завантаження) та запобігає проблемі N+1 queries (виконання окремого запиту для кожного пов'язаного об'єкта).

Приклад: Зв'язок Many-to-Many (N:M) — Студенти та Курси:

type Student struct {
    ID      uint
    Name    string
    Courses []Course `gorm:"many2many:student_courses;"` // Зв'язок N:M
}

type Course struct {
    ID       uint
    Title    string
    Students []Student `gorm:"many2many:student_courses;"` // Зворотний зв'язок
}

// GORM автоматично створить проміжну таблицю student_courses:
// CREATE TABLE student_courses (
//     student_id INTEGER,
//     course_id INTEGER,
//     PRIMARY KEY (student_id, course_id)
// );

// Створення студента з курсами:
student := Student{
    Name: "Іван Петренко",
    Courses: []Course{
        {Title: "Бази даних"},
        {Title: "Алгоритми"},
    },
}
db.Create(&student)

// Завантаження студента з усіма курсами:
var loadedStudent Student
db.Preload("Courses").First(&loadedStudent, student.ID)

for _, course := range loadedStudent.Courses {
    log.Println(course.Title)
}

// Додавання студента до існуючого курсу:
var course Course
db.First(&course, 1)
db.Model(&course).Association("Students").Append(&student)
При роботі зі зв'язками N:M завжди використовуйте Preload(), інакше виникне проблема N+1 queries: GORM виконає окремий запит для кожного пов'язаного об'єкта у циклі, що різко знизить продуктивність.

Хуки життєвого циклу GORM

GORM підтримує хуки (hooks) — спеціальні методи, що автоматично викликаються на певних етапах життєвого циклу моделі (створення, оновлення, видалення).

Доступні хуки:

  • BeforeCreate, AfterCreate — до/після створення запису.
  • BeforeUpdate, AfterUpdate — до/після оновлення.
  • BeforeSave, AfterSave — перед будь-яким збереженням (Create або Update).
  • BeforeDelete, AfterDelete — до/після видалення.
  • AfterFind — після завантаження запису з БД.

Приклад: автоматичне хешування пароля перед збереженням:

type User struct {
    ID           uint
    Email        string
    Password     string `gorm:"-"` // Не зберігати у БД
    PasswordHash string
}

// BeforeCreate викликається перед INSERT
func (u *User) BeforeCreate(tx *gorm.DB) error {
    if u.Password == "" {
        return fmt.Errorf("пароль не може бути порожнім")
    }
    
    hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    
    u.PasswordHash = string(hash)
    u.Password = "" // Очищаємо відкритий пароль
    
    return nil
}

// BeforeUpdate викликається перед UPDATE
func (u *User) BeforeUpdate(tx *gorm.DB) error {
    // Якщо пароль змінено, перехешуємо
    if u.Password != "" {
        hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
        if err != nil {
            return err
        }
        u.PasswordHash = string(hash)
        u.Password = ""
    }
    return nil
}

// Використання:
user := User{
    Email:    "john@example.com",
    Password: "mySecretPassword",
}

db.Create(&user) // BeforeCreate автоматично хешує пароль

Приклад: логування змін:

type Article struct {
    ID        uint
    Title     string
    Content   string
    UpdatedBy string
}

func (a *Article) AfterUpdate(tx *gorm.DB) error {
    log.Printf("📝 Стаття '%s' оновлена користувачем %s", a.Title, a.UpdatedBy)
    
    // Можна записати у таблицю аудиту:
    audit := AuditLog{
        EntityType: "Article",
        EntityID:   a.ID,
        Action:     "UPDATE",
        ChangedBy:  a.UpdatedBy,
        ChangedAt:  time.Now(),
    }
    return tx.Create(&audit).Error
}
Коли використовувати хуки?
  • Валідація даних перед збереженням (перевірка бізнес-правил).
  • Автоматичне перетворення даних (хешування паролів, нормалізація email).
  • Логування змін (аудит, версіонування).
  • Очищення пов'язаних даних при видаленні (каскадне видалення файлів).
Обережно: хуки виконуються синхронно та можуть сповільнити операції. Для важких обчислень використовуйте асинхронні черги (наприклад, RabbitMQ).

Розширені можливості моделей GORM

Тепер, коли ми розглянули базові операції GORM, заглибимося у потужні можливості визначення моделей, які роблять ORM гнучким та виразним інструментом.

Повна специфікація тегів структур

Теги структур (struct tags) — це основний механізм налаштування поведінки GORM. Кожен тег визначає, як поле відображається на колонку бази даних, які обмеження накладаються та як виконуються операції.

Синтаксис тегів:

type Model struct {
    Field Type `gorm:"тег1:значення;тег2:значення;тег3"`
}

Теги розділяються крапкою з комою (;), деякі теги мають значення через двокрапку (:).

Основні теги відображення

type User struct {
    ID        uint   `gorm:"column:user_id"` // Колонка називається user_id замість id
    FirstName string `gorm:"column:first_name"`
}

Теги первинних та зовнішніх ключів

type User struct {
    // Первинний ключ
    ID uint `gorm:"primaryKey"`
    
    // Композитний первинний ключ
    // ID   uint   `gorm:"primaryKey"`
    // Code string `gorm:"primaryKey"`
    
    // Автоінкремент (за замовчуванням для primaryKey)
    ID uint `gorm:"primaryKey;autoIncrement"`
    
    // Відключення автоінкременту
    UUID string `gorm:"primaryKey;autoIncrement:false"`
}

type Order struct {
    ID     uint
    UserID uint `gorm:"index"` // Створити індекс
    User   User `gorm:"foreignKey:UserID;references:ID"`
    // foreignKey вказує поле у Order, references — поле у User
}
Автоматичні первинні ключі:
Якщо структура має поле ID типу uint, int або string, GORM автоматично робить його первинним ключем з автоінкрементом (для числових типів).

Теги обмежень та валідації

type User struct {
    Email    string `gorm:"not null;unique"`           // NOT NULL + UNIQUE
    Username string `gorm:"not null;uniqueIndex"`      // NOT NULL + унікальний індекс
    Age      int    `gorm:"check:age >= 18"`           // CHECK обмеження
    Status   string `gorm:"default:'active'"`          // Значення за замовчуванням
    Role     string `gorm:"default:'user';not null"`   // Комбінація тегів
    
    // Іменований індекс
    Email2   string `gorm:"uniqueIndex:idx_email"`
    
    // Композитний індекс
    FirstName string `gorm:"index:idx_name,priority:1"` // Перше поле у індексі
    LastName  string `gorm:"index:idx_name,priority:2"` // Друге поле у індексі
}
CHECK обмеження підтримуються не всіма СУБД однаково. PostgreSQL повністю підтримує їх, але SQLite має обмеження. Завжди перевіряйте документацію вашої СУБД.

Теги керування читанням/записом

type User struct {
    // Лише для читання (ігнорується при Create/Update)
    CreatedAt time.Time `gorm:"<-:false"`
    
    // Лише для запису (не повертається при запитах)
    Password string `gorm:"->:false;<-:create"` // Тільки при створенні
    
    // Повне ігнорування (не відображається на БД)
    TempField string `gorm:"-"`
    
    // Ігнорування при читанні, але запис дозволено
    APIKey string `gorm:"->;-:migration"` // - при міграції, -> для читання
    
    // Лише для створення
    InitialPassword string `gorm:"<-:create"`
    
    // Лише для оновлення
    UpdateToken string `gorm:"<-:update"`
}

Розшифровка стрілок:

  • -> — читання з БД (SELECT)
  • <- — запис до БД (INSERT, UPDATE)
  • ->:false — заборонити читання
  • <-:false — заборонити запис
  • <-:create — дозволити лише при INSERT
  • <-:update — дозволити лише при UPDATE

Теги для часових міток

type Model struct {
    // Автоматичне заповнення при створенні
    CreatedAt time.Time `gorm:"autoCreateTime"`
    
    // Автоматичне оновлення при Create/Update
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
    
    // Unix timestamp замість time.Time
    CreatedUnix int64 `gorm:"autoCreateTime:nano"` // Наносекунди
    UpdatedUnix int64 `gorm:"autoUpdateTime:milli"` // Мілісекунди
    
    // Soft Delete
    DeletedAt gorm.DeletedAt `gorm:"index"`
}
Стандартна модель:
GORM надає gorm.Model, яка містить ID, CreatedAt, UpdatedAt, DeletedAt. Ви можете вбудувати її у свої моделі:
type User struct {
    gorm.Model // Автоматично додає ID, CreatedAt, UpdatedAt, DeletedAt
    Email string
    Name  string
}

Теги коментарів

type User struct {
    Name  string `gorm:"comment:Повне ім'я користувача"`
    Email string `gorm:"comment:Email адреса для зв'язку"`
    Age   int    `gorm:"comment:Вік користувача в роках"`
}

// У PostgreSQL згенерує:
// COMMENT ON COLUMN users.name IS 'Повне ім''я користувача';

Серіалізатори

GORM підтримує автоматичну серіалізацію складних типів у JSON, BSON тощо:

type User struct {
    Name       string
    Attributes map[string]interface{} `gorm:"serializer:json"` // Зберігається як JSON
    Metadata   Metadata              `gorm:"serializer:json"`
}

type Metadata struct {
    Theme    string
    Language string
}

// При збереженні:
user := User{
    Name: "Alice",
    Attributes: map[string]interface{}{
        "age": 25,
        "city": "Київ",
    },
    Metadata: Metadata{Theme: "dark", Language: "uk"},
}
db.Create(&user)
// У БД: attributes = '{"age":25,"city":"Київ"}'

Підтримувані серіалізатори:

  • json — JSON (за замовчуванням для map, slice, struct)
  • gob — Go binary encoding
  • unixtime — Unix timestamp для time.Time

Вбудовані структури (Embedded Structs)

GORM підтримує композицію структур — механізм вбудовування одних структур у інші, що дозволяє повторно використовувати спільні поля.

Базове вбудовування

type BaseModel struct {
    ID        uint `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
}

type User struct {
    BaseModel        // Вбудована структура (усі поля стають полями User)
    Email     string `gorm:"unique"`
    Name      string
}

type Article struct {
    BaseModel
    Title   string
    Content string
}

// У БД створяться таблиці:
// users: id, created_at, updated_at, email, name
// articles: id, created_at, updated_at, title, content

Як це працює:
Поля ID, CreatedAt, UpdatedAt з BaseModel «підіймаються» на верхній рівень структури. Це еквівалентно явному оголошенню цих полів у User та Article.

Вбудовування з префіксом

Коли потрібно уникнути конфліктів імен або згрупувати поля, використовуйте тег embeddedPrefix:

type Address struct {
    Street  string
    City    string
    ZipCode string
}

type User struct {
    ID              uint
    Name            string
    HomeAddress     Address `gorm:"embedded;embeddedPrefix:home_"`
    WorkAddress     Address `gorm:"embedded;embeddedPrefix:work_"`
}

// У БД таблиця users міститиме:
// id, name,
// home_street, home_city, home_zip_code,
// work_street, work_city, work_zip_code

Без префіксу:

type User struct {
    ID      uint
    Name    string
    Address Address `gorm:"embedded"` // Без префіксу
}

// У БД: id, name, street, city, zip_code
Якщо вбудовуєте кілька структур одного типу без префіксів, виникне конфлікт імен колонок. Завжди використовуйте embeddedPrefix для таких випадків.

Вбудовування без тегу gorm

Якщо вбудовуєте структуру без анотацій, GORM автоматично розгортає її поля:

type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

type User struct {
    ID         uint
    Email      string
    Timestamps // Автоматично вбудовується
}

// Еквівалентно:
type User struct {
    ID        uint
    Email     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

Вкладені вбудовані структури

type Audit struct {
    CreatedBy uint
    UpdatedBy uint
}

type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
    Audit     // Вкладене вбудовування
}

type Article struct {
    ID         uint
    Title      string
    Timestamps // Усі поля Audit та Timestamps стають полями Article
}

// У БД: id, title, created_at, updated_at, created_by, updated_by

Перевизначення імен таблиць

За замовчуванням GORM генерує назви таблиць у множині та snake_case:

  • User → users
  • BlogPost → blog_posts
  • OrderItem → order_items

Метод TableName()

Найчастіший спосіб перевизначення:

type User struct {
    ID   uint
    Name string
}

func (User) TableName() string {
    return "app_users" // Таблиця називатиметься app_users
}

Динамічні назви таблиць

type ShardedUser struct {
    ID   uint
    Name string
}

func (u ShardedUser) TableName() string {
    // Шардування за ID
    shard := u.ID % 10
    return fmt.Sprintf("users_shard_%d", shard)
}

// Використання:
user := ShardedUser{ID: 42, Name: "Alice"}
db.Table(user.TableName()).Create(&user)
// INSERT INTO users_shard_2 ...

Глобальне налаштування

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    NamingStrategy: schema.NamingStrategy{
        TablePrefix:   "app_",           // Префікс для всіх таблиць
        SingularTable: true,             // Не використовувати множину
        NoLowerCase:   false,            // Використовувати CamelCase замість snake_case
    },
})

// User → app_user (замість users)
// BlogPost → app_blogpost (замість blog_posts)
Коли використовувати TableName() vs глобальне налаштування?
  • TableName() — для окремих моделей, legacy таблиць, шардування.
  • Глобальне налаштування — для узгодженості назв у всьому застосунку (наприклад, всі таблиці з префіксом myapp_).

::


Порівняння підходів: Raw SQL vs ORM

Тепер, коли ми розглянули обидва підходи, давайте порівняємо їх переваги та недоліки.

Чистий SQL (database/sql, pgx)

Переваги:

  1. Повний контроль над запитами:
    Ви пишете точний SQL, що виконується. Це дозволяє використовувати всі можливості PostgreSQL: оконні функції (window functions), CTE (Common Table Expressions), складні агрегації.
  2. Максимальна продуктивність:
    Відсутність проміжного шару ORM зменшує накладні витрати. Ви можете оптимізувати запити до мікросекунд.
  3. Прозорість:
    Легко зрозуміти, що саме відбувається з базою даних. Немає «магії» — кожен запит явний.
  4. Відсутність абстракцій, що протікають:
    ORM може генерувати неефективні запити (наприклад, N+1 queries). З чистим SQL ви завжди знаєте, скільки запитів виконується.

Недоліки:

  1. Більше коду:
    Кожна операція вимагає написання SQL, передачі параметрів, обробки помилок та сканування результатів.
  2. Ризик SQL-ін'єкцій:
    Потрібна дисципліна для використання параметризованих запитів.
  3. Складність роботи зі зв'язками:
    Завантаження пов'язаних даних вимагає написання JOIN або виконання кількох запитів вручну.

ORM (GORM)

Переваги:

  1. Швидка розробка:
    Базові операції (CRUD) виконуються однією командою. Немає потреби писати SQL для стандартних операцій.
  2. Безпека за замовчуванням:
    ORM автоматично використовує параметризовані запити, що захищає від SQL-ін'єкцій.
  3. Автоматична робота зі зв'язками:
    Preload() дозволяє легко завантажувати пов'язані дані без написання JOIN.
  4. Міграції структур:
    AutoMigrate автоматично створює або оновлює таблиці на основі Go-структур.
  5. Портативність:
    Зміна СУБД (з PostgreSQL на MySQL) вимагає лише зміни драйвера — код залишається незмінним.

Недоліки:

  1. Обмежений контроль:
    Складні запити (вкладені підзапити, оконні функції, рекурсивні CTE) важко виразити через ORM API.
  2. Накладні витрати продуктивності:
    ORM додає шар абстракції, що сповільнює запити на 10-30% порівняно з чистим SQL.
  3. Неявність:
    Не завжди зрозуміло, який SQL генерується під капотом. Це може призвести до несподіваних проблем продуктивності.
  4. Проблема N+1 queries:
    Якщо забути використати Preload(), ORM виконає окремий запит для кожного пов'язаного об'єкта у циклі.

Коли використовувати що?

✅ Використовуйте, коли:
- Потрібна максимальна продуктивність
- Складні аналітичні запити
- Повний контроль над SQL
- Критичні ділянки коду (платежі, фінанси)
- Робота з великими обсягами даних
Рекомендація для промислового коду:Використовуйте гібридний підхід:
  1. Застосовуйте GORM для базових операцій (створення, читання за ID, простий пошук).
  2. Для складних запитів (аналітика, звіти, агрегації) пишіть чистий SQL через db.Raw() або db.Exec().
  3. Критичні ділянки (платежі, транзакції) реалізуйте через чистий SQL з явними транзакціями.
Це дозволяє поєднати швидкість розробки з продуктивністю та контролем.

Патерн Repository: абстракція доступу до даних

Незалежно від того, використовуєте ви чистий SQL чи ORM, важливо ізолювати логіку роботи з базою даних у окремому шарі — Repository.

Що таке Repository?

Repository (Репозиторій) — це патерн проєктування, який забезпечує єдиний інтерфейс для доступу до даних, приховуючи деталі реалізації (SQL, ORM, NoSQL тощо).

Структура:

internal/
├── domain/
│   └── user.go          # Доменна модель (без залежностей від БД)
├── repository/
│   ├── interface.go     # Інтерфейс репозиторію
│   ├── postgres/
│   │   └── user_repository.go  # Реалізація через pgx
│   └── gorm/
│       └── user_repository.go  # Реалізація через GORM
└── usecase/
    └── user_service.go   # Бізнес-логіка (використовує інтерфейс)

Інтерфейс репозиторію:

package repository

import (
    "context"
    "myapp/internal/domain"
)

type UserRepository interface {
    Create(ctx context.Context, user *domain.User) error
    GetByID(ctx context.Context, id int) (*domain.User, error)
    GetByEmail(ctx context.Context, email string) (*domain.User, error)
    Update(ctx context.Context, user *domain.User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context, limit, offset int) ([]*domain.User, error)
}

Реалізація через чистий SQL (pgx):

package postgres

import (
    "context"
    "database/sql"
    "myapp/internal/domain"
    "myapp/internal/repository"
)

type userRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) repository.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
    query := `INSERT INTO users (email, password_hash, full_name) 
              VALUES ($1, $2, $3) RETURNING id`
    return r.db.QueryRowContext(ctx, query,
        user.Email, user.PasswordHash, user.FullName,
    ).Scan(&user.ID)
}

// ... інші методи

Реалізація через GORM:

package gormrepo

import (
    "context"
    "myapp/internal/domain"
    "myapp/internal/repository"
    "gorm.io/gorm"
)

type userRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) repository.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

// ... інші методи

UseCase (бізнес-логіка):

package usecase

import (
    "context"
    "myapp/internal/domain"
    "myapp/internal/repository"
)

type UserService struct {
    repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) RegisterUser(ctx context.Context, email, password, fullName string) (*domain.User, error) {
    // Перевірка, чи користувач вже існує
    existing, _ := s.repo.GetByEmail(ctx, email)
    if existing != nil {
        return nil, errors.New("користувач з таким email вже існує")
    }
    
    // Хешування пароля
    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }
    
    // Створення користувача
    user := &domain.User{
        Email:        email,
        PasswordHash: string(hash),
        FullName:     fullName,
    }
    
    if err := s.repo.Create(ctx, user); err != nil {
        return nil, err
    }
    
    return user, nil
}

Переваги патерну Repository:

  1. Слабка зв'язність: бізнес-логіка не залежить від конкретної реалізації доступу до даних.
  2. Тестованість: можна легко замінити реальну базу даних на mock-об'єкт у тестах.
  3. Гнучкість: можна змінити СУБД (з PostgreSQL на MongoDB) або підхід (з SQL на ORM), не торкаючись бізнес-логіки.
  4. Повторне використання: один інтерфейс може мати кілька реалізацій (SQL, GORM, in-memory для тестів).

Підсумок

У цій лекції ми опанували базові навички роботи з реляційними базами даних у Go:

✅ Що ми вивчили

Робота з базами даних через database/sql:

  1. Роль баз даних у клієнт-серверних системах та концепція персистентності даних.
  2. Стандартний інтерфейс database/sql та архітектура драйверів.
  3. Підключення до PostgreSQL через драйвер pgx та налаштування пулу з'єднань.
  4. Виконання SQL-запитів: Exec, Query, QueryRow, параметризовані запити.
  5. Бібліотека sqlx для спрощення сканування результатів у структури через теги db.
  6. Транзакції: ACID властивості, BeginTx, Commit, Rollback, SavePoints.
  7. Версіонування схеми через міграції golang-migrate.

Всебічне опанування GORM: 8. Моделі: повна специфікація тегів структур, embedded структури, перевизначення імен таблиць. 9. CRUD операції: розширені можливості Where, Or, Not, Select, Omit, Order, Limit, Distinct, Group, Having. 10. Scopes: переважні запити для DRY принципу та композиційних фільтрів. 11. Batch операції: CreateInBatches, FindInBatches, Upsert (OnConflict), оптимізація продуктивності. 12. Зв'язки: детальна робота з 1:1, 1:N, N:M, Association API, Preload vs Joins, поліморфні зв'язки, self-referencing. 13. Session та контекст: WithContext, Session налаштування (DryRun, SkipHooks, PrepareStmt). 14. Raw SQL: інтеграція чистого SQL, субзапити, змішування підходів. 15. Транзакції: вкладені транзакції, рівні ізоляції, pessimistic/optimistic locking. 16. Logger та Debug: налаштування логування, кастомні логери, інтеграція з slog. 17. Migrator: програмне управління схемою БД, операції з таблицями, колонками, індексами. 18. Хуки: BeforeCreate, AfterUpdate тощо для автоматичної обробки подій моделей. 19. Callbacks система: глобальні обробники для розширення функціональності GORM. 20. Plugins: створення власних плагінів, інтеграція з Prometheus, OpenTelemetry, кешування. 21. Оптимізація: N+1 queries, індекси, prepared statements, connection pool. 22. Патерн Repository: абстракція доступу до даних для слабкої зв'язності та тестованості.

📚 Ключові концепції

Основи баз даних:

  • Connection Pool: механізм багаторазового використання з'єднань для підвищення продуктивності.
  • Параметризовані запити: захист від SQL-ін'єкцій через плейсхолдери $1, $2.
  • ACID: атомарність, узгодженість, ізольованість, довговічність транзакцій.
  • Міграції: версіоновані SQL-скрипти для керування змінами схеми БД.

Підходи до роботи з БД:

  • database/sql: низькорівневий інтерфейс, повний контроль, максимальна продуктивність.
  • sqlx: обгортка для автоматичного сканування результатів SQL у структури через теги db.
  • ORM: автоматичне відображення структур Go на таблиці БД з генерацією SQL.

GORM концепції:

  • Теги структур: column, type, size, primaryKey, foreignKey, uniqueIndex, default, та багато інших для налаштування відображення.
  • Embedded структури: композиція моделей через вбудовування з підтримкою префіксів.
  • Scopes: переважні запити для DRY принципу та композиційної фільтрації.
  • Eager Loading: Preload() для жадібного завантаження пов'язаних даних, запобігання N+1 queries.
  • Association API: методи для керування зв'язками (Append, Replace, Delete, Clear).
  • Session: тонке налаштування поведінки запитів (DryRun, SkipHooks, PrepareStmt).
  • Callbacks: глобальні обробники для модифікації поведінки GORM.
  • Plugins: система розширення функціональності (метрики, трейсинг, кешування).
  • Migrator: програмний інтерфейс для управління схемою БД.
  • Repository Pattern: абстракція доступу до даних для слабкої зв'язності та тестованості.
Що далі?На наступних лекціях ми застосуємо отримані знання для створення повноцінного REST API з автентифікацією, інтеграцією з базою даних та розгортанням у Docker. Ви навчитеся:
  • Інтегрувати репозиторії GORM з HTTP-обробниками Gin.
  • Реалізовувати автентифікацію JWT із збереженням токенів у БД.
  • Організовувати складні запити з фільтрацією, пагінацією та сортуванням через Scopes.
  • Працювати з потоковими даними через WebSocket та зберігати їх у PostgreSQL.
  • Впроваджувати системи аудиту та логування змін через хуки GORM.
  • Оптимізувати продуктивність запитів та уникати N+1 queries.


Складні запити та умови

Базові операції CRUD покривають лише найпростіші сценарії. У реальних застосунках потрібні складні умови фільтрації, сортування, пагінація та агрегація. GORM надає потужний Query Builder API для побудови виразних запитів.

Where — фільтрація даних

Метод Where() додає умови до запиту. Він підтримує кілька форматів:

Рядкові умови з параметрами

// Одна умова
db.Where("email = ?", "alice@example.com").First(&user)
// SELECT * FROM users WHERE email = 'alice@example.com' LIMIT 1

// Кілька параметрів
db.Where("age > ? AND city = ?", 18, "Київ").Find(&users)
// SELECT * FROM users WHERE age > 18 AND city = 'Київ'

// IN умова
db.Where("status IN ?", []string{"active", "pending"}).Find(&users)
// SELECT * FROM users WHERE status IN ('active', 'pending')

// LIKE пошук
db.Where("name LIKE ?", "%Іван%").Find(&users)
// SELECT * FROM users WHERE name LIKE '%Іван%'

// BETWEEN
db.Where("created_at BETWEEN ? AND ?", startDate, endDate).Find(&orders)
Завжди використовуйте параметризовані запити (?) замість конкатенації рядків. Це єдиний спосіб захистити застосунок від SQL-ін'єкцій:
// ❌ НЕБЕЗПЕЧНО!
email := userInput
db.Where("email = '" + email + "'").Find(&user)

// ✅ БЕЗПЕЧНО
db.Where("email = ?", email).Find(&user)

Умови через структури

// Рівність (лише ненульові поля)
db.Where(&User{Name: "Alice", Age: 25}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice' AND age = 25

// Нульові значення ігноруються!
db.Where(&User{Name: "Alice", Age: 0}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice'
// Age = 0 не включено у запит
Чому нульові значення ігноруються?
GORM не може відрізнити явно встановлений нуль (Age: 0) від незаповненого поля. Для пошуку за нульовими значеннями використовуйте map або рядкові умови.

Умови через map

// Map дозволяє шукати за нульовими значеннями
db.Where(map[string]interface{}{
    "name": "Alice",
    "age":  0, // Нуль враховується!
}).Find(&users)
// SELECT * FROM users WHERE name = 'Alice' AND age = 0

Or та Not — логічні оператори

// OR умови
db.Where("role = ?", "admin").Or("role = ?", "moderator").Find(&users)
// SELECT * FROM users WHERE role = 'admin' OR role = 'moderator'

// Комбінація AND та OR
db.Where("age > ?", 18).
   Where("city = ?", "Київ").
   Or("status = ?", "vip").
   Find(&users)
// SELECT * FROM users WHERE (age > 18 AND city = 'Київ') OR status = 'vip'

// NOT умови
db.Not("name = ?", "Alice").Find(&users)
// SELECT * FROM users WHERE NOT name = 'Alice'

db.Not(map[string]interface{}{"name": []string{"Alice", "Bob"}}).Find(&users)
// SELECT * FROM users WHERE name NOT IN ('Alice', 'Bob')

// NOT з первинним ключем
db.Not([]int{1, 2, 3}).Find(&users)
// SELECT * FROM users WHERE id NOT IN (1, 2, 3)

Select та Omit — вибір полів

За замовчуванням GORM завантажує всі колонки (SELECT *). Це можна оптимізувати:

// Вибрати конкретні колонки
db.Select("name", "email").Find(&users)
// SELECT name, email FROM users

db.Select([]string{"name", "email", "age"}).Find(&users)
// SELECT name, email, age FROM users

// Виключити колонки
db.Omit("password_hash", "secret_token").Find(&users)
// SELECT id, name, email, created_at, ... (крім password_hash та secret_token)

// Вибір з виразами
db.Select("name, age, age * 2 as double_age").Find(&users)

// Підрахунок
type Result struct {
    Name  string
    Total int
}
var result Result
db.Model(&User{}).Select("name, COUNT(*) as total").Group("name").Scan(&result)
Коли використовувати Select?
  • Коли потрібні лише деякі поля (економія трафіку та пам'яті).
  • Для вибірки великих текстових полів (наприклад, виключити body статті у списку).
  • При агрегації та групуванні даних.
Коли використовувати Omit?
  • Для виключення чутливих даних (паролі, токени).
  • Коли легше вказати, що виключити, ніж перелічувати всі потрібні поля.

Order — сортування

// Сортування за одним полем
db.Order("age DESC").Find(&users)
// SELECT * FROM users ORDER BY age DESC

db.Order("created_at ASC").Find(&articles)

// Кілька полів
db.Order("age DESC, name ASC").Find(&users)
// ORDER BY age DESC, name ASC

// Через кілька викликів
db.Order("age DESC").Order("name").Find(&users)

// Сортування з виразами
db.Order("CASE WHEN role = 'admin' THEN 1 ELSE 2 END").Find(&users)

Limit та Offset — пагінація

// Перші 10 записів
db.Limit(10).Find(&users)
// SELECT * FROM users LIMIT 10

// Пропустити 20, взяти 10 (сторінка 3, по 10 на сторінці)
db.Limit(10).Offset(20).Find(&users)
// SELECT * FROM users LIMIT 10 OFFSET 20

// Функція пагінації
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        offset := (page - 1) * pageSize
        return db.Offset(offset).Limit(pageSize)
    }
}

// Використання:
db.Scopes(Paginate(3, 20)).Find(&users)
// Сторінка 3, 20 записів на сторінці

Distinct — унікальні значення

// Унікальні міста
db.Distinct("city").Find(&users)
// SELECT DISTINCT city FROM users

// Підрахунок унікальних
var count int64
db.Model(&User{}).Distinct("city").Count(&count)
// SELECT COUNT(DISTINCT city) FROM users

Group та Having — групування

type Result struct {
    City  string
    Count int
}

var results []Result

// Групування з підрахунком
db.Model(&User{}).
   Select("city, count(*) as count").
   Group("city").
   Scan(&results)
// SELECT city, count(*) as count FROM users GROUP BY city

// Групування з фільтрацією (HAVING)
db.Model(&User{}).
   Select("city, count(*) as count").
   Group("city").
   Having("count > ?", 10).
   Scan(&results)
// SELECT city, count(*) FROM users GROUP BY city HAVING count > 10

// Складна агрегація
type AgeStats struct {
    AvgAge float64
    MinAge int
    MaxAge int
}

var stats AgeStats
db.Model(&User{}).
   Select("AVG(age) as avg_age, MIN(age) as min_age, MAX(age) as max_age").
   Scan(&stats)

Pluck — вибірка одного стовпця у слайс

// Отримати всі email як []string
var emails []string
db.Model(&User{}).Pluck("email", &emails)
// SELECT email FROM users

// Отримати ID
var ids []uint
db.Model(&User{}).Pluck("id", &ids)

// З умовами
var activeEmails []string
db.Model(&User{}).Where("status = ?", "active").Pluck("email", &activeEmails)

First, Last, Take — отримання одного запису

// First — перший запис (ORDER BY primary key ASC)
var user User
db.First(&user)
// SELECT * FROM users ORDER BY id ASC LIMIT 1

// Last — останній запис (ORDER BY primary key DESC)
db.Last(&user)
// SELECT * FROM users ORDER BY id DESC LIMIT 1

// Take — будь-який запис (без сортування)
db.Take(&user)
// SELECT * FROM users LIMIT 1

// З умовами
db.Where("age > ?", 18).First(&user)

// За первинним ключем
db.First(&user, 42)
// SELECT * FROM users WHERE id = 42 LIMIT 1

db.First(&user, []int{1, 2, 3})
// SELECT * FROM users WHERE id IN (1,2,3) LIMIT 1
Помилка ErrRecordNotFound:
Якщо запис не знайдено, First, Last, Take повертають помилку gorm.ErrRecordNotFound. Завжди перевіряйте цю помилку:
if err := db.First(&user, id).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        // Запис не знайдено
        return nil, fmt.Errorf("користувача не існує")
    }
    // Інша помилка БД
    return nil, err
}

Scopes — переможні запити

Scopes (області застосування) — це функції, які інкапсулюють логіку запиту та можуть багаторазово використовуватися. Вони дозволяють створювати читабельні, композиційні запити без дублювання коду.

Концепція Scopes

Scope — це функція з сигнатурою:

func ScopeName(db *gorm.DB) *gorm.DB {
    return db.Where(...).Order(...)
}

Вона приймає *gorm.DB, модифікує запит та повертає модифіковану версію.

Базові приклади

// Scope для активних користувачів
func ActiveUsers(db *gorm.DB) *gorm.DB {
    return db.Where("status = ?", "active")
}

// Scope для сортування за датою
func OrderByCreatedAt(db *gorm.DB) *gorm.DB {
    return db.Order("created_at DESC")
}

// Використання:
var users []User
db.Scopes(ActiveUsers, OrderByCreatedAt).Find(&users)
// SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC

Переваги:

  1. Переважне використання: один scope можна застосовувати у багатьох місцях.
  2. Читабельність: запит виглядає декларативно: db.Scopes(ActiveUsers, Premium, RecentlyActive).
  3. Композиція: scopes можна комбінувати.

Scopes з параметрами

Для створення scopes з параметрами використовуйте замикання (closures):

// Scope для пагінації
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        offset := (page - 1) * pageSize
        return db.Offset(offset).Limit(pageSize)
    }
}

// Scope для фільтрації за віковим діапазоном
func AgeRange(min, max int) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("age BETWEEN ? AND ?", min, max)
    }
}

// Scope для пошуку за назвою
func SearchByName(query string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("name LIKE ?", "%"+query+"%")
    }
}

// Використання:
db.Scopes(
    ActiveUsers,
    AgeRange(18, 65),
    SearchByName("Іван"),
    Paginate(2, 20),
).Find(&users)

Умовні Scopes

// Scope застосовується лише якщо умова виконується
func FilterByCity(city string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        if city != "" {
            return db.Where("city = ?", city)
        }
        return db // Не модифікуємо запит
    }
}

// Використання з необов'язковими фільтрами:
city := r.URL.Query().Get("city")      // Може бути порожнім
status := r.URL.Query().Get("status")

db.Scopes(
    FilterByCity(city),           // Застосується лише якщо city != ""
    FilterByStatus(status),
).Find(&users)

Scopes для складних бізнес-правил

// Користувачі з премія-підпискою
func PremiumUsers(db *gorm.DB) *gorm.DB {
    return db.Where("subscription_plan IN ?", []string{"premium", "pro"}).
        Where("subscription_expires_at > ?", time.Now())
}

// Користувачі, що нещодавно заходили (останні 7 днів)
func RecentlyActive(db *gorm.DB) *gorm.DB {
    return db.Where("last_login_at > ?", time.Now().AddDate(0, 0, -7))
}

// Користувачі з повним профілем
func CompleteProfile(db *gorm.DB) *gorm.DB {
    return db.Where("avatar_url IS NOT NULL").
        Where("bio IS NOT NULL").
        Where("city IS NOT NULL")
}

// Комбінований запит:
var premiumActiveUsers []User
db.Scopes(PremiumUsers, RecentlyActive, CompleteProfile).Find(&premiumActiveUsers)

Методи моделей через Scopes

Можна прикріпити scopes до моделей:

type User struct {
    ID     uint
    Name   string
    Status string
    Role   string
}

// Scope як метод моделі
func (u *User) Active(db *gorm.DB) *gorm.DB {
    return db.Where("status = ?", "active")
}

func (u *User) Admins(db *gorm.DB) *gorm.DB {
    return db.Where("role = ?", "admin")
}

// Використання:
var user User
db.Scopes(user.Active, user.Admins).Find(&users)
Best Practices для Scopes:
  1. Іменування: використовуйте описові назви у Present Tense: ActiveUsers, PublishedArticles, RecentOrders.
  2. Однозначність: один scope — одна відповідальність. Не змішуйте фільтрацію та сортування в одному scope.
  3. Параметризація: якщо scope потребує параметрів, використовуйте замикання.
  4. Композиція: scopes мають бути незалежними та комбінуватися у будь-якому порядку.
  5. Тестування: scopes легко тестувати ізольовано.
func TestActiveUsersScope(t *testing.T) {
    db := setupTestDB(t)
    
    var users []User
    result := db.Scopes(ActiveUsers).Find(&users)
    
    assert.NoError(t, result.Error)
    for _, user := range users {
        assert.Equal(t, "active", user.Status)
    }
}

Реальний приклад: API фільтрації

// HTTP Handler для /api/users?status=active&role=admin&page=2&page_size=20
func (h *Handler) ListUsers(c *gin.Context) {
    var users []User
    
    // Параметри з query string
    status := c.Query("status")
    role := c.Query("role")
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
    
    // Побудова запиту через scopes
    query := h.db.Model(&User{})
    
    if status != "" {
        query = query.Scopes(FilterByStatus(status))
    }
    
    if role != "" {
        query = query.Scopes(FilterByRole(role))
    }
    
    query = query.Scopes(Paginate(page, pageSize))
    
    if err := query.Find(&users).Error; err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(200, users)
}

// Scopes
func FilterByStatus(status string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("status = ?", status)
    }
}

func FilterByRole(role string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("role = ?", role)
    }
}

Batch операції та продуктивність

При роботі з великими обсягами даних важливо оптимізувати операції, щоб уникнути перевантаження бази даних та пам'яті застосунку.

CreateInBatches — пакетне створення

Коли потрібно вставити тисячі записів, Create() виконає окремий INSERT для кожного. CreateInBatches() групує вставки:

var users []User
for i := 0; i < 10000; i++ {
    users = append(users, User{
        Name:  fmt.Sprintf("User %d", i),
        Email: fmt.Sprintf("user%d@example.com", i),
    })
}

// ❌ Повільно: 10000 окремих INSERT запитів
db.Create(&users)

// ✅ Швидко: вставка по 1000 записів за раз
db.CreateInBatches(users, 1000)
// INSERT INTO users (...) VALUES (...), (...), ... (1000 рядків)
// INSERT INTO users (...) VALUES (...), (...), ... (1000 рядків)
// ...

Оптимальний розмір пакету:

  • PostgreSQL: 500-2000 рядків
  • MySQL: 100-500 рядків
  • Залежить від розміру рядка та налаштувань СУБД
Обмеження:
Деякі бази даних мають ліміт на розмір одного SQL-запиту (наприклад, PostgreSQL — max_allowed_packet). Експериментуйте з розміром пакету для вашої СУБД.

FindInBatches — обробка великих datasets

Коли потрібно обробити мільйони записів, завантаження всіх у пам'ять призведе до її виснаження. FindInBatches() обробляє дані частинами:

result := db.Where("status = ?", "pending").FindInBatches(&users, 1000, func(tx *gorm.DB, batch int) error {
    // Обробка кожної партії з 1000 записів
    for _, user := range users {
        // Виконати щось з користувачем
        processUser(user)
    }
    
    // Повернути nil для продовження, або помилку для зупинки
    return nil
})

if result.Error != nil {
    log.Printf("Помилка обробки: %v", result.Error)
}

log.Printf("Оброблено партій: %d, записів: %d", result.RowsAffected)

Як це працює:

  1. GORM виконує запит SELECT ... LIMIT 1000 OFFSET 0.
  2. Викликає вашу функцію з першою партією.
  3. Виконує LIMIT 1000 OFFSET 1000, викликає функцію знову.
  4. Продовжує, поки є записи.

Переваги:

  • Контрольоване споживання пам'яті.
  • Можливість зупинки обробки (повернути помилку).
  • Прогрес обробки (параметр batch).

Upsert — вставка або оновлення (OnConflict)

Upsert (insert or update) — операція, що вставляє запис, якщо його немає, або оновлює, якщо він існує.

// PostgreSQL: ON CONFLICT DO UPDATE
user := User{
    Email: "alice@example.com",
    Name:  "Alice Cooper",
    Age:   30,
}

result := db.Clauses(clause.OnConflict{
    Columns:   []clause.Column{{Name: "email"}}, // Ключ конфлікту
    DoUpdates: clause.AssignmentColumns([]string{"name", "age"}), // Оновити ці колонки
}).Create(&user)

// INSERT INTO users (email, name, age) VALUES (?, ?, ?)
// ON CONFLICT (email)
// DO UPDATE SET name = excluded.name, age = excluded.age

Варіанти обробки конфлікту:

// 1. Оновити всі колонки крім певних
db.Clauses(clause.OnConflict{
    Columns:   []clause.Column{{Name: "email"}},
    DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),
}).Create(&user)

// 2. Оновити лише вказані колонки
db.Clauses(clause.OnConflict{
    Columns: []clause.Column{{Name: "email"}},
    DoUpdates: clause.Assignments(map[string]interface{}{
        "name": "New Name",
        "updated_at": time.Now(),
    }),
}).Create(&user)

// 3. Нічого не робити при конфлікті
db.Clauses(clause.OnConflict{
    Columns:   []clause.Column{{Name: "email"}},
    DoNothing: true,
}).Create(&user)

// 4. Оновити, використовуючи значення з INSERT
db.Clauses(clause.OnConflict{
    Columns: []clause.Column{{Name: "email"}},
    DoUpdates: clause.Assignments(map[string]interface{}{
        "name": gorm.Expr("excluded.name"),
        "age":  gorm.Expr("excluded.age"),
    }),
}).Create(&user)
Коли використовувати Upsert?
  • Імпорт даних з зовнішніх джерел (синхронізація з API).
  • Кешування даних у БД (оновити, якщо існує, інакше створити).
  • Ідемпотентні операції (повторний виклик не створить дублікатів).

Updates з обмеженнями

// Оновити лише перші 10 записів
db.Model(&User{}).Where("status = ?", "pending").
   Limit(10).
   Update("status", "processed")

// Оновити лише якщо вік менше певного значення
db.Model(&User{}).
   Where("age < ?", 18).
   Update("status", "minor")

Prepared Statements для продуктивності

GORM може кешувати підготовлені SQL-запити (prepared statements) для покращення продуктивності повторюваних запитів:

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    PrepareStmt: true, // Увімкнути prepared statements
})

// Тепер GORM кешує та повторно використовує підготовлені запити
for i := 0; i < 1000; i++ {
    var user User
    db.First(&user, i)
    // Запит підготовлений лише один раз, параметри змінюються
}

Переваги:

  • Підвищення продуктивності (до 30% для простих запитів).
  • Захист від SQL-ін'єкцій (вбудований).
  • Зменшення навантаження на парсер SQL.

Недоліки:

  • Споживає з'єднання БД довше.
  • Може спричинити витоки пам'яті при великій кількості унікальних запитів.
Увімкнення PrepareStmt у комбінації з динамічними запитами (де запити постійно змінюються) може призвести до витоків пам'яті через нескінченне накопичення підготовлених запитів. Використовуйте лише для застосунків із обмеженим набором запитів.

Індекси через теги

GORM дозволяє визначати індекси безпосередньо у моделях:

type User struct {
    ID    uint
    Email string `gorm:"uniqueIndex"`           // Унікальний індекс
    Name  string `gorm:"index"`                 // Звичайний індекс
    City  string `gorm:"index:idx_city_status"` // Іменований індекс
    Status string `gorm:"index:idx_city_status"` // Композитний індекс
}

// AutoMigrate створить індекси:
db.AutoMigrate(&User{})
// CREATE UNIQUE INDEX idx_users_email ON users(email);
// CREATE INDEX idx_users_name ON users(name);
// CREATE INDEX idx_city_status ON users(city, status);

Параметри індексів:

type User struct {
    Email string `gorm:"index:,unique,where:deleted_at IS NULL"`
    // Унікальний індекс лише для неvidалених записів
    
    Name string `gorm:"index:,type:btree,length:100"`
    // B-tree індекс з обмеженням довжини
    
    Tags []string `gorm:"type:text[];index:,type:gin"`
    // GIN індекс для масивів (PostgreSQL)
}

Детальна робота зі зв'язками

Зв'язки між таблицями — одна з найпотужніших можливостей GORM. Розглянемо всі типи зв'язків детально.

Налаштування зв'язків через теги

GORM автоматично визначає зв'язки, але часто потрібне тонке налаштування:

type User struct {
    ID       uint
    Name     string
    Profile  Profile `gorm:"foreignKey:UserID;references:ID"`
    // foreignKey — поле у Profile, що вказує на User
    // references — поле у User, на яке вказує foreignKey
}

type Profile struct {
    ID     uint
    UserID uint   // Зовнішній ключ
    Bio    string
}

Теги налаштування зв'язків

// 1. Перевизначення зовнішнього ключа
type User struct {
    ID      uint
    Profile Profile `gorm:"foreignKey:OwnerID"` // Замість UserID
}

type Profile struct {
    OwnerID uint // Зовнішній ключ
    Bio     string
}

// 2. Перевизначення поля посилання
type User struct {
    UUID    string `gorm:"primaryKey"`
    Profile Profile `gorm:"foreignKey:UserUUID;references:UUID"`
}

type Profile struct {
    UserUUID string
    Bio      string
}

// 3. Каскадне видалення
type User struct {
    ID    uint
    Posts []Post `gorm:"constraint:OnDelete:CASCADE;"`
}
// При видаленні User автоматично видалятимуться всі Posts

// 4. Каскадне оновлення
type User struct {
    ID    uint
    Posts []Post `gorm:"constraint:OnUpdate:CASCADE;"`
}

Параметри constraint:

  • OnDelete:CASCADE — видалити пов'язані записи
  • OnDelete:SET NULL — встановити NULL у зовнішньому ключі
  • OnDelete:RESTRICT — заборонити видалення (за замовчуванням)
  • OnUpdate:CASCADE — оновити зовнішній ключ при зміні первинного
Каскадне видалення працює на рівні БД, тому хуки GORM (BeforeDelete, AfterDelete) не викликаються для пов'язаних записів. Якщо потрібна логіка при видаленні, обробляйте це вручну.

Association API — керування зв'язками

GORM надає методи для роботи зі зв'язками без написання SQL:

// Отримати зв'язок
user := User{ID: 1}
db.First(&user)

var posts []Post
db.Model(&user).Association("Posts").Find(&posts)
// Завантажити всі пости користувача

// Підрахувати кількість пов'язаних записів
count := db.Model(&user).Association("Posts").Count()
log.Printf("Користувач має %d постів", count)

// Додати пов'язані записи
newPosts := []Post{
    {Title: "New Post 1"},
    {Title: "New Post 2"},
}
db.Model(&user).Association("Posts").Append(&newPosts)
// INSERT INTO posts (user_id, title) VALUES (1, 'New Post 1'), (1, 'New Post 2')

// Замінити всі пов'язані записи
db.Model(&user).Association("Posts").Replace(&newPosts)
// Видалить старі пости та додасть нові

// Видалити конкретні пов'язані записи
db.Model(&user).Association("Posts").Delete(&posts[0])
// DELETE FROM posts WHERE id = ? AND user_id = 1

// Очистити всі зв'язки
db.Model(&user).Association("Posts").Clear()
// DELETE FROM posts WHERE user_id = 1

Eager Loading стратегії: Preload vs Joins

Існує два способи завантаження пов'язаних даних:

1. Preload — окремі запити

var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);

Переваги:

  • Простота та гнучкість.
  • Працює для всіх типів зв'язків (1:1, 1:N, N:M).
  • Можна фільтрувати завантажені дані.

Недоліки:

  • Виконує кілька запитів (N+1 за умови без умов).
  • Більше мережевих round-trips.

2. Joins — одні запит з JOIN

var users []User
db.Joins("Posts").Find(&users)
// SELECT users.*, posts.* FROM users
// LEFT JOIN posts ON posts.user_id = users.id

Переваги:

  • Один SQL-запит.
  • Швидше для невеликих datasets.

Недоліки:

  • Дублювання даних (кожен User повторюється для кожного Post).
  • Не працює для N:M без додаткових налаштувань.
  • Складніше фільтрувати пов'язані дані.
Коли використовувати що?
  • Preload — коли завантажується багато пов'язаних записів (1:N, N:M).
  • Joins — коли потрібна фільтрація за пов'язаними даними у WHERE.
// Знайти користувачів з опублікованими постами
db.Joins("Posts").Where("posts.status = ?", "published").Find(&users)

Вкладене Preload

// Завантажити користувачів, їхні пости та коментарі до постів
db.Preload("Posts.Comments").Find(&users)
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (...);
// SELECT * FROM comments WHERE post_id IN (...);

// Вкладене з умовами
db.Preload("Posts", "status = ?", "published").
   Preload("Posts.Comments", func(db *gorm.DB) *gorm.DB {
       return db.Order("created_at DESC").Limit(5)
   }).
   Find(&users)

Preload з умовами

// Завантажити лише опубліковані пости
db.Preload("Posts", "status = ?", "published").Find(&users)

// Через функцію
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
    return db.Where("status = ?", "published").Order("created_at DESC")
}).Find(&users)

Поліморфні зв'язки

Поліморфні зв'язки дозволяють моделі належати до кількох різних типів:

// Коментарі можуть належати до Post або Video
type Comment struct {
    ID            uint
    Body          string
    CommentableID uint   // ID записи
    CommentableType string // Тип: "Post" або "Video"
}

type Post struct {
    ID       uint
    Title    string
    Comments []Comment `gorm:"polymorphic:Commentable;"`
}

type Video struct {
    ID       uint
    URL      string
    Comments []Comment `gorm:"polymorphic:Commentable;"`
}

// Використання:
post := Post{Title: "My Post"}
db.Create(&post)

comment := Comment{
    Body:            "Great post!",
    CommentableID:   post.ID,
    CommentableType: "Post",
}
db.Create(&comment)

// Завантаження коментарів
db.Preload("Comments").First(&post, post.ID)

Налаштування поліморфних зв'язків:

type Comment struct {
    ID         uint
    Body       string
    OwnerID    uint   `gorm:"index"` // Перевизначена назва
    OwnerType  string `gorm:"index"`
}

type Post struct {
    Comments []Comment `gorm:"polymorphic:Owner;polymorphicValue:post"`
    // polymorphicValue: OwnerType буде "post" замість "Post"
}

Self-Referencing — ієрархії та дерева

// Категорії з підкатегоріями
type Category struct {
    ID         uint
    Name       string
    ParentID   *uint       // Nullable foreign key
    Parent     *Category   `gorm:"foreignKey:ParentID"`
    Children   []Category  `gorm:"foreignKey:ParentID"`
}

// Використання:
electronics := Category{Name: "Електроніка"}
db.Create(&electronics)

smartphones := Category{
    Name:     "Смартфони",
    ParentID: &electronics.ID,
}
db.Create(&smartphones)

// Завантаження з підкатегоріями
var category Category
db.Preload("Children").First(&category, electronics.ID)

for _, child := range category.Children {
    log.Println(child.Name) // Смартфони, Ноутбуки, ...
}

// Завантаження батьківської категорії
db.Preload("Parent").First(&category, smartphones.ID)
log.Println(category.Parent.Name) // Електроніка
Обмеження Self-Referencing:
GORM не підтримує рекурсивне завантаження всього дерева одним запитом. Для глибоких ієрархій потрібно завантажувати рівні вручну або використовувати спеціалізовані пакети (наприклад, closure table або materialized path).

Many-to-Many з додатковими полями

Іноді проміжна таблиця N:M потребує додаткових полів:

// Студенти записуються на курси з оцінкою та датою
type Student struct {
    ID         uint
    Name       string
    Enrollments []Enrollment
}

type Course struct {
    ID         uint
    Title      string
    Enrollments []Enrollment
}

// Проміжна модель з додатковими полями
type Enrollment struct {
    StudentID  uint `gorm:"primaryKey"`
    CourseID   uint `gorm:"primaryKey"`
    Grade      string
    EnrolledAt time.Time
    Student    Student `gorm:"foreignKey:StudentID"`
    Course     Course  `gorm:"foreignKey:CourseID"`
}

// Використання:
student := Student{Name: "Іван"}
course := Course{Title: "Бази даних"}
db.Create(&student)
db.Create(&course)

enrollment := Enrollment{
    StudentID:  student.ID,
    CourseID:   course.ID,
    Grade:      "A",
    EnrolledAt: time.Now(),
}
db.Create(&enrollment)

// Завантаження з проміжною моделлю
var students []Student
db.Preload("Enrollments.Course").Find(&students)

for _, s := range students {
    for _, e := range s.Enrollments {
        log.Printf("%s записаний на %s з оцінкою %s",
            s.Name, e.Course.Title, e.Grade)
    }
}

Session та контекст виконання

GORM надає механізм Session для тонкого налаштування поведінки окремих запитів без зміни глобальних параметрів.

WithContext — передача контексту

Контекст дозволяє контролювати таймаути, скасування та трейсинг запитів:

func (r *Repository) GetUser(ctx context.Context, id uint) (*User, error) {
    var user User
    
    // Передача контексту у GORM
    err := r.db.WithContext(ctx).First(&user, id).Error
    if err != nil {
        return nil, err
    }
    
    return &user, nil
}

// Використання з таймаутом:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

user, err := repo.GetUser(ctx, 42)
if err == context.DeadlineExceeded {
    log.Println("Запит перевищив таймаут 5 секунд")
}

Переваги WithContext:

  • Автоматичне скасування запиту при закінченні таймауту.
  • Інтеграція з трейсингом (OpenTelemetry, Jaeger).
  • Передача метаданих між шарами застосунку.
Best Practice:
Завжди передавайте контекст у методи доступу до даних. Це стандарт у Go-екосистемі та дозволяє коректно обробляти таймаути HTTP-запитів.
// ❌ Погано
func (r *Repo) GetUser(id uint) (*User, error) {
    return db.First(&user, id)
}

// ✅ Добре
func (r *Repo) GetUser(ctx context.Context, id uint) (*User, error) {
    return db.WithContext(ctx).First(&user, id)
}

Session конфігурація

Метод Session() дозволяє налаштувати поведінку для одного запиту або ланцюжка запитів:

// 1. DryRun — показати SQL без виконання
stmt := db.Session(&gorm.Session{DryRun: true}).
    Where("age > ?", 18).
    Find(&users).
    Statement

log.Println(stmt.SQL.String())    // SELECT * FROM users WHERE age > 18
log.Println(stmt.Vars)             // [18]

// 2. SkipHooks — пропустити хуки (BeforeCreate, AfterUpdate тощо)
db.Session(&gorm.Session{SkipHooks: true}).Create(&user)
// BeforeCreate та AfterCreate не викликаються

// 3. PrepareStmt — увімкнути prepared statements для сесії
db.Session(&gorm.Session{PrepareStmt: true}).First(&user, 1)

// 4. NewDB — створити нову сесію без попередніх умов
newDB := db.Session(&gorm.Session{NewDB: true})
// newDB не успадковує Where, Preload тощо від db

// 5. SkipDefaultTransaction — вимкнути автоматичні транзакції
// За замовчуванням Create/Update/Delete обгортаються у транзакцію
db.Session(&gorm.Session{SkipDefaultTransaction: true}).Create(&user)
// Працює швидше, але без атомарності

// 6. AllowGlobalUpdate — дозволити оновлення/видалення без WHERE
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{})
// DELETE FROM users (без WHERE — видалить ВСІ записи)

// 7. Context — встановити контекст
ctx := context.WithValue(context.Background(), "user_id", 42)
db.Session(&gorm.Session{Context: ctx}).Find(&users)

// 8. Logger — перевизначити логер для сесії
customLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{SlowThreshold: 200 * time.Millisecond},
)
db.Session(&gorm.Session{Logger: customLogger}).Find(&users)

// 9. NowFunc — перевизначити функцію поточного часу
db.Session(&gorm.Session{
    NowFunc: func() time.Time {
        return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
    },
}).Create(&user)
// CreatedAt буде 2020-01-01 (корисно для тестів)

QueryFields — вибір полів автоматично

За замовчуванням GORM використовує SELECT *. Для оптимізації можна вибирати лише потрібні поля:

db.Session(&gorm.Session{QueryFields: true}).Find(&users)
// SELECT id, name, email, created_at FROM users
// Замість SELECT *

CreateBatchSize — розмір пакету за замовчуванням

db.Session(&gorm.Session{CreateBatchSize: 1000}).Create(&users)
// Якщо users містить більше 1000 елементів,
// автоматично розбиває на пакети

Комбінування налаштувань

result := db.Session(&gorm.Session{
    PrepareStmt:             true,
    SkipDefaultTransaction: true,
    QueryFields:            true,
    Logger:                 customLogger,
}).
WithContext(ctx).
Where("status = ?", "active").
Find(&users)
Порядок виконання:
Session налаштування застосовуються перед будь-якими методами запиту (Where, Preload, Order). Тому Session() має викликатися першим у ланцюжку.

Raw SQL та інтеграція

Хоча GORM надає багатий Query Builder API, іноді потрібен повний контроль через чистий SQL.

Raw запити для читання

// 1. Простий Raw запит
var users []User
db.Raw("SELECT * FROM users WHERE age > ?", 18).Scan(&users)

// 2. З іменованими параметрами
db.Raw("SELECT * FROM users WHERE age > @age AND city = @city",
    sql.Named("age", 18),
    sql.Named("city", "Київ"),
).Scan(&users)

// 3. Scan у map
var results []map[string]interface{}
db.Raw("SELECT id, name, email FROM users LIMIT 10").Scan(&results)

for _, row := range results {
    log.Printf("ID: %v, Name: %v", row["id"], row["name"])
}

// 4. Scan у кастомну структуру
type UserSummary struct {
    Name  string
    Total int
}

var summaries []UserSummary
db.Raw(`
    SELECT u.name, COUNT(p.id) as total
    FROM users u
    LEFT JOIN posts p ON p.user_id = u.id
    GROUP BY u.name
`).Scan(&summaries)

Row та Rows — низькорівневий доступ

// Отримати один рядок
row := db.Table("users").Where("id = ?", 1).Select("name, age").Row()

var name string
var age int
row.Scan(&name, &age)

// Отримати кілька рядків
rows, err := db.Table("users").Where("age > ?", 18).Select("name, age").Rows()
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var name string
    var age int
    rows.Scan(&name, &age)
    log.Printf("%s: %d", name, age)
}

Exec — виконання команд зміни

// 1. Простий Exec
db.Exec("UPDATE users SET status = ? WHERE age < ?", "minor", 18)

// 2. Отримання кількості змінених рядків
result := db.Exec("DELETE FROM users WHERE status = ?", "banned")
log.Printf("Видалено %d користувачів", result.RowsAffected)

// 3. DDL команди
db.Exec("CREATE INDEX idx_users_email ON users(email)")
db.Exec("DROP TABLE IF EXISTS temp_users")

// 4. Виклик збережених процедур
db.Exec("CALL update_user_stats(?)", userID)

Змішування GORM та Raw SQL

// Використання GORM для побудови WHERE, але Raw для SELECT
query := db.Model(&User{}).
    Where("age > ?", 18).
    Where("city = ?", "Київ")

var results []UserSummary
query.Raw(`
    SELECT name, email, age * 2 as double_age
    FROM users
    WHERE ` + query.Statement.SQL.String(),
    query.Statement.Vars...,
).Scan(&results)
При змішуванні підходів уважно стежте за SQL-ін'єкціями. Завжди передавайте параметри через ? або sql.Named(), а не через конкатенацію рядків.

Субзапити (Subqueries)

// 1. Субзапит у WHERE
subQuery := db.Model(&Post{}).
    Select("user_id").
    Where("status = ?", "published")

db.Where("id IN (?)", subQuery).Find(&users)
// SELECT * FROM users WHERE id IN (
//     SELECT user_id FROM posts WHERE status = 'published'
// )

// 2. Субзапит у FROM
subQuery2 := db.Model(&User{}).
    Select("id, name, age").
    Where("age > ?", 18)

db.Table("(?) as u", subQuery2).
    Where("u.name LIKE ?", "A%").
    Find(&users)
// SELECT * FROM (
//     SELECT id, name, age FROM users WHERE age > 18
// ) as u
// WHERE u.name LIKE 'A%'

Розширена робота з транзакціями

Ми вже розглянули базові транзакції, тепер заглибимося у розширені можливості.

Транзакційний блок через callback

GORM надає метод Transaction(), який автоматично обробляє commit та rollback:

err := db.Transaction(func(tx *gorm.DB) error {
    // Крок 1: створити користувача
    user := User{Name: "Alice", Email: "alice@example.com"}
    if err := tx.Create(&user).Error; err != nil {
        return err // Автоматичний rollback
    }
    
    // Крок 2: створити профіль
    profile := Profile{UserID: user.ID, Bio: "Software Engineer"}
    if err := tx.Create(&profile).Error; err != nil {
        return err // Автоматичний rollback
    }
    
    // Крок 3: нарахувати бонуси
    if err := tx.Exec("INSERT INTO bonuses (user_id, amount) VALUES (?, ?)",
        user.ID, 100).Error; err != nil {
        return err
    }
    
    // Якщо функція повертає nil, автоматичний commit
    return nil
})

if err != nil {
    log.Printf("Транзакція скасована: %v", err)
}

Переваги:

  • Не потрібно вручну викликати Commit() та Rollback().
  • Гарантована обробка помилок.
  • Чистий, читабельний код.

Вкладені транзакції (SavePoints)

PostgreSQL підтримує вкладені транзакції через механізм SavePoint:

db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&user1)
    
    // Вкладена транзакція
    tx.SavePoint("sp1")
    tx.Create(&user2)
    
    if someCondition {
        tx.RollbackTo("sp1") // Відкотити лише user2
    }
    
    tx.SavePoint("sp2")
    tx.Create(&user3)
    tx.RollbackTo("sp2") // Відкотити лише user3
    
    return nil // Commit user1 та user2 (якщо не відкочено)
})

Автоматичні SavePoints через вкладені Transaction():

db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&user)
    
    // Вкладена транзакція автоматично створює SavePoint
    tx.Transaction(func(tx2 *gorm.DB) error {
        tx2.Create(&profile)
        
        if err := validate(profile); err != nil {
            return err // Відкат лише profile, user залишається
        }
        
        return nil
    })
    
    tx.Create(&settings)
    return nil
})

Ручні SavePoints

tx := db.Begin()

tx.Create(&user)

tx.SavePoint("before_profile")
tx.Create(&profile)

if profile.Bio == "" {
    tx.RollbackTo("before_profile")
    // profile не створено, але user залишається
}

tx.Commit()

Рівні ізоляції

GORM підтримує налаштування рівнів ізоляції транзакцій:

import "database/sql"

// Serializable — найвищий рівень ізоляції
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelSerializable})
tx.First(&user, 1)
tx.Commit()

// Read Committed (за замовчуванням у PostgreSQL)
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadCommitted})

// Repeatable Read
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead})

// Read Uncommitted (найнижчий рівень)
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadUncommitted})

Рівні ізоляції (від найнижчого до найвищого):

  1. Read Uncommitted: можна читати незакомічені дані (dirty reads).
  2. Read Committed: лише закомічені дані, але можливі non-repeatable reads.
  3. Repeatable Read: незмінність даних протягом транзакції, але можливі phantom reads.
  4. Serializable: повна ізоляція, як би транзакції виконувалися послідовно.
PostgreSQL особливості:
PostgreSQL не має справжнього Read Uncommitted — він працює як Read Committed. Також Repeatable Read у PostgreSQL запобігає phantom reads, на відміну від стандарту SQL.

Read-Only транзакції

tx := db.Begin(&sql.TxOptions{ReadOnly: true})
defer tx.Rollback()

// Дозволено
var users []User
tx.Find(&users)

// Заборонено (помилка)
tx.Create(&user) // Error: cannot execute INSERT in a read-only transaction

tx.Commit()

Переваги Read-Only:

  • База даних може оптимізувати запити (не блокувати рядки).
  • Захист від випадкових змін.

Pessimistic Locking — блокування на читання

// FOR UPDATE — ексклюзивне блокування
var user User
tx := db.Begin()

tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, 1)
// SELECT * FROM users WHERE id = 1 FOR UPDATE

// Інші транзакції чекатимуть, поки ця не завершиться
user.Balance -= 100
tx.Save(&user)
tx.Commit()

// FOR SHARE — блокування на запис (дозволяє читання іншим)
tx.Clauses(clause.Locking{Strength: "SHARE"}).First(&user, 1)
// SELECT * FROM users WHERE id = 1 FOR SHARE

// NOWAIT — не чекати, якщо заблоковано
tx.Clauses(clause.Locking{
    Strength: "UPDATE",
    Options:  "NOWAIT",
}).First(&user, 1)
// Якщо рядок заблоковано, поверне помилку замість очікування

Оптимістичні блокування (Optimistic Locking)

type User struct {
    ID      uint
    Name    string
    Balance int
    Version int `gorm:"default:0"` // Лічильник версій
}

func (r *Repository) UpdateBalance(id uint, amount int) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        var user User
        if err := tx.First(&user, id).Error; err != nil {
            return err
        }
        
        originalVersion := user.Version
        user.Balance += amount
        user.Version++
        
        // Оновити лише якщо версія не змінилася
        result := tx.Model(&user).
            Where("id = ? AND version = ?", user.ID, originalVersion).
            Updates(map[string]interface{}{
                "balance": user.Balance,
                "version": user.Version,
            })
        
        if result.RowsAffected == 0 {
            return fmt.Errorf("конфлікт оновлення: запис змінено іншою транзакцією")
        }
        
        return nil
    })
}

Переваги оптимістичного блокування:

  • Не блокує рядки (вища конкурентність).
  • Підходить для сценаріїв із рідкісними конфліктами.

Недоліки:

  • Потрібен retry механізм при конфлікті.
  • Додаткове поле version у таблиці.
Коли використовувати яке блокування?
  • Оптимістичне — для веб-застосунків, де конфлікти рідкісні (редагування профілю, коментарі).
  • Песимістичне — для фінансових операцій, де конфлікти частіші та критичні (переказ коштів, резервування місць).

Logger та налагодження

GORM має вбудовану систему логування, яка допомагає розуміти, які SQL-запити виконуються.

Рівні логування

import (
    "gorm.io/gorm/logger"
)

// 1. Silent — не логувати нічого
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Silent),
})

// 2. Error — лише помилки
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Error),
})

// 3. Warn — помилки та попередження (повільні запити)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Warn),
})

// 4. Info — всі запити, помилки та попередження
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Info),
})

Налаштування логера

newLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags), // Вивід
    logger.Config{
        SlowThreshold:             200 * time.Millisecond, // Поріг для "повільного запиту"
        LogLevel:                  logger.Info,            // Рівень логування
        IgnoreRecordNotFoundError: true,                   // Не логувати ErrRecordNotFound
        Colorful:                  true,                   // Кольоровий вивід
    },
)

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: newLogger,
})

Debug режим для окремих запитів

// Вивести SQL для одного запиту
db.Debug().Where("age > ?", 18).Find(&users)
// Вивід у консоль:
// [2.123ms] [rows:5] SELECT * FROM users WHERE age > 18

// Без Debug — запит не логується
db.Where("age > ?", 18).Find(&users)

Кастомний логер

type MyLogger struct {
    logger.Interface
}

func (l *MyLogger) LogMode(level logger.LogLevel) logger.Interface {
    return l
}

func (l *MyLogger) Info(ctx context.Context, msg string, data ...interface{}) {
    log.Printf("[INFO] "+msg, data...)
}

func (l *MyLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
    log.Printf("[WARN] "+msg, data...)
}

func (l *MyLogger) Error(ctx context.Context, msg string, data ...interface{}) {
    log.Printf("[ERROR] "+msg, data...)
}

func (l *MyLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
    elapsed := time.Since(begin)
    sql, rows := fc()
    log.Printf("[TRACE] [%v] [rows:%d] %s", elapsed, rows, sql)
}

// Використання:
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: &MyLogger{},
})

Інтеграція з slog (Go 1.21+)

import (
    "log/slog"
    "os"
)

type SlogLogger struct {
    logger *slog.Logger
}

func NewSlogLogger() *SlogLogger {
    return &SlogLogger{
        logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
    }
}

func (l *SlogLogger) LogMode(level logger.LogLevel) logger.Interface {
    return l
}

func (l *SlogLogger) Info(ctx context.Context, msg string, data ...interface{}) {
    l.logger.InfoContext(ctx, msg, "data", data)
}

// ... інші методи

func (l *SlogLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
    elapsed := time.Since(begin)
    sql, rows := fc()
    
    l.logger.InfoContext(ctx, "SQL Query",
        "duration_ms", elapsed.Milliseconds(),
        "rows", rows,
        "sql", sql,
        "error", err,
    )
}

Migrator — управління схемою БД

GORM надає інтерфейс Migrator для програмного керування схемою бази даних.

AutoMigrate — автоматична синхронізація

// Створити/оновити таблиці на основі моделей
db.AutoMigrate(&User{}, &Post{}, &Comment{})

// Додасть відсутні колонки, індекси та зв'язки
// НЕ видалить існуючі колонки або таблиці
Обмеження AutoMigrate:
  • ✅ Створює нові таблиці
  • ✅ Додає нові колонки
  • ✅ Додає індекси та foreign keys
  • ❌ НЕ видаляє колонки
  • ❌ НЕ змінює типи колонок
  • ❌ НЕ перейменовує колонки
Для продакшн-міграцій використовуйте golang-migrate або інші спеціалізовані інструменти.

Ручні операції через Migrator

migrator := db.Migrator()

// Перевірка існування таблиці
if migrator.HasTable(&User{}) {
    log.Println("Таблиця users існує")
}

if migrator.HasTable("users") {
    log.Println("Таблиця users існує (за назвою)")
}

// Створення таблиці
migrator.CreateTable(&User{})

// Видалення таблиці
migrator.DropTable(&User{})
migrator.DropTable("users")

// Перейменування таблиці
migrator.RenameTable(&User{}, &Customer{})
migrator.RenameTable("users", "customers")

Операції з колонками

type User struct {
    ID    uint
    Name  string
    Email string
    Age   int
}

migrator := db.Migrator()

// Перевірка існування колонки
if migrator.HasColumn(&User{}, "Email") {
    log.Println("Колонка email існує")
}

// Додавання колонки
type UpdatedUser struct {
    User
    PhoneNumber string
}
migrator.AddColumn(&UpdatedUser{}, "PhoneNumber")

// Видалення колонки
migrator.DropColumn(&User{}, "Age")

// Зміна типу колонки
migrator.AlterColumn(&User{}, "Name")

// Перейменування колонки
migrator.RenameColumn(&User{}, "Name", "FullName")

// Отримання типу колонки
columnTypes, _ := migrator.ColumnTypes(&User{})
for _, ct := range columnTypes {
    log.Printf("Колонка: %s, Тип: %s", ct.Name(), ct.DatabaseTypeName())
}

Операції з індексами

type User struct {
    ID    uint
    Email string `gorm:"index:idx_email,unique"`
    Name  string `gorm:"index:idx_name"`
}

migrator := db.Migrator()

// Створення індексу
migrator.CreateIndex(&User{}, "Email")
migrator.CreateIndex(&User{}, "idx_email")

// Видалення індексу
migrator.DropIndex(&User{}, "idx_email")

// Перевірка існування індексу
if migrator.HasIndex(&User{}, "idx_email") {
    log.Println("Індекс існує")
}

// Перейменування індексу
migrator.RenameIndex(&User{}, "idx_email", "idx_user_email")

Операції з обмеженнями (Constraints)

type User struct {
    ID   uint
    Name string
}

type Post struct {
    ID     uint
    UserID uint
    User   User `gorm:"constraint:OnDelete:CASCADE;"`
}

migrator := db.Migrator()

// Перевірка існування constraint
if migrator.HasConstraint(&Post{}, "fk_posts_user") {
    log.Println("Constraint існує")
}

// Створення constraint
migrator.CreateConstraint(&Post{}, "User")

// Видалення constraint
migrator.DropConstraint(&Post{}, "fk_posts_user")

Отримання інформації про таблиці

// Список всіх таблиць
tables, err := migrator.GetTables()
for _, table := range tables {
    log.Println(table)
}

// Отримання SQL для створення таблиці (PostgreSQL)
db.Exec("SELECT pg_get_tabledef('users')")

Практичні патерни та рекомендації

Soft Delete — детальніше

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

user := User{ID: 1, Name: "Alice"}
db.Create(&user)

// Soft Delete
db.Delete(&user)
// UPDATE users SET deleted_at = '2024-01-01 10:00:00' WHERE id = 1

// Запити ігнорують soft-deleted
var users []User
db.Find(&users)
// SELECT * FROM users WHERE deleted_at IS NULL

// Пошук лише видалених
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&users)

// Постійне видалення
db.Unscoped().Delete(&user)
// DELETE FROM users WHERE id = 1

// Відновлення видаленого запису
db.Unscoped().Model(&user).Update("deleted_at", nil)

Тенантність (Multi-Tenancy)

// Схема через окремі БД
type Tenant struct {
    ID  uint
    DSN string
}

func GetTenantDB(tenantID uint) (*gorm.DB, error) {
    var tenant Tenant
    // Завантажити tenant з основної БД
    mainDB.First(&tenant, tenantID)
    
    // Підключитися до БД тенанта
    return gorm.Open(postgres.Open(tenant.DSN), &gorm.Config{})
}

// Схема через tenant_id колонку
type User struct {
    ID       uint
    TenantID uint `gorm:"index"`
    Name     string
}

func TenantScope(tenantID uint) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("tenant_id = ?", tenantID)
    }
}

// Використання:
tenantID := 42
db.Scopes(TenantScope(tenantID)).Find(&users)

Аудит-логи

type AuditLog struct {
    ID        uint
    UserID    uint
    Action    string
    TableName string
    RecordID  uint
    Changes   string // JSON
    CreatedAt time.Time
}

type User struct {
    gorm.Model
    Name  string
    Email string
}

func (u *User) AfterCreate(tx *gorm.DB) error {
    return tx.Create(&AuditLog{
        UserID:    getCurrentUserID(tx),
        Action:    "CREATE",
        TableName: "users",
        RecordID:  u.ID,
        Changes:   fmt.Sprintf(`{"name": "%s", "email": "%s"}`, u.Name, u.Email),
    }).Error
}

func (u *User) AfterUpdate(tx *gorm.DB) error {
    changes := tx.Statement.Changed()
    changesJSON, _ := json.Marshal(changes)
    
    return tx.Create(&AuditLog{
        UserID:    getCurrentUserID(tx),
        Action:    "UPDATE",
        TableName: "users",
        RecordID:  u.ID,
        Changes:   string(changesJSON),
    }).Error
}
Рекомендації для промислового коду:
  1. Завжди використовуйте WithContext() для таймаутів та трейсингу.
  2. Індексуйте зовнішні ключі та колонки у WHERE/JOIN.
  3. Використовуйте Scopes для DRY принципу.
  4. Увімкніть PrepareStmt для high-frequency запитів.
  5. Логуйте повільні запити (SlowThreshold).
  6. Тестуйте з реальною БД, не лише з моками.
  7. Використовуйте міграції для production змін схеми.
  8. Обробляйте ErrRecordNotFound явно.

Callbacks система — розширення GORM

Хуки (hooks) — це методи моделей, але callbacks — це глобальні обробники, які можуть модифікувати поведінку GORM для всіх моделей.

Життєвий цикл запиту

Кожна операція GORM проходить через серію callbacks:

Create:
  BeforeSave → BeforeCreate → Create → AfterCreate → AfterSave

Query:
  BeforeQuery → Query → AfterQuery

Update:
  BeforeSave → BeforeUpdate → Update → AfterUpdate → AfterSave

Delete:
  BeforeDelete → Delete → AfterDelete

Row:
  Row → AfterFind

Реєстрація глобальних callbacks

// Callback для логування всіх CREATE операцій
func LogCreateCallback(db *gorm.DB) {
    // Доступ до контексту запиту
    if db.Statement.Schema != nil {
        log.Printf("[CREATE] Table: %s", db.Statement.Schema.Table)
    }
}

// Реєстрація callback
db.Callback().Create().After("gorm:create").Register("log:after_create", LogCreateCallback)
// Виконається після стандартного callback "gorm:create"

// Callback для автоматичного заповнення created_by
func SetCreatedBy(db *gorm.DB) {
    if db.Statement.Schema != nil {
        // Отримати user_id з контексту
        if userID, ok := db.Statement.Context.Value("user_id").(uint); ok {
            // Якщо модель має поле CreatedBy
            if field := db.Statement.Schema.LookUpField("CreatedBy"); field != nil {
                field.Set(db.Statement.Context, db.Statement.ReflectValue, userID)
            }
        }
    }
}

// Реєстрація перед створенням
db.Callback().Create().Before("gorm:create").Register("custom:set_created_by", SetCreatedBy)

Видалення callback

// Видалити стандартний callback
db.Callback().Create().Remove("gorm:create")

// Видалити власний callback
db.Callback().Create().Remove("custom:set_created_by")

Заміна callback

// Замінити стандартний callback власною логікою
db.Callback().Create().Replace("gorm:create", func(db *gorm.DB) {
    // Власна логіка створення
    log.Println("Custom create logic")
    
    // Викликати оригінальний, якщо потрібно
    // або реалізувати повністю свій
})

Порядок виконання callbacks

// Before — виконати перед
db.Callback().Create().Before("gorm:create").Register("custom:before", myCallback)

// After — виконати після
db.Callback().Create().After("gorm:create").Register("custom:after", myCallback)

// Match — замінити існуючий
db.Callback().Create().Match("gorm:create").Register("custom:override", myCallback)

Реальний приклад: автоматичний UUID

// Callback для автоматичної генерації UUID
func GenerateUUID(db *gorm.DB) {
    if db.Statement.Schema != nil {
        for _, field := range db.Statement.Schema.Fields {
            // Шукаємо поле типу string з тегом "autoUUID"
            if field.DataType == "string" && field.Tag.Get("autoUUID") == "true" {
                // Генеруємо UUID, якщо поле порожнє
                fieldValue := field.ReflectValueOf(db.Statement.Context, db.Statement.ReflectValue)
                if fieldValue.String() == "" {
                    newUUID := uuid.New().String()
                    field.Set(db.Statement.Context, db.Statement.ReflectValue, newUUID)
                }
            }
        }
    }
}

// Реєстрація
db.Callback().Create().Before("gorm:create").Register("custom:generate_uuid", GenerateUUID)

// Модель з автоматичним UUID
type User struct {
    UUID  string `gorm:"primaryKey;autoUUID:true"`
    Name  string
}

Умовні callbacks

func ConditionalCallback(db *gorm.DB) {
    // Виконувати лише для певних таблиць
    if db.Statement.Schema != nil && db.Statement.Schema.Table == "users" {
        // Логіка лише для таблиці users
        log.Println("Processing users table")
    }
    
    // Виконувати лише якщо у контексті є певне значення
    if skipAudit, ok := db.Statement.Context.Value("skip_audit").(bool); ok && skipAudit {
        return // Пропустити callback
    }
    
    // Основна логіка
}

Plugins — розширення функціональності

GORM підтримує систему плагінів для додавання нової функціональності.

Інтерфейс Plugin

type Plugin interface {
    Name() string
    Initialize(*gorm.DB) error
}

Створення власного плагіна

package plugins

import (
    "log"
    "time"
    "gorm.io/gorm"
)

// Плагін для метрик продуктивності
type MetricsPlugin struct{}

func (p *MetricsPlugin) Name() string {
    return "metrics_plugin"
}

func (p *MetricsPlugin) Initialize(db *gorm.DB) error {
    // Реєстрація callbacks
    
    // Before Query
    db.Callback().Query().Before("gorm:query").Register("metrics:before_query", func(db *gorm.DB) {
        db.InstanceSet("metrics:start_time", time.Now())
    })
    
    // After Query
    db.Callback().Query().After("gorm:query").Register("metrics:after_query", func(db *gorm.DB) {
        if startTime, ok := db.InstanceGet("metrics:start_time"); ok {
            elapsed := time.Since(startTime.(time.Time))
            
            // Відправити метрики
            log.Printf("[METRICS] Query took: %v, Rows: %d", 
                elapsed, 
                db.RowsAffected,
            )
            
            // Тут можна інтегрувати Prometheus, StatsD тощо
        }
    })
    
    return nil
}

// Використання:
db.Use(&MetricsPlugin{})

Популярні плагіни

1. Prometheus плагін

import "github.com/prometheus/client_golang/prometheus"

type PrometheusPlugin struct {
    queryDuration *prometheus.HistogramVec
    queryCounter  *prometheus.CounterVec
}

func NewPrometheusPlugin() *PrometheusPlugin {
    return &PrometheusPlugin{
        queryDuration: prometheus.NewHistogramVec(
            prometheus.HistogramOpts{
                Name: "gorm_query_duration_seconds",
                Help: "Duration of GORM queries",
            },
            []string{"table", "operation"},
        ),
        queryCounter: prometheus.NewCounterVec(
            prometheus.CounterOpts{
                Name: "gorm_query_total",
                Help: "Total number of GORM queries",
            },
            []string{"table", "operation", "status"},
        ),
    }
}

func (p *PrometheusPlugin) Name() string {
    return "prometheus"
}

func (p *PrometheusPlugin) Initialize(db *gorm.DB) error {
    prometheus.MustRegister(p.queryDuration, p.queryCounter)
    
    // Реєстрація callbacks для всіх операцій
    for _, operation := range []string{"create", "query", "update", "delete"} {
        op := operation
        
        db.Callback().Create().Before("gorm:"+op).Register("prometheus:before_"+op, func(db *gorm.DB) {
            db.InstanceSet("prometheus:start_time", time.Now())
        })
        
        db.Callback().Create().After("gorm:"+op).Register("prometheus:after_"+op, func(db *gorm.DB) {
            if startTime, ok := db.InstanceGet("prometheus:start_time"); ok {
                elapsed := time.Since(startTime.(time.Time))
                table := db.Statement.Table
                status := "success"
                if db.Error != nil {
                    status = "error"
                }
                
                p.queryDuration.WithLabelValues(table, op).Observe(elapsed.Seconds())
                p.queryCounter.WithLabelValues(table, op, status).Inc()
            }
        })
    }
    
    return nil
}

2. OpenTelemetry трейсинг

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

type TracingPlugin struct {
    tracer trace.Tracer
}

func NewTracingPlugin() *TracingPlugin {
    return &TracingPlugin{
        tracer: otel.Tracer("gorm"),
    }
}

func (p *TracingPlugin) Name() string {
    return "opentelemetry"
}

func (p *TracingPlugin) Initialize(db *gorm.DB) error {
    db.Callback().Query().Before("gorm:query").Register("tracing:before", func(db *gorm.DB) {
        ctx, span := p.tracer.Start(db.Statement.Context, "gorm.query")
        db.Statement.Context = ctx
        db.InstanceSet("tracing:span", span)
        
        // Додати атрибути
        span.SetAttributes(
            attribute.String("db.table", db.Statement.Table),
            attribute.String("db.sql", db.Statement.SQL.String()),
        )
    })
    
    db.Callback().Query().After("gorm:query").Register("tracing:after", func(db *gorm.DB) {
        if span, ok := db.InstanceGet("tracing:span"); ok {
            s := span.(trace.Span)
            if db.Error != nil {
                s.RecordError(db.Error)
            }
            s.End()
        }
    })
    
    return nil
}

3. Кешування результатів

import (
    "crypto/md5"
    "encoding/json"
    "fmt"
    "time"
)

type CachePlugin struct {
    cache map[string]interface{} // Використовуйте Redis у production
    ttl   time.Duration
}

func NewCachePlugin(ttl time.Duration) *CachePlugin {
    return &CachePlugin{
        cache: make(map[string]interface{}),
        ttl:   ttl,
    }
}

func (p *CachePlugin) Name() string {
    return "cache"
}

func (p *CachePlugin) Initialize(db *gorm.DB) error {
    db.Callback().Query().Before("gorm:query").Register("cache:before", func(db *gorm.DB) {
        // Перевірити, чи запит має тег кешування
        if cacheable, ok := db.InstanceGet("cache:enabled"); ok && cacheable.(bool) {
            // Згенерувати ключ кешу
            key := p.generateCacheKey(db.Statement.SQL.String(), db.Statement.Vars)
            
            // Перевірити кеш
            if cached, found := p.cache[key]; found {
                // Встановити результат з кешу
                db.InstanceSet("cache:hit", true)
                db.InstanceSet("cache:result", cached)
            }
        }
    })
    
    db.Callback().Query().After("gorm:query").Register("cache:after", func(db *gorm.DB) {
        // Якщо був cache hit, пропустити
        if hit, ok := db.InstanceGet("cache:hit"); ok && hit.(bool) {
            return
        }
        
        // Зберегти результат у кеші
        if cacheable, ok := db.InstanceGet("cache:enabled"); ok && cacheable.(bool) {
            key := p.generateCacheKey(db.Statement.SQL.String(), db.Statement.Vars)
            p.cache[key] = db.Statement.Dest
            
            // Встановити TTL (у production використовуйте Redis EXPIRE)
            go func() {
                time.Sleep(p.ttl)
                delete(p.cache, key)
            }()
        }
    })
    
    return nil
}

func (p *CachePlugin) generateCacheKey(sql string, vars []interface{}) string {
    data, _ := json.Marshal(map[string]interface{}{
        "sql":  sql,
        "vars": vars,
    })
    return fmt.Sprintf("%x", md5.Sum(data))
}

// Використання:
db.Use(NewCachePlugin(5 * time.Minute))

// Увімкнути кешування для запиту
var users []User
db.Set("cache:enabled", true).Find(&users)
Рекомендації для плагінів:
  1. Ізольованість: плагін не повинен конфліктувати з іншими плагінами.
  2. Продуктивність: мінімізуйте overhead callbacks.
  3. Налаштовність: дозволяйте конфігурувати плагін через опції.
  4. Документація: надайте приклади використання.
  5. Тестування: покрийте плагін тестами.

Оптимізація та найкращі практики

1. N+1 Query Problem — виявлення та вирішення

Проблема:

// ❌ N+1 queries
var users []User
db.Find(&users) // 1 запит

for _, user := range users {
    db.Model(&user).Association("Posts").Find(&user.Posts) // N запитів
}
// Всього: 1 + N запитів

Рішення:

// ✅ 2 запити
var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users
// SELECT * FROM posts WHERE user_id IN (1,2,3,...)

2. Вибір полів — зменшення трафіку

// ❌ Завантажує всі поля
db.Find(&users)

// ✅ Лише потрібні поля
db.Select("id", "name", "email").Find(&users)

// ✅ Виключити великі поля
db.Omit("avatar_data", "biography").Find(&users)

3. Пагінація для великих datasets

// ❌ Завантажує всі записи
var users []User
db.Find(&users) // Може бути мільйони рядків!

// ✅ Використовуйте Limit та Offset
db.Limit(20).Offset(40).Find(&users)

// ✅ Або cursor-based pagination
db.Where("id > ?", lastSeenID).Limit(20).Find(&users)

4. Batch операції

// ❌ Повільно
for _, user := range users {
    db.Create(&user) // N запитів
}

// ✅ Швидко
db.CreateInBatches(users, 1000) // Пакетами по 1000

5. Індекси

// Створіть індекси для:
// - WHERE умов
// - JOIN колонок
// - ORDER BY полів
// - Foreign keys

type User struct {
    Email  string `gorm:"uniqueIndex"`      // Часто використовується у WHERE
    Status string `gorm:"index"`            // Фільтрація за статусом
    City   string `gorm:"index:idx_location"` // Композитний індекс
    State  string `gorm:"index:idx_location"`
}

6. Connection Pool налаштування

sqlDB, err := db.DB()

// Максимум відкритих з'єднань
sqlDB.SetMaxOpenConns(100)

// Максимум idle з'єднань
sqlDB.SetMaxIdleConns(10)

// Максимальний час життя з'єднання
sqlDB.SetConnMaxLifetime(time.Hour)

// Максимальний час простою
sqlDB.SetConnMaxIdleTime(10 * time.Minute)

7. Prepared Statements

// Увімкнути для повторюваних запитів
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    PrepareStmt: true,
})

// Кешує підготовлені запити для покращення продуктивності

8. Моніторинг повільних запитів

newLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{
        SlowThreshold: 200 * time.Millisecond, // Логувати запити > 200ms
        LogLevel:      logger.Warn,
    },
)

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: newLogger,
})
Типові помилки продуктивності:
  1. N+1 queries — найпоширеніша проблема. Завжди використовуйте Preload().
  2. **SELECT *** — завантаження всіх полів, навіть непотрібних.
  3. Відсутність індексів — запити без індексів стають повільними на великих таблицях.
  4. Завантаження всіх даних — використовуйте пагінацію або FindInBatches.
  5. Ігнорування connection pool — невірні налаштування можуть виснажити БД.

Порівняльна таблиця: database/sql vs sqlx vs GORM

Характеристикаdatabase/sqlsqlxGORM
Рівень абстракціїНизькийСереднійВисокий
Написання SQLВручнуВручнуАвтоматично
Безпека від SQL-ін'єкційЯкщо використовувати ?ТакТак
Сканування у структуриВручну Scan()Автоматично через теги dbАвтоматично
Зв'язки (Relations)Вручну через JOINВручну через JOINАвтоматично (Preload)
МіграціїНемаєНемаєAutoMigrate, Migrator API
Хуки життєвого циклуНемаєНемаєТак (Before/After)
ТранзакціїBeginTx()BeginTxx()Begin(), Transaction()
ПродуктивністьМаксимальнаВисокаСередня (overhead ORM)
Складність кодуБагато шаблонного кодуПомірнаМінімальна
Контроль над SQLПовнийПовнийОбмежений (але є Raw)
Підтримка СУБДЧерез драйвериЧерез драйвериPostgreSQL, MySQL, SQLite, SQL Server
Крива навчанняНизькаНизькаСередня
Розмір бібліотекиСтандартна (вбудована)Мала (~500 KB)Велика (~5 MB)

Рекомендації щодо вибору

Використовуйте database/sql + pgx, коли:

  • Потрібна максимальна продуктивність (high-frequency trading, real-time systems)
  • Складні аналітичні запити, оконні функції, CTE
  • Критичні системи (фінанси, платежі)
  • Команда має сильні SQL-навички

Використовуйте sqlx, коли:

  • Хочете зручність без overhead ORM
  • Пишете багато SQL вручну
  • Потрібен контроль над запитами, але без шаблонного коду
  • Середні проєкти (10-100k LOC)

Використовуйте GORM, коли:

  • Швидка розробка (MVP, прототипи, стартапи)
  • Багато стандартних CRUD операцій
  • Складні зв'язки між таблицями
  • Команда менш досвідчена у SQL
  • Потрібна портативність між СУБД
  • Великі проєкти з багатьма моделями (100+ таблиць)

Гібридний підхід (рекомендовано):

// Використовуйте GORM для 80% операцій
db.Where("status = ?", "active").Preload("Posts").Find(&users)

// Складні запити через Raw SQL
db.Raw(`
    WITH ranked_users AS (
        SELECT u.*, ROW_NUMBER() OVER (PARTITION BY city ORDER BY score DESC) as rank
        FROM users u
    )
    SELECT * FROM ranked_users WHERE rank <= 10
`).Scan(&topUsers)

// Критичні операції через чистий database/sql
sqlDB, _ := db.DB()
tx, _ := sqlDB.Begin()
// ... низькорівневі операції
tx.Commit()
Copyright © 2026