Final Project

Reviewed & published by Brayan K

This is your capstone. You'll build BookNook — a small but complete bookstore database — from an empty editor to indexed, transactional, report-ready SQL. Every concept from the course shows up here, assembled into one real system you design and query yourself.

Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Build

The Plan: 6 Milestones

You're building a bookstore. Customers place orders; each order contains one or more books (the line items). Here's the path from empty database to finished system:

Milestone 1 — Design the Schema

A good schema is the foundation everything else stands on. You'll create four tables. A primary key (PK) is the column that uniquely identifies a row; a foreign key (FK) is a column that points at another table's PK, which is how the database knows an order belongs to a customer.

Think of order_items as the receipt lines. The order is the receipt; each line says "1 copy of Dune". The line links the receipt (order_id) to the book (product_id) — exactly what a foreign key does.

Notice the guardrails: NOT NULL forbids blanks, UNIQUE stops duplicate emails, CHECK rejects nonsense like a negative price, and REFERENCES enforces that every order points at a real customer.

-- MILESTONE 1 — Build the BookNook bookstore schema
-- Four tables, linked by primary keys (PK) and foreign keys (FK).

-- customers: one row per shopper. The PK uniquely identifies each row.
CREATE TABLE customers (
    customer_id   INTEGER PRIMARY KEY,          -- PK: unique id for each customer
    full_name     TEXT    NOT NULL,             -- NOT NULL = required, no blanks
    email         TEXT    NOT NULL UNIQUE,       -- UNIQUE = no two customers share one
    joined_on     DATE    NOT NULL
);

-- products: the books you sell. category groups them for reporting.
CREATE TABLE products (
    product_id    INTEGER PRIMARY KEY,
    title         TEXT    NOT NULL,
    category      TEXT    NOT NULL,
    price         REAL    NOT NULL CHECK (price >= 0),  -- CHECK rejects bad data
    stock         INTEGER NOT NULL DEFAULT 0            -- DEFAULT fills it in for you
);

-- orders: one row per order. customer_id is a FK back to customers.
CREATE TABLE orders (
    order_id      INTEGER PRIMARY KEY,
    customer_id   INTEGER NOT NULL REFERENCES customers(customer_id),  -- FK
    ordered_on    DATE    NOT NULL,
    status        TEXT    NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending','shipped','delivered'))   -- only these 3
);

-- order_items: the line items. This is the JOIN table linking orders to products.
CREATE TABLE order_items (
    order_id      INTEGER NOT NULL REFERENCES orders(order_id),
    product_id    INTEGER NOT NULL REFERENCES products(product_id),
    quantity      INTEGER NOT NULL CHECK (quantity > 0),
    PRIMARY KEY (order_id, product_id)           -- a composite PK: one row per book per order
);

-- ✅ Expected result: 4 tables created, no rows yet. Seed them in Milestone 2.

Milestone 2 — Seed Sample Data

Empty tables can't be queried meaningfully, so add rows. The one rule that trips everyone up: insert parents before children. A foreign key can only point at a row that already exists, so customers and products must exist before the orders that reference them, and orders before their items.

-- MILESTONE 2 — Insert sample rows so we have something to query.
-- Insert PARENTS before CHILDREN: customers + products first, then orders,
-- then order_items (a FK can only point at a row that already exists).

INSERT INTO customers (customer_id, full_name, email, joined_on) VALUES
    (1, 'Ada Lovelace',   '[email protected]',   '2025-01-10'),
    (2, 'Alan Turing',    '[email protected]',  '2025-02-02'),
    (3, 'Grace Hopper',   '[email protected]', '2025-02-20');

INSERT INTO products (product_id, title, category, price, stock) VALUES
    (10, 'The SQL Cookbook',     'Tech',    39.00, 120),
    (11, 'Clean Code',          'Tech',    32.50, 60),
    (12, 'Dune',                'SciFi',   18.00, 200),
    (13, 'Project Hail Mary',   'SciFi',   22.00, 75),
    (14, 'The Pragmatic Coder', 'Tech',    41.00, 40);

INSERT INTO orders (order_id, customer_id, ordered_on, status) VALUES
    (100, 1, '2025-03-01', 'delivered'),
    (101, 1, '2025-03-15', 'shipped'),
    (102, 2, '2025-03-18', 'delivered'),
    (103, 3, '2025-04-02', 'pending');

INSERT INTO order_items (order_id, product_id, quantity) VALUES
    (100, 10, 1),
    (100, 12, 2),
    (101, 11, 1),
    (102, 13, 3),
    (102, 10, 1),
    (103, 14, 1);

-- ✅ Expected result: 3 customers, 5 products, 4 orders, 6 order items.

Milestone 3 — Core Queries

Now the payoff: answering real business questions. Both reports below JOIN tables back together and use an aggregate (SUM, COUNT) with GROUP BY to collapse many rows into one summary row per group.

-- MILESTONE 3a — Revenue per category
-- JOIN three tables, multiply price x quantity, then total it per category.

SELECT
    p.category,
    SUM(p.price * oi.quantity)  AS revenue   -- money earned per book line
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id   -- attach the book's price + category
GROUP BY p.category                               -- one output row per category
ORDER BY revenue DESC;

-- ✅ Expected result:
-- category | revenue
-- Tech | 151.5
-- SciFi | 102
-- MILESTONE 3b — Top customers by amount spent
-- Walk customers → orders → order_items → products to add up each spend.

SELECT
    c.full_name,
    COUNT(DISTINCT o.order_id)   AS orders,      -- how many orders they placed
    SUM(p.price * oi.quantity)   AS total_spent  -- their lifetime spend
FROM customers c
JOIN orders       o  ON o.customer_id = c.customer_id
JOIN order_items  oi ON oi.order_id   = o.order_id
JOIN products     p  ON p.product_id  = oi.product_id
GROUP BY c.customer_id, c.full_name
ORDER BY total_spent DESC;

-- ✅ Expected result:
-- full_name | orders | total_spent
-- Ada Lovelace | 2 | 107.5
-- Alan Turing | 1 | 105
-- Grace Hopper | 1 | 41

Your Turn #1: average per customer

The joins are written for you. Fill in the two ___ blanks: the aggregate that means "average", and the column to group by. The expected shape is in the comments.

-- 🎯 YOUR TURN #1 — average order value per customer
-- Goal: for each customer show their name and the AVERAGE money per order.
-- The joins are done for you. Fill in the two blanks.

SELECT
    c.full_name,
    ___(p.price * oi.quantity)   AS avg_per_line   -- 👉 the aggregate that means "average"
FROM customers c
JOIN orders       o  ON o.customer_id = c.customer_id
JOIN order_items  oi ON oi.order_id   = o.order_id
JOIN products     p  ON p.product_id  = oi.product_id
GROUP BY c.customer_id, ___;                        -- 👉 group by the same name column you selected

-- ✅ Expected result: 3 rows, one per customer, each with an avg_per_line value.
--    (Hint: the missing aggregate is AVG.)

Milestone 4 — Add Indexes for the Hot Queries

As data grows, the queries you run most often need to be fast. An index is a sorted lookup structure — like the index at the back of a book — that lets the database jump straight to matching rows instead of scanning every one. Index the columns you filter on (WHERE) and join on (ON).

EXPLAIN (or EXPLAIN QUERY PLAN in SQLite) shows you the database's plan for a query without running it. Seeing "USING INDEX" instead of "SCAN" tells you the index is doing its job.

-- MILESTONE 4 — Speed up the hot queries with indexes.
-- An index is a lookup structure (like a book's index) the database can scan
-- instead of reading every row. Index the columns you FILTER or JOIN on.

-- We JOIN order_items to orders on order_id and to products on product_id a LOT:
CREATE INDEX idx_items_order   ON order_items(order_id);
CREATE INDEX idx_items_product ON order_items(product_id);

-- We frequently look up orders by customer and filter by status:
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status   ON orders(status);

-- Did it help? Ask the planner with EXPLAIN — it shows HOW the query runs
-- without changing your data. Look for an index scan instead of a full scan:
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 1;

-- ✅ Expected: the plan now mentions "USING INDEX idx_orders_customer"
--    instead of "SCAN orders" (a slow full-table read).
--
-- Read the LAST column, "detail" — that is the part SQLite documents and
-- the part this lesson is about. The third column is literally called
-- "notused": it is internal scratch space, and different SQLite builds
-- put different numbers in it. Pressing Run here shows one value; the
-- sqlite3 command line on your machine may show another. Neither is
-- wrong, so this block claims no exact row.

Milestone 5 — A View and a Transaction

A view saves a SELECT under a name so you can reuse a complex report as if it were a simple table — it stores no data, just the query. A transaction bundles several writes so they either all succeed or all get undone; that all-or-nothing property is called atomicity.

Placing an order touches three tables (create the order, add the items, lower the stock). Without a transaction, a crash halfway through leaves you with an order that sold phantom stock. BEGIN … COMMIT makes those three changes one indivisible unit; ROLLBACK throws them all away.

-- MILESTONE 5a — A VIEW: save a query under a name and reuse it.
-- A view is a stored SELECT. It holds no data of its own — it re-runs each
-- time you query it, so it's always up to date.

CREATE VIEW order_totals AS
SELECT
    o.order_id,
    o.customer_id,
    o.status,
    SUM(p.price * oi.quantity)  AS order_total
FROM orders o
JOIN order_items oi ON oi.order_id   = o.order_id
JOIN products    p  ON p.product_id  = oi.product_id
GROUP BY o.order_id, o.customer_id, o.status;

-- Now query it like any table:
SELECT * FROM order_totals WHERE status = 'delivered';

-- ✅ Expected result: order 100 and order 102 with their totals.
-- MILESTONE 5b — A TRANSACTION: place an order atomically.
-- A transaction groups statements so they ALL succeed or ALL roll back.
-- "Atomic" = no half-finished state. Perfect for "take stock + record sale".

BEGIN TRANSACTION;

-- 1) Create the order header
INSERT INTO orders (order_id, customer_id, ordered_on, status)
VALUES (104, 3, '2025-04-10', 'pending');

-- 2) Add a line item: 2 copies of "Clean Code" (product 11)
INSERT INTO order_items (order_id, product_id, quantity)
VALUES (104, 11, 2);

-- 3) Decrement stock to match the sale
UPDATE products SET stock = stock - 2 WHERE product_id = 11;

COMMIT;   -- make all three changes permanent together.
-- If any step had failed, you'd run ROLLBACK and the order never happened.

-- ✅ Expected result: order 104 exists, has 1 line item, and "Clean Code"
--    stock dropped from 60 to 58 — all or nothing.

Your Turn #2: undo with ROLLBACK

Fill in the two ___ blanks so the bad insert is thrown away and order 999 never exists. One blank starts the transaction, the other cancels it.

-- 🎯 YOUR TURN #2 — undo a mistake with ROLLBACK
-- You start a transaction, realise the quantity is wrong, and want to abandon it.
-- Fill in the two blanks so NOTHING is saved.

___;   -- 👉 the keyword that STARTS a transaction (two words)

INSERT INTO orders (order_id, customer_id, ordered_on, status)
VALUES (999, 1, '2025-04-11', 'pending');

-- Oops — wrong order. Throw the whole thing away:
___;   -- 👉 the keyword that CANCELS everything since BEGIN

-- ✅ Expected result: order 999 does NOT exist afterwards.
--    SELECT * FROM orders WHERE order_id = 999;  -- returns 0 rows.

Milestone 6 — Advanced Touch: Best-Sellers per Category

Time to flex. A CTE (the WITH block) names an intermediate result so the final query reads cleanly. A window function like RANK() OVER (...) computes a value across a set of rows without collapsing them the way GROUP BY does — so you keep every book and get its rank.

PARTITION BY category restarts the ranking for each category, so every category gets its own #1, #2, #3 — exactly what a "best-sellers by section" report needs.

-- MILESTONE 6 — Advanced touch: rank best-sellers WITHIN each category.
-- A CTE (the WITH block) names a sub-result. A WINDOW FUNCTION (RANK() OVER...)
-- computes a value across a group of rows WITHOUT collapsing them with GROUP BY.

WITH sales AS (                                  -- CTE: revenue per book
    SELECT
        p.category,
        p.title,
        SUM(p.price * oi.quantity) AS revenue
    FROM order_items oi
    JOIN products p ON p.product_id = oi.product_id
    GROUP BY p.category, p.title
)
SELECT
    category,
    title,
    revenue,
    RANK() OVER (                                -- 1, 2, 3... restarting per category
        PARTITION BY category
        ORDER BY revenue DESC
    ) AS rank_in_category
FROM sales
ORDER BY category, rank_in_category;

-- ✅ Expected result: each category's books numbered 1, 2, 3...
--    The #1 row in each category is its best-seller by revenue.

Common Pitfalls (and the fix)

Frequently Asked Questions

Q: Why store line items in a separate order_items table?

Because an order can contain many books, and a book can appear in many orders — a many-to-many relationship. The line-items table is the bridge that makes that possible cleanly.

Q: Do I need indexes on such a tiny database?

Not for speed — with a handful of rows a full scan is instant. You add them here to learn the workflow; on a table with millions of rows they're the difference between milliseconds and minutes.

Q: Is a view slower than a real table?

A plain view re-runs its query each time, so it's exactly as fast as that query. If you need to cache the result, that's a materialised view — a different tool.

Q: What happens if a statement fails mid-transaction?

Nothing is saved until COMMIT. You run ROLLBACK (or the session ends) and the database returns to exactly how it was before BEGIN.

Q: My SQL playground rejected SERIAL or JSONB — why?

Those are PostgreSQL-specific. This project uses portable SQLite-friendly types (INTEGER, TEXT, REAL) so it runs nearly anywhere. Swap dialects if you target a specific engine.

🎯 Stretch Challenge: Add Product Reviews

No answer this time — just a brief and a comment outline. Extend BookNook with a reviews feature, wire its foreign keys, and write an aggregate report. This is the faded, build-it-yourself rung. Sketch it here, then run it in a playground.

-- 🎯 STRETCH CHALLENGE — extend BookNook with product reviews.
-- No answer given. Design it yourself using everything above.
--
-- 1) Create a "reviews" table:
--      review_id   INTEGER PRIMARY KEY
--      product_id  INTEGER  -> FK to products(product_id)
--      customer_id INTEGER  -> FK to customers(customer_id)
--      rating      INTEGER  -> CHECK it's between 1 and 5
--      comment     TEXT
--      created_on  DATE NOT NULL
--
-- 2) Seed 4–5 reviews across a couple of books.
--
-- 3) Write a query: each product's title, its AVG(rating) rounded to 1 decimal,
--    and COUNT(*) of reviews. Use a LEFT JOIN so books with NO reviews still show.
--
-- 4) BONUS: a view "top_rated" listing only products with avg rating >= 4.
--    Use HAVING to filter on the aggregate.
--
-- ✅ Expected: every product appears; unreviewed books show NULL/0;
--    "top_rated" lists only the crowd favourites.

-- your schema + queries here

🎉 Project Complete

Practice quiz

What does a PRIMARY KEY do?

  • Points at another table's key
  • Allows duplicate values
  • Uniquely identifies each row in a table
  • Stores the row's timestamp

Answer: Uniquely identifies each row in a table. The PK is the column that uniquely identifies a row, like customer_id in customers.

What is a foreign key (FK)?

  • A column that points at another table's primary key
  • A column that must be unique
  • An index on a text column
  • A computed column

Answer: A column that points at another table's primary key. An FK links tables: orders.customer_id REFERENCES customers(customer_id).

Why must you insert parents before children when seeding data?

  • Children are alphabetically first
  • The database sorts inserts randomly
  • Parents have no constraints
  • A foreign key can only point at a row that already exists

Answer: A foreign key can only point at a row that already exists. Insert customers and products before orders, and orders before order_items, to satisfy the FKs.

What does the CHECK (price >= 0) constraint do?

  • Sets a default price of 0
  • Rejects any row whose price is negative
  • Indexes the price column
  • Makes price required

Answer: Rejects any row whose price is negative. CHECK rejects bad data; here it forbids a negative price.

What does the composite PRIMARY KEY (order_id, product_id) on order_items guarantee?

  • One row per book per order (no duplicate pairings)
  • Orders can have only one product
  • Products can appear in one order only
  • Quantities must be unique

Answer: One row per book per order (no duplicate pairings). A composite PK of both keys means each book appears once per order.

In Milestone 3, why route revenue through the order_items table?

  • order_items stores the prices
  • It is the only indexed table
  • Joining orders straight to products without it multiplies rows and breaks totals
  • Products have no category

Answer: Joining orders straight to products without it multiplies rows and breaks totals. The line-items table is the bridge; skipping it fans out rows and produces wrong sums.

What does CREATE INDEX on a join/filter column achieve?

  • Stores a copy of the whole table
  • Lets the database jump to matching rows instead of scanning every one
  • Encrypts the column
  • Makes writes faster

Answer: Lets the database jump to matching rows instead of scanning every one. Index the columns you filter (WHERE) and join (ON); reads speed up, writes pay a small cost.

What is a VIEW?

  • A cached copy of query results
  • A second physical table
  • An index on multiple columns
  • A stored SELECT that holds no data and re-runs each time you query it

Answer: A stored SELECT that holds no data and re-runs each time you query it. A plain view stores only the query, so it is always up to date when queried.

What property does a transaction (BEGIN ... COMMIT) provide?

  • Faster individual inserts
  • Atomicity: all statements succeed together or all roll back
  • Automatic indexing
  • Encryption of the rows

Answer: Atomicity: all statements succeed together or all roll back. A transaction is all-or-nothing; ROLLBACK throws away everything since BEGIN.

What does RANK() OVER (PARTITION BY category ORDER BY revenue DESC) do?

  • Sums revenue per category
  • Deletes lower-ranked rows
  • Numbers rows 1, 2, 3 within each category without collapsing them
  • Groups every category into one row

Answer: Numbers rows 1, 2, 3 within each category without collapsing them. A window function ranks within each partition while keeping every row, unlike GROUP BY.

Continue this course

Related lessons