Membuka koneksi dan menyetel pool hanyalah persiapan — pekerjaan sehari-hari ada di eksekusi query. database/sql menyediakan tiga method eksekusi dengan hasil berbeda, dan semuanya menerima parameter lewat pola parameter binding: nilai dikirim sebagai data terpisah, bukan sebagai bagian dari teks perintah. Chapter ini membahas ketiga method, perbedaan placeholder PostgreSQL dan MySQL, penanganan NULL dengan sql.NullString, klausa IN dinamis, serta bahaya SQL injection.

Tiga Metode Eksekusi #

Pilihan method mengikuti bentuk hasil yang kita harapkan:

MethodHasilKapan dipakai
QueryRowContextTepat satu baris, langsung di-ScanMembaca detail satu entitas
QueryContext*sql.Rows untuk banyak barisMembaca daftar data
ExecContextRingkasan baris terpengaruhINSERT, UPDATE, DELETE

Membaca satu baris cukup satu pemanggilan berantai. Kode berikut membutuhkan database aktif untuk dijalankan; versi lengkapnya dipraktikkan di proyek REST API dengan PostgreSQL :

go
var nama string
err := db.QueryRowContext(ctx, "SELECT nama FROM pengguna WHERE id = $1", 1).Scan(&nama)
if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("pengguna tidak ditemukan")
}

Query yang tidak menemukan baris tidak mengembalikan error jaringan, melainkan sentinel sql.ErrNoRows — periksa dengan errors.Is. Untuk perubahan data, ExecContext dipakai dan hasilnya berisi jumlah baris terpengaruh lewat method RowsAffected.

Placeholder: $1 di PostgreSQL, ? di MySQL #

Posisi parameter ditandai placeholder yang bentuknya berbeda antar database:

DatabaseBentuk placeholderContoh
PostgreSQL$1, $2, $3WHERE email = $1 AND aktif = $2
MySQL?WHERE email = ? AND aktif = ?

Angka pada placeholder PostgreSQL adalah nomor urut argumen: argumen ketiga QueryRowContext mengisi $1, argumen keempat mengisi $2. Karena nilai dikirim terpisah dari teks SQL, tanda kutip dan karakter khusus pada input tidak pernah ditafsirkan sebagai bagian dari perintah.

Klausa IN Dinamis #

Klausa IN menuntut perhatian khusus: sebuah slice tidak bisa dikirim sebagai satu parameter. Setiap elemen membutuhkan placeholder sendiri, sehingga teks query dibangun sesuai jumlah elemen:

go
query := fmt.Sprintf("SELECT nama FROM pengguna WHERE id IN (%s)", placeholderPG(len(ids)))
args := make([]any, len(ids))
for i, v := range ids {
        args[i] = v
}

rows, err := db.QueryContext(ctx, query, args...)

Nilai elemen tetap dikirim lewat args... — hanya jumlah placeholder yang dibangun dinamis, datanya tidak pernah disisipkan ke teks SQL.

NULL dan sql.NullString #

NULL di SQL bukan string kosong. Memindahkan kolom ber-NULL ke variabel string lewat Scan membuat panggilan gagal — database/sql menolak menebak arti NULL. Solusi standarnya sql.NullString: field String menampung nilai dan field Valid menyatakan kolom memang berisi nilai:

go
var email sql.NullString
err := db.QueryRowContext(ctx, "SELECT email FROM pengguna WHERE id = $1", 1).Scan(&email)
if err == nil && email.Valid {
        fmt.Println("email:", email.String)
}

Dua alternatif yang umum: pointer *string (nil berarti NULL) atau COALESCE(email, '') di sisi SQL. Untuk tipe lain tersedia sql.NullInt64, sql.NullFloat64, sql.NullBool, dan sql.NullTime.

SQL Injection: Bahaya Penggabungan String #

Membangun query dengan "... WHERE email = '" + input + "'" terlihat tidak berbahaya sampai input datang dari pengguna yang tidak baik-baik saja. Input klasik ' OR '1'='1 mengubah struktur query menjadi selalu benar sehingga filter email lumpuh — variasi yang lebih agresif bisa menambahkan perintah berbahaya. Dengan parameter binding, struktur query terkunci sejak awal dan input hanya menjadi data. Pembahasan mendalam tersedia di chapter SQL Injection .

Pola Baca Banyak Baris #

Hasil QueryContext berupa *sql.Rows — kursor yang digarap dengan pola tiga langkah:

go
rows, err := db.QueryContext(ctx, "SELECT id, nama FROM pengguna WHERE aktif = $1", true)
if err != nil {
        return err
}
defer rows.Close()

for rows.Next() {
        var id int64
        var nama string
        if err := rows.Scan(&id, &nama); err != nil {
                return err
        }
        fmt.Println(id, nama)
}
if err := rows.Err(); err != nil {
        return err
}

Urutan argumen Scan harus mengikuti urutan kolom pada SELECT — menukar &id dan &nama tetap berhasil dijalankan, tetapi hasilnya tertukar. Pemanggilan rows.Err() setelah loop menangkap error di tengah iterasi, dan rows.Close() memulangkan koneksi pinjaman ke pool — detail yang dibahas di chapter Connection Pool .

Program Utuh Tanpa Database #

Berkas contoh.go pada bagian bawah halaman merangkum materi chapter ini dalam bentuk yang bisa dijalankan tanpa database — pembangun placeholder, konversi tanda tanya, klausa IN, perbandingan query rentan versus berparameter, dan perilaku sql.NullString:

go
package main

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

// placeholderPG membangun daftar placeholder gaya PostgreSQL: $1, $2, ...
func placeholderPG(n int) string {
	bagian := make([]string, n)
	for i := range n {
		bagian[i] = fmt.Sprintf("$%d", i+1)
	}
	return strings.Join(bagian, ", ")
}

// konversiTandaTanya mengubah query bergaya MySQL (?) menjadi
// placeholder bernomor PostgreSQL ($1, $2, ...).
func konversiTandaTanya(query string) string {
	nomor := 0
	var b strings.Builder
	for _, r := range query {
		if r == '?' {
			nomor++
			fmt.Fprintf(&b, "$%d", nomor)
		} else {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// klausaIN membangun klausa IN dengan satu placeholder per elemen.
func klausaIN(kolom string, nilai []int) string {
	return fmt.Sprintf("WHERE %s IN (%s)", kolom, placeholderPG(len(nilai)))
}

func main() {
	fmt.Println(placeholderPG(4))
	fmt.Println(konversiTandaTanya("SELECT nama FROM pengguna WHERE email = ? AND aktif = ?"))
	fmt.Println(klausaIN("id", []int{4, 8, 15, 16, 23, 42}))

	// Input jahat klasik: penggabungan string mengubah struktur query,
	// sedangkan parameter binding menjaga struktur tetap utuh.
	input := "' OR '1'='1"
	rentan := "SELECT id FROM pengguna WHERE email = '" + input + "'"
	fmt.Println("query rentan :", rentan)
	fmt.Println("query aman   : SELECT id FROM pengguna WHERE email = $1")

	// sql.NullString menyimpan nilai kolom yang mungkin NULL.
	var kosong sql.NullString
	terisi := sql.NullString{String: "Budi", Valid: true}
	fmt.Printf("kosong: valid=%t nilai=%q\n", kosong.Valid, kosong.String)
	fmt.Printf("terisi: valid=%t nilai=%q\n", terisi.Valid, terisi.String)
}

Output yang Dihasilkan:

text
$1, $2, $3, $4
SELECT nama FROM pengguna WHERE email = $1 AND aktif = $2
WHERE id IN ($1, $2, $3, $4, $5, $6)
query rentan : SELECT id FROM pengguna WHERE email = '' OR '1'='1'
query aman   : SELECT id FROM pengguna WHERE email = $1
kosong: valid=false nilai=""
terisi: valid=true nilai="Budi"

Mari kita telaah baris demi baris program di atas:

  1. Baris 10-15 (func placeholderPG): membangun $1, $2, ... sebanyak n — slice diisi lalu di-join, pola yang dipakai klausa IN.
  2. Baris 20-30 (func konversiTandaTanya): memindai teks query dan mengganti tiap ? dengan nomor berturut — dua gaya placeholder bisa saling dikonversi.
  3. Baris 35-38 (func klausaIN): memanggil placeholderPG sebanyak elemen — enam elemen menghasilkan enam placeholder.
  4. Baris 46-48 (input := ...): penggabungan string memperlihatkan query rentan berubah menjadi email = '' OR '1'='1' — tanda kutip penutup dari program dan tanda kutip dari input saling menyambung membentuk kondisi yang selalu benar; versi berparameter tetap $1.
  5. Baris 52-54 (sql.NullString): tanpa inisialisasi hasilnya valid=false; struct dengan Valid: true menyimpan nilai aslinya.

Kita kini bisa membaca dan menulis data secara aman. Namun query masih berjalan satu per satu — dua UPDATE yang harus berhasil bersama-sama masih bisa terpisah. Chapter berikutnya, Transaction, membahas Begin, Commit, dan Rollback untuk menutup celah tersebut.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

contoh.go

Unduh berkas
go
package main

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

// placeholderPG membangun daftar placeholder gaya PostgreSQL: $1, $2, ...
func placeholderPG(n int) string {
	bagian := make([]string, n)
	for i := range n {
		bagian[i] = fmt.Sprintf("$%d", i+1)
	}
	return strings.Join(bagian, ", ")
}

// konversiTandaTanya mengubah query bergaya MySQL (?) menjadi
// placeholder bernomor PostgreSQL ($1, $2, ...).
func konversiTandaTanya(query string) string {
	nomor := 0
	var b strings.Builder
	for _, r := range query {
		if r == '?' {
			nomor++
			fmt.Fprintf(&b, "$%d", nomor)
		} else {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// klausaIN membangun klausa IN dengan satu placeholder per elemen.
func klausaIN(kolom string, nilai []int) string {
	return fmt.Sprintf("WHERE %s IN (%s)", kolom, placeholderPG(len(nilai)))
}

func main() {
	fmt.Println(placeholderPG(4))
	fmt.Println(konversiTandaTanya("SELECT nama FROM pengguna WHERE email = ? AND aktif = ?"))
	fmt.Println(klausaIN("id", []int{4, 8, 15, 16, 23, 42}))

	// Input jahat klasik: penggabungan string mengubah struktur query,
	// sedangkan parameter binding menjaga struktur tetap utuh.
	input := "' OR '1'='1"
	rentan := "SELECT id FROM pengguna WHERE email = '" + input + "'"
	fmt.Println("query rentan :", rentan)
	fmt.Println("query aman   : SELECT id FROM pengguna WHERE email = $1")

	// sql.NullString menyimpan nilai kolom yang mungkin NULL.
	var kosong sql.NullString
	terisi := sql.NullString{String: "Budi", Valid: true}
	fmt.Printf("kosong: valid=%t nilai=%q\n", kosong.Valid, kosong.String)
	fmt.Printf("terisi: valid=%t nilai=%q\n", terisi.Valid, terisi.String)
}