Ми пройшли довгий шлях. На концептуальному рівні ми виявили сутності, атрибути та зв'язки нашої аудіоплатформи. На логічному — трансформували їх у реляційні таблиці з ключами та обмеженнями цілісності. На нормалізаційному — перевірили схему на відсутність аномалій.
Тепер настає фізичний рівень (Physical Level) — фінальний крок, де абстрактна схема стає реальним SQL-кодом, виконуваним у конкретній СУБД. Саме тут з'являються типи даних (UUID, VARCHAR(64), INTEGER), синтаксис обмежень, індекси та команди наповнення бази даними.
Фізичний рівень — найбільш СУБД-специфічний. Одна й та сама логічна схема матиме дещо відмінний синтаксис у PostgreSQL, H2, SQLite або Oracle. У цьому модулі ми використовуємо H2 як основний діалект (оскільки саме H2 є рушієм для Java-проєктів з Data Mapper, що розглядаються далі), але там, де синтаксис суттєво відрізняється від PostgreSQL — наводимо паралельний варіант.
SQL (Structured Query Language) поділяється на кілька підмов. Нас зараз цікавить DDL — Data Definition Language, мова визначення даних. DDL описує структуру бази: таблиці, типи, обмеження, індекси.
Основні DDL-команди:
| Команда | Призначення |
|---|---|
CREATE TABLE | Створити нову таблицю |
ALTER TABLE | Змінити структуру існуючої таблиці |
DROP TABLE | Видалити таблицю (і всі її дані) |
CREATE INDEX | Створити індекс |
CREATE TYPE | Створити користувацький тип (ENUM тощо) |
Перш ніж писати CREATE TABLE, важливо усвідомити: таблиці не є незалежними. Зовнішній ключ audiobooks.author_id → authors.id означає, що таблиця authors повинна існувати раніше, ніж audiobooks. Якщо порядок порушений — СУБД поверне помилку при спробі створити FK на неіснуючу таблицю.
Правило просте: спочатку створювати батьківські таблиці, потім — дочірні. Для нашої схеми порядок такий:
file_format_enum — його потрібно оголосити до таблиці audiobook_files.
authors, genres, users — жодних FK між ними; їх можна створювати в довільному порядку відносно одне одного.
audiobooks (FK → authors, genres), collections (FK → users).
audiobook_files (FK → audiobooks), audiobook_collection (FK → collections, audiobooks), listening_progresses (FK → users, audiobooks).
Цей порядок є не просто рекомендацією — він є вимогою сумлінно реалізованої посилальної цілісності. Порушення порядку або призведе до помилки, або — якщо FK-перевірку тимчасово вимкнено — до некоректного стану схеми.
Тип даних — це не лише технічна деталь. Вибір типу є архітектурним рішенням, що впливає на коректність, продуктивність та читабельність схеми. Тип є першою лінією бізнес-валідації: якщо duration оголошено як INTEGER — жодна рядкова помилка не потрапить у цей стовпець фізично.
Усі 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(n) — рядок зі максимальною довжиною n символів. Якщо значення коротше — зберігається як є (без доповнення пробілами, на відміну від CHAR(n)).
Вибір максимальної довжини — не довільний. Розглянемо рішення для кожного рядкового поля аудіоплатформи:
| Стовпець | Тип | Обґрунтування |
|---|---|---|
first_name, last_name | VARCHAR(64) | Імена рідко перевищують 64 символи; стандарт для більшості систем |
username | VARCHAR(64) | Логіни зазвичай лімітовані 32–64 символами |
email | VARCHAR(376) | Максимальна допустима довжина email за RFC 5321 |
title | VARCHAR(255) | Назви книг рідко перевищують 255 символів |
file_path, image_path | VARCHAR(2048) | Максимальна довжина URL у більшості браузерів |
bio, description | TEXT | Необмежений текст; для довгих полів без верхньої межі |
TEXT vs VARCHAR у H2/PostgreSQL. В обох СУБД TEXT і VARCHAR без обмеження є фактично еквівалентними за продуктивністю. Різниця суто семантична: VARCHAR(n) сигналізує читачеві, що є бізнес-обмеження на довжину, а TEXT — що поле необмежене за природою.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 дозволяє обмежити значення стовпця фіксованим набором рядків. Це строго типізований варіант: СУБД відхилить будь-яке значення, що не входить до переліку.
-- 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
-- PostgreSQL: ідентичний синтаксис
CREATE TYPE file_format_enum AS ENUM ('mp3', 'ogg', 'wav', 'm4b', 'aac', 'flac');
-- Додавання нового значення без перестворення типу:
ALTER TYPE file_format_enum ADD VALUE 'opus';
VARCHAR. Але зміна ENUM — болісна операція: додавання нового значення у PostgreSQL вимагає ALTER TYPE, а у деяких СУБД — повного перестворення. Якщо перелік значень може часто змінюватися — розгляньте VARCHAR із CHECK-обмеженням або окрему таблицю-довідник.TIMESTAMP зберігає дату і час без інформації про часовий пояс. Для полів created_at, last_listened це підходить, якщо вся система працює в одному часовому поясі. Для міжнародних систем краще TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ у PostgreSQL).
Розглянемо повний 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: фото необов'язкове
);
-- genres: статичний довідник жанрів
-- UNIQUE (name): назва жанру унікальна в системі
CREATE TABLE genres (
PRIMARY KEY (id),
id UUID NOT NULL,
name VARCHAR(64) NOT NULL,
CONSTRAINT genres_name_key
UNIQUE (name), -- унікальна назва
description TEXT -- NULL: опис необов'язковий
);
-- users: основна сутність; username унікальний та не порожній
CREATE TABLE users (
PRIMARY KEY (id),
id UUID NOT NULL,
username VARCHAR(64) NOT NULL,
CONSTRAINT users_username_key
UNIQUE (username), -- логін унікальний
CONSTRAINT users_username_not_empty_check
CHECK (length(trim(username)) > 0), -- не порожній рядок
password_hash VARCHAR(128) NOT NULL, -- зберігаємо лише хеш, ніколи пароль
email VARCHAR(376), -- NULL: email необов'язковий
avatar_path VARCHAR(2048) -- NULL
);
CREATE INDEX users_email_idx ON users(email); -- прискорення пошуку за email
-- audiobooks: залежить від authors та genres (FK)
-- CHECK: duration > 0 і release_year у допустимому діапазоні
CREATE TABLE audiobooks (
PRIMARY KEY (id),
id UUID NOT NULL,
author_id UUID NOT NULL,
CONSTRAINT audiobooks_author_id_authors_id_fkey
FOREIGN KEY (author_id)
REFERENCES authors(id)
ON DELETE CASCADE, -- видалення автора → видалення книг
genre_id UUID NOT NULL,
CONSTRAINT audiobooks_genre_id_genres_id_fkey
FOREIGN KEY (genre_id)
REFERENCES genres(id)
ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
duration INTEGER NOT NULL,
CONSTRAINT audiobooks_duration_positive_check
CHECK (duration > 0), -- тривалість > 0 секунд
release_year INTEGER NOT NULL,
CONSTRAINT audiobooks_release_year_check
CHECK (release_year >= 1900
AND release_year <= EXTRACT(YEAR FROM CURRENT_DATE) + 1),
description TEXT,
cover_image_path VARCHAR(2048)
);
-- Індекси за FK-полями: прискорюють JOIN та WHERE
CREATE INDEX audiobooks_author_id_idx ON audiobooks(author_id);
CREATE INDEX audiobooks_genre_id_idx ON audiobooks(genre_id);
-- collections: слабка сутність, залежить від users
CREATE TABLE collections (
PRIMARY KEY (id),
id UUID NOT NULL,
user_id UUID, -- NULL допускається (проєктне рішення)
CONSTRAINT collections_user_id_users_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE,
name VARCHAR(128) NOT NULL,
CONSTRAINT collections_name_not_empty_check
CHECK (length(trim(name)) > 0),
created_at TIMESTAMP
);
-- audiobook_collection: junction-таблиця M:N
-- Складений PK гарантує: книга не може бути в колекції двічі
CREATE TABLE audiobook_collection (
PRIMARY KEY (collection_id, audiobook_id),
collection_id UUID NOT NULL,
CONSTRAINT audiobook_collection_collection_id_fkey
FOREIGN KEY (collection_id)
REFERENCES collections(id) ON DELETE CASCADE,
audiobook_id UUID NOT NULL,
CONSTRAINT audiobook_collection_audiobook_id_fkey
FOREIGN KEY (audiobook_id)
REFERENCES audiobooks(id) ON DELETE CASCADE
);
-- audiobook_files: слабка сутність, залежить від audiobooks
CREATE TABLE audiobook_files (
PRIMARY KEY (id),
id UUID NOT NULL,
audiobook_id UUID NOT NULL,
CONSTRAINT audiobook_files_audiobook_id_fkey
FOREIGN KEY (audiobook_id)
REFERENCES audiobooks(id) ON DELETE CASCADE,
file_path VARCHAR(2048) NOT NULL,
CONSTRAINT audiobook_files_file_path_not_empty_check
CHECK (length(trim(file_path)) > 0),
format file_format_enum NOT NULL, -- тільки значення з ENUM
size INTEGER,
CONSTRAINT audiobook_files_size_positive_check
CHECK (size IS NULL OR size > 0) -- NULL або > 0
);
CREATE INDEX audiobook_files_audiobook_id_idx ON audiobook_files(audiobook_id);
-- listening_progresses: зв'язок з атрибутами (User ↔ Audiobook)
CREATE TABLE listening_progresses (
PRIMARY KEY (id),
id UUID NOT NULL,
user_id UUID,
CONSTRAINT listening_progresses_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
audiobook_id UUID NOT NULL,
CONSTRAINT listening_progresses_audiobook_id_fkey
FOREIGN KEY (audiobook_id)
REFERENCES audiobooks(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
CONSTRAINT listening_progresses_position_positive_check
CHECK (position > 0), -- позиція у секундах > 0
last_listened TIMESTAMP -- NULL: ще не слухали
);
CREATE INDEX listening_progresses_user_id_idx ON listening_progresses(user_id);
CREATE INDEX listening_progresses_audiobook_id_idx ON listening_progresses(audiobook_id);
Індекс (Index) — це допоміжна структура даних, що прискорює пошук рядків у таблиці за значенням певного стовпця (або кількох стовпців). Аналогія з книгою: алфавітний покажчик наприкінці дозволяє знайти потрібне слово без перегляду кожної сторінки.
За замовчуванням СУБД автоматично створює індекс для Primary Key. Для інших стовпців — лише якщо ми явно вкажемо CREATE INDEX.
Три основних сигнали для введення індексу:
JOIN. Без індексу СУБД виконуватиме повне сканування таблиці (Full Table Scan).WHERE — наприклад, пошук за email користувача.ORDER BY чи GROUP BY — індекс може усунути операцію сортування.Розглянемо, навіщо саме ці індекси були створені у DDL:
| Індекс | Стовпець | Навіщо |
|---|---|---|
audiobooks_author_id_idx | audiobooks.author_id | JOIN authors ON audiobooks.author_id = authors.id — найпоширеніший запит каталогу |
audiobooks_genre_id_idx | audiobooks.genre_id | Фільтрація за жанром: WHERE genre_id = ? |
users_email_idx | users.email | Пошук користувача при вході (якщо вхід за email) |
audiobook_files_audiobook_id_idx | audiobook_files.audiobook_id | Отримання файлів аудіокниги при відтворенні |
listening_progresses_user_id_idx | listening_progresses.user_id | Всі прогреси конкретного користувача |
listening_progresses_audiobook_id_idx | listening_progresses.audiobook_id | Скільки людей слухали книгу |
INSERT, UPDATE або DELETE СУБД повинна оновити усі відповідні індекси. Таблиця з 10 індексами буде записувати дані повільніше, ніж таблиця без індексів. Не додавайте індекси «про всяк випадок» — тільки там, де є виміряна або очевидна (FK) потреба.DML (Data Manipulation Language) — підмова SQL для маніпуляції даними: INSERT, UPDATE, DELETE, SELECT. На цьому етапі нас цікавить INSERT — початкове наповнення бази.
Як і зі створенням таблиць, вставка даних підпорядковується порядку залежностей: спочатку батьківські записи, потім — дочірні.
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', 'Роман',
'Художня проза про людські стосунки.');
-- 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');
-- 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');
Дано фрагмент 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
);
Завдання:
comment не має NOT NULL?Платформа вводить систему підписок (Subscription). Вимоги:
free, basic, premium.Завдання:
CHECK або логіка на рівні додатку?Проаналізуйте DDL вашої схеми (з рівня 2 або власний проєкт):
CREATE TABLE — чи всі залежності дотримані?NOT NULL — чи є бізнес-обґрунтування для кожного?ON DELETE — яка стратегія і чому?CHECK, але їх немає? Додайте.Фізичний рівень — це місце, де проектування завершується і починається реалізація. На цьому рівні всі абстрактні рішення лопереднього етапу набувають конкретної форми:
UUID, INTEGER, ENUM, VARCHAR).CONSTRAINT) роблять схему самодокументованою і полегшують відладку помилок.CREATE TABLE визначається графом залежностей FK: батьківські таблиці — першими.INSERT у DML слідує тій самій логіці залежностей.У наступній статті ми розглянемо архітектурну класифікацію таблиць: чим відрізняються статичні довідники від транзакційних таблиць і які стратегії роботи з кожним типом є найефективнішими.
Нормалізація: Гігієна даних та боротьба з аномаліями
Від хаотичних таблиць до елегантних структур. Розбираємо 1НФ, 2НФ, 3НФ та НФБК на прикладі аудіоплатформи — і вчимося свідомо порушувати правила там, де це виправдано.
Архітектурна класифікація таблиць
Не всі таблиці однакові. Розуміємо різницю між статичними довідниками, стрижневими сутностями, junction-таблицями та транзакційними записами — і обираємо правильні стратегії для кожного типу.