Ten tables. It answers all three client questions. Click a question to see the path it takes.
How to read the diagram
Box
A table. Name on top, columns underneath.
PK · primary key
The row's own ID. Like a passport number: it never repeats.
FK · foreign key
A column holding another table's PK. It is what makes a line real.
+ green column
Added to answer one of the questions.
dashed box
A whole new table.
1..1 · 0..* · 1..*
Exactly one · none or many · at least one.
Read each line from the many end. A Booking is made by one Client. A Client makes none or many Bookings.
The model
Pick a question. The tables and lines it uses light up; the rest fade.
On a phone, scroll the diagram sideways.
Q1 · Price-change alerts
Find all changed prices, and the customers who opted in to price-change alerts.As a programmer, I want to send price-change alerts to customers who opted in.
Old model
Couldn't answer
fares.price only held today's price. A new price overwrote the old one, so there was nothing to compare. Nothing recorded who wanted alerts.
What was added
FareHistory: a new row every time a price changes. Old price, new price, when.
PriceAlert: links a Client to a Flight they want alerts for.
Why a table in the middle? One client can watch many flights. One flight can be watched by many clients. That is many-to-many, and many-to-many always becomes a link table.
The SQL
SELECT c.name, c.email, f.flight_number,
fa.passenger_type, fh.old_price, fh.new_price
FROM FareHistory fh
JOIN Fare fa ON fa.fare_id = fh.fare_id
JOIN Flight f ON f.flight_id = fa.flight_id
JOIN PriceAlert pa ON pa.flight_id = f.flight_id
JOIN Client c ON c.client_id = pa.client_id
WHERE fh.changed_at >= '2019-11-01' -- since the last runORDER BY c.name;
Read it like this
Start at FareHistory: every price change.
Join Fare, then Flight: which flight the price belongs to.
Join PriceAlert: who is watching that flight.
Join Client: their name and email.
Keep only changes since the program last ran.
What it returns with the practice data
name
flight
fare
old
new
Amy Clarke
EK001
adult
480.00
450.00
Amy Clarke
EK001
child
360.00
340.00
Raj Patel
BA139
adult
580.00
610.00
Tom Hughes
EK001
adult
480.00
450.00
Tom Hughes
EK001
child
360.00
340.00
In short
The first model couldn't answer Q1 because a new price overwrote the old one. FareHistory keeps every change, and PriceAlert records who opted in. Clients and flights are many-to-many here, so PriceAlert is a link table between them.
Q2 · Bookings by country
Find bookings per country for one airline, most first.As an airline, I want to know which country most bookings come from, to plan more services.
Old model
Half
It could find the airline, and the departure airport's country. It had no customer country.
Decide first: "which country" could mean the customer's country or the departure airport's country. This example uses the customer's, because that's where demand comes from.
What was added
Country: a lookup table. Each country is written once.
Client.country_id: points at Country.
Airport.country_id: points at the same table, so "UK" is never spelt two ways.
Not a normalisation problem. The old tables were linked fine. The query would not fail; the country just wasn't stored anywhere.
The SQL
SELECT co.name AS country, COUNT(*) AS bookings
FROM Booking b
JOIN Flight f ON f.flight_id = b.flight_id
JOIN Airline a ON a.airline_id = f.airline_id
JOIN Client c ON c.client_id = b.client_id
JOIN Country co ON co.country_id = c.country_id
WHERE a.name = 'Emirates'
AND b.status = 'confirmed'
GROUP BY co.country_id, co.name
ORDER BY bookings DESC;
Read it like this
Start at Booking.
Join Flight and Airline. Keep only Emirates.
Join Client and Country to get each booking's country.
Leave out cancelled bookings.
GROUP BY country, then COUNT the rows in each group.
ORDER BY the count, biggest first (DESC).
What it returns with the practice data
country
bookings
United Kingdom
3
India
2
United Arab Emirates
1
In short
"Which country" was ambiguous, so this uses the customer's country. A Country lookup table stores each country once, and both Client and Airport point to it. Cancelled bookings are left out so the count is real demand.
Q3 · Family flights
List all flights in December 2019 that offer child discounts and kids' meals.As a user, I want better options to travel with my family.
Old model
Couldn't answer
Fares had no child price. There was no meal data. dietary_restrictions is what a passenger needs, not what a flight offers.
Watch out: filter on the flight's departure date. The booking date is when someone paid, not when they fly.
What was added
Fare.passenger_type: adult, child or infant. A child discount is a child fare row.
Flight.offers_kids_meal: yes or no.
Passenger.date_of_birth: so a child fare can be checked against a real child.
Simple vs flexible. Yes/no is the simple option. If the airline had many meal types, a Meal table plus a link table would be better.
The SQL
SELECT DISTINCT f.flight_number, f.departure_time
FROM Flight f
JOIN Fare fa ON fa.flight_id = f.flight_id
WHERE f.departure_time >= '2019-12-01'
AND f.departure_time < '2020-01-01'
AND f.offers_kids_meal = TRUEAND fa.passenger_type = 'child'
ORDER BY f.departure_time;
Read it like this
Start at Flight.
Keep flights that depart in December 2019.
Keep flights that offer a kids' meal.
Join Fare. Keep flights that have a child fare.
DISTINCT: show each flight once, even if it has two child fares.
Stretch: check the child price is lower than the adult price. That needs a subquery (textbook 6.3.5).
What it returns with the practice data
flight_number
departure_time
EK001
2019-12-20 08:00
BA139
2019-12-23 20:00
EK003 has a kids' meal but no child fare. BA107 has a child fare but no kids' meal. EK002 flies in November. All three are correctly left out.
In short
Q3 needed two things the first model didn't have: passenger_type on Fare, so a flight can have a child fare, and a yes/no offers_kids_meal on Flight. The filter uses the flight's departure date, not the booking date.
Why this one is normalised
Every fact lives in one place. Other tables point at it with an ID instead of copying it.
A country's nameonly in Country
An airline's nameonly in Airline
A client's emailonly in Client
An airport's cityonly in Airport
A price changeits own row in FareHistory
One thing per box.passenger_type holds one word. offers_kids_meal is yes or no. No lists with commas.
None of the three questions need them. Add a table only when a question needs it.
Try it yourself
The full MySQL script (all 10 tables, mock data and these three queries) is in this repository: airline_worked_example.sql. Run it in MySQL Workbench or any MySQL 8 client. Then change a price, add a client, and run the queries again.