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

sqlc — SQL jadi kode bertipe

sqlc membaca skema dan berkas kueri SQL-mu, lalu menghasilkan kode Go: struct hasil, parameter bertipe, dan fungsi siap panggil. Kamu tetap menulis SQL — yang hilang cuma boilerplate dan salah ketik.

Sumber asli docs.sqlc.dev Resmi Rangkuman ~7 menit baca

Intisari

  • Alur kerjanya: schema.sql + query.sql → sqlc generate → kode Go bertipe.
  • Salah nama kolom, salah jumlah parameter, dan salah tipe jadi error saat generate — bukan panic di produksi.
  • Tanpa refleksi dan tanpa biaya runtime: keluarannya kode yang bisa kamu baca dan langkahi di debugger.
  • Kode hasil generate di-commit, dan CI memastikan ia sinkron dengan berkas SQL.
  • Batasnya: kueri yang bentuknya berubah-ubah (filter opsional) tetap butuh query builder atau SQL manual.

Alur kerjanya

db/
├── schema.sql        # CREATE TABLE — sumber kebenaran struktur
├── query.sql         # kueri, dengan anotasi -- name:
└── sqlc.yaml         # konfigurasi

    ↓  sqlc generate

internal/db/          # DIGENERATE — jangan diedit tangan
├── db.go
├── models.go         # struct dari schema.sql
└── query.sql.go      # fungsi dari query.sql
# sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    schema: "db/schema.sql"
    queries: "db/query.sql"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_pointers_for_null_types: true

Menulis kueri

-- db/query.sql

-- name: AmbilProduk :one
SELECT id, nama, harga, dibuat
FROM produk
WHERE id = $1 AND dihapus_pada IS NULL;

-- name: CariProduk :many
SELECT id, nama, harga
FROM produk
WHERE nama ILIKE '%' || $1 || '%'
  AND dihapus_pada IS NULL
ORDER BY harga
LIMIT $2 OFFSET $3;

-- name: BuatProduk :one
INSERT INTO produk (nama, harga)
VALUES ($1, $2)
RETURNING id, dibuat;

-- name: HapusProduk :exec
UPDATE produk SET dihapus_pada = now() WHERE id = $1;

-- name: HitungProduk :one
SELECT count(*) FROM produk WHERE dihapus_pada IS NULL;
AnotasiMenghasilkan
:one(T, error) — pgx.ErrNoRows kalau kosong
:many([]T, error)
:execerror
:execrows(int64, error) — jumlah baris terpengaruh
:batchexecVersi batch pgx
:copyfromCopyFrom untuk muat massal

Yang dihasilkannya

// internal/db/query.sql.go — DIGENERATE
type CariProdukParams struct {
	Column1 pgtype.Text `json:"column1"`
	Limit   int32       `json:"limit"`
	Offset  int32       `json:"offset"`
}

type CariProdukRow struct {
	ID    int64 `json:"id"`
	Nama  string `json:"nama"`
	Harga int64  `json:"harga"`
}

func (q *Queries) CariProduk(ctx context.Context, arg CariProdukParams) ([]CariProdukRow, error)
// Pemakaian di repository-mu
type Repo struct{ q *db.Queries }

func NewRepo(pool *pgxpool.Pool) *Repo {
	return &Repo{q: db.New(pool)}
}

func (r *Repo) Cari(ctx context.Context, kata string, batas, offset int32) ([]produk.Produk, error) {
	baris, err := r.q.CariProduk(ctx, db.CariProdukParams{
		Column1: pgtype.Text{String: kata, Valid: true},
		Limit:   batas,
		Offset:  offset,
	})
	if err != nil {
		return nil, fmt.Errorf("cari produk: %w", err)
	}

	// Petakan tipe DB ke tipe domain — jangan biarkan tipe generate
	// bocor ke seluruh aplikasi.
	hasil := make([]produk.Produk, 0, len(baris))
	for _, b := range baris {
		hasil = append(hasil, produk.Produk{ID: b.ID, Nama: b.Nama, Harga: b.Harga})
	}
	return hasil, nil
}

Beri nama parameter supaya kodenya terbaca. Column1 muncul karena kueri di atas memakai $1 di dalam ekspresi. Tulis sqlc.arg(kata) atau @kata di SQL dan hasilnya jadi field bernama Kata:

WHERE nama ILIKE '%' || sqlc.arg(kata) || '%'
LIMIT sqlc.arg(batas) OFFSET sqlc.arg(lewati);

Transaksi

func (r *Repo) BuatPesanan(ctx context.Context, p Pesanan) error {
	tx, err := r.pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	q := r.q.WithTx(tx)          // Queries yang sama, terikat ke transaksi ini

	id, err := q.BuatPesanan(ctx, ...)
	if err != nil {
		return err
	}
	for _, it := range p.Item {
		if err := q.KurangiStok(ctx, db.KurangiStokParams{...}); err != nil {
			return err
		}
	}
	return tx.Commit(ctx)
}

Menjaganya tetap sinkron

# Pasang lewat direktif tool di go.mod — versinya ikut tercatat (Fase 0)
go get -tool github.com/sqlc-dev/sqlc/cmd/sqlc
go tool sqlc generate
go tool sqlc vet          # linter kueri, termasuk EXPLAIN kalau dikonfigurasi
# Langkah CI: gagal kalau ada yang lupa generate ulang
go tool sqlc generate
git diff --exit-code internal/db

Commit kode hasil generate. Alternatifnya — generate saat build — membuat go build di laptop orang baru gagal sampai ia memasang sqlc, dan membuat kode tidak bisa dibaca di GitHub. Yang di-commit tetap dijaga benar oleh langkah CI di atas.

Kapan sqlc bukan jawabannya

KasusAlternatif
Filter opsional yang jumlahnya berubah-ubahQuery builder (squirrel), atau kueri dengan ($1::text IS NULL OR nama = $1)
Nama tabel dinamis (multi-tenant per skema)SQL manual dengan daftar putih
Pencarian dengan banyak kombinasi pengurutanBeberapa kueri bernama, satu per kombinasi yang benar-benar dipakai
Prototipe yang skemanya berubah tiap jamSQL manual dulu; masuk sqlc setelah stabil

Perbandingan singkat pendekatan lapisan data di Go:

  • database/sql / pgx manual — kendali penuh, boilerplate paling banyak, salah ketik kolom baru ketahuan saat runtime.
  • sqlc — SQL tetap SQL, bertipe, tanpa biaya runtime. Pilihan default roadmap ini.
  • GORM — paling cepat untuk CRUD, menyembunyikan SQL, punya biaya refleksi dan kejutan performa (materi berikutnya).

Latihan: siapkan schema.sql dan empat kueri untuk tabel produk, jalankan sqlc generate, dan panggil dari repository-mu. Lalu ganti nama satu kolom di schema.sql tanpa mengubah query.sql dan jalankan generate lagi — baca errornya. Itu kelas bug yang biasanya baru ketahuan di produksi.

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