Normalisasi mengajarkan memecah fakta ke banyak tabel; kunci adalah kontrak yang membuat hasil pecahan tetap utuh. Primary key memberi setiap baris pengenal yang dijamin unik, dan foreign key memastikan rujukan antar tabel selalu menunjuk baris yang benar-benar ada. Tanpa keduanya, tidak ada yang melarang pesanan menunjuk pelanggan id 99 yang tidak pernah terdaftar — data rusak diam-diam tanpa satu pun galat. Chapter ini menguji seluruh kontrak tersebut secara nyata di SQLite.

Primary Key: Natural vs Surrogate #

Kunci natural adalah kolom yang unik karena sifat dunia nyata: nomor SKU produk, alamat email, nomor polisi. Kunci surrogate adalah pengenal buatan (id bertambah otomatis) yang tidak punya makna di luar database. Pilihan di antara keduanya adalah trade-off klasik:

AspekNatural keySurrogate key
Makna bagi bisnisada, langsung terbacatidak ada
Kestabilanbisa berubah (email ganti, SKU rombak)tidak pernah berubah
Ukuran pada joinbisa panjang (teks)kecil dan seragam (integer)
Jaminan keunikanbergantung proses bisnisdijamin database

Praktik umum untuk tabel dimensi toko online: pakai id integer sebagai primary key, lalu lindungi kunci natural dengan UNIQUE — sku TEXT NOT NULL UNIQUE. Kunci join jadi ringkas tanpa kehilangan jaminan keunikan SKU.

Foreign Key Menjaga Rujukan #

Berikut skema uji lengkap. Perhatikan klausa ON DELETE pada tiap foreign key — perilakunya kita uji sebentar lagi:

python
import sqlite3

koneksi = sqlite3.connect(":memory:")
koneksi.execute("PRAGMA foreign_keys = ON")
koneksi.executescript("""
CREATE TABLE kategori (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL UNIQUE
);
CREATE TABLE pelanggan (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);
CREATE TABLE produk (
    id INTEGER PRIMARY KEY,
    sku TEXT NOT NULL UNIQUE,
    nama TEXT NOT NULL,
    kategori_id INTEGER REFERENCES kategori(id) ON DELETE SET NULL,
    harga INTEGER NOT NULL CHECK (harga >= 0)
);
CREATE TABLE pesanan (
    id INTEGER PRIMARY KEY,
    pelanggan_id INTEGER NOT NULL REFERENCES pelanggan(id) ON DELETE RESTRICT,
    tanggal TEXT NOT NULL
);
CREATE TABLE detail_pesanan (
    pesanan_id INTEGER REFERENCES pesanan(id) ON DELETE CASCADE,
    produk_id INTEGER REFERENCES produk(id) ON DELETE RESTRICT,
    jumlah INTEGER NOT NULL CHECK (jumlah > 0),
    PRIMARY KEY (pesanan_id, produk_id)
);
INSERT INTO kategori VALUES (1, 'elektronik'), (2, 'fashion');
INSERT INTO pelanggan VALUES
    (1, 'Budi Santoso', '[email protected]'),
    (2, 'Siti Rahayu', '[email protected]');
INSERT INTO produk VALUES
    (1, 'ELK-001', 'Smartphone X1', 1, 3500000),
    (2, 'FSH-001', 'Kemeja Batik', 2, 250000);
INSERT INTO pesanan VALUES
    (1, 1, '2024-06-01'), (2, 2, '2024-06-03');
INSERT INTO detail_pesanan VALUES
    (1, 1, 1), (1, 2, 2), (2, 2, 1);
""")

def uji(nama, sql):
    try:
        koneksi.execute(sql)
        print(f"{nama}: BERHASIL")
    except sqlite3.IntegrityError as galat:
        print(f"{nama}: DITOLAK -> {galat}")

uji("email duplikat",
    "INSERT INTO pelanggan VALUES (3, 'Dewi Lestari', '[email protected]')")
uji("pesanan ke pelanggan yang tidak ada",
    "INSERT INTO pesanan VALUES (3, 99, '2024-07-01')")
uji("hapus pelanggan yang masih punya pesanan",
    "DELETE FROM pelanggan WHERE id = 1")
uji("jumlah nol pada detail",
    "INSERT INTO detail_pesanan VALUES (2, 1, 0)")

Output yang Dihasilkan:

text
email duplikat: DITOLAK -> UNIQUE constraint failed: pelanggan.email
pesanan ke pelanggan yang tidak ada: DITOLAK -> FOREIGN KEY constraint failed
hapus pelanggan yang masih punya pesanan: DITOLAK -> FOREIGN KEY constraint failed
jumlah nol pada detail: DITOLAK -> CHECK constraint failed: jumlah > 0

Empat penolakan di atas memperlihatkan empat constraint bekerja sekaligus. Baris pertama: UNIQUE menolak email milik Budi dipakai Dewi. Baris kedua: foreign key menolak pesanan baru menunjuk pelanggan 99 yang tidak pernah terdaftar. Baris ketiga: foreign key yang sama menolak penghapusan pelanggan 1 karena pesanan 1 masih merujuk pelanggan tersebut — rujukan menggantung dicegah dari dua arah. Baris keempat: CHECK menolak jumlah pembelian nol yang tidak masuk akal untuk bisnis.

Perilaku ON DELETE: CASCADE, RESTRICT, dan SET NULL #

Ketika baris induk dihapus, anak-anaknya yang merujuk harus ada keputusannya. Tiga perilaku yang dipakai skema di atas diuji satu per satu:

python
print("-- CASCADE: hapus pesanan 1, detail ikut terhapus --")
koneksi.execute("DELETE FROM pesanan WHERE id = 1")
for b in koneksi.execute("SELECT * FROM detail_pesanan"):
    print(b)

print("-- RESTRICT: hapus produk 2 yang masih dirujuk --")
uji("hapus produk 2", "DELETE FROM produk WHERE id = 2")

print("-- SET NULL: hapus kategori 2, kategori_id produk jadi NULL --")
koneksi.execute("DELETE FROM kategori WHERE id = 2")
for b in koneksi.execute("SELECT id, sku, nama, kategori_id FROM produk"):
    print(b)

Output yang Dihasilkan:

text
-- CASCADE: hapus pesanan 1, detail ikut terhapus --
(2, 2, 1)
-- RESTRICT: hapus produk 2 yang masih dirujuk --
hapus produk 2: DITOLAK -> FOREIGN KEY constraint failed
-- SET NULL: hapus kategori 2, kategori_id produk jadi NULL --
(1, 'ELK-001', 'Smartphone X1', 1)
(2, 'FSH-001', 'Kemeja Batik', None)

Mari kita telaah ketiga hasilnya:

  1. ON DELETE CASCADE (pesanan → detail): menghapus pesanan 1 otomatis menghapus dua baris detailnya; yang tersisa hanya detail pesanan 2. Cocok untuk data anak yang tak bermakna tanpa induknya.
  2. ON DELETE RESTRICT (produk → detail): produk 2 masih dibeli di pesanan 2, jadi penghapusannya ditolak sampai riwayat detail dipindahkan atau dihapus manual. Cocok untuk data yang tidak boleh kehilangan konteks sejarah.
  3. ON DELETE SET NULL (kategori → produk): kategori 2 dihapus, dan kategori_id produk 2 berubah menjadi NULL — produk tetap ada, hanya kehilangan kategorinya. Cocok untuk relasi opsional.

SQLite juga mengenal ON DELETE NO ACTION (perilaku bawaan) yang secara efek mirip RESTRICT: keduanya menolak penghapusan induk yang masih dirujuk. Perbedaannya baru terasa pada constraint yang ditunda (deferred) — tanpa penundaan, keduanya menolak seketika. Rincian seluruh perilaku foreign key ada di dukungan foreign key SQLite .

Rangkuman Constraint #

ConstraintYang dijagaContoh pada skema
PRIMARY KEYkeunikan dan keberadaan pengenalid tiap tabel, PK komposit detail_pesanan
FOREIGN KEYrujukan menunjuk baris yang adapelanggan_id pada pesanan
NOT NULLkolom wajib terisinama, tanggal
UNIQUEtanpa duplikatemail, sku, nama kategori
CHECKaturan nilai khususharga >= 0, jumlah > 0

Constraint adalah filter kualitas di pintu masuk data — baris yang melanggar ditolak sebelum tersimpan. Namun constraint menjaga kebenaran, bukan kecepatan: mencari satu baris di antara jutaan baris tetap lambat bila database memeriksanya satu per satu. Struktur yang membuat pencarian melompat langsung ke baris tujuan adalah materi chapter Indexing.