সরাসরি পর্বে যান
পর্ব ২৯ · ৬৭-এর মধ্যে · মডিউল ৪

Trigger — নিয়ম মেনে স্বয়ংক্রিয় কাজ

Triggers — Automatic Actions on INSERT, UPDATE and DELETE, and When Not to Use Them

Taught in Bangla, this lesson covers database triggers as event-condition-action rules: CREATE TRIGGER, NEW and OLD rows, BEFORE, AFTER and INSTEAD OF timing, row versus statement level, RAISE to reject changes, and six real uses including price audit logs, stock updates, forward-only order status, derived columns, soft delete and updated_at, plus trigger chains, recursion and when to prefer constraints or application code, all in live SQL.

এই পর্বে যা শিখবেন

  • Event-condition-action ধারণা, CREATE TRIGGER-এর প্রতিটি অংশ আর NEW/OLD কখন পাওয়া যায়
  • BEFORE, AFTER আর INSTEAD OF-এর সময়ের পার্থক্য; row-level বনাম statement-level trigger
  • RAISE-এর চার রূপ (ABORT, FAIL, ROLLBACK, IGNORE) দিয়ে নিয়মভঙ্গ আটকানো
  • ছয়টি বাস্তব ব্যবহার — audit log, স্টক, status-এর নিয়ম, derived কলাম, soft delete আর updated_at — লাইভ SQL-এ
  • Trigger-এর শিকল, recursive trigger, PostgreSQL ও MySQL-এর রূপ, আর trigger বনাম constraint বনাম অ্যাপের কোড

১ · Trigger কী — ঘটনা, শর্ত আর কাজ

পর্ব ১৩-এ দেখেছেন, CHECK আর NOT NULL একটি সারির ভেতরের নিয়ম পাহারা দেয়; পর্ব ৬ আর ২৩-এ দেখেছেন, foreign key দুই টেবিলের সম্পর্ক রক্ষা করে। কিন্তু কিছু নিয়ম এর বাইরে পড়ে। “দাম বদলালে পুরোনো দামটি ইতিহাসের খাতায় লিখে রাখো।” “অর্ডারে পণ্য যোগ হলে গুদামের স্টক কমাও।” “অর্ডারের অবস্থা delivered থেকে আর pending-এ ফিরবে না।” এগুলো শুধু “এই মানটি বৈধ কি না” নয় — এগুলো ঘটনার প্রতিক্রিয়া। এমন প্রতিক্রিয়ার জন্যই trigger: ডেটাবেসে রাখা একটি কোড, যা নির্দিষ্ট টেবিলে নির্দিষ্ট পরিবর্তন হলে DBMS নিজে চালায়।

বিশ্ববিদ্যালয়ের রেজিস্টার অফিস ভাবুন। নিয়ম: কোনো শিক্ষার্থীর গ্রেড বদলালে পুরোনো আর নতুন গ্রেড, তারিখসহ, আলাদা একটি খাতায় লিখতে হবে। নিয়মটি যদি শুধু কর্মীদের মনে থাকার ওপর নির্ভর করে, ব্যস্ত দিনে কেউ না কেউ ভুলবেন। এবার ভাবুন, গ্রেডের খাতাটিই এমনভাবে বানানো যে কলম দিয়ে কোনো গ্রেড কাটলেই পাশের খাতায় নিজে থেকে একটি লাইন লেখা হয়ে যায়। কর্মী জানুন বা না জানুন, ভুলুন বা না ভুলুন — লাইনটি লেখা হবেই। Trigger ঠিক এই “খাতার ভেতরে বসানো নিয়ম”।

গবেষকেরা এই ধারণাকে বলেন event-condition-action (ECA) নিয়ম। তিনটি অংশ: ঘটনা (কোন টেবিলে কোন পরিবর্তন — INSERT, UPDATE, নির্দিষ্ট কলামের UPDATE, বা DELETE), শর্ত (ঘটনা ঘটলেও কাজটি কি করতে হবে? — WHEN), আর কাজ (এক বা একাধিক SQL স্টেটমেন্ট, BEGIN … END-এর ভেতরে)। নিচের ডায়াগ্রামে একটি দাম বদলের UPDATE-এ তিনটি অংশ একে একে দেখুন:

  1. ঘটনাUPDATE OF priceকী ঘটলে জাগবে
  2. OLD আর NEW650 → 585সারির আগে-পরে
  3. শর্তWHENকাজটি দরকার?
  4. কাজINSERT …ইতিহাসে লেখা
অ্যাপ যা পাঠালtrigger-এর কথা অ্যাপ জানে না
UPDATE products SET price = 585 WHERE product_id = 104;

-- DBMS খোঁজে: products-এ UPDATE-এ কে কে কান পেতে আছে?
trg_price_audit   AFTER UPDATE OF price ON products
সারি 104 (DSA Book)দুটি ছবি
OLD = (104, 'DSA Book', 2, 650, 25)   -- বদলের আগে
NEW = (104, 'DSA Book', 2, 585, 25)   -- বদলের পরে
শর্ত যাচাইসারি ধরে, প্রতিটি সারিতে আলাদা
WHEN NEW.price <> OLD.price
     585       <> 650        →  TRUE
কাজ চললএকই transaction-এর ভেতরে
INSERT INTO price_history (product_id, old_price, new_price)
VALUES (OLD.product_id, OLD.price, NEW.price);

price_history:  (104, 650, 585, '…সময়…')
অন্য একটি UPDATEঘটনা ঘটল, কাজ হলো না
UPDATE products SET price = 15000 WHERE product_id = 101;

OLD.price = 15000, NEW.price = 15000
WHEN 15000 <> 15000  →  FALSE  → কাজ বাদ, ইতিহাসে কিছু লেখা হয় না
  1. ঘটনা। অ্যাপ একটি সাধারণ UPDATE পাঠাল — সে জানেই না যে কোনো trigger আছে। DBMS দেখল, products টেবিলের price কলাম বদলের জন্য trg_price_audit নামে একটি trigger অপেক্ষা করছে।
  2. OLD আর NEW. DBMS বদলাতে যাওয়া সারিটির দুটি ছবি তৈরি করে: বদলের আগের OLD (দাম 650) আর পরের NEW (দাম 585). trigger-এর কোড এই দুটি নাম দিয়েই সারিটির কলামগুলো পড়ে।
  3. শর্ত। WHEN NEW.price <> OLD.price — 585 আর 650 আলাদা, তাই শর্ত TRUE. শর্তটি প্রতিটি সারির জন্য আলাদা যাচাই হয়; দশটি সারি বদলালে দশবার।
  4. কাজ। BEGIN … END-এর ভেতরের INSERT চলল; price_history-তে একটি লাইন যোগ হলো। কাজটি মূল UPDATE-এর একই transaction-এ — UPDATE বাতিল হলে এই লাইনও থাকে না।
  5. শর্ত মিথ্যা হলে। কেউ Mobile Phone-এর দাম 15000-কে আবার 15000 লিখলেন। UPDATE হয়েছে, trigger জেগেছে, কিন্তু শর্ত FALSE — তাই কোনো কাজ নেই। শর্ত না থাকলে ইতিহাস ভরে যেত অর্থহীন “15000 → 15000” লাইনে।

দুটি জিনিস মনে রাখুন। প্রথমত, trigger-কে কেউ সরাসরি ডাকে না, তার কোনো parameter নেই — এখানেই পর্ব ২৮-এর stored procedure-এর সাথে পার্থক্য, যাকে নাম ধরে ডাকতে হয়। দ্বিতীয়ত, trigger যে পথেই ডেটা বদলাক — ওয়েবসাইট, মোবাইল অ্যাপ, অ্যাডমিনের হাতে লেখা SQL, রাতের স্ক্রিপ্ট — সবার জন্য চলে। নিয়মটি অ্যাপে নয়, ডেটার পাশে বসে আছে।

ইতিহাস এক নজরে। “ডেটা বদলালে নিজে থেকে কিছু ঘটুক” — এই ধারণা নিয়ে ১৯৮০-এর দশকে active database নামে গবেষণা চলে; HiPAC-এর মতো প্রকল্প event-condition-action কাঠামোটি পরিচিত করে। Sybase-এর মতো বাণিজ্যিক DBMS সেই দশকের শেষেই trigger দিয়েছিল, Oracle দেয় Oracle 7 (১৯৯২)-এ। কিন্তু SQL-92 standard-এ trigger ছিল না, তাই প্রতিটি DBMS নিজের মতো syntax বানায় — যে বৈচিত্র্য আজও আছে। Standard-এ CREATE TRIGGER আসে SQL:1999-এ (BEFORE/AFTER, FOR EACH ROW/STATEMENT, OLD/NEW); INSTEAD OF পরে, SQL:2008-এ। SQLite-এ trigger বহু আগে থেকে আছে, আর recursive trigger যোগ হয় ৩.৬.১৮ সংস্করণে (২০০৯)।

২ · প্রথম trigger — দামের প্রতিটি বদল খাতায় লেখা

লাইভ SQL চলে দোকানের ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন), categories (category_id, name — ৪টি শ্রেণি), products (product_id, name, category_id, price, stock — ১০টি পণ্য; price-এ CHECK (price > 0), stock-এ CHECK (stock >= 0)), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার; status হলো pending, shipped, delivered বা cancelled) আর order_items (order_id, product_id, quantity, unit_price — ১৬টি লাইন)। প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়, foreign key চালু থাকে। নমুনা ডেটা দেখতে এডিটরের নিচের বাক্সটি খুলুন।

প্রথম কাজ audit log: দাম বদলের পূর্ণ ইতিহাস। ভ্যাট বা ভোক্তা-অধিকারের কোনো প্রশ্নে দোকানকে দেখাতে হতে পারে, কোন দিন কোন পণ্যের দাম কত ছিল। এই এডিটরে আগে থেকে একটি খালি টেবিল বানানো আছে — price_history (product_id, old_price, new_price, changed_at; শেষটির DEFAULT CURRENT_TIMESTAMP, অর্থাৎ লেখার মুহূর্তের UTC সময়)। trigger-এর গঠন লাইন ধরে পড়ুন:

CREATE TRIGGER trg_price_audit
AFTER UPDATE OF price ON products        -- ঘটনা: price কলামের UPDATE
FOR EACH ROW                             -- প্রতিটি বদলানো সারির জন্য একবার
WHEN NEW.price <> OLD.price               -- শর্ত: দাম সত্যিই বদলেছে
BEGIN                                    -- কাজ
  INSERT INTO price_history (product_id, old_price, new_price)
  VALUES (OLD.product_id, OLD.price, NEW.price);
END;

UPDATE products SET price = price * 0.9 WHERE category_id = 2;  -- বইয়ে ১০% ছাড়
UPDATE products SET stock = stock - 1 WHERE product_id = 101;   -- দাম নয়, স্টক
UPDATE products SET price = 15000 WHERE product_id = 101;       -- একই দাম

SELECT product_id, old_price, new_price, changed_at IS NOT NULL AS has_time
FROM price_history;

“✓ CREATE TRIGGER”, তারপর তিনটি UPDATE-এ যথাক্রমে ২, ১ আর ১টি সারি বদলেছে। কিন্তু price_history-তে মাত্র দুটি লাইন: 104 | 650 | 585 | 1 আর 105 | 550 | 495 | 1 — দুটি বইয়ের ছাড়। দ্বিতীয় UPDATE stock বদলেছে, price নয়; UPDATE OF price লেখায় trigger জাগেইনি। তৃতীয়টিতে trigger জেগেছিল, কিন্তু WHEN FALSE (15000 = 15000). changed_at-এর আসল মান চালানোর সময়ের ওপর নির্ভর করে, তাই এখানে শুধু “আছে কি না” (1) দেখানো হলো।

গঠনটি সাধারণ রূপে: CREATE TRIGGER নাম {BEFORE | AFTER | INSTEAD OF} {INSERT | UPDATE [OF কলাম, …] | DELETE} ON টেবিল [FOR EACH ROW] [WHEN শর্ত] BEGIN স্টেটমেন্ট; … END;. মুছতে DROP TRIGGER নাম. টেবিল মুছলে তার trigger-ও মুছে যায়। আর NEW ও OLD সব ঘটনায় থাকে না — যুক্তিটি সহজ:

ঘটনাOLDNEWকেন
INSERTনেইনতুন সারিআগে কোনো সারি ছিল না
UPDATEবদলের আগের সারিবদলের পরের সারিএকই সারির দুটি অবস্থা
DELETEমুছে যাওয়া সারিনেইপরে কোনো সারি থাকবে না

NULL-এর ফাঁদ। WHEN NEW.price <> OLD.price এখানে নিরাপদ, কারণ price-এ NOT NULL আছে। কিন্তু city-র মতো NULL-যোগ্য কলামে NULL থেকে 'Dhaka' হলে NULL <> 'Dhaka'-এর ফল UNKNOWN (পর্ব ১৬), আর WHEN শুধু TRUE-তেই কাজ করে — বদলটি নীরবে ইতিহাস থেকে বাদ পড়বে। NULL-যোগ্য কলামে লিখুন WHEN NEW.city IS NOT OLD.city (SQLite), বা standard-এর IS DISTINCT FROM.

৩ · BEFORE, AFTER আর INSTEAD OF — কোন মুহূর্তে চলে

একটি INSERT বাইরে থেকে এক মুহূর্তের কাজ মনে হয়, কিন্তু ভেতরে কয়েকটি ধাপ আছে। BEFORE trigger চলে সারি টেবিলে লেখার আগে, এমনকি NOT NULL, CHECK আর PRIMARY KEY যাচাইয়েরও আগে। AFTER trigger চলে সারি লেখা আর যাচাই শেষ হওয়ার পরে। নিচের ডায়াগ্রামে একটি অর্ডার-লাইন ঢোকানোর পুরো সময়রেখা দেখুন — অর্ডার 1006-এ আগে থেকে একটি লাইন আছে (Smart Watch), এখন দুটি DSA Book যোগ হচ্ছে:

INSERT INTO order_items VALUES (1006, 104, 2, 650)লাল = এই ধাপের কাজ

            
অবস্থা
নতুন সারি টেবিলে?
1006-এর লাইন
NEW বদলানো যায়?
এখন থামালে
  1. স্টেটমেন্ট এল। DBMS নতুন সারির একটি ছবি বানাল — NEW. অর্ডার 1006-এ এখন একটি লাইন।
  2. BEFORE trigger. সারি এখনো টেবিলে নেই, তাই টেবিল গুনলে 1. যাচাইয়ের সেরা জায়গা: “স্টক আছে তো?” না থাকলে RAISE, আর সারিটি ঢোকেই না। PostgreSQL আর MySQL-এ এখানে NEW-এর মানও বদলানো যায় (যেমন দাম পণ্যের তালিকা থেকে বসানো); SQLite-এ NEW শুধু পড়া যায়।
  3. Constraint যাচাই। BEFORE trigger পার হওয়ার পর NOT NULL, CHECK, PRIMARY KEY আর UNIQUE দেখা হয়। তাই BEFORE trigger এমন সারিও দেখতে পারে, যা পরে CHECK-এ বাতিল হবে।
  4. সারি লেখা হলো। টেবিলে এখন 1006-এর দুটি লাইন।
  5. AFTER trigger. সারি টেবিলে আছে, যাচাইও পার — তাই “ঘটনা সত্যিই ঘটেছে” ধরে নিয়ে বাকি টেবিল হালনাগাদের জায়গা: স্টক কমানো, total বাড়ানো, audit log. গুনলে এখন 2. NEW এখানে শুধু পড়ার জন্য।
  6. স্টেটমেন্টের শেষ। SQLite সাধারণ foreign key যাচাই করে এখানে, AFTER trigger-এরও পরে। যেকোনো ধাপে ত্রুটি হলে মূল সারি আর সব trigger-এর কাজ একসাথে ফিরে যায় — পর্ব ১৪-এর statement atomicity. বার্তা আসে শুধু “১টি সারি বদলেছে”, trigger যত সারিই বদলাক।

এবার আসল SQLite-এ একই পরীক্ষা। দুটি trigger একটি ছোট timing_log টেবিলে লিখবে — কোন মুহূর্ত, NEW.quantity কত, আর তখন অর্ডার 1006-এর কয়টি লাইন টেবিলে:

CREATE TABLE timing_log (step TEXT, new_qty INTEGER, lines_now INTEGER);
CREATE TRIGGER trg_before BEFORE INSERT ON order_items
BEGIN
  INSERT INTO timing_log SELECT 'BEFORE', NEW.quantity,
    (SELECT COUNT(*) FROM order_items WHERE order_id = NEW.order_id);
END;
CREATE TRIGGER trg_after AFTER INSERT ON order_items
BEGIN
  INSERT INTO timing_log SELECT 'AFTER', NEW.quantity,
    (SELECT COUNT(*) FROM order_items WHERE order_id = NEW.order_id);
END;
INSERT INTO order_items VALUES (1006, 104, 2, 650);
SELECT * FROM timing_log;

শেষ SELECT দুটি সারি দেয়: BEFORE | 2 | 1 আর AFTER | 2 | 2. দুই trigger-ই NEW.quantity = 2 দেখেছে, কিন্তু টেবিলের অবস্থা আলাদা — BEFORE-এর সময় নতুন সারি ছিল না, AFTER-এর সময় ছিল। INSERT-এর বার্তা “১টি সারি বদলেছে”, যদিও timing_log-এ আরও দুটি সারি গেছে: SQLite-এর changes() trigger-এর কাজ গোনে না। নিয়মটি সহজ: যাচাই আর বাতিল BEFORE-এ, অন্য টেবিলের হালনাগাদ AFTER-এ। SQLite-এর ডকুমেন্টেশন সতর্ক করে, BEFORE UPDATE বা BEFORE DELETE trigger-এর ভেতরে যে সারি বদলাতে যাচ্ছে সেটিকেই বদলালে বা মুছলে ফল অনির্ধারিত।

INSTEAD OF — view-এর জন্য বিকল্প কাজ

তৃতীয় সময় INSTEAD OF: মূল কাজটি হয়ই না, তার বদলে trigger-এর কাজ হয়। SQLite-এ এটি শুধু view-এর ওপর বানানো যায়, আর BEFORE/AFTER শুধু টেবিলের ওপর। পর্ব ২৪-এ দেখেছেন, JOIN বা GROUP BY-ওয়ালা view-এ সরাসরি INSERT/UPDATE/DELETE চলে না — SQLite-এ কোনো view-ই সরাসরি লেখা যায় না। INSTEAD OF trigger বলে দেয়, “এই view-এ কেউ DELETE চালালে আসলে কোন টেবিলে কী করতে হবে।” অংশ ৭-এ এ দিয়ে soft delete বানাবেন।

Row-level বনাম statement-level

UPDATE products SET price = price * 0.9 WHERE category_id = 2 দুটি সারি বদলেছে, আর trg_price_audit দুবার চলেছে — প্রতি সারিতে একবার। একে বলে row-level trigger (FOR EACH ROW). SQLite-এ শুধু এটিই আছে; FOR EACH ROW না লিখলেও অর্থ একই। SQL standard, PostgreSQL, Oracle আর DB2-তে আরেক রকম আছে: statement-level trigger (FOR EACH STATEMENT), যা একটি স্টেটমেন্টে একবারই চলে — শূন্য সারি বদলালেও। বড় UPDATE-এ এটি অনেক সস্তা, কারণ এক লাখ সারির জন্য trigger এক লাখবার না চলে একবার চলে। Standard, PostgreSQL (১০ থেকে) আর DB2-তে এটি বদলানো সব সারি একসাথে একটি টেবিল হিসেবেও দেখতে পারে (Oracle-এর সাধারণ statement-level trigger পারে না) — একে বলে transition table (REFERENCING OLD TABLE AS … NEW TABLE AS …)। SQL Server-এ trigger শুধুই statement-level, আর বদলানো সারিগুলো আসে inserted ও deleted নামের দুটি ছদ্ম-টেবিলে। MySQL উল্টো: শুধু row-level.

অন্য DBMS-এ একই কাজ কেমন দেখায়, নিচের ট্যাবে দেখুন — এগুলো তুলনার জন্য, এই পাতায় চালানো যায় না। PostgreSQL-এ trigger দুই ভাগে: আগে একটি trigger function (RETURNS trigger, সাধারণত PL/pgSQL-এ), তারপর CREATE TRIGGER … EXECUTE FUNCTION দিয়ে সেটিকে টেবিলের সাথে জোড়া। একই function একাধিক টেবিলে কাজে লাগানো যায়।

CREATE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at := now();   -- BEFORE trigger NEW বদলাতে পারে
  RETURN NEW;                -- NULL ফেরালে সারিটি বাদ পড়ত
END;
$$;

CREATE TRIGGER trg_products_touch
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();   -- PostgreSQL 11-এর আগে: EXECUTE PROCEDURE
CREATE FUNCTION log_price_batch() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO price_history (product_id, old_price, new_price)
  SELECT o.product_id, o.price, n.price
  FROM old_rows o JOIN new_rows n USING (product_id)
  WHERE o.price <> n.price;
  RETURN NULL;               -- AFTER trigger-এর ফেরত মান উপেক্ষিত
END;
$$;

CREATE TRIGGER trg_price_audit_stmt
AFTER UPDATE ON products
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION log_price_batch();
DELIMITER //
CREATE TRIGGER trg_stock_check
BEFORE INSERT ON order_items
FOR EACH ROW
BEGIN
  -- MySQL-এ WHEN নেই; শর্ত লিখতে হয় IF দিয়ে
  IF NEW.quantity > (SELECT stock FROM products
                     WHERE product_id = NEW.product_id) THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'not enough stock';
  END IF;
END//
DELIMITER ;
CREATE TRIGGER trg_price_audit ON products
AFTER UPDATE
AS
BEGIN
  SET NOCOUNT ON;
  -- একবারই চলে; বদলানো সব সারি inserted/deleted-এ
  INSERT INTO price_history (product_id, old_price, new_price)
  SELECT d.product_id, d.price, i.price
  FROM deleted d JOIN inserted i ON i.product_id = d.product_id
  WHERE i.price <> d.price;
END;

কয়েকটি পার্থক্য খেয়াল করুন। PostgreSQL-এর row-level BEFORE function যা ফেরত দেয়, সেটিই লেখা হয় — NEW বদলে ফেরত দিলে বদলানো সারি, NULL দিলে সারিটি নীরবে বাদ। transition table-এর সাথে UPDATE OF কলাম লেখা যায় না, তাই সেখানে শর্ত WHERE-এ। MySQL-এর DELIMITER // কোনো SQL নয় — mysql client-কে বলা, “trigger-এর ভেতরের ; দেখে স্টেটমেন্ট শেষ ভেবো না।” MySQL-এ ত্রুটি তোলা হয় SIGNAL SQLSTATE '45000' দিয়ে, যা SQLite-এর RAISE-এর সমতুল্য। Oracle-এ NEW/OLD লেখা হয় :NEW.price, :OLD.price.

৪ · RAISE — নিয়ম ভাঙলে পরিবর্তন বাতিল, আর স্টকের হিসাব

দোকানের সবচেয়ে পরিচিত নিয়ম: যত পিস স্টকে আছে, তার বেশি বিক্রি নয়; আর বিক্রি হলে স্টক কমবে। প্রথমটি যাচাই — BEFORE INSERT-এর কাজ। দ্বিতীয়টি হালনাগাদ — AFTER INSERT-এর কাজ। যাচাই ব্যর্থ হলে SQLite-এ trigger-এর ভেতর থেকে ত্রুটি তোলা হয় RAISE() দিয়ে, যা শুধু trigger-এর ভেতরেই চলে। এই এডিটরে অর্ডার 1009 (Rahim-এর pending অর্ডার)-এ দুটি নতুন লাইন যোগের চেষ্টা:

CREATE TRIGGER trg_stock_check BEFORE INSERT ON order_items
WHEN NEW.quantity > (SELECT stock FROM products WHERE product_id = NEW.product_id)
BEGIN
  SELECT RAISE(ABORT, 'not enough stock');
END;
CREATE TRIGGER trg_stock_take AFTER INSERT ON order_items
BEGIN
  UPDATE products SET stock = stock - NEW.quantity
  WHERE product_id = NEW.product_id;
END;
INSERT INTO order_items VALUES (1009, 107, 3, 4500);   -- Saree, স্টক 8
SELECT product_id, name, stock FROM products WHERE product_id IN (103, 107);
INSERT INTO order_items VALUES (1009, 103, 1, 1800);   -- Power Bank, স্টক 0

দুটি “✓ CREATE TRIGGER”, তারপর প্রথম INSERT-এ “১টি সারি বদলেছে”। SELECT দেখায় 103 | Power Bank | 0 আর 107 | Saree | 5 — Saree-র স্টক 8 থেকে 5, কেউ হাতে UPDATE লেখেননি। শেষ INSERT-এ ত্রুটি: not enough stock. Power Bank-এর স্টক 0, চাওয়া 1 — BEFORE trigger সারিটি ঢুকতেই দেয়নি। trg_stock_check না থাকলেও এই INSERT আটকাত: AFTER trigger স্টক −1 করতে গেলে products-এর CHECK (stock >= 0) ভাঙত, ত্রুটি CHECK constraint failed: stock >= 0. তাহলে BEFORE trigger কেন? বার্তাটি ব্যবসার ভাষায় আসে, আর কাজ শুরুর আগেই থামে। কিন্তু CHECK-টি রেখে দিন — সেটি শেষ পাহারাদার, trigger মুছে গেলে বা ভুল লেখা হলেও।

একসাথে দুজন কিনলে? ধরুন স্টকে শেষ একটি Saree, আর দুজন গ্রাহক একই মুহূর্তে কিনছেন। PostgreSQL-এর ডিফল্ট isolation-এ দুটি transaction-এর BEFORE trigger-ই হয়তো স্টক 1 দেখবে, দুজনই পার পাবে। তখন দ্বিতীয় UPDATE-এ CHECK (stock >= 0)-ই আসল রক্ষাকবচ। নিরাপদ নকশা: যাচাই আর কমানো একটি স্টেটমেন্টে — UPDATE products SET stock = stock - 1 WHERE product_id = 107 AND stock >= 1, তারপর বদলানো সারির সংখ্যা দেখা। SQLite-এ এই সমস্যা হয় না, কারণ একসাথে একটি সংযোগই লিখতে পারে। কারণসহ বিস্তারিত পর্ব ৪৭-এ।

RAISE-এর চার রূপ

RAISE(ABORT, 'বার্তা') ছাড়াও আরও তিনটি রূপ আছে; পার্থক্য হলো “কতখানি ফেরানো হবে”। এক স্টেটমেন্টে অনেক সারি বদলালে পার্থক্যটি চোখে পড়ে। নিচের ডায়াগ্রামে একটি transaction: আগে একটি স্টেটমেন্ট সফল হয়েছে (Rice-এর স্টক 100 → 90), তারপর Electronics-এর চারটি পণ্যের দাম ১০% বাড়ানোর UPDATE, আর একটি trigger তৃতীয় সারিতে (Power Bank, 103) RAISE করে:

BEGIN; … UPDATE products SET price = price * 1.1 WHERE category_id = 1;লাল = এই রূপের ফল

            
ফল
রূপ
② থেকে টিকল
① টিকল?
ত্রুটি-বার্তা
  1. পরিস্থিতি। একটি transaction-এ ① আগেই সফল। ② চারটি সারি ক্রমে বদলাচ্ছে; 101 আর 102 পার, তৃতীয় সারি 103-এ BEFORE UPDATE trigger RAISE করল। কোন রূপ, তার ওপর ফল নির্ভর করে।
  2. ABORT (সবচেয়ে প্রচলিত). শুধু এই স্টেটমেন্টটি পুরো ফিরে যায় — 101, 102-ও আগের দামে। আগের স্টেটমেন্ট ① অক্ষত, transaction খোলা। এটিই SQLite-এর সাধারণ constraint ত্রুটির আচরণ।
  3. FAIL. থামে, কিন্তু ফেরায় না: যে দুটি সারি বদলে গেছে, সেগুলো থাকে। একটি স্টেটমেন্টের অর্ধেক কাজ টিকে থাকা প্রায় কখনো কাম্য নয়; নিশ্চিত না হলে ABORT ব্যবহার করুন।
  4. ROLLBACK. সবচেয়ে কড়া: পুরো transaction বাতিল, আগের ① সহ। দাম বাড়ানো আর স্টক বদল যদি একসাথে হওয়া বা একসাথে না-হওয়া উচিত, এটি কাজের।
  5. IGNORE. ত্রুটি নয়: trigger যে সারির জন্য চলছিল, শুধু সেটি বাদ দিয়ে স্টেটমেন্ট চলতে থাকে। বার্তা আসে “৩টি সারি বদলেছে”। শক্তিশালী, কিন্তু বিপজ্জনক: অ্যাপ জানতেই পারে না যে একটি সারি নীরবে বাদ গেছে।

IGNORE-এর একটি বৈধ ব্যবহার: পরীক্ষার ডেটা যেন production-এ না ঢোকে। এখানে নামের শুরুতে TEST থাকলে গ্রাহক নীরবে বাদ, বাকিরা ঢোকেন:

CREATE TRIGGER trg_skip_test BEFORE INSERT ON customers
WHEN NEW.name LIKE 'TEST%'
BEGIN
  SELECT RAISE(IGNORE);
END;
INSERT INTO customers (customer_id, name, city, joined_on) VALUES
  (9,  'Sadia Islam', 'Dhaka',  '2025-10-20'),
  (10, 'TEST user',   NULL,     '2025-10-20'),
  (11, 'Rakib Hasan', 'Khulna', '2025-10-21');
SELECT customer_id, name FROM customers WHERE customer_id >= 9;

INSERT-এ “২টি সারি বদলেছে” — তিনটি পাঠানো হয়েছিল। SELECT-এ 9 | Sadia Islam আর 11 | Rakib Hasan; TEST user কোথাও নেই, কোনো ত্রুটিও নেই। ফলটি সুবিধার, আবার ভয়েরও: কেউ এই trigger-এর কথা না জানলে “আমার INSERT গেল কোথায়?” খুঁজতে ঘণ্টা কাটাবেন। IGNORE ব্যবহার করলে trigger-এর নাম আর উদ্দেশ্য দলের ডকুমেন্টেশনে লিখে রাখুন। (PostgreSQL-এ একই কাজ: BEFORE row trigger থেকে RETURN NULL.)

৫ · CHECK যা পারে না — অর্ডারের status শুধু সামনে চলবে

orders.status-এ CHECK আছে: মান হবে pending, shipped, delivered বা cancelled. কিন্তু ব্যবসার আসল নিয়ম আরও সূক্ষ্ম: অর্ডার pending → shipped → delivered পথে শুধু সামনে চলে; cancelled হতে পারে শুধু pending থেকে; আর delivered বা cancelled-এর পরে আর কোথাও যাওয়া নেই। কুরিয়ারে পাঠানো পার্সেল হঠাৎ “pending” হয়ে গেলে গুদামে আবার প্যাক হবে — দুবার পাঠানো, দুবার খরচ।

CHECK এটি কেন পারে না? CHECK শুধু সারির বর্তমান মান দেখে — status IN (…). “আগে কী ছিল” তার কাছে নেই। নিয়মটি আসলে একটি state machine: অবস্থাগুলোর মধ্যে কোন কোন পথ বৈধ। বৈধ পথের তালিকা লিখতে OLD আর NEW দুটিই লাগে — ঠিক যা UPDATE trigger-এর কাছে আছে। এটি একটি business rule, যা declarative constraint দিয়ে লেখা যায় না:

CREATE TRIGGER trg_status_forward
BEFORE UPDATE OF status ON orders
WHEN NOT (NEW.status = OLD.status
       OR (OLD.status = 'pending' AND NEW.status IN ('shipped', 'cancelled'))
       OR (OLD.status = 'shipped' AND NEW.status = 'delivered'))
BEGIN
  SELECT RAISE(ABORT, 'status can only move forward');
END;
UPDATE orders SET status = 'shipped'   WHERE order_id = 1006;
UPDATE orders SET status = 'delivered' WHERE order_id = 1006;
SELECT order_id, status FROM orders WHERE order_id IN (1001, 1006);
UPDATE orders SET status = 'pending'   WHERE order_id = 1001;

অর্ডার 1006 pending ছিল; দুটি UPDATE-এ (প্রতিটিতে “১টি সারি বদলেছে”) এটি shipped, তারপর delivered — দুটিই বৈধ পথ। SELECT: 1001 | delivered, 1006 | delivered. শেষ UPDATE-এ ত্রুটি status can only move forward: 1001 delivered থেকে pending-এ ফিরতে চেয়েছিল। খেয়াল করুন, শর্তটি লেখা হয়েছে বৈধ পথের তালিকা দিয়ে, আর তার NOT. “নিষিদ্ধ পথ” গোনার চেয়ে এটি নিরাপদ: কাল কেউ নতুন status 'returned' যোগ করলে, তালিকায় না তোলা পর্যন্ত সেখানে যাওয়ার সব পথ আপনা থেকেই বন্ধ থাকবে।

বাস্তবে এমন নিয়ম কোথায় লাগে: bKash বা Nagad-এর লেনদেনের অবস্থা (initiated → completed বা failed, completed থেকে আর ফেরা নেই), পরীক্ষার ফল (draft → published), ট্রেনের টিকিট (reserved → paid → cancelled/used). আরেক ধরনের নিয়ম যা CHECK পারে না: অন্য টেবিলের দিকে তাকানো — “গ্রাহকের বকেয়া ৫০,০০০ টাকার বেশি হলে নতুন অর্ডার নয়” — এখানেও trigger-এর ভেতরের subquery কাজে লাগে। (SQL standard-এ এমন নিয়মের জন্য CREATE ASSERTION আছে, কিন্তু প্রায় কোনো প্রধান DBMS এটি বাস্তবায়ন করেনি।)

৬ · Derived কলাম হালনাগাদ রাখা — আর drift-এর ঝুঁকি

অর্ডারের মোট টাকা order_items থেকে প্রতিবার হিসাব করা যায় — SUM(quantity * unit_price). কিন্তু অর্ডারের তালিকার পাতায় প্রতিটি অর্ডারের জন্য প্রতিবার যোগ করা ব্যস্ত সাইটে ভারী হতে পারে। তাই অনেকে orders-এ একটি total কলাম রাখেন — একটি সংরক্ষিত derived মান, অর্থাৎ পর্ব ৩৮-এর denormalization. প্রশ্ন: কলামটি সবসময় সঠিক থাকবে কীভাবে? Trigger দিয়ে — প্রতিটি লাইন যোগ বা মোছার সাথে সাথে:

ALTER TABLE orders ADD COLUMN total REAL NOT NULL DEFAULT 0;
UPDATE orders SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
                           FROM order_items i WHERE i.order_id = orders.order_id);
CREATE TRIGGER trg_total_add AFTER INSERT ON order_items
BEGIN
  UPDATE orders SET total = total + NEW.quantity * NEW.unit_price
  WHERE order_id = NEW.order_id;
END;
CREATE TRIGGER trg_total_sub AFTER DELETE ON order_items
BEGIN
  UPDATE orders SET total = total - OLD.quantity * OLD.unit_price
  WHERE order_id = OLD.order_id;
END;
INSERT INTO order_items VALUES (1006, 104, 2, 650);
DELETE FROM order_items WHERE order_id = 1006 AND product_id = 110;
SELECT order_id, total FROM orders WHERE order_id IN (1001, 1006);

প্রথম UPDATE (backfill, পর্ব ২৩)-এ “১০টি সারি বদলেছে”। তারপর অর্ডার 1006-এ দুটি DSA Book যোগ (+1300) আর Smart Watch মোছা (−3500). ফল: 1001 | 17400 (15000 + 2 × 1200) আর 1006 | 1300 (3500 + 1300 − 3500). দুটি trigger-ই বাড়ানো-কমানো (increment) পদ্ধতিতে কাজ করে — পুরো যোগফল আবার না গুনে শুধু পার্থক্যটি যোগ করে। দ্রুত, কিন্তু একটি দুর্বলতা আছে, যা পরের এডিটরে দেখবেন।

লক্ষ্য করুন, এখানে UPDATE-এর trigger নেই। কেউ একটি লাইনের পরিমাণ বদলালে কী হবে? এই এডিটরে উপরের কলাম, backfill আর দুটি trigger আগে থেকেই বসানো:

-- কেউ একটি লাইনের পরিমাণ বদলালেন — UPDATE-এর trigger নেই!
UPDATE order_items SET quantity = 3 WHERE order_id = 1001 AND product_id = 102;

-- মিলিয়ে দেখা: সংরক্ষিত total বনাম লাইন থেকে হিসাব
SELECT o.order_id, o.total AS stored,
       SUM(i.quantity * i.unit_price) AS actual
FROM orders o JOIN order_items i ON i.order_id = o.order_id
GROUP BY o.order_id
HAVING o.total <> SUM(i.quantity * i.unit_price);

UPDATE-এ “১টি সারি বদলেছে”, কোনো ত্রুটি নেই। কিন্তু মিলিয়ে দেখার কোয়েরি একটি অমিল দেখায়: 1001 | 17400 | 18600 — একটি বাড়তি Headphone (1200 টাকা) total-এ ওঠেনি। একে বলে drift: সংরক্ষিত মান আর আসল মান ধীরে ধীরে আলাদা হয়ে যাওয়া। কারণ শুধু “trigger ভুলে যাওয়া” নয়; trigger বন্ধ রেখে ডেটা আমদানি, অন্য পথে লেখা (যেমন MySQL-এ foreign key cascade, যা trigger চালায় না), বা trigger-এর কোডে ছোট ভুল — সবই drift ঘটায়। তাই derived কলাম রাখলে এমন একটি reconciliation কোয়েরি নিয়মিত (ধরুন প্রতি রাতে) চালান, আর অমিল পেলে সতর্কবার্তা দিন। অনুশীলন ৪-এ অনুপস্থিত UPDATE trigger-টি লিখবেন।

নিরাপদ বিকল্প তিনটি, ঝুঁকি কম থেকে বেশি: (১) সংরক্ষণই না করা — একটি view-তে প্রতিবার হিসাব (পর্ব ২৪), সঠিক index থাকলে প্রায়ই যথেষ্ট দ্রুত; (২) trigger-এ বাড়ানো-কমানোর বদলে প্রতিবার পুরো যোগফল আবার গোনা — SET total = (SELECT SUM(…) …) — ধীর, কিন্তু আগের ভুল নিজে থেকে শুধরে যায়; (৩) increment trigger, তিনটি ঘটনাতেই (INSERT, UPDATE, DELETE), সাথে রাতের reconciliation.

৭ · Soft delete আর updated_at — INSTEAD OF আর AFTER UPDATE

পর্ব ১৪-এ soft delete দেখেছেন: সারি সত্যিই না মুছে একটি চিহ্ন দেওয়া। সমস্যা হলো, অ্যাপের প্রতিটি কোয়েরিতে WHERE deleted_at IS NULL মনে রাখা, আর প্রতিটি DELETE-কে UPDATE-এ বদলানো। একটি view আর একটি INSTEAD OF trigger মিলে কাজটি ডেটাবেসেই সেরে ফেলে: অ্যাপ শুধু view দেখে আর view থেকেই “মোছে”। এই এডিটরে customers-এ আগে থেকে একটি deleted_at কলাম (TEXT, শুরুতে সব NULL) যোগ করা আছে:

CREATE VIEW active_customers AS
  SELECT customer_id, name, city FROM customers WHERE deleted_at IS NULL;

CREATE TRIGGER trg_soft_delete INSTEAD OF DELETE ON active_customers
BEGIN
  UPDATE customers SET deleted_at = '2025-10-20'
  WHERE customer_id = OLD.customer_id;
END;

DELETE FROM active_customers WHERE customer_id = 5;
SELECT COUNT(*) AS active FROM active_customers;
SELECT customer_id, name, deleted_at FROM customers WHERE deleted_at IS NOT NULL;

DELETE-এর বার্তা “০টি সারি বদলেছে” — অবাক হওয়ার কিছু নেই: view-এর কোনো সারি সরাসরি বদলায়নি, আর SQLite INSTEAD OF trigger-এর কাজ এই সংখ্যায় গোনে না। কিন্তু কাজ হয়েছে: সক্রিয় গ্রাহক এখন 7, আর customers-এ 5 | Tanvir Ahmed | 2025-10-20. Tanvir-এর সারি আছে, তাই তাঁর পুরোনো অর্ডার আর রিপোর্ট অক্ষত। বাস্তবে তারিখের জায়গায় datetime('now'); এখানে স্থির তারিখ, যাতে ফল সবসময় একই দেখায়। একটি সতর্কতা: যে অ্যাপ সরাসরি customers টেবিল থেকে DELETE করে, তার জন্য এই trigger চলবে না — নিয়মটি শুধু view-এর দরজায় বসানো। আর বার্তার “০টি সারি” দেখে অ্যাপ “গ্রাহক পাওয়া যায়নি” ভাবতে পারে — এটিও trigger-এর লুকানো আচরণের একটি উদাহরণ।

updated_at — শেষ কবে বদলেছে

প্রায় প্রতিটি পেশাদার টেবিলে একটি updated_at কলাম থাকে: সারিটি শেষ কবে বদলেছে। এটি দিয়ে মোবাইল অ্যাপ শুধু নতুন বদলগুলো আনে, cache বাতিল হয়, ডেটা warehouse রাতে শুধু বদলানো সারি তোলে (পর্ব ৫৫)। PostgreSQL-এ BEFORE trigger NEW.updated_at বদলে দেয় (উপরের ট্যাবে দেখেছেন)। SQLite-এ NEW বদলানো যায় না, তাই প্রচলিত কৌশল AFTER UPDATE থেকে একই সারিতে আরেকটি UPDATE. এই এডিটরে products-এ updated_at কলাম যোগ করা আছে, সব সারিতে শুরুর মান '2025-01-01 00:00:00':

CREATE TRIGGER trg_touch AFTER UPDATE ON products
WHEN NEW.updated_at = OLD.updated_at      -- কেউ নিজে সময় না দিলে
BEGIN
  UPDATE products SET updated_at = datetime('now')
  WHERE product_id = NEW.product_id;
END;
UPDATE products SET stock = stock + 10 WHERE product_id = 103;
SELECT product_id, stock, updated_at > '2025-01-01 00:00:00' AS touched
FROM products WHERE product_id IN (102, 103);

UPDATE-এ “১টি সারি বদলেছে”; ফল: 102 | 40 | 0 আর 103 | 10 | 1. Power Bank (103)-এর স্টক 0 থেকে 10, আর updated_at এখন চালানোর মুহূর্তের UTC সময় (তাই touched = 1); Headphone (102) ছোঁয়া হয়নি। trigger-এর ভেতরের UPDATE নিজেও একটি products-এর UPDATE — তাহলে trg_touch আবার জাগবে না কেন, অনন্তকাল ধরে? দুটি কারণ: SQLite-এ recursive_triggers ডিফল্টে বন্ধ, তাই trigger নিজের কাজে নিজে আবার জাগে না; আর জাগলেও দ্বিতীয়বার WHEN FALSE, কারণ এবার updated_at সত্যিই বদলেছে। এই “পাহারাদার শর্ত” অভ্যাস করুন — পরের অংশে দেখবেন কেন।

৮ · Trigger-এর শিকল — cascade, recursion আর চলার ক্রম

Trigger-এর কাজ নিজেই একটি INSERT, UPDATE বা DELETE — তাই সেটি আরেকটি টেবিলের trigger জাগাতে পারে, সেটি আবার আরেকটির। একে বলে cascading trigger. ধরুন অংশ ৪-এর স্টক কমানোর trigger-এর পাশে আরেকটি নিয়ম: “কোনো পণ্যের স্টক ৫-এর নিচে নামলে পুনরায় অর্ডারের তালিকায় (reorder_alerts) তোলো।” এখন একটি সাধারণ INSERT-এর ভেতরে কী কী ঘটে:

INSERT INTO order_items VALUES (1006, 107, 4, 4500)লাল = এই স্তরে যা ঘটল

            
হিসাব
trigger-এর গভীরতা
বদলানো সারি (মোট)
Saree-র stock
  1. একটি সাধারণ INSERT. অর্ডার 1006-এ চারটি Saree. অ্যাপের চোখে এটুকুই। Saree-র স্টক এখন 8.
  2. স্তর ১। সারি লেখা হলো, order_items-এর AFTER INSERT trigger জাগল আর products-এ UPDATE চালাল: স্টক 8 → 4. বদলানো সারি এখন দুটি, দুটি টেবিলে।
  3. স্তর ২। products-এর UPDATE নিজেই একটি ঘটনা। UPDATE OF stock-এর জন্য trg_reorder অপেক্ষা করছিল; শর্ত দুটি অংশে: নতুন স্টক ৫-এর নিচে, আর পুরোনোটি ৫ বা তার বেশি — অর্থাৎ “এইমাত্র সীমা পার হলো”। দ্বিতীয় অংশ না থাকলে স্টক 4 → 3 → 2-এর প্রতিটি বিক্রিতে নতুন সতর্কবার্তা জমত।
  4. শিকল থামল। reorder_alerts-এ একটি লাইন যোগ হলো; এই টেবিলে কোনো trigger নেই, তাই আর কিছু জাগল না। স্তরগুলো একে একে ফিরে এসে মূল INSERT শেষ করে।
  5. বাইরে থেকে যা দেখা যায়। তিনটি টেবিলে তিনটি সারি বদলেছে, অথচ বার্তা “১টি সারি”। পুরো শিকল একটি স্টেটমেন্টের অংশ — reorder_alerts-এর INSERT ব্যর্থ হলে Saree-র স্টক আর অর্ডার-লাইনও ফিরে যেত।
  6. নিজের শিকলে নিজে। যে trigger নিজের টেবিলই বদলায়, সে নিজেকে আবার জাগাতে পারে — recursive trigger. SQLite-এ PRAGMA recursive_triggers ডিফল্টে OFF, তখন একটি trigger নিজের (প্রত্যক্ষ বা পরোক্ষ) কাজে আবার জাগে না। ON করলে জাগে, আর পাহারাদার শর্ত না থাকলে গভীরতা ১০০০-এ (SQLITE_MAX_TRIGGER_DEPTH-এর ডিফল্ট) পৌঁছে ত্রুটি দেয়।

এবার শিকলটি আসল SQLite-এ। শেষ SELECT দেখাবে, ডেটাবেসে কোন কোন trigger আছে — system catalog sqlite_schema থেকে। “লুকানো logic” খোঁজার প্রথম জায়গা এটিই:

CREATE TABLE reorder_alerts (product_id INTEGER, stock_left INTEGER);
CREATE TRIGGER trg_stock_take AFTER INSERT ON order_items
BEGIN
  UPDATE products SET stock = stock - NEW.quantity WHERE product_id = NEW.product_id;
END;
CREATE TRIGGER trg_reorder AFTER UPDATE OF stock ON products
WHEN NEW.stock < 5 AND OLD.stock >= 5
BEGIN
  INSERT INTO reorder_alerts VALUES (NEW.product_id, NEW.stock);
END;
INSERT INTO order_items VALUES (1006, 107, 4, 4500);  -- Saree: 8 → 4
INSERT INTO order_items VALUES (1006, 106, 2, 2200);  -- Panjabi: 15 → 13
SELECT * FROM reorder_alerts;
SELECT name, tbl_name FROM sqlite_schema WHERE type = 'trigger';

দুটি INSERT-এর প্রতিটিতে “১টি সারি বদলেছে”। reorder_alerts-এ একটিই লাইন: 107 | 4 — Panjabi-র স্টক 13, সীমার ওপরে, তাই সেখানে শিকল স্তর ১-এ থেমেছে। শেষ SELECT দুটি সারি দেয়: trg_stock_take | order_items আর trg_reorder | products. কোনো টেবিলে অদ্ভুত কিছু ঘটলে আগে এখানে দেখুন, কে কে কান পেতে আছে। PostgreSQL-এ একই তথ্য information_schema.triggers বা psql-এর \d products-এ; MySQL-এ SHOW TRIGGERS.

একই ঘটনায় একাধিক trigger — কোনটি আগে?

একই টেবিল, একই সময় (ধরুন AFTER INSERT), একাধিক trigger — চলার ক্রম DBMS-ভেদে আলাদা, আর এটি পুরোনো বাগের একটি পরিচিত উৎস। PostgreSQL নাম ধরে বর্ণানুক্রমে চালায় (তাই অনেক দল নামের শুরুতে নম্বর দেয়: t10_…, t20_…)। MySQL 5.7.2-এর আগে একই ঘটনায় একটির বেশি trigger-ই বানানো যেত না; এখন FOLLOWS/PRECEDES দিয়ে ক্রম বলা যায়। SQL Server-এ শুধু প্রথম আর শেষটি ঠিক করা যায় (sp_settriggerorder). SQLite-এর ডকুমেন্টেশন কোনো ক্রমের প্রতিশ্রুতি দেয় না; বর্তমান সংস্করণে পরীক্ষা করলে শেষে বানানোটি আগে চলে — কিন্তু প্রতিশ্রুতি নেই এমন আচরণের ওপর নির্ভর করবেন না। সবচেয়ে নিরাপদ: যে কাজগুলোর ক্রম জরুরি, সেগুলো একটি trigger-এর ভেতরে পরপর লিখুন।

আরও গভীরে: Oracle-এ row-level trigger যে টেবিলের জন্য চলছে, সেই টেবিলই পড়তে বা বদলাতে গেলে আসে বিখ্যাত ORA-04091: table … is mutating ত্রুটি। কারণ, স্টেটমেন্ট অর্ধেক পথে থাকায় টেবিলটি একটি অসম্পূর্ণ অবস্থায়, আর সারির প্রক্রিয়াকরণের ক্রম অনুযায়ী ফল বদলে যেতে পারত; Oracle তাই নিষেধ করে, আর সমাধান হিসেবে দেয় compound trigger (row-এর কাজ জমিয়ে statement-এর শেষে একবারে করা)। SQLite আর PostgreSQL এমন পড়া নিষেধ করে না — অংশ ৩-এর এডিটরে BEFORE আর AFTER trigger নিজের টেবিলই গুনেছে — কিন্তু SQLite BEFORE trigger-এ একই সারি বদলানোকে অনির্ধারিত বলে। মূল শিক্ষা একই: row-level trigger নিজের টেবিলের ওপর যত কম নির্ভর করে, ফল তত নিশ্চিত।

৯ · Trigger, constraint না অ্যাপের কোড — বাছাই আর ঝুঁকি

Trigger শক্তিশালী বলেই অপব্যবহারও সহজ। অভিজ্ঞ দলগুলোর সাধারণ নীতি একটি সিঁড়ি: যা declarative constraint (NOT NULL, CHECK, UNIQUE, foreign key) দিয়ে লেখা যায়, সেখানেই লিখুন; যা পারে না কিন্তু ডেটার অখণ্ডতার জন্য জরুরি, তার জন্য trigger; আর যার কাজ ডেটাবেসের বাইরে, তা অ্যাপের কোডে। Constraint-কে optimizer বোঝে, ডকুমেন্টেশনে দেখা যায়, আর প্রায় খরচহীন; trigger একটি কালো বাক্স।

নিয়মসেরা জায়গাকেন
দাম শূন্যের বেশিCHECKএকটি সারির ভেতরের শর্ত
অর্ডারের গ্রাহক থাকতেই হবেForeign keyদুই টেবিলের সম্পর্ক; DBMS নিজে পাহারা দেয়
ফোন নম্বর পুনরাবৃত্ত নয়UNIQUEindex-সহ, দ্রুত আর নির্ভুল
status শুধু সামনে চলবেTriggerOLD আর NEW দুটিই লাগে
দামের প্রতিটি বদলের ইতিহাসTrigger (বা CDC)যে পথেই বদলাক, লেখা হবেই
স্টক ঋণাত্মক নয়CHECK + স্টেটমেন্টে শর্তtrigger বার্তা সুন্দর করে, CHECK শেষ পাহারা
অর্ডারের পর গ্রাহককে SMS/emailঅ্যাপ (বা outbox টেবিল)ROLLBACK হলে পাঠানো SMS ফেরানো যায় না
ছাড়ের জটিল হিসাব, কুপনঅ্যাপঘন ঘন বদলায়, test আর review সহজ

SMS-এর সারিটি বিশেষ করে খেয়াল করুন। trigger transaction-এর ভেতরে চলে; সেখান থেকে বাইরের কোনো সার্ভারে বার্তা পাঠালে, পরে transaction ROLLBACK হলেও বার্তা চলে গেছে — গ্রাহক এমন অর্ডারের SMS পাবেন, যা কখনো হয়নি। প্রচলিত সমাধান outbox: trigger বা অ্যাপ একই transaction-এ একটি outbox টেবিলে “পাঠাতে হবে” লেখে; COMMIT হলে তবেই একটি আলাদা প্রক্রিয়া সেটি পড়ে SMS পাঠায়।

Trigger-এর দাম — যা আগে থেকে জানা দরকার

  • লুকানো logic: কোয়েরি পড়ে বোঝা যায় না, পেছনে কী ঘটছে। নতুন ডেভেলপার একটি DELETE লিখে দেখেন “০টি সারি বদলেছে”, অথচ ডেটা বদলেছে — অংশ ৭।
  • Debugging কঠিন: ত্রুটি আসে স্তর ২-এর কোনো trigger থেকে, অথচ অ্যাপের log-এ শুধু মূল INSERT. বেশিরভাগ DBMS-এ trigger-এর ভেতরে breakpoint বসানোর সহজ উপায় নেই।
  • বড় আমদানিতে ধীর: row-level trigger প্রতি সারিতে চলে; দশ লাখ সারির bulk load-এ দশ লাখবার audit INSERT. তাই আমদানির সময় trigger বন্ধ রাখা হয় (PostgreSQL: ALTER TABLE … DISABLE TRIGGER) — আর তখনই drift ঢোকে।
  • যে পথে trigger চলে না: TRUNCATE সাধারণত DELETE trigger চালায় না; MySQL-এ foreign key-র CASCADE trigger চালায় না; SQL Server-এর BULK INSERT ডিফল্টে trigger চালায় না (FIRE_TRIGGERS লাগে)।
  • ক্রম আর শিকলের চমক: অংশ ৮ — একাধিক trigger-এর ক্রম, অপ্রত্যাশিত শিকল, recursion.
  • বহনযোগ্যতা কম: চারটি DBMS-এর trigger-এর syntax চার রকম (অংশ ৩-এর ট্যাব)। DBMS বদলালে সব trigger নতুন করে লিখতে হয় (পর্ব ৩১)।

DDL trigger আর event trigger

এতক্ষণের trigger ডেটার বদলে (DML) জাগে। কিছু DBMS schema-র বদলেও (DDL) trigger দেয়: SQL Server-এ DDL trigger (CREATE TRIGGER … ON DATABASE FOR DROP_TABLE), PostgreSQL-এ event trigger (CREATE EVENT TRIGGER … ON ddl_command_start), Oracle-এ BEFORE DROP ON SCHEMA. ব্যবহার: production-এ ভুল করে DROP TABLE আটকানো, কে কখন schema বদলাল তার log, migration-এর নিয়ম চাপানো (পর্ব ২৩)। SQLite আর MySQL-এ এমন কিছু নেই।

CREATE FUNCTION block_drop() RETURNS event_trigger
LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'DROP TABLE is blocked on this server (%)', tg_tag;
END;
$$;

CREATE EVENT TRIGGER trg_no_drop ON ddl_command_start
WHEN TAG IN ('DROP TABLE')
EXECUTE FUNCTION block_drop();

১০ · AI-এর যুগে trigger

“প্রতিটি দাম বদলের log রাখার trigger লিখে দাও” — AI কয়েক সেকেন্ডে লিখে দেয়, আর সেটি সাধারণত চলেও। বিপদ হলো, trigger-এর ভুল চুপচাপ থাকে: ভুল DBMS-এর syntax, NULL-এ বাদ পড়া বদল, statement-level DBMS-এ এক সারির অনুমান, বা অনন্ত recursion. AI-এর সাধারণ ভুলগুলো:

AI-এর ভুলফলকীভাবে ধরবেন
SQLite-এ SET NEW.col = … বা PostgreSQL-এর মতো function লিখেছেsyntax errorDBMS আর সংস্করণ প্রম্পটে বলুন; ছোট কপিতে চালান
SQL Server-এ SELECT @id = id FROM insertedবহু-সারির UPDATE-এ শুধু একটি সারির কাজএক স্টেটমেন্টে ৩টি সারি বদলে test করুন
NULL-যোগ্য কলামে NEW.x <> OLD.xNULL থেকে/তে বদল log-এ ওঠে নাIS NOT / IS DISTINCT FROM; NULL দিয়ে test
নিজের টেবিল বদলানো trigger, পাহারাদার শর্ত নেইrecursion বা অপ্রত্যাশিত ঢেউWHEN শর্ত আর recursive_triggers ON রেখে test
derived কলামে শুধু INSERT triggerUPDATE/DELETE-এ driftreconciliation কোয়েরি চালিয়ে অমিল গুনুন
trigger থেকে email/HTTP পাঠানোর পরামর্শROLLBACK হলেও বার্তা চলে যায়outbox টেবিল চান
নেই এমন টেবিল বা কলামের নামCREATE TRIGGER ব্যর্থ, বা প্রথম চলার সময় ব্যর্থschema প্রম্পটে দিন; sqlite_schema মিলিয়ে নিন

AI-কে শুধু trigger চাইবেন না — নিয়ম, DBMS, schema আর test-এর ঘটনাগুলো দিন:

আমার ডেটাবেস SQLite 3.45 (পরে PostgreSQL 16-এ যাবে). orders টেবিল: order_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, order_date TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending','shipped','delivered','cancelled')). নিয়ম: status শুধু pending → shipped → delivered পথে চলবে; cancelled শুধু pending থেকে; একই মান আবার লেখা চলবে। ১. SQLite-এর জন্য একটি BEFORE UPDATE OF status trigger দাও — বৈধ পথের তালিকা দিয়ে, RAISE(ABORT, ...) সহ। ২. PostgreSQL 16-এর জন্য trigger function + CREATE TRIGGER দাও। ৩. ৬টি test UPDATE দাও: ৩টি বৈধ, ৩টি অবৈধ; সাথে আরও একটি, যা এক স্টেটমেন্টে একাধিক সারি বদলায় — প্রতিটির প্রত্যাশিত ফল লেখো। ৪. এই নিয়ম trigger ছাড়া (constraint বা অ্যাপে) লেখা যেত কি না, আর trigger-এর ঝুঁকি কী — দুই লাইনে বলো। ৫. কোনো টেবিল বা কলামের নাম অনুমান করবে না — না জানলে জিজ্ঞেস করো।

AI-এর দেওয়া trigger চালু করার আগে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Trigger আর stored procedure-এর পার্থক্য? — trigger ঘটনায় নিজে চলে, ডাকা যায় না, parameter নেই; procedure নাম ধরে ডাকতে হয়।
  • BEFORE আর AFTER trigger কখন? — BEFORE: যাচাই, বাতিল (আর কিছু DBMS-এ NEW বদলানো); AFTER: সারি টিকে যাওয়ার পর অন্য টেবিল হালনাগাদ, audit log.
  • NEW আর OLD কোন ঘটনায় থাকে? — INSERT-এ NEW, DELETE-এ OLD, UPDATE-এ দুটিই।
  • Row-level আর statement-level-এর পার্থক্য? — প্রতি সারিতে একবার বনাম প্রতি স্টেটমেন্টে একবার (শূন্য সারিতেও)।
  • INSTEAD OF trigger কোথায় লাগে? — view-এর ওপর, যাতে view-এ INSERT/UPDATE/DELETE আসল টেবিলের সঠিক কাজে রূপ নেয়।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১SQL Server-এ একটি audit trigger বছরখানেক ঠিকঠাক চলল, তারপর একদিন দেখা গেল একটি বড় দাম-বদলের মাত্র একটি সারি log-এ উঠেছে। কী ঘটেছিল, আর এ থেকে row বনাম statement নিয়ে কী শিখি?

খুব সম্ভবত trigger-টি লেখা হয়েছিল এভাবে: DECLARE @id INT, @old MONEY, @new MONEY; SELECT @id = i.product_id, @new = i.price FROM inserted i; … তারপর একটি INSERT. লেখক ধরে নিয়েছিলেন, trigger প্রতিটি সারির জন্য চলে — যেমন MySQL বা SQLite-এ। কিন্তু SQL Server-এ trigger statement-level: একটি UPDATE যত সারিই বদলাক, trigger একবারই চলে, আর inserted ছদ্ম-টেবিলে সব বদলানো সারি থাকে। বহু-সারির টেবিল থেকে একটি ভেরিয়েবলে SELECT করলে SQL Server কোনো ত্রুটি দেয় না — নীরবে যেকোনো একটি সারির মান রাখে। এক বছর ধরে অ্যাপ এক এক করে দাম বদলাত, তাই বাগটি দেখা যায়নি। যেদিন কেউ “সব ইলেকট্রনিক্সে ১০% ছাড়” একটি স্টেটমেন্টে চালালেন, সেদিন একটি ছাড়া বাকি সব ইতিহাস হারাল।

সঠিক লেখা অংশ ৩-এর SQL Server ট্যাবে: INSERT … SELECT … FROM deleted d JOIN inserted i … — সেট ধরে চিন্তা, যা এক সারিতেও ঠিক, লাখ সারিতেও। এটি relational চিন্তার মূল কথাও: SQL সেটের ওপর কাজ করে, লুপের ওপর নয়।

দুই নকশার লাভ-ক্ষতি আছে। Row-level লেখা সহজ, NEW/OLD সরাসরি পাওয়া যায়, কিন্তু বড় স্টেটমেন্টে trigger লাখবার চলে — প্রতিবার আলাদা ছোট INSERT. Statement-level-এ কাজ একবার, একটি বড় সেট-ভিত্তিক INSERT-এ, যা optimizer ভালো চালাতে পারে; কিন্তু চিন্তায় শৃঙ্খলা লাগে। PostgreSQL 10 transition table এনে দুটির সুবিধা মিলিয়েছে: statement-level trigger পুরোনো আর নতুন সারির পুরো সেট টেবিলের মতো পায়। এ কারণে বড় batch-এ PostgreSQL-এর অনেক audit ব্যবস্থা statement-level-এ যায়।

Test-এর শিক্ষাটিও জরুরি: trigger-এর test-এ সবসময় অন্তত একটি বহু-সারির স্টেটমেন্ট রাখুন, আর একটি শূন্য-সারির স্টেটমেন্টও — statement-level trigger শূন্য সারিতেও চলে, আর “inserted খালি” অবস্থায় কোড ভাঙে কি না দেখা দরকার।

মূল উপলব্ধি: trigger কতবার চলে — প্রতি সারিতে না প্রতি স্টেটমেন্টে — এটি DBMS-এর নকশা, আপনার অনুমান নয়। সেট ধরে লেখা trigger দুই জগতেই নিরাপদ।

প্র ০২audit log trigger দিয়ে রাখা হয়েছে, তাই “কেউ ইতিহাস এড়িয়ে দাম বদলাতে পারবে না” — এই দাবি কতটা সত্য? কোন কোন পথে trigger এড়িয়ে যাওয়া যায়, আর আধুনিক বিকল্প কী?

দাবিটি অ্যাপের দিক থেকে সত্য: অ্যাপ বা অ্যাডমিন যে পথেই সাধারণ UPDATE চালান, trigger চলবে। কিন্তু ডেটাবেসের ভেতরেই কয়েকটি দরজা আছে, যেখানে trigger চলে না বা বন্ধ করা যায়। TRUNCATE সারি ধরে মোছে না, তাই ON DELETE trigger চালায় না (PostgreSQL-এ আলাদা ON TRUNCATE statement trigger আছে)। MySQL-এর InnoDB-তে foreign key-র CASCADE বা SET NULL কোনো trigger চালায় না — অর্থাৎ parent মুছে child বদলালে child-এর audit trigger জানতেই পারে না, যা MySQL ব্যবহারকারীদের একটি পুরোনো অভিযোগ। SQL Server-এর BULK INSERT আর bcp ডিফল্টে trigger চালায় না। আর যার যথেষ্ট অধিকার আছে, তিনি trigger বন্ধ (DISABLE TRIGGER) বা মুছে দিতে পারেন, কাজ সেরে আবার চালু করতে পারেন — ইতিহাসে কিছুই থাকবে না, যদি না DDL trigger বা event trigger সেটিও লিখে রাখে।

দ্বিতীয় দুর্বলতা হলো “কে বদলাল”। ওয়েব অ্যাপ সাধারণত একটি সাধারণ ডেটাবেস ব্যবহারকারী দিয়ে সংযোগ করে, তাই trigger-এর কাছে current_user মানে শুধু “app”. আসল ব্যক্তির পরিচয় পেতে অ্যাপকে প্রতিটি transaction-এ একটি session ভেরিয়েবল বসাতে হয় (PostgreSQL-এ SET LOCAL app.user_id = …), যা trigger পড়ে। SQLite-এ ব্যবহারকারী বলে কিছুই নেই।

বিকল্পগুলো কী? SQL:2011 standard-এ system-versioned temporal table এসেছে: DBMS নিজেই প্রতিটি সারির পুরোনো সংস্করণ সময়সহ রাখে (SQL Server, MariaDB, DB2-এ আছে; PostgreSQL-এ extension দিয়ে)। আরেকটি পথ change data capture (CDC): ডেটাবেসের নিজের transaction log (WAL বা binlog) পড়ে প্রতিটি পরিবর্তন অন্য সিস্টেমে পাঠানো — Debezium-এর মতো টুল এ কাজে পরিচিত। log সব পরিবর্তন ধরে, trigger-এর পথ হোক বা না হোক, আর মূল লেখার ওপর বাড়তি খরচ সাধারণত সামান্য (পর্ব ৫২)। তবে CDC-র নিজস্ব জটিলতা আছে: আলাদা সিস্টেম চালানো, আর log থেকে ব্যবসার অর্থ পুনর্গঠন।

আইনি বা আর্থিক audit-এ তাই সাধারণত কয়েক স্তর থাকে: trigger বা temporal table, অধিকার সীমিত রাখা (পর্ব ৫১ — অ্যাপের ব্যবহারকারী trigger বন্ধ করতে পারবে না), DDL-এর log, আর log-টি এমন জায়গায় পাঠানো যেখানে মূল ডেটাবেসের অ্যাডমিনও বদলাতে পারেন না।

মূল উপলব্ধি: trigger অ্যাপের ভুলের বিরুদ্ধে চমৎকার পাহারা, কিন্তু ক্ষমতাবান ব্যবহারকারী বা বিশেষ পথের বিরুদ্ধে নয়। audit-এর বিশ্বাসযোগ্যতা আসে কয়েকটি স্তর মিলিয়ে।

প্র ০৩orders.total-এর মতো derived কলাম trigger দিয়ে রাখবেন, প্রতিবার হিসাব করবেন, নাকি materialized view? একসাথে অনেক অর্ডার-লাইন ঢুকলে increment trigger কি নিরাপদ?

শুরুতে প্রশ্ন করুন, সমস্যাটি আছে কি? দশ লাখ অর্ডার আর গড়ে তিনটি লাইন হলে, order_id-তে index থাকলে একটি অর্ডারের যোগফল কয়েকটি সারি পড়ার কাজ — মিলিসেকেন্ডের কম। অর্ডারের তালিকার পাতায় একসাথে বিশটি অর্ডারের যোগফলও সস্তা। তাই প্রথম উত্তর প্রায় সবসময়: সংরক্ষণ করবেন না, view বা কোয়েরিতে হিসাব করুন। “একটি সত্য, এক জায়গায়” — normalization-এর মূল কথা (পর্ব ৩৬)।

সংরক্ষণের যুক্তি আসে যখন পড়া অনেক বেশি আর হিসাব ভারী — যেমন “এই মাসে প্রতিটি গ্রাহক মোট কত কিনেছেন” লাখো সারির ওপর, প্রতি সেকেন্ডে শতবার। তখন বিকল্প দুটি। Materialized view (পর্ব ২৪) পুরো ফল জমা রাখে আর নির্দিষ্ট সময়ে REFRESH হয় — সহজ, কিন্তু মাঝের সময়ে ফল পুরোনো। Trigger মানটি প্রতি লেখায় হালনাগাদ রাখে — সবসময় তাজা, কিন্তু প্রতিটি লেখার খরচ বাড়ে, আর drift-এর ঝুঁকি।

একসাথে লেখার প্রশ্নটি সূক্ষ্ম। increment trigger লেখে UPDATE orders SET total = total + x WHERE order_id = …. এটি নিরাপদ, কারণ UPDATE সারিটিতে lock নিয়ে তখনকার total পড়ে যোগ করে; দুটি transaction একই অর্ডারে লাইন যোগ করলে দ্বিতীয়টি প্রথমটির commit-এর অপেক্ষা করে, তারপর নতুন মানের ওপর যোগ করে (PostgreSQL-এর READ COMMITTED-এ এভাবেই কাজ করে)। বিপদ হয় অন্য রূপে: trigger যদি আগে SELECT total INTO v পড়ে, পরে SET total = v + x লেখে, তাহলে দুটি transaction একই পুরোনো v পড়ে একটি যোগ হারাতে পারে — পর্ব ৪৪ আর ৪৭-এর lost update. আরেকটি খরচ: জনপ্রিয় একটি সারি (যেমন “আজকের মোট বিক্রি”) প্রতিটি অর্ডারে হালনাগাদ হলে সব লেখা সেই একটি সারির lock-এর লাইনে দাঁড়ায় — hot row. বড় সিস্টেমে তাই কয়েকটি ভাগে গুনে পড়ার সময় যোগ করা হয়।

আর drift-এর বিরুদ্ধে সবসময় একটি reconciliation কোয়েরি, যেমন অংশ ৬-এ দেখলেন, নিয়মিত চালানো দরকার — সাথে একটি “পুনর্গণনা” স্ক্রিপ্ট, যা দরকার হলে সব total আবার হিসাব করে।

মূল উপলব্ধি: derived মান সংরক্ষণ একটি ক্যাশ, আর প্রতিটি ক্যাশের দুটি প্রশ্ন: কীভাবে তাজা থাকবে, আর ভুল হলে কীভাবে ধরব। trigger প্রথম প্রশ্নের উত্তর দেয়, দ্বিতীয়টির নয়।

প্র ০৪অনেক বড় প্রতিষ্ঠান নীতি করে রাখে “ডেটাবেসে কোনো business logic নয়, trigger নিষেধ” — আবার অনেকে উল্টো, সব নিয়ম ডেটাবেসে। দুই পক্ষের যুক্তি কী, আর মাঝামাঝি পথ কোথায়?

“trigger নিষেধ” পক্ষের যুক্তি মূলত প্রকৌশলের অভ্যাস থেকে। অ্যাপের কোড version control-এ থাকে, unit test হয়, code review হয়, debugger দিয়ে ধাপে ধাপে দেখা যায়, আর log-এ প্রতিটি সিদ্ধান্ত লেখা থাকে। trigger-এর কোড ডেটাবেসে থাকে — অনেক দলে migration ফাইলে লুকানো, আর কোনো ডেভেলপার কোয়েরি পড়ে বোঝেন না যে পেছনে তিনটি টেবিল বদলাচ্ছে। Microservice স্থাপত্যে প্রতিটি সেবার নিজের ডেটাবেস, আর ডেটাবেস শুধু “সংরক্ষণের জায়গা” — এই দর্শনে trigger অচেনা। ২০০০-এর দশক থেকে Rails-এর মতো framework-এর জনপ্রিয় দর্শনও ছিল নিয়ম মডেলের কোডে রাখা। বহনযোগ্যতার যুক্তিও আছে: trigger-এর syntax DBMS-ভেদে আলাদা।

“ডেটাবেসে নিয়ম” পক্ষের যুক্তি অখণ্ডতা থেকে। একটি ডেটাবেস প্রায় কখনো একটি অ্যাপের থাকে না: ওয়েবসাইট, মোবাইল API, রিপোর্টের স্ক্রিপ্ট, অ্যাডমিনের হাতে লেখা SQL, ডেটা আমদানি — সবাই লেখে, আর প্রতিটিতে নিয়মটি আলাদা করে লিখলে একদিন কোথাও বাদ পড়বে। অ্যাপ বদলায়, ভাষা বদলায়, কিন্তু ডেটা দশক ধরে থাকে; ব্যাংক বা সরকারি রেকর্ডের মতো জায়গায় তাই নিয়ম ডেটার কাছে রাখার রেওয়াজ। Oracle আর SQL Server-এর ঐতিহ্যবাহী ব্যবসায়িক সিস্টেমগুলোতে stored procedure আর trigger-ই ছিল অ্যাপের বড় অংশ।

মাঝামাঝি পথ, যা আজ অনেক দল মানে: অখণ্ডতার নিয়ম ডেটাবেসে, প্রবাহের নিয়ম অ্যাপে। “এই ডেটা কখনো এমন হতে পারবে না” — অর্থাৎ invariant — যেমন status পিছনে যাবে না, স্টক ঋণাত্মক নয়, দামের ইতিহাস হারাবে না: এগুলো constraint, আর তা না পারলে ছোট, স্পষ্ট নামের trigger. “এটি হলে ওটি করো” ধরনের ব্যবসায়িক প্রবাহ — SMS, ছাড়, অনুমোদনের ধাপ: অ্যাপে। আর trigger রাখলে সেটিকে অ্যাপের কোডের মতোই সামলানো: migration-এ version, test (অংশ ১০-এর চেকলিস্ট), আর ডকুমেন্টেশনে তালিকা।

এই প্রশ্নের এক কথায় ঠিক উত্তর নেই; দলের দক্ষতা, কতগুলো অ্যাপ একই ডেটাবেসে লেখে, আর DBMS বদলের সম্ভাবনা — এগুলো সিদ্ধান্ত বদলে দেয়। ইন্টারভিউতে তাই এক পক্ষ বেছে নেওয়ার চেয়ে লাভ-ক্ষতি বলতে পারা বেশি মূল্যবান।

মূল উপলব্ধি: প্রশ্নটি “trigger ভালো না খারাপ” নয়, “কোন নিয়ম কার দায়িত্ব”। যে নিয়ম ভাঙলে ডেটা মিথ্যা হয়, তা ডেটার পাশে; যে নিয়ম কাজের ধারা ঠিক করে, তা অ্যাপে।

প্র ০৫SQLite শুধু row-level trigger দেয়, BEFORE trigger-এ NEW বদলাতে দেয় না, আর recursive trigger ডিফল্টে বন্ধ রাখে। এই সীমাবদ্ধতাগুলোর পেছনে নকশার কী যুক্তি থাকতে পারে?

SQLite-এর লক্ষ্য একটি ছোট, embedded, এক-ফাইলের ডেটাবেস (পর্ব ১২), যা মোবাইল ফোন থেকে ব্রাউজার পর্যন্ত কোটি কোটি যন্ত্রে চলে। এমন জায়গায় প্রতিটি বৈশিষ্ট্যের দাম — কোডের আকার, test-এর বোঝা, ভুলের সুযোগ — সার্ভার DBMS-এর চেয়ে বেশি ভারী। তাই SQLite সাধারণত সেই ন্যূনতম রূপ দেয়, যা দিয়ে বাকিটা বানিয়ে নেওয়া যায়।

Statement-level trigger না থাকার ক্ষতি SQLite-এ কম, কারণ ব্যবহারের ধরন আলাদা: একটি অ্যাপ, একটি সংযোগ, সাধারণত ছোট লেনদেন। বড় batch-এ row-level trigger-এর খরচ আছে ঠিকই, কিন্তু SQLite-এ trigger একই প্রক্রিয়ার ভেতরে, নেটওয়ার্ক ছাড়া চলে, তাই প্রতি সারির খরচ কম। transition table বাস্তবায়নে অস্থায়ী টেবিল সামলানোর জটিলতাও আছে।

NEW বদলাতে না দেওয়ার যুক্তি সরলতা আর পূর্বানুমেয়তা। SQLite-এ trigger-এর কাজ শুধু সাধারণ SQL স্টেটমেন্ট — কোনো procedural ভাষা নেই, ভেরিয়েবল নেই, assignment নেই। NEW বদলানোর অনুমতি মানে একটি নতুন ভাষা-গঠন। তার বদলে অংশ ৭-এর কৌশল: AFTER trigger থেকে একই সারিতে UPDATE. দাম একটি বাড়তি লেখা, লাভ একটি ছোট ও সহজে যাচাইযোগ্য trigger-ভাষা।

Recursive trigger ডিফল্টে বন্ধের পেছনে আছে ইতিহাস আর নিরাপত্তা। ৩.৬.১৮-এর আগে SQLite-এ recursion ছিলই না, আর লাখো অ্যাপ সেই আচরণের ওপর দাঁড়িয়ে ছিল — যেমন updated_at-এর trigger নিজের UPDATE-এ আবার জাগবে না। ডিফল্ট বদলালে সেই অ্যাপগুলো হঠাৎ অনন্ত চক্রে পড়ত। তাই নতুন ক্ষমতা এল ঐচ্ছিক PRAGMA হিসেবে। একটি সূক্ষ্ম পরিণতিও আছে: recursive_triggers চালু থাকলে REPLACE বা INSERT OR REPLACE পুরোনো সারি মোছার সময় DELETE trigger-ও চালায়, বন্ধ থাকলে চালায় না — অর্থাৎ PRAGMA-টি শুধু recursion নয়, আরও কিছু আচরণ বদলায়।

সার্ভার DBMS-গুলো উল্টো পথে গেছে: PostgreSQL-এ trigger function যেকোনো ভাষায়, row আর statement দুটিই, recursion-এর কোনো সুইচ নেই (চাইলে pg_trigger_depth() দিয়ে নিজে থামাতে হয়)। ক্ষমতা বেশি, তাই ভুলের সুযোগও বেশি।

মূল উপলব্ধি: প্রতিটি DBMS-এর trigger-ব্যবস্থা তার ব্যবহারের জগতের প্রতিফলন। সীমাবদ্ধতা কখনো দুর্বলতা নয়, সচেতন সিদ্ধান্ত — আর সেটি জানলে আপনি জানবেন কোন কৌশল কোথায় লাগবে।

অনুশীলন

একটি trigger লেখা, একটি হাতে ফল বের করা, একটি ডিজাইন, একটি মেরামত।

  1. Trigger লিখুন: নিয়ম — একবারে কোনো পণ্যের দাম ৫০%-এর বেশি বাড়ানো যাবে না (ভুল করে একটি শূন্য বেশি টাইপ ঠেকাতে)। db-shop-এর products-এ SQLite trigger লিখুন। তারপর ভাবুন: Headphone (102, দাম 1200)-এর দাম 1700 আর 1900 করার চেষ্টায় কী হবে? (উপরের যেকোনো db-shop এডিটরে চালিয়ে দেখতে পারেন।)
    CREATE TRIGGER trg_price_cap
    BEFORE UPDATE OF price ON products
    WHEN NEW.price > OLD.price * 1.5
    BEGIN
      SELECT RAISE(ABORT, 'price jump over 50%');
    END;

    1200 × 1.5 = 1800. তাই 1700 সফল (“১টি সারি বদলেছে”), আর মূল দাম 1200 থেকে সরাসরি 1900 করতে চাইলে ত্রুটি price jump over 50%. কিন্তু খেয়াল করুন: 1700 সফল হওয়ার পরে 1900 চাইলে সেটিও সফল, কারণ এবার তুলনা 1700 × 1.5 = 2550-এর সাথে। অর্থাৎ ধাপে ধাপে বাড়িয়ে নিয়মটি এড়ানো যায় — trigger শুধু একটি স্টেটমেন্ট দেখে, ইতিহাস নয়। কড়া নিয়ম চাইলে price_history থেকে, ধরুন, গত ৭ দিনের সর্বনিম্ন দামের সাথে তুলনা করতে হবে। BEFORE কেন? কারণ কাজটি বাতিল করা; AFTER-এ RAISE দিলেও বাতিল হতো, কিন্তু অকারণে সারি লেখার পরে।

  2. ফল আগে বলুন (trace): টেবিল c(id, n)-এ একটি সারি (1, 0). trigger: CREATE TRIGGER inc AFTER UPDATE ON c WHEN NEW.n < 5 BEGIN UPDATE c SET n = n + 1 WHERE id = NEW.id; END; তারপর UPDATE c SET n = 1 WHERE id = 1; চলল। (ক) PRAGMA recursive_triggers = OFF (ডিফল্ট) হলে শেষে n কত? (খ) ON হলে? (গ) ON রেখে WHEN শর্তটি মুছে দিলে?

    (ক) n = 2. UPDATE n-কে 1 করল; trigger জাগল (1 < 5), n হলো 2. trigger-এর নিজের UPDATE-ও c-এর UPDATE, কিন্তু recursion বন্ধ থাকায় inc আবার জাগে না।

    (খ) n = 5. প্রতিবার trigger-এর UPDATE আবার inc জাগায়: 1 → 2 → 3 → 4 → 5. n = 5 হওয়ার পর WHEN (5 < 5) FALSE, শিকল থামে। মূল UPDATE-এর বার্তা তবু “১টি সারি বদলেছে”।

    (গ) ত্রুটি: too many levels of trigger recursion. থামার শর্ত নেই, তাই গভীরতা সীমায় পৌঁছায়, আর পুরো স্টেটমেন্ট ফিরে যায় — n থাকে 0. পাহারাদার শর্ত ছাড়া নিজের টেবিল বদলানো trigger একটি টাইম-বোমা, যা কেউ একদিন PRAGMA চালু করলেই ফাটে।

  3. ডিজাইন করুন: বিশ্ববিদ্যালয়ের ডেটাবেসের (db-uni) এই নিয়মগুলোর প্রতিটির জন্য বলুন — CHECK, foreign key, UNIQUE, trigger, না অ্যাপের কোড — আর এক লাইনের যুক্তি দিন: (ক) গ্রেড A, B, C, D বা F; (খ) একই শিক্ষার্থী একই কোর্সে একবারই ভর্তি; (গ) একটি কোর্সে সর্বোচ্চ ৪০ জন; (ঘ) গ্রেড প্রকাশের পর বদলালে পুরোনো গ্রেড log-এ; (ঙ) ফল প্রকাশ হলে শিক্ষার্থীকে SMS.
    • (ক) CHECK — CHECK (grade IN ('A','B','C','D','F')); একটি সারির ভেতরের শর্ত।
    • (খ) UNIQUE বা PRIMARY KEY — (student_id, course_id)-এর ওপর; DBMS index দিয়ে নির্ভুলভাবে পাহারা দেয়।
    • (গ) Trigger (BEFORE INSERT ON enrollments, ভর্তির সংখ্যা গুনে RAISE) — অন্য সারি গোনা লাগে, তাই CHECK পারে না। সার্ভার DBMS-এ একসাথে দুজন ভর্তি হলে দুজনেই ৪০তম আসন পেতে পারেন; সেখানে courses-এ একটি seats_left কলাম আর UPDATE … WHERE seats_left > 0 বেশি নিরাপদ।
    • (ঘ) Trigger (AFTER UPDATE OF grade, WHEN NEW.grade IS NOT OLD.grade) — যে পথেই বদলাক, log হবেই; NULL থেকে গ্রেড বসলেও ধরা পড়ে।
    • (ঙ) অ্যাপ (বা outbox টেবিল) — SMS ডেটাবেসের বাইরের কাজ; ROLLBACK হলে পাঠানো SMS ফেরানো যায় না।
  4. মেরামত করুন: অংশ ৬-এর orders.total-এ drift ধরা পড়েছে, কারণ order_items-এর UPDATE-এর জন্য কোনো trigger নেই। (ক) অনুপস্থিত trigger-টি লিখুন — মনে রাখুন, একটি লাইন এক অর্ডার থেকে অন্য অর্ডারে সরানোও (order_id বদল) একটি UPDATE. (খ) ইতিমধ্যে ভুল হয়ে যাওয়া total-গুলো একটি স্টেটমেন্টে কীভাবে শোধরাবেন?
    CREATE TRIGGER trg_total_upd
    AFTER UPDATE OF quantity, unit_price, order_id ON order_items
    BEGIN
      UPDATE orders SET total = total - OLD.quantity * OLD.unit_price
      WHERE order_id = OLD.order_id;
      UPDATE orders SET total = total + NEW.quantity * NEW.unit_price
      WHERE order_id = NEW.order_id;
    END;
    
    -- (খ) সব total আবার হিসাব (backfill-এর মতো)
    UPDATE orders SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
                               FROM order_items i WHERE i.order_id = orders.order_id);

    (ক) পুরোনো লাইনের অবদান OLD-এর অর্ডার থেকে বাদ, নতুনটি NEW-এর অর্ডারে যোগ। order_id না বদলালে দুটি UPDATE একই অর্ডারে পড়ে, ফল ঠিকই আসে: অর্ডার 1001-এ Headphone-এর পরিমাণ 2 থেকে 3 করলে total 17400 − 2400 + 3600 = 18600. (খ) পুনর্গণনার UPDATE-এ COALESCE জরুরি — লাইনহীন অর্ডারে SUM-এর ফল NULL, আর total-এ NOT NULL আছে। শোধরানোর পর reconciliation কোয়েরি আবার চালিয়ে “(কোনো সারি নেই)” নিশ্চিত করুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমSQLite-এ trigger-ওয়ালা একটি টেবিলে একটি সাধারণ INSERT-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. BEFORE INSERT trigger চলে — NEW পড়া যায়, সারি এখনো টেবিলে নেই
  2. NOT NULL, CHECK আর PRIMARY KEY যাচাই
  3. সারিটি টেবিলে লেখা হয়
  4. AFTER INSERT trigger চলে — অন্য টেবিল হালনাগাদ
  5. স্টেটমেন্টের শেষে foreign key যাচাই
  6. COMMIT — সব পরিবর্তন স্থায়ী

একটি DELETE trigger-এর ভেতরে কোন সারি-রেফারেন্স ব্যবহার করা যায়?

  • শুধু NEW
  • NEW আর OLD দুটিই
  • শুধু OLD
  • কোনোটিই নয়

SQLite-এ একটি transaction-এর ভেতরে একটি UPDATE তিনটি সারি বদলাচ্ছে; দ্বিতীয় সারিতে একটি BEFORE UPDATE trigger RAISE(FAIL, 'x') দিল। ফল কী?

  • তিনটি সারিই আগের মানে, transaction খোলা
  • প্রথম সারি বদলানো অবস্থায় থাকে, বাকি দুটি বদলায় না
  • পুরো transaction বাতিল
  • শুধু দ্বিতীয় সারি বাদ, প্রথম আর তৃতীয় বদলায়, কোনো ত্রুটি নেই

SQL Server-এ products টেবিলে একটি AFTER UPDATE trigger আছে। একটি UPDATE স্টেটমেন্ট ৫০টি সারি বদলাল। trigger কতবার চলবে?

  • একবার
  • ৫০ বার
  • একবারও না, কারণ বহু-সারির UPDATE-এ trigger চলে না
  • ৫১ বার — প্রতি সারিতে একবার আর শেষে একবার

orders টেবিলের status কলামের কোন নিয়মটি CHECK constraint দিয়ে লেখা যায় না, trigger লাগে?

  • status-এর মান pending, shipped, delivered বা cancelled-এর একটি
  • status কখনো ফাঁকা থাকবে না
  • status delivered হলে delivered_on কলামে তারিখ থাকতে হবে
  • delivered হয়ে যাওয়া অর্ডার আর pending-এ ফিরতে পারবে না

SQLite-এ INSTEAD OF trigger কিসের ওপর বানানো যায়?

  • শুধু সাধারণ টেবিলের ওপর
  • শুধু view-এর ওপর
  • টেবিল আর view দুটির ওপরই
  • SQLite-এ INSTEAD OF trigger নেই

অর্ডার নিশ্চিত হলে গ্রাহককে SMS পাঠাতে হবে। সবচেয়ে নিরাপদ নকশা কোনটি?

  • একই transaction-এ একটি outbox টেবিলে লেখা; COMMIT-এর পর আলাদা প্রক্রিয়া সেটি পড়ে SMS পাঠায়
  • AFTER INSERT trigger থেকে সরাসরি SMS সার্ভারে অনুরোধ
  • BEFORE INSERT trigger থেকে SMS পাঠানো, যাতে দ্রুত যায়
  • INSERT-এর পরে কিন্তু COMMIT-এর আগে অ্যাপ থেকে SMS পাঠানো

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Trigger = ঘটনা + শর্ত + কাজ: INSERT/UPDATE [OF কলাম]/DELETE হলে, WHEN সত্য হলে, BEGIN … END-এর SQL চলে — একই transaction-এ, কেউ না ডাকলেও।
  2. BEFORE যাচাই ও বাতিলের জন্য (সারি এখনো নেই), AFTER অন্য টেবিল হালনাগাদের জন্য (সারি আছে), INSTEAD OF view-এর জন্য। NEW শুধু INSERT/UPDATE-এ, OLD শুধু UPDATE/DELETE-এ।
  3. SQLite-এ RAISE: ABORT স্টেটমেন্ট ফেরায়, FAIL আধা-কাজ রাখে, ROLLBACK পুরো transaction ফেরায়, IGNORE সারিটি নীরবে বাদ দেয়।
  4. SQLite শুধু row-level; SQL Server শুধু statement-level; PostgreSQL দুটিই, function আলাদা করে। changes() trigger-এর কাজ গোনে না, আর trigger শিকল বানায় — পাহারাদার শর্ত রাখুন।
  5. সিঁড়ি: constraint আগে, তারপর trigger, বাইরের কাজ অ্যাপে। derived কলামে reconciliation, audit-এ একাধিক স্তর।

পরের পর্বে: এতক্ষণ সব ডেটা ছিল সারি আর কলামে সাজানো। পর্ব ৩০-এ দেখবেন JSON, full-text search আর বিশেষ ডেটা টাইপ — যখন একটি কলামের ভেতরেই আস্ত একটি document থাকে, বা “ঢাকায় সস্তা মোবাইল” লিখে খুঁজতে হয়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান