SQL ও PostgreSQL
SELECT, JOIN, GROUP BY থেকে subquery পর্যন্ত
AI খুব ভালো SQL লেখে। কিন্তু সেই query ঠিক data দিচ্ছে কিনা, কেন ধীর চলছে, কোথায় index লাগবে, এগুলো বুঝতে তোমাকে SQL জানতেই হবে। Backend developer এর সবচেয়ে টেকসই দক্ষতার একটা হলো SQL: ৪০ বছর ধরে চলছে, আরও অনেক বছর চলবে। এই অধ্যায়ে PostgreSQL দিয়ে SQL শিখবো, একটা food delivery এর ছোট database এর উপর।
#PostgreSQL চালু করা
সবচেয়ে সহজ উপায় Docker (Docker নিয়ে বিস্তারিত পরে, এখন শুধু command টা চালাও):
docker run -d --name pg \
-e POSTGRES_USER=app -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=food \
-p 5432:5432 postgres:17
# database এর ভেতরে ঢোকো
docker exec -it pg psql -U app -d foodDocker না চাইলে Mac এ Postgres.app install করতে পারো। GUI চাইলে TablePlus, DBeaver বা VS Code এর SQLTools extension।
#Table বানানো
CREATE TABLE restaurants (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
area TEXT NOT NULL,
rating NUMERIC(2,1) DEFAULT 0,
is_open BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id),
restaurant_id INT NOT NULL REFERENCES restaurants(id),
total INT NOT NULL CHECK (total >= 0), -- পয়সায়
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);PRIMARY KEY: প্রতিটা row এর unique পরিচয়NOT NULL: খালি রাখা যাবে নাUNIQUE: দুইজনের একই phone হবে নাREFERENCES: foreign key, অন্য table এর row কে নির্দেশ করে। না থাকা customer এর order বানানো যাবে না।CHECK: নিজের নিয়মTIMESTAMPTZ: timezone সহ সময় (সবসময় এটা ব্যবহার করো,TIMESTAMPনা)
কিছু data দাও:
INSERT INTO restaurants (name, area, rating) VALUES
('Kacchi House', 'Dhanmondi', 4.6),
('Burger Lab', 'Gulshan', 4.2),
('Pitha Ghor', 'Mirpur', 4.8),
('Noodle Bar', 'Dhanmondi', 3.9);
INSERT INTO customers (name, phone) VALUES
('Rahim', '01711111111'), ('Nusrat', '01822222222'), ('Karim', '01933333333');
INSERT INTO orders (customer_id, restaurant_id, total, status, created_at) VALUES
(1, 1, 45000, 'delivered', now() - interval '3 days'),
(1, 2, 32000, 'delivered', now() - interval '2 days'),
(2, 1, 90000, 'delivered', now() - interval '1 day'),
(2, 3, 15000, 'cancelled', now() - interval '1 day'),
(3, 1, 45000, 'pending', now()),
(1, 3, 22000, 'delivered', now());#SELECT: data পড়া
SELECT * FROM restaurants; -- সব column (production code এ * এড়িয়ো)
SELECT name, rating FROM restaurants
WHERE area = 'Dhanmondi' AND is_open
ORDER BY rating DESC
LIMIT 10;
SELECT name FROM restaurants WHERE name ILIKE '%burger%'; -- case ছাড়া খোঁজা
SELECT * FROM orders WHERE status IN ('pending', 'confirmed');
SELECT * FROM orders WHERE created_at >= now() - interval '7 days';
SELECT * FROM restaurants WHERE rating BETWEEN 4.0 AND 4.7;SQL পড়ার স্বাভাবিক ক্রম আর database চালানোর ক্রম আলাদা। এটা বুঝলে অনেক বিভ্রান্তি কেটে যায়:
#JOIN: কয়েকটা table জোড়া
Order table এ শুধু customer_id আছে, নাম নেই। নাম আনতে JOIN:
SELECT o.id, c.name AS customer, r.name AS restaurant, o.total / 100.0 AS taka, o.status
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN restaurants r ON r.id = o.restaurant_id
ORDER BY o.created_at DESC;-- যে restaurant এ এখনো কোনো order হয়নি, সেটাও দেখাও
SELECT r.name, COUNT(o.id) AS order_count
FROM restaurants r
LEFT JOIN orders o ON o.restaurant_id = r.id
GROUP BY r.id, r.name
ORDER BY order_count DESC;Noodle Bar এর order নেই। JOIN দিলে সে বাদ পড়তো, LEFT JOIN এ আসবে 0 সহ।
#GROUP BY: হিসাব
-- প্রতি restaurant এর মোট বিক্রি (শুধু delivered)
SELECT r.name,
COUNT(*) AS orders,
SUM(o.total) / 100.0 AS revenue_taka,
AVG(o.total) / 100.0 AS avg_order_taka
FROM orders o
JOIN restaurants r ON r.id = o.restaurant_id
WHERE o.status = 'delivered'
GROUP BY r.name
HAVING SUM(o.total) > 30000 -- group এর উপর শর্ত
ORDER BY revenue_taka DESC;WHERErow বাছাই করে group করার আগেHAVINGgroup বাছাই করে group করার পরে
-- দিন অনুযায়ী order
SELECT date_trunc('day', created_at) AS day, COUNT(*) AS orders
FROM orders
GROUP BY day
ORDER BY day;#Subquery আর CTE
-- গড়ের চেয়ে বড় order
SELECT id, total FROM orders
WHERE total > (SELECT AVG(total) FROM orders);
-- CTE (WITH): জটিল query কে নাম দেওয়া ধাপে ভাঙা
WITH customer_spend AS (
SELECT customer_id, SUM(total) AS spent
FROM orders
WHERE status = 'delivered'
GROUP BY customer_id
)
SELECT c.name, cs.spent / 100.0 AS taka
FROM customer_spend cs
JOIN customers c ON c.id = cs.customer_id
WHERE cs.spent > 50000;CTE হলো SQL এর "variable"। জটিল query AI লিখলে CTE দিয়ে ভাঙতে বলো, পড়া আর যাচাই করা সহজ হবে।
#Window function: প্রতিটা row এর সাথে হিসাব
-- প্রতিটা customer এর order, সাথে তার কততম order আর এ পর্যন্ত মোট
SELECT customer_id, id, total,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS nth_order,
SUM(total) OVER (PARTITION BY customer_id ORDER BY created_at) AS running_total
FROM orders
ORDER BY customer_id, created_at;
-- প্রতিটা area র সেরা restaurant
SELECT * FROM (
SELECT name, area, rating,
RANK() OVER (PARTITION BY area ORDER BY rating DESC) AS rnk
FROM restaurants
) t WHERE rnk = 1;GROUP BY row গুলোকে একটায় মিলিয়ে দেয়, window function প্রতিটা row রেখে তার পাশে হিসাব যোগ করে।
#Data বদলানো
UPDATE orders SET status = 'confirmed' WHERE id = 5;
DELETE FROM orders WHERE status = 'cancelled' AND created_at < now() - interval '1 year';
-- না থাকলে যোগ, থাকলে বদল (upsert)
INSERT INTO customers (name, phone) VALUES ('Rahim Uddin', '01711111111')
ON CONFLICT (phone) DO UPDATE SET name = EXCLUDED.name;
-- কী বদলালো সেটা ফেরত দাও
UPDATE orders SET status = 'delivered' WHERE id = 5 RETURNING id, status;#Transaction
BEGIN;
UPDATE wallets SET balance = balance - 50000 WHERE user_id = 1;
UPDATE wallets SET balance = balance + 50000 WHERE user_id = 2;
COMMIT; -- দুটো একসাথে স্থায়ী হলো। মাঝে সমস্যা হলে ROLLBACK;Transaction এর গভীর আলোচনা system design অংশে।
#SQL Injection: কখনো string জোড়া দিয়ে query না
# ❌ ভয়ানক বিপদ
phone = request_data["phone"]
cursor.execute(f"SELECT * FROM customers WHERE phone = '{phone}'")
# কেউ phone হিসেবে পাঠালো: ' OR '1'='1
# query হয়ে গেল: SELECT * FROM customers WHERE phone = '' OR '1'='1' → সবার data!
# ✓ সবসময় parameter দিয়ে
cursor.execute("SELECT * FROM customers WHERE phone = %s", (phone,))Parameter দিলে database data আর command আলাদা রাখে, user এর দেওয়া জিনিস কখনো SQL হিসেবে চলে না। SQLAlchemy (পরের অধ্যায়) এটা নিজে থেকেই করে, কিন্তু কখনো raw SQL লিখলে মনে রাখবে।
#অনুশীলন
#নিজেকে যাচাই করো
WHERE আর HAVING এর পার্থক্য কী?
WHERE row বাছাই করে group করার আগে। HAVING group বাছাই করে group করার পরে, তাই HAVING এ SUM(), COUNT() এর মতো হিসাব ব্যবহার করা যায়।
কোন restaurant এ কোনো order হয়নি, সেগুলোও list এ আনতে কোন JOIN?
restaurants LEFT JOIN orders। INNER JOIN দিলে যাদের কোনো order নেই তারা বাদ পড়বে।
Foreign key কী রক্ষা করে?
এমন row তৈরি হতে দেয় না যা অন্য table এর না থাকা row কে নির্দেশ করে, যেমন না থাকা customer এর order। Data এর সম্পর্ক সবসময় ঠিক থাকে।
SQL injection কী, আর কীভাবে আটকাবে?
User এর দেওয়া input সরাসরি SQL string এ জুড়লে সেই input SQL command হিসেবে চলতে পারে, ফলে আক্রমণকারী অন্যের data পড়তে বা মুছতে পারে। আটকানোর উপায়: সবসময় parameterized query (বা ORM) ব্যবহার করা।
GROUP BY আর window function এর মূল পার্থক্য কী?
GROUP BY অনেক row মিলিয়ে প্রতি group এ একটা row দেয়। Window function প্রতিটা row রেখে দেয় আর তার পাশে group ভিত্তিক হিসাব (rank, running total) যোগ করে।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।