Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Типи й обмеження

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

19 питань

NULL у SQL означає «невідоме значення». Будь-яке порівняння з невідомим дає теж невідоме - NULL, а не true чи false. А WHERE пропускає лише рядки, де умова true.

SELECT * FROM users WHERE deleted_at = NULL;   -- завжди порожньо
SELECT * FROM users WHERE deleted_at IS NULL;  -- правильно
SELECT * FROM users WHERE deleted_at IS NOT NULL;

Де ще NULL поводиться несподівано:

  • NULL = NULL - теж NULL. Для «рівні, з урахуванням NULL» є IS NOT DISTINCT FROM у PostgreSQL і оператор <=> у MySQL.
  • COUNT(*) рахує всі рядки, COUNT(column) - лише ті, де значення не NULL.
  • SUM, AVG ігнорують NULL. AVG з трьох значень, одне з яких NULL, ділить на 2.
  • Арифметика з NULL дає NULL: price * NULL. Підставити значення за замовчуванням допомагає COALESCE(discount, 0).
  • WHERE status <> 'banned' не поверне рядки зі status IS NULL.

Порада: колонки, де значення обов'язкове, позначайте NOT NULL. Тоді питання «а що, як тут NULL» просто не виникає.

Докладніше в документації: Оператори порівняння

Первинний ключ (PRIMARY KEY) однозначно ідентифікує рядок: значення унікальне й не може бути NULL. Під нього база автоматично створює унікальний індекс.

Зовнішній ключ (FOREIGN KEY) гарантує, що значення в колонці посилається на існуючий рядок іншої таблиці.

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    total NUMERIC(10, 2) NOT NULL
);

Що дає зовнішній ключ:

  • Неможливо створити замовлення для неіснуючого користувача - помилка буде одразу, а не через місяць у звіті.
  • Видалення батьківського рядка керується явно: CASCADE (видалити й залежні), SET NULL, RESTRICT (заборонити, поки є залежні).

Чому інколи без FK: при шардингу чи в дуже навантажених системах цілісність перевіряють у коді. Але для звичайного застосунку обмеження в базі - найнадійніший захист: код може мати баги, кілька сервісів можуть писати в одну базу, а обмеження перевіряється завжди.

Нюанс MySQL: зовнішні ключі підтримує лише InnoDB. У PostgreSQL під FK індекс на колонці, що посилається, не створюється автоматично - його варто додати, інакше JOIN і каскадне видалення будуть повільними.

Докладніше в документації: Обмеження

Тип Байтів Діапазон зі знаком UNSIGNED
TINYINT 1 -128..127 0..255
SMALLINT 2 -32 768..32 767 0..65 535
MEDIUMINT 3 ±8,4 млн 0..16,7 млн
INT 4 ±2,1 млрд 0..4,29 млрд
BIGINT 8 ±9,2 × 10^18 0..1,8 × 10^19

UNSIGNED - лише невід'ємні значення, зате вдвічі більший верхній діапазон.

Як обирати:

  • Первинні ключі - BIGINT UNSIGNED. INT закінчується на 2,1 мільярда (4,29 з UNSIGNED), і в таблицях подій, логів чи лічильників це трапляється раніше, ніж очікують. Змінити тип ключа на великій таблиці - довга й ризикована операція. Laravel $table->id() створює саме BIGINT UNSIGNED.
  • Зовнішні ключі - того самого типу, що й ключ, на який вони посилаються, включно з UNSIGNED. Інакше обмеження не створиться.
  • Прапорці й маленькі перелічення - TINYINT. BOOLEAN у MySQL - це синонім TINYINT(1).

Пастки:

  • INT(11) - не обмеження розміру. Число в дужках - лише «ширина відображення» для старого ZEROFILL, на діапазон не впливає й застаріло з MySQL 8.0.17.
  • Віднімання з UNSIGNED: SELECT a - b для UNSIGNED-колонок при від'ємному результаті дає помилку «out of range» (а в нестрогому режимі - величезне число). Для арифметики, де можливі від'ємні значення, - CAST(a AS SIGNED) - b.
  • Гроші не в INT гривнях, а в копійках (BIGINT) чи DECIMAL.
  • Переповнення: у строгому режимі (STRICT_TRANS_TABLES, за замовчуванням) вставка значення поза діапазоном - помилка; без нього значення тихо обрізається до межі.

Докладніше в документації: Цілочисельні типи

  • CHAR(n) - фіксованої довжини: значення доповнюється пробілами до n символів при збереженні, а при читанні кінцеві пробіли прибираються.
  • VARCHAR(n) - змінної довжини: зберігається рівно стільки символів, скільки записано, плюс 1-2 байти на довжину.
CREATE TABLE codes (
    country CHAR(2),        -- завжди 2 символи: 'UA', 'PL'
    email VARCHAR(255)      -- від 0 до 255 символів
);

Коли CHAR: значення завжди однакової довжини - коди країн і валют, хеші фіксованої довжини, коди статусів. Тоді немає зайвих байтів на довжину.

Коли VARCHAR: усе інше - імена, email, адреси, заголовки.

Нюанси:

  • n - у символах, а не байтах. В utf8mb4 символ займає до 4 байтів, тож VARCHAR(255) - до 1020 байтів даних. Це важливо для обмежень розміру рядка таблиці й індексу.
  • CHAR в utf8mb4 у InnoDB займає змінну кількість байтів, тож перевага «фіксованої довжини» для багатобайтових кодувань майже зникає.
  • Кінцеві пробіли: CHAR втрачає їх при читанні; для VARCHAR вони зберігаються, але порівняння в collations з PAD SPACE їх ігнорують ('a' = 'a ' - істина). Collations utf8mb4_0900_* у MySQL 8 - NO PAD, і там пробіли значущі.
  • Обмеження n: VARCHAR до 65 535 байтів, але всі колонки рядка разом не можуть перевищити 65 535 байтів - довгі тексти виносять у TEXT.

VARCHAR(255) за звичкою - нормально як верхня межа, але для полів з відомим обмеженням (телефон, код) варто ставити реальну довжину: це й документація, і захист від сміття. Валідація довжини все одно потрібна й у застосунку, щоб користувач отримав зрозуміле повідомлення.

Докладніше в документації: Типи CHAR і VARCHAR

Розміри типів TEXT:

  • TINYTEXT - до 255 байтів;
  • TEXT - до 64 КБ;
  • MEDIUMTEXT - до 16 МБ;
  • LONGTEXT - до 4 ГБ.

Ліміти - у байтах: в utf8mb4 TEXT вміщує від ~16 до 64 тисяч символів залежно від мови.

Відмінності від VARCHAR:

  • Ліміт рядка таблиці. Усі колонки рядка разом - не більше 65 535 байтів, і VARCHAR враховується повністю. TEXT займає в цьому ліміті лише 9-12 байтів (вміст зберігається окремо), тож довгі тексти - лише TEXT.
  • Індекси. Індекс на TEXT можливий лише за префіксом (INDEX (body(100))), повний унікальний індекс - ні. VARCHAR індексується цілком (в межах ліміту ключа 3072 байти).
  • Значення за замовчуванням. Для TEXT - лише як вираз у дужках (з MySQL 8.0.13); VARCHAR приймає звичайний літерал.
  • Зберігання. InnoDB з форматом рядка DYNAMIC виносить великі значення на окремі сторінки. Запит, що вибирає такі колонки (SELECT *), читає ці сторінки - звідси повільні списки.

Як обирати:

  • Обмежений короткий текст (імена, заголовки, email, URL) - VARCHAR з розумною довжиною.
  • Довгий текст без жорсткої межі (статті, коментарі, описи, сирий HTML) - TEXT / MEDIUMTEXT.
  • Великі бінарні дані (файли) - у сховище (S3), а в базі - шлях; BLOB лише для невеликих бінарних значень.

Практичні наслідки:

  • Не вибирайте TEXT-колонки в списках: select(['id', 'title']) замість SELECT *.
  • Для пошуку по тексту LIKE '%...%' на TEXT - повний перегляд таблиці. Потрібен FULLTEXT-індекс або пошуковий рушій.
  • У Laravel $table->string() - VARCHAR(255), $table->text() - TEXT, $table->longText() - LONGTEXT.

Докладніше в документації: Типи BLOB і TEXT

ENUM - колонка, що приймає одне значення з фіксованого списку. SET - кілька значень з фіксованого списку одночасно.

CREATE TABLE orders (
    status ENUM('new', 'paid', 'shipped', 'cancelled') NOT NULL DEFAULT 'new',
    flags SET('gift', 'urgent', 'fragile')
);

Всередині ENUM зберігається як номер значення в списку (1-2 байти) - компактно й з перевіркою допустимих значень.

Підводні камені ENUM:

  • Сортування за номером, а не за абеткою. ORDER BY status упорядкує в порядку оголошення (new, paid, shipped, cancelled). Інколи це зручно, частіше - несподівано.
  • Числа в контексті чисел. WHERE status = 1 порівнює з номером, а не з рядком '1'. Для ENUM('0', '1', '2') це джерело плутанини.
  • Зміна списку - ALTER TABLE. Додати значення в кінець у MySQL 8 можна миттєво (лише метадані). Додати в середину, перейменувати чи видалити - перебудова таблиці.
  • Нестрогий режим: недопустиме значення перетворюється на порожній рядок '' з номером 0 замість помилки. У строгому режимі (за замовчуванням у MySQL 8) - помилка.
  • Перенесення між СУБД: це нестандартний тип MySQL.

SET - ще специфічніший: значення зберігаються як бітова маска (до 64 елементів), шукати за ними незручно (FIND_IN_SET), а індекси майже не допомагають. Майже завжди краще окрема таблиця зв'язку.

Альтернативи ENUM:

  • VARCHAR + CHECK (MySQL 8.0.16+): status VARCHAR(20) CHECK (status IN ('new', 'paid', ...)) - зміна переліку не чіпає дані.
  • Таблиця-довідник із зовнішнім ключем - коли значення змінюються без деплою чи мають атрибути (назва, колір, порядок).
  • PHP enum у застосунку + рядок у базі - логіка значень живе в коді, а база зберігає просте значення.

$table->enum() у Laravel-міграціях на MySQL створює саме нативний ENUM.

Докладніше в документації: Тип ENUM

FLOAT і DOUBLE зберігають числа у двійковій формі з плаваючою комою. Більшість десяткових дробів так точно не записати: 0.1 + 0.2 дає 0.30000000000000004. На мільйонах операцій копійки «губляться», а сума в звіті не сходиться з сумою платежів.

Два правильні варіанти:

  1. NUMERIC / DECIMAL - точний десятковий тип:
amount NUMERIC(12, 2) NOT NULL
  1. Ціле число в найменших одиницях - копійках чи центах (BIGINT). Так роблять Stripe та більшість платіжних систем: 1 999 копійок замість 19.99 грн.

Що ще важливо:

  • Валюта поруч із сумою. 100 без валюти нічого не означає. Окрема колонка currency CHAR(3) (ISO 4217).
  • У PHP теж не float: суму тримають як int у копійках або як рядок і рахують через bcmath чи бібліотеки на кшталт brick/money.
  • Округлення - явне й в одному місці. Розподіл 100 грн на три частини має дати 33.34 + 33.33 + 33.33, а не загубити копійку.
  • Тип money у PostgreSQL не радять: його формат залежить від налаштувань локалі сервера.

Докладніше в документації: Числові типи

Правило: зберігати момент часу в UTC (або в типі, що зберігає саме момент), а в часовий пояс користувача перетворювати лише під час показу.

PostgreSQL:

  • timestamptz (timestamp with time zone) зберігає момент часу. Під час запису значення переводиться в UTC, під час читання - у пояс сесії (TimeZone). Назва вводить в оману: сам пояс не зберігається.
  • timestamp (без поясу) - просто дата й час «на годиннику», без прив'язки до поясу. Годиться для «зустріч о 10:00 за місцевим часом», але не для моментів подій.

MySQL:

  • TIMESTAMP перетворюється в UTC за поясом сесії й назад, але має межу 2038 року.
  • DATETIME зберігає значення як є, без перетворень. Застосунок сам відповідає за те, щоб писати в UTC.

Типові проблеми:

  • Сервер БД, PHP і застосунок мають різні пояси, і час «з'їжджає» на кілька годин. Явно налаштований пояс на кожному шарі прибирає цей клас багів.
  • Перехід на літній час: «щодня о 02:30» за київським часом раз на рік не існує, а раз на рік буває двічі.
  • Порівняння DATE(created_at) = '2026-10-04' у UTC і в місцевому часі дає різні множини рядків.

Для майбутніх подій (запис до лікаря через пів року) інколи зберігають місцевий час і назву поясу окремо: правила поясів можуть змінитися до того моменту.

Докладніше в документації: Типи дати й часу

Історично utf8 у MySQL - це utf8mb3: до 3 байтів на символ. Справжній UTF-8 використовує до 4 байтів, і 4-байтові символи (емодзі, частина ієрогліфів, математичні символи) в utf8mb3 не вміщуються.

Що відбувається на практиці: користувач пише коментар з емодзі, і вставка падає з Incorrect string value: '\xF0\x9F\x98\x80', а в нестрогому режимі - текст обрізається на першому емодзі без помилки.

utf8mb4 - повний UTF-8. У MySQL 8 це кодування за замовчуванням (з collation utf8mb4_0900_ai_ci), а utf8mb3 застаріло.

Переведення наявних таблиць:

ALTER DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

ALTER TABLE comments CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

CONVERT TO перекодовує дані всіх текстових колонок - це перебудова таблиці: на великих таблицях довго, з блокуванням запису. Для продакшену - через gh-ost чи pt-online-schema-change.

Підводні камені переходу:

  • Ліміт довжини ключа індексу. У старих версіях і форматах рядка (COMPACT) ключ обмежений 767 байтами: VARCHAR(255) в utf8mb4 - це 1020 байтів, і індекс не створюється. Звідси Schema::defaultStringLength(191) у старих Laravel-проєктах. З форматом DYNAMIC (за замовчуванням у MySQL 8) ліміт - 3072 байти, і проблеми немає.
  • Зміна collation змінює порівняння: нечутливість до регістру й діакритики (ai_ci) може зробити «різні» значення однаковими - і унікальний індекс не створиться, поки дублікати не прибрати.
  • Розмір: тексти, що містять 4-байтові символи, займуть більше; для кирилиці розмір не зміниться (2 байти на символ).
  • З'єднання теж має бути utf8mb4: charset у конфігурації підключення ('charset' => 'utf8mb4' у Laravel) - інакше дані перекодуються в utf8mb3 ще по дорозі.

Правило для нових проєктів: лише utf8mb4 скрізь - сервер, база, таблиці, з'єднання.

Докладніше в документації: Кодування utf8mb4

CHECK - умова, яку має задовольняти кожен рядок. База перевіряє її при кожній вставці й оновленні.

CREATE TABLE products (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    price DECIMAL(10, 2) NOT NULL,
    discount_price DECIMAL(10, 2),
    stock INT NOT NULL,
    CONSTRAINT price_positive CHECK (price > 0),
    CONSTRAINT stock_not_negative CHECK (stock >= 0),
    CONSTRAINT discount_below_price CHECK (discount_price IS NULL OR discount_price < price)
);

INSERT INTO products (price, stock) VALUES (-5, 10);
-- ERROR 3819: Check constraint 'price_positive' is violated.

Важлива історія версій: до MySQL 8.0.16 синтаксис CHECK приймався, але ігнорувався - обмеження не перевірялися зовсім. Код, написаний для старих версій з «перевірками», насправді нічого не перевіряв. З 8.0.16 обмеження працюють.

Що вміє CHECK:

  • умови на значення однієї колонки і на кілька колонок того самого рядка;
  • NOT ENFORCED - оголосити, але не перевіряти (для поступового впровадження).

Обмеження:

  • Лише в межах рядка: не можна посилатися на інші рядки чи таблиці («сума залишків не більше ліміту складу») і використовувати недетерміновані функції (NOW(), RAND()) чи підзапити.
  • Колонки з AUTO_INCREMENT у CHECK не беруть участі.
  • Додавання до наявної таблиці перевіряє всі рядки: якщо хоч один порушує умову, ALTER TABLE не виконається - спершу виправити дані.

Навіщо, якщо є валідація в застосунку: застосунок - не єдине джерело змін (консоль, міграції, імпорт, інший сервіс, баг у коді). CHECK - остання лінія захисту цілісності, а від'ємний залишок на складі - це завжди баг, хай би звідки він прийшов.

У Laravel-міграціях окремого методу для CHECK немає - додають сирим SQL через DB::statement('ALTER TABLE ... ADD CONSTRAINT ... CHECK (...)').

Докладніше в документації: CHECK-обмеження

Згенерована колонка обчислюється з інших колонок того самого рядка за виразом.

CREATE TABLE order_items (
    price DECIMAL(10, 2) NOT NULL,
    quantity INT NOT NULL,
    total DECIMAL(12, 2) AS (price * quantity) STORED
);

ALTER TABLE users
    ADD COLUMN email_domain VARCHAR(255) AS (SUBSTRING_INDEX(email, '@', -1)) VIRTUAL,
    ADD INDEX users_email_domain_idx (email_domain);

Два види:

  • VIRTUAL (за замовчуванням у MySQL) - не зберігається, обчислюється при читанні.
  • STORED - обчислюється при записі й зберігається, як звичайна колонка.

Головна особливість MySQL - індекс на віртуальній колонці. InnoDB дозволяє вторинний індекс на VIRTUAL-колонці: значення обчислюються й зберігаються в індексі, але не в самій таблиці. Так індексують те, що інакше не проіндексувати:

Поле JSON:

ALTER TABLE products
    ADD COLUMN brand VARCHAR(100) AS (attributes ->> '$.brand') VIRTUAL,
    ADD INDEX products_brand_idx (brand);

SELECT * FROM products WHERE brand = 'Bosch';   -- використовує індекс

Вираз для пошуку без урахування регістру, дату з часу, нормалізоване значення.

Обмеження:

  • Вираз - детермінований: без NOW(), RAND(), змінних, підзапитів і посилань на інші таблиці.
  • Не можна записати значення напряму.
  • Первинний ключ - лише на STORED.
  • Зміна виразу STORED-колонки - перебудова таблиці.

Функціональні індекси (MySQL 8.0.13+) - коротший запис того самого: індекс за виразом без явної колонки.

CREATE INDEX users_lower_email_idx ON users ((LOWER(email)));

Під капотом MySQL створює приховану віртуальну колонку. Щоб індекс спрацював, вираз у запиті має збігатися з виразом в індексі.

У Laravel-міграціях: $table->string('brand')->virtualAs("attributes->>'$.brand'") і ->storedAs(...).

Докладніше в документації: Згенеровані колонки

sql_mode визначає, наскільки суворо MySQL поводиться з некоректними даними. Ключовий прапорець - STRICT_TRANS_TABLES (строгий режим), увімкнений за замовчуванням з MySQL 5.7.

Без строгого режиму MySQL «виправляє» дані мовчки:

-- name VARCHAR(10), age TINYINT UNSIGNED, created_at DATE
INSERT INTO users (name, age, created_at) VALUES ('Олександра Іваненко', 300, '2026-02-30');
-- вставлено з попередженнями:
-- name = 'Олександра', age = 255, created_at = '0000-00-00'
  • рядок обрізано до довжини колонки;
  • число поза діапазоном замінено на межу;
  • неіснуюча дата - на нульову;
  • значення не того типу - на 0 чи порожній рядок.

Застосунок отримує «успіх», а в базі - зіпсовані дані, які виявляють через місяці.

Зі строгим режимом ті самі вставки - помилки, і застосунок дізнається про проблему одразу.

Інші важливі прапорці режиму за замовчуванням у MySQL 8:

  • ONLY_FULL_GROUP_BY - заборона неоднозначних GROUP BY;
  • NO_ZERO_DATE, NO_ZERO_IN_DATE - заборона «нульових» дат 0000-00-00;
  • ERROR_FOR_DIVISION_BY_ZERO - ділення на нуль при записі - помилка, а не NULL;
  • NO_ENGINE_SUBSTITUTION - помилка замість тихої заміни рушія таблиці.

Де режим вимикають (і не варто):

  • 'strict' => false у config/database.php Laravel - встановлює м'який режим для з'єднання застосунку. Інколи так «лікують» помилки старого коду при оновленні MySQL - і повертають тихе псування даних.
  • Старі дампи з 0000-00-00 не імпортуються в строгому режимі - їх треба виправити, а не вимикати режим глобально.

Як перевірити:

SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;
SHOW WARNINGS;   -- після операції, якщо режим м'який

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

Докладніше в документації: Режими SQL сервера

Автоматичні мітки часу - для TIMESTAMP і DATETIME:

CREATE TABLE posts (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
  • DEFAULT CURRENT_TIMESTAMP - значення при вставці, якщо колонку не передали.
  • ON UPDATE CURRENT_TIMESTAMP - автоматичне оновлення при будь-якій зміні рядка.
  • Дробові секунди - з точністю: DATETIME(6) ... DEFAULT CURRENT_TIMESTAMP(6).

Нюанси ON UPDATE:

  • Спрацьовує, лише якщо значення якоїсь колонки справді змінилося. UPDATE posts SET title = title мітку не оновить.
  • Спрацьовує й на технічні зміни (лічильник переглядів, службовий прапорець) - «оновлено» перестає означати «змінено автором». Тоді краще керувати міткою в застосунку.

Значення за замовчуванням виразом (MySQL 8.0.13+) - вираз у дужках:

CREATE TABLE api_tokens (
    id BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)) PRIMARY KEY,
    expires_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL 30 DAY),
    settings JSON NOT NULL DEFAULT (JSON_OBJECT()),
    notes TEXT DEFAULT ('')
);

До 8.0.13 дозволялися лише літерали й CURRENT_TIMESTAMP; TEXT, BLOB і JSON не могли мати значення за замовчуванням узагалі.

Обмеження виразів: без підзапитів, змінних, збережених функцій і посилань на AUTO_INCREMENT-колонку.

Laravel і мітки часу: Eloquent сам заповнює created_at / updated_at у застосунку (з часовим поясом застосунку) і не покладається на ON UPDATE. Але масові оновлення через Query Builder (DB::table()->update()) Eloquent не бачить - там updated_at не зміниться, якщо в базі немає ON UPDATE. $table->timestamp('updated_at')->useCurrentOnUpdate() додає його в міграції.

Часові пояси: CURRENT_TIMESTAMP обчислюється в поясі сесії MySQL. Якщо він не збігається з поясом застосунку, мітки, поставлені базою й застосунком, розійдуться на кілька годин.

Докладніше в документації: Ініціалізація TIMESTAMP і DATETIME

Тригер - SQL-код, який MySQL виконує автоматично до чи після INSERT, UPDATE або DELETE для кожного рядка.

DELIMITER //
CREATE TRIGGER orders_audit
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
    IF NOT (OLD.status <=> NEW.status) THEN
        INSERT INTO order_status_log (order_id, old_status, new_status, changed_at)
        VALUES (NEW.id, OLD.status, NEW.status, NOW());
    END IF;
END //
DELIMITER ;
  • OLD - рядок до зміни (у UPDATE і DELETE), NEW - після (у INSERT і UPDATE);
  • у тригері BEFORE можна змінити значення: SET NEW.slug = LOWER(NEW.slug);
  • SIGNAL SQLSTATE '45000' у BEFORE-тригері скасовує операцію з помилкою.

Обмеження MySQL, які варто знати:

  • лише рядкові тригери (FOR EACH ROW) - тригерів рівня оператора, як у PostgreSQL, немає. Масовий UPDATE мільйона рядків - мільйон викликів;
  • не можна змінювати ту саму таблицю, на яку спрацював тригер (і будь-яку таблицю, яку вже читає оператор, що його викликав) - помилка Can't update table ... in stored function/trigger;
  • тригери не спрацьовують на дії зовнішніх ключів: ON DELETE CASCADE видалить дочірні рядки без їхніх тригерів;
  • кілька тригерів на одну подію дозволені, порядок задають FOLLOWS / PRECEDES;
  • реплікація: з рядковим форматом бінарного журналу (за замовчуванням) на репліці тригери не виконуються - туди приходять уже готові зміни рядків, включно з тими, що зробив тригер на джерелі. Зі STATEMENT-форматом тригери виконуються на репліці знову.

Тригер чи подія Eloquent:

Тригер Подія моделі Eloquent
спрацьовує при масовому update(), сирому SQL, зміні з іншого сервісу так ні
видно в коді застосунку ні так
тестування на справжній базі звичайними тестами
може надіслати лист, поставити завдання в чергу ні так

Підводні камені: невидима логіка (розробник не знає, що UPDATE змінює ще одну таблицю), складність налагодження, додаткові блокування в довгих транзакціях. Тригери мають бути задокументовані, мати зрозумілі назви й створюватися в міграціях (DB::unprepared()), а тести - запускатися на MySQL, а не SQLite.

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

Докладніше в документації: MySQL: синтаксис тригерів

JSON у реляційній базі доречний, коли дані:

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

Окрема таблиця краща, коли:

  • за полями потрібно фільтрувати, сортувати, групувати, з'єднувати;
  • потрібні обмеження: NOT NULL, унікальність, зовнішні ключі;
  • елементи масиву - самостійні сутності, які змінюють поштучно (коментарі, позиції замовлення).

PostgreSQL: json чи jsonb. jsonb зберігається в розібраному бінарному вигляді: трохи повільніший запис, зате швидкі оператори (->, ->>, @>, ?) і підтримка GIN-індексів. json зберігає текст як є (з пробілами й порядком ключів) і майже ніколи не потрібен.

CREATE INDEX products_attrs_gin ON products USING gin (attributes);
SELECT * FROM products WHERE attributes @> '{"color": "red"}';

MySQL: тип JSON з валідацією; для індексу за полем створюють згенеровану колонку (GENERATED ALWAYS AS (attributes->>'$.color')) або функціональний індекс (8.0.13+).

Червоні прапорці: JSON-колонку, з якої постійно витягують одне поле в WHERE, часто варто перетворити на звичайну колонку. А зберігання в JSON списку ID інших записів - це втрачений зовнішній ключ.

Докладніше в документації: Типи JSON