অধ্যায় 3 · পারফরম্যান্স ও প্রোডাকশন
ভিউ, স্টোরড প্রসিডিওর, ফাংশন আর ট্রিগার
- পৃষ্ঠা 10 / 22
- 18 মিনিট পড়া
এতদিন প্রতিটা কোয়েরি থাকত আপনার কোডে: Python-এর একটা স্ট্রিং, নোটবুকের একটা সেল, রিপোর্টের একটা স্ক্রিপ্ট। ডেটাবেস নিজেও লজিক রাখতে পারে। ভিউ (view) একটা কোয়েরিকে নাম দিয়ে সংরক্ষণ করে, ম্যাটেরিয়ালাইজড ভিউ (materialized view) সংরক্ষণ করে তার ফলাফল, স্টোরড প্রসিডিওর আর ফাংশন সার্ভারের ভেতরেই কোড চালায়, ট্রিগার সারি বদলালে নিজে থেকে চলে, আর ইভেন্ট চলে নির্দিষ্ট সময়সূচিতে।
ডেটা আর AI-এর কাজে এগুলো প্রায়ই দেখবেন: এমন একটা ভিউ, যা "বৈধ ট্রেনিং উদাহরণ" কী তা সব নোটবুকের জন্য একবারেই ঠিক করে দেয়; ড্যাশবোর্ড বা ফিচার টেবিলে ডেটা জোগানো একটা ম্যাটেরিয়ালাইজড ভিউ; এমন একটা ট্রিগার, যা লেবেলের প্রতিটা পরিবর্তন লিখে রাখে, যাতে মডেলের ট্রেনিং সেট কেন বদলাল তা ব্যাখ্যা করতে পারেন। আবার এগুলোর অতিরিক্ত ব্যবহারও খুব সহজ। এই পাতা প্রতিটা টুল আসল ডেটাবেসে চালিয়ে দেখায়, তারপর একটা নিয়ম দেয়: কখন লজিক ডেটাবেসে থাকবে, আর কখন অ্যাপ্লিকেশনে।
যা শিখবেন
- ভিউ বানানো ও ব্যবহার; কোন ভিউ আপডেট করা যায়, আর
WITH CHECK OPTION - PostgreSQL-এ ম্যাটেরিয়ালাইজড ভিউ আর তা রিফ্রেশ করা
- IN/OUT প্যারামিটার, ভেরিয়েবল আর কন্ট্রোল ফ্লো-সহ স্টোরড প্রসিডিওর (MySQL), আর স্টোরড ফাংশন
- BEFORE আর AFTER ট্রিগার, আর ট্রিগার দিয়ে বানানো পূর্ণ একটা অডিট ট্রেইল (SQLite)
- নির্ধারিত সময়ের ইভেন্ট, আর কখন লজিক ডেটাবেসে রাখবেন বনাম অ্যাপ্লিকেশনে
ভিউ: নাম দেওয়া একটা সংরক্ষিত কোয়েরি
অর্ডারপ্রতি আয় বের করতে লাগে একটা জয়েন আর একটা GROUP BY, যা নইলে প্রত্যেক অ্যানালিস্ট নতুন করে লিখবেন (আর কেউ কেউ ভুল লিখবেন)। একবারই সংরক্ষণ করুন:
CREATE VIEW order_totals AS
SELECT o.order_id,
o.customer_id,
o.order_date,
o.status,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.customer_id, o.order_date, o.status;
SELECT c.name, COUNT(*) AS orders, SUM(t.total) AS spent
FROM order_totals t
JOIN customers c ON c.customer_id = t.customer_id
WHERE t.status = 'delivered'
GROUP BY c.name
ORDER BY spent DESC
LIMIT 3;+---------------+--------+-------+
| name | orders | spent |
+---------------+--------+-------+
| Nadia Rahman | 3 | 23600 |
| Tanvir Ahmed | 2 | 19500 |
| Farhana Akter | 3 | 9500 |
+---------------+--------+-------+SELECT-এ ভিউ টেবিলের মতোই আচরণ করে, কিন্তু এতে কোনো ডেটা জমা থাকে না: এর ওপর প্রতিটা কোয়েরি সংরক্ষিত কোয়েরিটা আবার চালায়, তাই এটা সবসময় হালনাগাদ। একটা অর্ডার বদলান, ভিউতে সাথে সাথে তা দেখা যাবে:
UPDATE order_items SET quantity = 3 WHERE order_id = 1 AND product_id = 4;
SELECT order_id, total FROM order_totals WHERE order_id = 1;+----------+-------+
| order_id | total |
+----------+-------+
| 1 | 3900 |
+----------+-------+ভিউ তিনটা কাজে ভালো: একটা ব্যবসায়িক নিয়মের একটাই সংজ্ঞা ("আয়ে বাতিল অর্ডার ধরা হয় না"), অ্যানালিস্ট আর BI টুলের জন্য সহজ কোয়েরি, আর অ্যাক্সেস নিয়ন্ত্রণ — টেবিলের বদলে সংবেদনশীল কলাম ছাড়া একটা ভিউ ব্যবহারের অনুমতি দিন (পরের পাতা দেখুন)। DROP VIEW order_totals; শুধু সংরক্ষিত কোয়েরিটা মোছে, কখনো ডেটা নয়। ভিউয়ের ওপর ভিউ বানানো যায়, কিন্তু গভীর স্তূপ ধীর হয় আর ডিবাগ করা কঠিন হয়ে পড়ে।
ভিউয়ের মধ্য দিয়ে কি লেখা যায়?
SQLite-এ কখনোই না — ভিউ শুধু পড়ার জন্য (যদি না INSTEAD OF ট্রিগার যোগ করেন):
UPDATE order_totals SET total = 0 WHERE order_id = 1;Error: cannot modify order_totals because it is a viewPostgreSQL আর MySQL একটা সাধারণ ভিউ আপডেট করতে পারে — একটা টেবিল, কোনো GROUP BY, DISTINCT, অ্যাগ্রিগেট বা UNION নেই — পরিবর্তনটা নিচের টেবিলে পৌঁছে দিয়ে (MySQL কিছু জয়েন করা ভিউও আপডেট করতে পারে, যদি প্রতিটা স্টেটমেন্ট মূল টেবিলগুলোর মধ্যে মাত্র একটাকে বদলায়)। WITH CHECK OPTION ভিউয়ের মধ্য দিয়ে এমন কোনো লেখা হতে দেয় না, যার ফলে তৈরি হওয়া সারি ভিউ নিজেই আর দেখতে পাবে না:
-- PostgreSQL
CREATE VIEW dhaka_customers AS
SELECT customer_id, name, email, city
FROM customers
WHERE city = 'Dhaka'
WITH CHECK OPTION;
UPDATE dhaka_customers SET name = 'Nadia R. Rahman' WHERE customer_id = 1;
SELECT customer_id, name, city FROM customers WHERE customer_id = 1;
UPDATE dhaka_customers SET city = 'Sylhet' WHERE customer_id = 3;+-------------+-----------------+-------+
| customer_id | name | city |
+-------------+-----------------+-------+
| 1 | Nadia R. Rahman | Dhaka |
+-------------+-----------------+-------+
Error: new row violates check option for view "dhaka_customers"কাস্টমারের নাম বদলানো কাজ করেছে, আর আসল টেবিলেই বদলেছে। একজন কাস্টমারকে ঢাকার বাইরে সরানো ফিরিয়ে দেওয়া হয়েছে, কারণ সারিটা তখন ভিউ থেকে হারিয়ে যেত। অ্যাগ্রিগেটওয়ালা ভিউ একেবারেই আপডেট করা যায় না:
-- PostgreSQL
CREATE VIEW order_totals AS
SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_items
GROUP BY order_id;
UPDATE order_totals SET total = 0 WHERE order_id = 1;Error: cannot update view "order_totals"ম্যাটেরিয়ালাইজড ভিউ: সংরক্ষিত ফলাফল
সাধারণ ভিউ প্রতিবার তার কোয়েরি আবার চালায়। কোয়েরিটা যখন দামি — লাখ লাখ সারির ওপর বিক্রির সারাংশ, কাস্টমারপ্রতি অ্যাগ্রিগেট করা ফিচার টেবিল — আর একটু পুরোনো ডেটাতেও চলে, তখন PostgreSQL ফলাফলটাই একটা ম্যাটেরিয়ালাইজড ভিউ হিসেবে জমা রাখতে পারে, টেবিলের মতো ইনডেক্সসহ:
-- PostgreSQL
CREATE MATERIALIZED VIEW product_sales AS
SELECT p.product_id,
p.name,
SUM(oi.quantity) AS units,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM products p
JOIN order_items oi ON oi.product_id = p.product_id
JOIN orders o ON o.order_id = oi.order_id
WHERE o.status <> 'cancelled'
GROUP BY p.product_id, p.name;
CREATE UNIQUE INDEX idx_product_sales_id ON product_sales (product_id);
SELECT * FROM product_sales ORDER BY revenue DESC LIMIT 3;+------------+-------------------------+-------+---------+
| product_id | name | units | revenue |
+------------+-------------------------+-------+---------+
| 7 | AI Engineering Bootcamp | 2 | 30000 |
| 5 | 27-inch Monitor | 1 | 18500 |
| 3 | Mechanical Keyboard | 2 | 8550 |
+------------+-------------------------+-------+---------+এর মাশুল হলো বাসি ডেটা। দুটো মনিটরের নতুন একটা অর্ডার রিফ্রেশ না করা পর্যন্ত দেখা যায় না:
-- PostgreSQL
INSERT INTO orders (order_id, customer_id, order_date, status) VALUES (15, 4, '2026-06-20', 'delivered');
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (15, 5, 2, 18500);
SELECT * FROM product_sales WHERE product_id = 5;
REFRESH MATERIALIZED VIEW CONCURRENTLY product_sales;
SELECT * FROM product_sales WHERE product_id = 5;+------------+-----------------+-------+---------+
| product_id | name | units | revenue |
+------------+-----------------+-------+---------+
| 5 | 27-inch Monitor | 1 | 18500 |
+------------+-----------------+-------+---------+
+------------+-----------------+-------+---------+
| product_id | name | units | revenue |
+------------+-----------------+-------+---------+
| 5 | 27-inch Monitor | 3 | 55500 |
+------------+-----------------+-------+---------+REFRESH MATERIALIZED VIEW পুরো ফলাফল নতুন করে হিসাব করে। CONCURRENTLY ছাড়া চলার সময় এটা পাঠকদের আটকে রাখে; এটাসহ পাঠকেরা নতুন সারি তৈরি না হওয়া পর্যন্ত পুরোনোগুলোই দেখতে থাকেন — তবে এর জন্য একটা ইউনিক ইনডেক্স লাগে, সেজন্যই ওপরে সেটা বানানো হয়েছিল। কখন রিফ্রেশ হবে, তা কাউকে ঠিক করতে হবে: প্রতি রাতের লোডের পর, নির্দিষ্ট সময়সূচিতে (নিচে ইভেন্ট দেখুন), বা ট্রেনিং চালানোর আগে। MySQL আর SQLite-এ ম্যাটেরিয়ালাইজড ভিউ নেই; একই জিনিস বানাতে হয় সাধারণ একটা টেবিল দিয়ে, যা একটা নির্ধারিত জব খালি করে আবার ভরে।
স্টোরড প্রসিডিওর (MySQL)
স্টোরড প্রসিডিওর হলো ডেটাবেসে রাখা নাম দেওয়া একটা প্রোগ্রাম, যা CALL দিয়ে চালানো হয়। এটা IN প্যারামিটার নিতে পারে, OUT প্যারামিটার দিয়ে মান ফেরত দিতে পারে, ভেরিয়েবল ঘোষণা করতে পারে, শর্ত অনুযায়ী আলাদা পথে যেতে আর লুপ চালাতে পারে। এর ভেতরে ; দিয়ে আলাদা করা কয়েকটা স্টেটমেন্ট থাকে, যা mysql কমান্ড-লাইন ক্লায়েন্টকে বিভ্রান্ত করে, তাই ওই ক্লায়েন্টে সাময়িকভাবে স্টেটমেন্টের ডিলিমিটার বদলে নিতে হয়:
mysql shop <<'SQL'
DELIMITER //
CREATE PROCEDURE order_summary(IN p_order_id INT, OUT p_items INT, OUT p_total INT)
BEGIN
SELECT COUNT(*), COALESCE(SUM(quantity * unit_price), 0)
INTO p_items, p_total
FROM order_items
WHERE order_id = p_order_id;
END //
DELIMITER ;
SQLDELIMITER ওই ক্লায়েন্টের একটা সুবিধা, SQL-এর অংশ নয়। PyMySQL-এর মতো ড্রাইভার প্রতিটা স্টেটমেন্ট পুরোটা একসাথে পাঠায়, তাই Python থেকে প্রসিডিওর সরাসরিই বানানো যায়। মাইগ্রেশন টুলগুলোও এভাবেই করে। দ্বিতীয় প্রসিডিওরটা দেখায় ভেরিয়েবল (DECLARE), শর্ত (IF) আর নিজের এরর তোলা (SIGNAL):
-- MySQL
SELECT product_id, name, stock FROM products WHERE product_id IN (6, 9) ORDER BY product_id;+------------+-----------------+-------+
| product_id | name | stock |
+------------+-----------------+-------+
| 6 | SQL Masterclass | NULL |
| 9 | USB-C Hub | 0 |
+------------+-----------------+-------+import os
import pymysql
mysql = pymysql.connect(
host=os.environ["MYSQL_HOST"], port=int(os.environ["MYSQL_PORT"]),
user=os.environ["MYSQL_USER"], password=os.environ["MYSQL_PASSWORD"],
database=os.environ["MYSQL_DATABASE"], autocommit=True,
)
ORDER_SUMMARY = """
CREATE PROCEDURE order_summary(IN p_order_id INT, OUT p_items INT, OUT p_total INT)
BEGIN
SELECT COUNT(*), COALESCE(SUM(quantity * unit_price), 0)
INTO p_items, p_total
FROM order_items
WHERE order_id = p_order_id;
END"""
RESTOCK = """
CREATE PROCEDURE restock(IN p_product_id INT, IN p_qty INT, OUT p_new_stock INT)
BEGIN
DECLARE v_stock INT;
IF p_qty <= 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'quantity must be positive';
END IF;
SELECT stock INTO v_stock FROM products WHERE product_id = p_product_id;
IF v_stock IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'unknown product or stock not tracked';
END IF;
UPDATE products SET stock = stock + p_qty WHERE product_id = p_product_id;
SET p_new_stock = v_stock + p_qty;
END"""
with mysql.cursor() as cur:
for name, ddl in (("order_summary", ORDER_SUMMARY), ("restock", RESTOCK)):
cur.execute(f"DROP PROCEDURE IF EXISTS {name}")
cur.execute(ddl)
print("created", name)created order_summary
created restockএবার প্রসিডিওর দুটো CALL দিয়ে চালান। OUT প্যারামিটারের মান জমা হয় সেশন ভেরিয়েবলে (@ দিয়ে শুরু হওয়া নাম), যা পরে একটা SELECT দিয়ে পড়তে হয়:
-- MySQL
CALL order_summary(6, @items, @total);
SELECT @items AS items, @total AS total;
CALL restock(9, 10, @new_stock);
SELECT @new_stock AS new_stock;
CALL restock(6, 5, @new_stock);+-------+-------+
| items | total |
+-------+-------+
| 1 | 15000 |
+-------+-------+
+-----------+
| new_stock |
+-----------+
| 10 |
+-----------+
Error: ERROR 1644: unknown product or stock not trackedUSB-C হাবের (প্রোডাক্ট ৯) স্টক ০ থেকে ১০ হয়েছে। SQL কোর্সের (প্রোডাক্ট ৬) স্টক NULL, তাই প্রসিডিওরটা নিজের মেসেজ আর ১৬৪৪ কোড দিয়ে ফিরিয়ে দিয়েছে — SIGNAL-এর জন্য MySQL এই কোডই ব্যবহার করে। ভুল পরিমাণও একইভাবে ফিরিয়ে দেওয়া হয়:
-- MySQL
CALL restock(9, -5, @new_stock);Error: ERROR 1644: quantity must be positiveMySQL-এর প্রসিডিউরাল ভাষায় আরও আছে WHILE … DO … END WHILE, REPEAT … UNTIL, LEAVE-সহ LOOP, কার্সর আর এরর হ্যান্ডলার (DECLARE … HANDLER)। PostgreSQL-এ এর সমতুল্য PL/pgSQL (CREATE PROCEDURE … LANGUAGE plpgsql, ডাকা হয় CALL দিয়ে, PostgreSQL 11+); SQL Server-এ T-SQL। খুঁটিনাটি প্রতিটা সিনট্যাক্স আলাদা — প্রসিডিওরের সবচেয়ে বড় খরচ এই বহনযোগ্যতার (portability) অভাব।
স্টোরড ফাংশন বনাম প্রসিডিওর
ফাংশন একটা মান ফেরত দেয়, আর ROUND()-এর মতো কোয়েরির ভেতরেই ব্যবহার করা যায়। এক স্টেটমেন্টের MySQL ফাংশনে BEGIN … END লাগে না:
-- MySQL
CREATE FUNCTION order_total(p_order_id INT) RETURNS INT
READS SQL DATA
RETURN (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items WHERE order_id = p_order_id);
SELECT order_id, status, order_total(order_id) AS total
FROM orders
WHERE customer_id = 1
ORDER BY order_id;+----------+-----------+-------+
| order_id | status | total |
+----------+-----------+-------+
| 1 | delivered | 2100 |
| 3 | delivered | 3000 |
| 10 | delivered | 18500 |
+----------+-----------+-------+বাইনারি লগিং চালু থাকলে (MySQL 8-এ ডিফল্ট) READS SQL DATA বাধ্যতামূলক: প্রতিটা ফাংশনকে ঘোষণা করতে হয় সেটা DETERMINISTIC কিনা, ডেটা পড়ে কিনা, নাকি কোনো SQL-ই ব্যবহার করে না — যাতে রেপ্লিকাগুলো নিরাপদে সেটা আবার চালাতে পারে। সাধারণ SQL-এ লেখা PostgreSQL সংস্করণ:
-- PostgreSQL
CREATE FUNCTION order_total(p_order_id int) RETURNS bigint
LANGUAGE sql STABLE
AS $$
SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items WHERE order_id = p_order_id
$$;
SELECT order_id, status, order_total(order_id) AS total
FROM orders
WHERE customer_id = 1
ORDER BY order_id;+----------+-----------+-------+
| order_id | status | total |
+----------+-----------+-------+
| 1 | delivered | 2100 |
| 3 | delivered | 3000 |
| 10 | delivered | 18500 |
+----------+-----------+-------+STABLE প্রতিশ্রুতি দেয় যে একটা কোয়েরির ভেতরে একই ইনপুটে ফাংশনটা একই ফলাফল দেবে, ফলে প্ল্যানার সেই অনুযায়ী অপ্টিমাইজ করতে পারে। SQLite-এ ফাংশন যোগ করেন Python থেকে, con.create_function() দিয়ে (দেখুন টেক্সট, সংখ্যা ও তারিখের ফাংশন, CAST আর COALESCE)।
| ফাংশন | প্রসিডিওর | |
|---|---|---|
| কীভাবে ডাকা হয় | কোয়েরির ভেতরে: SELECT order_total(6) | CALL restock(9, 10, @s) |
| কী ফেরত দেয় | একটা মান (PostgreSQL-এ একটা টেবিলও) | কিছুই না, অথবা OUT প্যারামিটার দিয়ে মান (MySQL-এ রেজাল্ট সেটও) |
| ডেটা বদলায়? | পারে, কিন্তু কমই উচিত (যে স্টেটমেন্ট ডেকেছে সেটা যে টেবিল ব্যবহার করছে, MySQL তা বদলাতে দেয় না) | সাধারণত বদলায় |
| ট্রানজ্যাকশন | যে স্টেটমেন্ট ডেকেছে তার ভেতরেই চলে | COMMIT/ROLLBACK করতে পারে (MySQL; PostgreSQL 11+) |
| সাধারণ ব্যবহার | বারবার লাগা হিসাব, পরিষ্কার করার একটা নিয়ম | কয়েক ধাপের কাজ: অর্ডার দেওয়া, রাতের পুনর্গঠন |
ট্রিগার: সারি বদলালে যে কোড চলে
ট্রিগার একটা টেবিলের সাথে যুক্ত থাকে আর INSERT, UPDATE বা DELETE হলে প্রভাবিত প্রতিটা সারির জন্য একবার করে নিজে থেকে চলে (FOR EACH ROW; PostgreSQL-এ FOR EACH STATEMENT ট্রিগারও আছে, যা পুরো স্টেটমেন্টে একবার চলে)। BEFORE ট্রিগার পরিবর্তনের আগে চলে, আর সেটা ফিরিয়ে দিতে পারে (MySQL আর PostgreSQL-এ নতুন মানগুলো বদলেও দিতে পারে)। AFTER ট্রিগার চলে পরিবর্তন হয়ে যাওয়ার পর — লগ রাখার মতো পার্শ্ব-কাজের জায়গা এটাই। ভেতরে OLD হলো আগের সারি, আর NEW পরের সারি।
পাহারাদার হিসেবে BEFORE ট্রিগার
ধরুন অর্ধেকের বেশি দাম কমাতে হলে অনুমোদন লাগে। ডেটাবেসের ভেতরের একটা পাহারাদার এটা ধরে ফেলে — যে অ্যাপ, স্ক্রিপ্ট বা হাতের সম্পাদনা থেকেই আসুক:
CREATE TRIGGER trg_products_price_guard
BEFORE UPDATE OF price ON products
FOR EACH ROW
WHEN NEW.price < OLD.price * 0.5
BEGIN
SELECT RAISE(ABORT, 'price cut of more than 50% needs approval');
END;
UPDATE products SET price = 2000 WHERE product_id = 3;Error: price cut of more than 50% needs approvalMySQL-এ একই পাহারাদার BEGIN … END-এর ভেতরে SIGNAL ব্যবহার করে:
-- MySQL
CREATE TRIGGER trg_products_price_guard
BEFORE UPDATE ON products
FOR EACH ROW
BEGIN
IF NEW.price < OLD.price * 0.5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'price cut of more than 50% needs approval';
END IF;
END;
UPDATE products SET price = 2000 WHERE product_id = 3;Error: ERROR 1644: price cut of more than 50% needs approvalPostgreSQL-এ ট্রিগার PL/pgSQL-এ লেখা আলাদা একটা ট্রিগার ফাংশন ডাকে (CREATE FUNCTION … RETURNS trigger, তারপর CREATE TRIGGER … EXECUTE FUNCTION …), যা এরর তুলতে বা NEW বদলাতেও পারে।
AFTER ট্রিগার দিয়ে অডিট ট্রেইল
"এই দাম কে বদলেছে, আর আগে কত ছিল?" একটা অডিট টেবিল আর তিনটা AFTER ট্রিগার প্রতিটা পরিবর্তনের জন্য এর উত্তর রাখে, যে প্রোগ্রামই পরিবর্তনটা করুক। json_object() পুরোনো আর নতুন মানগুলো সংক্ষেপে জমা রাখে:
CREATE TABLE product_audit (
audit_id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
action TEXT NOT NULL,
old_values TEXT,
new_values TEXT,
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_products_audit_insert
AFTER INSERT ON products
FOR EACH ROW
BEGIN
INSERT INTO product_audit (product_id, action, new_values)
VALUES (NEW.product_id, 'insert', json_object('price', NEW.price, 'stock', NEW.stock));
END;
CREATE TRIGGER trg_products_audit_update
AFTER UPDATE ON products
FOR EACH ROW
WHEN OLD.price IS NOT NEW.price OR OLD.stock IS NOT NEW.stock
BEGIN
INSERT INTO product_audit (product_id, action, old_values, new_values)
VALUES (OLD.product_id, 'update',
json_object('price', OLD.price, 'stock', OLD.stock),
json_object('price', NEW.price, 'stock', NEW.stock));
END;
CREATE TRIGGER trg_products_audit_delete
AFTER DELETE ON products
FOR EACH ROW
BEGIN
INSERT INTO product_audit (product_id, action, old_values)
VALUES (OLD.product_id, 'delete', json_object('price', OLD.price, 'stock', OLD.stock));
END;এবার কিছু পরিবর্তন করুন — এর মধ্যে একটা, যা দাম বা স্টক কোনোটাই বদলায় না:
UPDATE products SET price = 4200 WHERE product_id = 3;
UPDATE products SET stock = stock - 1 WHERE product_id IN (1, 4);
UPDATE products SET name = 'Python Crash Course (3rd ed.)' WHERE product_id = 1;
INSERT INTO products (product_id, name, category_id, price, stock)
VALUES (12, 'Webcam', 2, 3500, 20);
DELETE FROM products WHERE product_id = 11;
SELECT audit_id, product_id, action, old_values, new_values
FROM product_audit
ORDER BY audit_id;+----------+------------+--------+-----------------------------+---------------------------+
| audit_id | product_id | action | old_values | new_values |
+----------+------------+--------+-----------------------------+---------------------------+
| 1 | 3 | update | {"price":4500,"stock":12} | {"price":4200,"stock":12} |
| 2 | 1 | update | {"price":1200,"stock":40} | {"price":1200,"stock":39} |
| 3 | 4 | update | {"price":900,"stock":60} | {"price":900,"stock":59} |
| 4 | 12 | insert | NULL | {"price":3500,"stock":20} |
| 5 | 11 | delete | {"price":1000,"stock":null} | NULL |
+----------+------------+--------+-----------------------------+---------------------------+পাঁচটা আসল পরিবর্তনের জন্য পাঁচটা সারি। নাম বদলানোয় দাম বা স্টকে হাত পড়েনি, তাই WHEN শর্ত সেটা বাদ দিয়েছে; আগের সেই ফিরিয়ে দেওয়া দাম কমানোর কোনো চিহ্ন নেই, কারণ কিছু ঘটার আগেই পাহারাদার সেটা বাতিল করেছিল। changed_at কলাম (দেখানো হয়নি, কারণ এতে বর্তমান সময় থাকে) রাখে কখন বদলেছে। SQLite-এ কোনো ইউজার নেই, তাই কে বদলেছে তা অ্যাপ্লিকেশনকে লিখতে হবে; PostgreSQL আর MySQL-এ ট্রিগার নিজেই current_user / USER() জমা রাখতে পারে — তবে সেটা ডেটাবেসের অ্যাকাউন্ট; সব রিকোয়েস্ট যদি অ্যাপের একটাই অ্যাকাউন্ট দিয়ে আসে, তাহলে সেখানে শুধু "অ্যাপ" লেখা থাকবে, তাই আসল মানুষটা কে, তা অ্যাপ্লিকেশনকেই জানাতে হবে।
ML-এর জন্য এটা অমূল্য: অ্যানোটেটররা যদি একটা labels টেবিলে লেবেল বদলান, অডিট ট্রেইল থেকে আপনি হুবহু আবার গড়ে তুলতে পারবেন, যেদিন মডেল ট্রেন হয়েছিল সেদিন ট্রেনিং ডেটা কেমন ছিল।
নির্ধারিত সময়ের ইভেন্ট
MySQL ইভেন্ট দিয়ে সময়সূচি ধরে SQL চালাতে পারে (MySQL 8-এ event scheduler ডিফল্টভাবে চালু)। এখানে প্রতি রাতের একটা জব ছোট একটা সামারি টেবিল নতুন করে বানায় — সেই "ম্যাটেরিয়ালাইজড ভিউ", যা MySQL-এ নেই:
-- MySQL
CREATE TABLE daily_sales (
sales_day DATE PRIMARY KEY,
revenue INT NOT NULL
);
CREATE EVENT refresh_daily_sales
ON SCHEDULE EVERY 1 DAY STARTS '2030-01-01 02:00:00'
DO
REPLACE INTO daily_sales (sales_day, revenue)
SELECT o.order_date, SUM(oi.quantity * oi.unit_price)
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY o.order_date;
SELECT EVENT_NAME, INTERVAL_VALUE, INTERVAL_FIELD, STARTS, STATUS
FROM information_schema.EVENTS
WHERE EVENT_SCHEMA = DATABASE();+---------------------+----------------+----------------+---------------------+---------+
| EVENT_NAME | INTERVAL_VALUE | INTERVAL_FIELD | STARTS | STATUS |
+---------------------+----------------+----------------+---------------------+---------+
| refresh_daily_sales | 1 | DAY | 2030-01-01 02:00:00 | ENABLED |
+---------------------+----------------+----------------+---------------------+---------+PostgreSQL-এ নিজস্ব কোনো শিডিউলার নেই; pg_cron এক্সটেনশন একটা যোগ করে (SELECT cron.schedule('nightly-sales', '0 2 * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY product_sales');), আর Amazon RDS ও Supabase-এর মতো ম্যানেজড সার্ভিস এটা দেয়। SQLite-এ নেই। বাস্তবে অনেক টিম ডেটাবেসের বাইরে শিডিউল করে — cron, Airflow, ক্লাউডের শিডিউলার — কারণ সেখানে জবটা চোখে পড়ে, লগ হয়, ব্যর্থ হলে আবার চেষ্টা হয়, সতর্কবার্তা আসে (দেখুন ETL ও ELT: নির্ভরযোগ্য ডেটা পাইপলাইন বানানো)।
ডেটাবেস নাকি অ্যাপ্লিকেশন?
| ডেটাবেসে রাখুন যখন… | অ্যাপ্লিকেশনে রাখুন যখন… |
|---|---|
| ডেটা যে-ই লিখুক, নিয়মটা খাটতেই হবে (কনস্ট্রেইন্ট, পাহারাদার, অডিট) | এটা ব্যবসায়িক লজিক, যা প্রায়ই বদলায় আর যার জন্য টেস্ট, কোড রিভিউ আর দ্রুত ডিপ্লয় দরকার |
| এতে বিশাল ডেটা বাইরে আনা বাঁচে (লাখ লাখ সারি অ্যাগ্রিগেট করে দশটা ফেরত) | এটা অন্য সিস্টেমকে ডাকে: API, একটা LLM, মেসেজ কিউ, ইমেইল |
| অনেক টুল একই সংজ্ঞা পড়ে (অ্যানালিস্ট আর BI-এর জন্য একটা ভিউ) | ডেটাবেস বদলাতে হতে পারে, বা টেস্টে একই কোড SQLite-এ চালাতে চান |
| এটা ছোট, স্থির, সেট-ভিত্তিক SQL | এটা লম্বা প্রসিডিউরাল কোড, যা Python-এ ডিবাগ করা সহজ |
| ফিচার | SQLite | MySQL 8 | PostgreSQL |
|---|---|---|---|
| ভিউ | হ্যাঁ (শুধু পড়া) | হ্যাঁ (সাধারণগুলো আপডেটযোগ্য) | হ্যাঁ (সাধারণগুলো আপডেটযোগ্য) |
| ম্যাটেরিয়ালাইজড ভিউ | না | না (টেবিল + ইভেন্ট দিয়ে) | হ্যাঁ |
| স্টোরড প্রসিডিওর | না | হ্যাঁ | হ্যাঁ (11+) |
| স্টোরড ফাংশন | হোস্ট ভাষা (Python) থেকে | হ্যাঁ | হ্যাঁ (SQL, PL/pgSQL, আরও) |
| ট্রিগার | হ্যাঁ (ভেতরে SQL) | হ্যাঁ | হ্যাঁ (একটা ট্রিগার ফাংশন ডাকে) |
| নির্ধারিত জব | না | ইভেন্ট | pg_cron এক্সটেনশন |
সাধারণ ভুল
- ভিউ "আছে" বলে সেটা দ্রুত হবে ভাবা। ভিউ হলো জমানো কোয়েরি, জমানো ডেটা নয়; এর গতি ঠিক এর ভেতরের কোয়েরির সমান। ধীর হলে মূল টেবিলগুলোতে ইনডেক্স দিন, অথবা ম্যাটেরিয়ালাইজড ভিউ বানান।
- ম্যাটেরিয়ালাইজড ভিউ রিফ্রেশ করতে ভুলে যাওয়া। ড্যাশবোর্ড আর ফিচার টেবিল চুপচাপ গতকালের ডেটা দেখায়। লোড জবের অংশ হিসেবেই রিফ্রেশ করুন, আর শেষবার কখন চলেছে তা লিখে রাখুন।
- লুকানো পার্শ্ব-প্রতিক্রিয়া। অন্য টেবিল আপডেট করা ট্রিগার পরের ডেভেলপারকে চমকে দেয় আর প্রতিটা লেখাকে ধীর করে। ট্রিগার ছোট রাখুন (পাহারাদার, অডিট); ডকুমেন্ট করুন;
SELECT name FROM sqlite_schema WHERE type = 'trigger'দিয়ে তালিকা দেখুন। - mysql ক্লায়েন্টের বাইরে
DELIMITERব্যবহার। PyMySQL বা মাইগ্রেশন টুল এটা সার্ভারে পাঠিয়ে দেয়, আর সার্ভার একে সিনট্যাক্স এরর বলে ফিরিয়ে দেয় (GUI এডিটরগুলো একেক রকম: কেউ এটা বোঝে, কেউ বোঝে না)। তার বদলেCREATE PROCEDUREস্টেটমেন্টটা পুরোটা একসাথে পাঠান। CREATEস্ক্রিপ্ট আবার চালানো। দ্বিতীয়বারCREATE VIEWবাCREATE PROCEDUREব্যর্থ হয়, কারণ অবজেক্টটা আগেই আছে। আগেDROP … IF EXISTSচালান, অথবাCREATE OR REPLACE VIEW(MySQL, PostgreSQL) আরCREATE VIEW IF NOT EXISTS(SQLite) লিখুন।
নিজে চেষ্টা করুন
- সহজ: SQLite-এ
product_catalogনামে একটা ভিউ বানান, যা প্রতিটা প্রোডাক্টের নাম, তার ক্যাটাগরির নাম (না থাকলে'Uncategorised') আর দাম দেখায়, তারপর সেখান থেকে সবচেয়ে দামি তিনটা প্রোডাক্ট দেখান। - মাঝারি:
order_items-এ SQLite-এর একটা BEFORE INSERT ট্রিগার যোগ করুন, যা সেই সারি ফিরিয়ে দেয় যারunit_priceপ্রোডাক্টের বর্তমান দামের চেয়ে বেশি, আর দেখান যে এটা কাজ করে। - কঠিন: MySQL-এ
customer_spend(p_customer_id INT)নামে একটা স্টোরড ফাংশন লিখুন, যা বাতিল নয় এমন অর্ডারে কাস্টমারের মোট খরচ ফেরত দেয়, আর সেটা দিয়ে প্রত্যেক কাস্টমারের খরচসহ তালিকা দেখান।
উত্তর
-- ১. সহজ
CREATE VIEW product_catalog AS
SELECT p.name AS product, COALESCE(c.name, 'Uncategorised') AS category, p.price
FROM products p
LEFT JOIN categories c ON c.category_id = p.category_id;
SELECT * FROM product_catalog ORDER BY price DESC, product LIMIT 3;-- ২. মাঝারি
CREATE TRIGGER trg_order_items_price_check
BEFORE INSERT ON order_items
FOR EACH ROW
WHEN NEW.unit_price > (SELECT price FROM products WHERE product_id = NEW.product_id)
BEGIN
SELECT RAISE(ABORT, 'unit_price above the current list price');
END;
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (13, 4, 1, 99999);Error: unit_price above the current list price-- MySQL
-- ৩. কঠিন
CREATE FUNCTION customer_spend(p_customer_id INT) RETURNS INT
READS SQL DATA
RETURN (SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.customer_id = p_customer_id AND o.status <> 'cancelled');
SELECT customer_id, name, customer_spend(customer_id) AS spend
FROM customers
ORDER BY spend DESC, customer_id;সারসংক্ষেপ
- ভিউ হলো নাম দেওয়া কোয়েরি: সবসময় হালনাগাদ, কোনো ডেটা জমায় না। PostgreSQL আর MySQL-এ সাধারণ ভিউ আপডেট করা যায়;
WITH CHECK OPTIONলেখাগুলোকে ভিউয়ের সীমার ভেতরে রাখে। - ম্যাটেরিয়ালাইজড ভিউ (PostgreSQL) ফলাফল জমা রাখে: পড়া দ্রুত, কিন্তু
REFRESH MATERIALIZED VIEWনা করা পর্যন্ত বাসি (CONCURRENTLY-র জন্য ইউনিক ইনডেক্স লাগে)। - প্রসিডিওর ডাকা হয়
CALLদিয়ে, IN/OUT প্যারামিটার নেয় আর ডেটা বদলাতে পারে; ফাংশন একটা মান ফেরত দেয় আর কোয়েরির ভেতরে ব্যবহার হয়।DELIMITERশুধু mysql ক্লায়েন্টের জন্য। - BEFORE ট্রিগার পাহারা দেয় আর মান ঠিক করে; AFTER ট্রিগার লিখে রাখে — অডিট ট্রেইল প্রতিটা পরিবর্তন ধরে রাখে, যে-ই করুক।
- ডেটাবেসের লজিক ছোট আর ডেটার শুদ্ধতা নিয়ে রাখুন; বদলাতে থাকা ব্যবসায়িক লজিক আর অন্য সিস্টেমকে ডাকা কোড রাখুন অ্যাপ্লিকেশনে।
এরপর: নিরাপত্তা: রোল, SQL ইনজেকশন, সংবেদনশীল ডেটা আর ব্যাকআপ পাতা ঠিক করে এসব কে চালাতে পারবে: ন্যূনতম অধিকারসহ ইউজার আর রোল, ভিউয়ের মাধ্যমে শুধু-পড়ার অ্যাক্সেস, SQL ইনজেকশন আর প্যারামিটার কীভাবে তা আটকায়, অ্যানালিটিক্স আর ML-এ ব্যবহৃত ব্যক্তিগত ডেটার সুরক্ষা, আর এমন ব্যাকআপ যা সত্যিই রিস্টোর করা যায়।