Practical Project: Building a Small Shop Database

Welcome back.
In the previous lesson, you learned about indexes and basic performance.
You learned:
- why queries can become slow;
- what indexes are;
- how
EXPLAIN ANALYZEhelps you understand queries; - why indexes are useful;
- why indexes are not free;
- when indexes can help;
- when indexes are just expensive decorations.
Very good.
Now it is time to put many pieces together.
Today we will build a small practical PostgreSQL project.
Not a fake one-table example.
Not a mysterious database with names like table1 and data2.
No.
Today we will create a small shop database.
It will have:
customers
categories
products
orders
order_items
This is close to real database design.
Small enough to understand.
Real enough to be useful.
Like a training sword.
But for SQL.
Less dangerous.
Usually.
What You Will Learn
In this lesson, you will learn how to:
- design a small relational database;
- create multiple related tables;
- use primary keys;
- use foreign keys;
- insert realistic data;
- read data with
JOIN; - calculate order totals;
- count orders per customer;
- calculate total spending per customer;
- calculate sales by category;
- use aggregate functions in a practical project;
- create useful indexes;
- think like a database designer.
By the end of this lesson, you will have a working mini database project.
Not just random SQL commands.
A real structure.
A little database machine.
With customers.
Products.
Orders.
And enough relationships to make PostgreSQL feel important.
Project Idea
We are building a small shop database.
The shop sells products.
Each product belongs to a category.
Customers can create orders.
Each order can contain many products.
This means we need several tables.
The structure will be:
categories
products
customers
orders
order_items
Relationships:
categories -> products
customers -> orders
orders -> order_items
products -> order_items
In simple words:
One category has many products.
One customer has many orders.
One order has many order items.
One product can appear in many order items.
This is where relational databases become useful.
Instead of storing everything in one giant messy table, we separate data into clean tables.
Because one giant table is easy at the beginning.
And terrible later.
Like putting all your tools, socks, documents, passwords, and pasta in one drawer.
Possible.
But not wise.
Prepare the Database
Open PostgreSQL:
sudo -iu postgres psql
Connect to your database:
\c learning_postgresql
If the database does not exist, create it:
CREATE DATABASE learning_postgresql;
Then connect:
\c learning_postgresql
Now remove old tables if they exist.
The order is important because some tables depend on others.
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS categories;
We drop order_items first because it depends on orders and products.
PostgreSQL protects relationships.
Like a strict librarian.
But with more semicolons.
Create the Categories Table
A category groups products.
Examples:
Electronics
Furniture
Office
Kitchen
Create the table:
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
Important details:
idis the primary key;nameis required;namemust be unique.
We do not want two categories called Electronics.
One Electronics is enough.
Even for PostgreSQL.
Create the Products Table
Each product belongs to one category.
Create the table:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
stock_quantity INTEGER NOT NULL CHECK (stock_quantity >= 0),
category_id INTEGER NOT NULL REFERENCES categories(id)
);
This table has:
- product name;
- product price;
- quantity in stock;
- category relationship.
The important relationship is:
category_id INTEGER NOT NULL REFERENCES categories(id)
This means:
Each product must belong to an existing category.
PostgreSQL will not allow a product to point to a category that does not exist.
Good.
Because imaginary categories are not great for reports.
Or sanity.
Create the Customers Table
Customers place orders.
Create the table:
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Important details:
emailis unique;nameis required;created_atis automatically filled.
In real applications, email is often used for login or communication.
So duplicate emails are usually a bad idea.
Two Annas can exist.
Two identical emails should not.
PostgreSQL agrees.
Create the Orders Table
An order belongs to one customer.
Create the table:
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status VARCHAR(50) NOT NULL DEFAULT 'new',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
This table stores the order itself.
But notice something.
There is no total column.
Why?
Because the total can be calculated from order_items.
In many systems, totals are stored too for performance and history.
But for learning, calculating totals is better.
It teaches you how the data works.
And PostgreSQL likes teaching.
In a strict way.
Create the Order Items Table
An order can contain many products.
For example:
Order 1:
- Laptop x 1
- Mouse x 2
This is why we need order_items.
Create the table:
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL CHECK (unit_price >= 0)
);
Important details:
order_idconnects toorders;product_idconnects toproducts;quantitymust be greater than zero;unit_pricestores the product price at the moment of the order.
Why store unit_price here?
Because product prices can change later.
Imagine a product costs 25 today.
Tomorrow it costs 30.
Old orders should still remember the old price.
Otherwise your reports become a historical crime scene.
So order_items.unit_price stores the price used in that order.
Very practical.
Very real.
Insert Categories
Add categories:
INSERT INTO categories (name)
VALUES
('Electronics'),
('Furniture'),
('Office'),
('Kitchen');
Check them:
SELECT * FROM categories;
You should see four categories.
Small beginning.
Database civilization starts here.
Insert Products
Add products:
INSERT INTO products (name, price, stock_quantity, category_id)
VALUES
('Laptop', 900.00, 5, 1),
('Mouse', 25.00, 30, 1),
('Keyboard', 70.00, 15, 1),
('Desk Chair', 150.00, 8, 2),
('Bookshelf', 120.00, 4, 2),
('Notebook', 5.00, 100, 3),
('Pen', 2.00, 200, 3),
('Whiteboard', 80.00, 3, 3),
('Coffee Mug', 12.00, 40, 4),
('Kitchen Knife', 35.00, 20, 4);
Check products:
SELECT * FROM products;
Now we have products connected to categories.
But we still see category_id.
Humans prefer names.
So soon we will use JOIN.
Because mysterious IDs are not a user interface.
They are a cry for help.
Insert Customers
Add customers:
INSERT INTO customers (email, name)
VALUES
('anna@example.com', 'Anna'),
('marco@example.com', 'Marco'),
('sofia@example.com', 'Sofia'),
('luca@example.com', 'Luca');
Check customers:
SELECT * FROM customers;
Now we have people who can buy things.
Very dangerous.
For wallets.
Insert Orders
Create some orders:
INSERT INTO orders (customer_id, status)
VALUES
(1, 'paid'),
(1, 'paid'),
(2, 'paid'),
(3, 'new'),
(4, 'cancelled');
This means:
Anna has two orders.
Marco has one order.
Sofia has one new order.
Luca has one cancelled order.
Check orders:
SELECT * FROM orders;
The orders exist.
But they are empty.
An order without items is like a pizza box without pizza.
Technically a box.
Emotionally disappointing.
Insert Order Items
Now add items to orders:
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES
(1, 1, 1, 900.00),
(1, 2, 2, 25.00),
(1, 3, 1, 70.00),
(2, 6, 10, 5.00),
(2, 7, 20, 2.00),
(3, 4, 1, 150.00),
(3, 5, 1, 120.00),
(4, 9, 2, 12.00),
(4, 10, 1, 35.00),
(5, 2, 1, 25.00);
Check order items:
SELECT * FROM order_items;
Now we have a real mini shop structure.
Customers.
Orders.
Products.
Items.
Prices.
Quantities.
Relationships.
Beautiful little database machinery.
Show Products with Category Names
Let us start with a simple JOIN.
SELECT
p.id,
p.name AS product_name,
p.price,
p.stock_quantity,
c.name AS category_name
FROM products AS p
JOIN categories AS c
ON p.category_id = c.id
ORDER BY p.id;
This gives readable product data.
Instead of:
Laptop | category_id = 1
We get:
Laptop | Electronics
This is what real applications need.
Users should not need to decode IDs.
Users already suffer enough.
Especially when passwords require one symbol, one number, one ancient rune, and emotional stability.
Show Orders with Customer Names
Now show orders with customer names:
SELECT
o.id AS order_id,
c.name AS customer_name,
c.email,
o.status,
o.created_at
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
ORDER BY o.id;
This tells us who placed each order.
Very useful.
A table with only customer_id is technically correct.
But readable data is better.
Readable data is what separates a good admin panel from a spreadsheet cave.
Show Full Order Details
Now let us show order items with product and customer information.
SELECT
o.id AS order_id,
c.name AS customer_name,
p.name AS product_name,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS item_total
FROM order_items AS oi
JOIN orders AS o
ON oi.order_id = o.id
JOIN customers AS c
ON o.customer_id = c.id
JOIN products AS p
ON oi.product_id = p.id
ORDER BY o.id, oi.id;
This is a serious query.
It connects:
order_items
orders
customers
products
Now we can see:
Which customer bought which product.
How many.
At what price.
With what total.
This is the power of relational databases.
Small tables.
Connected properly.
Useful answers.
No chaos drawer.
Calculate Total for Each Order
Now calculate the total value of each order.
SELECT
o.id AS order_id,
c.name AS customer_name,
o.status,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
JOIN order_items AS oi
ON o.id = oi.order_id
GROUP BY o.id, c.name, o.status
ORDER BY o.id;
This uses:
JOIN;SUM;GROUP BY.
A very useful combination.
The database calculates totals from order items.
No calculator needed.
No nervous Excel file needed.
PostgreSQL handles it.
With dignity.
Calculate Only Paid Orders
Usually, cancelled orders should not count as revenue.
So we can filter by status.
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
JOIN order_items AS oi
ON o.id = oi.order_id
WHERE o.status = 'paid'
GROUP BY o.id, c.name
ORDER BY order_total DESC;
This shows only paid orders.
Very important.
Because counting cancelled orders as revenue is a great way to make beautiful reports and terrible business decisions.
Pretty numbers can lie.
SQL helps.
But only if you ask correctly.
Total Revenue
Now calculate total revenue from paid orders.
SELECT
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.id = oi.order_id
WHERE o.status = 'paid';
This gives one number.
A very business-looking number.
The kind of number people put on dashboards.
Then stare at seriously.
Even if the dashboard has only three rows.
Orders Per Customer
Count how many orders each customer has.
SELECT
c.name AS customer_name,
COUNT(o.id) AS orders_count
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
GROUP BY c.name
ORDER BY orders_count DESC;
We use LEFT JOIN because we may want to show customers with zero orders too.
In our current data, every customer has at least one order.
But in real life, many customers register and buy nothing.
Very normal.
Very painful for marketing.
Paid Orders Per Customer
Now count only paid orders.
SELECT
c.name AS customer_name,
COUNT(o.id) AS paid_orders_count
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
AND o.status = 'paid'
GROUP BY c.name
ORDER BY paid_orders_count DESC;
Important detail:
The status condition is inside the ON clause:
AND o.status = 'paid'
Why?
Because we still want all customers.
If we put this condition in WHERE, customers without paid orders might disappear.
This is a classic LEFT JOIN detail.
Small detail.
Big difference.
Database trap number 247.
Collect them all.
Total Spending Per Customer
Now calculate how much each customer spent on paid orders.
SELECT
c.name AS customer_name,
COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS total_spent
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
AND o.status = 'paid'
LEFT JOIN order_items AS oi
ON o.id = oi.order_id
GROUP BY c.name
ORDER BY total_spent DESC;
Here we use COALESCE.
Why?
Because if a customer has no paid orders, the sum is NULL.
We want 0.
This:
COALESCE(SUM(oi.quantity * oi.unit_price), 0)
means:
If the sum exists, show it.
If the sum is NULL, show 0.
Very practical.
Very dashboard-friendly.
Very “do not scare the accountant”.
Sales by Category
Now let us calculate sales by category.
SELECT
cat.name AS category_name,
SUM(oi.quantity * oi.unit_price) AS total_sales
FROM order_items AS oi
JOIN products AS p
ON oi.product_id = p.id
JOIN categories AS cat
ON p.category_id = cat.id
JOIN orders AS o
ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY cat.name
ORDER BY total_sales DESC;
This answers:
Which categories generate the most revenue?
Very useful for business.
Maybe Electronics wins.
Maybe Office wins.
Maybe Kitchen surprises everyone with mugs.
Never underestimate mugs.
Products Sold Count
Now count how many units of each product were sold.
SELECT
p.name AS product_name,
SUM(oi.quantity) AS units_sold
FROM products AS p
JOIN order_items AS oi
ON p.id = oi.product_id
JOIN orders AS o
ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY p.name
ORDER BY units_sold DESC;
This is useful for stock planning.
If pens sell a lot, order more pens.
If bookshelves do not sell, maybe stop building a wooden empire.
Data helps decisions.
Sometimes brutally.
Products Never Sold
Now find products that were never sold in paid orders.
SELECT
p.name AS product_name
FROM products AS p
LEFT JOIN order_items AS oi
ON p.id = oi.product_id
LEFT JOIN orders AS o
ON oi.order_id = o.id
AND o.status = 'paid'
GROUP BY p.id, p.name
HAVING COUNT(o.id) = 0
ORDER BY p.name;
This query is a little more advanced.
It uses:
LEFT JOIN;GROUP BY;HAVING;COUNT.
The idea is:
Show products where there are zero paid orders.
Very useful.
Very business.
Very “why did we buy 500 of these?”.
Low Stock Products
Show products with low stock.
SELECT
name AS product_name,
stock_quantity
FROM products
WHERE stock_quantity < 10
ORDER BY stock_quantity ASC;
This is a simple query.
But very practical.
In real systems, this could power an alert:
Stock is low. Order more.
Databases are not only for storing data.
They can help you notice problems.
Like a quiet assistant.
Who only speaks SQL.
Create Useful Indexes
Now let us add indexes that make sense for this project.
Primary keys already have indexes automatically.
But foreign keys are good candidates for indexes.
Create indexes:
CREATE INDEX idx_products_category_id
ON products(category_id);
CREATE INDEX idx_orders_customer_id
ON orders(customer_id);
CREATE INDEX idx_order_items_order_id
ON order_items(order_id);
CREATE INDEX idx_order_items_product_id
ON order_items(product_id);
These indexes can help with joins.
Now create indexes for common filtering:
CREATE INDEX idx_orders_status
ON orders(status);
CREATE INDEX idx_orders_created_at
ON orders(created_at);
These can help queries like:
WHERE status = 'paid'
and:
ORDER BY created_at DESC
Do not create indexes blindly.
Here they make sense because these columns are likely to appear in joins, filters, or sorting.
Indexes should follow real queries.
Not vibes.
PostgreSQL does not optimize vibes.
We already discussed this.
Still tragic.
Check a Query with EXPLAIN ANALYZE
Let us inspect a query.
EXPLAIN ANALYZE
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
JOIN order_items AS oi
ON o.id = oi.order_id
WHERE o.status = 'paid'
GROUP BY o.id, c.name
ORDER BY order_total DESC;
Look for words like:
Seq Scan
Index Scan
Hash Join
Nested Loop
Planning Time
Execution Time
Do not panic if the output looks complicated.
The goal is not to understand every detail yet.
The goal is to start reading the plan.
PostgreSQL is telling you how it works.
Like a mechanic opening the engine.
At first it looks scary.
Then slowly it starts making sense.
Then you become the scary one.
In a good way.
Update Stock After an Order
In real shops, when an order is paid, stock should decrease.
For example, if order 1 sold:
Laptop x 1
Mouse x 2
Keyboard x 1
We can update stock manually:
UPDATE products
SET stock_quantity = stock_quantity - 1
WHERE id = 1;
UPDATE products
SET stock_quantity = stock_quantity - 2
WHERE id = 2;
UPDATE products
SET stock_quantity = stock_quantity - 1
WHERE id = 3;
Then check:
SELECT
name,
stock_quantity
FROM products
WHERE id IN (1, 2, 3);
This works.
But manual updates are dangerous.
In real applications, this should be handled carefully.
Usually inside a transaction.
We will talk more about safe database changes later.
Because databases are powerful.
And power plus carelessness equals interesting disasters.
A Better Stock Update Query
We can update stock from order items.
For order 1:
UPDATE products AS p
SET stock_quantity = p.stock_quantity - oi.quantity
FROM order_items AS oi
WHERE p.id = oi.product_id
AND oi.order_id = 1;
This updates all products from order 1 in one query.
Very useful.
Very powerful.
Also dangerous if you use the wrong order_id.
PostgreSQL will obey.
It is not your mother.
It will not ask:
Are you sure, dear?
So always check your conditions.
Find Order Details for One Customer
Show all paid order items for Anna:
SELECT
c.name AS customer_name,
o.id AS order_id,
p.name AS product_name,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS item_total
FROM customers AS c
JOIN orders AS o
ON c.id = o.customer_id
JOIN order_items AS oi
ON o.id = oi.order_id
JOIN products AS p
ON oi.product_id = p.id
WHERE c.email = 'anna@example.com'
AND o.status = 'paid'
ORDER BY o.id;
This is the kind of query you might use for:
Customer order history.
Invoices.
Admin panels.
Customer support.
Very real.
Very useful.
Very “please tell me what this customer bought”.
Search Products by Category and Price
Show products from Electronics cheaper than 100:
SELECT
p.name AS product_name,
p.price,
c.name AS category_name
FROM products AS p
JOIN categories AS c
ON p.category_id = c.id
WHERE c.name = 'Electronics'
AND p.price < 100
ORDER BY p.price ASC;
This query could power a filter in an online shop.
Category.
Price.
Sorting.
Very normal.
Very common.
Very good place to think about indexes if the table grows.
Add a Multi-Column Index
If you often filter products by category and sort by price, this index can help:
CREATE INDEX idx_products_category_price
ON products(category_id, price);
This index is useful for queries like:
WHERE category_id = 1
ORDER BY price ASC
Column order matters.
The index starts with category_id.
Then price.
It is not random.
It is based on how the query works.
Indexes are not stickers.
They are strategy.
Tiny database strategy.
Like chess, but with more invoices.
Common Mistakes in Small Database Projects
Storing Everything in One Table
Bad idea:
customer_name
customer_email
product_name
category_name
order_date
quantity
price
All in one table.
This looks easy.
At first.
Then customer email changes.
Then category name changes.
Then product price changes.
Then reports become strange.
Then someone opens Excel.
Then society collapses.
Use related tables.
That is the point of relational databases.
Forgetting Foreign Keys
Without foreign keys, your data can become inconsistent.
For example:
Order item points to product 999.
But product 999 does not exist.
Bad.
Foreign keys protect relationships.
PostgreSQL can guard the door.
Let it.
Forgetting Historical Prices
If order items only point to products and do not store unit_price, old orders may change when product prices change.
That is usually bad.
An old order should remember the price at the time of purchase.
History matters.
Especially when money is involved.
Counting Cancelled Orders as Revenue
This is a classic reporting mistake.
Always think about status.
WHERE o.status = 'paid'
Without this, your revenue report may include cancelled or unpaid orders.
The chart looks better.
Reality does not.
Reality usually wins.
Using INNER JOIN When You Need LEFT JOIN
If you want to show all customers, even customers without orders, use LEFT JOIN.
If you use INNER JOIN, customers without orders disappear.
Sometimes this is correct.
Sometimes it silently ruins your report.
SQL does not scream.
It just returns exactly what you asked.
Even if you asked badly.
Very honest.
Very dangerous.
Practice
Show all products with categories:
SELECT
p.name AS product_name,
p.price,
c.name AS category_name
FROM products AS p
JOIN categories AS c
ON p.category_id = c.id
ORDER BY c.name, p.name;
Show all orders with customers:
SELECT
o.id AS order_id,
c.name AS customer_name,
o.status,
o.created_at
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
ORDER BY o.id;
Calculate totals for each order:
SELECT
o.id AS order_id,
c.name AS customer_name,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
JOIN order_items AS oi
ON o.id = oi.order_id
GROUP BY o.id, c.name
ORDER BY order_total DESC;
Calculate total paid revenue:
SELECT
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.id = oi.order_id
WHERE o.status = 'paid';
Show total spending per customer:
SELECT
c.name AS customer_name,
COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS total_spent
FROM customers AS c
LEFT JOIN orders AS o
ON c.id = o.customer_id
AND o.status = 'paid'
LEFT JOIN order_items AS oi
ON o.id = oi.order_id
GROUP BY c.name
ORDER BY total_spent DESC;
Show sales by category:
SELECT
cat.name AS category_name,
SUM(oi.quantity * oi.unit_price) AS total_sales
FROM order_items AS oi
JOIN products AS p
ON oi.product_id = p.id
JOIN categories AS cat
ON p.category_id = cat.id
JOIN orders AS o
ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY cat.name
ORDER BY total_sales DESC;
Show low stock products:
SELECT
name,
stock_quantity
FROM products
WHERE stock_quantity < 10
ORDER BY stock_quantity ASC;
Run these queries.
Change the data.
Add customers.
Add orders.
Add order items.
Break something.
Fix it.
That is learning.
Very PostgreSQL.
Very practical.
Mini Challenge
Extend the shop database.
Add a new table called suppliers.
Rules:
- one supplier can provide many products;
- each product can have one supplier;
- supplier name is required;
- supplier email must be unique.
Create the table:
CREATE TABLE suppliers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
);
Insert suppliers:
INSERT INTO suppliers (name, email)
VALUES
('Tech Supplier', 'tech@example.com'),
('Office Supplier', 'office@example.com'),
('Home Supplier', 'home@example.com');
Add a supplier column to products:
ALTER TABLE products
ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id);
Update products:
UPDATE products
SET supplier_id = 1
WHERE category_id = 1;
UPDATE products
SET supplier_id = 2
WHERE category_id = 3;
UPDATE products
SET supplier_id = 3
WHERE category_id IN (2, 4);
Now write queries to:
- show products with category and supplier names;
- count products per supplier;
- find suppliers that provide low-stock products;
- calculate sales by supplier;
- show suppliers whose products generated more than 500 in paid sales.
Hint for sales by supplier:
SELECT
s.name AS supplier_name,
SUM(oi.quantity * oi.unit_price) AS total_sales
FROM suppliers AS s
JOIN products AS p
ON s.id = p.supplier_id
JOIN order_items AS oi
ON p.id = oi.product_id
JOIN orders AS o
ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY s.name
ORDER BY total_sales DESC;
This challenge adds another real-world layer.
Suppliers.
Products.
Sales.
Reports.
The database grows.
The brain suffers a little.
This is normal.
Growth has a price.
So do JOINs.
Summary
Today you built a practical PostgreSQL project.
You learned how to:
- design a small shop database;
- create related tables;
- use primary keys;
- use foreign keys;
- separate data into clean structures;
- store historical unit prices in order items;
- insert practical sample data;
- read related data with
JOIN; - calculate order totals;
- calculate revenue;
- count orders per customer;
- calculate customer spending;
- calculate sales by category;
- find low-stock products;
- create useful indexes;
- think about real reporting problems.
This is a big step.
Now PostgreSQL is no longer just theory.
You have used it like a real tool.
A database is not only tables.
It is structure.
Relationships.
Rules.
Questions.
Answers.
And sometimes strange errors that make you question your life choices.
But that is part of the journey.
Next Lesson
In the next lesson, we will learn how PostgreSQL works with applications.
We will discuss how backend applications connect to PostgreSQL.
We will look at connection strings.
Environment variables.
Basic security ideas.
And why putting database passwords directly in your code is a terrible idea.
A very terrible idea.
The kind of idea that wakes up security people at night.