Materi 1 · Modul 0
Kenalan sama database dan MySQL
Sebelum menulis satu baris SQL pun, pahami dulu masalah apa yang diselesaikan database.
Kenapa tidak cukup pakai file atau array biasa
Bayangkan toko online dengan ribuan produk dan ratusan pesanan per hari. Kalau semua data disimpan di file teks atau array PHP yang dibuat ulang tiap kali program jalan, muncul masalah:
- Data hilang saat program berhenti. Array PHP cuma hidup selama skrip berjalan (ingat Materi 1 handbook PHP — tiap request mulai dari nol).
- Sulit mencari dengan cepat. Mencari satu pesanan di antara jutaan baris teks itu lambat kalau dilakukan manual.
- Rawan data tidak konsisten. Dua orang mengubah data yang sama bersamaan bisa saling menimpa tanpa terdeteksi.
- Sulit menjaga hubungan antar data. Satu pesanan berisi banyak produk, satu pelanggan punya banyak pesanan — mengurus ini manual sangat rawan salah.
Database adalah sistem yang dirancang khusus untuk menyimpan, mencari, dan menjaga konsistensi data dalam jumlah besar, dan tetap ada meski programnya sudah berhenti.
MySQL: database relasional
MySQL menyimpan data dalam bentuk tabel — mirip spreadsheet, dengan baris dan kolom. Yang membuatnya "relasional": tabel-tabel bisa saling terhubung lewat kunci (dibahas mendalam di Modul 3).
Tabel: produk
┌────┬──────────────┬────────┬──────┐
│ id │ nama │ harga │ stok │
├────┼──────────────┼────────┼──────┤
│ 1 │ Kaos Polos │ 85000 │ 24 │
│ 2 │ Hoodie │ 210000 │ 8 │
│ 3 │ Topi │ 45000 │ 15 │
└────┴──────────────┴────────┴──────┘
↑ setiap BARIS = satu produk
↑ setiap KOLOM = satu jenis dataIstilah yang akan sering muncul: tabel (kumpulan data sejenis, seperti "produk" atau "pelanggan"), baris/row (satu data, seperti satu produk), kolom/field (satu jenis informasi, seperti "harga"), dan SQL — bahasa yang dipakai untuk berbicara dengan database.
SQL: bahasa untuk database
SQL (Structured Query Language) bukan bahasa pemrograman seperti PHP — dia bahasa khusus untuk meminta sesuatu dari database: ambil data ini, simpan data itu, ubah baris ini. Kamu akan menulisnya dalam kalimat yang mendekati bahasa Inggris biasa.
SELECT nama, harga FROM produk WHERE stok > 0;
-- "Ambil nama dan harga dari tabel produk, yang stoknya lebih dari 0"Catatan — MySQL adalah salah satu mesin database yang memahami SQL — ada juga PostgreSQL, SQLite, SQL Server, dan lainnya. Perintah SQL dasar hampir sama di semua mesin ini, jadi apa yang kamu pelajari di sini sebagian besar bisa dipakai di database lain juga. MySQL dipilih di handbook ini karena paling umum dipakai berdampingan dengan PHP, dan tersedia gratis di hampir semua layanan hosting.
Yang akan kamu pelajari
- Modul 0 — instalasi, membuat database dan tabel pertama, tipe data.
- Modul 1 — CRUD: menyimpan, membaca, mengubah, menghapus data.
- Modul 2 — query lanjutan: agregasi, JOIN, subquery.
- Modul 3 — desain database yang benar: relasi, normalisasi, index.
- Modul 4 — transaksi, view, backup, hak akses pengguna.
- Modul 5 — performa dan praktik produksi.
Handbook ini murni SQL — tidak ada PHP sama sekali. Setelah selesai, ada handbook studi kasus yang menyatukan PHP dan MySQL jadi satu aplikasi utuh.
Materi 2 · Modul 0
Instalasi dan alat kerja
Menyiapkan MySQL di komputer, dan mengenal cara mengaksesnya.
Cara paling mudah: paket all-in-one
Sama seperti disebutkan di handbook PHP, paket seperti Laragon (Windows), XAMPP (semua OS), atau MAMP (macOS) sudah menyertakan MySQL sekaligus. Kalau kamu sudah memasang salah satunya untuk belajar PHP, MySQL kemungkinan sudah terpasang juga.
Dua cara mengakses MySQL
| Cara | Kapan dipakai |
|---|---|
| Command line (CLI) | Cepat, ringan, bagus untuk belajar — kamu melihat persis apa yang terjadi. |
| phpMyAdmin / GUI | Tampilan visual, memudahkan menjelajah data. Biasanya sudah termasuk di Laragon/XAMPP. |
Handbook ini memakai command line untuk semua contoh, karena itu cara paling jelas memahami apa yang sebenarnya dikerjakan SQL. phpMyAdmin bisa dipakai belakangan untuk melihat-lihat data dengan nyaman.
Masuk ke MySQL lewat terminal
# masuk sebagai user root (default, tanpa password di banyak instalasi lokal)
mysql -u root -p
# akan diminta password, tekan Enter kalau kosong
# setelah masuk, prompt berubah jadi:
mysql>Catatan — Setiap perintah SQL di command line harus diakhiri titik koma (;). Tanpa itu, MySQL mengira kamu belum selesai mengetik dan menunggu baris berikutnya. Ini kesalahan paling umum bagi pemula — kalau terminal seperti "macet" menunggu, kemungkinan besar kamu lupa titik koma.
Perintah navigasi dasar
SHOW DATABASES; -- lihat semua database yang ada
USE nama_database; -- pilih database yang mau dipakai
SHOW TABLES; -- lihat semua tabel di database aktif
DESCRIBE nama_tabel; -- lihat struktur kolom sebuah tabel
SELECT VERSION(); -- cek versi MySQL yang terpasang
EXIT; -- keluar dari MySQLMenjalankan file SQL
Daripada mengetik satu-satu di terminal, kamu bisa menyimpan perintah SQL dalam file .sql dan menjalankannya sekaligus — cara yang akan sering kamu pakai.
# dari luar mysql (di terminal biasa)
mysql -u root -p nama_database < skema.sql
# atau dari dalam prompt mysql>
SOURCE /path/ke/skema.sql;Latihan — Masuk ke MySQL lewat terminal. Jalankan SHOW DATABASES; untuk melihat database apa saja yang sudah ada bawaan sistem. Jalankan SELECT VERSION(); untuk memastikan versi MySQL-mu 8.0 ke atas (handbook ini memakainya sebagai acuan).
Materi 3 · Modul 0
Database dan tabel pertama
Membuat wadah untuk data, lalu tabel pertama di dalamnya.
Membuat database
CREATE DATABASE toko_saya;
USE toko_saya;
-- praktik yang baik: tentukan charset supaya emoji & karakter khusus tersimpan benar
CREATE DATABASE toko_saya
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;Catatan — utf8mb4 (bukan utf8 biasa) penting supaya database bisa menyimpan emoji dan karakter Unicode lengkap. utf8 bawaan MySQL sebenarnya versi terbatas yang tidak mendukung semua karakter. Biasakan selalu menambahkan ini saat membuat database baru.
Membuat tabel
Tabel didefinisikan dengan menyebutkan nama tiap kolom dan tipe datanya.
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
harga INT NOT NULL,
stok INT NOT NULL DEFAULT 0,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Membacanya baris per baris:
id INT AUTO_INCREMENT PRIMARY KEY— angka unik yang bertambah otomatis untuk tiap baris baru. Ini "identitas" tiap produk.nama VARCHAR(150) NOT NULL— teks maksimal 150 karakter, wajib diisi (tidak boleh kosong).harga INT NOT NULL— angka bulat, wajib diisi.stok INT NOT NULL DEFAULT 0— angka bulat, kalau tidak diisi otomatis bernilai 0.dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP— otomatis terisi waktu saat ini kalau tidak disebutkan.
Melihat dan mengubah struktur tabel
DESCRIBE produk;
-- menampilkan tiap kolom: nama, tipe, boleh NULL atau tidak, key, default
SHOW CREATE TABLE produk;
-- menampilkan perintah CREATE TABLE lengkap yang menghasilkan tabel ini-- tambah kolom baru
ALTER TABLE produk ADD deskripsi TEXT;
-- ubah tipe/aturan kolom yang sudah ada
ALTER TABLE produk MODIFY nama VARCHAR(200) NOT NULL;
-- ganti nama kolom
ALTER TABLE produk RENAME COLUMN deskripsi TO catatan;
-- hapus kolom
ALTER TABLE produk DROP COLUMN catatan;
-- hapus seluruh tabel (HATI-HATI, tidak bisa dibatalkan)
DROP TABLE produk;Hati-hati — DROP TABLE dan DROP DATABASE langsung menghapus permanen, tanpa konfirmasi, tanpa tempat sampah. Sebelum menjalankan perintah ini di database sungguhan, pastikan betul-betul yakin — dan idealnya sudah punya backup (dibahas di Modul 4).
Latihan — Buat database bernama latihan_toko dengan charset utf8mb4. Di dalamnya, buat tabel pelanggan dengan kolom: id (auto increment, primary key), nama (wajib diisi), email (wajib diisi), dan bergabung_pada (timestamp otomatis). Jalankan DESCRIBE pelanggan; untuk memastikan strukturnya benar.
Materi 4 · Modul 0
Tipe data
Memilih tipe data yang tepat untuk tiap kolom — keputusan yang berpengaruh ke ukuran, kecepatan, dan kebenaran data.
Tipe angka
| Tipe | Untuk |
|---|---|
INT | Bilangan bulat biasa (id, stok, umur). Rentang hingga ±2 miliar. |
BIGINT | Bilangan bulat sangat besar (jarang dibutuhkan kecuali data dalam skala miliaran baris). |
TINYINT | Angka kecil (0–255 tanpa tanda). Sering dipakai untuk boolean (0/1). |
DECIMAL(p,s) | Angka desimal presisi tepat — wajib untuk uang. DECIMAL(10,2) = 10 digit total, 2 di belakang koma. |
FLOAT / DOUBLE | Desimal perkiraan (cepat, tapi tidak presisi). Hindari untuk uang. |
Hati-hati — Sama seperti disinggung di handbook PHP, jangan pakai FLOAT/DOUBLE untuk uang. Keduanya menyimpan perkiraan, bukan nilai persis — 0.1 + 0.2 bisa menghasilkan 0.30000000000000004. Untuk harga dan nominal uang, pakai DECIMAL(10,2) (persis) atau INT dalam satuan rupiah penuh tanpa desimal (paling umum untuk kasus di Indonesia).
Tipe teks
| Tipe | Untuk |
|---|---|
VARCHAR(n) | Teks pendek dengan batas panjang (nama, email, judul). n = maksimal karakter. |
TEXT | Teks panjang tanpa batas praktis (deskripsi, isi artikel, komentar). |
CHAR(n) | Teks dengan panjang tetap (jarang dipakai — cocok untuk kode 2 huruf seperti kode negara). |
ENUM(...) | Salah satu dari daftar nilai tetap, mis. ENUM('pending','lunas','batal'). |
Catatan — Pilih VARCHAR(n) dengan n secukupnya, jangan asal besar. Untuk nama, VARCHAR(150) lebih dari cukup; untuk email, VARCHAR(255) aman. Batasan ini bukan cuma soal hemat ruang — ini juga bentuk validasi dasar di level database.
Tipe tanggal dan waktu
| Tipe | Untuk |
|---|---|
DATE | Tanggal saja: 2026-12-31. Untuk tanggal lahir, tanggal event. |
DATETIME | Tanggal + jam: 2026-12-31 14:30:00. Tidak terikat zona waktu tertentu. |
TIMESTAMP | Mirip DATETIME, tapi otomatis mengikuti zona waktu server, dan rentangnya lebih pendek (sampai 2038). Sering dipakai untuk dibuat_pada/diubah_pada. |
TIME | Jam saja: 14:30:00. Untuk jam operasional, durasi. |
Tipe lain yang berguna
CREATE TABLE pengaturan (
aktif BOOLEAN DEFAULT TRUE, -- sebenarnya TINYINT(1) di balik layar
status ENUM('draft', 'aktif', 'nonaktif') DEFAULT 'draft',
metadata JSON, -- menyimpan data JSON, bisa di-query sebagiannya
foto VARCHAR(255) -- biasanya simpan PATH file, bukan filenya sendiri
);Catatan — Jangan simpan file gambar/dokumen langsung di database (walau ada tipe BLOB untuk itu). Simpan filenya di disk atau layanan penyimpanan (seperti S3), dan database cukup menyimpan path atau URL-nya sebagai VARCHAR. Ini yang dipraktikkan di Materi 14 handbook PHP (upload file).
NULL vs nilai kosong
-- NULL berarti "tidak ada nilai" — beda dengan 0, string kosong, atau false
CREATE TABLE pelanggan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL, -- WAJIB ada nilai
telepon VARCHAR(20) NULL -- BOLEH kosong (belum diisi)
);Pakai NOT NULL untuk kolom yang secara logis harus selalu ada nilainya. Biarkan nullable (tanpa NOT NULL) untuk data yang memang opsional. Ini keputusan desain yang berpengaruh — dibahas lebih dalam di Modul 3.
Latihan — Rancang tabel artikel dengan tipe data yang tepat untuk: judul (pendek, wajib), isi (panjang), status (draft/terbit/arsip), harga_sponsor (uang, boleh kosong), tanggal_terbit (boleh kosong sampai diterbitkan), dan dibuat_pada (otomatis). Tuliskan CREATE TABLE-nya.
Materi 5 · Modul 0
Constraint: aturan yang dijaga database
Membuat database menolak data yang tidak masuk akal — sebelum sempat tersimpan.
Kenapa constraint penting
Validasi di PHP (seperti Materi 12 handbook PHP) bisa dilewati kalau ada bug, atau kalau data dimasukkan lewat jalur lain (skrip import, query manual). Constraint di level database adalah jaring pengaman terakhir — database sendiri yang menolak data yang melanggar aturan, apa pun sumbernya.
NOT NULL — wajib ada nilai
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL
);
INSERT INTO produk (nama) VALUES (NULL);
-- ERROR: Column 'nama' cannot be nullUNIQUE — tidak boleh ada duplikat
CREATE TABLE pelanggan (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
INSERT INTO pelanggan (email) VALUES ('nus@toko.com');
INSERT INTO pelanggan (email) VALUES ('nus@toko.com');
-- ERROR: Duplicate entry 'nus@toko.com' for key 'email'Catatan — UNIQUE penting untuk email, username, nomor identitas — apa pun yang secara alami harus berbeda tiap baris. Ini mencegah pendaftaran ganda dengan email yang sama, tanpa harus mengecek manual di kode PHP dulu (walau tetap sebaiknya dicek juga di PHP, untuk pesan error yang lebih ramah — dibahas di handbook studi kasus).
CHECK — aturan nilai kustom
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
harga INT NOT NULL CHECK (harga >= 0),
stok INT NOT NULL DEFAULT 0 CHECK (stok >= 0)
);
INSERT INTO produk (harga, stok) VALUES (-5000, 10);
-- ERROR: Check constraint 'produk_chk_1' is violatedCHECK menegakkan aturan bisnis dasar langsung di database — harga tidak boleh negatif, stok tidak boleh minus, dan sejenisnya.
DEFAULT — nilai bawaan
CREATE TABLE pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
status ENUM('pending','lunas','batal') DEFAULT 'pending',
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- tidak perlu menyebut status dan dibuat_pada — otomatis terisi
INSERT INTO pesanan () VALUES ();Menambah constraint ke tabel yang sudah ada
ALTER TABLE pelanggan ADD UNIQUE (email);
ALTER TABLE produk ADD CHECK (harga >= 0);
ALTER TABLE produk MODIFY nama VARCHAR(150) NOT NULL;Hati-hati — Menambahkan constraint ke tabel yang sudah berisi data akan gagal kalau ada data yang melanggar aturan baru itu. Misalnya menambahkan NOT NULL ke kolom yang sebagian barisnya masih kosong. Bersihkan atau isi dulu data yang bermasalah sebelum menambahkan constraint.
Latihan — Pada tabel pelanggan dari latihan Materi 3, tambahkan constraint: email harus UNIQUE, dan tambahkan kolom umur INT dengan CHECK supaya nilainya antara 1 dan 120. Uji dengan mencoba memasukkan data yang melanggar masing-masing aturan, pastikan keduanya ditolak.
Materi 6 · Modul 1
INSERT: menyimpan data
Perintah pertama dari empat operasi dasar (CRUD: Create, Read, Update, Delete).
INSERT dasar
INSERT INTO produk (nama, harga, stok)
VALUES ('Kaos Polos', 85000, 24);
-- kolom yang tidak disebut akan pakai DEFAULT (atau NULL kalau boleh kosong)
INSERT INTO produk (nama, harga)
VALUES ('Topi', 45000);
-- stok otomatis 0 (sesuai DEFAULT dari Materi 3)Catatan — Selalu sebutkan nama kolom secara eksplisit seperti contoh di atas, jangan mengandalkan urutan kolom tabel (INSERT INTO produk VALUES (...) tanpa nama kolom). Kalau struktur tabel berubah di kemudian hari (kolom ditambah/diurutkan ulang), versi tanpa nama kolom akan diam-diam memasukkan data ke kolom yang salah.
Insert banyak baris sekaligus
INSERT INTO produk (nama, harga, stok) VALUES
('Kaos Polos', 85000, 24),
('Hoodie', 210000, 8),
('Topi', 45000, 15),
('Jaket', 320000, 3);Insert banyak baris dalam satu perintah jauh lebih cepat daripada satu perintah INSERT per baris — penting saat mengimpor banyak data sekaligus.
Mendapatkan ID yang baru dibuat
INSERT INTO produk (nama, harga) VALUES ('Kaos Limited', 120000);
SELECT LAST_INSERT_ID();
-- mengembalikan id yang baru saja dibuat oleh AUTO_INCREMENTCatatan — LAST_INSERT_ID() sangat berguna saat kamu perlu langsung memakai id yang baru dibuat, misalnya untuk menyimpan baris terkait di tabel lain (seperti item pesanan yang butuh id pesanan induknya). Nanti di handbook studi kasus, PDO punya cara sendiri untuk mengambil nilai ini langsung dari PHP.
INSERT dengan penanganan duplikat
-- kalau email sudah ada (melanggar UNIQUE), UPDATE bagian ini alih-alih error
INSERT INTO pelanggan (email, nama, poin)
VALUES ('nus@toko.com', 'Nus', 10)
ON DUPLICATE KEY UPDATE poin = poin + 10;
-- INSERT IGNORE: lewati baris yang melanggar constraint, tanpa error
INSERT IGNORE INTO pelanggan (email, nama) VALUES ('nus@toko.com', 'Nus');Hati-hati — INSERT IGNORE membungkam semua jenis error, bukan cuma duplikat — termasuk data yang melanggar NOT NULL atau tipe yang salah. Ini bisa menyembunyikan bug tanpa kamu sadari. Pakai ON DUPLICATE KEY UPDATE saat memang bermaksud menangani duplikat secara spesifik, dan hindari INSERT IGNORE kecuali benar-benar paham konsekuensinya.
Latihan — Pada tabel pelanggan yang sudah kamu buat, insert 5 pelanggan sekaligus dalam satu perintah. Lalu coba insert pelanggan dengan email yang sama seperti salah satu yang sudah ada — pastikan errornya sesuai constraint UNIQUE dari Materi 5.
Materi 7 · Modul 1
SELECT: membaca data
Perintah yang paling sering kamu tulis di SQL. Mengambil data sesuai kebutuhan.
SELECT dasar
SELECT * FROM produk;
-- ambil SEMUA kolom, semua baris
SELECT nama, harga FROM produk;
-- ambil hanya kolom tertentu — lebih hemat dan lebih jelasHati-hati — SELECT * nyaman saat belajar dan mengeksplorasi data, tapi di kode aplikasi sungguhan sebaiknya sebutkan kolom yang benar-benar dibutuhkan. Alasannya: kalau tabel nanti bertambah kolom (misalnya kolom besar seperti deskripsi TEXT), SELECT * ikut menariknya walau tidak dipakai — memperlambat query tanpa alasan.
WHERE: menyaring baris
SELECT * FROM produk WHERE stok > 0;
SELECT * FROM produk WHERE harga <= 100000;
SELECT * FROM produk WHERE nama = 'Kaos Polos';
SELECT * FROM produk WHERE nama != 'Kaos Polos';
-- gabungkan kondisi
SELECT * FROM produk WHERE stok > 0 AND harga < 100000;
SELECT * FROM produk WHERE stok = 0 OR harga > 300000;
-- rentang nilai
SELECT * FROM produk WHERE harga BETWEEN 50000 AND 200000;
-- salah satu dari beberapa nilai
SELECT * FROM produk WHERE nama IN ('Kaos Polos', 'Topi', 'Hoodie');
-- cek NULL — TIDAK BISA pakai = , harus pakai IS
SELECT * FROM pelanggan WHERE telepon IS NULL;
SELECT * FROM pelanggan WHERE telepon IS NOT NULL;Catatan — NULL itu istimewa: WHERE telepon = NULL tidak akan pernah cocok dengan apa pun (bahkan baris yang telepon-nya memang NULL), karena secara konsep "tidak ada nilai" tidak bisa dibandingkan sama dengan apa pun, termasuk NULL lain. Selalu pakai IS NULL / IS NOT NULL untuk mengecek NULL.
Mencari teks dengan LIKE
-- % = karakter apa pun, sebanyak apa pun (termasuk nol karakter)
SELECT * FROM produk WHERE nama LIKE 'Kaos%'; -- diawali "Kaos"
SELECT * FROM produk WHERE nama LIKE '%Polos'; -- diakhiri "Polos"
SELECT * FROM produk WHERE nama LIKE '%kaos%'; -- mengandung "kaos" (case-insensitive secara default)
-- _ = tepat satu karakter apa pun
SELECT * FROM produk WHERE nama LIKE 'Ka_s%'; -- "Kaos", "Kats", dstORDER BY dan LIMIT
SELECT * FROM produk ORDER BY harga ASC; -- termurah dulu
SELECT * FROM produk ORDER BY harga DESC; -- termahal dulu
-- urutkan berdasarkan beberapa kolom
SELECT * FROM produk ORDER BY stok DESC, nama ASC;
-- batasi jumlah hasil
SELECT * FROM produk ORDER BY harga DESC LIMIT 5;
-- 5 produk termahal
-- pagination: LIMIT jumlah OFFSET lewati sekian baris
SELECT * FROM produk ORDER BY id LIMIT 10 OFFSET 20;
-- ambil 10 baris, lewati 20 pertama (halaman ke-3 kalau 10/halaman)Catatan — Pola LIMIT ... OFFSET ... adalah dasar dari pagination — persis seperti yang dibahas di handbook Node.js dan Go sebelumnya (respons { data, meta }). Halaman 1 = OFFSET 0, halaman 2 = OFFSET 10 (kalau 10 per halaman), dan seterusnya: OFFSET = (halaman - 1) * jumlah_per_halaman.
DISTINCT: nilai unik
SELECT DISTINCT status FROM pesanan;
-- daftar status yang benar-benar ada, tanpa duplikatLatihan — Dari tabel produk yang sudah berisi data latihan sebelumnya, tulis query untuk: (1) produk dengan stok di bawah 10, diurutkan dari yang paling sedikit, (2) produk dengan nama mengandung kata "kaos" (case-insensitive), (3) 3 produk termahal.
Materi 8 · Modul 1
UPDATE: mengubah data
Mengubah baris yang sudah ada — dan cara paling umum bikin kesalahan fatal di SQL.
UPDATE dasar
UPDATE produk
SET harga = 95000
WHERE id = 1;
-- ubah beberapa kolom sekaligus
UPDATE produk
SET harga = 95000, stok = 30
WHERE id = 1;Peringatan paling penting di seluruh handbook ini — UPDATE tanpa WHERE akan mengubah SEMUA baris di tabel, tanpa peringatan, tanpa konfirmasi:
UPDATE produk SET harga = 0;
-- mengubah harga SEMUA produk jadi 0. tidak ada tombol undo.Kebiasaan yang menyelamatkan: sebelum menjalankan UPDATE atau DELETE, jalankan dulu SELECT dengan WHERE yang sama untuk melihat baris mana saja yang akan terdampak. Baru setelah yakin, ganti SELECT * jadi UPDATE ... SET ....
Kebiasaan aman: SELECT dulu, baru UPDATE
-- 1. cek dulu baris mana yang akan terdampak
SELECT * FROM produk WHERE stok = 0;
-- 2. kalau sudah sesuai harapan, baru jalankan UPDATE dengan WHERE yang SAMA
UPDATE produk SET status = 'habis' WHERE stok = 0;Update berdasarkan nilai kolom itu sendiri
-- kurangi stok setelah ada penjualan
UPDATE produk
SET stok = stok - 3
WHERE id = 1;
-- naikkan semua harga 10% (dengan WHERE untuk membatasi cakupan)
UPDATE produk
SET harga = harga * 1.1
WHERE id IN (1, 2, 3);Update aman untuk stok (mencegah nilai negatif)
-- tambahkan syarat di WHERE supaya tidak sampai minus
UPDATE produk
SET stok = stok - 5
WHERE id = 1 AND stok >= 5;
-- cek berapa baris yang benar-benar berubah
SELECT ROW_COUNT();
-- kalau 0, berarti syarat "stok >= 5" tidak terpenuhi — update ditolak, stok amanCatatan — Pola WHERE id = ? AND stok >= ? ini penting untuk mencegah overselling (menjual lebih banyak dari stok yang ada) saat banyak orang membeli produk yang sama bersamaan. Ini pola yang sama yang dibahas di handbook Go untuk kasus serupa — memastikan operasi tetap konsisten sekalipun terjadi bersamaan.
Latihan — Pada tabel produk, jalankan SELECT untuk melihat produk dengan stok di bawah 5. Lalu UPDATE produk itu untuk menambah stoknya sebanyak 20 (pakai stok = stok + 20, bukan menuliskan angka pasti). Verifikasi hasilnya dengan SELECT lagi.
Materi 9 · Modul 1
DELETE: menghapus data
Operasi paling berbahaya kalau dipakai sembarangan. Kebiasaan aman yang sama seperti UPDATE berlaku di sini.
DELETE dasar
DELETE FROM produk WHERE id = 5;
-- hapus banyak baris sekaligus
DELETE FROM produk WHERE stok = 0 AND dibuat_pada < '2025-01-01';Hati-hati — Sama seperti UPDATE: DELETE FROM produk; tanpa WHERE menghapus SEMUA baris di tabel, permanen. Selalu SELECT dulu dengan WHERE yang sama untuk memastikan baris yang akan terhapus sudah benar, sebelum mengubahnya jadi DELETE.
Soft delete: menandai terhapus, bukan menghapus sungguhan
Untuk data penting (pesanan, transaksi, akun pengguna), sering lebih aman menandai sebagai terhapus daripada benar-benar menghapusnya. Ini disebut soft delete.
-- tambahkan kolom penanda
ALTER TABLE produk ADD dihapus_pada TIMESTAMP NULL;
-- "menghapus" berarti mengisi timestamp ini, bukan DELETE sungguhan
UPDATE produk SET dihapus_pada = NOW() WHERE id = 5;
-- query normal harus selalu menyaring yang belum "dihapus"
SELECT * FROM produk WHERE dihapus_pada IS NULL;
-- bisa "dipulihkan" kapan saja
UPDATE produk SET dihapus_pada = NULL WHERE id = 5;Catatan — Soft delete berguna kalau kamu butuh riwayat (laporan penjualan yang mengacu produk lama), ada kemungkinan penghapusan tidak sengaja, atau alasan hukum/audit yang mengharuskan data tetap ada. Konsekuensinya: setiap query SELECT di aplikasi harus konsisten menambahkan WHERE dihapus_pada IS NULL — mudah lupa kalau tidak dikerjakan dengan disiplin (framework seperti Laravel mengotomatiskan ini lewat fitur bernama soft deletes).
TRUNCATE: mengosongkan tabel
TRUNCATE TABLE log_aktivitas;
-- menghapus SEMUA baris, dan reset AUTO_INCREMENT kembali ke 1
-- jauh lebih cepat dari DELETE untuk tabel besar, tapi TIDAK bisa memakai WHERE| Perintah | Karakteristik |
|---|---|
DELETE | Bisa pakai WHERE. Lebih lambat untuk data besar. AUTO_INCREMENT tidak reset. |
TRUNCATE | Tidak bisa WHERE — selalu semua baris. Sangat cepat. Reset AUTO_INCREMENT. |
Menghapus dengan aman dalam praktik
-- langkah 1: lihat dulu apa yang akan dihapus
SELECT * FROM produk WHERE stok = 0 AND dibuat_pada < '2024-01-01';
-- langkah 2: hitung dulu berapa baris (sanity check jumlahnya masuk akal)
SELECT COUNT(*) FROM produk WHERE stok = 0 AND dibuat_pada < '2024-01-01';
-- langkah 3: baru jalankan DELETE dengan WHERE yang PERSIS sama
DELETE FROM produk WHERE stok = 0 AND dibuat_pada < '2024-01-01';Latihan — Tambahkan kolom dihapus_pada ke tabel pelanggan. Praktikkan alur soft delete: "hapus" satu pelanggan (isi timestamp-nya), tulis query SELECT yang menampilkan hanya pelanggan aktif, lalu "pulihkan" pelanggan itu kembali.
Materi 10 · Modul 1
Fungsi bawaan yang sering dipakai
Mengolah data langsung di dalam query — teks, angka, dan tanggal.
Fungsi string
SELECT UPPER(nama) FROM produk; -- KAOS POLOS
SELECT LOWER(nama) FROM produk; -- kaos polos
SELECT LENGTH(nama) FROM produk; -- jumlah karakter
SELECT CONCAT(nama, ' - Rp', harga) FROM produk; -- gabung teks
SELECT TRIM(' spasi '); -- "spasi"
SELECT SUBSTRING(nama, 1, 4) FROM produk; -- 4 karakter pertama
SELECT REPLACE(nama, ' ', '-') FROM produk; -- ganti spasi jadi stripFungsi angka
SELECT ROUND(harga * 1.11) FROM produk; -- pembulatan
SELECT FLOOR(19.9); -- 19
SELECT CEIL(19.1); -- 20
SELECT ABS(-5000); -- 5000
SELECT harga FROM produk WHERE MOD(id, 2) = 0; -- id genapFungsi tanggal
SELECT NOW(); -- tanggal + jam saat ini
SELECT CURDATE(); -- tanggal hari ini saja
-- format tampilan tanggal
SELECT DATE_FORMAT(dibuat_pada, '%d/%m/%Y') FROM produk;
-- tambah / kurangi waktu
SELECT DATE_ADD(NOW(), INTERVAL 7 DAY);
SELECT DATE_SUB(NOW(), INTERVAL 1 MONTH);
-- selisih dua tanggal (dalam hari)
SELECT DATEDIFF('2026-12-31', CURDATE());
-- filter berdasarkan rentang tanggal
SELECT * FROM pesanan
WHERE dibuat_pada >= '2026-01-01' AND dibuat_pada < '2026-02-01';
-- pesanan dari 7 hari terakhir — pola yang sangat umum untuk dashboard
SELECT * FROM pesanan
WHERE dibuat_pada >= DATE_SUB(NOW(), INTERVAL 7 DAY);Hati-hati — Untuk memfilter "semua pesanan hari ini", hindari DATE(dibuat_pada) = CURDATE() pada tabel besar — fungsi yang membungkus kolom di WHERE mencegah MySQL memakai index (dibahas di Modul 5). Lebih baik pakai rentang eksplisit: WHERE dibuat_pada >= CURDATE() AND dibuat_pada < DATE_ADD(CURDATE(), INTERVAL 1 DAY).
CASE: kondisi di dalam query
SELECT
nama,
stok,
CASE
WHEN stok = 0 THEN 'Habis'
WHEN stok < 10 THEN 'Menipis'
ELSE 'Tersedia'
END AS status_stok
FROM produk;CASE memungkinkan logika kondisional langsung di query — berguna untuk membuat label, kategori, atau kolom turunan tanpa perlu memproses ulang di PHP.
Latihan — Tulis satu query pada tabel produk yang menampilkan: nama dalam huruf besar, harga sudah termasuk PPN 11% (dibulatkan), dan kolom label status stok (Habis/Menipis/Tersedia) memakai CASE seperti contoh di atas.
Materi 11 · Modul 2
Fungsi agregat
Meringkas banyak baris jadi satu angka — total, rata-rata, jumlah, dan sejenisnya.
Lima fungsi agregat dasar
SELECT COUNT(*) FROM produk; -- jumlah baris
SELECT SUM(stok) FROM produk; -- total semua stok
SELECT AVG(harga) FROM produk; -- rata-rata harga
SELECT MIN(harga) FROM produk; -- harga termurah
SELECT MAX(harga) FROM produk; -- harga termahal
-- gabungkan beberapa dalam satu query
SELECT
COUNT(*) AS total_produk,
SUM(stok) AS total_stok,
AVG(harga) AS rata_rata_harga
FROM produk;Catatan — COUNT(*) menghitung semua baris, termasuk yang punya nilai NULL di kolom manapun. COUNT(nama_kolom) hanya menghitung baris yang kolom itu tidak NULL — bedanya penting kalau datamu punya banyak nilai kosong.
Agregat dengan WHERE
SELECT COUNT(*) FROM produk WHERE stok = 0;
-- berapa produk yang habis
SELECT SUM(harga * stok) FROM produk WHERE stok > 0;
-- total nilai inventori yang masih tersediaWHERE menyaring baris sebelum diagregasi — jadi AVG(harga) dengan WHERE stok > 0 berarti "rata-rata harga, dihitung hanya dari produk yang stoknya masih ada".
Kasus nyata: ringkasan dashboard
SELECT
COUNT(*) AS total_pesanan,
SUM(CASE WHEN status = 'lunas' THEN 1 ELSE 0 END) AS pesanan_lunas,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pesanan_pending,
SUM(CASE WHEN status = 'lunas' THEN total ELSE 0 END) AS pendapatan
FROM pesanan
WHERE dibuat_pada >= DATE_SUB(NOW(), INTERVAL 30 DAY);Menggabungkan CASE (Materi 10) dengan SUM adalah pola umum untuk membuat ringkasan bercabang — "hitung yang lunas" dan "hitung yang pending" dalam satu query, alih-alih dua query terpisah.
Latihan — Pada tabel produk, tulis satu query yang menampilkan: total jumlah produk, total nilai inventori (harga × stok, dijumlahkan), harga termurah, dan harga termahal — semuanya dalam satu hasil baris.
Materi 12 · Modul 2
GROUP BY dan HAVING
Mengelompokkan baris untuk meringkas per kategori — bukan seluruh tabel sekaligus.
GROUP BY: agregat per kelompok
Fungsi agregat di Materi 11 meringkas seluruh tabel jadi satu angka. GROUP BY membuatnya meringkas per kelompok — misalnya total penjualan per kategori, bukan total keseluruhan.
SELECT kategori, COUNT(*) AS jumlah
FROM produk
GROUP BY kategori;
-- hasil:
-- kategori | jumlah
-- Pakaian | 12
-- Aksesoris | 5
-- Sepatu | 3Hati-hati — Aturan penting: setiap kolom di SELECT yang bukan hasil fungsi agregat harus ada di GROUP BY. SELECT kategori, nama, COUNT(*) FROM produk GROUP BY kategori akan error atau memberi hasil tak terduga, karena nama tidak jelas "milik baris mana" saat dikelompokkan per kategori.
GROUP BY dengan beberapa agregat
SELECT
kategori,
COUNT(*) AS jumlah_produk,
SUM(stok) AS total_stok,
AVG(harga) AS rata_harga
FROM produk
GROUP BY kategori
ORDER BY jumlah_produk DESC;HAVING: menyaring hasil kelompok
WHERE menyaring baris sebelum dikelompokkan. HAVING menyaring kelompok setelah diagregasi — kamu tidak bisa menulis kondisi agregat (seperti COUNT(*) > 5) di WHERE.
-- kategori dengan lebih dari 5 produk
SELECT kategori, COUNT(*) AS jumlah
FROM produk
GROUP BY kategori
HAVING COUNT(*) > 5;
-- kombinasi WHERE (sebelum kelompok) dan HAVING (sesudah kelompok)
SELECT kategori, AVG(harga) AS rata_harga
FROM produk
WHERE stok > 0 -- saring baris dulu: hanya yang stoknya ada
GROUP BY kategori
HAVING AVG(harga) > 100000 -- baru saring kelompok: rata-rata di atas 100rb;| Klausa | Menyaring apa | Kapan dievaluasi |
|---|---|---|
WHERE | Baris individual | Sebelum pengelompokan |
HAVING | Hasil kelompok (agregat) | Setelah pengelompokan |
Urutan klausa yang benar
SELECT ...
FROM ...
WHERE ... -- 1. saring baris
GROUP BY ... -- 2. kelompokkan
HAVING ... -- 3. saring kelompok
ORDER BY ... -- 4. urutkan hasil akhir
LIMIT ... -- 5. batasi jumlahCatatan — Urutan penulisan klausa di SQL selalu tetap seperti di atas — ini bukan pilihan gaya, tapi aturan sintaks. Menghafal urutan ini (WHERE → GROUP BY → HAVING → ORDER BY → LIMIT) akan sangat membantu saat menulis query yang lebih kompleks nanti.
Latihan — Tambahkan kolom kategori VARCHAR(50) ke tabel produk, isi beberapa nilai (misalnya "Pakaian", "Aksesoris"). Tulis query yang menampilkan tiap kategori beserta jumlah produk dan total nilai stoknya, tapi hanya untuk kategori yang punya lebih dari 1 produk, diurutkan dari yang paling banyak.
Materi 13 · Modul 2
JOIN: menggabungkan tabel
Konsep terpenting di database relasional — mengambil data yang tersebar di beberapa tabel dalam satu query.
Kenapa data dipisah ke beberapa tabel
Bayangkan menyimpan nama pelanggan berulang di setiap baris pesanan miliknya — kalau pelanggan itu ganti nama, kamu harus mengubah ratusan baris. Solusinya: simpan data pelanggan sekali di tabel pelanggan, lalu tabel pesanan cukup menyimpan referensi (id) ke pelanggan itu. JOIN adalah cara menyatukan kembali data yang terpisah ini saat dibutuhkan.
pelanggan pesanan
┌────┬───────┐ ┌────┬───────────────┬────────┐
│ id │ nama │ │ id │ pelanggan_id │ total │
├────┼───────┤ ├────┼───────────────┼────────┤
│ 1 │ Nus │ ◄─────────── │ 1 │ 1 │ 85000 │
│ 2 │ Rifki │ ◄─┐ │ 2 │ 2 │ 210000 │
└────┴───────┘ └───────── │ 3 │ 2 │ 45000 │
└────┴───────────────┴────────┘
pesanan.pelanggan_id MENUNJUK ke pelanggan.idINNER JOIN: hanya yang punya pasangan
SELECT pesanan.id, pelanggan.nama, pesanan.total
FROM pesanan
INNER JOIN pelanggan ON pesanan.pelanggan_id = pelanggan.id;
-- hasil:
-- id | nama | total
-- 1 | Nus | 85000
-- 2 | Rifki | 210000
-- 3 | Rifki | 45000Membacanya: "ambil pesanan, gabungkan dengan pelanggan di mana pesanan.pelanggan_id sama dengan pelanggan.id". INNER JOIN hanya menampilkan baris yang cocok di kedua tabel — kalau ada pesanan dengan pelanggan_id yang tidak ada di tabel pelanggan, baris itu tidak muncul.
Alias: mempersingkat penulisan
SELECT p.id, pl.nama, p.total
FROM pesanan AS p
INNER JOIN pelanggan AS pl ON p.pelanggan_id = pl.id;
-- AS boleh dihilangkan
SELECT p.id, pl.nama
FROM pesanan p
JOIN pelanggan pl ON p.pelanggan_id = pl.id;Catatan — Alias (p, pl) membuat query lebih ringkas dibaca, terutama saat menggabungkan banyak tabel. JOIN tanpa kata INNER di depannya di MySQL berarti sama dengan INNER JOIN — keduanya identik.
LEFT JOIN: tetap tampil walau tidak punya pasangan
-- semua pelanggan, LENGKAP DENGAN yang belum pernah pesan
SELECT pl.nama, p.id AS id_pesanan, p.total
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id;
-- hasil:
-- nama | id_pesanan | total
-- Nus | 1 | 85000
-- Rifki | 2 | 210000
-- Rifki | 3 | 45000
-- Jasmine| NULL | NULL ← belum pernah pesan, tetap muncul| Jenis JOIN | Perilaku |
|---|---|
INNER JOIN | Hanya baris yang cocok di kedua tabel. |
LEFT JOIN | Semua baris dari tabel kiri (yang disebut pertama), meski tak ada pasangan di kanan — kolom kanan jadi NULL. |
Catatan — Kapan pakai yang mana? Kalau kamu ingin "tampilkan semua X, termasuk yang belum punya Y" (semua pelanggan termasuk yang belum pernah order, semua produk termasuk yang belum pernah terjual), pakai LEFT JOIN. Kalau kamu hanya peduli data yang benar-benar punya pasangan di kedua sisi, INNER JOIN sudah cukup dan biasanya lebih cepat.
Mencari yang TIDAK punya pasangan
-- pelanggan yang BELUM PERNAH order sama sekali
SELECT pl.nama
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id
WHERE p.id IS NULL;
-- LEFT JOIN dulu (semua pelanggan tetap muncul), lalu saring yang sisi kanannya kosongLatihan — Buat tabel pesanan dengan kolom id, pelanggan_id (INT), dan total (INT). Isi beberapa baris yang mengacu ke id dari tabel pelanggan-mu. Tulis query INNER JOIN untuk melihat semua pesanan beserta nama pelanggannya, dan query LEFT JOIN untuk menemukan pelanggan yang belum pernah order.
Materi 14 · Modul 2
JOIN lanjutan dan agregat gabungan
Menggabungkan lebih dari dua tabel, dan meringkas data lintas tabel.
Menggabungkan tiga tabel atau lebih
Bayangkan struktur toko yang lebih lengkap: pesanan punya banyak item, dan tiap item mengacu ke satu produk.
pelanggan ──1:N── pesanan ──1:N── item_pesanan ──N:1── produk
SELECT
pl.nama AS pelanggan,
pr.nama AS produk,
ip.qty,
ip.harga_saat_beli
FROM pesanan p
JOIN pelanggan pl ON p.pelanggan_id = pl.id
JOIN item_pesanan ip ON ip.pesanan_id = p.id
JOIN produk pr ON ip.produk_id = pr.id
WHERE p.id = 1;Catatan — Setiap JOIN tambahan menyambung satu tabel lagi ke "meja kerja" query. Urutan penulisan JOIN umumnya tidak memengaruhi hasil (MySQL yang menentukan urutan eksekusi paling efisien), tapi menulisnya mengikuti alur relasi (pesanan → pelanggan, pesanan → item, item → produk) membuat query lebih mudah dibaca dan dilacak.
JOIN dengan GROUP BY: laporan lintas tabel
-- total belanja tiap pelanggan
SELECT pl.nama, COUNT(p.id) AS jumlah_pesanan, SUM(p.total) AS total_belanja
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id
GROUP BY pl.id, pl.nama
ORDER BY total_belanja DESC;
-- produk terlaris (butuh join ke item_pesanan)
SELECT pr.nama, SUM(ip.qty) AS total_terjual
FROM produk pr
JOIN item_pesanan ip ON ip.produk_id = pr.id
GROUP BY pr.id, pr.nama
ORDER BY total_terjual DESC
LIMIT 5;Hati-hati — Pakai LEFT JOIN (bukan JOIN biasa) kalau ingin pelanggan yang belum pernah pesan tetap muncul di laporan (dengan total 0), seperti contoh pertama di atas. Kalau memakai INNER JOIN, pelanggan tanpa pesanan akan hilang sepenuhnya dari hasil — sering jadi bug yang tidak disadari saat membuat laporan.
Self join: tabel bergabung dengan dirinya sendiri
Kadang sebuah tabel punya relasi ke dirinya sendiri — misalnya karyawan yang punya atasan, yang juga seorang karyawan.
CREATE TABLE karyawan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100),
atasan_id INT NULL -- mengacu ke id di tabel yang SAMA
);
-- tampilkan tiap karyawan beserta nama atasannya
SELECT k.nama AS karyawan, a.nama AS atasan
FROM karyawan k
LEFT JOIN karyawan a ON k.atasan_id = a.id;Trik di sini: tabel yang sama (karyawan) dipakai dua kali dengan alias berbeda (k dan a) — MySQL memperlakukannya seolah dua tabel terpisah.
Latihan — Buat tabel item_pesanan (id, pesanan_id, produk_id, qty). Isi beberapa baris. Tulis query yang menampilkan, untuk tiap pesanan: nama pelanggan, daftar nama produk yang dibeli, dan qty-nya — menggabungkan empat tabel (pelanggan, pesanan, item_pesanan, produk).
Materi 15 · Modul 2
Subquery
Query di dalam query — untuk pertanyaan yang butuh langkah perantara.
Subquery di WHERE
-- produk yang harganya di atas rata-rata semua produk
SELECT nama, harga
FROM produk
WHERE harga > (SELECT AVG(harga) FROM produk);
-- pelanggan yang pernah membuat pesanan di atas 200rb
SELECT nama FROM pelanggan
WHERE id IN (
SELECT pelanggan_id FROM pesanan WHERE total > 200000
);Subquery di dalam kurung dijalankan lebih dulu, hasilnya dipakai oleh query luar. Contoh pertama: MySQL menghitung rata-rata harga dulu (misal 130000), baru mencari produk dengan harga di atas angka itu.
Subquery vs JOIN: kapan pakai yang mana
-- dengan subquery
SELECT nama FROM pelanggan
WHERE id IN (SELECT pelanggan_id FROM pesanan WHERE total > 200000);
-- query yang sama, dengan JOIN
SELECT DISTINCT pl.nama
FROM pelanggan pl
JOIN pesanan p ON p.pelanggan_id = pl.id
WHERE p.total > 200000;Catatan — Keduanya menghasilkan hal yang sama di sini. Aturan praktis: pakai JOIN saat kamu butuh menampilkan kolom dari kedua tabel sekaligus; pakai subquery saat kamu hanya butuh menyaring berdasarkan tabel lain tanpa menampilkan kolomnya. Untuk data besar, JOIN umumnya lebih cepat karena MySQL bisa mengoptimalkannya lebih baik — dibahas lagi di Modul 5.
Subquery di SELECT (scalar subquery)
-- tampilkan tiap pelanggan beserta jumlah pesanannya
SELECT
pl.nama,
(SELECT COUNT(*) FROM pesanan WHERE pelanggan_id = pl.id) AS jumlah_pesanan
FROM pelanggan pl;Hati-hati — Subquery di SELECT seperti di atas dijalankan sekali per baris dari query luar — untuk 1000 pelanggan, itu berarti 1000 subquery kecil. Untuk data besar, versi dengan LEFT JOIN + GROUP BY (seperti Materi 14) biasanya jauh lebih cepat karena cuma satu kali proses.
EXISTS: cek keberadaan tanpa mengambil datanya
-- pelanggan yang PERNAH order (tanpa perlu tahu detail pesanannya)
SELECT nama FROM pelanggan pl
WHERE EXISTS (
SELECT 1 FROM pesanan WHERE pelanggan_id = pl.id
);
-- kebalikannya: yang BELUM PERNAH order
SELECT nama FROM pelanggan pl
WHERE NOT EXISTS (
SELECT 1 FROM pesanan WHERE pelanggan_id = pl.id
);EXISTS lebih efisien daripada IN untuk mengecek keberadaan data, karena MySQL bisa berhenti begitu menemukan satu kecocokan — tidak perlu menghitung semuanya.
Latihan — Tulis query dengan subquery untuk menemukan produk yang belum pernah ada di item_pesanan sama sekali (produk yang belum pernah terjual). Coba dengan NOT IN dan dengan NOT EXISTS, bandingkan hasilnya.
Materi 16 · Modul 2
UNION dan window function
Menggabungkan hasil beberapa query, dan menghitung sesuatu relatif terhadap baris lain.
UNION: menumpuk hasil dua query
-- gabungkan daftar dari dua tabel berbeda jadi satu hasil
SELECT nama, 'pelanggan' AS jenis FROM pelanggan
UNION
SELECT nama, 'supplier' AS jenis FROM supplier;
-- UNION otomatis membuang duplikat. UNION ALL menyimpan semuanya (lebih cepat)
SELECT email FROM pelanggan
UNION ALL
SELECT email FROM newsletter_subscriber;Catatan — Syarat UNION: kedua query harus punya jumlah kolom yang sama, dengan tipe data yang kompatibel. Nama kolom di hasil akhir mengikuti query yang pertama. Pakai UNION ALL (bukan UNION) kalau kamu yakin tidak ada duplikat atau memang tidak masalah ada duplikat — jauh lebih cepat karena tidak perlu mengecek dan membuang baris ganda.
Window function: ROW_NUMBER, RANK
Window function menghitung sesuatu relatif terhadap sekelompok baris, tapi tetap menampilkan tiap baris individual — berbeda dari GROUP BY yang meringkas jadi satu baris per kelompok.
-- beri nomor urut produk berdasarkan harga termahal
SELECT
nama,
harga,
ROW_NUMBER() OVER (ORDER BY harga DESC) AS peringkat
FROM produk;
-- nomor urut TERPISAH per kategori (PARTITION BY)
SELECT
kategori,
nama,
harga,
ROW_NUMBER() OVER (PARTITION BY kategori ORDER BY harga DESC) AS peringkat_di_kategori
FROM produk;SELECT * FROM (
SELECT
kategori, nama, harga,
ROW_NUMBER() OVER (PARTITION BY kategori ORDER BY harga DESC) AS rn
FROM produk
) AS t
WHERE rn = 1;
-- hasil: satu produk termahal untuk TIAP kategoriCatatan — Ini pola yang sangat berguna dan sulit dilakukan dengan GROUP BY biasa: "ambil baris teratas per kelompok" (produk termahal per kategori, transaksi terbaru per pelanggan, dsb). PARTITION BY berarti "hitung ulang nomor urutnya untuk tiap kelompok", mirip cara kerja GROUP BY tapi tanpa meringkas jadi satu baris.
Latihan — Pada tabel produk yang sudah punya kolom kategori, tulis query dengan window function untuk menampilkan 2 produk termurah di tiap kategori (bukan termurah keseluruhan).
Materi 17 · Modul 3
Primary key dan foreign key
Dua jenis kunci yang menjaga integritas data — identitas tiap baris, dan hubungan antar tabel.
Primary key: identitas unik tiap baris
Sudah kamu pakai sejak Materi 3 (id INT AUTO_INCREMENT PRIMARY KEY), tapi sekarang pahami kenapa itu penting. Primary key adalah kolom (atau kombinasi kolom) yang secara unik mengidentifikasi satu baris — tidak boleh ada dua baris dengan primary key yang sama, dan nilainya tidak boleh NULL.
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150)
);
-- primary key komposit (gabungan dua kolom) — untuk kasus tertentu
CREATE TABLE favorit (
pelanggan_id INT,
produk_id INT,
PRIMARY KEY (pelanggan_id, produk_id)
-- kombinasi keduanya harus unik: satu pelanggan tak bisa favorit produk yang sama dua kali
);Catatan — Hampir selalu, cara paling aman adalah memakai kolom id INT AUTO_INCREMENT tersendiri sebagai primary key (disebut surrogate key) — bukan data asli seperti email atau nomor KTP. Alasannya: data asli bisa berubah (orang ganti email) atau ternyata tidak seunik yang dikira. Kolom id murni buatan sistem, tidak akan pernah perlu diubah.
Foreign key: menghubungkan ke tabel lain
Foreign key adalah kolom yang menunjuk ke primary key di tabel lain — inilah yang membuat JOIN (Materi 13) bekerja, dan yang membuat database menolak data yang "menunjuk ke sesuatu yang tidak ada".
CREATE TABLE pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pelanggan_id INT NOT NULL,
total INT NOT NULL,
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);
-- sekarang database MENOLAK pesanan dengan pelanggan_id yang tidak ada
INSERT INTO pesanan (pelanggan_id, total) VALUES (9999, 50000);
-- ERROR: Cannot add or update a child row: a foreign key constraint failsHati-hati — Tabel harus memakai storage engine InnoDB (default di MySQL modern) supaya foreign key benar-benar ditegakkan. Engine lama seperti MyISAM menerima sintaks FOREIGN KEY tapi diam-diam tidak menegakkannya — jebakan klasik yang bikin data jadi tidak konsisten tanpa peringatan.
ON DELETE: apa yang terjadi kalau induk dihapus
CREATE TABLE pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pelanggan_id INT NOT NULL,
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
ON DELETE RESTRICT -- TOLAK hapus pelanggan kalau masih punya pesanan (default, paling aman)
);
CREATE TABLE item_pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pesanan_id INT NOT NULL,
FOREIGN KEY (pesanan_id) REFERENCES pesanan(id)
ON DELETE CASCADE -- hapus pesanan → OTOMATIS hapus semua itemnya juga
);| Opsi | Perilaku saat induk dihapus |
|---|---|
RESTRICT (default) | Tolak penghapusan induk selama masih ada data anak yang mengacu ke situ. |
CASCADE | Otomatis hapus semua data anak yang terkait juga. |
SET NULL | Set kolom foreign key anak jadi NULL (kolomnya harus nullable). |
Catatan — Pilih dengan hati-hati sesuai makna bisnisnya. CASCADE masuk akal untuk item_pesanan (kalau pesanannya dihapus, item di dalamnya memang seharusnya ikut hilang). RESTRICT lebih aman untuk pelanggan → pesanan (kamu tidak mau tiba-tiba kehilangan riwayat pesanan hanya karena menghapus data pelanggan).
Latihan — Pada tabel pesanan dan item_pesanan yang sudah kamu buat di latihan sebelumnya, tambahkan constraint FOREIGN KEY yang sesuai (kalau belum ada). Uji: coba hapus satu pelanggan yang masih punya pesanan, pastikan ditolak. Coba hapus satu pesanan dengan ON DELETE CASCADE di item_pesanan, pastikan item-nya ikut terhapus.
Materi 18 · Modul 3
Merancang relasi: one-to-many dan many-to-many
Tiga pola relasi dasar yang mencakup hampir semua kebutuhan desain database.
One-to-many (satu-ke-banyak)
Pola paling umum: satu baris di tabel A bisa punya banyak baris terkait di tabel B, tapi tiap baris B hanya milik satu baris A. Satu pelanggan punya banyak pesanan; satu pesanan cuma milik satu pelanggan.
pelanggan (1) ──────── (N) pesanan pelanggan pesanan ┌────┬───────┐ ┌────┬──────────────┐ │ id │ nama │ │ id │ pelanggan_id │ ← foreign key ada di sisi "many" └────┴───────┘ └────┴──────────────┘
Aturan sederhana: foreign key selalu diletakkan di sisi "many" (tabel pesanan menyimpan pelanggan_id, bukan sebaliknya) — karena satu pesanan hanya bisa punya satu pelanggan, tapi satu pelanggan bisa punya banyak pesanan.
Many-to-many (banyak-ke-banyak)
Kapan satu baris A bisa berelasi dengan banyak baris B, dan satu baris B juga bisa berelasi dengan banyak baris A. Contoh klasik: satu pesanan bisa berisi banyak produk, dan satu produk bisa muncul di banyak pesanan berbeda.
pesanan (N) ──── item_pesanan ──── (N) produk
↑ tabel PIVOT / PENGHUBUNG
pesanan item_pesanan produk
┌────┐ ┌────┬─────────────┬───────────┐ ┌────┐
│ id │◄──────────│ id │ pesanan_id │ produk_id │───►│ id │
└────┘ │ │ │ │ └────┘
└────┴─────────────┴───────────┘
+ kolom tambahan: qty, harga_saat_beliCREATE TABLE item_pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pesanan_id INT NOT NULL,
produk_id INT NOT NULL,
qty INT NOT NULL DEFAULT 1,
harga_saat_beli INT NOT NULL, -- simpan harga SAAT ITU, bukan mengacu harga produk sekarang
FOREIGN KEY (pesanan_id) REFERENCES pesanan(id) ON DELETE CASCADE,
FOREIGN KEY (produk_id) REFERENCES produk(id) ON DELETE RESTRICT
);Catatan penting — Kolom harga_saat_beli di atas sengaja menyimpan salinan harga, bukan mengandalkan JOIN ke harga produk saat ini. Kalau tidak, riwayat pesanan lama akan ikut berubah setiap kali harga produk diubah — padahal nota pelanggan seharusnya tetap menunjukkan harga saat mereka beli. Ini contoh nyata kapan menduplikasi data justru pilihan yang tepat.
Tabel penghubung seperti item_pesanan disebut pivot table atau junction table — tugasnya menyatukan dua relasi one-to-many jadi satu relasi many-to-many.
One-to-one (satu-ke-satu)
Lebih jarang, tapi berguna untuk memisahkan data yang jarang diakses dari tabel utama — misalnya profil detail yang terpisah dari data login pokok.
CREATE TABLE pengguna (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL
);
CREATE TABLE profil (
pengguna_id INT PRIMARY KEY, -- PK di sini SEKALIGUS foreign key
alamat TEXT,
tanggal_lahir DATE,
FOREIGN KEY (pengguna_id) REFERENCES pengguna(id) ON DELETE CASCADE
);Ringkasan memilih pola relasi
| Pertanyaan | Pola |
|---|---|
| "Satu X punya banyak Y, satu Y milik satu X" | One-to-many — FK di tabel Y |
| "Satu X bisa banyak Y, dan satu Y bisa banyak X" | Many-to-many — butuh tabel pivot |
| "Satu X selalu tepat satu Y, dan sebaliknya" | One-to-one — FK sekaligus jadi PK di salah satu tabel |
Latihan — Rancang skema untuk sistem blog sederhana: penulis bisa menulis banyak artikel (one-to-many), dan satu artikel bisa punya banyak tag, satu tag bisa dipakai banyak artikel (many-to-many). Tulis CREATE TABLE untuk penulis, artikel, tag, dan tabel pivot artikel_tag.
Materi 19 · Modul 3
Normalisasi
Prinsip merancang tabel supaya data tidak berulang dan tidak mudah jadi tidak konsisten.
Masalah data yang tidak dinormalisasi
-- SEMUA data digabung jadi satu tabel raksasa
CREATE TABLE pesanan (
id INT PRIMARY KEY,
nama_pelanggan VARCHAR(150),
email_pelanggan VARCHAR(255),
alamat_pelanggan TEXT,
nama_produk VARCHAR(150),
harga_produk INT
);Masalahnya: kalau pelanggan yang sama order 10 kali, namanya, emailnya, alamatnya terulang 10 kali. Kalau dia pindah alamat, kamu harus meng-update 10 baris — dan kalau ada satu yang terlewat, datanya jadi tidak konsisten (baris mana yang benar?). Ini yang disebut anomali update.
Bentuk normal pertama (1NF): satu nilai per sel
-- satu kolom berisi BANYAK nilai sekaligus — melanggar 1NF
CREATE TABLE pesanan (
id INT PRIMARY KEY,
produk_dibeli VARCHAR(500) -- isinya: "Kaos, Topi, Hoodie" ← BURUK
);Solusinya: pecah jadi tabel terpisah, satu baris per produk (seperti item_pesanan di Materi 18). Tiap sel harus berisi satu nilai tunggal, bukan daftar yang digabung jadi satu string.
Bentuk normal kedua (2NF): tidak ada duplikasi berdasarkan sebagian primary key
Berlaku untuk tabel dengan primary key gabungan (komposit). Setiap kolom non-key harus bergantung pada seluruh primary key, bukan cuma sebagiannya.
-- nama_produk seharusnya di tabel produk, BUKAN di item_pesanan
-- karena nama_produk hanya bergantung pada produk_id, bukan kombinasi (pesanan_id, produk_id)
CREATE TABLE item_pesanan (
pesanan_id INT,
produk_id INT,
qty INT,
PRIMARY KEY (pesanan_id, produk_id)
-- TIDAK ada nama_produk di sini — itu tugas tabel produk
);Bentuk normal ketiga (3NF): tidak ada ketergantungan transitif
-- kode_pos MENENTUKAN nama_kota — tapi keduanya disimpan di tabel pelanggan
CREATE TABLE pelanggan (
id INT PRIMARY KEY,
nama VARCHAR(150),
kode_pos VARCHAR(10),
nama_kota VARCHAR(100) -- BURUK: bergantung pada kode_pos, bukan langsung pada id
);Kalau nama_kota sepenuhnya ditentukan oleh kode_pos (bukan oleh identitas pelanggannya), sebaiknya dipisah ke tabel kode_pos tersendiri. Ini mencegah data kota yang tidak konsisten untuk kode pos yang sama.
Kapan boleh "melanggar" normalisasi
Normalisasi murni kadang butuh banyak JOIN untuk membaca data sederhana — bisa memperlambat query pada data besar. Denormalisasi (sengaja menduplikasi sedikit data) kadang jadi trade-off yang masuk akal:
harga_saat_belidiitem_pesanan(Materi 18) — sengaja disalin, karena riwayat harus tetap seperti saat transaksi terjadi.- Kolom ringkasan seperti
total_pesanandi tabelpesanan, dihitung sekali saat disimpan, alih-alih menjumlahkanitem_pesanansetiap kali dibaca.
Catatan — Aturan praktis untuk pemula: normalisasi dulu sampai 3NF sebagai default. Denormalisasi hanya setelah kamu benar-benar mengukur ada masalah performa nyata (dibahas di Modul 5), bukan sebagai tebakan di awal. Normalisasi yang benar mencegah jauh lebih banyak masalah (data tidak konsisten) daripada masalah performa yang mungkin belum tentu terjadi.
Latihan — Tabel ini melanggar normalisasi: pesanan(id, nama_pelanggan, email_pelanggan, nama_produk_1, nama_produk_2, nama_produk_3). Rancang ulang jadi beberapa tabel yang ternormalisasi dengan benar (pelanggan, pesanan, item_pesanan, produk), dan jelaskan singkat masalah apa yang diperbaiki di tiap perubahan.
Materi 20 · Modul 3
Index
Struktur bantu yang membuat pencarian data jauh lebih cepat — tanpanya, database "membaca semua halaman buku" tiap kali mencari sesuatu.
Analogi: daftar isi buku
Tanpa index, mencari satu baris di tabel jutaan baris berarti MySQL memeriksa setiap baris satu per satu (disebut full table scan) — seperti membaca seluruh buku halaman demi halaman untuk mencari satu topik. Index adalah seperti daftar isi: struktur terurut yang membuat MySQL bisa langsung "melompat" ke tempat yang tepat.
Membuat index
-- index pada satu kolom yang sering dicari
CREATE INDEX idx_produk_nama ON produk(nama);
-- index pada kolom yang sering dipakai untuk filter
CREATE INDEX idx_pesanan_status ON pesanan(status);
-- lihat index apa saja yang ada di sebuah tabel
SHOW INDEX FROM produk;
-- hapus index
DROP INDEX idx_produk_nama ON produk;Catatan — Primary key dan kolom UNIQUE otomatis mendapat index — kamu tidak perlu membuatnya manual. Fokus menambah index pada kolom yang sering dipakai di WHERE, JOIN ... ON, atau ORDER BY, tapi belum punya index otomatis.
Index gabungan (composite index)
-- index pada KOMBINASI beberapa kolom yang sering dipakai BERSAMAAN
CREATE INDEX idx_pesanan_pelanggan_status ON pesanan(pelanggan_id, status);
-- index ini efektif dipakai untuk query yang menyaring pelanggan_id (di depan)
SELECT * FROM pesanan WHERE pelanggan_id = 5;
SELECT * FROM pesanan WHERE pelanggan_id = 5 AND status = 'lunas';
-- TAPI tidak efektif untuk query yang HANYA menyaring status saja
SELECT * FROM pesanan WHERE status = 'lunas'; -- index di atas TIDAK terpakai optimalHati-hati — Urutan kolom di index gabungan sangat penting — ibarat buku telepon yang diurutkan nama-lalu-kota: kamu bisa cepat mencari "semua yang namanya Budi", tapi tidak bisa langsung mencari "semua yang tinggal di Jakarta" tanpa membaca seluruh buku. Urutkan kolom index dari yang paling sering dipakai sendirian atau paling menyaring banyak data, ke yang paling jarang.
Melihat apakah query memakai index
EXPLAIN SELECT * FROM produk WHERE nama = 'Kaos Polos';
-- perhatikan kolom "type" dan "key" di hasilnya:
-- type: ALL → BURUK, full table scan (baca semua baris)
-- type: ref/const → BAIK, memakai index
-- key: NULL → tidak ada index yang dipakai
-- key: idx_... → nama index yang dipakaiEXPLAIN dibahas lebih dalam di Modul 5, tapi sekarang cukup tahu: ini alat untuk mengecek apakah query-mu memakai index atau melakukan full table scan yang lambat.
Kapan tidak perlu (atau tidak boleh) index
- Tabel kecil. Untuk tabel dengan puluhan atau ratusan baris, full table scan sudah cukup cepat — index tidak memberi manfaat berarti.
- Kolom yang jarang dipakai untuk mencari/filter. Index tidak gratis — setiap
INSERT/UPDATEjuga harus memperbarui index, jadi menambah index pada kolom yang tak pernah dicari cuma memperlambat penulisan tanpa manfaat. - Kolom dengan sedikit nilai unik. Index pada kolom
ENUM('L','P')(jenis kelamin) biasanya kurang membantu — terlalu banyak baris yang sama nilainya.
Latihan — Pada tabel pesanan, tambahkan index pada pelanggan_id dan index gabungan pada (pelanggan_id, status). Jalankan EXPLAIN pada query yang mencari pesanan berdasarkan pelanggan_id, dan perhatikan kolom key di hasilnya untuk memastikan index terpakai.
Materi 21 · Modul 3
Merancang skema dari nol: studi kasus mini
Menerapkan semua prinsip Modul 3 lewat satu contoh utuh — dari kebutuhan sampai skema final.
Kebutuhan: sistem presensi sekolah sederhana
Bayangkan diminta merancang database untuk mencatat kehadiran siswa. Kebutuhannya: sekolah punya banyak kelas, tiap kelas punya banyak siswa, tiap siswa punya banyak catatan kehadiran per hari, dan tiap catatan kehadiran punya status (hadir/izin/sakit/alpa).
Langkah 1: identifikasi entitas (calon tabel)
- kelas — daftar kelas (10A, 10B, dst).
- siswa — data tiap siswa.
- presensi — catatan kehadiran per siswa per hari.
Langkah 2: tentukan relasi
- kelas → siswa: one-to-many (satu kelas punya banyak siswa, satu siswa di satu kelas).
- siswa → presensi: one-to-many (satu siswa punya banyak catatan presensi, satu presensi milik satu siswa).
Langkah 3: tulis skema
CREATE TABLE kelas (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL, -- "10A"
wali_kelas VARCHAR(150)
);
CREATE TABLE siswa (
id INT AUTO_INCREMENT PRIMARY KEY,
kelas_id INT NOT NULL,
nis VARCHAR(20) NOT NULL UNIQUE, -- nomor induk siswa, harus unik
nama VARCHAR(150) NOT NULL,
FOREIGN KEY (kelas_id) REFERENCES kelas(id) ON DELETE RESTRICT
);
CREATE TABLE presensi (
id INT AUTO_INCREMENT PRIMARY KEY,
siswa_id INT NOT NULL,
tanggal DATE NOT NULL,
status ENUM('hadir','izin','sakit','alpa') NOT NULL DEFAULT 'hadir',
dicatat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (siswa_id) REFERENCES siswa(id) ON DELETE CASCADE,
UNIQUE KEY unik_siswa_tanggal (siswa_id, tanggal), -- cegah dobel catat di hari yang sama
INDEX idx_tanggal (tanggal) -- untuk laporan per tanggal
);Catatan — Perhatikan UNIQUE KEY unik_siswa_tanggal (siswa_id, tanggal) — ini constraint gabungan yang menegakkan aturan bisnis "satu siswa cuma boleh punya satu catatan presensi per tanggal". Ini contoh bagaimana constraint (Materi 5) dan desain relasi (Materi 17–18) bekerja sama menjaga data tetap masuk akal.
Langkah 4: uji dengan query nyata
-- rekap kehadiran per siswa bulan ini
SELECT
s.nama,
SUM(CASE WHEN p.status = 'hadir' THEN 1 ELSE 0 END) AS hadir,
SUM(CASE WHEN p.status = 'alpa' THEN 1 ELSE 0 END) AS alpa
FROM siswa s
LEFT JOIN presensi p ON p.siswa_id = s.id
AND p.tanggal >= DATE_FORMAT(NOW(), '%Y-%m-01')
WHERE s.kelas_id = 1
GROUP BY s.id, s.nama;Kalau sebuah query untuk kebutuhan nyata (seperti rekap di atas) bisa ditulis secara alami tanpa perlu membongkar struktur tabel, itu tanda skema-nya sudah dirancang dengan baik. Kalau kamu kesulitan menulis query sederhana, sering kali itu tanda ada yang perlu diperbaiki di desainnya.
Latihan — Perluas skema ini: tambahkan tabel guru yang mengajar banyak mata_pelajaran di banyak kelas (relasi many-to-many, butuh tabel pivot). Tulis skemanya lengkap dengan foreign key dan constraint yang sesuai.
Materi 22 · Modul 4
Transaksi
Memastikan sekelompok operasi berjalan semuanya, atau tidak sama sekali — kunci konsistensi data untuk operasi kritis.
Masalah yang diselesaikan transaksi
Bayangkan transfer saldo antar rekening: kurangi saldo pengirim, tambah saldo penerima. Ini dua perintah UPDATE terpisah. Kalau server mati tepat setelah perintah pertama tapi sebelum yang kedua, saldo pengirim sudah berkurang tapi penerima tidak pernah menerima — uang "hilang". Transaksi memastikan kedua operasi ini diperlakukan sebagai satu kesatuan yang tidak bisa dipecah.
BEGIN, COMMIT, ROLLBACK
START TRANSACTION;
UPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;
UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;
COMMIT;
-- kedua UPDATE baru benar-benar "permanen" tersimpan setelah COMMITSTART TRANSACTION;
UPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;
-- misal di sini terdeteksi saldo jadi negatif — batalkan semuanya
ROLLBACK;
-- UPDATE di atas DIBATALKAN sepenuhnya, seolah tidak pernah terjadiCatatan — COMMIT mengunci perubahan secara permanen. ROLLBACK membatalkan semua perubahan sejak START TRANSACTION, mengembalikan data seperti semula. Selama transaksi belum di-COMMIT, koneksi lain tidak melihat perubahan yang sedang berlangsung — ini yang menjaga konsistensi.
Sifat ACID
| Sifat | Artinya |
|---|---|
| Atomicity | Semua operasi dalam transaksi berhasil semua, atau gagal semua — tak ada "separuh jalan". |
| Consistency | Transaksi membawa database dari satu keadaan valid ke keadaan valid lain — constraint tetap terjaga. |
| Isolation | Transaksi yang berjalan bersamaan tidak saling mengganggu satu sama lain. |
| Durability | Setelah COMMIT, data tersimpan permanen — bahkan kalau server mati detik berikutnya. |
Empat sifat ini (disingkat ACID) adalah jaminan yang diberikan engine InnoDB. Inilah alasan utama kenapa InnoDB (bukan MyISAM) adalah pilihan default untuk hampir semua aplikasi modern.
Kasus nyata: mengurangi stok saat checkout
START TRANSACTION;
-- 1. buat pesanan
INSERT INTO pesanan (pelanggan_id, total) VALUES (5, 255000);
SET @id_pesanan = LAST_INSERT_ID();
-- 2. catat item, DAN kurangi stok — hanya kalau stok cukup
INSERT INTO item_pesanan (pesanan_id, produk_id, qty, harga_saat_beli)
VALUES (@id_pesanan, 1, 3, 85000);
UPDATE produk SET stok = stok - 3 WHERE id = 1 AND stok >= 3;
-- 3. cek: kalau stok tidak cukup, ROW_COUNT() akan 0
-- (di aplikasi nyata, PHP yang mengecek ini dan memutuskan COMMIT atau ROLLBACK)
COMMIT;Hati-hati — Jangan biarkan transaksi terbuka terlalu lama (misalnya menunggu input pengguna di tengah transaksi). Transaksi yang lama menahan lock pada baris yang terlibat, bisa memperlambat atau memblokir operasi lain yang butuh baris sama. Selalu selesaikan transaksi (COMMIT atau ROLLBACK) secepat mungkin.
Latihan — Buat tabel rekening (id, nama, saldo). Isi dua baris dengan saldo awal. Tulis skenario transaksi transfer saldo dari satu rekening ke rekening lain, lengkap dengan pengecekan saldo cukup sebelum COMMIT. Uji juga skenario ROLLBACK saat saldo tidak cukup.
Materi 23 · Modul 4
View
Query kompleks yang disimpan dengan nama, dipakai seolah-olah tabel biasa.
Membuat dan memakai view
Kalau kamu punya query gabungan (JOIN + agregasi) yang sering dipakai berulang, simpan sebagai view — nama tersimpan yang mewakili query itu.
CREATE VIEW ringkasan_pelanggan AS
SELECT
pl.id,
pl.nama,
COUNT(p.id) AS jumlah_pesanan,
COALESCE(SUM(p.total), 0) AS total_belanja
FROM pelanggan pl
LEFT JOIN pesanan p ON p.pelanggan_id = pl.id
GROUP BY pl.id, pl.nama;
-- sekarang bisa dipakai SEPERTI TABEL BIASA
SELECT * FROM ringkasan_pelanggan WHERE total_belanja > 500000;
SELECT * FROM ringkasan_pelanggan ORDER BY total_belanja DESC LIMIT 10;Catatan — COALESCE(nilai, cadangan) mengembalikan nilai pertama yang tidak NULL — di atas dipakai supaya pelanggan tanpa pesanan menampilkan 0, bukan NULL (karena SUM pada baris kosong menghasilkan NULL). View tidak menyimpan data sendiri — setiap kali dipakai, query di baliknya dijalankan ulang, jadi hasilnya selalu mencerminkan data terkini.
Kenapa view berguna
- Menyembunyikan kerumitan. Kode aplikasi cukup
SELECT * FROM ringkasan_pelanggan, tanpa perlu tahu ada JOIN dan GROUP BY di baliknya. - Konsistensi logika. Kalau logikanya perlu diubah (misalnya menambah filter), cukup ubah definisi view sekali — semua tempat yang memakainya otomatis ikut ter-update.
- Membatasi akses kolom. View bisa dibuat untuk menampilkan hanya kolom tertentu — berguna kalau ada pengguna database yang tak boleh melihat kolom sensitif seperti
password_hash.
Mengelola view
SHOW FULL TABLES WHERE table_type = 'VIEW'; -- lihat semua view
SHOW CREATE VIEW ringkasan_pelanggan; -- lihat definisinya
CREATE OR REPLACE VIEW ringkasan_pelanggan AS
SELECT ...; -- ubah definisi view yang sudah ada
DROP VIEW ringkasan_pelanggan; -- hapus viewLatihan — Buat view produk_terlaris yang menampilkan nama produk beserta total qty terjual (dari item_pesanan), diurutkan dari yang paling banyak terjual. Query view itu untuk menampilkan 5 produk teratas.
Materi 24 · Modul 4
Stored procedure dan trigger
Logika yang berjalan langsung di database — sekilas pengenalan, dipakai secukupnya dan hati-hati.
Stored procedure: kumpulan perintah tersimpan
DELIMITER $$
CREATE PROCEDURE TambahStokProduk(
IN p_produk_id INT,
IN p_jumlah INT
)
BEGIN
UPDATE produk
SET stok = stok + p_jumlah
WHERE id = p_produk_id;
END$$
DELIMITER ;
-- memanggilnya
CALL TambahStokProduk(1, 50);Catatan — DELIMITER $$ sementara mengganti "tanda akhir perintah" dari ; jadi $$ — supaya titik koma di dalam isi procedure tidak dikira mengakhiri seluruh perintah CREATE PROCEDURE. Setelah selesai, dikembalikan lagi ke ; dengan DELIMITER ;.
Trigger: kode yang jalan otomatis saat data berubah
DELIMITER $$
CREATE TRIGGER catat_perubahan_stok
AFTER UPDATE ON produk
FOR EACH ROW
BEGIN
IF OLD.stok != NEW.stok THEN
INSERT INTO log_stok (produk_id, stok_lama, stok_baru, diubah_pada)
VALUES (NEW.id, OLD.stok, NEW.stok, NOW());
END IF;
END$$
DELIMITER ;
-- sekarang SETIAP kali stok produk berubah, otomatis tercatat di log_stok
UPDATE produk SET stok = 20 WHERE id = 1;
-- trigger jalan otomatis, tanpa perlu ditulis manual setiap kaliOLD merujuk nilai sebelum perubahan, NEW merujuk nilai sesudahnya — tersedia otomatis di dalam trigger UPDATE.
Kapan (dan kapan tidak) memakainya
Pertimbangkan baik-baik — Stored procedure dan trigger memindahkan sebagian logika bisnis dari kode aplikasi (PHP) ke dalam database. Ini punya trade-off nyata: logikanya jadi tersebar di dua tempat berbeda, lebih sulit diuji dengan alat testing biasa (Materi 28 handbook PHP), dan tidak semua developer di tim familiar dengan sintaksnya. Untuk kebanyakan aplikasi web modern, lebih baik menaruh logika bisnis di kode aplikasi (PHP), dan menyimpan trigger/procedure hanya untuk hal yang benar-benar spesifik di level data — seperti audit log otomatis, atau constraint yang tidak bisa diekspresikan lewat CHECK biasa.
Latihan — Tidak ada latihan wajib untuk materi ini — cukup pahami konsepnya untuk saat kamu menemuinya di project orang lain. Kalau ingin coba, buat trigger sederhana yang mencatat ke tabel log_aktivitas setiap kali ada baris baru di tabel pesanan.
Materi 25 · Modul 4
Backup dan restore
Melindungi data dari kesalahan, kerusakan, atau bencana — dengan cara yang bisa diandalkan.
mysqldump: cara paling umum
# backup satu database ke file .sql
mysqldump -u root -p toko_saya > backup_toko.sql
# backup SEMUA database sekaligus
mysqldump -u root -p --all-databases > backup_semua.sql
# backup hanya strukturnya (tanpa data) — berguna untuk migrasi skema
mysqldump -u root -p --no-data toko_saya > struktur_saja.sql
# restore dari file backup
mysql -u root -p toko_saya < backup_toko.sqlCatatan — mysqldump menghasilkan file .sql berisi perintah CREATE TABLE dan INSERT yang, kalau dijalankan, membangun ulang database persis seperti saat di-backup. File ini bisa dibuka dengan editor teks biasa — berguna untuk sekadar melihat isinya atau mengedit manual kalau perlu.
Backup terjadwal otomatis
# jalankan setiap hari jam 2 pagi, simpan dengan nama bertanggal
0 2 * * * mysqldump -u root -p'password' toko_saya > /backup/toko_$(date +\%Y\%m\%d).sqlBackup manual gampang terlupa. Di server produksi, backup harus berjalan otomatis dan terjadwal (misalnya lewat cron job di Linux), dan idealnya disimpan di lokasi terpisah dari server utama (cloud storage, server lain) — supaya kalau server utama rusak total, backup tetap aman.
Strategi backup yang masuk akal
- Backup harian otomatis, disimpan minimal 7–30 hari ke belakang.
- Backup sebelum perubahan besar — migrasi skema, deploy fitur besar, migrasi server.
- Simpan di lokasi terpisah dari server database utama.
- Uji proses restore-nya secara berkala. Backup yang tidak pernah dicoba di-restore adalah backup yang tidak bisa diandalkan — banyak kasus backup ternyata rusak atau tidak lengkap, dan baru ketahuan saat sudah terlambat.
Hati-hati — Backup yang cuma tersimpan di server yang sama dengan database aslinya tidak melindungi dari kegagalan hardware server itu, ransomware, atau server yang ter-hapus tak sengaja. Aturan dasar backup yang aman: minimal ada satu salinan di lokasi fisik/layanan yang benar-benar terpisah.
Latihan — Praktikkan siklus penuh: buat backup database latihanmu dengan mysqldump. Hapus salah satu tabel (sengaja, untuk simulasi). Restore dari file backup, dan pastikan tabel yang dihapus kembali lengkap dengan datanya.
Materi 26 · Modul 4
User dan hak akses
Mengontrol siapa boleh melakukan apa di database — penting untuk keamanan produksi.
Kenapa tidak boleh selalu pakai root
Saat belajar, wajar memakai user root yang punya akses penuh ke segalanya. Di produksi, ini berbahaya: kalau kredensial aplikasi bocor (misalnya lewat celah keamanan di kode, seperti dibahas Materi 15 handbook PHP), penyerang mendapat akses penuh ke seluruh server database, bukan cuma satu aplikasi.
Membuat user dan memberi hak akses
-- buat user baru, khusus untuk satu aplikasi
CREATE USER 'app_toko'@'localhost' IDENTIFIED BY 'password_kuat_dan_acak';
-- beri akses HANYA ke database yang relevan, HANYA operasi yang dibutuhkan
GRANT SELECT, INSERT, UPDATE, DELETE ON toko_saya.* TO 'app_toko'@'localhost';
-- terapkan perubahan hak akses
FLUSH PRIVILEGES;
-- lihat hak akses user
SHOW GRANTS FOR 'app_toko'@'localhost';Catatan — Perhatikan: user app_toko di atas tidak diberi hak DROP atau CREATE — bahkan kalau kredensialnya bocor, penyerang tidak bisa menghapus tabel atau membuat database baru. Prinsip ini disebut least privilege (hak akses seminimal mungkin yang dibutuhkan) — jangan pernah memberi lebih dari yang benar-benar diperlukan.
Hak akses yang lebih terbatas untuk kasus tertentu
-- user untuk laporan/analitik — HANYA baca, tidak bisa mengubah apa pun
CREATE USER 'app_laporan'@'%' IDENTIFIED BY 'password_lain';
GRANT SELECT ON toko_saya.* TO 'app_laporan'@'%';
-- akses hanya ke satu tabel tertentu, bukan seluruh database
GRANT SELECT ON toko_saya.produk TO 'app_katalog'@'localhost';
-- mencabut hak akses
REVOKE DELETE ON toko_saya.* FROM 'app_toko'@'localhost';
-- menghapus user
DROP USER 'app_lama'@'localhost';'user'@'host' — bagian host menentukan dari mana user itu boleh terhubung. 'localhost' berarti hanya dari komputer/server yang sama; '%' berarti dari mana saja (kurang aman, tapi kadang diperlukan kalau aplikasi dan database ada di server terpisah).
Checklist keamanan user database
- ✅ Aplikasi tidak pernah memakai user
rootuntuk operasi sehari-hari. - ✅ Tiap aplikasi punya user database sendiri, bukan berbagi satu user untuk semuanya.
- ✅ Hak akses dibatasi hanya pada database dan operasi yang benar-benar dipakai.
- ✅ Password user database kuat dan disimpan di
.env(Materi 24 handbook PHP), bukan di kode. - ✅ User dengan akses
DROP/CREATEhanya dipakai manual saat migrasi, bukan oleh aplikasi.
Latihan — Buat user database baru khusus untuk aplikasi latihanmu, dengan hak akses SELECT, INSERT, UPDATE, DELETE hanya pada database latihan (bukan DROP atau CREATE). Uji dengan login sebagai user itu dan pastikan mencoba DROP TABLE memang ditolak.
Materi 27 · Modul 5
Memahami EXPLAIN dan optimasi query
Membaca "rencana eksekusi" query untuk tahu persis kenapa sesuatu lambat, bukan menebak-nebak.
EXPLAIN secara mendalam
EXPLAIN SELECT * FROM pesanan WHERE pelanggan_id = 5;| Kolom | Artinya |
|---|---|
type | Cara MySQL mengakses data. Dari terbaik ke terburuk: const, eq_ref, ref, range, index, ALL. |
key | Index yang benar-benar dipakai. NULL berarti tidak ada index yang terpakai. |
rows | Perkiraan jumlah baris yang harus diperiksa MySQL. Semakin kecil semakin baik. |
Extra | Info tambahan. Using filesort atau Using temporary sering jadi tanda ada yang bisa dioptimalkan. |
Hati-hati — type: ALL berarti full table scan — MySQL memeriksa setiap baris di tabel. Untuk tabel kecil ini tidak masalah, tapi untuk tabel dengan jutaan baris, ini bisa berarti query yang memakan waktu berdetik-detik alih-alih milidetik. Kalau kamu melihat ALL pada query yang sering dijalankan di tabel besar, itu tanda kuat perlu index (Materi 20).
Kasus: kenapa index tidak terpakai
-- ❌ fungsi membungkus kolom — index TIDAK terpakai
EXPLAIN SELECT * FROM pesanan WHERE YEAR(dibuat_pada) = 2026;
-- ✅ rentang eksplisit — index BISA terpakai
EXPLAIN SELECT * FROM pesanan
WHERE dibuat_pada >= '2026-01-01' AND dibuat_pada < '2027-01-01';
-- ❌ LIKE dengan % di awal — index TIDAK bisa membantu
EXPLAIN SELECT * FROM produk WHERE nama LIKE '%polos';
-- ✅ LIKE dengan % hanya di akhir — index bisa terpakai
EXPLAIN SELECT * FROM produk WHERE nama LIKE 'Kaos%';Catatan — Pola umumnya: begitu kamu membungkus kolom yang ber-index dengan fungsi (YEAR(...), LOWER(...)) atau mencari dengan wildcard di awal pola (LIKE '%kata'), MySQL tidak bisa lagi memakai index secara efisien — dia harus memeriksa dan menghitung ulang tiap baris. Tulis kondisi WHERE supaya kolom aslinya tetap "polos", biarkan sisi kanan yang dihitung.
Masalah N+1: kesalahan performa paling umum
Ini bukan soal SQL murni, tapi soal bagaimana query dipanggil dari kode aplikasi — sangat sering terjadi, dan penting dipahami sebelum masuk ke handbook studi kasus.
-- 1 query untuk ambil semua pesanan
pesanan = SELECT * FROM pesanan;
-- lalu, untuk TIAP pesanan, query TERPISAH untuk ambil nama pelanggannya
foreach (pesanan as p) {
pelanggan = SELECT * FROM pelanggan WHERE id = p.pelanggan_id;
-- kalau ada 100 pesanan → 1 + 100 = 101 query total!
}SELECT p.*, pl.nama AS nama_pelanggan
FROM pesanan p
JOIN pelanggan pl ON p.pelanggan_id = pl.id;
-- HANYA 1 query, berapa pun jumlah pesanannyaHati-hati — Pola N+1 sangat mudah tidak sengaja ditulis, terutama saat kode terlihat "bersih" — loop yang memanggil query di dalamnya terlihat wajar sampai kamu sadar itu berjalan ratusan kali. Ini salah satu alasan terbesar aplikasi terasa lambat di produksi padahal lancar saat development dengan data sedikit. Aturan umum: kalau kamu menulis query di dalam loop, itu sinyal kuat untuk berhenti dan cari cara menggabungkannya jadi satu JOIN atau satu query dengan WHERE ... IN (...).
Query cache dan connection pooling (sekilas)
Dua istilah yang akan kamu temui saat aplikasi mulai berskala: connection pooling (menyiapkan sekumpulan koneksi database yang dipakai bergantian, bukan membuka koneksi baru tiap request — driver database modern seperti PDO di PHP dan pgx di Go biasanya sudah menyediakan ini), dan caching (menyimpan hasil query yang sering diminta di memori, seperti Redis, supaya tidak perlu query database berulang untuk data yang sama). Keduanya di luar cakupan handbook ini, tapi baik untuk diketahui namanya sejak sekarang.
Latihan — Jalankan EXPLAIN pada tiga query berbeda dari latihan-latihan sebelumnya (satu tanpa WHERE, satu dengan WHERE pada kolom ber-index, satu dengan WHERE pada kolom tanpa index). Bandingkan nilai type dan rows di antara ketiganya.
Materi 28 · Modul 5 · Penutup
Praktik produksi dan langkah selanjutnya
Merangkum kebiasaan baik untuk database sungguhan, dan peta jalan setelah handbook ini.
Checklist sebelum database go-live
- ✅ Semua tabel penting pakai engine
InnoDB(Materi 17, 22) — cek denganSHOW TABLE STATUS. - ✅ Foreign key terpasang di semua relasi, dengan
ON DELETEyang sesuai makna bisnisnya (Materi 17). - ✅ Constraint (
NOT NULL,UNIQUE,CHECK) menjaga aturan dasar di level database (Materi 5). - ✅ Index terpasang pada kolom yang sering dipakai di
WHERE/JOIN/ORDER BYpada tabel besar (Materi 20). - ✅ Skema sudah ternormalisasi wajar, dengan denormalisasi hanya untuk alasan yang jelas (Materi 19).
- ✅ Backup otomatis terjadwal, tersimpan di lokasi terpisah, dan pernah diuji restore-nya (Materi 25).
- ✅ Aplikasi memakai user database dengan hak akses terbatas, bukan
root(Materi 26). - ✅ Operasi kritis (transfer saldo, checkout, kurangi stok) dibungkus transaksi (Materi 22).
- ✅ Query yang sering dipanggil sudah dicek dengan
EXPLAIN, dan pola N+1 sudah dihindari (Materi 27).
Kebiasaan menulis SQL yang aman
- SELECT dulu sebelum UPDATE/DELETE — pastikan
WHERE-nya menyaring baris yang benar (Materi 8, 9). - Selalu pakai prepared statement dari kode aplikasi — tidak pernah menyambung input pengguna langsung ke query (dibahas mendalam di handbook studi kasus).
- Bungkus operasi multi-langkah dalam transaksi — jangan biarkan data setengah jadi kalau ada kegagalan di tengah jalan.
- Uji query pada data yang realistis — query yang cepat dengan 100 baris bisa jadi sangat lambat dengan 1 juta baris. Kalau bisa, uji dengan volume data yang mendekati produksi.
Yang sudah kamu kuasai
- Modul 0 — konsep database, instalasi, tabel, tipe data, constraint.
- Modul 1 — CRUD penuh: INSERT, SELECT (WHERE, LIKE, ORDER BY, LIMIT), UPDATE, DELETE, fungsi bawaan.
- Modul 2 — query lanjutan: agregat, GROUP BY/HAVING, JOIN (INNER/LEFT), subquery, UNION, window function.
- Modul 3 — desain database: primary/foreign key, relasi one-to-many & many-to-many, normalisasi, index.
- Modul 4 — transaksi (ACID), view, pengenalan stored procedure/trigger, backup/restore, user & hak akses.
- Modul 5 — membaca EXPLAIN, menghindari pola N+1, checklist praktik produksi.
Langkah selanjutnya: menyatukan PHP dan MySQL
Handbook studi kasus — Sekarang kamu punya dua fondasi terpisah: PHP (logika aplikasi) dan MySQL (penyimpanan data). Handbook studi kasus akan menyatukan keduanya jadi satu aplikasi CRUD utuh — memakai PDO untuk koneksi dan prepared statement (disinggung sekilas di Materi 27 handbook PHP), menerapkan skema database yang benar (seperti Materi 17–21 di sini), transaksi untuk operasi kritis (Materi 22), dan struktur project produksi (Materi 25 handbook PHP) — semuanya dalam satu studi kasus nyata yang bisa kamu jadikan referensi untuk project sendiri.
Setiap konsep di handbook PHP dan handbook ini akan bertemu langsung di studi kasus: form (PHP) yang divalidasi lalu disimpan lewat prepared statement (SQL), halaman daftar yang memakai JOIN dan pagination (SQL) untuk ditampilkan lewat foreach (PHP), dan checkout yang dibungkus transaksi (SQL) dipanggil dari controller (PHP). Kalau kamu sudah nyaman dengan isi kedua handbook ini, studi kasusnya akan terasa seperti menyambungkan bagian-bagian yang sudah kamu kenal, bukan mempelajari hal baru dari nol.