1. Карта схемы и связи
Если пытаться проектировать таблицы «слева направо», не поднимая головы, легко получить либо монстра, либо кашу. Поэтому начнём с обзорной картины: какие подсистемы есть в домене mini‑shop и где между ними границы. У нас три смысловые зоны — каталог, остатки и заказы, — и это удобно отражается в таблицах: справочники живут отдельно, количественные остатки отдельно, а заказ — это отдельная сущность со своими строками состава.
Ниже — простая схема связей. Это не «магическая UML», а просто подсказка: кто на кого ссылается через FOREIGN KEY, где у нас связь «один ко многим», а где — «один к одному».
erDiagram
CATEGORY ||--o{ PRODUCT : contains
PRODUCT ||--|| STOCK_ITEM : has
CUSTOMER_ORDER ||--o{ ORDER_ITEM : includes
PRODUCT ||--o{ ORDER_ITEM : ordered_as
Чтобы не путаться, договоримся об одном важном приёме: у каждой таблицы будет технический первичный ключ id (это удобно для ссылок), а также будут бизнес-идентификаторы, которые тоже важны и должны быть уникальными. Для категории это code, для товара — sku, для заказа — order_number. То есть id — это «паспортный номер строки», а code/sku/order_number — то, что реально видит бизнес и что нельзя дублировать.
2. Таблица category
Категория — хороший старт, потому что это справочник: данные здесь меняются редко, но используются часто. В магазине категории — как таблички над отделами: «Книги», «Игры», «Спорт». Если однажды позволить создать две категории с одинаковым кодом, потом придётся героически разбираться, почему половина товаров лежит в BOOKS, а вторая половина — в ещё одной BOOKS.
Сделаем таблицу category с простыми полями: code и name обязательны, code ещё и уникален. Плюс добавим description как необязательное поле и active как флаг «категория ещё используется».
-- Справочник категорий: бизнес-идентификатор code + технический id
CREATE TABLE category (
id BIGINT PRIMARY KEY, -- технический PK для ссылок
code VARCHAR(50) NOT NULL UNIQUE, -- бизнес-код категории (уникален)
name VARCHAR(100) NOT NULL, -- отображаемое имя категории
description VARCHAR(500), -- опциональное описание
active BOOLEAN NOT NULL -- явно храним активность (без NULL-состояния)
);
Обратите внимание на логику ограничений: NOT NULL на name — это «категория без названия не имеет смысла», а UNIQUE на code — это «код — стабильный идентификатор, он не должен повторяться». Флаг active мы делаем NOT NULL, потому что состояние «неизвестно, активна ли категория» — плохая идея. Даже если мы пока не знаем, как будем деактивировать категории, сама возможность хранить это состояние в таблице уже полезна.
Для ощущения реальности можно представить простую вставку данных. Сейчас мы не тренируем INSERT как отдельную тему (это отдельный разговор), но маленький пример помогает «оживить» таблицу и убедиться, что она адекватна.
-- Пример одной записи справочника
INSERT INTO category (id, code, name, description, active)
VALUES (1, 'BOOKS', 'Books', 'All kinds of books', true);
3. Таблица product
Товар — центральная сущность каталога, но именно здесь начинаются типичные ошибки вроде «а давайте прямо в товар положим имя категории строкой». Это кажется удобным ровно до первой ситуации, когда категория переименовалась и вы внезапно обнаружили, что Books у вас встречается в трёх вариантах: Books, Book, Книги. Поэтому товар должен ссылаться на категорию по ключу, а не копировать её данные.
У товара будет sku (уникальный бизнес-идентификатор), name, status и price. И самое главное — category_id как обязательная ссылка на category(id).
-- Товары каталога: уникальный sku + ссылка на категорию через FK
CREATE TABLE product (
id BIGINT PRIMARY KEY, -- технический PK
sku VARCHAR(50) NOT NULL UNIQUE, -- бизнес-артикул (уникален)
name VARCHAR(200) NOT NULL, -- имя товара
status VARCHAR(30) NOT NULL, -- статус (например, ACTIVE / DISABLED)
price NUMERIC(10, 2) NOT NULL, -- цена с точностью до копеек
category_id BIGINT NOT NULL, -- обязательная ссылка на категорию
FOREIGN KEY (category_id) REFERENCES category(id) -- FK защищает от "категорий-строк"
);
Здесь важно почувствовать смысл каждого решения. sku помечаем UNIQUE, потому что это «артикул» товара: если у вас два товара с одинаковым sku, вы уже не понимаете, что именно продаёте. price делаем NUMERIC(10, 2), потому что деньги любят точность. Если хранить цену как double, однажды вы получите сумму заказа 599.9999999997 и начнёте подозревать, что у вас в магазине поселился полтергейст. На деле это просто особенности floating point.
category_id делаем NOT NULL, потому что «товар без категории» почти всегда означает либо «модель не додумана», либо «данные испорчены». Да, в реальном мире бывают товары «Без категории», но это обычно отдельная категория, а не NULL.
Теперь короткий запрос, который показывает нам товар вместе с названием категории. Это как раз иллюстрация того, что нормализация не мешает чтению, если вы умеете JOIN.
-- Чтение товаров вместе с категорией через JOIN по FK
SELECT p.sku, p.name, c.name AS category_name
FROM product p
JOIN category c ON c.id = p.category_id -- связываем товар с его категорией
ORDER BY p.name;
4. Таблица stock_item
Когда вы строите учебный проект, очень хочется «экономить таблицы»: мол, давайте положим available_quantity прямо в product и поехали. Но это экономия примерно как «давайте сложим носки и суп в одну кастрюлю — так меньше посуды». Каталог и остатки живут по разным правилам: каталогные данные меняются относительно редко, а остатки — часто, иногда несколько раз в минуту. Если их смешать, таблица товара начнёт жить нервной жизнью склада.
stock_item — отдельная таблица, которая хранит количественные значения и привязана к товару. Мы явно фиксируем правило «один товар — одна запись об остатках» через UNIQUE на product_id.
-- Остатки по товарам: один товар -> одна запись с количествами
CREATE TABLE stock_item (
id BIGINT PRIMARY KEY, -- технический PK
product_id BIGINT NOT NULL UNIQUE, -- 1:1 с product (UNIQUE фиксирует правило)
available_quantity INT NOT NULL, -- доступно для продажи
reserved_quantity INT NOT NULL, -- зарезервировано (ещё не списано)
FOREIGN KEY (product_id) REFERENCES product(id) -- FK на товар
);
Здесь available_quantity — то, что можно прямо сейчас продать, а reserved_quantity — то, что уже «забронировано» в процессе оформления заказов (или под отложенные операции). Даже если вы пока не реализуете сложную механику резерва, сама модель с двумя полями помогает объяснять нормальную логику заказов, где не всегда можно просто «вычесть из available».
И да, оба поля NOT NULL, потому что NULL в количествах — это почти всегда «непонятно, что происходит». Ноль — понятен, NULL — это уже философия: «количество неизвестно, но мы торгуем». Не надо так.
Мини-запрос, чтобы увидеть остатки вместе с товаром:
-- Быстрое чтение остатков, чтобы видеть картину по складу
SELECT p.sku, p.name, s.available_quantity, s.reserved_quantity
FROM stock_item s
JOIN product p ON p.id = s.product_id -- связываем остатки с товаром
ORDER BY p.name;
5. Заказ: customer_order и order_item
Заказы — это место, где особенно видно, зачем нужна нормализация. У заказа есть «шапка» (номер, email, сумма), и есть «строчки» (какой товар, сколько штук, по какой цене). Если попытаться засунуть всё в одну таблицу, вы либо получите повтор шапки на каждую позицию, либо начнёте хранить список товаров в одной колонке — а это уже дорога к боли, слезам и запросам вида «найди заказы, где есть товар X», которые быстро превращаются в археологию.
Сначала создадим customer_order. Здесь ключевой бизнес‑идентификатор — order_number, он должен быть уникальным. customer_email обязателен. total_amount тоже обязателен, потому что заказ без суммы выглядит странно (даже если сумма нулевая — лучше хранить 0.00, чем NULL).
-- "Шапка" заказа: уникальный номер + данные покупателя + итог
CREATE TABLE customer_order (
id BIGINT PRIMARY KEY, -- технический PK
order_number VARCHAR(30) NOT NULL UNIQUE, -- бизнес-номер заказа
customer_email VARCHAR(255) NOT NULL, -- контакт покупателя
total_amount NUMERIC(10, 2) NOT NULL -- итог по заказу (фиксируем явно)
);
Теперь таблица order_item. Она хранит позиции заказа. Здесь две ссылки: order_id на customer_order(id) и product_id на product(id). Количество — quantity. Цена позиции — unit_price. И итог по строке — line_total.
-- "Строки" заказа: товар, количество, цена на момент покупки и итог по строке
CREATE TABLE order_item (
id BIGINT PRIMARY KEY, -- технический PK
order_id BIGINT NOT NULL, -- ссылка на заказ
product_id BIGINT NOT NULL, -- ссылка на товар
quantity INT NOT NULL, -- сколько штук
unit_price NUMERIC(10, 2) NOT NULL, -- цена на момент заказа (не из каталога)
line_total NUMERIC(10, 2) NOT NULL, -- итог позиции (quantity * unit_price)
FOREIGN KEY (order_id) REFERENCES customer_order(id), -- FK на "шапку"
FOREIGN KEY (product_id) REFERENCES product(id) -- FK на товар
);
Почему мы храним unit_price, если цена есть в product.price? Потому что цена товара может поменяться завтра, а заказ — это «снимок сделки». Если в заказе хранить только product_id и каждый раз подглядывать актуальную цену из товара, то через неделю старый заказ внезапно станет дороже просто потому, что менеджер поменял прайс. Это уже не магазин, а машина времени.
Почему line_total хранится отдельно, хотя он вычисляется как quantity * unit_price? В учебном проекте можно было бы и не хранить, но отдельное хранение удобно как зафиксированный результат расчёта на момент заказа. Это упрощает чтение и помогает ловить ошибки: если line_total не совпал с произведением, значит, где-то в логике оформления заказа была проблема.
И вот запрос, который читает состав заказа «одной картинкой»: номер заказа, товары, количество и суммы.
-- Состав заказа: объединяем "шапку", строки и товары
SELECT o.order_number, p.name, oi.quantity, oi.line_total
FROM customer_order o
JOIN order_item oi ON oi.order_id = o.id -- строки конкретного заказа
JOIN product p ON p.id = oi.product_id -- подтягиваем имя товара
WHERE o.order_number = 'ORD-1001'
ORDER BY p.name;
6. Проверка схемы через JOIN
Хорошая схема — это не та, которая «красивая на бумаге», а та, которая отвечает на реальные вопросы бизнеса без гимнастики. Поэтому сделаем короткую проверку на здравый смысл: представим несколько типовых вопросов и посмотрим, легко ли они выражаются SELECT-запросами. Это не «отчётность уровня корпорации», а базовые чтения, которые должны быть понятны даже если вы пока только привыкаете к SQL.
Вопрос 1: показать товары с категорией, отсортировать и ограничить выдачу.
-- Каталог с категорией + пагинация через LIMIT/OFFSET
SELECT p.id, p.sku, p.name, p.price, c.name AS category_name
FROM product p
JOIN category c ON c.id = p.category_id -- связь товара с категорией
ORDER BY p.id
LIMIT 20 OFFSET 0;
Вопрос 2: показать остатки по товарам (чтобы быстро увидеть, что заканчивается).
-- Товары, у которых мало доступного количества (сортировка по возрастанию)
SELECT p.sku, p.name, s.available_quantity
FROM stock_item s
JOIN product p ON p.id = s.product_id -- из остатков идём к товару
ORDER BY s.available_quantity ASC
LIMIT 10;
Вопрос 3: вывести состав заказа по номеру заказа.
-- По номеру заказа получаем товары и количество в строках
SELECT o.order_number, p.sku, p.name, oi.quantity
FROM customer_order o
JOIN order_item oi ON oi.order_id = o.id -- строки заказа
JOIN product p ON p.id = oi.product_id -- данные товара
WHERE o.order_number = 'ORD-1001';
Если эти запросы выглядят естественно, значит, мы на правильном пути: схема нормализована, связи выражены ключами, и чтение не требует хранить дублирующие данные в каждой строке.
С этой картой в голове уже проще говорить не только о чтении, но и о том, как данные меняются: где появляется INSERT, что именно обновляет UPDATE и почему write-side почти всегда быстро упирается в транзакционную логику.
7. Типичные ошибки в схеме mini-shop
Ошибка №1: хранить категорию прямо в product строками.
category_code и category_name в одной таблице выглядят удобно, пока данные маленькие. Но любая опечатка ("Books " vs "Books") создаёт новую «категорию». Без FOREIGN KEY база не защищает вас от таких расхождений. Категория — это отдельная сущность, а не строка «на всякий случай».
Ошибка №2: смешивать каталог и остатки в одной таблице.
Добавить stock_quantity в product кажется проще, чем вводить stock_item. Но вы тем самым объединяете два разных смысла: описание товара и его текущее состояние. В результате таблица каталога начинает постоянно обновляться и теряет свою стабильность как справочник.
Ошибка №3: забывать про UNIQUE на ключевых полях.
Без UNIQUE на sku база спокойно примет дубликаты, и проблема проявится только позже — например, при поиске товара. Ограничения — это не «дополнение», а часть модели. Они защищают данные там, где код может ошибиться.
Ошибка №4: не фиксировать цену в order_item.
Если брать цену из product.price, вы привязываете заказ к текущему состоянию каталога. При изменении цены старые заказы «пересчитываются», что ломает смысл заказа как исторического документа. unit_price нужен, чтобы зафиксировать факт покупки во времени.
Ошибка №5: хранить списки значений в одной колонке (строкой или JSON).
"SKU1,SKU2,SKU3" или JSON в колонке выглядит как быстрый путь, но ломает возможности базы: фильтрацию, индексацию, связи. Любая аналитика превращается в поиск по тексту. Правильная модель — отдельная таблица order_item, где каждый элемент живёт как строка.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ