Audit yang hanya mencatat penambahan data baru setengah kisah — aplikasi nyata juga mengubah dan menghapus baris, dan justru dua kejadian itulah yang paling sering perlu diawasi. Kabar baiknya, ketiga kejadian DML memakai kerangka trigger yang sama persis; yang berubah hanyalah alias yang tersedia di dalamnya. UPDATE unik karena menyediakan OLD dan NEW sekaligus, sehingga trigger dapat merekam perbandingan “dari nilai apa menjadi nilai apa”. Pada chapter ini kita memasang tiga trigger audit untuk ketiga kejadian, memblokir perubahan tidak sah dengan BEFORE UPDATE, lalu menutup dengan aturan tegas ketersediaan alias dan teknik memasang beberapa trigger pada satu kejadian.

Menyiapkan Tabel Latihan #

Database belajar_trig kita bangun ulang dengan tabel produk, tabel audit_log, plus tabel kecil urutan_log untuk percobaan multi-trigger di akhir chapter. Dua produk awal kita masukkan sebelum trigger apa pun terpasang supaya jejak audit hanya berisi aksi yang kita demonstrasikan:

sql
DROP DATABASE IF EXISTS belajar_trig;
CREATE DATABASE belajar_trig;
USE belajar_trig;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
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
);

CREATE TABLE audit_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  aksi VARCHAR(20) NOT NULL,
  detail VARCHAR(255) NOT NULL,
  dibuat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE urutan_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pesan VARCHAR(100) NOT NULL
);

INSERT INTO produk (nama, harga, stok) VALUES
  ('Kopi Gayo', 5000.00, 10),
  ('Teh Melati', 25000.00, 5);

Output yang Dihasilkan:

text
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

Harga Kopi Gayo sengaja kita tulis 5000.00 — angka yang kelak kita perlukan sebagai “nilai lama” saat mendemonstrasikan audit perubahan harga.

Tiga Trigger Audit dalam Satu Definisi #

Ketiga trigger audit dapat kita pasang dalam satu blok DELIMITER. Perhatikan bagaimana setiap trigger hanya menempel pada kejadiannya sendiri, dan bagaimana kolom yang dibaca berbeda mengikuti kejadian tersebut:

sql
DELIMITER ;;

CREATE TRIGGER audit_produk_insert
AFTER INSERT ON produk
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (aksi, detail)
  VALUES ('INSERT', CONCAT('produk baru: ', NEW.nama));
END;;

CREATE TRIGGER audit_produk_update
AFTER UPDATE ON produk
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (aksi, detail)
  VALUES ('UPDATE', CONCAT('harga ', OLD.harga, ' menjadi ', NEW.harga, ' pada ', OLD.nama));
END;;

CREATE TRIGGER audit_produk_delete
AFTER DELETE ON produk
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (aksi, detail)
  VALUES ('DELETE', CONCAT('produk terhapus: ', OLD.nama));
END;;

DELIMITER ;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Tiga blok CREATE TRIGGER menghasilkan tiga laporan Query OK — semuanya tersimpan. Trigger pertama membaca NEW.nama karena baris baru memang hanya punya masa depan. Trigger kedua membaca OLD.harga, NEW.harga, dan OLD.nama sekaligus — inilah satu-satunya kejadian yang menghadirkan dua dunia dalam satu statement. Trigger ketiga membaca OLD.nama, satu-satunya jejak yang tersisa setelah baris dihapus.

AFTER INSERT: Membaca Baris Baru #

Kita tambahkan produk baru dan amati catatan auditnya:

sql
INSERT INTO produk (nama, harga, stok) VALUES ('Kopi Toraja', 62000.00, 4);

SELECT id, aksi, detail FROM audit_log ORDER BY id;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

+----+--------+--------------------------+
| id | aksi   | detail                   |
+----+--------+--------------------------+
|  1 | INSERT | produk baru: Kopi Toraja |
+----+--------+--------------------------+
1 row in set (0.00 sec)

Baris audit produk baru: Kopi Toraja terbentuk tanpa perintah tambahan, persis pola chapter sebelumnya. Tidak ada yang baru secara konsep — yang penting justru kontras yang akan muncul pada dua kejadian berikutnya, ketika alias OLD mulai ikut bermain.

AFTER UPDATE: Nilai Lama dan Baru dalam Satu Catatan #

Trigger AFTER UPDATE menyala sekali untuk setiap baris yang berubah, membawa salinan nilai sebelum dan sesudah. Kita manfaatkan untuk mengoreksi salah input harga pada Kopi Gayo:

sql
UPDATE produk SET harga = 55000.00 WHERE id = 1;

SELECT id, aksi, detail FROM audit_log ORDER BY id;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

+----+--------+-----------------------------------------------+
| id | aksi   | detail                                        |
+----+--------+-----------------------------------------------+
|  1 | INSERT | produk baru: Kopi Toraja                      |
|  2 | UPDATE | harga 5000.00 menjadi 55000.00 pada Kopi Gayo |
+----+--------+-----------------------------------------------+
2 rows in set (0.00 sec)

Baris kedua audit berbunyi harga 5000.00 menjadi 55000.00 pada Kopi Gayo — nilai lama, nilai baru, dan identitas barisnya terekam lengkap. Angka 5000.00 berasal dari OLD.harga, angka 55000.00 dari NEW.harga, dan nama dari OLD.nama (sama saja dengan NEW.nama karena statement kita tidak mengubah nama). Jejak semacam ini tidak mungkin direkonstruksi setelah fakta: begitu UPDATE selesai, nilai lama sungguh-sungguh hilang dari tabel, dan hanya trigger yang sempat menyalinnya. Kalau kebutuhan auditnya lebih rinci, pola CONCAT yang sama bisa diperluas dengan kolom lain seperti stok.

AFTER DELETE: Menyimpan Baris yang Telah Lenyap #

Penghapusan adalah kejadian paling merusak bagi jejak audit — setelah DELETE, tabel tidak lagi menyimpan apa pun tentang baris tersebut. Trigger AFTER DELETE menyala pada saat-saat terakhir baris masih terbaca lewat OLD:

sql
DELETE FROM produk WHERE id = 2;

SELECT id, nama, harga, stok FROM produk ORDER BY id;

SELECT id, aksi, detail FROM audit_log ORDER BY id;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

+----+-------------+----------+------+
| id | nama        | harga    | stok |
+----+-------------+----------+------+
|  1 | Kopi Gayo   | 55000.00 |   10 |
|  3 | Kopi Toraja | 62000.00 |    4 |
+----+-------------+----------+------+
2 rows in set (0.00 sec)

+----+--------+-----------------------------------------------+
| id | aksi   | detail                                        |
+----+--------+-----------------------------------------------+
|  1 | INSERT | produk baru: Kopi Toraja                      |
|  2 | UPDATE | harga 5000.00 menjadi 55000.00 pada Kopi Gayo |
|  3 | DELETE | produk terhapus: Teh Melati                   |
+----+--------+-----------------------------------------------+
3 rows in set (0.00 sec)

Tabel produk kini hanya menyisakan Kopi Gayo dan Kopi Toraja, namun audit_log masih menyimpan baris produk terhapus: Teh Melati. Kolom OLD pada kejadian DELETE menjadi salinan terakhir yang bisa kita simpan — menggabungkan OLD.nama, OLD.harga, dan OLD.id ke dalam detail akan menghasilkan arsip penghapusan yang lengkap. Perhatikan pula pola pembuktiannya: dua SELECT berurutan menampilkan keadaan tabel dan keadaan audit, sehingga “baris hilang di tabel, tercatat di log” terbaca sebagai satu fakta utuh.

BEFORE UPDATE: Memblokir Perubahan yang Melanggar Aturan #

Ketiga trigger di atas semuanya bersifat pencatat — kejadian tetap berjalan apa adanya. Trigger BEFORE memungkinkan sikap yang berbeda: menolak statement sebelum baris berubah. Kita pasang aturan bisnis sederhana, stok tidak boleh negatif, menggunakan SIGNAL:

sql
DELIMITER ;;

CREATE TRIGGER larang_stok_minus
BEFORE UPDATE ON produk
FOR EACH ROW
BEGIN
  IF NEW.stok < 0 THEN
    SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Stok tidak boleh negatif';
  END IF;
END;;

DELIMITER ;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

IF NEW.stok < 0 memeriksa nilai stok hasil perubahan — nilai yang akan disimpan, bukan nilai saat ini. SIGNAL SQLSTATE '45000' mengangkat error buatan dengan pesan kustom; kode 45000 adalah konvensi SQL untuk “kondisi tak terpenuhi yang didefinisikan aplikasi”. Sekarang kita coba melanggar aturannya:

sql
-- @error
UPDATE produk SET stok = -5 WHERE id = 1;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1644 (45000): Stok tidak boleh negatif
Bye

Statement ditolak dengan ERROR 1644 dan pesan yang kita tulis sendiri. Kondisi tabel setelah penolakan tersebut sebaiknya tidak ditebak-tebak:

sql
SELECT id, nama, stok FROM produk WHERE id = 1;

Output yang Dihasilkan:

text
+----+-----------+------+
| id | nama      | stok |
+----+-----------+------+
|  1 | Kopi Gayo |   10 |
+----+-----------+------+
1 row in set (0.00 sec)

Stok Kopi Gayo tetap 10 — statement di-rollback utuh, dan karena trigger BEFORE UPDATE gagal, trigger AFTER UPDATE yang mencatat audit sama sekali tidak menyala. Interaksi berlapis ini penting dicamkan: kegagalan di tahap mana pun membatalkan seluruh rangkaian, sehingga tabel dan audit tidak pernah saling bertentangan.

Aturan Tegas Ketersediaan OLD dan NEW #

Alias yang tersedia pada setiap kejadian bukan konvensi yang bisa dilanggar — MySQL memvalidasinya saat pembuatan trigger. Mencoba membaca OLD pada trigger INSERT langsung ditolak:

sql
-- @error
CREATE TRIGGER salah_baca_old BEFORE INSERT ON produk FOR EACH ROW SET NEW.harga = OLD.harga;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1363 (HY000): There is no OLD row in on INSERT trigger
Bye

ERROR 1363 muncul pada saat CREATE TRIGGER dieksekusi — trigger tidak jadi terpasang sama sekali. Pesan serupa muncul bila NEW dibaca pada trigger DELETE. Rangkuman aturannya: OLD hanya hidup pada kejadian UPDATE dan DELETE, NEW hanya hidup pada kejadian INSERT dan UPDATE, dan hanya NEW pada trigger BEFORE yang boleh ditulis. Aturan ini konsisten dengan makna tiap kejadian, sehingga hafalan tidak diperlukan — cukup tanyakan apakah kejadian tersebut punya masa lalu (OLD) atau masa depan (NEW) di tabel.

Beberapa Trigger pada Kejadian yang Sama #

Pada MySQL 5.7 dan sebelumnya, satu tabel hanya boleh memiliki satu trigger per kombinasi waktu dan kejadian — memasang trigger kedua untuk AFTER INSERT ON produk ditolak keras. MySQL 8 mencabut larangan tersebut: trigger sebanyak apa pun boleh menempel pada kejadian yang sama, dan klausa FOLLOWS (atau PRECEDES) mengatur urutannya. Kita buktikan dengan dua trigger kecil pada kejadian AFTER INSERT:

sql
CREATE TRIGGER log_langkah1 AFTER INSERT ON produk FOR EACH ROW INSERT INTO urutan_log (pesan) VALUES ('langkah 1');

CREATE TRIGGER log_langkah2 AFTER INSERT ON produk FOR EACH ROW FOLLOWS log_langkah1 INSERT INTO urutan_log (pesan) VALUES ('langkah 2');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Kedua definisi diterima — padahal audit_produk_insert juga sudah menempel pada kejadian AFTER INSERT ON produk yang sama, menjadikan total tiga trigger aktif. Klausa FOLLOWS log_langkah1 memastikan log_langkah2 selalu berjalan setelah log_langkah1. Urutan tersebut kita amati dengan satu penambahan produk:

sql
INSERT INTO produk (nama, harga, stok) VALUES ('Gula Aren', 15000.00, 20);

SELECT id, pesan FROM urutan_log ORDER BY id;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

+----+-----------+
| id | pesan     |
+----+-----------+
|  1 | langkah 1 |
|  2 | langkah 2 |
+----+-----------+
2 rows in set (0.00 sec)

Dua baris muncul dalam urutan langkah 1 lalu langkah 2 — persis seperti yang diatur FOLLOWS. Tanpa klausa FOLLOWS, MySQL tidak menjanjikan urutan eksekusi antar-trigger pada kejadian yang sama; trigger baru hanya “ditempatkan setelah” yang lama sebagai kebiasaan, bukan kontrak.

Membersihkan Database Latihan #

Lima trigger beserta seluruh tabel latihan kita jatuhkan sekaligus:

sql
DROP DATABASE belajar_trig;

Output yang Dihasilkan:

text
Query OK, 3 rows affected (0.00 sec)

Referensi #

Ketiga kejadian DML kini punya pola audit yang teruji, termasuk perbandingan OLD lawan NEW dan pemblokiran lewat SIGNAL. Contoh SIGNAL kita memeriksa satu kolom pada baris itu sendiri — padahal CHECK constraint juga bisa melakukannya tanpa trigger. Kekuatan sesungguhnya trigger baru terasa ketika validasi harus menengok tabel lain atau menjaga angka turunan tetap konsisten, dan itulah bahasan chapter berikutnya tentang integritas data.