Проектування баз даних

Фізична схема: Від абстракції до DDL

Матеріалізуємо логічну схему в реальний SQL. Типи даних, обмеження, індекси та DML — повний DDL платформи аудіокниг на діалекті H2 з нотатками для PostgreSQL.

Фізична схема: Від абстракції до DDL

Вступ: Остання миля проектування

Ми пройшли довгий шлях. На концептуальному рівні ми виявили сутності, атрибути та зв'язки нашої аудіоплатформи. На логічному — трансформували їх у реляційні таблиці з ключами та обмеженнями цілісності. На нормалізаційному — перевірили схему на відсутність аномалій.

Тепер настає фізичний рівень (Physical Level) — фінальний крок, де абстрактна схема стає реальним SQL-кодом, виконуваним у конкретній СУБД. Саме тут з'являються типи даних (UUID, VARCHAR(64), INTEGER), синтаксис обмежень, індекси та команди наповнення бази даними.

Фізичний рівень — найбільш СУБД-специфічний. Одна й та сама логічна схема матиме дещо відмінний синтаксис у PostgreSQL, H2, SQLite або Oracle. У цьому модулі ми використовуємо H2 як основний діалект (оскільки саме H2 є рушієм для Java-проєктів з Data Mapper, що розглядаються далі), але там, де синтаксис суттєво відрізняється від PostgreSQL — наводимо паралельний варіант.

H2 — це відкрита реляційна СУБД, написана на Java, яка підтримує роботу як у вбудованому режимі (embedded, без окремого сервера), так і в серверному. Вона надзвичайно зручна для розробки і тестування Java-додатків: запускається в пам'яті, не потребує інсталяції, підтримує JDBC та Flyway. Синтаксис H2 близький до стандарту SQL-2003 і значною мірою сумісний із PostgreSQL.

SQL як мова визначення даних (DDL)

SQL (Structured Query Language) поділяється на кілька підмов. Нас зараз цікавить DDL — Data Definition Language, мова визначення даних. DDL описує структуру бази: таблиці, типи, обмеження, індекси.

Основні DDL-команди:

КомандаПризначення
CREATE TABLEСтворити нову таблицю
ALTER TABLEЗмінити структуру існуючої таблиці
DROP TABLEВидалити таблицю (і всі її дані)
CREATE INDEXСтворити індекс
CREATE TYPEСтворити користувацький тип (ENUM тощо)

Порядок виконання DDL: Залежності між таблицями

Перш ніж писати CREATE TABLE, важливо усвідомити: таблиці не є незалежними. Зовнішній ключ audiobooks.author_id → authors.id означає, що таблиця authors повинна існувати раніше, ніж audiobooks. Якщо порядок порушений — СУБД поверне помилку при спробі створити FK на неіснуючу таблицю.

Правило просте: спочатку створювати батьківські таблиці, потім — дочірні. Для нашої схеми порядок такий:

Крок 1: Типи даних (ENUM)

file_format_enum — його потрібно оголосити до таблиці audiobook_files.

Крок 2: Незалежні сутності (довідники)

authors, genres, users — жодних FK між ними; їх можна створювати в довільному порядку відносно одне одного.

Крок 3: Залежні сутності

audiobooks (FK → authors, genres), collections (FK → users).

Крок 4: Слабкі сутності та junction-таблиці

audiobook_files (FK → audiobooks), audiobook_collection (FK → collections, audiobooks), listening_progresses (FK → users, audiobooks).

Цей порядок є не просто рекомендацією — він є вимогою сумлінно реалізованої посилальної цілісності. Порушення порядку або призведе до помилки, або — якщо FK-перевірку тимчасово вимкнено — до некоректного стану схеми.


Типи даних: Перша лінія оборони

Тип даних — це не лише технічна деталь. Вибір типу є архітектурним рішенням, що впливає на коректність, продуктивність та читабельність схеми. Тип є першою лінією бізнес-валідації: якщо duration оголошено як INTEGER — жодна рядкова помилка не потрапить у цей стовпець фізично.

UUID — Ідентифікатори

Усі PK нашої схеми мають тип UUID. У H2 тип UUID зберігається як 128-бітне значення і підтримується нативно. У PostgreSQL — аналогічно. У SQLite — як TEXT або BLOB.

-- H2 та PostgreSQL: UUID як нативний тип
id UUID

При вставці значення UUID можна генерувати на стороні Java (UUID.randomUUID()) або просити СУБД через функцію:

-- H2
INSERT INTO authors (id, ...) VALUES (RANDOM_UUID(), ...);

-- PostgreSQL (розширення uuid-ossp)
INSERT INTO authors (id, ...) VALUES (gen_random_uuid(), ...);

VARCHAR та TEXT — Рядкові типи

VARCHAR(n) — рядок зі максимальною довжиною n символів. Якщо значення коротше — зберігається як є (без доповнення пробілами, на відміну від CHAR(n)).

Вибір максимальної довжини — не довільний. Розглянемо рішення для кожного рядкового поля аудіоплатформи:

СтовпецьТипОбґрунтування
first_name, last_nameVARCHAR(64)Імена рідко перевищують 64 символи; стандарт для більшості систем
usernameVARCHAR(64)Логіни зазвичай лімітовані 32–64 символами
emailVARCHAR(376)Максимальна допустима довжина email за RFC 5321
titleVARCHAR(255)Назви книг рідко перевищують 255 символів
file_path, image_pathVARCHAR(2048)Максимальна довжина URL у більшості браузерів
bio, descriptionTEXTНеобмежений текст; для довгих полів без верхньої межі
TEXT vs VARCHAR у H2/PostgreSQL. В обох СУБД TEXT і VARCHAR без обмеження є фактично еквівалентними за продуктивністю. Різниця суто семантична: VARCHAR(n) сигналізує читачеві, що є бізнес-обмеження на довжину, а TEXT — що поле необмежене за природою.

INTEGER — Числові типи

INTEGER (або INT) — 32-бітне ціле число (від -2,147,483,648 до 2,147,483,647). Для нашої схеми:

  • duration INTEGER NOT NULL — тривалість у секундах. Максимальне значення — ~2.1 млрд секунд (~68 роки), що з величезним запасом.
  • release_year INTEGER NOT NULL — рік виходу. 4-значне число від 1900 до ~2100.
  • size INTEGER — розмір файлу у байтах. Якщо файли можуть перевищувати ~2 ГБ, слід розглянути BIGINT (64-бітне, до ~9.2 × 10¹⁸).

ENUM — Перерахування

ENUM дозволяє обмежити значення стовпця фіксованим набором рядків. Це строго типізований варіант: СУБД відхилить будь-яке значення, що не входить до переліку.

-- H2: ENUM оголошується inline або як тип
-- Варіант 1: inline ENUM (H2 MODE=PostgreSQL або як окремий тип)
CREATE TYPE file_format_enum AS ENUM ('mp3', 'ogg', 'wav', 'm4b', 'aac', 'flac');

-- Потім використовується як тип стовпця:
format file_format_enum NOT NULL
ENUM: Переваги та ризики. ENUM захищає від некоректних значень на рівні БД і може бути дещо ефективнішим за VARCHAR. Але зміна ENUM — болісна операція: додавання нового значення у PostgreSQL вимагає ALTER TYPE, а у деяких СУБД — повного перестворення. Якщо перелік значень може часто змінюватися — розгляньте VARCHAR із CHECK-обмеженням або окрему таблицю-довідник.

TIMESTAMP — Дата і час

TIMESTAMP зберігає дату і час без інформації про часовий пояс. Для полів created_at, last_listened це підходить, якщо вся система працює в одному часовому поясі. Для міжнародних систем краще TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ у PostgreSQL).


Обмеження (Constraints): Повний DDL аудіоплатформи

Розглянемо повний DDL-скрипт схеми аудіоплатформи. Кожна таблиця прокоментована: пояснюємо призначення кожного обмеження та вибору типу.

-- Крок 1: Оголошуємо ENUM-тип до будь-яких таблиць
CREATE TYPE file_format_enum AS ENUM ('mp3', 'ogg', 'wav', 'm4b', 'aac', 'flac');

-- authors: незалежна сутність, PK — UUID
-- Відповідає 3НФ: всі атрибути залежать лише від id
CREATE TABLE authors (
    PRIMARY KEY (id),
    id          UUID            NOT NULL,  -- сурогатний ключ
    first_name  VARCHAR(64)     NOT NULL,  -- ім'я обов'язкове
    last_name   VARCHAR(64)     NOT NULL,  -- прізвище обов'язкове
    bio         TEXT,                      -- NULL: біографія необов'язкова
    image_path  VARCHAR(2048)              -- NULL: фото необов'язкове
);

Індекси: Коли читання важливіше за запис

Індекс (Index) — це допоміжна структура даних, що прискорює пошук рядків у таблиці за значенням певного стовпця (або кількох стовпців). Аналогія з книгою: алфавітний покажчик наприкінці дозволяє знайти потрібне слово без перегляду кожної сторінки.

За замовчуванням СУБД автоматично створює індекс для Primary Key. Для інших стовпців — лише якщо ми явно вкажемо CREATE INDEX.

Коли варто створювати індекс

Три основних сигнали для введення індексу:

  1. Стовпець є FK — рядки за FK шукаються при кожному JOIN. Без індексу СУБД виконуватиме повне сканування таблиці (Full Table Scan).
  2. Стовпець часто використовується у WHERE — наприклад, пошук за email користувача.
  3. Стовпець використовується у ORDER BY чи GROUP BY — індекс може усунути операцію сортування.

Аналіз індексів аудіоплатформи

Розглянемо, навіщо саме ці індекси були створені у DDL:

ІндексСтовпецьНавіщо
audiobooks_author_id_idxaudiobooks.author_idJOIN authors ON audiobooks.author_id = authors.id — найпоширеніший запит каталогу
audiobooks_genre_id_idxaudiobooks.genre_idФільтрація за жанром: WHERE genre_id = ?
users_email_idxusers.emailПошук користувача при вході (якщо вхід за email)
audiobook_files_audiobook_id_idxaudiobook_files.audiobook_idОтримання файлів аудіокниги при відтворенні
listening_progresses_user_id_idxlistening_progresses.user_idВсі прогреси конкретного користувача
listening_progresses_audiobook_id_idxlistening_progresses.audiobook_idСкільки людей слухали книгу
Індекс прискорює читання, але уповільнює запис. При кожному INSERT, UPDATE або DELETE СУБД повинна оновити усі відповідні індекси. Таблиця з 10 індексами буде записувати дані повільніше, ніж таблиця без індексів. Не додавайте індекси «про всяк випадок» — тільки там, де є виміряна або очевидна (FK) потреба.

DML: Наповнення бази тестовими даними

DML (Data Manipulation Language) — підмова SQL для маніпуляції даними: INSERT, UPDATE, DELETE, SELECT. На цьому етапі нас цікавить INSERT — початкове наповнення бази.

Порядок вставки: Дотримання посилальної цілісності

Як і зі створенням таблиць, вставка даних підпорядковується порядку залежностей: спочатку батьківські записи, потім — дочірні.

Крок 1: Автори та жанри

INSERT INTO authors (id, first_name, last_name, bio, image_path) VALUES
('550e8400-e29b-41d4-a716-446655440001', 'Олена',  'Шевченко',
 'Українська письменниця, авторка популярних романів.',
 '/images/authors/olena_shevchenko.jpg'),
('550e8400-e29b-41d4-a716-446655440002', 'Іван', 'Коваль',
 'Сучасний поет і прозаїк, лауреат літературних премій.',
 '/images/authors/ivan_koval.jpg');

INSERT INTO genres (id, name, description) VALUES
('660e8400-e29b-41d4-a716-446655440001', 'Фантастика',
 'Наукова фантастика, фентезі та альтернативна історія.'),
('660e8400-e29b-41d4-a716-446655440002', 'Роман',
 'Художня проза про людські стосунки.');

Крок 2: Аудіокниги та користувачі

-- audiobooks: вставляємо ПІСЛЯ authors та genres
INSERT INTO audiobooks (id, author_id, genre_id, title, duration, release_year) VALUES
('770e8400-e29b-41d4-a716-446655440001',
 '550e8400-e29b-41d4-a716-446655440002',  -- Іван Коваль
 '660e8400-e29b-41d4-a716-446655440001',  -- Фантастика
 'Космічна подорож', 7200, 2023);

INSERT INTO users (id, username, password_hash, email) VALUES
('880e8400-e29b-41d4-a716-446655440001',
 'oleksandr23', 'hashed_password_1', 'oleksandr23@gmail.com');

Крок 3: Колекції, файли та прогрес

-- collections: ПІСЛЯ users
INSERT INTO collections (id, user_id, name, created_at) VALUES
('990e8400-e29b-41d4-a716-446655440001',
 '880e8400-e29b-41d4-a716-446655440001',
 'Мої улюблені', '2025-01-15 10:00:00');

-- audiobook_files: ПІСЛЯ audiobooks
INSERT INTO audiobook_files (id, audiobook_id, file_path, format, size) VALUES
('aa0e8400-e29b-41d4-a716-446655440001',
 '770e8400-e29b-41d4-a716-446655440001',
 '/audio/kosmichna_podorozh.mp3', 'mp3', 150000000);

-- audiobook_collection: ПІСЛЯ collections та audiobooks
INSERT INTO audiobook_collection (collection_id, audiobook_id) VALUES
('990e8400-e29b-41d4-a716-446655440001',
 '770e8400-e29b-41d4-a716-446655440001');

Практичні завдання

Рівень 1 — Базовий: Читання DDL

Дано фрагмент DDL нової таблиці:

CREATE TABLE reviews (
    PRIMARY KEY (id),
    id           UUID        NOT NULL,
    user_id      UUID        NOT NULL,
                  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    audiobook_id UUID        NOT NULL,
                  FOREIGN KEY (audiobook_id) REFERENCES audiobooks(id) ON DELETE CASCADE,
    rating       INTEGER     NOT NULL,
                 CHECK (rating >= 1 AND rating <= 5),
    comment      TEXT,
    created_at   TIMESTAMP   NOT NULL
);

Завдання:

  1. Які сутності пов'язує ця таблиця? Яка її роль у схемі (зв'язок, слабка сутність, тощо)?
  2. Яке обмеження цілісності захищає від рейтингу «0» або «10»?
  3. Чому comment не має NOT NULL?
  4. Які індекси варто додати і чому?

Рівень 2 — Проектування: DDL для нового функціоналу

Платформа вводить систему підписок (Subscription). Вимоги:

  • Є три плани: free, basic, premium.
  • Кожен користувач у будь-який момент має рівно один активний план.
  • Зберігається дата початку та закінчення підписки.
  • Потрібно мати можливість бачити історію підписок користувача.

Завдання:

  1. Спроектуйте таблицю (або таблиці) для цього функціоналу.
  2. Напишіть повний DDL із типами даних, обмеженнями та індексами.
  3. Як реалізувати обмеження «лише одна активна підписка»? Чи потрібен CHECK або логіка на рівні додатку?

Рівень 3 — Критичний аналіз: Повна фізична схема

Проаналізуйте DDL вашої схеми (з рівня 2 або власний проєкт):

  1. Перевірте порядок CREATE TABLE — чи всі залежності дотримані?
  2. Перевірте кожне NOT NULL — чи є бізнес-обґрунтування для кожного?
  3. Перевірте кожен ON DELETE — яка стратегія і чому?
  4. Чи є поля, що потребують CHECK, але їх немає? Додайте.
  5. Запропонуйте нові індекси на основі найімовірніших запитів.

Підсумок

Фізичний рівень — це місце, де проектування завершується і починається реалізація. На цьому рівні всі абстрактні рішення лопереднього етапу набувають конкретної форми:

  • Типи даних захищають від некоректних значень на рівні СУБД (UUID, INTEGER, ENUM, VARCHAR).
  • Іменовані обмеження (CONSTRAINT) роблять схему самодокументованою і полегшують відладку помилок.
  • Порядок CREATE TABLE визначається графом залежностей FK: батьківські таблиці — першими.
  • Індекси — лише там, де вони вимірювано необхідні: FK-поля, поля частого пошуку.
  • Порядок INSERT у DML слідує тій самій логіці залежностей.

У наступній статті ми розглянемо архітектурну класифікацію таблиць: чим відрізняються статичні довідники від транзакційних таблиць і які стратегії роботи з кожним типом є найефективнішими.