Go × GORM × PostgreSQL

Bộ bài giảng đầy đủ — dành cho học viên đã biết MySQL cơ bản
Go 1.21+ GORM v2 PostgreSQL 15+ So sánh MySQL
0
Cài đặt & Kết nối PostgreSQL
Cài package, kết nối, so sánh DSN với MySQL
🗺️ Kiến trúc tổng quan
Go App ──▶ GORM v2 ──▶ pgx driver ──▶ PostgreSQL Server
GORM viết một lần — chạy được MySQL, PostgreSQL, SQLite, SQL Server. Khi đổi DB chỉ cần đổi driver.
📦 Cài gói
Terminal
go mod init school-app

# GORM core
go get gorm.io/gorm

# Driver PostgreSQL (dùng pgx — hiệu năng cao hơn lib/pq)
go get gorm.io/driver/postgres
🐘 GORM dùng pgx bên dưới — đây là driver PostgreSQL hiện đại nhất cho Go, hỗ trợ đầy đủ kiểu PG như JSONB, UUID, Array.
🔌 So sánh chuỗi kết nối DSN
🐬 MySQL (cũ)
// Định dạng: user:pass@tcp(host:port)/db
dsn := "root:pass@tcp(127.0.0.1:3306)/school?charset=utf8mb4&parseTime=True&loc=Local"

db, err := gorm.Open(
  mysql.Open(dsn),
  &gorm.Config{},
)
🐘 PostgreSQL (mới)
// Định dạng: key=value (dễ đọc hơn)
dsn := "host=localhost user=postgres password=pass dbname=school port=5432 sslmode=disable TimeZone=Asia/Ho_Chi_Minh"

db, err := gorm.Open(
  postgres.Open(dsn),
  &gorm.Config{},
)
Điểm khác biệt: PG dùng cú pháp key=value thay vì URL-style của MySQL. Port mặc định PG là 5432, MySQL là 3306.
⚙️ Kết nối đầy đủ với Connection Pool
database/db.go
package database

import (
  "gorm.io/driver/postgres"
  "gorm.io/gorm"
  "gorm.io/gorm/logger"
  "time"
)

func Connect() *gorm.DB {
  dsn := "host=localhost user=postgres password=secret dbname=school port=5432 sslmode=disable TimeZone=Asia/Ho_Chi_Minh"

  db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Info), // in SQL khi dev
  })
  if err != nil {
    log.Fatal("Không kết nối được PostgreSQL:", err)
  }

  sqlDB, _ := db.DB()
  sqlDB.SetMaxIdleConns(10)
  sqlDB.SetMaxOpenConns(100)
  sqlDB.SetConnMaxLifetime(time.Hour)

  return db
}
📋 Bảng so sánh nhanh MySQL ↔ PostgreSQL
Tính năngMySQLPostgreSQL
Port mặc định33065432
Auto incrementAUTO_INCREMENTSERIAL hoặc BIGSERIAL
Kiểu chuỗi lớnTEXT, LONGTEXTTEXT (không giới hạn)
JSONJSONJSON + JSONB (có index!)
UUIDphải tự xử lýkiểu native UUID
Arraykhông cónative INTEGER[], TEXT[]
Case sensitivemặc định khôngmặc định có (ILIKE để không phân biệt)
Backtick`table_name`"table_name" (dùng nháy kép)
BooleanTINYINT(1)kiểu native BOOLEAN
Limit offsetLIMIT x OFFSET yLIMIT x OFFSET y (giống)
✏️ Bài tập 0
  1. Cài PostgreSQL local (hoặc dùng Docker: docker run -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres).
  2. Tạo project gorm-pg-demo, cài GORM + driver postgres.
  3. Viết hàm Connect() kết nối đến database school.
  4. In ra "PostgreSQL kết nối thành công!" khi không có lỗi.
  5. Bonus: So sánh — sửa lại code dùng MySQL driver (chỉ cần đổi import + dsn), thấy phần GORM không đổi gì.
1
Định nghĩa Model
Struct, tags, kiểu dữ liệu đặc trưng PostgreSQL
📐 gorm.Model & Convention
models/student.go
type Student struct {
  gorm.Model          // tự có: ID, CreatedAt, UpdatedAt, DeletedAt
  Name    string  `gorm:"size:100;not null"`
  Age     int     `gorm:"check:age > 0 AND age < 100"`
  Email   string  `gorm:"uniqueIndex;size:200"`
  Score   float64 `gorm:"default:0;check:score >= 0 AND score <= 10"`
  ClassID uint
}
// Convention: struct "Student" → bảng "students" (tự động snake_case + plural)
🆕 Kiểu dữ liệu đặc trưng PostgreSQL PG only
🐬 MySQL — không có kiểu này
// MySQL không có UUID native
type Product struct {
  ID   string `gorm:"primaryKey;size:36"`
  // phải tự generate UUID string

  Tags string  // lưu JSON dưới dạng text
  // Không có Array native
}
🐘 PostgreSQL — kiểu native mạnh hơn
import "github.com/google/uuid"

type Product struct {
  ID   uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
  // PG tự sinh UUID — không cần code Go!

  Tags    pq.StringArray `gorm:"type:text[]"`
  Ratings pq.Int64Array  `gorm:"type:integer[]"`
  Meta    datatypes.JSON `gorm:"type:jsonb"`
}
🐘 Cần cài thêm: go get github.com/lib/pq (pq.StringArray) và go get gorm.io/datatypes (datatypes.JSON).
🏷️ Toàn bộ struct tags quan trọng
TagÝ nghĩaVí dụ PG
columnTên cột tùy chỉnhgorm:"column:student_name"
typeKiểu SQL cụ thểgorm:"type:uuid", gorm:"type:jsonb"
sizeĐộ dài VARCHARgorm:"size:255"
primaryKeyKhoá chínhgorm:"primaryKey"
not nullBắt buộcgorm:"not null"
uniqueGiá trị duy nhấtgorm:"unique"
uniqueIndexUnique + có indexgorm:"uniqueIndex"
indexIndex thườnggorm:"index"
defaultGiá trị mặc địnhgorm:"default:gen_random_uuid()"
checkRàng buộc CHECKgorm:"check:score >= 0"
-Bỏ qua fieldgorm:"-"
embeddedNhúng structgorm:"embedded;embeddedPrefix:addr_"
serializer:jsonTự marshal/unmarshal JSONgorm:"serializer:json"
commentBình luận cộtgorm:"comment:Điểm học sinh"
🔑 Tên bảng tùy chỉnh & Composite PK
models/enrollment.go
// Đặt tên bảng tùy ý
func (Student) TableName() string { return "hs_students" }

// Composite primary key (bảng trung gian)
type Enrollment struct {
  StudentID uint    `gorm:"primaryKey"`
  SubjectID uint    `gorm:"primaryKey"`
  Score     float64
  EnrolledAt time.Time
}

// Embedded struct — tái sử dụng fields
type Address struct {
  Street string
  City   string
}
type Teacher struct {
  gorm.Model
  Name    string
  Address Address `gorm:"embedded;embeddedPrefix:addr_"`
  // → cột: addr_street, addr_city
}
✏️ Bài tập 1
  1. Tạo model Student với các field: name (not null), age, email (uniqueIndex), score (default 0, check 0-10).
  2. Tạo model Product dùng UUID làm PK (default gen_random_uuid()).
  3. Thêm field Tags pq.StringArray kiểu text[] vào Product — đây là kiểu PG không có trong MySQL.
  4. Tạo embedded struct Address và nhúng vào Teacher với prefix addr_.
2
Create — Tạo bản ghi
Insert single, batch, selected fields, FirstOrCreate, Upsert
➕ Tạo một bản ghi
create.go
student := Student{
  Name:  "Nguyễn Văn An",
  Age:   16,
  Email: "an@school.edu.vn",
  Score: 8.5,
}
result := db.Create(&student)

if result.Error != nil {
  log.Println("Lỗi:", result.Error)
}

// Sau Create, GORM tự điền ID vào struct!
fmt.Println("ID vừa tạo:", student.ID)
fmt.Println("Rows affected:", result.RowsAffected)
ℹ️ Luôn truyền pointer (&student) — GORM cần ghi ID trả về vào struct.
🆕 RETURNING — PostgreSQL trả về nhiều field PG only
🐬 MySQL — chỉ lấy được LastInsertId
// MySQL chỉ trả về ID sau INSERT
db.Create(&student)
fmt.Println(student.ID) // được
// CreatedAt phải query lại mới có
🐘 PostgreSQL — RETURNING trả về bất kỳ field nào
// PG có thể lấy ngay CreatedAt sau INSERT
db.Clauses(clause.Returning{}).
  Create(&student)
// student.ID, student.CreatedAt, student.Score... đều được điền!

// Chỉ lấy 1 số fields
db.Clauses(clause.Returning{
  Columns: []clause.Column{{Name: "id"}, {Name: "created_at"}},
}).Create(&student)
📦 Batch Insert & FirstOrCreate
create.go
// Tạo nhiều bản ghi
students := []Student{
  {Name: "Trần Thị Bích", Age: 15, Email: "bich@school.edu.vn"},
  {Name: "Lê Văn Cường",  Age: 17, Email: "cuong@school.edu.vn"},
  {Name: "Phạm Thị Dung", Age: 16, Email: "dung@school.edu.vn"},
}
db.Create(&students)

// Batch 100 / lần — tốt cho dữ liệu lớn
db.CreateInBatches(&students, 100)

// FirstOrCreate — tìm theo email, không có thì tạo
var s Student
db.Attrs(Student{Name: "Hoàng Em", Age: 16}).
  FirstOrCreate(&s, Student{Email: "em@school.edu.vn"})
🔄 Upsert — Insert hoặc Update PG mạnh hơn MySQL
🐬 MySQL — ON DUPLICATE KEY
// MySQL dùng ON DUPLICATE KEY UPDATE
db.Clauses(clause.OnConflict{
  Columns: []clause.Column{
    {Name: "email"},
  },
  DoUpdates: clause.AssignmentColumns(
    []string{"name", "score"},
  ),
}).Create(&student)
🐘 PostgreSQL — ON CONFLICT DO UPDATE
// PG: giống cú pháp, nhưng chuẩn SQL hơn
db.Clauses(clause.OnConflict{
  Columns: []clause.Column{
    {Name: "email"},
  },
  DoUpdates: clause.AssignmentColumns(
    []string{"name", "score"},
  ),
}).Create(&student)

// Hoặc bỏ qua nếu conflict
db.Clauses(clause.OnConflict{
  DoNothing: true,
}).Create(&student)
✏️ Bài tập 2
  1. Tạo 1 học sinh và in ra ID + CreatedAt nhận được từ clause.Returning{}.
  2. Tạo batch 5 học sinh bằng CreateInBatches, batch size 2.
  3. Dùng FirstOrCreate — chạy 2 lần với cùng email, quan sát lần 2 không tạo mới.
  4. Bonus PG: Implement Upsert — import điểm học sinh: nếu email đã có thì cập nhật score, chưa có thì tạo mới.
3
Read — Truy vấn dữ liệu
First/Find, Where, Order/Limit/Offset, ILIKE, JSONB, Array operators
🔍 First, Last, Find, Take
query.go
var s  Student
var ss []Student

db.First(&s, 1)          // SELECT * FROM students WHERE id=1 ORDER BY id LIMIT 1
db.Last(&s)              // ORDER BY id DESC LIMIT 1
db.Take(&s, 1)           // không có ORDER BY — nhanh hơn
db.Find(&ss)             // SELECT * FROM students
db.Find(&ss, []int{1,2,3}) // WHERE id IN (1,2,3)

// Xử lý "không tìm thấy"
result := db.First(&s, 999)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
  fmt.Println("Không tìm thấy")
}
🎛️ Where — Và so sánh LIKE vs ILIKE
🐬 MySQL — LIKE không phân biệt HOA/thường
// MySQL LIKE mặc định case-insensitive
db.Where("name LIKE ?", "%van%").
  Find(&students)
// Tìm được: "Văn An", "văn bình"...
🐘 PostgreSQL — LIKE phân biệt, dùng ILIKE
// PG: LIKE phân biệt HOA/thường!
db.Where("name LIKE ?", "%Van%").Find(&ss)
// Chỉ tìm "Van" — không tìm "van"

// Dùng ILIKE để không phân biệt (PG only)
db.Where("name ILIKE ?", "%van%").Find(&ss)
// Tìm được cả "Van", "van", "VAN"
Các điều kiện Where thông dụng
var students []Student

db.Where("age > ?", 15).Find(&students)
db.Where("score BETWEEN ? AND ?", 7.0, 10.0).Find(&students)
db.Where("name ILIKE ?", "%văn%").Find(&students)   // PG: ILIKE
db.Where("age IN ?", []int{15, 16, 17}).Find(&students)
db.Not("class_id = ?", 3).Find(&students)
db.Where("age = ?", 15).Or("age = ?", 16).Find(&students)

// Map condition (không bỏ qua zero value)
db.Where(map[string]interface{}{
  "score": 0,   // sẽ được lọc, không bị bỏ qua
  "age":   16,
}).Find(&students)
⚠️ Khi dùng Struct trong Where, field có giá trị zero (0, "") bị bỏ qua. Dùng Map nếu cần lọc giá trị zero.
📄 Order, Limit, Offset — Phân trang
query.go
page, size := 2, 10
var students []Student
var total    int64

// Đếm tổng
db.Model(&Student{}).Count(&total)

// Lấy trang
db.Order("score DESC").Order("name ASC").
  Limit(size).Offset((page-1)*size).
  Find(&students)

fmt.Printf("Trang %d/%d — %d học sinh\n", page, (total+int64(size)-1)/int64(size), int64(len(students)))
🎯 Select, Pluck, Scan vào DTO
query.go
// Chỉ lấy cột cần thiết
var students []Student
db.Select("name, score").Find(&students)

// DTO — struct riêng để nhận kết quả
type StudentDTO struct {
  Name  string
  Score float64
}
var dtos []StudentDTO
db.Model(&Student{}).Select("name, score").Scan(&dtos)

// Pluck — lấy 1 cột thành slice
var names []string
db.Model(&Student{}).Pluck("name", &names)
🔗 Joins, Group, Having
query.go
type Report struct {
  ClassName string
  Total     int64
  AvgScore  float64
}
var reports []Report

db.Model(&Student{}).
  Select("classes.name as class_name, COUNT(students.id) as total, AVG(students.score) as avg_score").
  Joins("JOIN classes ON classes.id = students.class_id").
  Group("classes.name").
  Having("AVG(students.score) > ?", 7.0).
  Order("avg_score DESC").
  Scan(&reports)
🆕 Query JSONB — Chỉ có ở PostgreSQL PG only
🐘 PostgreSQL có kiểu JSONB — lưu JSON nhưng có thể đánh index và query trực tiếp trong SQL. MySQL chỉ có JSON không có index được.
query_jsonb.go
// Model có field JSONB
type Product struct {
  gorm.Model
  Name  string
  Meta  datatypes.JSON `gorm:"type:jsonb"`
  // Meta lưu: {"color": "red", "size": "XL", "inStock": true}
}

// Query theo key trong JSONB (PG syntax)
var products []Product

// Lấy sản phẩm màu đỏ
db.Where("meta->>'color' = ?", "red").Find(&products)

// Lấy sản phẩm đang còn hàng
db.Where("(meta->>'inStock')::boolean = true").Find(&products)

// Dùng datatypes helper (gorm.io/datatypes)
db.Where(datatypes.JSONQuery("meta").Equals("red", "color")).Find(&products)
💡 PG JSON operators: -> trả về JSON, ->> trả về text. Ví dụ: meta->'color' = "red" (JSON), meta->>'color' = red (string).
✏️ Bài tập 3
  1. Lấy tất cả học sinh có tên chứa "văn" dùng ILIKE (thử lại với LIKE để thấy sự khác biệt).
  2. Phân trang: lấy trang 2, mỗi trang 5 học sinh, kèm tổng số bản ghi.
  3. Thống kê số học sinh và điểm trung bình theo từng lớp, chỉ lấy lớp có điểm TB >= 7.
  4. Bonus PG: Tạo Product có field Meta JSONB, lưu một vài sản phẩm, rồi query theo meta->>'color'.
4
Update — Cập nhật dữ liệu
Save, Update, Updates, gorm.Expr, RETURNING
💾 Save vs Updates — Phân biệt quan trọng
⚠️ Save — Update TẤT CẢ fields
var s Student
db.First(&s, 1)
s.Score = 9.5

// Cập nhật TẤT CẢ columns!
db.Save(&s)
// SQL: UPDATE students SET name=..., age=...,
//      score=9.5, email=..., ... WHERE id=1
// Nguy hiểm nếu có field nào = 0 / ""
✅ Updates — Chỉ update cột được chỉ định
var s Student
db.First(&s, 1)

// Chỉ update score — an toàn hơn
db.Model(&s).Update("score", 9.5)
// SQL: UPDATE students SET score=9.5,
//      updated_at=NOW() WHERE id=1

// Nhiều cột dùng map (kể cả zero value)
db.Model(&s).Updates(map[string]interface{}{
  "score": 9.5, "name": "Tên mới",
})
🧮 gorm.Expr — Biểu thức SQL
update.go
// Cộng thêm điểm (dùng giá trị hiện tại trong DB)
db.Model(&Student{}).Where("id = ?", 1).
  Update("score", gorm.Expr("score + ?", 0.5))

// Tăng điểm tất cả học sinh lớp 1 lên 10%
db.Model(&Student{}).Where("class_id = ?", 1).
  Updates(map[string]interface{}{
    "score": gorm.Expr("LEAST(score * 1.1, 10)"), // LEAST: không vượt 10
  })
🐘 PG có hàm LEAST(a, b)GREATEST(a, b) rất tiện. MySQL cũng có nhưng ít biết hơn.
🆕 Update + RETURNING PG only
update.go
var updated Student

// Cập nhật và lấy lại bản ghi sau khi update — 1 round trip!
db.Model(&Student{}).Where("id = ?", 1).
  Clauses(clause.Returning{}).
  Updates(Student{Score: 9.5, Name: "Tên mới"})

// MySQL: phải UPDATE rồi SELECT riêng = 2 round trips
🎯 Select & Omit khi Update
update.go
var s Student
db.First(&s, 1)
s.Name = "Tên mới"; s.Score = 10.0; s.Age = 20

// Chỉ update Name, Score — bỏ qua Age
db.Model(&s).Select("Name", "Score").Updates(&s)

// Update tất cả TRỪ Age
db.Model(&s).Omit("Age").Updates(&s)
✏️ Bài tập 4
  1. Dùng Model + Update để cập nhật score của học sinh id=1 thành 9.5.
  2. Tăng score của tất cả học sinh lớp 2 lên 0.5 điểm bằng gorm.Expr, không vượt quá 10 (dùng LEAST).
  3. Cập nhật Name + Email của học sinh id=3 nhưng không được đụng Score — dùng Select.
  4. Bonus PG: Dùng clause.Returning{} để lấy lại bản ghi sau update trong 1 lệnh.
5
Delete — Xoá dữ liệu
Soft delete, Hard delete, Unscoped, Restore
🗑️ Soft Delete vs Hard Delete
Soft Delete Set deleted_at = NOW() — dữ liệu vẫn còn trong DB, có thể khôi phục
Hard Delete DELETE FROM ... — xoá vĩnh viễn, không khôi phục được
Model có gorm.Model (có field DeletedAt) → tự động dùng Soft Delete.
delete.go
// ── Soft Delete ──
var s Student
db.First(&s, 1)
db.Delete(&s)
// SQL: UPDATE students SET deleted_at=NOW() WHERE id=1

// Xoá theo điều kiện
db.Where("score < ?", 3.0).Delete(&Student{})

// Xoá theo nhiều ID
db.Delete(&Student{}, []int{1, 2, 3})

// ── Hard Delete vĩnh viễn ──
db.Unscoped().Delete(&s)
// SQL: DELETE FROM students WHERE id=1
🔭 Query & Khôi phục bản ghi đã xoá mềm
delete.go
var students []Student

// Mặc định GORM tự thêm: WHERE deleted_at IS NULL
db.Find(&students)  // chỉ học sinh chưa xoá

// Lấy cả đã xoá lẫn chưa xoá
db.Unscoped().Find(&students)

// Chỉ lấy bản ghi đã xoá mềm
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&students)

// Khôi phục — set deleted_at về NULL
var s Student
db.Unscoped().First(&s, 1)
db.Unscoped().Model(&s).Update("deleted_at", nil)
🆕 DELETE + RETURNING PG only
🐬 MySQL — query trước rồi mới xoá
// Phải query trước để lưu thông tin
var s Student
db.First(&s, 1)        // 1 query
name := s.Name
db.Delete(&s)           // 1 query
log.Println("Xoá:", name)
🐘 PostgreSQL — DELETE RETURNING trong 1 lệnh
// PG: xoá và lấy lại thông tin — 1 query!
var deleted Student
db.Clauses(clause.Returning{}).
  Delete(&deleted, 1)

log.Println("Đã xoá:", deleted.Name)
✏️ Bài tập 5
  1. Soft delete học sinh id=5, rồi query lại — xác nhận không thấy.
  2. Dùng Unscoped để tìm lại bản ghi vừa xoá.
  3. Khôi phục học sinh đó.
  4. Bonus PG: Xoá và lấy tên học sinh bị xoá trong 1 lệnh bằng clause.Returning{}.
6
Associations — Quan hệ giữa bảng
HasOne, HasMany, BelongsTo, Many2Many, Preload
🗂️ Sơ đồ các kiểu quan hệ
HasOne1 User có 1 Profile (khoá ngoại ở Profile)
HasMany1 Class có nhiều Student (khoá ngoại ở Student)
BelongsToStudent thuộc về Class (khoá ngoại ở Student)
Many2ManyStudent ↔ Subject (bảng trung gian tự động)
🗃️ HasMany + BelongsTo
models.go
type Class struct {
  gorm.Model
  Name     string
  Teacher  string
  Students []Student  // HasMany — GORM biết ClassID là khoá ngoại
}

type Student struct {
  gorm.Model
  Name    string
  Score   float64
  ClassID uint   // khoá ngoại
  Class   Class  // BelongsTo
}

// Tạo lớp kèm học sinh trong 1 lệnh
db.Create(&Class{
  Name: "10A", Teacher: "Thầy Minh",
  Students: []Student{
    {Name: "An", Score: 8.5},
    {Name: "Bình", Score: 9.0},
  },
})
🔄 Many to Many
models.go
type Student struct {
  gorm.Model
  Name     string
  Subjects []Subject `gorm:"many2many:student_subjects;"`
}

type Subject struct {
  gorm.Model
  Name     string
  Students []Student `gorm:"many2many:student_subjects;"`
}
// PG tự tạo bảng: student_subjects(student_id BIGINT, subject_id BIGINT)
// Dùng kiểu BIGINT (không phải INT như MySQL mặc định)

// Thêm môn học cho học sinh
var s Student
db.First(&s, 1)
db.Model(&s).Association("Subjects").Append(&Subject{Name: "Toán"})
⚡ Preload — Load association kèm theo
query.go
var class  Class
var classes []Class

// Load kèm học sinh
db.Preload("Students").First(&class, 1)

// Preload có điều kiện — chỉ load học sinh giỏi
db.Preload("Students", "score >= ?", 8.0).Find(&classes)

// Preload lồng nhau: Class → Students → Subjects
db.Preload("Students.Subjects").Find(&classes)

// Load tất cả associations
db.Preload(clause.Associations).Find(&classes)

// Association Mode — quản lý liên kết
var s Student
db.First(&s, 1)
count := db.Model(&s).Association("Subjects").Count()
db.Model(&s).Association("Subjects").Delete(&Subject{ID: 2})
db.Model(&s).Association("Subjects").Replace([]Subject{{Name: "Hoá"}})
⚠️ N+1 Problem — Lỗi hiệu năng phổ biến
❌ SAI — N+1 queries
var classes []Class
db.Find(&classes)  // 1 query

for _, c := range classes {
  fmt.Println(c.Students)
  // ↑ mỗi lần này = 1 query!
  // 10 lớp = 10 queries thêm = N+1
}
✅ ĐÚNG — Dùng Preload
var classes []Class
// Preload = 2 queries tổng (không phải N+1)
db.Preload("Students").Find(&classes)

for _, c := range classes {
  fmt.Println(c.Students)
  // Không query thêm — đã load sẵn!
}
✏️ Bài tập 6
  1. Tạo model Class (HasMany Student), Student (BelongsTo Class), Subject (Many2Many Student).
  2. Tạo 1 lớp kèm 3 học sinh trong 1 lần Create.
  3. Dùng Preload("Students") để lấy lớp kèm danh sách học sinh. In ra từng tên.
  4. Thêm 2 môn học cho học sinh id=1 bằng Association("Subjects").Append.
  5. Bonus: Preload lồng nhau Students.Subjects — lấy tất cả lớp kèm học sinh kèm môn học mỗi người.
7
Hooks / Callbacks
BeforeCreate, AfterCreate, BeforeSave, AfterFind, BeforeDelete
⚙️ Thứ tự Hooks
Thao tácTrướcSau
CreateBeforeSaveBeforeCreateAfterCreateAfterSave
UpdateBeforeSaveBeforeUpdateAfterUpdateAfterSave
DeleteBeforeDeleteAfterDelete
QueryAfterFind
⚠️ Trả về error trong hook sẽ hủy toàn bộ thao tác và rollback (nếu có transaction).
🪝 Ví dụ thực tế đầy đủ
models/student.go
type Student struct {
  gorm.Model
  Name     string
  Email    string
  Password string
  Score    float64
}

// 1. Validate + chuẩn hoá trước khi lưu
func (s *Student) BeforeSave(tx *gorm.DB) error {
  if s.Score < 0 || s.Score > 10 {
    return errors.New("điểm phải từ 0 đến 10")
  }
  // Chuẩn hoá email — chữ thường, bỏ khoảng trắng
  s.Email = strings.ToLower(strings.TrimSpace(s.Email))
  // Viết hoa chữ cái đầu tên
  s.Name  = cases.Title(language.Vietnamese).String(s.Name)
  return nil
}

// 2. Hash mật khẩu trước khi tạo
func (s *Student) BeforeCreate(tx *gorm.DB) error {
  if !strings.HasPrefix(s.Password, "$2a$") {
    hashed, err := bcrypt.GenerateFromPassword([]byte(s.Password), 12)
    if err != nil { return err }
    s.Password = string(hashed)
  }
  return nil
}

// 3. Ghi log sau khi tạo
func (s *Student) AfterCreate(tx *gorm.DB) error {
  log.Printf("[NEW] Học sinh: id=%d name=%s\n", s.ID, s.Name)
  return nil
}

// 4. Bảo vệ học sinh giỏi không bị xoá
func (s *Student) BeforeDelete(tx *gorm.DB) error {
  if s.Score >= 9.0 {
    return errors.New("không thể xoá học sinh xuất sắc")
  }
  return nil
}
✏️ Bài tập 7
  1. Viết BeforeSave tự động viết hoa chữ cái đầu của Name.
  2. Viết BeforeCreate kiểm tra email có ký tự @. không, nếu không thì lỗi.
  3. Viết AfterCreate ghi log ra console: "Đã tạo: {name} ({email})".
  4. Bonus: BeforeDelete — không cho xoá học sinh nếu score >= 9.0.
8
Transactions
Closure, Begin/Commit/Rollback, SavePoint — PG hỗ trợ tốt hơn
💳 Tại sao cần Transaction?
BEGIN UPDATE học sinh A UPDATE học sinh B COMMIT ✓
Nếu lỗi ở bước nào → ROLLBACK — hoàn tác tất cả
✅ Cách 1: Transaction Closure (khuyên dùng)
transaction.go
func enrollStudent(db *gorm.DB, studentID, classID uint) error {
  return db.Transaction(func(tx *gorm.DB) error {

    // Cập nhật lớp cho học sinh
    if err := tx.Model(&Student{}).Where("id = ?", studentID).
      Update("class_id", classID).Error; err != nil {
      return err  // tự động ROLLBACK
    }

    // Tăng số học sinh trong lớp
    if err := tx.Model(&Class{}).Where("id = ?", classID).
      Update("student_count", gorm.Expr("student_count + 1")).Error; err != nil {
      return err  // tự động ROLLBACK
    }

    return nil  // tự động COMMIT
  })
}
🔧 Cách 2: Thủ công + SavePoint
transaction.go
tx := db.Begin()
defer func() {
  if r := recover(); r != nil { tx.Rollback() }
}()

tx.Create(&Student{Name: "Học sinh 1"})

// Đánh dấu điểm khôi phục
tx.SavePoint("sp1")

tx.Create(&Student{Name: "Học sinh 2"})

// Rollback về sp1 — Học sinh 2 bị hủy, Học sinh 1 còn
tx.RollbackTo("sp1")

tx.Commit()  // chỉ có Học sinh 1
🆕 PostgreSQL — DDL trong Transaction PG only
🐬 MySQL — DDL không rollback được
// MySQL: CREATE TABLE, ALTER TABLE
// tự động COMMIT (implicit commit)
// Không rollback được!
tx := db.Begin()
tx.Exec("CREATE TABLE test ...")
tx.Rollback()
// Bảng vẫn tồn tại! ← nguy hiểm
🐘 PostgreSQL — DDL rollback được!
// PG: DDL cũng nằm trong transaction!
tx := db.Begin()
tx.Exec("CREATE TABLE test (id SERIAL)")
tx.Rollback()
// Bảng KHÔNG tồn tại — rollback hoàn hảo!

// Cực kỳ hữu ích khi viết migration
🐘 Đây là một trong những điểm PostgreSQL vượt trội so với MySQL — DDL (CREATE, ALTER, DROP) có thể rollback, giúp viết migration an toàn hơn rất nhiều.
✏️ Bài tập 8
  1. Viết hàm transferStudent(from, to, studentID uint): chuyển học sinh giữa 2 lớp, cập nhật student_count của cả 2 lớp trong 1 transaction.
  2. Mô phỏng lỗi giữa chừng, kiểm tra không có gì thay đổi.
  3. Dùng SavePoint: tạo 3 học sinh, sau khi tạo học sinh 2 thì rollback về sp1, commit — chỉ có học sinh 1 và 3.
  4. Bonus PG: Thử CREATE TABLE trong transaction rồi ROLLBACK — xác nhận bảng không tồn tại.
9
Scopes — Điều kiện tái sử dụng
Viết filter logic một lần, dùng ở nhiều nơi
♻️ Định nghĩa và kết hợp Scopes
scopes/student.go
// Học sinh xuất sắc
func Excellent(db *gorm.DB) *gorm.DB {
  return db.Where("score >= ?", 9.0)
}

// Học sinh theo lớp
func InClass(classID uint) func(*gorm.DB) *gorm.DB {
  return func(db *gorm.DB) *gorm.DB {
    return db.Where("class_id = ?", classID)
  }
}

// Phân trang
func Paginate(page, size int) func(*gorm.DB) *gorm.DB {
  return func(db *gorm.DB) *gorm.DB {
    return db.Offset((page - 1) * size).Limit(size)
  }
}

// Tìm theo tên (PG: ILIKE không phân biệt hoa/thường)
func NameContains(keyword string) func(*gorm.DB) *gorm.DB {
  return func(db *gorm.DB) *gorm.DB {
    return db.Where("name ILIKE ?", "%"+keyword+"%")
    // ILIKE: đặc trưng PG, không cần phân biệt hoa/thường
  }
}

// Sử dụng — kết hợp linh hoạt
var students []Student
db.Scopes(Excellent, InClass(2), Paginate(1, 10)).
  Order("score DESC").
  Find(&students)
💡 Scopes giúp tái sử dụng điều kiện query, tránh lặp code. Đặc biệt hữu ích khi có nhiều endpoint dùng chung filter logic.
✏️ Bài tập 9
  1. Tạo scope AgeRange(min, max int) lọc học sinh theo khoảng tuổi.
  2. Tạo scope NameContains(keyword) dùng ILIKE thay vì LIKE.
  3. Kết hợp InClass + AgeRange + Paginate trong 1 query — in ra SQL bằng Debug().
10
Raw SQL
Raw, Exec, Scan, Named Args, Window Functions
📝 Raw & Exec cơ bản
raw.go
// Raw SELECT — scan vào slice struct
var students []Student
db.Raw("SELECT * FROM students WHERE age > ? AND score > ?", 15, 7.0).Scan(&students)

// Scan vào DTO
type Report struct { ClassID uint; Count int; AvgScore float64 }
var reports []Report
db.Raw(`SELECT class_id, COUNT(*) as count, AVG(score) as avg_score FROM students GROUP BY class_id`).Scan(&reports)

// Exec — INSERT / UPDATE / DELETE thủ công
db.Exec("UPDATE students SET score = ? WHERE class_id = ?", 9.0, 2)
🆕 Window Functions — PostgreSQL mạnh hơn MySQL PG only
🐬 MySQL 8+ — có Window nhưng hạn chế
// MySQL 8+ mới có, cú pháp giống nhưng
// không hỗ trợ tất cả window functions
db.Raw(`
  SELECT name, score,
    RANK() OVER (
      PARTITION BY class_id
      ORDER BY score DESC
    ) as rank_in_class
  FROM students
`).Scan(&results)
🐘 PostgreSQL — Window Functions đầy đủ
type StudentRank struct {
  Name        string
  Score       float64
  RankInClass int
  Percentile  float64
}
var results []StudentRank

db.Raw(`
  SELECT name, score,
    RANK() OVER (
      PARTITION BY class_id ORDER BY score DESC
    ) as rank_in_class,
    PERCENT_RANK() OVER (
      ORDER BY score
    ) as percentile
  FROM students
  WHERE deleted_at IS NULL
`).Scan(&results)
🆕 CTE — Common Table Expression PG fully
raw_pg.go
// CTE: tìm học sinh có điểm cao nhất mỗi lớp
type TopStudent struct {
  ClassID uint; Name string; Score float64
}
var tops []TopStudent

db.Raw(`
  WITH ranked AS (
    SELECT *,
      ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS rn
    FROM students
    WHERE deleted_at IS NULL
  )
  SELECT class_id, name, score
  FROM ranked
  WHERE rn = 1
`).Scan(&tops)
🐘 PG hỗ trợ CTE writeable (WITH ... INSERT/UPDATE/DELETE) và Recursive CTE — MySQL 8+ mới có một phần.
🏷️ Named Arguments
raw.go
var students []Student

// Dùng @tên thay vì ? — dễ đọc hơn
db.Raw("SELECT * FROM students WHERE name ILIKE @name OR email = @email",
  map[string]interface{}{
    "name":  "%an%",
    "email": "an@school.edu.vn",
  },
).Scan(&students)
✏️ Bài tập 10
  1. Viết Raw SQL lấy top 3 học sinh điểm cao nhất mỗi lớp (dùng ROW_NUMBER() Window Function).
  2. Dùng CTE để tính xếp hạng học sinh trong toàn trường.
  3. Dùng Exec để reset điểm âm về 0.
  4. Bonus: Dùng PERCENT_RANK() để tính phân vị điểm số mỗi học sinh.
11
Migration
AutoMigrate, Migrator API, PG-specific schema
🏗️ AutoMigrate
migrate.go
// Tạo / cập nhật tất cả bảng theo struct
err := db.AutoMigrate(
  &Student{},
  &Class{},
  &Subject{},
  &Teacher{},
)
if err != nil {
  log.Fatal("Migration thất bại:", err)
}
// AutoMigrate chỉ THÊM cột / index mới, KHÔNG XOÁ cột cũ
🔬 Migrator API đầy đủ
migrate.go
m := db.Migrator()

// ── Bảng ──
m.HasTable(&Student{})           // bool
m.CreateTable(&Student{})
m.DropTable(&Student{})
m.RenameTable("students", "pupils")
m.GetTables()                   // []string

// ── Cột ──
m.HasColumn(&Student{}, "Score")
m.AddColumn(&Student{}, "Phone")
m.DropColumn(&Student{}, "Phone")
m.AlterColumn(&Student{}, "Score")
m.RenameColumn(&Student{}, "Score", "GradePoint")

// ── Index ──
m.HasIndex(&Student{}, "idx_email")
m.CreateIndex(&Student{}, "idx_email")
m.DropIndex(&Student{}, "idx_email")

// ── Constraint ──
m.HasConstraint(&Student{}, "fk_class")
m.CreateConstraint(&Student{}, "fk_class")
m.DropConstraint(&Student{}, "fk_class")
🆕 Migration an toàn với Transaction (PG) PG only
🐬 MySQL — DDL không rollback được
// MySQL: nếu migration lỗi giữa chừng
// các bước trước đó KHÔNG rollback được
// → database ở trạng thái nửa vời

func Migrate(db *gorm.DB) {
  db.AutoMigrate(&Student{})
  db.AutoMigrate(&Class{})
  // Lỗi ở đây → Student đã tạo, Class chưa
}
🐘 PostgreSQL — wrap migration trong Transaction
func Migrate(db *gorm.DB) error {
  return db.Transaction(func(tx *gorm.DB) error {
    if err := tx.AutoMigrate(&Student{}); err != nil {
      return err // ROLLBACK — không có gì thay đổi!
    }
    if err := tx.AutoMigrate(&Class{}); err != nil {
      return err
    }
    return nil // COMMIT toàn bộ
  })
}
🐘 Wrapping migration trong transaction là best practice với PostgreSQL — nếu có lỗi, schema không bị thay đổi một phần. MySQL không làm được điều này.
✏️ Bài tập 11
  1. Gọi AutoMigrate cho 4 models bên trong 1 transaction (chỉ được trên PG).
  2. Kiểm tra bảng students có tồn tại bằng HasTable.
  3. Thêm cột phone vào bảng students bằng AddColumn.
  4. Liệt kê tất cả bảng đang có bằng GetTables().
12
PostgreSQL Nâng cao
Full-Text Search, Array ops, JSONB index, Listen/Notify, Copy
🔍 Full-Text Search — Tìm kiếm văn bản PG only
🐬 MySQL — FULLTEXT index
// MySQL: FULLTEXT chỉ hỗ trợ
// tiếng Anh tốt, tiếng Việt khó
db.Where("MATCH(name) AGAINST(?)",
  "nguyen",
).Find(&students)
🐘 PostgreSQL — tsvector, GIN index
// PG: to_tsvector + to_tsquery
// Có thể cấu hình unaccent cho tiếng Việt
var students []Student
db.Raw(`
  SELECT * FROM students
  WHERE to_tsvector('simple', name)
    @@ to_tsquery('simple', ?)
`, "nguyen").Scan(&students)

// Tạo GIN index để tăng tốc (trong migration)
db.Exec(`
  CREATE INDEX IF NOT EXISTS idx_students_fts
  ON students USING GIN(to_tsvector('simple', name))
`)
📋 Array Operations PG only
array_ops.go
type Student struct {
  gorm.Model
  Name   string
  Hobbies pq.StringArray `gorm:"type:text[]"`
  Scores  pq.Float64Array `gorm:"type:float[]"`
}

// Tạo học sinh với mảng
db.Create(&Student{
  Name:    "An",
  Hobbies: pq.StringArray{"đọc sách", "bóng đá", "lập trình"},
  Scores:  pq.Float64Array{8.5, 9.0, 7.5},
})

// Query: tìm học sinh có hobby "bóng đá"
var students []Student
db.Where("? = ANY(hobbies)", "bóng đá").Find(&students)

// Query: tìm học sinh có TẤT CẢ hobby này
db.Where("hobbies @> ?", pq.StringArray{"đọc sách", "lập trình"}).Find(&students)

// Query: tìm học sinh có ÍT NHẤT 1 hobby trong danh sách
db.Where("hobbies && ?", pq.StringArray{"bóng đá", "võ thuật"}).Find(&students)
OperatorÝ nghĩaVí dụ
= ANY(arr)Phần tử có trong mảng"bóng đá" = ANY(hobbies)
arr @> arr2arr chứa tất cả phần tử arr2hobbies @> '{đọc sách}'
arr && arr2Hai mảng có phần tử chunghobbies && '{bóng đá}'
array_length(arr, 1)Độ dài mảngarray_length(hobbies, 1) > 2
🚀 COPY — Bulk insert siêu nhanh PG only
bulk_import.go
// pgx COPY — import 100,000 rows trong vài giây
// Nhanh hơn INSERT 10-50x
import "github.com/jackc/pgx/v5/pgxpool"

func BulkInsertStudents(pool *pgxpool.Pool, students []Student) error {
  rows := make([][]interface{}, len(students))
  for i, s := range students {
    rows[i] = []interface{}{s.Name, s.Age, s.Email, s.Score}
  }

  _, err := pool.CopyFrom(
    context.Background(),
    pgx.Identifier{"students"},
    []string{"name", "age", "email", "score"},
    pgx.CopyFromRows(rows),
  )
  return err
}
⚡ Session & Logger — Debug và tối ưu
debug.go
// Xem SQL được tạo ra
db.Debug().Where("score > ?", 8).Find(&students)

// DryRun — chỉ tạo SQL, không chạy
stmt := db.Session(&gorm.Session{DryRun: true}).
  Where("class_id = ?", 1).Find(&students).Statement

fmt.Println("SQL:", stmt.SQL.String())
fmt.Println("Params:", stmt.Vars)

// Prepared statement — cache để query nhanh hơn
fastDB := db.Session(&gorm.Session{PrepareStmt: true})
✏️ Bài tập 12
  1. Thêm field Hobbies pq.StringArray vào Student, tạo vài bản ghi với hobbies khác nhau.
  2. Query: tìm học sinh có hobby "bóng đá" bằng = ANY(hobbies).
  3. Query: tìm học sinh có cả 2 hobby "đọc sách" VÀ "lập trình".
  4. Dùng DryRun để in SQL của câu query phức tạp nhất bạn viết hôm nay.
  5. Bonus: Tạo GIN index trên cột hobbies, đo tốc độ trước và sau.
🏆
Project Tổng hợp — Hệ thống trường học
Kết hợp tất cả kiến thức, tận dụng tính năng đặc trưng PostgreSQL
🗂️ Schema
teachers 1──▶N classes 1──▶N students N──▶M subjects
students.hobbies = text[] (PG Array) students.metadata = jsonb (PG JSONB)
📋 Danh sách nhiệm vụ
🎯 Yêu cầu project
  1. Tạo 4 model: Teacher, Class, Student, Subject với đầy đủ quan hệ. Student có thêm Hobbies pq.StringArrayMetadata datatypes.JSON.
  2. AutoMigrate tất cả trong 1 transaction (PG feature).
  3. Seed: 3 giáo viên, 6 lớp, 30 học sinh, 8 môn học.
  4. Hàm GetClassReport(classID): tên lớp, giáo viên, danh sách học sinh + điểm, điểm TB — dùng Preload + GORM query.
  5. Hàm TopStudentsPerClass(): học sinh điểm cao nhất mỗi lớp — dùng Window Function ROW_NUMBER().
  6. Hàm SearchStudents(keyword): tìm theo tên bằng ILIKE + Scope + phân trang.
  7. Hàm FindByHobby(hobby): tìm học sinh có hobby cụ thể — dùng Array operator = ANY(hobbies).
  8. Hàm BulkImportScores([]ScoreRecord): Upsert điểm hàng loạt bằng OnConflict.
  9. Hook BeforeSave: validate email, Score 0-10. AfterCreate: log ra console.
  10. Bonus: Soft delete + restore endpoint. DELETE ... RETURNING.
  11. Super Bonus: Tạo GIN index trên hobbiesmetadata, đo hiệu năng query trước/sau.
📁 Cấu trúc đề xuất
Cấu trúc thư mục
school-app/
├── main.go
├── database/
│   └── db.go           // Connect + pool config
├── models/
│   ├── teacher.go
│   ├── class.go
│   ├── student.go      // hooks, pq.StringArray, jsonb
│   └── subject.go
├── services/
│   ├── class.go        // GetClassReport
│   ├── student.go      // TopStudents, Search, FindByHobby
│   └── import.go       // BulkImportScores (Upsert)
├── scopes/
│   └── student.go      // Excellent, InClass, Paginate, NameContains (ILIKE)
└── seed/
    └── seed.go
📌
Quick Reference
Bảng tra cứu nhanh — MySQL so với PostgreSQL
⚡ MySQL vs PostgreSQL — Điểm khác biệt quan trọng trong GORM
Tính năngMySQLPostgreSQL
Case-insensitive searchLIKE (mặc định)ILIKE phải dùng ILIKE
RETURNINGKhông cóclause.Returning{}
UUID nativeKhônggen_random_uuid()
Array typeKhôngpq.StringArray, text[]
JSONB + indexJSON (không index)JSONB + GIN index ✓
Window FunctionsMySQL 8+ (hạn chế)Đầy đủ, mạnh hơn ✓
CTE writeableKhôngWITH ... INSERT/UPDATE ✓
DDL TransactionKhông (implicit commit)Rollback được ✓
Full-text searchFULLTEXT (tiếng Anh)tsvector + GIN index ✓
Bulk COPYLOAD DATA INFILEpgx CopyFrom (nhanh hơn) ✓
🔗 Chain methods thường dùng
Chain đầy đủ
db.
  Debug().
  Model(&Student{}).
  Select("name, score").
  Joins("JOIN classes ON ...").
  Preload("Subjects").
  Where("score > ?", 8).
  Where("name ILIKE ?", "%van%").
  Scopes(Excellent).
  Order("score DESC").
  Limit(10).Offset(0).
  Find(&students)
🐘 PG-only snippets
Chỉ dùng với PostgreSQL
// ILIKE
.Where("name ILIKE ?", "%van%")

// RETURNING
.Clauses(clause.Returning{})

// Array ANY
.Where("? = ANY(hobbies)", "bóng đá")

// JSONB query
.Where("meta->>'color' = ?", "red")

// Window (Raw SQL)
.Raw("... RANK() OVER (...)")

// DDL trong transaction
tx.AutoMigrate(&T{})