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 টা চালাও):

Terminal
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 food

Docker না চাইলে Mac এ Postgres.app install করতে পারো। GUI চাইলে TablePlus, DBeaver বা VS Code এর SQLTools extension।

#Table বানানো

SQL
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 দাও:

SQL
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 পড়া

SQL
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 চালানোর ক্রম আলাদা। এটা বুঝলে অনেক বিভ্রান্তি কেটে যায়:

FROM/JOIN WHERE GROUP BY HAVING SELECT ORDER LIMIT এজন্যই WHERE এ SELECT এর alias ব্যবহার করা যায় না, কিন্তু ORDER BY তে যায়
আগে কোন table থেকে, তারপর কোন row, তারপর group, তারপর কোন column, শেষে সাজানো আর সীমা।

#JOIN: কয়েকটা table জোড়া

Order table এ শুধু customer_id আছে, নাম নেই। নাম আনতে JOIN:

SQL
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;
restaurants orders INNER JOIN: শুধু মাঝের অংশ restaurants orders LEFT JOIN: বামের সব
INNER JOIN দেয় যেগুলোর দুই দিকেই মিল আছে। LEFT JOIN দেয় বামের সব, ডানে মিল না থাকলে NULL।
SQL
-- যে 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: হিসাব

SQL
-- প্রতি 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;
  • WHERE row বাছাই করে group করার আগে
  • HAVING group বাছাই করে group করার পরে
SQL
-- দিন অনুযায়ী order
SELECT date_trunc('day', created_at) AS day, COUNT(*) AS orders
FROM orders
GROUP BY day
ORDER BY day;

#Subquery আর CTE

SQL
-- গড়ের চেয়ে বড় 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 এর সাথে হিসাব

SQL
-- প্রতিটা 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 বদলানো

SQL
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

SQL
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 না

Python
# ❌ ভয়ানক বিপদ
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 ভরে যাবে।