Aplikasi kontak yang baik tidak dimulai dari tampilan, melainkan dari pertanyaan tentang datanya: informasi apa saja yang disimpan satu kontak, bolehkah dua kontak berbagi satu alamat email, dan kapan kontak pertama kali dibuat. Jawaban atas pertanyaan seperti itu paling murah ditegakkan di tingkat database, karena aturan yang hidup di schema tetap berlaku untuk setiap program yang menyentuhnya. Pada bagian ini kita menerjemahkan kebutuhan aplikasi kontak menjadi satu berkas schema.sql di atas SQLite, lengkap dengan constraint, index, dan data awal, lalu membuktikan lewat PHP bahwa setiap aturan benar-benar dijalankan oleh database.

Dari Kebutuhan ke Struktur Data #

Sebelum menulis satu baris SQL, kita daftarkan dulu kebutuhan datanya secara eksplisit. Aplikasi kontak yang akan kita bangun setidaknya harus memenuhi hal berikut:

  • Setiap kontak menyimpan nama, alamat email, dan nomor telepon.
  • Ketiga kolom tersebut wajib diisi; email bahkan tidak boleh dipakai dua kontak berbeda.
  • Nomor telepon sering diawali angka nol, misalnya 0812, dan nol itu harus utuh tersimpan.
  • Waktu pembuatan kontak tercatat otomatis tanpa bergantung pada kode PHP.
  • Daftar kontak paling sering ditampilkan urut nama dan dicari berdasarkan nama.

Setiap kebutuhan tersebut diterjemahkan menjadi satu keputusan konkret pada schema, bukan dibiarkan sebagai niat baik di kode aplikasi:

KebutuhanKeputusan pada schema
Identitas kontak yang stabilid INTEGER PRIMARY KEY AUTOINCREMENT
Nama wajib adanama TEXT NOT NULL
Email wajib dan unikemail TEXT NOT NULL UNIQUE
Nol di depan telepon utuhtelepon TEXT
Waktu simpan otomatiscreated_at TEXT DEFAULT CURRENT_TIMESTAMP
Urut dan cari namaindex pada kolom nama

Pemetaan ini akan menjadi kontrak antara database dan seluruh bagian proyek berikutnya. Ketika Bagian 2 mulai menulis repository, method di sana cukup percaya bahwa email duplikat tidak mungkin lolos dan created_at selalu terisi.

Merancang Tabel contacts #

Seluruh kebutuhan di atas bermuara pada satu tabel dengan lima kolom. Berikut definisinya sebagaimana ditulis pada berkas schema.sql:

sql
CREATE TABLE IF NOT EXISTS contacts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nama       TEXT    NOT NULL,
    email      TEXT    NOT NULL UNIQUE,
    telepon    TEXT    NOT NULL,
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Mari kita bedah peran setiap baris pada deklarasi tersebut:

  1. id INTEGER PRIMARY KEY AUTOINCREMENT: kolom id menjadi primary key. SQLite memperlakukan INTEGER PRIMARY KEY sebagai alias rowid, sehingga nilai id terisi otomatis dan unik. AUTOINCREMENT mencegah SQLite memakai ulang id kontak yang sudah dihapus, sehingga nomor id hanya naik dan riwayat lama tidak tertukar.
  2. nama TEXT NOT NULL: kontak tanpa nama adalah data yang tidak bermakna, jadi database menolaknya sejak awal.
  3. email TEXT NOT NULL UNIQUE: constraint UNIQUE menolak dua kontak dengan email yang sama, sekaligus otomatis membuat index pada kolom email sehingga pencarian email menjadi cepat.
  4. telepon TEXT NOT NULL: nomor telepon disimpan sebagai teks, bukan angka.
  5. created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP: kolom waktu terisi otomatis saat baris dibuat. SQLite menyimpannya sebagai teks format YYYY-MM-DD HH:MM:SS pada zona waktu UTC.

Keputusan menyimpan telepon sebagai TEXT layak ditegaskan karena sering menipu pemula. Kolom bertipe INTEGER akan membuang angka nol di depan: 081234567890 tersimpan sebagai 81234567890, dan nomor sudah salah sejak disimpan. Nomor telepon memang terlihat seperti angka, tetapi ia pengenal, bukan besaran yang dihitung — sama seperti kode pos dan NIK.

Indeks untuk Pola Query #

Index mempercepat pencarian dengan biaya ruang dan sedikit perlambatan pada setiap penulisan, jadi kita hanya membuat index untuk kolom yang benar-benar dipakai pola query aplikasi. Kolom nama adalah kandidatnya karena ORDER BY nama dan pencarian nama akan dipakai di Bagian 3:

sql
CREATE INDEX IF NOT EXISTS idx_contacts_nama ON contacts(nama);

Selain index eksplisit tersebut, constraint UNIQUE pada email secara diam-diam membuat index keduanya. SQLite menamainya sqlite_autoindex_contacts_1, dan kita akan melihat keduanya saat memeriksa struktur database di bagian bawah halaman ini:

IndexSumberManfaat
idx_contacts_namaCREATE INDEX eksplisitmempercepat urut dan cari nama
sqlite_autoindex_contacts_1UNIQUE pada kolom emailmempercepat cek duplikat dan cari email

Kolom telepon sengaja tidak diberi index. Belum ada query yang mencari berdasarkan nomor telepon, dan index yang tidak pernah dipakai hanya menambah biaya penulisan. Jika kelak fitur pencarian nomor ditambahkan, satu CREATE INDEX pada saat itu cukup.

Data Awal yang Aman Dijalankan Berulang #

Database kosong memaksa setiap demo menyusun data dari nol. Karena itu akhir schema.sql menyisipkan enam kontak contoh:

sql
INSERT OR IGNORE INTO contacts (nama, email, telepon, created_at) VALUES
    ('Budi Santoso',   '[email protected]',        '081234567890', '2026-01-10 09:15:00'),
    ('Siti Nurhaliza', '[email protected]',        '082198765432', '2026-01-11 10:30:00'),
    ('Andi Wijaya',    '[email protected]', '081377712345', '2026-01-12 14:05:00'),
    ('Dewi Lestari',   '[email protected]',        '085611122233', '2026-01-13 16:45:00'),
    ('Rian Hidayat',   '[email protected]',        '087812340098', '2026-01-14 11:20:00'),
    ('Maya Putri',     '[email protected]',  '089955667788', '2026-01-15 08:00:00');

Dua keputusan kecil pada blok tersebut perlu dijelaskan. Pertama, INSERT OR IGNORE bekerja sama dengan UNIQUE pada email: jika sebuah email sudah ada, SQLite melewati baris itu diam-diam alih-alih melaporkan galat, sehingga schema.sql aman dijalankan berulang kali. Kedua, kolom created_at diisi nilai eksplisit, bukan dibiarkan pada nilai default, agar setiap contoh pada bagian-bagian proyek ini menampilkan output yang sama persis.

Catatan PRAGMA foreign_keys #

Berkas schema.sql sengaja tidak berisi PRAGMA foreign_keys = ON. SQLite menonaktifkan pemeriksaan foreign key secara default pada setiap koneksi, dan pragma tersebut berlaku per koneksi, bukan menempel pada database — menuliskannya saat membuat tabel tidak memberi efek apa pun pada koneksi berikutnya. Karena tabel contacts masih berdiri sendiri, belum ada relasi yang perlu diperiksa; namun kebiasaan menyalakannya tetap kita bawa sejak Bagian 2 lewat kode PHP. Skrip kecil berikut membuktikan perilaku per koneksi itu:

php
<?php
$pdo = new PDO('sqlite::memory:');

$sebelum = (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn();
$pdo->exec('PRAGMA foreign_keys = ON');
$sesudah = (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn();

echo "Koneksi baru   : foreign_keys = $sebelum (pemeriksaan nonaktif)\n";
echo "Setelah ON     : foreign_keys = $sesudah (pemeriksaan aktif)\n";

Output yang Dihasilkan:

text
Koneksi baru   : foreign_keys = 0 (pemeriksaan nonaktif)
Setelah ON     : foreign_keys = 1 (pemeriksaan aktif)

Query PRAGMA foreign_keys membaca status pragma pada koneksi saat ini, sementara PRAGMA foreign_keys = ON mengubah status itu untuk sisa umur koneksi. Koneksi baru yang kemudian dibuka kembali dimulai dari nol, itulah sebabnya penyalakan pragma harus dilakukan setiap kali membuka koneksi.

Menguji Skema dari PHP #

Skema belum bisa dipercaya sebelum strukturnya terbukti. SQLite menyimpan daftar tabel dan index miliknya pada tabel khusus bernama sqlite_master, dan posisi kolom tabel bisa dibaca lewat PRAGMA table_info. Skrip berikut memuat schema.sql ke database :memory: lalu memeriksa keduanya:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

echo "Objek database hasil schema.sql:\n";
foreach ($pdo->query('SELECT type, name FROM sqlite_master ORDER BY type, name') as $objek) {
    echo "  {$objek['type']}: {$objek['name']}\n";
}

echo "\nStruktur kolom contacts:\n";
foreach ($pdo->query('PRAGMA table_info(contacts)') as $kolom) {
    $default = $kolom['dflt_value'] ?? '-';
    echo "  {$kolom['name']} bertipe {$kolom['type']},"
        . " wajib diisi: {$kolom['notnull']},"
        . " primary key: {$kolom['pk']},"
        . " nilai default: $default\n";
}

Output yang Dihasilkan:

text
Objek database hasil schema.sql:
  index: idx_contacts_nama
  index: sqlite_autoindex_contacts_1
  table: contacts
  table: sqlite_sequence

Struktur kolom contacts:
  id bertipe INTEGER, wajib diisi: 0, primary key: 1, nilai default: -
  nama bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
  email bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
  telepon bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
  created_at bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: CURRENT_TIMESTAMP

Ada empat objek pada output di atas dan semuanya sah. contacts dan idx_contacts_nama adalah buatan kita; sqlite_autoindex_contacts_1 dibuat diam-diam oleh constraint UNIQUE pada email; dan sqlite_sequence dikelola SQLite sendiri untuk mengingat nomor id terakhir pada tabel bertipe AUTOINCREMENT. Baris id wajib diisi: 0 juga bukan kekeliruan: primary key INTEGER selalu terisi otomatis oleh SQLite, sehingga kolom tersebut tidak perlu ditandai NOT NULL secara eksplisit.

Selanjutnya kita pastikan isi data awal dan nilai default created_at benar-benar bekerja. Karena jam server berubah-ubah, pemeriksaan default di bawah sengaja memakai IS NOT NULL alih-alih mencetak nilainya:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

echo "Data awal:\n";
foreach ($pdo->query('SELECT id, nama, email, created_at FROM contacts ORDER BY id') as $kontak) {
    echo "  #{$kontak['id']} {$kontak['nama']} <{$kontak['email']}> sejak {$kontak['created_at']}\n";
}

$pdo->exec("INSERT INTO contacts (nama, email, telepon) VALUES ('Tono', '[email protected]', '081234500011')");
$terisi = $pdo->query(
    "SELECT created_at IS NOT NULL FROM contacts WHERE email = '[email protected]'"
)->fetchColumn();

echo "\nKontak Tono disimpan tanpa created_at, kolom terisi otomatis: ";
echo $terisi == 1 ? "ya\n" : "tidak\n";

Output yang Dihasilkan:

text
Data awal:
  #1 Budi Santoso <[email protected]> sejak 2026-01-10 09:15:00
  #2 Siti Nurhaliza <[email protected]> sejak 2026-01-11 10:30:00
  #3 Andi Wijaya <[email protected]> sejak 2026-01-12 14:05:00
  #4 Dewi Lestari <[email protected]> sejak 2026-01-13 16:45:00
  #5 Rian Hidayat <[email protected]> sejak 2026-01-14 11:20:00
  #6 Maya Putri <[email protected]> sejak 2026-01-15 08:00:00

Kontak Tono disimpan tanpa created_at, kolom terisi otomatis: ya

Enam kontak awal tampil dengan waktu yang kita bekukan di schema.sql, sedangkan kontak Tono yang disimpan tanpa kolom created_at tetap mendapat nilai dari DEFAULT CURRENT_TIMESTAMP. Perhatikan juga file_get_contents() mengambil seluruh isi schema.sql dan PDO::exec() menjalankan seluruh statement di dalamnya sekaligus — tabel, index, dan data awal dipasang dalam satu panggilan.

Membuktikan Constraint Bekerja #

Aturan yang paling penting adalah yang paling sering dilanggar. Dua penyisipan berikut sengaja melanggar UNIQUE pada email dan NOT NULL pada nama; keduanya ditangkap PDO dalam mode ERRMODE_EXCEPTION:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

$sql = 'INSERT INTO contacts (nama, email, telepon) VALUES (?, ?, ?)';

try {
    $pdo->prepare($sql)->execute(['Budi Kedua', '[email protected]', '081111122222']);
    echo "Tersimpan.\n";
} catch (PDOException $e) {
    echo "Email duplikat ditolak [{$e->getCode()}]\n";
    echo "  {$e->getMessage()}\n";
}

try {
    $pdo->prepare($sql)->execute([null, '[email protected]', '081200000000']);
    echo "Tersimpan.\n";
} catch (PDOException $e) {
    echo "Nama kosong ditolak [{$e->getCode()}]\n";
    echo "  {$e->getMessage()}\n";
}

$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
$jumlah = $pdo->query('SELECT COUNT(*) FROM contacts')->fetchColumn();
echo "Schema dijalankan dua kali, jumlah kontak tetap: $jumlah\n";

Output yang Dihasilkan:

text
Email duplikat ditolak [23000]
  SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: contacts.email
Nama kosong ditolak [23000]
  SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: contacts.nama
Schema dijalankan dua kali, jumlah kontak tetap: 6

Kedua pelanggaran ditolak dengan kode SQLSTATE 23000 — kode standar industri untuk pelanggaran integritas constraint. Baris terakhir membuktikan sifat idempoten: schema.sql dijalankan untuk kedua kalinya dan jumlah kontak tetap enam karena INSERT OR IGNORE melewati seluruh email yang sudah ada. Penanganan galat yang lebih ramah pengguna akan kita bangun pada Bagian 4; untuk sekarang, pesan mentah dari SQLite sudah cukup membuktikan bahwa garis pertahanan pertama kita berdiri.

Cara Menjalankan Contoh #

Seluruh snippet pada proyek ini saling terhubung melalui berkas bundle di setiap bagian, jadi susun foldernya sebagai berikut sebelum mencoba:

text
aplikasi-kontak/
└── bagian-1-desain-data/
    ├── schema.sql
    └── uji-skema.php

Simpan snippet terakhir sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:

bash
php uji-skema.php

PHP membaca schema.sql relatif terhadap __DIR__, yaitu folder tempat uji-skema.php berada, sehingga perintah di atas bisa dijalankan dari folder mana pun asalkan kedua berkas tetap satu folder. Bagian-bagian berikutnya akan menambah folder saudara seperti bagian-2-operasi-kontak/ dan merujuk schema.sql ini lewat path relatif ../bagian-1-desain-data/schema.sql.

Apa yang Berubah #

Proyek dimulai dari folder kosong dan kini memiliki fondasi penyimpanan yang lengkap:

  • Berkas schema.sql berdiri sebagai satu-satunya sumber kebenaran struktur data: satu tabel contacts, satu index eksplisit pada nama, plus index otomatis dari UNIQUE email.
  • Enam kontak awal menyertai schema, membuat setiap demo pada bagian berikutnya langsung berisi data.
  • Catatan PRAGMA foreign_keys terdokumentasi di berkas schema dan perilakunya sudah dibuktikan; penyalakannya pindah ke tangan kode PHP mulai Bagian 2.
  • Seluruh aturan sudah teruji dari PHP: UNIQUE, NOT NULL, DEFAULT CURRENT_TIMESTAMP, dan sifat idempoten schema.

Ringkasan #

Desain data menegakkan aturan persis di tempat yang paling sulit dilanggar: database. Kebutuhan diterjemahkan menjadi kolom dan constraint, index dibuat hanya untuk pola query yang nyata, data awal dibekukan agar demo reproducible, dan semuanya dibuktikan lewat sqlite_master, PRAGMA table_info, serta dua pelanggaran yang sengaja dilakukan. Struktur ini yang akan dipakai berulang kali oleh lapisan PHP. Pada Bagian 2 kita menulis ContactRepository — class yang memegang seluruh operasi CRUD di atas PDO dengan prepared statements.

Referensi #

Berkas Sumber

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

schema.sql

Unduh berkas
sql
-- schema.sql - struktur database aplikasi kontak.
-- Aman dijalankan berulang: CREATE TABLE dan CREATE INDEX memakai
-- IF NOT EXISTS, sedangkan data awal memakai INSERT OR IGNORE.

CREATE TABLE IF NOT EXISTS contacts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nama       TEXT    NOT NULL,
    email      TEXT    NOT NULL UNIQUE,
    telepon    TEXT    NOT NULL,
    created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_contacts_nama ON contacts(nama);

INSERT OR IGNORE INTO contacts (nama, email, telepon, created_at) VALUES
    ('Budi Santoso',   '[email protected]',        '081234567890', '2026-01-10 09:15:00'),
    ('Siti Nurhaliza', '[email protected]',        '082198765432', '2026-01-11 10:30:00'),
    ('Andi Wijaya',    '[email protected]', '081377712345', '2026-01-12 14:05:00'),
    ('Dewi Lestari',   '[email protected]',        '085611122233', '2026-01-13 16:45:00'),
    ('Rian Hidayat',   '[email protected]',        '087812340098', '2026-01-14 11:20:00'),
    ('Maya Putri',     '[email protected]',  '089955667788', '2026-01-15 08:00:00');

-- Catatan: PRAGMA foreign_keys = ON sengaja tidak ditulis di berkas ini.
-- Pragma tersebut berlaku per koneksi, bukan per database, sehingga wajib
-- dieksekusi oleh kode PHP setiap kali membuka koneksi baru (lihat Bagian 2).