Normalization pada chapter sebelumnya membuat setiap fakta tersimpan di tempat yang tepat; primary dan foreign key menjaga identitas serta relasinya. Namun nilai yang masuk ke kolom masih bebas sebebas-bebasnya: harga bisa kosong, level pemain bisa nol atau negatif, dan dua akun bisa memakai satu email yang sama. Constraint adalah aturan pada level kolom atau tabel yang database tegakkan pada setiap INSERT dan UPDATE — tanpa peduli siapa atau apa yang mencoba menyimpan. Pada chapter ini kita mencoba empat constraint paling penting (NOT NULL, UNIQUE, CHECK, DEFAULT), lalu belajar menempatkannya sebagai lapis pertahanan terakhir setelah validasi aplikasi.

NOT NULL #

NULL berarti “tidak ada nilai sama sekali” — berbeda dari string kosong '' atau angka 0 yang tetap merupakan nilai. Banyak kolom tidak punya makna bila kosong: nama produk tanpa nama, pesanan tanpa total. Constraint NOT NULL membuat database menolak baris yang tidak menyediakan nilai untuk kolom tersebut:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE products (
    id    INTEGER PRIMARY KEY,
    name  TEXT NOT NULL,
    price INTEGER NOT NULL
)");

try {
    $pdo->exec("INSERT INTO products (name) VALUES ('Laptop')");
} catch (PDOException $e) {
    echo "Ditolak database: " . $e->getMessage() . "\n";
}

$pdo->exec("INSERT INTO products (name, price) VALUES ('Mouse', 150000)");
echo "Mouse tersimpan dengan harga 150000\n";

Output yang Dihasilkan:

text
Ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: products.price
Mouse tersimpan dengan harga 150000

Penyisipan Laptop hanya menyebut kolom name, sehingga price otomatis bernilai NULL — dan database menolaknya dengan pesan NOT NULL constraint failed: products.price. Penyisipan Mouse yang menyediakan kedua kolom berjalan normal. Perhatikan bahwa pesan error menyebut nama kolom dan tabel secara spesifik, sehingga log aplikasi bisa langsung menunjuk sumber masalahnya.

UNIQUE #

UNIQUE menjamin tidak ada dua baris dengan nilai yang sama pada kolom (atau gabungan kolom) tersebut. Constraint ini berbeda dari primary key meski terlihat mirip: primary key adalah identitas baris, sedangkan UNIQUE adalah aturan bisnis — username boleh jadi identitas internal, tetapi keunikan email adalah aturan domain yang layak ditegakkan sendiri. Satu tabel bisa memiliki banyak constraint UNIQUE, dan bentuk kompositnya ditulis UNIQUE (kolom_a, kolom_b):

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE users (
    id       INTEGER PRIMARY KEY,
    username TEXT NOT NULL UNIQUE,
    email    TEXT NOT NULL UNIQUE
)");

$pdo->exec("INSERT INTO users (username, email) VALUES ('budi', '[email protected]')");

try {
    $pdo->exec("INSERT INTO users (username, email) VALUES ('budi2', '[email protected]')");
} catch (PDOException $e) {
    echo "Ditolak database: " . $e->getMessage() . "\n";
}

$pdo->exec("INSERT INTO users (username, email) VALUES ('budi2', '[email protected]')");
echo "Username budi2 tersimpan dengan email berbeda\n";

Output yang Dihasilkan:

text
Ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email
Username budi2 tersimpan dengan email berbeda

Penyisipan kedua memakai username baru tetapi email yang sudah dimiliki Budi, sehingga yang menolak adalah constraint pada users.email — pesan error menyebutnya dengan jelas. Penyisipan ketiga dengan kedua nilai baru diterima. Sebagai efek samping yang menyenangkan, SQLite otomatis membuat index untuk setiap kolom UNIQUE, sehingga pencarian berdasarkan email juga menjadi cepat.

CHECK #

CHECK menerima ekspresi boolean dan menolak baris yang membuat ekspresi tersebut bernilai salah. Inilah constraint paling lentur: rentang angka, format sederhana, hingga hubungan antar kolom di baris yang sama (CHECK (price >= 0 AND stock >= 0)). Memberi nama lewat CONSTRAINT nama CHECK (...) membuat pesan error jauh lebih mudah dibaca:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE players (
    id       INTEGER PRIMARY KEY,
    nickname TEXT NOT NULL,
    level    INTEGER NOT NULL,
    CONSTRAINT level_positif CHECK (level > 0)
)");

try {
    $pdo->exec("INSERT INTO players (nickname, level) VALUES ('BudiPro', 0)");
} catch (PDOException $e) {
    echo "Ditolak database: " . $e->getMessage() . "\n";
}

$pdo->exec("INSERT INTO players (nickname, level) VALUES ('SitiGamer', 5)");
echo "SitiGamer tersimpan dengan level 5\n";

Output yang Dihasilkan:

text
Ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: level_positif
SitiGamer tersimpan dengan level 5

Level 0 membuat ekspresi level > 0 bernilai salah, sehingga baris BudiPro ditolak dengan pesan CHECK constraint failed: level_positif — nama yang kita pilih sendiri, bukan sekadar nomor kolom. Level 5 tentu saja lolos. Satu catatan portabilitas: MySQL baru benar-benar menegakkan CHECK sejak versi 8.0.16; versi lebih lama hanya mem-parsing sintaksnya tanpa menjalankannya, jadi jangan mengandalkan CHECK bila harus mendukung MySQL tua.

DEFAULT #

DEFAULT mengisi nilai bawaan ketika penyisipan tidak menyebut kolom tersebut. Constraint ini menjaga data tetap masuk akal tanpa memaksa pemanggil selalu menuliskan semua kolom — status pesanan baru selalu pending, total keranjang kosong selalu 0:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE orders (
    id     INTEGER PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'pending',
    total  INTEGER NOT NULL DEFAULT 0
)");

// Insert tanpa menyebut status dan total
$pdo->exec("INSERT INTO orders (id) VALUES (1)");

$row = $pdo->query("SELECT status, total FROM orders WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
echo json_encode($row, JSON_UNESCAPED_UNICODE) . "\n";

Output yang Dihasilkan:

text
{"status":"pending","total":0}

Penyisipan hanya menyebut id, dan hasil query menunjukkan status terisi "pending" serta total terisi 0. Nilai bawaan juga boleh berupa ekspresi seperti DEFAULT (datetime('now')) untuk kolom waktu — meski ingat bahwa nilai semacam itu berubah setiap eksekusi, sehingga lebih cocok dipakai di database sungguhan daripada di latihan yang ingin deterministik. Perpaduan NOT NULL DEFAULT adalah pola yang sering dipakai: kolom wajib terisi, tetapi pemanggil tidak wajib mengisinya.

Validasi Aplikasi vs Constraint Database #

Setiap contoh di atas sengaja menangkap PDOException — dan di situlah dua lapis pertahanan bertemu. Validasi aplikasi (misalnya filter_var() atau aturan form) memberi umpan balik cepat dan ramah kepada pengguna: pesan langsung tampil di samping kolom yang salah. Constraint database bekerja di lapis berbeda: ia berlaku untuk semua jalur masuk data — request pengguna, skrip maintenance, import dari berkas — dan tidak bisa dilupakan oleh sedikitnya satu programmer.

Berikut pola lengkapnya: validasi format di aplikasi, lalu tangani pelanggaran constraint dengan membaca kode SQLSTATE:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE subscribers (
    id    INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
)");

function saveSubscriber(PDO $pdo, string $email): void
{
    // Lapis pertama: validasi aplikasi (format)
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        echo "Ditolak aplikasi: format email tidak valid ({$email})\n";
        return;
    }

    // Lapis kedua: constraint database (keunikan)
    try {
        $pdo->prepare("INSERT INTO subscribers (email) VALUES (?)")->execute([$email]);
        echo "Berhasil disimpan: {$email}\n";
    } catch (PDOException $e) {
        if ($e->getCode() === "23000") {
            echo "Ditolak database: email sudah terdaftar ({$email})\n";
            return;
        }
        throw $e; // error lain: biarkan gagal keras
    }
}

saveSubscriber($pdo, "[email protected]");
saveSubscriber($pdo, "[email protected]");
saveSubscriber($pdo, "bukan-email");

Output yang Dihasilkan:

text
Berhasil disimpan: [email protected]
Ditolak database: email sudah terdaftar ([email protected])
Ditolak aplikasi: format email tidak valid (bukan-email)

Alur ketiga pemanggilan pada output tersebut memperlihatkan pembagian tugasnya:

  1. [email protected] (pertama): lolos validasi format, lolos constraint, tersimpan.
  2. [email protected] (kedua): lolos validasi format — formatnya memang sah — tetapi ditolak constraint UNIQUE. Bandingkan $e->getCode() dengan string "23000" mengenali kelas integrity constraint violation.
  3. bukan-email: ditolak di lapis aplikasi bahkan sebelum query dikirim; database tidak pernah repot-repot.

Angka 23000 pada getCode() berasal dari kode SQLSTATE, standar lintas-database untuk pelanggaran integritas — NOT NULL, UNIQUE, CHECK, hingga foreign key semuanya masuk kelas ini. Error lain (misalnya salah sintaks) tidak berkode 23000, sehingga blok throw $e membiarkannya tetap gagal keras sebagaimana mestinya.

Ringkasan #

Empat constraint yang kita bahas menutup celah yang tidak dijangkau struktur: NOT NULL memastikan kolom penting terisi, UNIQUE menegakkan aturan keunikan bisnis, CHECK menjaga validitas nilai, dan DEFAULT mengisi nilai wajar secara otomatis. Validasi aplikasi tetap diperlukan untuk pengalaman pengguna, tetapi constraint database adalah lapis pertahanan terakhir yang tidak bisa dilewati bug maupun race condition. Schema Anda kini lengkap: bentuknya normal, relasinya dijaga key, nilainya dijaga constraint. Pertanyaan terakhir: bagaimana membawa schema yang terus berubah ini ke seluruh lingkungan — dari laptop pengembang sampai server produksi — tanpa salin-tempel manual? Jawabannya adalah schema migrations, topik chapter penutup section ini.

Referensi #