MENU
Database Design and Normalization
Most shops start with a spreadsheet, one row per order line, with the customer and the book copied onto every row. This section normalizes one into the shop schema of Section 3.4. Rebuild it by flattening the sample data, adding an authors cell the way people type it:CREATE TABLE sheet (PRIMARY KEY (order_no, sku)) AS
SELECT o.id AS order_no, o.status, c.email, c.name AS cust_name, c.country, p.sku, p.title,
g.name AS category, i.unit_price AS price, i.quantity AS qty,
ELT(p.id, 'Maya Lind, Omar Haddad', 'Rui Costa', 'Rui Costa, Lena Vogel', 'Omar Haddad',
'Ines Park', 'Tom Ito') AS authors
FROM order_items i JOIN orders o ON o.id = i.order_id JOIN customers c ON c.id = o.customer_id
JOIN products p ON p.id = i.product_id JOIN categories g ON g.id = p.category_id;
SELECT order_no, cust_name, sku, category, price, qty, authors
FROM sheet WHERE order_no IN (1, 6);
+----------+-------------+-----------+-------------+-------+-----+------------------------+
| order_no | cust_name | sku | category | price | qty | authors |
+----------+-------------+-----------+-------------+-------+-----+------------------------+
| 1 | Ana Souza | AC-MUG-01 | Accessories | 12.50 | 2 | NULL |
| 1 | Ana Souza | BK-PHP-01 | Programming | 39.90 | 1 | Maya Lind, Omar Haddad |
| 6 | Elif Yilmaz | BK-LAR-01 | Programming | 49.00 | 1 | Omar Haddad |
| 6 | Elif Yilmaz | BK-PHP-01 | Programming | 39.90 | 2 | Maya Lind, Omar Haddad |
+----------+-------------+-----------+-------------+-------+-----+------------------------+
START TRANSACTION;
UPDATE sheet SET cust_name = 'Benjamin Carter' WHERE order_no = 9;
INSERT INTO sheet (sku, title, price) VALUES ('BK-API-01', 'REST APIs in PHP', 36.00);
DELETE FROM sheet WHERE order_no = 5;
SELECT (SELECT COUNT(DISTINCT cust_name) FROM sheet WHERE email LIKE 'ben@%') AS ben_names,
(SELECT COUNT(*) FROM sheet WHERE email = 'dara@example.com' OR sku = 'BK-UX-01') AS dara;
ROLLBACK;
ERROR 1364 (HY000): Field 'email' doesn't have a default value
+-----------+------+
| ben_names | dara |
+-----------+------+
| 2 | 0 |
+-----------+------+
3.10.1 Entities, Attributes, and Relationships
An entity-relationship (ER) model, proposed by Peter Chen in 1976, describes data before any table exists. An entity is a thing you store facts about, an attribute is one such fact, and a relationship connects entities. In the sheet, email, cust_name and country describe a customer, sku, title and authors a book, and status an order, while qty and price describe one book on one order: a relationship with attributes of its own.3.10.2 Cardinality and ER Diagram Notation
Cardinality says how many rows on each side of a relationship can pair up. Its maximum gives the shapes 1:1, 1:N and N:M; its minimum is zero (optional) or one (mandatory). Crow's foot notation, introduced by Gordon Everest in 1976 and drawn by MySQL Workbench, puts the maximum next to the entity (a bar for one, a fork for many) and the minimum behind it (a circle for zero, a bar for one). Read from one entity to the far end: "one order contains one or more items."
Figure 3.8: The shop in crow's foot notation. Each end shows how many
rows of the entity beside it pair with one row at the far endOrders and products are N:M, drawn here already resolved into the
associative entity Order item and two 1:N lines. Categories
form a recursive relationship. Cardinality comes from
business rules, so ask the owner; no foreign key can enforce "at least
one item," so PHP must.3.10.3 Translating an ER Model into Tables
Each entity becomes a table whose key is its primary key. A 1:N relationship becomes a foreign key on the "many" side (orders.customer_id). A 1:1 becomes a foreign key with a UNIQUE constraint on the optional side, or one merged table. An N:M becomes a junction table with a foreign key to each side and the pair as its primary key, and so does a multi-valued attribute whose values have facts of their own. order_items is the shop's junction table, and the sheet's authors need another:CREATE TABLE authors (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL);
CREATE TABLE product_authors (
product_id INT UNSIGNED NOT NULL, author_id INT UNSIGNED NOT NULL,
position TINYINT UNSIGNED NOT NULL DEFAULT 1,
PRIMARY KEY (product_id, author_id),
FOREIGN KEY (product_id) REFERENCES products (id),
FOREIGN KEY (author_id) REFERENCES authors (id)
);
3.10.4 Functional Dependencies
A functional dependency X â Y says each value of X comes with exactly one value of Y; X is the determinant. A candidate key is a minimal set of columns that determines all the others. With the key (order_no, sku), the sheet holds:order_no â status, email and sku â title, category: partial dependencies on part of the key;
email â cust_name, country: transitive, reaching the key only through email;
(order_no, sku) â qty, price: a full dependency on the whole key.
SELECT country, COUNT(DISTINCT email) AS emails FROM sheet GROUP BY country HAVING emails > 1;
SELECT order_no, cust_name, SUM(qty) AS items FROM sheet GROUP BY order_no;
+---------+--------+
| country | emails |
+---------+--------+
| MY | 2 |
+---------+--------+
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains
nonaggregated column 'shop.sheet.cust_name' which is not functionally dependent on columns
in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
3.10.5 First Normal Form
A table is in first normal form (1NF) when each column holds one atomic value, no group of columns repeats (author1, author2), and a key identifies every row. The authors cell fails: finding Omar Haddad's books takes LIKE '%Haddad%', which cannot use an index and would match "Haddadi" too. Give each value its own row; JSON_TABLE (Section 3.9.5) splits all lists at once:CREATE TABLE sku_authors (PRIMARY KEY (sku, author)) AS
SELECT DISTINCT s.sku, TRIM(j.author) AS author
FROM sheet AS s, JSON_TABLE(CONCAT('["', REPLACE(s.authors, ',', '","'), '"]'),
'$[*]' COLUMNS (author VARCHAR(40) PATH '$')) AS j;
ALTER TABLE sheet DROP COLUMN authors;
SELECT author, GROUP_CONCAT(sku ORDER BY sku) AS skus
FROM sku_authors GROUP BY author HAVING COUNT(*) > 1;
+-------------+---------------------+
| author | skus |
+-------------+---------------------+
| Omar Haddad | BK-LAR-01,BK-PHP-01 |
| Rui Costa | BK-SQL-01,BK-SQL-02 |
+-------------+---------------------+
3.10.6 Second and Third Normal Forms
Second normal form (2NF) forbids partial dependencies and third normal form (3NF) transitive ones; Codd defined both in 1971. In William Kent's words, every non-key column states a fact about "the key, the whole key, and nothing but the key." The fix: move the dependent columns into a table keyed by their determinant, and leave the determinant behind as a foreign key.CREATE TABLE products_2nf (PRIMARY KEY (sku)) AS
SELECT DISTINCT sku, title, category FROM sheet;
CREATE TABLE lines_2nf (PRIMARY KEY (order_no, sku)) AS
SELECT order_no, sku, price, qty FROM sheet;
CREATE TABLE orders_2nf (PRIMARY KEY (order_no)) AS
SELECT DISTINCT order_no, status, email, cust_name, country FROM sheet;
CREATE TABLE customers_3nf (PRIMARY KEY (email)) AS
SELECT DISTINCT email, cust_name, country FROM orders_2nf;
ALTER TABLE orders_2nf DROP COLUMN cust_name, DROP COLUMN country;
SELECT COUNT(*) AS lost FROM (TABLE sheet EXCEPT
SELECT order_no, status, email, cust_name, country, sku, title, category, price, qty
FROM lines_2nf JOIN orders_2nf USING (order_no) JOIN customers_3nf USING (email)
JOIN products_2nf USING (sku)) AS d;
+------+
| lost |
+------+
| 0 |
+------+
START TRANSACTION;
UPDATE customers SET name = 'Benjamin Carter' WHERE email = 'ben@example.com';
INSERT INTO products (sku, title, category_id, price) VALUES ('BK-API-01', 'REST APIs', 2, 36);
DELETE FROM order_items WHERE order_id = 5;
DELETE FROM orders WHERE id = 5;
SELECT (SELECT COUNT(DISTINCT name) FROM customers WHERE email = 'ben@example.com') AS ben,
(SELECT COUNT(*) FROM products WHERE sku IN ('BK-API-01', 'BK-UX-01')) AS books,
(SELECT COUNT(*) FROM customers WHERE email = 'dara@example.com') AS dara;
ROLLBACK;
+------+-------+------+
| ben | books | dara |
+------+-------+------+
| 1 | 2 | 1 |
+------+-------+------+
3.10.7 Boyce-Codd, Fourth, and Fifth Normal Forms
Boyce-Codd normal form (BCNF), from 1974, requires every determinant to be a candidate key, which differs from 3NF only when keys overlap. With unique titles stored on order lines, both (order_no, sku) and (order_no, title) are keys, and sku â title passes 3NF because title is part of a key. But sku alone is no key, so the title repeats per sale and can drift:CREATE TABLE lines_titled (order_no INT, sku VARCHAR(12), title VARCHAR(60), qty INT,
PRIMARY KEY (order_no, sku), UNIQUE (order_no, title));
INSERT INTO lines_titled VALUES (1, 'BK-PHP-01', 'Modern PHP in Practice', 1),
(6, 'BK-PHP-01', 'Modern PHP, 2nd Edition', 2);
SELECT sku, COUNT(DISTINCT title) AS titles FROM lines_titled GROUP BY sku;
+-----------+--------+
| sku | titles |
+-----------+--------+
| BK-PHP-01 | 2 |
+-----------+--------+
3.10.8 Surrogate Versus Natural Keys
A natural key means something (an email, a SKU); a surrogate key only identifies the row. Natural keys change, dragging every foreign key along, and InnoDB copies the primary key into each secondary index, so shop uses narrow surrogates and keeps natural keys UNIQUE. Natural keys suit stable codes (country) and junction tables. Use a UUID when ids are made outside the database or must not be guessable. UUID() returns version 1, which starts with the fastest-changing timestamp bits; UUID_TO_BIN(u, 1) packs it into BINARY(16) with the first and third groups swapped, so later values sort later:SET @u = UUID();
SELECT @u AS uuid, HEX(UUID_TO_BIN(@u, 1)) AS packed;
+--------------------------------------+----------------------------------+
| uuid | packed |
+--------------------------------------+----------------------------------+
| 98bfb64a-b727-11f1-98d6-00155d5f9c3f | 11F1B72798BFB64A98D600155D5F9C3F |
+--------------------------------------+----------------------------------+
3.10.9 Denormalization and When It Pays
Denormalization stores a derived or copied value on purpose to make a measured, hot read cheap. The classic case is an order total, which shop computes from order_items. At 200,000 orders:SET SESSION cte_max_recursion_depth = 200000;
CREATE TABLE big_orders (PRIMARY KEY (id)) AS WITH RECURSIVE n (id) AS
(SELECT 1 UNION ALL SELECT id + 1 FROM n WHERE id < 200000) SELECT id FROM n;
CREATE TABLE big_items (PRIMARY KEY (order_id, product_id)) AS
SELECT o.id AS order_id, p.id AS product_id, (o.id * p.id) % 4 + 1 AS quantity,
5 + (o.id * 7 + p.id) % 45 AS unit_price
FROM big_orders AS o JOIN (VALUES ROW(1), ROW(4), ROW(7)) AS p (id);
SET @t = NOW(6);
SELECT GROUP_CONCAT(order_id) INTO @a FROM (SELECT order_id, SUM(quantity * unit_price) AS t
FROM big_items GROUP BY order_id ORDER BY t DESC, order_id DESC LIMIT 10) AS x;
SET @agg = TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1e6;
ALTER TABLE big_orders ADD total DECIMAL(10,2) NOT NULL DEFAULT 0, ADD INDEX (total);
UPDATE big_orders AS o JOIN (SELECT order_id, SUM(quantity * unit_price) AS s FROM big_items
GROUP BY order_id) AS t ON t.order_id = o.id SET o.total = t.s;
SET @t = NOW(6);
SELECT GROUP_CONCAT(id) INTO @b FROM
(SELECT id FROM big_orders ORDER BY total DESC, id DESC LIMIT 10) AS x;
SELECT @agg AS aggregate_s, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1e6 AS stored_s,
@a = @b AS same_top_ten;
+-------------+----------+--------------+
| aggregate_s | stored_s | same_top_ten |
+-------------+----------+--------------+
| 0.319663 | 0.000616 | 1 |
+-------------+----------+--------------+