Связи: один-к-одному, один-ко-многим, многие-ко-многим
Связи: один-к-одному, один-ко-многим, многие-ко-многим
В модуле 6 вы использовали JOIN для соединения таблиц. За каждым JOIN стоит тип связи между сущностями. Понимание типов связей — основа проектирования реляционной схемы. Неправильный тип связи делает запросы громоздкими, а данные — избыточными или теряющими целостность.
Зачем различать типы связей
Связь описывает, сколько записей одной сущности может быть связано со сколькими записями другой. Три фундаментальных типа:
- Один-к-одному (1:1): каждая запись A связана ровно с одной записью B, и наоборот
- Один-ко-многим (1:N): каждая запись A связана с несколькими записями B, но каждая B — только с одной A
- Многие-ко-многим (M:N): каждая запись A может быть связана со многими B, и каждая B — со многими A
Тип связи определяет, как расположить данные в таблицах и где ставить внешний ключ.
Один-ко-многим (1:N): самый распространённый
Клиент может сделать много заказов, но каждый заказ принадлежит одному клиенту — это 1:N.
-- Один клиент (1) → много заказов (N)
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
total NUMERIC(10, 2)
);
Внешний ключ (customer_id) всегда находится на стороне «многие» — в orders. Каждая строка orders ссылается на одного клиента; у одного клиента может быть много заказов.
Другие примеры 1:N:
- Пользователь → много постов
- Категория → много товаров
- Отдел → много сотрудников
- Заказ → много позиций заказа
Запрос: все заказы конкретного клиента:
SELECT o.id, o.total FROM orders o
WHERE o.customer_id = 42;
Один-к-одному (1:1): расширение сущности
Каждый пользователь имеет ровно один профиль; каждый профиль принадлежит ровно одному пользователю — это 1:1.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE user_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
bio TEXT,
avatar_url TEXT,
website TEXT
);
user_id — одновременно PRIMARY KEY и FOREIGN KEY. PRIMARY KEY гарантирует уникальность — у одного пользователя ровно один профиль.
Связь 1:1 применяется, когда:
- Одна сущность используется редко (профиль читается реже, чем базовые данные)
- Данные секретны (платёжная информация в отдельной таблице)
- Сущность появляется позже (профиль создаётся после регистрации)
Альтернатива — все данные в одной таблице. Решение зависит от паттерна использования: если профиль читается в каждом запросе, лучше хранить вместе.
Многие-ко-многим (M:N): через промежуточную таблицу
Студент может записаться на много курсов; на один курс могут записаться много студентов — это M:N.
В реляционной модели M:N нельзя реализовать напрямую. Нужна промежуточная таблица (junction table, cross-reference table):
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL
);
-- Промежуточная таблица
CREATE TABLE student_courses (
student_id INTEGER NOT NULL REFERENCES students(id),
course_id INTEGER NOT NULL REFERENCES courses(id),
enrolled_at DATE DEFAULT CURRENT_DATE,
PRIMARY KEY (student_id, course_id)
);
student_courses хранит каждую пару «студент-курс». Составной PRIMARY KEY (student_id, course_id) гарантирует, что студент не запишется на один курс дважды. Промежуточная таблица может хранить дополнительные атрибуты — enrolled_at, оценку, статус.
Другие примеры M:N:
- Товары ↔ Заказы (позиции заказа — промежуточная таблица)
- Авторы ↔ Книги
- Теги ↔ Статьи
- Актёры ↔ Фильмы (роли — промежуточная таблица с атрибутом
role_name)
Запрос: все курсы студента с именем 'Анна':
SELECT c.title, sc.enrolled_at
FROM student_courses sc
JOIN students s ON s.id = sc.student_id
JOIN courses c ON c.id = sc.course_id
WHERE s.name = 'Анна';
Выбор места для внешнего ключа
| Тип | Где FK | Пример |
|---|---|---|
| 1:N | В таблице «многие» | orders.customer_id → customers.id |
| 1:1 | В «расширяющей» таблице | user_profiles.user_id → users.id |
| M:N | В промежуточной таблице | Оба FK в student_courses |
Самоссылающиеся связи (рекурсивные)
Иногда сущность ссылается на себя:
-- Дерево категорий
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(id) -- NULL = корневая категория
);
-- Иерархия сотрудников
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
manager_id INTEGER REFERENCES employees(id) -- NULL = нет менеджера
);
parent_id IS NULL — корневой узел дерева. Для обхода деревьев произвольной глубины используются рекурсивные CTE (изучены в модуле 7) или SELF JOIN (модуль 6).
Промежуточная таблица с атрибутами
Промежуточная таблица M:N может превращаться в полноценную сущность:
-- Не просто «пользователь добавил товар в корзину»,
-- а «позиция корзины» с количеством и ценой на момент добавления
CREATE TABLE cart_items (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
price_snapshot NUMERIC(10, 2), -- цена зафиксирована при добавлении
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, product_id)
);
Промежуточная таблица с SERIAL PRIMARY KEY и атрибутами — полноценная сущность. Это нормально и часто необходимо в реальных схемах.
Ментальная модель связей
customers ──────< orders ──────< order_items >────── products
1 : N 1 : N (M:N через order_items)
users ──── user_profiles
1 : 1
employees >────── employees
рекурсивная 1 : N (менеджер → подчинённые)
──────< читается как «один к многим». В каждом случае FK находится на стороне «<».
Полиморфные связи: антипаттерн и его решение
Иногда возникает соблазн создать «универсальный» FK, который может ссылаться на разные таблицы:
-- Антипаттерн: комментарии к разным сущностям
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
target_type TEXT NOT NULL, -- 'post', 'photo', 'video'
target_id INTEGER NOT NULL -- ID в соответствующей таблице
-- target_id не может быть FOREIGN KEY!
);
Это называется полиморфной ассоциацией. Проблема: target_id не может иметь FOREIGN KEY — ссылочная целостность не гарантирована. PostgreSQL не знает, в какой таблице искать.
Правильные решения:
Вариант 1: отдельная таблица для каждого типа комментариев:
CREATE TABLE post_comments (id SERIAL PK, post_id INTEGER REFERENCES posts(id), content TEXT);
CREATE TABLE photo_comments (id SERIAL PK, photo_id INTEGER REFERENCES photos(id), content TEXT);
Вариант 2: базовая таблица сущностей:
CREATE TABLE commentable_items (id SERIAL PRIMARY KEY, type TEXT NOT NULL);
CREATE TABLE posts (id INTEGER PRIMARY KEY REFERENCES commentable_items(id), title TEXT);
CREATE TABLE photos (id INTEGER PRIMARY KEY REFERENCES commentable_items(id), url TEXT);
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES commentable_items(id),
content TEXT NOT NULL
);
Второй вариант сложнее, но обеспечивает целостность и единый интерфейс для комментариев.
Мощность (cardinality) и обязательность
Кроме типа связи (1:1, 1:N, M:N) важно указать обязательность:
- Обязательная связь:
customer_id NOT NULL— заказ обязательно должен принадлежать клиенту - Необязательная связь:
manager_idбезNOT NULL— CEO не имеет менеджера
Обязательность определяет, нужен ли NOT NULL на FK-колонке. Пропустить это — значит допустить «потерянные» строки (заказ без клиента).
Для M:N промежуточной таблицы оба FK обычно NOT NULL — запись связи без обоих участников лишена смысла.
Проверь себя
Как смоделировать связь «пользователь может лайкнуть несколько постов, пост может получить лайки от многих пользователей»? Какой тип? Что хранить в таблице лайков кроме FK?
Краткий итог
- 1:N — FK в таблице «многие»; самый распространённый тип связи
- 1:1 — FK + PRIMARY KEY в «расширяющей» таблице; для разделения данных
- M:N — промежуточная таблица с двумя FK; составной PK предотвращает дубли
- Промежуточная таблица может содержать атрибуты связи (дата, статус, количество)
- Рекурсивная связь: FK ссылается на PK той же таблицы (
parent_id) - Обязательность связи определяет
NOT NULLна FK
Что дальше
Вы понимаете типы связей. Следующий урок — нормализация: как привести схему к нормальным формам, чтобы избежать аномалий при обновлении и удалении данных.