JavaRush /Курсы /Spring Data JPA /SQL‑карта mini‑shop

SQL‑карта mini‑shop

Spring Data JPA
2 уровень , 4 лекция
Открыта

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, где каждый элемент живёт как строка.

1
Задача
Spring Data JPA, 2 уровень, 4 лекция
Недоступна
Стартовая схема mini-shop
Стартовая схема mini-shop
1
Задача
Spring Data JPA, 2 уровень, 4 лекция
Недоступна
Чтение одного заказа на полной схеме mini-shop
Чтение одного заказа на полной схеме mini-shop
1
Опрос
База данных, 2 уровень, 4 лекция
Недоступен
База данных
Таблицы, связи, ограничения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ