Trigger memungkinkan database menjalankan logika sendirian tanpa menunggu perintah aplikasi — kekuatan yang sudah kita pakai untuk validasi tertunda, penjagaan DDL, dan jejak audit. Tetapi kekuatan itu punya tagihan yang jarang dibicarakan di tutorial: logika trigger berjalan secara tak terlihat, biayanya ditagih per baris, efeknya bisa menjalar ke tabel lain tanpa jejak di query yang Anda tulis, dan beberapa jebakannya menghilangkan data tanpa satu pun pesan error. Chapter penutup section ini adalah buku kas yang jujur. Kita akan mereproduksi lima keterbatasan paling mahal dari trigger — efek berantai, biaya per baris, kebutaan terhadap SELECT, jebakan RETURN NULL, dan rekursi tanpa pengaman — lalu menutupnya dengan daftar kapan sebaiknya Anda tidak memakai trigger sama sekali.

Efek Berantai Antar-Tabel #

Trigger boleh menulis ke tabel lain, dan tabel lain tersebut bisa memiliki trigger-nya sendiri. Satu UPDATE sederhana pun bisa berubah menjadi rantai aksi yang menjalar dua tingkat atau lebih:

CREATE TABLE stok_produk (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    stok integer NOT NULL
);
CREATE TABLE notifikasi (
    id  integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isi text NOT NULL
);
CREATE TABLE log_pengiriman (
    id  integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isi text NOT NULL
);

CREATE FUNCTION kirim_notifikasi() RETURNS trigger AS $$
BEGIN
    INSERT INTO notifikasi (isi)
    VALUES ('stok ' || NEW.nama || ' tinggal ' || NEW.stok);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE FUNCTION catat_pengiriman() RETURNS trigger AS $$
BEGIN
    INSERT INTO log_pengiriman (isi)
    VALUES ('notifikasi terkirim: ' || NEW.isi);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER pantau_stok AFTER UPDATE ON stok_produk
FOR EACH ROW EXECUTE FUNCTION kirim_notifikasi();

CREATE TRIGGER catat_notifikasi AFTER INSERT ON notifikasi
FOR EACH ROW EXECUTE FUNCTION catat_pengiriman();

INSERT INTO stok_produk VALUES (1, 'Kopi Gayo', 40);

-- Satu UPDATE: tiga tabel tersentuh
UPDATE stok_produk SET stok = 5 WHERE id = 1;

SELECT id, isi FROM notifikasi ORDER BY id;
SELECT id, isi FROM log_pengiriman ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE FUNCTION
CREATE FUNCTION
CREATE TRIGGER
CREATE TRIGGER
INSERT 0 1
UPDATE 1
 id |           isi            
----+--------------------------
  1 | stok Kopi Gayo tinggal 5
(1 row)

 id |                      isi                      
----+-----------------------------------------------
  1 | notifikasi terkirim: stok Kopi Gayo tinggal 5
(1 row)

Rantainya berjalan seperti ini: UPDATE stok_produk membangkitkan trigger pantau_stok; fungsi kirim_notifikasi() menyisipkan satu baris ke notifikasi; penyisipan tersebut membangkitkan trigger catat_notifikasi; fungsi catat_pengiriman() menyisipkan satu baris ke log_pengiriman. Bagi pengguna yang hanya menjalankan UPDATE, dua tabel terakhir berisi baris yang tidak pernah diminta — persis pengalaman klasik “dari mana baris ini datang?”. Masalahnya bukan barisnya salah, melainkan tidak ada satu baris kode pun di query aplikasi yang menyebut notifikasi.

Melacak Trigger sebagai Penyebab #

Saat baris misterius muncul, langkah diagnosisnya adalah membaca catalog: setiap trigger tercatat di pg_trigger dengan tabel, nama, dan fungsinya. Query berikut memetakan kedua trigger yang terlibat dalam rantai di atas:

-- "Dari mana baris ini datang?" — sinar-X daftar trigger per tabel
SELECT 'notifikasi' AS tabel, tgname AS trigger, tgfoid::regproc AS fungsi
FROM pg_trigger
WHERE tgrelid = 'notifikasi'::regclass AND NOT tgisinternal
UNION ALL
SELECT 'stok_produk', tgname, tgfoid::regproc
FROM pg_trigger
WHERE tgrelid = 'stok_produk'::regclass AND NOT tgisinternal
ORDER BY tabel, trigger;

Output yang Dihasilkan:

text
    tabel    |     trigger      |      fungsi      
-------------+------------------+------------------
 notifikasi  | catat_notifikasi | catat_pengiriman
 stok_produk | pantau_stok      | kirim_notifikasi
(2 rows)

Dua baris tersebut menjawab pertanyaan “siapa yang menulis ke tabel ini”: trigger pantau_stok pada stok_produk menjelaskan isi notifikasi, dan trigger catat_notifikasi pada notifikasi menjelaskan isi log_pengiriman. Perintah \d nama_tabel memperlihatkan daftar yang sama dengan lebih ringkas. Dua alat bantu lain layak dihapal: RAISE NOTICE di dalam fungsi trigger mengirim teks penjejak ke layar (psql menampilkannya di stderr, bukan sebagai hasil query), dan pg_trigger_depth() melaporkan kedalaman rantai trigger yang sedang berjalan — angka 1 berarti pemanggilan paling luar, angka 2 berarti trigger yang menyala karena trigger lain.

Biaya per Baris dan Operasi Massal #

Trigger level baris dijalankan sekali untuk setiap baris yang tersentuh — tidak ada pemanggilan massal. Operasi yang terasa sepele menjadi mahal ketika jumlah barisnya besar:

-- BEFORE row trigger pada operasi massal: satu pemanggilan per baris
CREATE TABLE log_massal (
    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);

CREATE FUNCTION catat_massal() RETURNS trigger AS $$
BEGIN
    INSERT INTO log_massal DEFAULT VALUES;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER catat_setiap_baris
BEFORE INSERT ON stok_produk
FOR EACH ROW EXECUTE FUNCTION catat_massal();

INSERT INTO stok_produk (id, nama, stok)
SELECT g, 'barang-' || g, 10 FROM generate_series(2, 1001) AS g;

SELECT count(*) AS baris_masuk FROM stok_produk;
SELECT count(*) AS pemanggilan_trigger FROM log_massal;

-- Pembacaan tidak pernah memicu trigger
SELECT count(*) AS jumlah_baca FROM stok_produk;
SELECT count(*) AS pemanggilan_setelah_baca FROM log_massal;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 1000
 baris_masuk 
-------------
        1001
(1 row)

 pemanggilan_trigger 
---------------------
                1000
(1 row)

 jumlah_baca 
-------------
        1001
(1 row)

 pemanggilan_setelah_baca 
--------------------------
                     1000
(1 row)

Seribu baris masuk, fungsi PL/pgSQL dipanggil seribu kali — itu tanpa beban tambahan dari audit jsonb di chapter sebelumnya, yang menggandakan setiap pemanggilan dengan dua konversi dan satu INSERT ke tabel riwayat. Mitigasinya sudah pernah kita lihat: trigger level pernyataan dengan transition table memproses seluruh batch sekali jalan, dan klausa WHEN menyaring baris yang benar-benar perlu diproses.

Dua baris terakhir output juga membuktikan batas fundamental lain: dua kali pembacaan seluruh tabel tidak menambah satu pun pemanggilan trigger. Trigger hanya menyala untuk tulis — SELECT tidak pernah memicunya, sehingga mekanisme audit trigger apa pun buta terhadap siapa saja yang membaca data. Bila kebijakan kepatuhan menuntut jejak baca, penyelesaiannya berada di luar trigger: pencatatan di lapisan aplikasi, atau ekstensi audit level server seperti pgAudit yang memanfaatkan hook internal server, bukan trigger tabel.

Jebakan RETURN NULL #

Fungsi trigger BEFORE mengendalikan nasib baris: kembalikan barisnya, baris tersimpan; kembalikan NULL, baris dibatalkan — tanpa error, tanpa warning:

sql return-null.sql
CREATE TABLE pesanan (
    id        integer PRIMARY KEY,
    pelanggan text,
    total     numeric(12,2) NOT NULL
);
CREATE TABLE bukti (
    id          integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pesanan_id  integer NOT NULL,
    catatan     text NOT NULL
);

CREATE FUNCTION saring_tanpa_pelanggan() RETURNS trigger AS $$
BEGIN
    INSERT INTO bukti (pesanan_id, catatan)
    VALUES (NEW.id, 'trigger BEFORE berjalan');

    IF NEW.pelanggan IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER saring
BEFORE INSERT ON pesanan
FOR EACH ROW EXECUTE FUNCTION saring_tanpa_pelanggan();

INSERT INTO pesanan VALUES (1, 'Budi', 100000);

-- Baris tanpa pelanggan: pernyataan "sukses", baris tidak tersimpan
INSERT INTO pesanan VALUES (2, NULL, 50000);

SELECT id, pesanan_id, catatan FROM bukti ORDER BY id;
SELECT count(*) AS pesanan_tersimpan FROM pesanan;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 1
INSERT 0 0
 id | pesanan_id |         catatan         
----+------------+-------------------------
  1 |          1 | trigger BEFORE berjalan
  2 |          2 | trigger BEFORE berjalan
(2 rows)

 pesanan_tersimpan 
-------------------
                 1
(1 row)

Bukti kejadiannya terbaca pada tiga hasil: perintah kedua melaporkan INSERT 0 0 — bukan INSERT 0 1 — tabel pesanan hanya berisi satu baris, tetapi tabel bukti berisi dua catatan. Efek samping trigger tetap berjalan untuk baris yang dibuang, sehingga jejak “trigger pernah dipanggil” tersimpan sementara datanya sendiri lenyap. Aplikasi yang tidak memeriksa jumlah baris terpengaruh akan menganggap penyimpanan berhasil.

Rekursi Tanpa Pengaman #

Trigger yang menulis ke tabelnya sendiri membangkitkan dirinya sendiri: UPDATE memicu trigger, trigger melakukan UPDATE lagi, trigger menyala lagi, dan seterusnya. PostgreSQL tidak mendeteksi siklus semacam ini secara otomatis — rantai berhenti hanya karena batas stack server terlampaui, dengan error stack depth limit exceeded dan seluruh pernyataan dibatalkan. Demo berikut memasang pengaman sengaja agar rantainya berhenti di tingkat tiga, supaya pola perilakunya terlihat:

CREATE TABLE dompet (
    id    integer PRIMARY KEY,
    saldo integer NOT NULL
);
CREATE TABLE jejak_rekursi (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tingkat    integer NOT NULL,
    saldo_baru integer NOT NULL
);

-- Trigger yang menulis tabel tempatnya menempel sendiri
CREATE FUNCTION sesuaikan_saldo() RETURNS trigger AS $$
BEGIN
    INSERT INTO jejak_rekursi (tingkat, saldo_baru)
    VALUES (pg_trigger_depth(), NEW.saldo);

    UPDATE dompet SET saldo = saldo + 1 WHERE id = NEW.id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Pengaman sengaja dipasang agar demo ini berhenti di tingkat 3
CREATE TRIGGER pembulat
AFTER UPDATE ON dompet
FOR EACH ROW WHEN (pg_trigger_depth() < 3)
EXECUTE FUNCTION sesuaikan_saldo();

INSERT INTO dompet VALUES (1, 100);

UPDATE dompet SET saldo = 200 WHERE id = 1;

SELECT tingkat, saldo_baru FROM jejak_rekursi ORDER BY id;
SELECT saldo FROM dompet WHERE id = 1;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 1
UPDATE 1
 tingkat | saldo_baru 
---------+------------
       1 |        200
       2 |        201
       3 |        202
(3 rows)

 saldo 
-------
   203
(1 row)

Satu UPDATE menjadi tiga pemanggilan bersarang: tingkat 1 melihat saldo 200 dan menaikkan menjadi 201, tingkat 2 melihat 201 dan menaikkan menjadi 202, tingkat 3 melihat 202 dan menaikkan menjadi 203 — pemanggilan tingkat 4 baru dicegat pengaman pg_trigger_depth() < 3. Saldo akhir 203, bukan 200 yang diminta. Tanpa klausa WHEN tersebut, rantai berlanjut sampai server menghentikannya dengan error; tanpa pengaman, perintah UPDATE yang tampak polos itu tidak pernah selesai dengan sukses.

Pola yang Aman dari Rekursi #

Dua kebiasaan menutup celah rekursi. Pertama, fungsi trigger yang memantau sebaiknya hanya membaca tabelnya — menulis ke tabel lain seperti riwayat_saldo tidak membangkitkan trigger apa pun pada tabel asal. Kedua, klausa WHEN dengan perbandingan IS DISTINCT FROM memastikan trigger hanya menyala saat nilai yang diawasi benar-benar berubah:

DROP TRIGGER pembulat ON dompet;

CREATE TABLE riwayat_saldo (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    saldo_baru integer NOT NULL
);

CREATE FUNCTION catat_saldo() RETURNS trigger AS $$
BEGIN
    INSERT INTO riwayat_saldo (saldo_baru) VALUES (NEW.saldo);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Hanya menyala saat saldo benar-benar berubah
CREATE TRIGGER catat_perubahan_saldo
AFTER UPDATE ON dompet
FOR EACH ROW WHEN (OLD.saldo IS DISTINCT FROM NEW.saldo)
EXECUTE FUNCTION catat_saldo();

UPDATE dompet SET saldo = 300 WHERE id = 1;
UPDATE dompet SET saldo = 300 WHERE id = 1;

SELECT saldo_baru FROM riwayat_saldo ORDER BY id;

Output yang Dihasilkan:

text
DROP TRIGGER
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
UPDATE 1
UPDATE 1
 saldo_baru 
------------
        300
(1 row)

Dua UPDATE dijalankan, tetapi riwayat_saldo hanya berisi satu baris. Pernyataan pertama mengubah 203 menjadi 300 sehingga trigger menyala sekali; pernyataan kedua menulis nilai yang sama persis sehingga OLD.saldo IS DISTINCT FROM NEW.saldo bernilai salah dan trigger tidak menyala — sekaligus bonus kecil: UPDATE yang tidak mengubah apa pun tidak mengotori jejak. Bila menulis ke tabel sendiri benar-benar tak terhindarkan, pertahankan pengaman pg_trigger_depth() dari demo sebelumnya sebagai jaring pengaman terakhir.

Kapan Sebaiknya Tidak Memakai Trigger #

Seluruh keterbatasan di atas punya benang merah yang sama: trigger menyembunyikan logika dari orang yang menjalankan perintah. Karena itu keputusan memasang trigger sebaiknya selalu kalah dari alternatif yang lebih terlihat, bila alternatif itu memadai:

KebutuhanAlat yang lebih tepat
Validasi format atau rentang satu barisCHECK, NOT NULL, tipe domain
Keunikan dan relasi antar barisUNIQUE, PRIMARY KEY, FOREIGN KEY, EXCLUDE
Validasi agregat lintas baris yang bisa ditundaconstraint trigger (chapter Constraint Trigger)
Audit perubahan skemaevent trigger (chapter Event Trigger)
Jejak pembacaan data (SELECT)lapisan aplikasi atau ekstensi pgAudit
Riwayat versi baris yang bisa dikueri waktutabel temporal, mis. ekstensi temporal_tables

Dan bila trigger memang menjadi pilihannya, jaga agar harganya tetap terkendali:

  1. Satu tanggung jawab per trigger. Fungsi yang mencatat sekaligus mengirim sekaligus menghitung adalah tiga tempat debugging dalam satu tubuh.
  2. Dokumentasikan setiap trigger di runbook tabelnya. Efek berantai tidak menyakitkan bila semua orang tahu peta rantainya.
  3. Ukur dengan data realistis. Pemicuan per baris yang terabaikan pada 10.000 baris per batch adalah insiden produksi menunggu jadwal.
  4. Hindari menulis ke tabel yang diamankan. Bila terpaksa, pasang pengaman WHEN atau pg_trigger_depth().
  5. Perlakukan RETURN NULL seperti DELETE diam-diam. Setiap kemunculannya wajib punya alasan tertulis.

Prinsip penutupnya sederhana: bisa tidak sama dengan seharusnya. Trigger terbaik adalah yang menjaga invariant yang tidak boleh dilanggar siapa pun — sisa logika bisnis lebih jujur hidup di tempat yang bisa dilihat pembacanya. Kebiasaan menimbang biaya tak terlihat semacam ini juga menjadi bekal untuk section berikutnya, Query Optimization, tempat kita berhenti menebak dan mulai mengukur: EXPLAIN, biaya plan, index yang dipakai atau diabaikan, dan cara membaca semuanya sebelum production mengalaminya.

Referensi #

Berkas Sumber

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

biaya-per-baris.sql

Unduh berkas
sql
-- BEFORE row trigger pada operasi massal: satu pemanggilan per baris
CREATE TABLE log_massal (
    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);

CREATE FUNCTION catat_massal() RETURNS trigger AS $$
BEGIN
    INSERT INTO log_massal DEFAULT VALUES;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER catat_setiap_baris
BEFORE INSERT ON stok_produk
FOR EACH ROW EXECUTE FUNCTION catat_massal();

INSERT INTO stok_produk (id, nama, stok)
SELECT g, 'barang-' || g, 10 FROM generate_series(2, 1001) AS g;

SELECT count(*) AS baris_masuk FROM stok_produk;
SELECT count(*) AS pemanggilan_trigger FROM log_massal;

-- Pembacaan tidak pernah memicu trigger
SELECT count(*) AS jumlah_baca FROM stok_produk;
SELECT count(*) AS pemanggilan_setelah_baca FROM log_massal;

efek-berantai.sql

Unduh berkas
sql
CREATE TABLE stok_produk (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    stok integer NOT NULL
);
CREATE TABLE notifikasi (
    id  integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isi text NOT NULL
);
CREATE TABLE log_pengiriman (
    id  integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    isi text NOT NULL
);

CREATE FUNCTION kirim_notifikasi() RETURNS trigger AS $$
BEGIN
    INSERT INTO notifikasi (isi)
    VALUES ('stok ' || NEW.nama || ' tinggal ' || NEW.stok);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE FUNCTION catat_pengiriman() RETURNS trigger AS $$
BEGIN
    INSERT INTO log_pengiriman (isi)
    VALUES ('notifikasi terkirim: ' || NEW.isi);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER pantau_stok AFTER UPDATE ON stok_produk
FOR EACH ROW EXECUTE FUNCTION kirim_notifikasi();

CREATE TRIGGER catat_notifikasi AFTER INSERT ON notifikasi
FOR EACH ROW EXECUTE FUNCTION catat_pengiriman();

INSERT INTO stok_produk VALUES (1, 'Kopi Gayo', 40);

-- Satu UPDATE: tiga tabel tersentuh
UPDATE stok_produk SET stok = 5 WHERE id = 1;

SELECT id, isi FROM notifikasi ORDER BY id;
SELECT id, isi FROM log_pengiriman ORDER BY id;

jejak-trigger.sql

Unduh berkas
sql
-- "Dari mana baris ini datang?" — sinar-X daftar trigger per tabel
SELECT 'notifikasi' AS tabel, tgname AS trigger, tgfoid::regproc AS fungsi
FROM pg_trigger
WHERE tgrelid = 'notifikasi'::regclass AND NOT tgisinternal
UNION ALL
SELECT 'stok_produk', tgname, tgfoid::regproc
FROM pg_trigger
WHERE tgrelid = 'stok_produk'::regclass AND NOT tgisinternal
ORDER BY tabel, trigger;

rekursi-aman.sql

Unduh berkas
sql
DROP TRIGGER pembulat ON dompet;

CREATE TABLE riwayat_saldo (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    saldo_baru integer NOT NULL
);

CREATE FUNCTION catat_saldo() RETURNS trigger AS $$
BEGIN
    INSERT INTO riwayat_saldo (saldo_baru) VALUES (NEW.saldo);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Hanya menyala saat saldo benar-benar berubah
CREATE TRIGGER catat_perubahan_saldo
AFTER UPDATE ON dompet
FOR EACH ROW WHEN (OLD.saldo IS DISTINCT FROM NEW.saldo)
EXECUTE FUNCTION catat_saldo();

UPDATE dompet SET saldo = 300 WHERE id = 1;
UPDATE dompet SET saldo = 300 WHERE id = 1;

SELECT saldo_baru FROM riwayat_saldo ORDER BY id;

rekursi-berantai.sql

Unduh berkas
sql
CREATE TABLE dompet (
    id    integer PRIMARY KEY,
    saldo integer NOT NULL
);
CREATE TABLE jejak_rekursi (
    id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tingkat    integer NOT NULL,
    saldo_baru integer NOT NULL
);

-- Trigger yang menulis tabel tempatnya menempel sendiri
CREATE FUNCTION sesuaikan_saldo() RETURNS trigger AS $$
BEGIN
    INSERT INTO jejak_rekursi (tingkat, saldo_baru)
    VALUES (pg_trigger_depth(), NEW.saldo);

    UPDATE dompet SET saldo = saldo + 1 WHERE id = NEW.id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Pengaman sengaja dipasang agar demo ini berhenti di tingkat 3
CREATE TRIGGER pembulat
AFTER UPDATE ON dompet
FOR EACH ROW WHEN (pg_trigger_depth() < 3)
EXECUTE FUNCTION sesuaikan_saldo();

INSERT INTO dompet VALUES (1, 100);

UPDATE dompet SET saldo = 200 WHERE id = 1;

SELECT tingkat, saldo_baru FROM jejak_rekursi ORDER BY id;
SELECT saldo FROM dompet WHERE id = 1;

return-null.sql

Unduh berkas
sql
CREATE TABLE pesanan (
    id        integer PRIMARY KEY,
    pelanggan text,
    total     numeric(12,2) NOT NULL
);
CREATE TABLE bukti (
    id          integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pesanan_id  integer NOT NULL,
    catatan     text NOT NULL
);

CREATE FUNCTION saring_tanpa_pelanggan() RETURNS trigger AS $$
BEGIN
    INSERT INTO bukti (pesanan_id, catatan)
    VALUES (NEW.id, 'trigger BEFORE berjalan');

    IF NEW.pelanggan IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER saring
BEFORE INSERT ON pesanan
FOR EACH ROW EXECUTE FUNCTION saring_tanpa_pelanggan();

INSERT INTO pesanan VALUES (1, 'Budi', 100000);

-- Baris tanpa pelanggan: pernyataan "sukses", baris tidak tersimpan
INSERT INTO pesanan VALUES (2, NULL, 50000);

SELECT id, pesanan_id, catatan FROM bukti ORDER BY id;
SELECT count(*) AS pesanan_tersimpan FROM pesanan;