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:
| Kebutuhan | Keputusan pada schema |
|---|---|
| Identitas kontak yang stabil | id INTEGER PRIMARY KEY AUTOINCREMENT |
| Nama wajib ada | nama TEXT NOT NULL |
| Email wajib dan unik | email TEXT NOT NULL UNIQUE |
| Nol di depan telepon utuh | telepon TEXT |
| Waktu simpan otomatis | created_at TEXT DEFAULT CURRENT_TIMESTAMP |
| Urut dan cari nama | index 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:
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:
id INTEGER PRIMARY KEY AUTOINCREMENT: kolomidmenjadi primary key. SQLite memperlakukanINTEGER PRIMARY KEYsebagai aliasrowid, sehingga nilai id terisi otomatis dan unik.AUTOINCREMENTmencegah SQLite memakai ulang id kontak yang sudah dihapus, sehingga nomor id hanya naik dan riwayat lama tidak tertukar.nama TEXT NOT NULL: kontak tanpa nama adalah data yang tidak bermakna, jadi database menolaknya sejak awal.email TEXT NOT NULL UNIQUE: constraintUNIQUEmenolak dua kontak dengan email yang sama, sekaligus otomatis membuat index pada kolom email sehingga pencarian email menjadi cepat.telepon TEXT NOT NULL: nomor telepon disimpan sebagai teks, bukan angka.created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP: kolom waktu terisi otomatis saat baris dibuat. SQLite menyimpannya sebagai teks formatYYYY-MM-DD HH:MM:SSpada 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:
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:
| Index | Sumber | Manfaat |
|---|---|---|
idx_contacts_nama | CREATE INDEX eksplisit | mempercepat urut dan cari nama |
sqlite_autoindex_contacts_1 | UNIQUE pada kolom email | mempercepat 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:
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
$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:
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
$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:
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_TIMESTAMPAda 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
$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:
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: yaEnam 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
$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:
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: 6Kedua 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:
aplikasi-kontak/
└── bagian-1-desain-data/
├── schema.sql
└── uji-skema.phpSimpan snippet terakhir sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:
php uji-skema.phpPHP 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.sqlberdiri sebagai satu-satunya sumber kebenaran struktur data: satu tabelcontacts, satu index eksplisit padanama, plus index otomatis dariUNIQUEemail. - Enam kontak awal menyertai schema, membuat setiap demo pada bagian berikutnya langsung berisi data.
- Catatan
PRAGMA foreign_keysterdokumentasi 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-- 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).