Mid-term Project — Design & Build a Real Schema Milestone

মিডটার্ম প্রোজেক্ট — একটি বাস্তব schema design

Read: ~50 min Medium 5 practice problems Live SQL runner Milestone

1. The Project — Why It Matters

You have spent twenty-three modules learning the parts: tables, joins, transactions, constraints, indexes. Now you assemble them into a real, runnable e-commerce database — the kind that powers Daraz, Pickaboo, or AjkerDeal — built from blank schema to seeded data to working queries, in this single page. Every block in this module runs live in your browser. By the end, you will have shipped your first real schema.

এতদিন আপনি database-এর আলাদা আলাদা অংশ শিখেছেন — table, join, transaction, constraint, index। এই module-এ সেগুলো একসাথে জোড়া দিয়ে একটি বাস্তব e-commerce database বানাবো — Daraz, Pickaboo, AjkerDeal-এর মতো একটি schema, একদম শূন্য থেকে শুরু করে seed data + working query পর্যন্ত। সব block ব্রাউজারেই চলবে। শেষ হলে আপনি বলতে পারবেন: "আমি একটি database design করেছি"।
Project pick (নির্বাচন)
We will build an e-commerce schema together because it touches every concept (joins, many-to-many, money, audit, indexes). At the end you'll find practice problems that ask you to design library and university versions on your own.

2. Step 1 — Gather Requirements

Schema design always starts with a conversation, not CREATE TABLE. Imagine you are sitting with the founder of "DhakaCart" — a hypothetical Bangladeshi e-commerce startup. The conversation produces this list of facts:

Schema design সবসময় কথোপকথন দিয়ে শুরু হয়, কোড দিয়ে নয়। কল্পনা করুন আপনি "DhakaCart" নামের একটি startup-এর founder-এর সাথে কথা বলছেন — তাঁর কথা থেকে যে নিয়ম-গুলো বের হলো, সেগুলোই schema-র base।
  • Customers register with a phone number and an email; phone is mandatory, email optional.
  • A customer has many shipping addresses (e.g. home, office).
  • Products belong to one category. A product has a name, price, stock, and is "active" or not.
  • An order has many line items. Each line item is one product × quantity × the price at the time of purchase.
  • Order status flows: pending → paid → shipped → delivered (or cancelled).
  • Payments can be by bKash, Nagad, card, or cash-on-delivery.
  • Customers leave reviews (1–5 stars) on products they bought.

From this list you can already see most of the entities (customers, products, orders, payments, reviews) and their relationships. The next step is to draw an ER diagram.

3. Step 2 — ER & Relational Mapping

Below is the ER sketch for DhakaCart. Each box is a relation (a future CREATE TABLE); each line marks a foreign key. The "many" end is shown by the crow's foot notation.

customers id (PK) phone, email name, created_at addresses id (PK) customer_id (FK) label, line1, city categories id (PK), name products id, name, price stock, is_active category_id (FK) orders id (PK), status customer_id (FK) address_id, created_at order_items id (PK) order_id, product_id qty, unit_price payments(order_id, method, amount) reviews(prod, cust, stars) Figure 24.1 — DhakaCart ER diagram. Each line becomes a foreign key.

The mapping rules from ER → relational are familiar: each entity becomes a table, each "1-to-many" relationship becomes a foreign key on the "many" side, and any many-to-many would become a join table. We do not have a true many-to-many here — order_items already serves that role between orders and products.

4. Step 3 — Full CREATE TABLE Listing

Below is the entire DhakaCart schema. Notice the use of every constraint we learned: PRIMARY KEY, NOT NULL, UNIQUE, CHECK, DEFAULT, REFERENCES, and a thoughtfully placed ON DELETE CASCADE on the dependent tables.

নিচের CREATE TABLE set-এ আপনি দেখবেন আগের সব lesson একসাথে ব্যবহৃত হচ্ছে — PRIMARY KEY, NOT NULL, UNIQUE, CHECK, DEFAULT, FOREIGN KEY, এবং নির্বাচিত জায়গায় ON DELETE CASCADE। এটি একটি real schema-এর pattern।
01_schema.sql
PRAGMA foreign_keys = ON;

CREATE TABLE customers (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    phone      TEXT NOT NULL UNIQUE CHECK (length(phone) = 11),
    email      TEXT UNIQUE,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE addresses (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    label       TEXT NOT NULL CHECK (label IN('home','office','other')),
    line1       TEXT NOT NULL,
    city        TEXT NOT NULL
);

CREATE TABLE categories (
    id   INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE products (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT NOT NULL,
    category_id INTEGER REFERENCES categories(id),
    price       INTEGER NOT NULL CHECK (price > 0),
    stock       INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    is_active   INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    address_id  INTEGER REFERENCES addresses(id),
    status      TEXT NOT NULL DEFAULT 'pending'
                  CHECK (status IN('pending','paid','shipped','delivered','cancelled')),
    created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER NOT NULL REFERENCES products(id),
    qty        INTEGER NOT NULL CHECK (qty > 0),
    unit_price INTEGER NOT NULL CHECK (unit_price >= 0)
);

CREATE TABLE payments (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    method   TEXT NOT NULL CHECK (method IN('bkash','nagad','card','cod')),
    amount   INTEGER NOT NULL CHECK (amount > 0),
    paid_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE reviews (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id  INTEGER NOT NULL REFERENCES products(id),
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    stars       INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5),
    body        TEXT,
    created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(product_id, customer_id)  -- one review per customer per product
);

SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;

Run it — you should see eight tables come back. Note data-shared-db="dhakacart" on the block: every block on this page that uses the same key will share the same database, so the seed in the next block populates these tables.

5. Step 4 — Seed Realistic Data

Realistic data matters. Five customers, four categories, ten products, several orders, payments and reviews — enough to make every query meaningful.

ভালো query শেখার জন্য বাস্তবধর্মী ডেটা দরকার। ৫ জন customer, ৪টি category, ১০টি product, কয়েকটি order ও payment — এই বীজ ডেটা ছাড়া আপনি ফলাফলের ভেতর pattern দেখতে পাবেন না।
02_seed.sql
INSERT INTO customers(name,phone,email) VALUES
 ('Arif Hossain',    '01711111111', 'arif@example.com'),
 ('Nusrat Jahan',    '01722222222', 'nusrat@example.com'),
 ('Rahim Ahmed',     '01733333333', NULL),
 ('Sumi Akter',      '01744444444', 'sumi@example.com'),
 ('Karim Uddin',     '01755555555', NULL);

INSERT INTO addresses(customer_id,label,line1,city) VALUES
 (1,'home','House 12, Road 4','Dhaka'),
 (2,'home','Flat 3B, Mirpur 1','Dhaka'),
 (3,'home','Lalmatia C-Block','Dhaka'),
 (4,'office','Gulshan 2','Dhaka'),
 (5,'home','Agrabad','Chattogram');

INSERT INTO categories(name) VALUES
 ('Electronics'),('Books'),('Fashion'),('Groceries');

INSERT INTO products(name,category_id,price,stock) VALUES
 ('Walton Refrigerator 8cft', 1, 42000, 12),
 ('Symphony Z40',             1, 8500,  40),
 ('Padma Nadir Majhi',        2, 350,   100),
 ('Pather Panchali',          2, 280,   85),
 ('Aarong Punjabi',           3, 2200,  25),
 ('Daraz Tee',                3, 450,   200),
 ('PRAN Mango Juice 1L',      4, 120,   300),
 ('Mishti Doi 500g',          4, 180,   50),
 ('iPhone 13',                1, 95000, 5),
 ('Tagore Rachanaboli',       2, 1200,  30);

INSERT INTO orders(customer_id,address_id,status) VALUES
 (1,1,'paid'),
 (2,2,'delivered'),
 (2,2,'pending'),
 (3,3,'shipped'),
 (4,4,'paid'),
 (5,5,'cancelled');

INSERT INTO order_items(order_id,product_id,qty,unit_price) VALUES
 (1,2,1,8500),
 (1,3,2,350),
 (2,5,1,2200),
 (2,7,5,120),
 (3,9,1,95000),
 (4,1,1,42000),
 (5,4,3,280),
 (5,10,1,1200),
 (6,6,2,450);

INSERT INTO payments(order_id,method,amount) VALUES
 (1,'bkash',9200),
 (2,'cod',2800),
 (4,'card',42000),
 (5,'nagad',2040);

INSERT INTO reviews(product_id,customer_id,stars,body) VALUES
 (2,1,5,'Excellent phone for the price'),
 (5,2,4,'Nice fabric'),
 (3,1,5,'A timeless classic'),
 (9,2,5,'Worth every taka'),
 (7,2,3,'OK');

SELECT (SELECT COUNT(*) FROM customers) AS cust,
       (SELECT COUNT(*) FROM products)  AS prod,
       (SELECT COUNT(*) FROM orders)    AS ord,
       (SELECT COUNT(*) FROM order_items) AS items;

6. Step 5 — Eighteen Useful Queries

What follows is a tour of the kinds of queries a real business actually runs. Each one is short and runs against the seeded data in the same in-page database. Read each, predict the output, then run it.

নিচের ১৮টি query আসল business-এ যা চালানো হয় ঠিক সেরকম। প্রতিটি query পড়ুন, output ভেবে নিন, তারপর Run করে মিলিয়ে নিন।
Q1 — every customer with their phone
SELECT id, name, phone FROM customers ORDER BY id;
Q2 — products in the Books category
SELECT p.id, p.name, p.price
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE c.name = 'Books'
ORDER BY p.price DESC;
Q3 — order total = sum(qty × unit_price)
SELECT o.id AS order_id, SUM(oi.qty * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id
ORDER BY total DESC;
Q4 — top 3 products by revenue
SELECT p.name, SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
GROUP BY p.id
ORDER BY revenue DESC
LIMIT 3;
Q5 — customer lifetime value
SELECT c.name,
       COALESCE(SUM(oi.qty * oi.unit_price), 0) AS lifetime_value
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled'
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id
ORDER BY lifetime_value DESC;
Q6 — orders with no payment yet
SELECT o.id, o.status, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN payments p ON p.order_id = o.id
WHERE p.id IS NULL;
Q7 — average rating per product
SELECT p.name, ROUND(AVG(r.stars), 2) AS avg_stars, COUNT(*) AS n
FROM reviews r
JOIN products p ON p.id = r.product_id
GROUP BY p.id
ORDER BY avg_stars DESC;
Q8 — customers who never reviewed
SELECT c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM reviews r WHERE r.customer_id = c.id);
Q9 — running revenue by order (window fn)
SELECT o.id,
       SUM(oi.qty * oi.unit_price) AS order_total,
       SUM(SUM(oi.qty * oi.unit_price)) OVER (ORDER BY o.id) AS running_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
Q10 — rank products within each category by price
SELECT c.name AS category, p.name, p.price,
       RANK() OVER (PARTITION BY p.category_id ORDER BY p.price DESC) AS rk
FROM products p JOIN categories c ON c.id = p.category_id;
Q11 — payment methods breakdown
SELECT method, COUNT(*) AS n, SUM(amount) AS total
FROM payments
GROUP BY method
ORDER BY total DESC;
Q12 — low-stock alert
SELECT name, stock
FROM products
WHERE stock < 15 AND is_active = 1
ORDER BY stock;
Q13 — orders by city
SELECT a.city, COUNT(*) AS orders
FROM orders o JOIN addresses a ON a.id = o.address_id
GROUP BY a.city;
Q14 — repeat customers (≥2 orders)
SELECT c.name, COUNT(o.id) AS orders
FROM customers c JOIN orders o ON o.customer_id = c.id
GROUP BY c.id
HAVING COUNT(o.id) >= 2;
Q15 — products never ordered
SELECT p.name
FROM products p
WHERE p.id NOT IN (SELECT DISTINCT product_id FROM order_items);
Q16 — best-rated category
SELECT c.name, ROUND(AVG(r.stars), 2) AS avg_stars
FROM reviews r
JOIN products p ON p.id = r.product_id
JOIN categories c ON c.id = p.category_id
GROUP BY c.id
ORDER BY avg_stars DESC;
Q17 — daily orders count
SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS n
FROM orders
GROUP BY day
ORDER BY day;
Q18 — full invoice for order #1
SELECT p.name, oi.qty, oi.unit_price,
       (oi.qty * oi.unit_price) AS line_total
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = 1;

7. Step 6 — Recommended Indexes

Looking at the queries above, the hot paths emerge: filter by customer_id on orders, by order_id on order_items, by product_id on reviews, by status on orders. Below is the index set we would ship to production.

উপরের query-গুলো বিশ্লেষণ করলে দেখা যায় কোন কোন কলাম প্রায়শই filter বা JOIN-এ ব্যবহৃত হয়। সেগুলোর জন্যই production-এ index দরকার।
07_indexes.sql
CREATE INDEX idx_orders_cust    ON orders(customer_id, created_at DESC);
CREATE INDEX idx_orders_status  ON orders(status) WHERE status = 'pending';
CREATE INDEX idx_oi_order       ON order_items(order_id);
CREATE INDEX idx_oi_product     ON order_items(product_id);
CREATE INDEX idx_reviews_prod   ON reviews(product_id);
CREATE INDEX idx_payments_order ON payments(order_id);
CREATE INDEX idx_products_cat   ON products(category_id);

SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%';

Re-run any of the eighteen queries with EXPLAIN QUERY PLAN in front and you will see SEARCH … USING INDEX instead of SCAN.

8. Practice — Your Turn

These problems extend the DhakaCart schema or ask you to design a similar one. Most answers are runnable against the seeded database from above.

এখান থেকে আপনার নিজের কাজ শুরু। নিচের প্রতিটি সমস্যা upper-এর schema-কে আরও বাড়াবে অথবা একই ধাঁচে নতুন schema বানাতে বলবে। উত্তরগুলো লাইভ চলে।
  1. Find the customer who has spent the most money (excluding cancelled orders).
    সবচেয়ে বেশি ব্যয় করা customer-কে খুঁজুন (cancelled order বাদে)।
    ✨ Show Answer
    ans1.sql
    SELECT c.name, SUM(oi.qty*oi.unit_price) AS spent
    FROM customers c
    JOIN orders o ON o.customer_id=c.id AND o.status<>'cancelled'
    JOIN order_items oi ON oi.order_id=o.id
    GROUP BY c.id
    ORDER BY spent DESC LIMIT 1;
  2. For each category, find the most expensive product.
    প্রতিটি category-তে সবচেয়ে দামি product খুঁজুন।
    ✨ Show Answer
    ans2.sql
    WITH ranked AS (
      SELECT p.*, RANK() OVER(PARTITION BY category_id ORDER BY price DESC) AS rk
      FROM products p
    )
    SELECT c.name AS category, r.name, r.price
    FROM ranked r JOIN categories c ON c.id=r.category_id
    WHERE r.rk = 1;
  3. List the products that have been reviewed by customers who never bought them (a possible fraud signal).
    এমন review খুঁজুন যেখানে customer সেই product কেনেননি — fraud-signal হতে পারে।
    ✨ Show Answer
    ans3.sql
    SELECT r.id, p.name AS product, c.name AS customer, r.stars
    FROM reviews r
    JOIN products p ON p.id=r.product_id
    JOIN customers c ON c.id=r.customer_id
    WHERE NOT EXISTS (
      SELECT 1 FROM orders o
      JOIN order_items oi ON oi.order_id=o.id
      WHERE o.customer_id = r.customer_id AND oi.product_id = r.product_id
    );
  4. Sketch (in your head — write it out) the same DhakaCart schema for a library: members, books, copies, loans. List the tables, key columns, and which constraints you'd add.
    একই pattern-এ একটি library-এর schema design করুন (members, books, copies, loans) — table, key, constraint-এর তালিকা লিখুন।
    ✨ Show Answer

    Answer:

    • members(id PK, name, phone NN UNIQUE, joined_at)
    • books(id PK, title NN, author NN, isbn UNIQUE)
    • copies(id PK, book_id FK, status CHECK in('available','loaned','lost'))
    • loans(id PK, copy_id FK, member_id FK, loaned_at, due_at, returned_at NULL)
    • Indexes: (member_id, returned_at) on loans for "active loans of a member"; (book_id, status) on copies; partial index on loans WHERE returned_at IS NULL.

    প্রতিটি member একাধিক loan নিতে পারে; প্রতিটি book-এর একাধিক copy থাকে; loan-এর active row হলো যেগুলোর returned_at NULL — সেগুলোর জন্য partial index।

  5. Add a "soft delete" pattern: a deleted_at column on products, plus a partial index that ignores deleted rows. Show that SELECT … WHERE deleted_at IS NULL uses the index.
    products-এ soft-delete column যোগ করুন এবং deleted_at IS NULL-এর জন্য partial index ব্যবহার দেখান।
    ✨ Show Answer
    ans5.sql
    ALTER TABLE products ADD COLUMN deleted_at TEXT;
    CREATE INDEX idx_products_active ON products(name) WHERE deleted_at IS NULL;
    
    EXPLAIN QUERY PLAN
    SELECT name FROM products WHERE deleted_at IS NULL AND name LIKE 'Walton%';

Summary — Module 24 (Milestone)

You designed a real e-commerce schema from a conversation, mapped it to relational tables, wrote every constraint that mattered, seeded believable Bangladeshi data, exercised it with eighteen real-world queries, and finished with a production-grade index set. Every concept from Modules 1–23 came together on this page. This is what database engineering looks like in practice.

আপনি একটি সত্যিকারের e-commerce schema design করেছেন — কথোপকথন থেকে শুরু করে ER, CREATE TABLE, seed data, ১৮টি বাস্তব query এবং production-grade index পর্যন্ত। Module ১ থেকে ২৩ পর্যন্ত শেখা সব কিছু এই পেজে একসাথে ব্যবহার হলো। এটাই বাস্তব database engineering।

Next Module → Functional Dependencies & Anomalies — schema কেন ভাঙে, তার গণিত।