Sejauh ini seluruh trigger yang kita bahas bereaksi terhadap baris data dan langsung dijalankan pada saat pernyataan dieksekusi. Padahal ada satu kelompok aturan bisnis yang canggung ditangani baik oleh constraint maupun trigger biasa: aturan yang melibatkan kumpulan baris. CHECK hanya boleh membaca barisnya sendiri — subquery di dalam CHECK ditolak server, sebagaimana sudah dibuktikan di chapter Exclusion Constraints. EXCLUDE menangani relasi berpasangan antar baris lewat operator index, tetapi aturan seperti “total saldo satu kelompok maksimal Rp5.000.000” bukan relasi pasangan; aturan tersebut menuntut SUM atas seluruh anggota kelompok. Trigger biasa bisa menjalankan query apa pun, namun trigger biasa menolak baris seketika — di tengah transaksi yang mungkin masih akan memperbaiki dirinya beberapa pernyataan kemudian. PostgreSQL menyediakan jalan tengahnya: CREATE CONSTRAINT TRIGGER, yaitu trigger yang mengikuti disiplin constraint — punya nama, boleh DEFERRABLE, dan pemeriksaannya bisa digeser hingga momen COMMIT.

Trigger dengan Disiplin Constraint #

Secara mekanisme, constraint trigger adalah trigger AFTER level baris yang diperlakukan server sebagai constraint bernama. Syaratnya ketat: deklarasi hanya menerima AFTER dan FOR EACH ROW — bentuk BEFORE atau FOR EACH STATEMENT ditolak parser sejak awal. Sebagai gantinya, constraint trigger menerima klausa DEFERRABLE, sesuatu yang tidak dimiliki trigger biasa, sehingga trigger tersebut bisa ikut serta dalam mekanisme penundaan pemeriksaan yang juga dipakai foreign key.

Kita pakai satu studi kasus yang konsisten di seluruh chapter: tabel rekening anggota koperasi. Aturan bisnisnya menyatakan total saldo satu kelompok tidak boleh melebihi Rp5.000.000 — aturan agregat lintas baris yang mustahil ditulis sebagai CHECK.

sql buat-tabel.sql
-- Batas bisnis: total saldo satu kelompok maksimal Rp5.000.000
CREATE TABLE rekening (
    id       integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    pemilik  text NOT NULL,
    kelompok text NOT NULL,
    saldo    numeric(12,2) NOT NULL DEFAULT 0
);

CREATE FUNCTION cek_total_saldo() RETURNS trigger AS $$
DECLARE
    total numeric;
BEGIN
    SELECT sum(saldo) INTO total
    FROM rekening
    WHERE kelompok = NEW.kelompok;

    IF total > 5000000 THEN
        RAISE EXCEPTION 'total saldo kelompok "%" melebihi batas %',
            NEW.kelompok, 5000000;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Constraint trigger: DEFERRABLE dan terdaftar dengan nama constraint
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

INSERT INTO rekening (pemilik, kelompok, saldo) VALUES
    ('Budi', 'keluarga-santoso', 3000000),
    ('Siti', 'keluarga-santoso', 1500000),
    ('Dewi', 'keluarga-wijaya', 1000000);

SELECT id, pemilik, kelompok, saldo FROM rekening ORDER BY id;

\d rekening

Output yang Dihasilkan:

text
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 3
 id | pemilik |     kelompok     |   saldo    
----+---------+------------------+------------
  1 | Budi    | keluarga-santoso | 3000000.00
  2 | Siti    | keluarga-santoso | 1500000.00
  3 | Dewi    | keluarga-wijaya  | 1000000.00
(3 rows)

                            Table "public.rekening"
  Column  |     Type      | Collation | Nullable |           Default            
----------+---------------+-----------+----------+------------------------------
 id       | integer       |           | not null | generated always as identity
 pemilik  | text          |           | not null | 
 kelompok | text          |           | not null | 
 saldo    | numeric(12,2) |           | not null | 0
Indexes:
    "rekening_pkey" PRIMARY KEY, btree (id)
Triggers:
    cek_saldo_kelompok AFTER INSERT OR UPDATE ON rekening DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION cek_total_saldo()

Mari kita bedah bagian-bagian pentingnya:

  1. Fungsi cek_total_saldo(): fungsi trigger PL/pgSQL biasa dari chapter sebelumnya. Fungsi tersebut menghitung sum(saldo) seluruh baris pada kelompok milik NEW — inilah kekuatan yang tidak dimiliki CHECK, yaitu membaca baris lain. Nilai total dibandingkan dengan batas 5.000.000, dan pelanggaran diangkat lewat RAISE EXCEPTION.
  2. CREATE CONSTRAINT TRIGGER cek_saldo_kelompok: kata kunci CONSTRAINT mengubah status trigger menjadi constraint bernama. Nama cek_saldo_kelompok bukan sekadar label — nama tersebut dipakai perintah SET CONSTRAINTS untuk menunda pemeriksaan secara selektif.
  3. AFTER INSERT OR UPDATE ... FOR EACH ROW: satu-satunya bentuk yang diizinkan. Constraint trigger selalu diperiksa setelah baris ditulis, per baris.
  4. DEFERRABLE INITIALLY IMMEDIATE: trigger boleh ditunda (DEFERRABLE), tetapi perilaku bawaannya tetap memeriksa seketika (INITIALLY IMMEDIATE). Tiga INSERT pertama pun langsung diperiksa satu per satu dan lolos karena total keluarga-santoso saat itu 4.500.000.
  5. \d rekening: psql menampilkan constraint trigger pada bagian Triggers: beserta status DEFERRABLE INITIALLY IMMEDIATE — keberadaannya transparan di catalog, bukan logika tersembunyi.

Pelanggaran pada Saat Pernyataan #

Dengan INITIALLY IMMEDIATE, constraint trigger berperilaku seperti trigger biasa yang cerewet: begitu total kelompok terlewati, pernyataan yang sedang berjalan langsung dibatalkan. Kita tambahkan rekening baru senilai Rp1.000.000 untuk keluarga-santoso sehingga totalnya melonjak menjadi Rp5.500.000:

-- Total keluarga-santoso akan menjadi 5.500.000: langsung ditolak
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Ani', 'keluarga-santoso', 1000000);

Output yang Dihasilkan:

text
psql:pelanggaran-immediate.sql:3: ERROR:  total saldo kelompok "keluarga-santoso" melebihi batas 5000000
CONTEXT:  PL/pgSQL function cek_total_saldo() line 10 at RAISE

Pesan error tersebut muncul dari RAISE EXCEPTION di dalam fungsi trigger, bukan dari mesin constraint. Inilah ciri pembeda yang berguna saat membaca log: error constraint trigger selalu membawa baris CONTEXT: yang menunjuk ke fungsi PL/pgSQL. Perhatikan juga bahwa id rekening Ani tidak pernah tersimpan, tetapi nilai sequence-nya sudah terpakai — baris berikutnya akan melompat ke id 5. Kesenjangan penomoran seperti ini normal pada kolom identity setelah pernyataan gagal.

Sekarang bagian yang tidak bisa dilakukan CHECK maupun trigger biasa: menggeser momen pemeriksaan ke akhir transaksi. Kita ganti trigger yang lama dengan versi INITIALLY DEFERRED, lalu jalankan satu transaksi yang kelebihan saldo di tengah jalan tetapi sadar diri sebelum COMMIT:

DROP TRIGGER cek_saldo_kelompok ON rekening;

-- Versi tertunda: pemeriksaan digeser ke momen COMMIT
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

BEGIN;
-- Total sementara 5.100.000 (melewati batas): pernyataan diterima, belum diperiksa
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Ani', 'keluarga-santoso', 600000);

-- Kompensasi: turunkan saldo Budi sehingga total kembali 4.500.000
UPDATE rekening SET saldo = 2400000 WHERE id = 1;

-- Di dalam transaksi, keadaan yang "ilegal" itu benar-benar terlihat
SELECT id, pemilik, saldo FROM rekening ORDER BY id;
COMMIT;

-- Setelah COMMIT, pemeriksaan sudah dijalankan dan lolos
SELECT id, pemilik, saldo FROM rekening ORDER BY id;

Output yang Dihasilkan:

text
DROP TRIGGER
CREATE TRIGGER
BEGIN
INSERT 0 1
UPDATE 1
 id | pemilik |   saldo    
----+---------+------------
  1 | Budi    | 2400000.00
  2 | Siti    | 1500000.00
  3 | Dewi    | 1000000.00
  5 | Ani     |  600000.00
(4 rows)

COMMIT
 id | pemilik |   saldo    
----+---------+------------
  1 | Budi    | 2400000.00
  2 | Siti    | 1500000.00
  3 | Dewi    | 1000000.00
  5 | Ani     |  600000.00
(4 rows)

Peristiwa di dalam transaksi tersebut berjalan seperti ini:

  1. INSERT rekening Ani Rp600.000: total keluarga-santoso menjadi Rp5.100.000 — melewati batas. Karena trigger bersifat INITIALLY DEFERRED, server tidak menjalankan fungsi pemeriksaan saat itu juga; server hanya mencatat pemanggilan tertunda pada antrean transaksi.
  2. UPDATE saldo Budi menjadi Rp2.400.000: total kembali turun ke Rp4.500.000. Pemanggilan tertunda kedua ikut masuk antrean untuk baris Budi.
  3. SELECT di dalam transaksi: konsisten membaca keadaan sementara — kelebihan saldo tadi pernah nyata terjadi, dan pembaca di dalam transaksi yang sama bisa melihatnya. Trigger tertunda tidak menyensor apa pun.
  4. COMMIT: server mengosongkan antrean pemanggilan tertunda. Fungsi cek_total_saldo() baru dijalankan sekarang, saat total sudah Rp4.500.000, sehingga transaksi dinyatakan sah.

Pola “melanggar dulu, kompensasi belakangan” ini adalah alasan utama fitur penundaan ada. Penyesuaian saldo antar-rekening, pemindahan baris antar-kelompok, dan proses impor bertahap sering membutuhkan jeda sebelum keadaan data menjadi sah.

Ketika COMMIT Itu Sendiri yang Gagal #

Apa yang terjadi bila transaksi berakhir tanpa memperbaiki pelanggarannya? Jawabannya dramatis: perintah COMMIT sendiri yang gagal. Pernyataan-pernyataan sebelumnya semuanya diterima, lalu seluruh transaksi dibatalkan pada detik terakhir:

sql komit-gagal.sql
BEGIN;
-- Total keluarga-santoso menjadi 5.400.000: pernyataan tetap diterima
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Rina', 'keluarga-santoso', 900000);
COMMIT;

Output yang Dihasilkan:

text
psql:komit-gagal.sql:5: ERROR:  total saldo kelompok "keluarga-santoso" melebihi batas 5000000
CONTEXT:  PL/pgSQL function cek_total_saldo() line 10 at RAISE

Pesan error muncul pada baris COMMIT, bukan pada baris INSERT. Karena pemeriksaan tertunda gagal, server membatalkan seluruh transaksi — baris Rina tidak pernah tersimpan, dan saldo anggota lain tidak tersentuh. File contoh berikutnya membuktikannya: SELECT pembuka di sana masih menampilkan empat baris yang sama, tanpa Rina.

SET CONSTRAINTS: Menunda Sesuai Permintaan #

Kadang trigger sebaiknya tegas secara bawaan, tetapi satu transaksi khusus perlu kelonggaran. Untuk itu ada perintah SET CONSTRAINTS, yang berlaku per transaksi dan memakai nama constraint trigger. Kita kembalikan trigger ke INITIALLY IMMEDIATE, lalu di dalam satu transaksi kita tunda pemeriksaannya secara eksplisit:

-- Baris Rina tidak pernah tersimpan: COMMIT yang gagal
-- membatalkan seluruh transaksi
SELECT id, pemilik, saldo FROM rekening ORDER BY id;

DROP TRIGGER cek_saldo_kelompok ON rekening;

-- Kembali ke INITIALLY IMMEDIATE, tetap DEFERRABLE
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

BEGIN;
-- Tunda pemeriksaan hanya untuk transaksi ini
SET CONSTRAINTS cek_saldo_kelompok DEFERRED;

-- Total sementara 6.000.000: lolos karena pemeriksaan ditunda
UPDATE rekening SET saldo = 3000000 WHERE id = 2;

-- Kompensasi: total kembali menjadi 4.500.000
UPDATE rekening SET saldo = 900000 WHERE id = 1;
COMMIT;

SELECT id, pemilik, saldo FROM rekening ORDER BY id;

Output yang Dihasilkan:

text
 id | pemilik |   saldo    
----+---------+------------
  1 | Budi    | 2400000.00
  2 | Siti    | 1500000.00
  3 | Dewi    | 1000000.00
  5 | Ani     |  600000.00
(4 rows)

DROP TRIGGER
CREATE TRIGGER
BEGIN
SET CONSTRAINTS
UPDATE 1
UPDATE 1
COMMIT
 id | pemilik |   saldo    
----+---------+------------
  1 | Budi    |  900000.00
  2 | Siti    | 3000000.00
  3 | Dewi    | 1000000.00
  5 | Ani     |  600000.00
(4 rows)

Perintah SET CONSTRAINTS cek_saldo_kelompok DEFERRED hanya berlaku sampai transaksi berakhir — transaksi berikutnya kembali ke perilaku INITIALLY IMMEDIATE tanpa perlu mengatur ulang apa pun. Varian SET CONSTRAINTS ALL DEFERRED menunda seluruh constraint yang bisa ditunda sekaligus, dan lawannya SET CONSTRAINTS ALL IMMEDIATE memaksa pemeriksaan sisa berjalan di tengah transaksi — trik yang berguna untuk memindahkan error lebih dekat ke penyebabnya sebelum COMMIT.

Memilih Alat Validasi #

Constraint trigger melengkapi dua alat validasi yang sudah kita kenal di section Table Design. Pilihan alatnya mengikuti bentuk aturan dan momen pemeriksaan yang dibutuhkan:

AlatBentuk aturan yang ditanganiMomen pemeriksaanBisa ditunda
CHECKsyarat pada satu baris (kolomnya sendiri)seketika, per baristidak
EXCLUDErelasi berpasangan antar baris lewat operator indexseketika, dibantu indextidak
Constraint triggeraturan bebas lewat query, termasuk agregat lintas barisseketika atau saat COMMITya

Aturan “total saldo kelompok maksimal Rp5.000.000” jatuh pada baris ketiga karena aturan tersebut butuh SUM atas banyak baris — bukan perbandingan satu pasangan. Sebaliknya, aturan “shift dokter tidak boleh tumpang tindih” dari chapter Exclusion Constraints lebih baik tetap memakai EXCLUDE: pemeriksaannya ditopang index sehingga tetap cepat saat tabel membesar. Constraint trigger yang melakukan SUM penuh pada setiap tulis akan semakin mahal seiring bertambahnya anggota kelompok — pertimbangan biaya yang akan kita bahas tuntas di chapter Keterbatasan Trigger.

Dengan constraint trigger, kotak validasi Anda kini lengkap dari sisi data. Namun semua trigger yang kita bahas di section ini masih terikat pada satu tabel dan hanya melihat DML. Perintah CREATE TABLE, ALTER TABLE, dan DROP TABLE berjalan tanpa satu pun trigger bereaksi — kecuali kita naik satu level ke trigger yang mengawasi DDL itu sendiri, yang menjadi topik chapter Event Trigger berikutnya.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Batas bisnis: total saldo satu kelompok maksimal Rp5.000.000
CREATE TABLE rekening (
    id       integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    pemilik  text NOT NULL,
    kelompok text NOT NULL,
    saldo    numeric(12,2) NOT NULL DEFAULT 0
);

CREATE FUNCTION cek_total_saldo() RETURNS trigger AS $$
DECLARE
    total numeric;
BEGIN
    SELECT sum(saldo) INTO total
    FROM rekening
    WHERE kelompok = NEW.kelompok;

    IF total > 5000000 THEN
        RAISE EXCEPTION 'total saldo kelompok "%" melebihi batas %',
            NEW.kelompok, 5000000;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Constraint trigger: DEFERRABLE dan terdaftar dengan nama constraint
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

INSERT INTO rekening (pemilik, kelompok, saldo) VALUES
    ('Budi', 'keluarga-santoso', 3000000),
    ('Siti', 'keluarga-santoso', 1500000),
    ('Dewi', 'keluarga-wijaya', 1000000);

SELECT id, pemilik, kelompok, saldo FROM rekening ORDER BY id;

\d rekening

ganti-deferred.sql

Unduh berkas
sql
DROP TRIGGER cek_saldo_kelompok ON rekening;

-- Versi tertunda: pemeriksaan digeser ke momen COMMIT
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

BEGIN;
-- Total sementara 5.100.000 (melewati batas): pernyataan diterima, belum diperiksa
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Ani', 'keluarga-santoso', 600000);

-- Kompensasi: turunkan saldo Budi sehingga total kembali 4.500.000
UPDATE rekening SET saldo = 2400000 WHERE id = 1;

-- Di dalam transaksi, keadaan yang "ilegal" itu benar-benar terlihat
SELECT id, pemilik, saldo FROM rekening ORDER BY id;
COMMIT;

-- Setelah COMMIT, pemeriksaan sudah dijalankan dan lolos
SELECT id, pemilik, saldo FROM rekening ORDER BY id;

komit-gagal.sql

Unduh berkas
sql
BEGIN;
-- Total keluarga-santoso menjadi 5.400.000: pernyataan tetap diterima
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Rina', 'keluarga-santoso', 900000);
COMMIT;

pelanggaran-immediate.sql

Unduh berkas
sql
-- Total keluarga-santoso akan menjadi 5.500.000: langsung ditolak
INSERT INTO rekening (pemilik, kelompok, saldo)
VALUES ('Ani', 'keluarga-santoso', 1000000);

set-constraints.sql

Unduh berkas
sql
-- Baris Rina tidak pernah tersimpan: COMMIT yang gagal
-- membatalkan seluruh transaksi
SELECT id, pemilik, saldo FROM rekening ORDER BY id;

DROP TRIGGER cek_saldo_kelompok ON rekening;

-- Kembali ke INITIALLY IMMEDIATE, tetap DEFERRABLE
CREATE CONSTRAINT TRIGGER cek_saldo_kelompok
AFTER INSERT OR UPDATE ON rekening
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW EXECUTE FUNCTION cek_total_saldo();

BEGIN;
-- Tunda pemeriksaan hanya untuk transaksi ini
SET CONSTRAINTS cek_saldo_kelompok DEFERRED;

-- Total sementara 6.000.000: lolos karena pemeriksaan ditunda
UPDATE rekening SET saldo = 3000000 WHERE id = 2;

-- Kompensasi: total kembali menjadi 4.500.000
UPDATE rekening SET saldo = 900000 WHERE id = 1;
COMMIT;

SELECT id, pemilik, saldo FROM rekening ORDER BY id;