Pada chapter Konsep Trigger kita memasang trigger AFTER tanpa banyak bertanya — pilihan kata AFTER terasa seperti hiasan sintaks. Padahal kata itu adalah keputusan arsitektural: kapan kode Anda berjalan, sebelum atau sesudah baris benar-benar ditulis ke tabel. Petugas gudang Toko Maju Jaya butuh keduanya — nama produk sebaiknya dirapikan sebelum disimpan, dan catatan perubahan harga sebaiknya dibuat setelah perubahan itu nyata terjadi. PostgreSQL memberi dua momen itu lewat klausa BEFORE dan AFTER, dan keduanya punya kekuatan yang tidak bisa saling menggantikan: hanya BEFORE yang bisa mengubah atau membatalkan baris, hanya AFTER yang pantas menjadi auditor. Chapter ini membuktikan kedua klaim itu lewat demo yang berjalan sungguhan.

BEFORE: Bodyguard Sebelum Pintu #

Trigger BEFORE menyala ketika baris baru sudah dibentuk di memori, tetapi belum ditulis ke tabel. Di titik ini record NEW masih bisa Anda ubah — kolom yang diubah akan menjadi nilai yang benar-benar tersimpan. Jika function mengembalikan RETURN NULL, baris itu tidak pernah ditulis sama sekali. Inilah slot yang tepat untuk normalisasi data dan validasi yang perlu menolak baris.

Seluruh demo chapter ini memakai pasangan tabel yang sama: produk sebagai tabel yang diawasi dan log_perubahan sebagai papan catat:

sql buat-tabel.sql
-- Tabel utama dan tabel log untuk demo BEFORE dan AFTER trigger
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12,2) NOT NULL
);

CREATE TABLE log_perubahan (
    id       integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kejadian text NOT NULL
);

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE

Aturan bisnis pertama kita: kolom nama pada tabel produk harus tersimpan dalam huruf kecil, apa pun kapitalisasi yang dikirim aplikasi kasir. Trigger BEFORE INSERT berikut merapikannya tanpa memedulikan siapa penulis datanya:

-- BEFORE INSERT: rapikan nama produk menjadi huruf kecil sebelum baris ditulis
CREATE FUNCTION kecilkan_nama() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.nama := lower(NEW.nama);
    RETURN NEW;
END
$$;

CREATE TRIGGER kecilkan_nama_insert
BEFORE INSERT ON produk
FOR EACH ROW EXECUTE FUNCTION kecilkan_nama();

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Kopi Kapal Kapten', 18000.00),
    (2, 'TEH MELATI SAPU TJUAH', 12000.00);

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

Output yang Dihasilkan:

text
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 2
 id |         nama          |  harga   
----+-----------------------+----------
  1 | kopi kapal kapten     | 18000.00
  2 | teh melati sapu tjuah | 12000.00
(2 rows)

Kedua baris masuk dengan kapitalisasi yang berbeda-bebas, tetapi tersimpan rata dalam huruf kecil. Bedah mekanismenya:

  1. NEW.nama := lower(NEW.nama); — penugasan ke kolom record NEW mengubah baris yang akan ditulis; sisi kanan membaca nilai asli dari aplikasi, sisi kiri menimpanya dengan hasil lower().
  2. RETURN NEW — baris yang sudah dirapikan dikembalikan ke server untuk ditulis. Mengembalikan record NEW (setelah atau sebelum dimodifikasi) artinya “lanjutkan, inilah baris finalnya”.

Membatalkan Operasi dengan RETURN NULL #

Kekuatan kedua milik BEFORE yang tidak dimiliki AFTER: membatalkan baris. Ketika trigger function mengembalikan NULL, server membuang operasi untuk baris itu dan melanjutkan ke baris berikutnya — tanpa error, tanpa ROLLBACK. Demo berikut menolak produk bernilai nol atau negatif sambil mencatat penolakannya ke tabel log:

-- BEFORE INSERT dengan RETURN NULL: baris ditolak diam-diam
CREATE FUNCTION tolak_harga_nol() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.harga <= 0 THEN
        INSERT INTO log_perubahan (kejadian)
        VALUES ('INSERT ditolak: ' || NEW.nama || ' bernilai ' || NEW.harga);
        RETURN NULL;
    END IF;

    RETURN NEW;
END
$$;

CREATE TRIGGER tolak_harga_nol_insert
BEFORE INSERT ON produk
FOR EACH ROW EXECUTE FUNCTION tolak_harga_nol();

-- Baris tunggal yang ditolak: perhatikan tag INSERT 0 0
INSERT INTO produk (id, nama, harga) VALUES (3, 'Barang Demo', 0.00);

-- Insert banyak baris sekaligus: yang lolas masuk, yang ditolak dibuang
INSERT INTO produk (id, nama, harga) VALUES
    (4, 'Gula Pasir', 14000.00),
    (5, 'Barang Uji', -500.00),
    (6, 'Roti Tawar', 15000.00);

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

SELECT id, kejadian FROM log_perubahan ORDER BY id;

Output yang Dihasilkan:

text
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 0
INSERT 0 2
 id |         nama          |  harga   
----+-----------------------+----------
  1 | kopi kapal kapten     | 18000.00
  2 | teh melati sapu tjuah | 12000.00
  4 | gula pasir            | 14000.00
  6 | roti tawar            | 15000.00
(4 rows)

 id |                  kejadian                   
----+---------------------------------------------
  1 | INSERT ditolak: barang demo bernilai 0.00
  2 | INSERT ditolak: barang uji bernilai -500.00
(2 rows)

Ada tiga fakta penting yang tersembunyi di output itu:

  1. INSERT 0 0 pada baris tunggal — perintah “berhasil” tanpa error, tetapi angka barisnya nol: satu-satunya baris dibatalkan trigger. Perintah SQL tidak gagal; barisnya saja tidak pernah tercipta.
  2. INSERT 0 2 pada insert tiga baris — Barang Uji dibuang diam-diam, dua baris sisanya masuk. Pemanggil yang tidak memeriksa row count tidak akan tahu ada baris yang hilang.
  3. Baris log memuat nama dalam huruf kecil (barang demo, bukan Barang Demo) — dua trigger BEFORE INSERT kini aktif, dan PostgreSQL menjalankan trigger dengan timing dan event yang sama secara alfabetis: kecilkan_nama_insert dulu, baru tolak_harga_nol_insert; penolak kedua melihat NEW versi rapikan dari trigger pertama.

AFTER: Kamera Setelah Kejadian #

Trigger AFTER baru menyala setelah baris selesai ditulis — untuk UPDATE, record OLD (versi lama) dan NEW (versi baru) sama-sama tersedia utuh. Karena barisnya sudah ada, momen ini cocok untuk efek samping: mencatat audit, mengirim notifikasi, atau memperbarui tabel ringkasan. Nilai balik function diabaikan server, jadi Anda cukup mengembalikan apa pun yang lazim.

Audit perubahan harga adalah contoh kanoniknya — dan hanya AFTER yang memberi data yang jujur, karena baris lama dan baru sudah final:

-- AFTER UPDATE: audit perubahan harga, OLD dan NEW sama-sama tersedia
CREATE FUNCTION catat_ubah_harga() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO log_perubahan (kejadian)
    VALUES ('harga ' || NEW.nama || ': ' || OLD.harga || ' -> ' || NEW.harga);

    RETURN NEW;
END
$$;

CREATE TRIGGER catat_ubah_harga_update
AFTER UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION catat_ubah_harga();

UPDATE produk SET harga = 19000.00 WHERE id = 1;

UPDATE produk SET harga = 12500.00 WHERE id = 2;

SELECT id, kejadian FROM log_perubahan WHERE kejadian LIKE 'harga %' ORDER BY id;

Output yang Dihasilkan:

text
CREATE FUNCTION
CREATE TRIGGER
UPDATE 1
UPDATE 1
 id |                     kejadian                      
----+---------------------------------------------------
  3 | harga kopi kapal kapten: 18000.00 -> 19000.00
  4 | harga teh melati sapu tjuah: 12000.00 -> 12500.00
(2 rows)

Setiap UPDATE meninggalkan satu baris audit berisi kedua versi harga: 18000.00 -> 19000.00 dan 12000.00 -> 12500.00. RETURN NEW di sini murni kebiasaan yang baik — pada trigger AFTER nilai baliknya diabaikan, dan RETURN NULL pun tidak akan membatalkan apa pun.

AFTER Tidak Lagi Bisa Mengubah Baris #

Apa yang terjadi kalau trigger AFTER mencoba menulis ke NEW? PL/pgSQL menerima penugasannya secara tata bahasa — NEW tetap variabel record yang sah — tetapi barisnya sudah tersimpan, sehingga perubahan itu menguap tanpa efek dan tanpa error. File berikut membuktikannya dengan trigger AFTER UPDATE yang mencoba menimpa harga menjadi 1:

-- AFTER UPDATE mencoba mengubah NEW: diterima secara tata bahasa, tanpa efek
DROP TRIGGER catat_ubah_harga_update ON produk;

CREATE FUNCTION coba_ubah_after() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.harga := 1.00;
    RETURN NEW;
END
$$;

CREATE TRIGGER coba_ubah_after_update
AFTER UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION coba_ubah_after();

UPDATE produk SET harga = 22000.00 WHERE id = 1;

SELECT id, nama, harga FROM produk WHERE id = 1;

Output yang Dihasilkan:

text
DROP TRIGGER
CREATE FUNCTION
CREATE TRIGGER
UPDATE 1
 id |       nama        |  harga   
----+-------------------+----------
  1 | kopi kapal kapten | 22000.00
(1 row)

Perintah UPDATE meminta harga Rp22.000, trigger AFTER menimpa NEW.harga := 1.00, dan hasil akhirnya tetap 22000.00 — penugasan itu tidak pernah sampai ke tabel. Jangan andalkan error untuk menangkap kesalahan semacam ini; PostgreSQL tidak menganggapnya kesalahan. Trigger auditor lama sengaja dijatuhkan dulu di file yang sama agar output tidak bercampur baris log dari catat_ubah_harga.

Memilih Momen: BEFORE atau AFTER #

AspekBEFOREAFTER
Momen berjalansebelum baris ditulissetelah baris ditulis
Mengubah NEWya, tersimpan ke tabelpenugasan diterima, tanpa efek
Membatalkan barisya, lewat RETURN NULLtidak — nilai balik diabaikan
OLD pada UPDATEtersediatersedia
Kegunaan khasnormalisasi, validasi, penolakanaudit, notifikasi, tabel ringkasan

Kedua momen itu baru satu sumbu pilihan. Sumbu kedua yang sama menentukan bentuknya: trigger menyala sekali per baris atau sekali per perintah. Insert tiga baris sekaligus tadi membuat trigger baris kita berjalan tiga kali — bagaimana dengan trigger per pernyataan, dan variabel apa yang tersedia pada masing-masing? Chapter berikutnya, Row-Level vs Statement-Level , mengukur keduanya dengan penghitung nyata.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

after-auditor.sql

Unduh berkas
sql
-- AFTER UPDATE: audit perubahan harga, OLD dan NEW sama-sama tersedia
CREATE FUNCTION catat_ubah_harga() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO log_perubahan (kejadian)
    VALUES ('harga ' || NEW.nama || ': ' || OLD.harga || ' -> ' || NEW.harga);

    RETURN NEW;
END
$$;

CREATE TRIGGER catat_ubah_harga_update
AFTER UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION catat_ubah_harga();

UPDATE produk SET harga = 19000.00 WHERE id = 1;

UPDATE produk SET harga = 12500.00 WHERE id = 2;

SELECT id, kejadian FROM log_perubahan WHERE kejadian LIKE 'harga %' ORDER BY id;

after-tak-bisa-ubah.sql

Unduh berkas
sql
-- AFTER UPDATE mencoba mengubah NEW: diterima secara tata bahasa, tanpa efek
DROP TRIGGER catat_ubah_harga_update ON produk;

CREATE FUNCTION coba_ubah_after() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.harga := 1.00;
    RETURN NEW;
END
$$;

CREATE TRIGGER coba_ubah_after_update
AFTER UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION coba_ubah_after();

UPDATE produk SET harga = 22000.00 WHERE id = 1;

SELECT id, nama, harga FROM produk WHERE id = 1;

before-pembatal.sql

Unduh berkas
sql
-- BEFORE INSERT dengan RETURN NULL: baris ditolak diam-diam
CREATE FUNCTION tolak_harga_nol() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.harga <= 0 THEN
        INSERT INTO log_perubahan (kejadian)
        VALUES ('INSERT ditolak: ' || NEW.nama || ' bernilai ' || NEW.harga);
        RETURN NULL;
    END IF;

    RETURN NEW;
END
$$;

CREATE TRIGGER tolak_harga_nol_insert
BEFORE INSERT ON produk
FOR EACH ROW EXECUTE FUNCTION tolak_harga_nol();

-- Baris tunggal yang ditolak: perhatikan tag INSERT 0 0
INSERT INTO produk (id, nama, harga) VALUES (3, 'Barang Demo', 0.00);

-- Insert banyak baris sekaligus: yang lolas masuk, yang ditolak dibuang
INSERT INTO produk (id, nama, harga) VALUES
    (4, 'Gula Pasir', 14000.00),
    (5, 'Barang Uji', -500.00),
    (6, 'Roti Tawar', 15000.00);

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

SELECT id, kejadian FROM log_perubahan ORDER BY id;

before-pengubah.sql

Unduh berkas
sql
-- BEFORE INSERT: rapikan nama produk menjadi huruf kecil sebelum baris ditulis
CREATE FUNCTION kecilkan_nama() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.nama := lower(NEW.nama);
    RETURN NEW;
END
$$;

CREATE TRIGGER kecilkan_nama_insert
BEFORE INSERT ON produk
FOR EACH ROW EXECUTE FUNCTION kecilkan_nama();

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Kopi Kapal Kapten', 18000.00),
    (2, 'TEH MELATI SAPU TJUAH', 12000.00);

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

buat-tabel.sql

Unduh berkas
sql
-- Tabel utama dan tabel log untuk demo BEFORE dan AFTER trigger
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12,2) NOT NULL
);

CREATE TABLE log_perubahan (
    id       integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kejadian text NOT NULL
);