PostgreSQL Trigger'ları: BEFORE/AFTER, Row ve Statement Seviyesi, Transition Table'lar, updated_at ve Audit Trigger'ı, Event Trigger'lar
PostgreSQL'de satırı değiştiren iş BEFORE ROW trigger'ında NEW üzerinden, başka tabloya yazan iş AFTER trigger'ında yapılır; AFTER ROW trigger'ları statement bitince çalışır. updated_at ve WHEN koşulu, jsonb audit trigger'ı, 200.000 satırlık UPDATE'te row ve transition table'lı statement trigger maliyeti, join view için INSTEAD OF, DDL kaydeden event trigger ve EF Core'da RETURNING ile trigger değerini okumak.
Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, Bookstore şeması yüklendikten hemen sonra çalıştırılmıştır; .NET örneği .NET 10, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve Entity Framework Core (EF Core) 10 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için books tablosuna dört trigger koyduk: UPDATE'ten önce ve sonra, statement ve row seviyesinde. Hepsi aynı function'ı çağırıyor ve yalnızca ne zaman, hangi seviyede ve hangi satır için çalıştığını yazıyor:
CREATE FUNCTION trace_trigger() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE '% % %: %', TG_WHEN, TG_LEVEL, TG_OP, CASE WHEN TG_LEVEL = 'ROW' THEN NEW.book_id::text ELSE '-' END;
IF TG_LEVEL = 'ROW' THEN
RETURN NEW;
END IF;
RETURN NULL;
END
$$;
CREATE TRIGGER t1 BEFORE UPDATE ON books FOR EACH STATEMENT EXECUTE FUNCTION trace_trigger();
CREATE TRIGGER t2 BEFORE UPDATE ON books FOR EACH ROW EXECUTE FUNCTION trace_trigger();
CREATE TRIGGER t3 AFTER UPDATE ON books FOR EACH ROW EXECUTE FUNCTION trace_trigger();
CREATE TRIGGER t4 AFTER UPDATE ON books FOR EACH STATEMENT EXECUTE FUNCTION trace_trigger();
UPDATE books SET price = price WHERE book_id IN (1, 2);
UPDATE books SET price = price WHERE book_id = -1;
İki satırı güncelleyen ilk UPDATE kaç mesaj yazar ve hangi sırayla? Hiç satır bulamayan ikinci UPDATE herhangi bir trigger'ı çalıştırır mı?
NOTICE: BEFORE STATEMENT UPDATE: -
NOTICE: BEFORE ROW UPDATE: 1
NOTICE: BEFORE ROW UPDATE: 2
NOTICE: AFTER ROW UPDATE: 1
NOTICE: AFTER ROW UPDATE: 2
NOTICE: AFTER STATEMENT UPDATE: -
UPDATE 2
NOTICE: BEFORE STATEMENT UPDATE: -
NOTICE: AFTER STATEMENT UPDATE: -
UPDATE 0
İki ayrıntı çoğu kişiyi şaşırtıyor. AFTER ROW trigger'ları her satırın hemen arkasından değil, statement bütün satırları işledikten sonra sırayla çalışıyor: PostgreSQL onları bir queue'da biriktiriyor. Statement seviyesindeki trigger'lar ise satır sayısına bakmıyor; sıfır satır etkileyen UPDATE'te de birer kez çalıştılar. Değeri değiştirmeyen SET price = price da trigger'ları tetikledi; PostgreSQL "değişmedi" diye satırı atlamıyor. SQL Server'dan gelen için bir fark daha: SQL Server'ın DML (Data Manipulation Language; veri değiştiren komutlar) trigger'ları yalnızca statement seviyesinde ve hep AFTER (ya da INSTEAD OF) çalışıyor, satırlar inserted/deleted tablolarından okunuyor; PostgreSQL'de row seviyesi, BEFORE ve satırı değiştirebilmek de var. Bu yazıda trigger'ların bu kurallarını, updated_at ve audit trigger'ını, toplu güncellemedeki maliyetlerini, INSTEAD OF ve event trigger'ları, EF Core ile ilişkilerini görüyoruz.
Trigger Function'ı: NEW, OLD, TG_OP ve Dönüş Değeri
PostgreSQL'de trigger iki parçadan oluşuyor: RETURNS trigger tipinde, parametresiz bir function ve onu bir tabloya, olaya ve zamana bağlayan CREATE TRIGGER. Aynı function birden çok tabloya bağlanabiliyor; tabloya özgü argüman gerekirse CREATE TRIGGER'da verilip function içinde TG_ARGV'den okunuyor. Function'ın içinde procedural dilin (PL/pgSQL, 22. yazı) hazırladığı özel değişkenler var: NEW (yazılacak satır, INSERT ve UPDATE'te), OLD (eski satır, UPDATE ve DELETE'te), TG_OP (INSERT, UPDATE, DELETE, TRUNCATE), TG_WHEN, TG_LEVEL, TG_TABLE_NAME ve TG_TABLE_SCHEMA. Statement seviyesinde NEW ve OLD yok; "Önce Tahmin Et"teki CASE bu yüzden.
Dönüş değeri yalnızca BEFORE ROW trigger'ında anlam taşıyor: NEW'i (ya da değiştirilmiş bir kopyasını) döndürürsen satır o hâliyle yazılıyor, NULL döndürürsen o satır için işlem sessizce atlanıyor ve sonraki trigger'lar da çalışmıyor. DELETE'te NEW boş olduğu için OLD döndürülüyor. AFTER ve statement trigger'larında dönüş değeri yok sayılıyor; RETURN NULL yazmak alışkanlık. Aynı tabloda aynı olay için birden çok trigger varsa ada göre alfabetik sırayla çalışıyorlar; sıra önemliyse adları buna göre ver (trg_10_..., trg_20_...).
Kural basit: satırın kendisini değiştirmek (bir kolonu doldurmak, normalize etmek, reddetmek) BEFORE ROW işi; başka tablolara yazmak ya da bildirim göndermek (11. yazıdaki pg_notify trigger'ı gibi) AFTER işi, çünkü AFTER çalıştığında satır constraint'lerden geçmiş ve son hâlini almış oluyor.
updated_at: BEFORE ROW Trigger ve WHEN Koşulu
Bookstore'un books tablosunda değişiklik zamanı yok. Kolonu ekleyip her güncellemede trigger'la dolduralım:
ALTER TABLE books ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
CREATE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END
$$;
CREATE TRIGGER trg_books_updated_at
BEFORE UPDATE ON books
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION set_updated_at();
SELECT book_id, price, updated_at FROM books WHERE book_id IN (1, 2) ORDER BY book_id;
SELECT pg_sleep(1);
UPDATE books SET price = price + 1 WHERE book_id = 1;
UPDATE books SET price = price WHERE book_id = 2;
SELECT book_id, price, updated_at FROM books WHERE book_id IN (1, 2) ORDER BY book_id;
UPDATE books SET updated_at = '2020-01-01' WHERE book_id = 2;
SELECT book_id, price, updated_at FROM books WHERE book_id = 2;
ALTER TABLE
Time: 0.809 ms
book_id | price | updated_at
---------+-------+-------------------------------
1 | 32.77 | 2026-11-15 00:50:27.986065+00
2 | 39.00 | 2026-11-15 00:50:27.986065+00
book_id | price | updated_at
---------+-------+-------------------------------
1 | 33.77 | 2026-11-15 00:50:28.994224+00
2 | 39.00 | 2026-11-15 00:50:27.986065+00
book_id | price | updated_at
---------+-------+-------------------------------
2 | 39.00 | 2026-11-15 00:50:28.998739+00
40.000 satırlık tabloya NOT NULL DEFAULT now() kolonu 0,8 ms'de eklendi. PostgreSQL 11'den beri volatile olmayan bir default ile eklenen kolon tabloyu yeniden yazmıyor; değer bir kez hesaplanıp catalog'da tutuluyor ve var olan bütün satırlar onu görüyor. Bu yüzden ilk sorguda iki kitabın zamanı aynı. DEFAULT clock_timestamp() yazsaydın tablo baştan yazılırdı.
Fiyatı değişen kitap 1'in zamanı güncellendi. Değeri aynı kalan kitap 2'ninki değişmedi: WHEN (OLD.* IS DISTINCT FROM NEW.*) function'ı hiç çağırmadı. WHEN koşulu trigger function'ının içinde IF yazmaktan ucuz, çünkü function çağrısı ve PL/pgSQL'e geçiş hiç yapılmıyor. Aynı amaçla PostgreSQL'in hazır bir function'ı da var: suppress_redundant_updates_trigger(), hiçbir değeri değişmeyen güncellemeyi tamamen atlıyor. Son UPDATE'te uygulama updated_at'i elle 2020'ye çekmeye çalıştı; satır değiştiği için trigger çalıştı ve değeri yeniden şimdiki zamana yazdı. Kolonun anlamını uygulamadaki hiçbir kod bozamıyor.
Aynı Tabloyu Güncelleyen AFTER Trigger
Aynı işi AFTER trigger'ında UPDATE ile yapmaya çalışmak sık görülen bir hata:
CREATE FUNCTION touch_book_after() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE books SET updated_at = now() WHERE book_id = NEW.book_id;
RETURN NULL;
END
$$;
CREATE TRIGGER trg_books_touch AFTER UPDATE ON books FOR EACH ROW EXECUTE FUNCTION touch_book_after();
UPDATE books SET price = price + 1 WHERE book_id = 3;
ERROR: stack depth limit exceeded
HINT: Increase the configuration parameter "max_stack_depth" (currently 2048kB), after ensuring the platform's stack depth limit is adequate.
CONTEXT: SQL statement "SELECT 1 FROM ONLY "public"."authors" x WHERE "author_id" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x"
SQL statement "UPDATE books SET updated_at = now() WHERE book_id = NEW.book_id"
PL/pgSQL function touch_book_after() line 3 at SQL statement
SQL statement "UPDATE books SET updated_at = now() WHERE book_id = NEW.book_id"
PL/pgSQL function touch_book_after() line 3 at SQL statement
...
Trigger'ın yaptığı UPDATE trigger'ı yeniden tetikledi, o da yeniden UPDATE yaptı. Hata mesajında aynı iki satır 663 kez tekrarlandı ve stack dolunca statement geri alındı (çıktıyı kısalttık). En içteki satır ilginç: foreign key kontrolü de PostgreSQL'in kendi iç trigger'larıyla yapılıyor. Hata hızlı ve gürültülü olduğu için bu hâliyle production'a gitmez; asıl tehlikeli olan, iki tablonun birbirini güncellediği ve yalnızca belirli verilerde döngüye giren zincirler. pg_trigger_depth() iç içe kaçıncı trigger'da olduğunu söylüyor ve döngüyü kırmak için kullanılabiliyor, ama doğru çözüm satırı BEFORE trigger'ında NEW üzerinden değiştirmek.
Audit Trigger'ı: Değişen Kolonları jsonb Olarak Saklamak
Siparişin durumu değiştiğinde kimin, ne zaman, neyi değiştirdiğini kaydetmek istiyoruz. Bütün tablolar için tek bir audit tablosu ve değişen kolonları eski ve yeni değerleriyle jsonb olarak yazan bir trigger:
CREATE TABLE audit_log
(
audit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
table_name text NOT NULL,
operation text NOT NULL,
row_id int NOT NULL,
changes jsonb NOT NULL,
changed_by text NOT NULL DEFAULT session_user,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE FUNCTION audit_order_change() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, row_id, changes)
SELECT TG_TABLE_NAME, TG_OP, NEW.order_id,
jsonb_object_agg(n.key, jsonb_build_object('old', to_jsonb(OLD) -> n.key, 'new', n.value))
FROM jsonb_each(to_jsonb(NEW)) AS n
WHERE to_jsonb(OLD) -> n.key IS DISTINCT FROM n.value;
RETURN NULL;
END
$$;
CREATE TRIGGER trg_orders_audit
AFTER UPDATE ON orders
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.total_amount IS DISTINCT FROM NEW.total_amount)
EXECUTE FUNCTION audit_order_change();
UPDATE orders SET status = 'Shipped' WHERE order_id = 1000000;
UPDATE orders SET status = 'Shipped' WHERE order_id = 1000000;
UPDATE orders SET status = 'Delivered', total_amount = total_amount - 5 WHERE order_id = 1000000;
SELECT audit_id, row_id, changes, changed_by FROM audit_log ORDER BY audit_id;
audit_id | row_id | changes | changed_by
----------+---------+--------------------------------------------------------------------------------------------------+-----------------
1 | 1000000 | {"status": {"new": "Shipped", "old": "Pending"}} | bookstore_owner
2 | 1000000 | {"status": {"new": "Delivered", "old": "Shipped"}, "total_amount": {"new": 44.34, "old": 49.34}} | bookstore_owner
to_jsonb(NEW) satırı kolon adı ve değerlerden oluşan bir object'e çeviriyor, jsonb_each onu key/value satırlarına açıyor (05. yazı), jsonb_object_agg da yalnızca farklı olanları tekrar topluyor (20. yazı). Function hiçbir kolon adı bilmediği için NEW.order_id dışında başka tablolara da bağlanabilir. İkinci UPDATE durumu zaten Shipped olan siparişe aynı değeri yazdığı için WHEN koşulunu geçemedi ve audit satırı üretmedi. Kayıt, ana değişiklikle aynı transaction'da yazılıyor: sipariş güncellemesi geri alınırsa audit kaydı da geri alınıyor, ki audit'ten beklenen de bu.
changed_by'da gördüğün gibi trigger yalnızca veritabanı rolünü biliyor. Uygulama bütün kullanıcıları tek bir rolle bağlıyorsa gerçek kullanıcıyı 14. yazıdaki app.customer_id gibi bir oturum ayarıyla (SET LOCAL app.user_id = ...) trigger'a taşıman gerekiyor. Audit trail'in tasarım tarafı, yani neyin, ne kadar süre ve hangi biçimde saklanacağı, Çözüm Desenleri serisinin audit trail yazısında.
Toplu UPDATE'te Maliyet: Row Seviyesi ve Transition Table
Row seviyesindeki trigger her satır için bir function çağrısı ve bir INSERT demek. Bir kampanya düzeltmesi yüzünden 200.000 siparişin tutarını tek UPDATE ile değiştirdiğimizi düşün. Statement seviyesindeki trigger REFERENCING ile etkilenen satırların eski ve yeni hâlini birer tablo gibi (transition table) alabiliyor ve audit'i tek bir INSERT ... SELECT ile yazabiliyor:
CREATE FUNCTION audit_order_amount_row() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, row_id, changes)
VALUES (TG_TABLE_NAME, TG_OP, NEW.order_id,
jsonb_build_object('total_amount', jsonb_build_object('old', OLD.total_amount, 'new', NEW.total_amount)));
RETURN NULL;
END
$$;
CREATE FUNCTION audit_order_amount_bulk() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, row_id, changes)
SELECT TG_TABLE_NAME, TG_OP, n.order_id,
jsonb_build_object('total_amount', jsonb_build_object('old', o.total_amount, 'new', n.total_amount))
FROM new_rows AS n
JOIN old_rows AS o ON o.order_id = n.order_id
WHERE o.total_amount IS DISTINCT FROM n.total_amount;
RETURN NULL;
END
$$;
CREATE TRIGGER trg_orders_amount_row
AFTER UPDATE OF total_amount ON orders
FOR EACH ROW
EXECUTE FUNCTION audit_order_amount_row();
CREATE TRIGGER trg_orders_amount_bulk
AFTER UPDATE ON orders
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION audit_order_amount_bulk();
UPDATE orders SET total_amount = total_amount + 1 WHERE order_id <= 200000;
Her denemede yalnızca bir trigger açık, UPDATE bir transaction içinde çalışıp geri alındı; dört varyantı üç tur çalıştırdık (db/23-trigger-cost.sql, süreler ms):
| Varyant | 1. tur | 2. tur | 3. tur |
|---|---|---|---|
| Trigger yok | 1502 | 2396 | 1990 |
Row seviyesi, sabit kolon (trg_orders_amount_row) | 2193 | 2841 | 2772 |
Statement seviyesi, transition table (trg_orders_amount_bulk) | 1993 | 2369 | 2236 |
Row seviyesi, genel jsonb farkı (trg_orders_audit) | 5039 | 5442 | 5434 |
Sonuç ilk akla gelenden daha ayrıntılı. Tek kolonu yazan basit bir row trigger'ı 200.000 satırlık güncellemeye turdan tura %20–45 ekledi; transition table'lı statement trigger'ı ise %0–35. Fark var ama büyük değil; asıl maliyeti trigger'ın her satırda ne yaptığı belirliyor. Yukarıdaki genel audit function'ı her satırı iki kez jsonb'ye çevirip bütün kolonlarını karşılaştırdığı için süreyi 2,3 ile 3,4 katına çıkardı. Ölçümler gürültülü (trigger'sız güncelleme bile 1,5 ile 2,4 saniye arasında değişti); karar verirken kendi verinle ölç. Transition table'ın iki sınırı var: UPDATE OF total_amount gibi kolon listesiyle birlikte kullanılamıyor (denediğimizde transition tables cannot be specified for triggers with column lists hatası aldık, bulk trigger'ın kolon listesiz olması bu yüzden) ve INSTEAD OF trigger'larında yok.
INSTEAD OF: Join View'ı Yazılabilir Yapmak
21. yazıda GROUP BY'lı v_category_stats view'ını güncellemeye çalışınca PostgreSQL INSTEAD OF trigger önermişti. Aynı durum iki tabloyu birleştiren view'larda da var. Depo ekranı fiyat ve stok miktarını tek bir satırda gösteren bir view kullanıyor:
CREATE VIEW v_book_stock AS
SELECT b.book_id, b.title, b.price, s.quantity_on_hand
FROM books AS b
JOIN stock_items AS s ON s.book_id = b.book_id;
UPDATE v_book_stock SET price = 19.90, quantity_on_hand = 50 WHERE book_id = 5;
CREATE FUNCTION v_book_stock_update() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.book_id IS DISTINCT FROM OLD.book_id THEN
RAISE EXCEPTION 'book_id cannot be changed through v_book_stock';
END IF;
UPDATE books SET title = NEW.title, price = NEW.price
WHERE book_id = OLD.book_id
AND (title, price) IS DISTINCT FROM (NEW.title, NEW.price);
UPDATE stock_items SET quantity_on_hand = NEW.quantity_on_hand, updated_at = now()
WHERE book_id = OLD.book_id
AND quantity_on_hand IS DISTINCT FROM NEW.quantity_on_hand;
RETURN NEW;
END
$$;
CREATE TRIGGER trg_v_book_stock_update
INSTEAD OF UPDATE ON v_book_stock
FOR EACH ROW
EXECUTE FUNCTION v_book_stock_update();
UPDATE v_book_stock SET price = 19.90, quantity_on_hand = 50 WHERE book_id = 5 RETURNING *;
DELETE FROM v_book_stock WHERE book_id = 5;
SELECT b.price, s.quantity_on_hand FROM books AS b JOIN stock_items AS s USING (book_id) WHERE book_id = 5;
ERROR: cannot update view "v_book_stock"
DETAIL: Views that do not select from a single table or view are not automatically updatable.
HINT: To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.
book_id | title | price | quantity_on_hand
---------+---------------+-------+------------------
5 | Foundation #5 | 19.90 | 50
UPDATE 1
ERROR: cannot delete from view "v_book_stock"
DETAIL: Views that do not select from a single table or view are not automatically updatable.
HINT: To enable deleting from the view, provide an INSTEAD OF DELETE trigger or an unconditional ON DELETE DO INSTEAD rule.
price | quantity_on_hand
-------+------------------
19.90 | 50
INSTEAD OF trigger yalnızca view'larda ve yalnızca row seviyesinde var: view'a gelen her satır için PostgreSQL işi yapmak yerine function'ı çağırıyor ve function'ın döndürdüğü NEW'i RETURNING'e ve UPDATE 1 sayısına yansıtıyor. NULL döndürseydi satır "işlenmedi" sayılırdı. Function hangi tabloya neyin gideceğine kendisi karar veriyor; değişmeyen tabloya dokunmamak için iki UPDATE'e de IS DISTINCT FROM koşulu koyduk, böylece yalnızca fiyat değişince stock_items'a boş bir satır versiyonu yazılmıyor. Trigger yalnızca UPDATE için tanımlı olduğundan DELETE hâlâ reddediliyor; desteklemek istediğin her işlem için ayrı bir trigger gerekiyor. v_category_stats gibi aggregate view'larda ise "kategorideki kitap sayısını 0 yap" isteğinin anlamlı bir karşılığı olmadığı için INSTEAD OF yazmak yerine view'ı salt okunur bırakmak doğru.
Event Trigger ile DDL Kaydı
Normal trigger'lar satır değişikliklerinde çalışıyor; event trigger'lar ise veritabanı genelindeki DDL (Data Definition Language; tablo ve index gibi yapıları değiştiren komutlar) komutlarında. Production'da şemaya kimin, ne zaman dokunduğunu kaydetmek için bir örnek. CREATE EVENT TRIGGER superuser yetkisi istediği için kurulum postgres rolüyle yapılıyor:
CREATE TABLE public.ddl_log
(
ddl_log_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
command_tag text NOT NULL,
object_type text NOT NULL,
object_identity text,
executed_by text NOT NULL,
executed_at timestamptz NOT NULL DEFAULT now()
);
CREATE FUNCTION public.log_ddl() RETURNS event_trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
INSERT INTO ddl_log (command_tag, object_type, object_identity, executed_by)
SELECT c.command_tag, c.object_type, c.object_identity, session_user
FROM pg_event_trigger_ddl_commands() AS c;
END
$$;
CREATE EVENT TRIGGER trg_log_ddl ON ddl_command_end EXECUTE FUNCTION public.log_ddl();
Ardından bookstore_owner ile ayrı bir oturumda dört DDL komutu çalıştırdık ve kaydı postgres ile okuduk:
ALTER TABLE books ADD COLUMN subtitle text;
CREATE INDEX ix_books_updated_at ON books (updated_at);
DROP INDEX ix_books_updated_at;
ALTER TABLE books DROP COLUMN subtitle;
SELECT ddl_log_id, command_tag, object_type, object_identity, executed_by FROM ddl_log ORDER BY ddl_log_id;
ddl_log_id | command_tag | object_type | object_identity | executed_by
------------+--------------+-------------+----------------------------+-----------------
1 | ALTER TABLE | table | public.books | bookstore_owner
2 | CREATE INDEX | index | public.ix_books_updated_at | bookstore_owner
3 | ALTER TABLE | table | public.books | bookstore_owner
Dört komuttan üçü kaydedildi; DROP INDEX yok. ddl_command_end olayında pg_event_trigger_ddl_commands() silinen object'leri döndürmüyor; onlar için ayrı bir sql_drop event trigger'ı ve pg_event_trigger_dropped_objects() gerekiyor. Diğer olaylar ddl_command_start (komut çalışmadan önce, örneğin belirli saatlerde DDL'i reddetmek için), table_rewrite ve PostgreSQL 17 ile gelen login. Function'ı SECURITY DEFINER yaptık ki DDL'i çalıştıran rolün ddl_log'a yazma yetkisi gerekmesin; 22. yazıdaki search_path kuralı burada da geçerli. Event trigger hata verirse DDL komutu da başarısız oluyor; yanlış yazılmış bir event trigger bütün migration'ları durdurabilir. PostgreSQL 17'den beri superuser SET event_triggers = off ile onları geçici olarak kapatıp sorunu düzeltebiliyor.
EF Core ve Trigger'lı Tablolar
Trigger updated_at'i değiştirdiğinde EF Core'un bundan haberi yok. 12. yazıdaki Npgsql provider'ıyla books'u iki farklı yapılandırmayla map ettik; tek fark UpdatedAt property'sinin ValueGeneratedOnAddOrUpdate() ile işaretlenmesi:
PropertyBuilder<DateTime> updatedAt = book.Property(b => b.UpdatedAt).HasColumnName("updated_at");
if (ReadTriggerValues)
{
updatedAt.ValueGeneratedOnAddOrUpdate();
}
Book book = await db.Books.SingleAsync(b => b.BookId == 10);
DateTime before = book.UpdatedAt;
book.Price += 1;
await db.SaveChangesAsync();
DateTime inDatabase = await db.Books.Where(b => b.BookId == 10).Select(b => b.UpdatedAt).SingleAsync();
Console.WriteLine($"entity: {book.UpdatedAt:HH:mm:ss.fff} (before {before:HH:mm:ss.fff}), database: {inDatabase:HH:mm:ss.fff}");
-- naive
UPDATE books SET price = @p0
WHERE book_id = @p1;
entity: 00:50:27.986 (before 00:50:27.986), database: 00:53:35.252
-- trigger-aware
UPDATE books SET price = @p0
WHERE book_id = @p1
RETURNING updated_at;
entity: 00:53:35.307 (before 00:53:35.252), database: 00:53:35.307
Çıktıdan EF Core'un log satırlarını çıkarıp yalnızca UPDATE komutlarını bıraktık. İlk yapılandırmada SaveChanges başarılı oldu ama entity'deki UpdatedAt eski değerde kaldı; aynı context'te bu entity'yi kullanmaya devam eden kod (bir API yanıtı, bir cache girdisi) yanlış zamanı görüyor. İkincisinde EF Core UPDATE'e RETURNING updated_at ekledi ve trigger'ın yazdığı değeri aynı round trip'te geri okudu. Aynı yöntem trigger'ın doldurduğu her kolon için geçerli; insert'te dolduruluyorsa ValueGeneratedOnAdd(). SQL Server'dan gelenler için not: EF Core 7'den beri SQL Server'da trigger'lı tablolar HasTrigger(...) ile bildirilmek zorunda, çünkü EF'in kullandığı OUTPUT clause'u trigger'larla birlikte çalışmıyor. PostgreSQL'in RETURNING'inde böyle bir kısıt yok; Npgsql'de ek ayar gerekmedi. ExecuteUpdate ve ExecuteDelete ise change tracker'ı hiç kullanmadığı için trigger'ın etkisini zaten entity'ye taşımıyor.
Üretimde Dikkat
Trigger'lar görünmez: tabloya bakan geliştirici onları ancak aradığında görüyor. psql'de \d books trigger'ları tablonun altında listeliyor; pg_trigger catalog'u (NOT tgisinternal ile foreign key'lerin iç trigger'larını eleyerek) ve pg_get_triggerdef hepsini veriyor. Her trigger'ı migration'da, ne yaptığını söyleyen bir adla ve tablonun dokümantasyonunda an.
Toplu yükleme ve bakım sırasında trigger'ları kapatmanın iki yolu var. ALTER TABLE ... DISABLE TRIGGER trg_orders_audit tek trigger'ı kapatıyor ama tabloya kısa da olsa güçlü bir lock alıyor ve bütün oturumları etkiliyor. SET session_replication_role = replica yalnızca o oturumda bütün normal trigger'ları ve foreign key kontrollerini atlıyor; superuser ister ve veri tutarlılığını sana bırakır. Logical replication'da da aynı ayar devrede: subscriber tarafında trigger'lar varsayılan olarak çalışmıyor, gerekiyorsa ENABLE REPLICA TRIGGER ya da ENABLE ALWAYS TRIGGER ile açılıyor.
Ne zaman trigger, ne zaman uygulama kodu? Verinin kendisiyle ilgili, her yazma yolunda (uygulama, migration, elle yapılan düzeltme) geçerli olması gereken küçük kurallar trigger'a uygun: updated_at, audit, türetilmiş bir kolonun güncel tutulması. Türetilmiş kolon yalnızca aynı satırdaki değerlerden hesaplanıyorsa 04 ve 16. yazılardaki generated column daha basit. E-posta göndermek, başka bir servise haber vermek gibi dış dünyaya giden işler trigger'a girmemeli: transaction geri alınsa da e-posta gitmiş olur. Bunun yerine trigger (ya da uygulama) aynı transaction'da bir outbox tablosuna yazar, ayrı bir worker gönderir; Message Queue'larla Çalışmak serisinin outbox yazısı ve 09. yazıdaki SKIP LOCKED'lı relay bu yüzden var.
Sık Düşülen Tuzaklar
Trigger zinciri ve sonsuz döngü: kendi tablosunu güncelleyen AFTER trigger ya da birbirini güncelleyen iki tablo. Satırı değiştireceksen BEFORE ve NEW kullan; başka tabloya yazıyorsan WHEN koşuluyla gereksiz tetiklemeleri kes.
Toplu UPDATE'te row seviyesi trigger'ın maliyetini ölçmemek: basit bir trigger kabul edilebilir, satır başına jsonb işleyen genel bir trigger işi üç katına çıkarabiliyor. Gece çalışan toplu işleri trigger'larla birlikte test et.
Trigger'a gizlenmiş iş kuralını kimsenin bilmemesi: uygulamada "fiyatı değiştirdim, neden stok tablosu da değişti?" sorusu. Trigger'lar az, küçük ve belgelenmiş olsun; iş akışını trigger'lar üzerinden kurma.
Trigger'ın doldurduğu kolonu EF Core'a bildirmemek: SaveChanges sonrası entity eski değeri taşır. ValueGeneratedOnAddOrUpdate ile RETURNING'i aç.
Küçük Bir Deneme
★☆☆ trg_books_updated_at'in WHEN koşulunu kaldırıp yerine suppress_redundant_updates_trigger()'ı çağıran ikinci bir BEFORE trigger ekle. Değişmeyen bir güncellemede psql hangi satır sayısını gösteriyor (UPDATE 0 mı, UPDATE 1 mi)? İki trigger'ın alfabetik sırası neden önemli?
★★☆ audit_order_change'i DELETE için de çalışacak şekilde genişlet: TG_OP'a göre OLD'u kaydet. Sonra 22. yazıdaki archive_shipments gibi parçalı silmeyi bu trigger açıkken çalıştır ve audit tablosunun boyutunu pg_total_relation_size ile izle.
★★★ v_book_stock için INSTEAD OF INSERT yaz: yeni kitap books'a, stok satırı stock_items'a eklensin ve RETURNING book_id yeni üretilen numarayı döndürsün. EF Core'da bu view'ı ToView yerine ToTable ile map edip bir Add + SaveChanges dene; üretilen SQL'i ve sonucu incele.
Bir sonraki yazıda bu uzatmanın son konusuna, psql ile günlük çalışmaya geçiyoruz: \d ailesi, \x ve \timing, \copy ile COPY farkı, \watch, \gexec, .psqlrc, schema'lar ve search_path.