← Semua pembelajaran / Go Nol → Enterprise
Fase 4 · Database & Persistensi

database/sql — pola dasar

database/sql bukan ORM. Ia lapisan tipis di atas driver, dengan connection pool di dalamnya — dan justru ketipisannya yang membuat setiap perilaku bisa kamu prediksi.

Sumber asli pkg.go.dev Resmi Rangkuman ~9 menit baca

Intisari

  • *sql.DB bukan satu koneksi — ia pool. Buat sekali, pakai bersama, jangan pernah tutup di tengah aplikasi.
  • Selalu pakai varian ...Context: QueryContext, ExecContext, QueryRowContext.
  • rows.Close() wajib, dan rows.Err() wajib diperiksa setelah loop selesai.
  • Parameter selalu lewat placeholder ($1 di Postgres). Menyambung string SQL adalah SQL injection.
  • Kolom yang bisa NULL butuh sql.Null[T] atau pointer — Scan ke string biasa akan gagal.

Membuka pool

import (
	"database/sql"
	_ "github.com/jackc/pgx/v5/stdlib"   // driver mendaftarkan diri lewat init()
)

func BukaDB(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("pgx", dsn)      // TIDAK menyambung — hanya menyiapkan pool
	if err != nil {
		return nil, fmt.Errorf("buka db: %w", err)
	}

	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(30 * time.Minute)
	db.SetConnMaxIdleTime(5 * time.Minute)

	// Sambungan pertama baru terjadi di sini — dan di sinilah kredensial
	// yang salah akhirnya ketahuan.
	ctx, batal := context.WithTimeout(ctx, 5*time.Second)
	defer batal()
	if err := db.PingContext(ctx); err != nil {
		return nil, fmt.Errorf("ping db: %w", err)
	}
	return db, nil
}

sql.Open hampir tidak pernah gagal. Ia cuma memvalidasi nama driver. Kalau host salah, sandi salah, atau security group memblokir, kamu baru tahu saat kueri pertama — yaitu saat permintaan pengguna pertama. Karena itu PingContext saat startup bukan hiasan: ia mengubah kesalahan konfigurasi jadi container yang gagal start, bukan 500 yang menetes ke pengguna.

Garis bawah di _ "github.com/jackc/pgx/v5/stdlib" berarti "impor untuk efek sampingnya" — paket itu memanggil sql.Register di init(). Tanpa impor itu, sql.Open("pgx", ...) gagal dengan "unknown driver".

Satu baris

func (r *Repo) Ambil(ctx context.Context, id int64) (Produk, error) {
	var p Produk
	err := r.db.QueryRowContext(ctx,
		`SELECT id, nama, harga, dibuat FROM produk WHERE id = $1`, id,
	).Scan(&p.ID, &p.Nama, &p.Harga, &p.Dibuat)

	switch {
	case errors.Is(err, sql.ErrNoRows):
		return Produk{}, fmt.Errorf("produk %d: %w", id, ErrTidakDitemukan)
	case err != nil:
		return Produk{}, fmt.Errorf("ambil produk %d: %w", id, err)
	}
	return p, nil
}

QueryRowContext tidak butuh Close: koneksinya dikembalikan otomatis saat Scan selesai. Ini alasan ia lebih disukai kapan pun kamu memang hanya butuh satu baris.

Banyak baris

func (r *Repo) Cari(ctx context.Context, kata string, batas int) ([]Produk, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT id, nama, harga
		FROM produk
		WHERE nama ILIKE $1 AND dihapus_pada IS NULL
		ORDER BY harga
		LIMIT $2`, "%"+kata+"%", batas)
	if err != nil {
		return nil, fmt.Errorf("cari produk: %w", err)
	}
	defer rows.Close()          // ← WAJIB. Tanpa ini koneksinya tidak kembali.

	hasil := make([]Produk, 0, batas)   // bukan nil: JSON-nya jadi [] bukan null
	for rows.Next() {
		var p Produk
		if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
			return nil, fmt.Errorf("scan produk: %w", err)
		}
		hasil = append(hasil, p)
	}

	// ← WAJIB. rows.Next() mengembalikan false BAIK saat selesai MAUPUN
	//   saat terjadi error di tengah. Tanpa baris ini, kegagalan jaringan
	//   di tengah pembacaan terlihat seperti "hasilnya memang cuma segitu".
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("baca hasil: %w", err)
	}
	return hasil, nil
}

Dua baris wajib itu adalah bug paling mahal di lapisan database Go.

  • defer rows.Close() yang hilang → koneksi tidak pernah kembali ke pool. Setelah MaxOpenConns permintaan, seluruh aplikasi berhenti melayani sambil menunggu koneksi yang tidak akan pernah bebas. Gejalanya: aplikasi berjalan normal berjam-jam lalu membeku total.
  • rows.Err() yang hilang → kegagalan di tengah pembacaan menghasilkan data yang tidak lengkap tanpa satu pun error. Laporan yang kurang barisnya, dan tidak ada yang tahu.

NULL

// Kolom yang bisa NULL tidak bisa di-Scan ke string/int biasa:
// "converting NULL to string is unsupported"

type Produk struct {
	ID        int64
	Nama      string
	Deskripsi sql.Null[string]   // Go 1.22+ generik
	Diskon    sql.Null[int64]
	Dihapus   *time.Time         // pointer: nil = NULL
}

rows.Scan(&p.ID, &p.Nama, &p.Deskripsi, &p.Diskon, &p.Dihapus)

if p.Deskripsi.Valid {
	fmt.Println(p.Deskripsi.V)
}

Cara termurah menghindari seluruh urusan ini: jangan izinkan NULL di skema. Beri kolom teks NOT NULL DEFAULT '' dan kolom angka NOT NULL DEFAULT 0 kecuali "tidak ada nilai" memang punya arti bisnis yang berbeda dari "kosong". Setiap kolom nullable menambah percabangan di setiap tempat yang membacanya.

Menulis

// INSERT dengan nilai kembalian — di Postgres pakai RETURNING,
// BUKAN LastInsertId (yang tidak didukung driver Postgres).
func (r *Repo) Buat(ctx context.Context, p *Produk) error {
	return r.db.QueryRowContext(ctx, `
		INSERT INTO produk (nama, harga)
		VALUES ($1, $2)
		RETURNING id, dibuat`, p.Nama, p.Harga,
	).Scan(&p.ID, &p.Dibuat)
}

// UPDATE — selalu periksa berapa baris yang benar-benar terpengaruh.
func (r *Repo) Ubah(ctx context.Context, p Produk) error {
	res, err := r.db.ExecContext(ctx,
		`UPDATE produk SET nama = $1, harga = $2 WHERE id = $3`,
		p.Nama, p.Harga, p.ID)
	if err != nil {
		return fmt.Errorf("ubah produk %d: %w", p.ID, err)
	}

	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return fmt.Errorf("produk %d: %w", p.ID, ErrTidakDitemukan)
	}
	return nil
}

RowsAffected() == 0 hampir selalu berarti sesuatu. Baris tidak ada, sudah dihapus, atau — di kueri yang membatasi kepemilikan — milik orang lain. Mengabaikannya berarti API-mu membalas 200 untuk operasi yang tidak terjadi. Ini juga pola otorisasi terkuat: WHERE id = $1 AND pemilik_id = $2 membuat data milik orang lain tidak pernah ditemukan, bukan sekadar ditolak (Fase 6).

SQL injection: satu aturan

// ❌ SQL injection. Selalu. Tanpa pengecualian.
q := "SELECT * FROM produk WHERE nama = '" + kata + "'"

// ✅ placeholder — nilai dikirim TERPISAH dari teks kueri
r.db.QueryContext(ctx, "SELECT * FROM produk WHERE nama = $1", kata)
DatabasePlaceholder
PostgreSQL$1, $2
MySQL / SQLite?
SQL Server@p1

Placeholder tidak bisa dipakai untuk nama tabel, nama kolom, atau arah ORDER BY. Untuk pengurutan dinamis, jangan pernah menyambung input pengguna — petakan lewat daftar putih:

urutan := map[string]string{
	"harga":   "harga ASC",
	"-harga":  "harga DESC",
	"terbaru": "dibuat DESC",
}[r.URL.Query().Get("urut")]

if urutan == "" {
	urutan = "dibuat DESC"     // default aman
}

Batch: satu perjalanan, bukan seribu

// ❌ N+1: satu kueri per produk. Di AWS, 200 produk × 2 ms latensi
//    jaringan = 400 ms yang hilang begitu saja.
for _, id := range ids {
	p, _ := repo.Ambil(ctx, id)
}

// ✅ satu kueri untuk semuanya
rows, err := db.QueryContext(ctx,
	`SELECT id, nama, harga FROM produk WHERE id = ANY($1)`,
	pq.Array(ids))

Ini bentuk N+1 di Go. Tidak ada lazy loading yang melakukannya diam-diam seperti di ORM — di Go, N+1 selalu berupa loop yang terlihat jelas di kode. Itu keuntungan: ia bisa ditemukan dengan membaca, dan bisa dijaga dengan tes yang menghitung jumlah kueri (Fase 8).

Latihan: tulis repository dengan Ambil, Cari, dan Buat untuk tabel produk di Postgres lokal. Lalu sengaja hapus defer rows.Close(), setel SetMaxOpenConns(2), dan panggil Cari tiga kali — buktikan panggilan ketiga menggantung sampai ctx habis. Itu bentuk kegagalan yang akan kamu kenali seumur hidup.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.