Stored procedure yang salah bisa kita panggil ulang sambil mengamati hasilnya; aplikasi yang salah bisa diberi breakpoint. Trigger tidak punya keduanya — trigger berjalan senyap di dalam server, tidak menampilkan apa pun ke layar, dan tidak bisa dijalankan manual dengan parameter uji. Ketika hasilnya tidak sesuai harapan, satu-satunya umpan balik awal adalah tabel yang tetap kosong atau error yang muncul dari statement pemanggil. Pada chapter ini kita merakit kotak peralatan debugging trigger: tabel log khusus untuk mengintip nilai OLD dan NEW, pembacaan metadata trigger dari information_schema, teknik melepas trigger untuk mengisolasi masalah, dan pemahaman atas jebakan rekursi yang kerap membuat INSERT gagal misterius.

Skenario Bug: Audit yang Tidak Mencatat #

Kita mulai dengan bug yang disengaja agar seluruh alat debugging punya korban uji. Skemanya sederhana: tabel produk, tabel audit_log, dan tabel debug_log yang akan kita pakai sebagai jendela pengintip:

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 debug_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tahap VARCHAR(30) NOT NULL,
  pesan VARCHAR(255) NOT NULL,
  dibuat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO produk (nama, harga, stok) VALUES
  ('Kopi Gayo', 55000.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

Sekarang trigger yang katanya mencatat setiap perubahan harga — tetapi isinya mengandung bug yang belum kita sadari:

sql
DELIMITER ;;

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

DELIMITER ;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Kenaikan harga semestinya tetap tercatat sebagai perubahan. Mari kita uji:

sql
UPDATE produk SET harga = 60000.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

Empty set (0.00 sec)

UPDATE melaporkan sukses, harga Kopi Gayo pasti sudah berubah, tetapi audit_log kosong. Inilah wajah khas bug trigger: tidak ada error, tidak ada pesan — hanya hasil yang tidak muncul.

Teknik 1: Tabel Log Khusus Debug #

Trigger tidak bisa mencetak, tetapi trigger bisa menulis ke tabel — dan tabel bisa kita baca kapan saja. Tekniknya memasang trigger debug sementara yang mencatat setiap aksi beserta nilai yang dilihat trigger saat itu. Karena trigger debug kita pasang di BEFORE UPDATE, catatannya muncul apa pun yang dilakukan trigger audit di AFTER:

sql
DELIMITER ;;

CREATE TRIGGER debug_harga_update
BEFORE UPDATE ON produk
FOR EACH ROW
BEGIN
  INSERT INTO debug_log (tahap, pesan)
  VALUES ('UPDATE produk', CONCAT(OLD.harga, ' -> ', NEW.harga, ' pada ', OLD.nama));
END;;

DELIMITER ;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Dua baris isi trigger bekerja sebagai alat rekam: kolom tahap menandai kejadian yang sedang diamati, dan pesan merangkai nilai OLD.harga, NEW.harga, serta OLD.nama — tiga hal yang dilihat trigger pada saat menyala. Kita ulangi aksi yang tadi gagal tercatat:

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

SELECT id, tahap, pesan FROM debug_log ORDER BY id;

Output yang Dihasilkan:

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

+----+---------------+-------------------------------------+
| id | tahap         | pesan                               |
+----+---------------+-------------------------------------+
|  1 | UPDATE produk | 60000.00 -> 65000.00 pada Kopi Gayo |
+----+---------------+-------------------------------------+
1 row in set (0.00 sec)

debug_log kini memuat dua baris: 55000.00 -> 60000.00 dari aksi pertama dan 60000.00 -> 65000.00 dari aksi kedua. Dua fakta penting terbaca dari sini: trigger BEFORE UPDATE memang menyala untuk setiap UPDATE, dan nilai OLD/NEW yang dilihat server sudah benar. Karena jalur data sehat, masalah pasti berada di kondisi trigger audit — itulah hipotesis untuk langkah berikutnya.

Teknik 2: Membaca Metadata Trigger #

Setelah perilaku diamati, langkah kedua adalah membaca definisi trigger apa adanya. Katalog information_schema.TRIGGERS menyimpan seluruh identitas trigger, termasuk isi statementnya:

sql
SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION, ACTION_STATEMENT
FROM information_schema.TRIGGERS
WHERE EVENT_OBJECT_SCHEMA = 'belajar_trig'
ORDER BY TRIGGER_NAME;

Output yang Dihasilkan:

text
+--------------------+---------------+--------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| TRIGGER_NAME       | ACTION_TIMING | EVENT_MANIPULATION | ACTION_STATEMENT                                                                                                                                                                             |
+--------------------+---------------+--------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| audit_harga_update | AFTER         | UPDATE             | BEGIN
  IF OLD.harga > NEW.harga THEN
    INSERT INTO audit_log (aksi, detail)
    VALUES ('UPDATE', CONCAT('harga ', OLD.harga, ' menjadi ', NEW.harga, ' pada ', OLD.nama));
  END IF;
END |
| debug_harga_update | BEFORE        | UPDATE             | BEGIN
  INSERT INTO debug_log (tahap, pesan)
  VALUES ('UPDATE produk', CONCAT(OLD.harga, ' -> ', NEW.harga, ' pada ', OLD.nama));
END                                                       |
+--------------------+---------------+--------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

Dua baris hasil memperlihatkan kedua trigger kita: kolom ACTION_TIMING memisahkan BEFORE dari AFTER, kolom EVENT_MANIPULATION menunjukkan kejadian UPDATE, dan kolom ACTION_STATEMENT menampung isi trigger utuh. Dengan membaca kolom terakhir itu perlahan, bug akhirnya terlihat: kondisi trigger audit berbunyi IF OLD.harga > NEW.harga — kenaikan harga (NEW lebih besar) tidak pernah lolos kondisi tersebut, sehingga audit hanya mencatat penurunan harga. Query SHOW TRIGGERS menghasilkan informasi serupa dengan tambahan kolom Created berisi waktu pembuatan; untuk alat otomatis, information_schema lebih praktis karena bisa difilter dengan WHERE dan diurutkan sesuai kebutuhan.

Teknik 3: Melepas Trigger untuk Mengisolasi #

Perbaikan bug dimulai dengan melepas definisi yang salah, lalu memasang versi yang benar. Teknik yang sama juga dipakai untuk mengisolasi kecurigaan: bila sebuah statement gagal tanpa sebab yang jelas, jatuhkan trigger yang dicurigai sementara, ulangi statementnya, dan pasang kembali trigger dengan versi yang diperbaiki:

sql
DROP TRIGGER audit_harga_update;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Versi perbaikannya mengganti kondisi menjadi “harga berubah” — turun maupun naik sama-sama tercatat:

sql
DELIMITER ;;

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

DELIMITER ;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Perbedaan antara versi lama dan versi baru hanya satu operator: > menjadi <>. Ulangi uji yang sama dan amati ketiga tabel sekaligus:

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

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

SELECT id, tahap, pesan FROM debug_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 | UPDATE | harga 65000.00 menjadi 70000.00 pada Kopi Gayo |
+----+--------+------------------------------------------------+
1 row in set (0.00 sec)

+----+---------------+-------------------------------------+
| id | tahap         | pesan                               |
+----+---------------+-------------------------------------+
|  1 | UPDATE produk | 60000.00 -> 65000.00 pada Kopi Gayo |
|  2 | UPDATE produk | 65000.00 -> 70000.00 pada Kopi Gayo |
+----+---------------+-------------------------------------+
2 rows in set (0.00 sec)

Kenaikan harga kini menghasilkan baris audit harga 60000.00 menjadi 70000.00 pada Kopi Gayo — bug tertutup. Baris debug_log juga bertambah satu, bukti trigger debug tetap bekerja berdampingan dengan trigger audit yang baru. Pola lengkap debugging-nya: amati gejala lewat tabel hasil, hipotesis lewat tabel debug, konfirmasi lewat metadata, lalu perbaiki dengan lepas-pasang ulang.

Jebakan Klasik: Trigger Rekursif (ERROR 1442) #

Satu kesalahan paling umum saat menulis trigger: tanpa sengaja membuat trigger menulis ke tabel yang sama dengan tabel yang menyalakannya. MySQL melarang keras pola tersebut karena membuka rekursi tak berujung. Kita uji secara nyata dengan trigger yang mencoba mengisi produk saat produk menerima baris baru:

sql
CREATE TRIGGER rekursif_produk
AFTER INSERT ON produk
FOR EACH ROW
INSERT INTO produk (nama, harga, stok) VALUES ('isi rekursif', 0.00, 0);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Definisi diterima tanpa protes — larangan baru efektif saat trigger menyala:

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

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1442 (HY000): Can't update table 'produk' in stored function/trigger because it is already used by statement which invoked this stored function/trigger.
Bye

ERROR 1442 menyebutkan tabel produk “sudah dipakai oleh statement yang memanggil trigger” — MySQL menutup jalur rekursi sebelum baris kedua tertulis. Kondisi tabel setelah kegagalan itu layak dibuktikan:

sql
SELECT COUNT(*) AS baris_produk FROM produk;

Output yang Dihasilkan:

text
+--------------+
| baris_produk |
+--------------+
|            2 |
+--------------+
1 row in set (0.00 sec)

Jumlah baris tetap dua: statement di-rollback utuh, termasuk baris Gula Aren yang sempat ditulis sebelum trigger menyala. Kesimpulannya penting untuk diagnosis: error 1442 bukan sekadar menolak efek trigger, melainkan membatalkan seluruh statement pemanggilnya. Setelah trigger bermasalah dijatuhkan, INSERT yang sama akan berhasil — kombinasi DROP TRIGGER lalu uji ulang adalah cara tercepat membuktikan trigger sebagai sumber kegagalan:

sql
DROP TRIGGER rekursif_produk;

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

SELECT COUNT(*) AS baris_produk FROM produk;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 1 row affected (0.00 sec)

+--------------+
| baris_produk |
+--------------+
|            3 |
+--------------+
1 row in set (0.00 sec)

Handler SQLSTATE dalam Trigger: Terbatas #

Isi trigger boleh memuat DECLARE ... HANDLER layaknya stored procedure, tetapi fasilitas tersebut jauh lebih kecil manfaatnya di dalam trigger. Handler CONTINUE untuk SQLEXCEPTION hanya menangkap error statement di dalam body trigger yang sama — statement pemanggil di luar trigger tetap menerima error yang tidak tertangani, dan handler tidak bisa menampilkan apa pun ke pengguna. Bahayanya justru arah sebaliknya: handler yang terlalu rakus bisa menelan SIGNAL validasi kita sendiri, sehingga statement yang seharusnya ditolak jadi lolos tanpa pesan. Untuk audit dan validasi, trigger yang gagal keras — errornya terlihat jelas — hampir selalu lebih mudah diperbaiki daripada trigger yang gagal diam-diam karena errornya ditelan handler.

Membersihkan Database Latihan #

Tiga trigger tersisa dan seluruh tabel latihan kita jatuhkan bersama:

sql
DROP DATABASE belajar_trig;

Output yang Dihasilkan:

text
Query OK, 3 rows affected (0.00 sec)

Referensi #

Trigger yang bandel kini punya prosedur pemeriksaan yang rapi: tabel debug untuk mengintip nilai, metadata untuk membaca definisi, dan lepas-pasang untuk mengisolasi. Semua teknik kita bergerak dalam ruang reaksi atas perubahan data. Pertanyaan berikutnya pindah ranah: bagaimana bila pekerjaan harus berjalan pada waktu tertentu tanpa ada statement DML sama sekali — misalnya rekap stok setiap malam? MySQL menjawab dengan objek berbeda bernama event, yang dijalankan scheduler internal server. Chapter berikutnya membahasnya tuntas.