Проектирование схемы и создание таблиц
Проектирование схемы и создание таблиц
В предыдущем уроке вы сформулировали задачу и выделили сущности. Теперь переводим концептуальную модель в SQL. Это центральный урок проекта: полная DDL-схема блог-платформы с объяснением каждого решения.
Порядок создания таблиц
Помните правило: родительская таблица должна существовать до дочерней. Наш порядок:
users— не зависит ни от когоcategories— не зависит ни от когоtags— не зависит ни от когоposts— зависит отusersиcategoriespost_tags— зависит отpostsиtagscomments— зависит отpostsиuserslikes— зависит отpostsиusers
Таблица пользователей
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
bio TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE users IS 'Зарегистрированные пользователи: авторы и читатели';
COMMENT ON COLUMN users.bio IS 'Краткая биография, необязательна';
BIGINT GENERATED ALWAYS AS IDENTITY — современный SQL-стандарт вместо SERIAL. Пользователей может быть много — BIGINT (8 байт) не переполнится. updated_at обновляется при каждом изменении через триггер или в приложении.
Таблица категорий и тегов
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL
);
slug — URL-безопасная версия имени: technology, machine-learning. Уникальный, потому что URL должен быть однозначным. Для tags нет created_at — теги обычно долгоживущие объекты, время создания неважно.
Таблица постов
CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
body TEXT NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
published_at TIMESTAMPTZ, -- NULL пока черновик
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_published_at CHECK (
(published = FALSE AND published_at IS NULL) OR
(published = TRUE AND published_at IS NOT NULL)
)
);
COMMENT ON COLUMN posts.published_at IS 'NULL для черновиков; заполняется при публикации';
Ключевые решения:
ON DELETE CASCADEдляauthor_id— удаление автора удаляет его постыON DELETE RESTRICTдляcategory_id— нельзя удалить категорию с постамиCHECKгарантирует консистентность: опубликованный пост имеетpublished_at, черновик — нетslug— уникальный URL-путь поста:/posts/why-sql-matters
Промежуточная таблица постов и тегов
CREATE TABLE post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
Составной PRIMARY KEY предотвращает дубли. Оба CASCADE: удаление поста удаляет его теги, удаление тега удаляет связи (не сами посты).
Таблица комментариев
CREATE TABLE comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_comment_content CHECK (length(trim(content)) > 0)
);
CHECK (length(trim(content)) > 0) запрещает пустые комментарии (пробелы обрезаются перед проверкой). Оба FK с CASCADE — удаление поста или пользователя удаляет комментарии.
Таблица лайков
CREATE TABLE likes (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (post_id, user_id)
);
Составной PRIMARY KEY — гарантия: один пользователь не лайкнет пост дважды. Нет суррогатного id — в промежуточных таблицах он обычно не нужен.
Индексы
-- FK без автоматических индексов (все FK-колонки)
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_category_id ON posts(category_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_likes_post_id ON likes(post_id);
CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
-- Составной: часто ищем опубликованные посты в хронологии
CREATE INDEX idx_posts_published_at ON posts(published, published_at DESC)
WHERE published = TRUE;
-- Полнотекстовый поиск по заголовкам
CREATE INDEX idx_posts_title_gin ON posts USING GIN(to_tsvector('russian', title));
Частичный индекс WHERE published = TRUE — меньше размера и быстрее, чем полный, если черновиков много.
Тонкости выбора типов данных
Разберём выбор типов для каждой таблицы подробнее.
id колонки: BIGINT GENERATED AS IDENTITY vs SERIAL:
BIGINT (8 байт) против INTEGER (4 байта) — для users и posts используем BIGINT, потому что платформа может вырасти. Для categories и tags — SERIAL (INTEGER): их несколько сотен максимум.
VARCHAR(n) vs TEXT:
username VARCHAR(50)— бизнес-ограничение: имя пользователя не может быть длиннее 50 символовtitle TEXT— нет естественного ограничения на заголовок постаslug VARCHAR(200)— URL-сегмент, ограничен практически;TEXTтоже допустим
Почему NOT NULL DEFAULT NOW() для created_at, а не просто DEFAULT NOW():
NOT NULL — явная гарантия, что запись никогда не создаётся без временной метки. DEFAULT NOW() удобство — но если не указать NOT NULL, приложение теоретически может вставить NULL. Комбинация — лучшая практика.
published_at TIMESTAMPTZ без NOT NULL:
Поле намеренно nullable — NULL означает «не опубликовано». CHECK ограничение гарантирует, что NULL здесь — не ошибка, а семантика черновика.
Управление публикацией: паттерн «publish action»
Обратите внимание на дизайн публикации:
-- При публикации поста приложение выполняет:
UPDATE posts
SET published = TRUE,
published_at = NOW(),
updated_at = NOW()
WHERE id = 42 AND published = FALSE; -- идемпотентно
-- При снятии с публикации:
UPDATE posts
SET published = FALSE,
published_at = NULL,
updated_at = NOW()
WHERE id = 42;
CHECK-ограничение (published = TRUE AND published_at IS NOT NULL) OR (published = FALSE AND published_at IS NULL) гарантирует, что эти два поля всегда согласованы. Если приложение случайно установит published = TRUE без published_at — база откажет.
Это пример использования CHECK для бизнес-правила, которое нельзя выразить одним ограничением на колонке.
Проверка схемы
После создания таблиц — убедиться, что всё корректно:
-- Посмотреть структуру таблицы posts
\d+ posts
-- Посмотреть все FK
SELECT
tc.table_name, kcu.column_name,
ccu.table_name AS references_table,
rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name
JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name
JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
ORDER BY tc.table_name;
-- Проверить все индексы
SELECT tablename, indexname FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename;
Полный скрипт создания схемы
Собираем всё в один файл миграции — стандартный подход в реальных проектах:
-- V1__create_blog_schema.sql
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
bio TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
body TEXT NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_published_at CHECK (
(published = FALSE AND published_at IS NULL) OR
(published = TRUE AND published_at IS NOT NULL)
)
);
CREATE TABLE post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE TABLE comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_comment_content CHECK (length(trim(content)) > 0)
);
CREATE TABLE likes (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (post_id, user_id)
);
-- Индексы на FK
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_category_id ON posts(category_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_likes_post_id ON likes(post_id);
CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
-- Дополнительные индексы
CREATE INDEX idx_posts_published_at ON posts(published_at DESC)
WHERE published = TRUE;
Этот скрипт можно запустить в psql командой \i V1__create_blog_schema.sql или через любой инструмент миграций.
Итоговая схема
Семь таблиц, 6 связей, 12 индексов, 2 CHECK-ограничения. Схема обеспечивает:
- Ссылочную целостность через FK и правильные
ON DELETE - Бизнес-правила через
CHECK - Производительность через индексы
- Документацию через
COMMENT ON
Это законченная, production-ориентированная схема, готовая к работе с реальными данными.
Что дальше
Схема создана. Следующий урок — заполнение тестовыми данными и выполнение базовых запросов: проверим, что схема работает так, как задумано.