Integritas data bukan urusan satu baris saja. Aturan bisnis yang nyata sering berbunyi “harga jual tidak boleh lebih murah dari harga beli di tabel pemasok” atau “jumlah produk per kategori harus selalu cocok” — aturan yang menengok tabel lain, hal yang tidak mungkin dijangkau CHECK constraint. Trigger membuka kelas aturan ini karena isi trigger adalah SQL biasa yang boleh membaca tabel mana pun lalu memutuskan menerima atau menolak perubahan. Pada chapter ini kita membangun validasi lintas tabel dengan SIGNAL, melengkapi audit trail dengan kolom pengguna dan waktu, menjaga counter denormalisasi tetap akurat, lalu menutup dengan peringatan penting: trigger bukan pengganti foreign key.
Menyiapkan Tabel Latihan #
Skema kita memakai empat tabel. produk kini terhubung ke kategori lewat foreign key, biaya menyimpan harga beli dari pemasok sebagai rujukan validasi, dan audit_log ditambah kolom pengguna untuk mencatat akun yang melakukan aksi:
DROP DATABASE IF EXISTS belajar_trig;
CREATE DATABASE belajar_trig;
USE belajar_trig;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changedCREATE TABLE kategori (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL,
jumlah_produk INT NOT NULL DEFAULT 0
);
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
harga DECIMAL(10,2) NOT NULL,
stok INT NOT NULL DEFAULT 0,
kategori_id INT NOT NULL,
CONSTRAINT fk_produk_kategori FOREIGN KEY (kategori_id) REFERENCES kategori (id),
CONSTRAINT chk_harga_positif CHECK (harga > 0)
);
CREATE TABLE biaya (
nama_produk VARCHAR(100) PRIMARY KEY,
harga_beli DECIMAL(10,2) NOT NULL
);
CREATE TABLE audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
aksi VARCHAR(20) NOT NULL,
detail VARCHAR(255) NOT NULL,
pengguna VARCHAR(60) NOT NULL,
dibuat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO kategori (nama) VALUES ('Kopi'), ('Teh');
INSERT INTO biaya (nama_produk, harga_beli) VALUES
('Kopi Gayo', 50000.00),
('Kopi Toraja', 55000.00),
('Teh Melati', 20000.00);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0Constraint chk_harga_positif sengaja dipasang sejak awal — ia menjadi pembanding untuk trigger kita nanti. Sejak MySQL 8.0.16, CHECK constraint benar-benar ditegakkan; percobaan memasukkan harga negatif langsung ditolak:
-- @error
INSERT INTO produk (nama, harga, stok, kategori_id) VALUES ('Teh Herbal', -5000.00, 8, 2);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
ERROR 3819 (HY000): Check constraint 'chk_harga_positif' is violated.
ByeBatas CHECK: Validasi Hanya Sebatas Satu Baris #
Error di atas menunjukkan CHECK bekerja baik untuk aturan satu kolom pada satu baris. Batasnya juga tegas: kondisi CHECK hanya boleh merujuk kolom tabel itu sendiri dan tidak boleh berisi subquery, jadi aturan “harga jual produk harus di atas harga_beli pada tabel biaya” tidak bisa ditulis sebagai CHECK — statement semacam itu ditolak parser. Aturan lintas tabel membutuhkan kode yang bisa menjalankan query, dan hanya trigger (atau stored procedure yang dipanggil aplikasi) yang punya kemampuan tersebut.
Validasi Lintas Tabel dengan Query dalam Trigger #
Trigger berikut memeriksa setiap produk baru terhadap tabel biaya sebelum baris diterima. Bila harga jual lebih rendah dari harga beli, statement ditolak lewat SIGNAL:
DELIMITER ;;
CREATE TRIGGER cek_harga_jual
BEFORE INSERT ON produk
FOR EACH ROW
BEGIN
DECLARE v_beli DECIMAL(10,2);
SELECT harga_beli INTO v_beli
FROM biaya
WHERE nama_produk = NEW.nama;
IF NEW.harga < v_beli THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Harga jual tidak boleh di bawah harga beli';
END IF;
END;;
DELIMITER ;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)Empat bagian isi trigger bekerja berlapis. DECLARE v_beli DECIMAL(10,2) menyiapkan variabel lokal penampung hasil query. SELECT ... INTO v_beli menjalankan query biasa terhadap tabel biaya — bukti bahwa trigger boleh membaca tabel lain selain tabel tempatnya menempel. Perbandingan NEW.harga < v_beli menilai baris calon terhadap data luar, dan SIGNAL SQLSTATE '45000' mengangkat error bila syarat dilanggar. Mari kita kirim produk dengan harga jual yang kalah dari harga beli pemasok:
-- @error
INSERT INTO produk (nama, harga, stok, kategori_id) VALUES ('Kopi Gayo', 5000.00, 10, 1);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
ERROR 1644 (45000): Harga jual tidak boleh di bawah harga beli
ByeERROR 1644 dengan pesan buatan kita menolak statement, dan karena trigger BEFORE menyala sebelum baris ditulis, Kopi Gayo versi murah tidak pernah masuk ke tabel — tidak ada baris setengah jadi yang harus dibersihkan. Pola ini bisa diperluas ke aturan apa pun yang butuh data dari luar: cek kuota pelanggan di tabel lain, cek stok gudang sebelum menerima pesanan, atau cek tanggal masa berlaku promo. Satu jebakan kecilnya: bila query SELECT ... INTO tidak menemukan baris, v_beli berisi NULL, perbandingan dengan NULL menghasilkan NULL, dan IF menganggapnya salah — produk tanpa catatan biaya lolos validasi. Aturan produksi biasanya menambahkan cabang IF v_beli IS NULL THEN SIGNAL ... supaya produk tak terdaftar juga ditolak.
Audit Trail: Mencatat Pengguna dan Waktu #
Jejak audit menjadi bukti hukum bila mencatat siapa dan kapan. MySQL menyediakan CURRENT_USER() untuk akun dan NOW() atau CURRENT_TIMESTAMP untuk waktu; keduanya bisa dipanggil langsung dari dalam trigger:
DELIMITER ;;
CREATE TRIGGER audit_produk_insert
AFTER INSERT ON produk
FOR EACH ROW
BEGIN
INSERT INTO audit_log (aksi, detail, pengguna)
VALUES ('INSERT', CONCAT('produk baru: ', NEW.nama), CURRENT_USER());
END;;
CREATE TRIGGER audit_produk_delete
AFTER DELETE ON produk
FOR EACH ROW
BEGIN
INSERT INTO audit_log (aksi, detail, pengguna)
VALUES ('DELETE', CONCAT('produk terhapus: ', OLD.nama), CURRENT_USER());
END;;
DELIMITER ;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)Kedua trigger mengisi kolom pengguna dengan CURRENT_USER() — akun yang dipakai server saat memeriksa hak akses statement, yaitu akun pembuat trigger pada konfigurasi bawaan. Kolom dibuat tidak perlu diisi trigger karena bawaan kolom sudah CURRENT_TIMESTAMP. Kita tambahkan satu produk valid — yang lolos pemeriksaan harga — lalu membaca auditnya:
-- @check
INSERT INTO produk (nama, harga, stok, kategori_id) VALUES ('Kopi Gayo', 55000.00, 10, 1);
SELECT id, aksi, detail, pengguna, dibuat FROM audit_log ORDER BY id;Output yang Dihasilkan:
Query OK, 1 row affected (0.00 sec)
+----+--------+------------------------+----------------+---------------------+
| id | aksi | detail | pengguna | dibuat |
+----+--------+------------------------+----------------+---------------------+
| 1 | INSERT | produk baru: Kopi Gayo | root@localhost | 2026-10-14 13:04:27 |
+----+--------+------------------------+----------------+---------------------+
1 row in set (0.00 sec)Kolom pengguna terisi root@localhost dan dibuat berisi waktu kejadian — dua kolom ini membuat log bisa dipertanggungjawabkan. Nilai waktu berubah setiap eksekusi, sehingga blok yang menampilkannya sengaja ditandai @check: harness tetap mengeksekusinya dan memastikan tanpa error, tetapi tidak membandingkan outputnya secara verbatim. Untuk latihan yang butuh output teruji penuh, biasakan menampilkan kolom deterministik saja (misalnya id, aksi, detail) atau memakai tanggal literal tetap pada data contoh.
Menjaga Konsistensi Turunan: Counter di Tabel Induk #
Chapter denormalisasi di section perancangan basis data memperkenalkan kolom cache seperti kategori.jumlah_produk: nilainya redundan karena bisa dihitung ulang dari produk, tetapi disimpan supaya laporan tidak perlu COUNT setiap saat. Trigger adalah penjaga alami kolom semacam itu. Dua trigger berikut menghitung naik-turunnya counter setiap kali baris produk berubah — isi masing-masing cukup satu statement sehingga bentuk padat tanpa BEGIN ... END pun sah:
CREATE TRIGGER naik_jumlah_produk
AFTER INSERT ON produk
FOR EACH ROW
UPDATE kategori SET jumlah_produk = jumlah_produk + 1 WHERE id = NEW.kategori_id;
CREATE TRIGGER turun_jumlah_produk
AFTER DELETE ON produk
FOR EACH ROW
UPDATE kategori SET jumlah_produk = jumlah_produk - 1 WHERE id = OLD.kategori_id;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)Trigger hanya menjaga ke depan — Kopi Gayo yang kita sisipkan sebelum kedua trigger ini terpasang belum dihitung. Nilai awal counter harus dihitung satu kali lewat rekap, pola “janitor” yang juga kita pakai di chapter denormalisasi:
UPDATE kategori k
SET jumlah_produk = (SELECT COUNT(*) FROM produk p WHERE p.kategori_id = k.id);
SELECT id, nama, jumlah_produk FROM kategori ORDER BY id;Output yang Dihasilkan:
Query OK, 1 row affected (0.00 sec)
Rows matched: 2 Changed: 1 Warnings: 0
+----+------+---------------+
| id | nama | jumlah_produk |
+----+------+---------------+
| 1 | Kopi | 1 |
| 2 | Teh | 0 |
+----+------+---------------+
2 rows in set (0.00 sec)Rekap menghasilkan Kopi berisi 1 dan Teh berisi 0 — cocok dengan isi tabel saat ini. Dari titik ini trigger mengambil alih. Kita tambahkan dua produk sekaligus dalam satu statement, lalu menghapus salah satunya:
INSERT INTO produk (nama, harga, stok, kategori_id) VALUES
('Kopi Toraja', 62000.00, 4, 1),
('Teh Melati', 25000.00, 5, 2);
SELECT id, nama, jumlah_produk FROM kategori ORDER BY id;
DELETE FROM produk WHERE nama = 'Kopi Toraja';
SELECT id, nama, jumlah_produk FROM kategori ORDER BY id;Output yang Dihasilkan:
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
+----+------+---------------+
| id | nama | jumlah_produk |
+----+------+---------------+
| 1 | Kopi | 2 |
| 2 | Teh | 1 |
+----+------+---------------+
2 rows in set (0.00 sec)
Query OK, 1 row affected (0.00 sec)
+----+------+---------------+
| id | nama | jumlah_produk |
+----+------+---------------+
| 1 | Kopi | 1 |
| 2 | Teh | 1 |
+----+------+---------------+
2 rows in set (0.00 sec)Setelah insert dua baris, counter menjadi Kopi 2 dan Teh 1; setelah satu baris kopi dihapus, counter Kopi turun menjadi 1. Statement INSERT dua baris menyalakan trigger naik_jumlah_produk dua kali — sekali per baris — sehingga kenaikannya akurat tanpa kita menulis kode perulangan. Dengan lima trigger aktif (satu validasi harga, dua audit, dua counter), satu statement INSERT kini melewati validasi harga, tercatat auditnya, dan menggerakkan counter kategori dalam satu transaksi statement yang atomik.
Trigger Bukan Pengganti Foreign Key #
Contoh kita memakai fk_produk_kategori di samping lima trigger, dan kombinasi itu memang tepat: keduanya menjawab masalah yang berbeda. Foreign key menjamin integritas referensial pada level mesin penyimpanan — produk.kategori_id tidak mungkin menunjuk kategori yang tidak ada, dan kebijakan ON DELETE RESTRICT atau CASCADE dijalankan InnoDB dengan efisien serta aman dari deadlock. Trigger memvalidasi aturan bisnis yang lebih kaya tetapi tidak menggantikan mekanisme tersebut: meniru foreign key dengan trigger berarti menulis kode tambahan, membayar biaya eksekusi per baris, dan tetap membuka celah pada operasi massal yang melewati jalur trigger.
Membersihkan Database Latihan #
Lima trigger dan seluruh tabel latihan kita jatuhkan bersama database:
DROP DATABASE belajar_trig;Output yang Dihasilkan:
Query OK, 4 rows affected (0.00 sec)Referensi #
- CREATE TRIGGER Statement — MySQL 8.4 Reference Manual
- Constraints — MySQL 8.4 Reference Manual
- CHECK Constraints — MySQL 8.4 Reference Manual
Validasi lintas tabel, audit berpengguna, dan counter turunan kini berjalan otomatis dalam satu kerangka trigger. Namun kekuatan ini punya sisi sulit: ketika hasil trigger tidak sesuai harapan, tidak ada layar yang menampilkan apa yang terjadi di dalamnya. Trigger juga menyimpan jebakan tersendiri yang bisa membuat statement gagal secara misterius. Chapter berikutnya melengkapi kotak peralatan debugging kita untuk situasi tersebut.