В'ю, збережені процедури і тригери
Зміст
Досі ми дивилися на базу як на пасивне сховище: є таблиці, ти пишеш SELECT і читаєш, що поклали. Але база вміє більше. Вона може ховати за іменем цілий важкий запит (вʼю), тримати заздалегідь порахований знімок (матеріалізована вʼю), виконувати справжню бізнес-логіку (функції та процедури) і — найпідступніше — сама щось міняти у відповідь на твій INSERT чи DELETE (тригери).
Для QA це не академічна дрібниця. Ти вставляєш один рядок замовлення — а в сусідній таблиці поповз лічильник, якого ти не чіпав. Дашборд показує вчорашню виручку, хоча оплата щойно пройшла. Розрахунку знижки немає в коді застосунку взагалі, бо він живе у функції в БД. Хто не знає, що логіка буває всередині бази, той годинами полює на фантомний баг у застосунку — а винен тригер або застарілий знімок. Ця глава — про те, де в базі ховається логіка і як її тестувати, не сплутавши з багом продукту.
В'ю: збережений запит, а не таблиця
Вʼю (view, подання) — це іменований SELECT, збережений у базі під власним іменем. Ключове: вʼю не має власних даних. Це не копія і не кеш, а формула. Коли ти звертаєшся до вʼю, СУБД щоразу виконує захований під нею запит поверх реальних таблиць.
-- В'ю: іменований SELECT, власних даних не зберігає
CREATE VIEW active_customers AS
SELECT id, email, created_at
FROM customers
WHERE status = 'active';
Тепер SELECT * FROM active_customers завжди повертає актуальний зріз — бо під капотом це той самий запит до customers. Навіщо це потрібно:
- Інкапсуляція складності. Важкий
JOINп'яти таблиць (див. /sql/join-typy-ziednan-tablyts) ховається за одним іменем. Аналітики й застосунок читають просту вʼю, не знаючи внутрішньої кухні. - Безпека і обмеження доступу. Вʼю може віддавати лише частину стовпців (без
password_hash, безsalary) або лише частину рядків. Користувачу дають права на вʼю, а не на таблицю. - Стабільний інтерфейс. Схему таблиці перейменували чи розбили — вʼю переписали, а зовнішній контракт лишився тим самим.
Тут ховається перша пастка для інтуїції: вʼю сама собою нічого не пришвидшує. Оптимізатор просто підставляє її визначення у зовнішній запит, тож важка вʼю виконується так само важко, як і руками написаний той самий JOIN. Вʼю — це зручність і безпека, а не оптимізація.
Ще нюанс — оновлюваність. Проста вʼю над однією таблицею без агрегатів у PostgreSQL оновлювана автоматично: у неї можна INSERT/UPDATE/DELETE, і зміни підуть у базову таблицю. А от вʼю з JOIN, GROUP BY чи DISTINCT зазвичай доступна лише для читання — записати в неї напряму не можна без спеціальних INSTEAD OF-тригерів.
Матеріалізована в'ю і застарілі дані
Іноді важкий запит хочеться не рахувати щоразу, а порахувати один раз і читати миттєво. Для цього є матеріалізована вʼю (materialized view) — на відміну від звичайної, вона зберігає результат фізично на диску. Це знімок (snapshot) даних на момент останнього оновлення.
CREATE MATERIALIZED VIEW sales_daily AS
SELECT date_trunc('day', created_at) AS day, sum(total) AS revenue
FROM orders
GROUP BY 1;
-- Дані НЕ оновляться самі — потрібен явний рефреш:
REFRESH MATERIALIZED VIEW sales_daily;
І ось головний підступ, заради якого QA взагалі має знати це слово: дані в матеріалізованій вʼю застарівають. У PostgreSQL вона не оновлюється автоматично, коли змінюються базові таблиці, — її треба перечитати командою REFRESH вручну або за розкладом (cron, планувальник). Між двома рефрешами вʼю показує стару картину.
Поведінка різних СУБД тут відрізняється, і це варто памʼятати: Oracle уміє оновлювати матеріалізовану вʼю автоматично (наприклад, ON COMMIT), а MySQL нативних матеріалізованих вʼю не має взагалі — там їх імітують таблицею плюс тригерами. Тож «матеріалізована вʼю сама оновиться» — небезпечне припущення; завжди уточнюй стратегію рефрешу на конкретному проєкті.
Що з цього випливає для тестів. Автотест створює замовлення через API, одразу відкриває дашборд — і бачить стару цифру. Це не баг підрахунку: просто матеріалізована вʼю ще не рефрешнулась. А буває навпаки — справжній баг: джоб рефрешу зламався, і користувачі місяць дивляться на застиглу виручку. Відрізнити одне від одного можна лише знаючи, що за дашбордом стоїть знімок, а не жива таблиця. У PostgreSQL є REFRESH MATERIALIZED VIEW CONCURRENTLY — він не блокує читання під час оновлення, але вимагає унікального індексу на вʼю.
Процедури і функції: логіка живе в базі
База вміє не лише зберігати й читати, а й виконувати процедурний код. Його пишуть діалектною мовою (PL/pgSQL у PostgreSQL, T-SQL у SQL Server) і зберігають усередині БД. Розрізняють два роди таких обʼєктів:
- Функція (function) повертає значення і може вживатися прямо у запиті — у
SELECT,WHERE, як стовпець. Історично функція не керує транзакцією. - Збережена процедура (stored procedure) викликається окремо командою
CALL, робить побічні ефекти (пише в кілька таблиць) і, на відміну від функції, може керувати транзакцією — робитиCOMMIT/ROLLBACKусередині. У PostgreSQL процедури зʼявилися у версії 11.
-- Функція повертає значення і вживається в запиті
CREATE FUNCTION order_total(p_order_id int) RETURNS numeric AS $$
SELECT sum(price * qty) FROM order_items WHERE order_id = p_order_id;
$$ LANGUAGE sql;
-- SELECT order_total(42); -> 150.00
Навіщо тягнути логіку в базу, а не тримати в застосунку? Причини реальні:
- Швидкодія на масивах даних. Операція над мільйоном рядків виконується поруч із даними, без мережевих поїздок «база → застосунок → база».
- Атомарність. Складну зміну кількох таблиць легко загорнути в одну транзакцію (див. /sql/tranzaktsii-ta-acid).
- Єдине джерело правди. Якщо до бази ходять три різні застосунки, правило, зашите в БД, працює однаково для всіх — його не треба дублювати в кожному.
Ціна теж реальна: логіку в БД важче версіонувати в git, важче покрити юніт-тестами, важче дебажити, і вона привʼязана до конкретного діалекту. Для QA головний наслідок простий — частина бізнес-логіки може бути невидимою в коді застосунку. Розрахунок податку, нарахування бонусів, перевірка ліміту цілком можуть жити у функції в базі. Не знайшовши логіку в коді, не роби висновку «фічі немає» — спершу глянь у БД.
Тригери: коли дані змінюються самі
Тригер (trigger) — це процедурний код, привʼязаний до таблиці, який СУБД викликає автоматично у відповідь на подію: INSERT, UPDATE або DELETE. Ніхто не викликає тригер явно — він спрацьовує сам. Саме звідси відчуття, що «дані змінилися самі».
За моментом спрацювання тригери діляться на два принципові типи:
BEFORE— спрацьовує до запису зміни. Може підправити рядок, який зараз запишеться (підставитиcreated_at, нормалізувати email до нижнього регістру), провалідувати його або взагалі скасувати операцію. Усередині доступні спеціальні записиNEW(новий рядок) іOLD(старий, дляUPDATE/DELETE).AFTER— спрацьовує після того, як зміну вже застосовано. Рядок змінити вже не можна, томуAFTERроблять для побічних ефектів: запис в аудит-лог, оновлення агрегату в іншій таблиці, каскад у сусідні сутності.
-- AFTER-тригер: після вставки замовлення підняти лічильник у клієнта
CREATE TRIGGER trg_bump_orders_count
AFTER INSERT ON orders
FOR EACH ROW
EXECUTE FUNCTION bump_orders_count();
Ще розрізняють рівень: FOR EACH ROW (спрацьовує на кожен зачеплений рядок) і FOR EACH STATEMENT (один раз на всю команду, скільки б рядків вона не зачепила).
Ось де це болить у тестах. Наочна послідовність:
Ти зробив один INSERT, а по факту: BEFORE-тригер підставив поля, яких ти не писав, а AFTER-тригер сходив і змінив іншу таблицю. Для наївної перевірки це виглядає як брудні дані або гонка. Гірше — тригери каскадять: тригер на таблиці A міняє таблицю B, у якої свій тригер, і так далі. Простежити ланцюжок наслідків одного запису буває непросто.
Як QA тестує логіку в БД
Головна ідея: логіку, що живе в базі, треба тестувати на її рівні, а не тільки крізь UI. Крізь інтерфейс ти побачиш лише кінцевий результат і не зрозумієш, який шар його дав.
Функції та процедури перевіряють прямим викликом. Готуєш вхідні дані, викликаєш, звіряєш вихід — по суті це юніт-тест, тільки виконаний у базі:
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('функція order_total рахує суму позицій', async () => {
const db = new Client({ connectionString: process.env.DB_URL });
await db.connect();
// припускаємо підготовлене замовлення id=42 з відомими позиціями
const { rows } = await db.query('SELECT order_total($1) AS total', [42]);
expect(Number(rows[0].total)).toBe(150);
await db.end();
});
Тригер перевіряють так: виконуєш ту дію, що його запускає (INSERT/UPDATE/DELETE), а потім асертиш побічний ефект — зʼявився рядок в аудиті, лічильник збільшився, поле проставилося. Обовʼязково перевір і негативний бік: BEFORE-тригер, що має відхиляти невалідний рядок, справді його відхиляє.
Звичайну вʼю тестують як запит: на відомому наборі даних вона повертає правильні рядки; вʼю для безпеки — що вона не показує зайвих стовпців і рядків. Матеріалізовану вʼю тестують у звʼязці з рефрешем: після REFRESH дані збігаються з базовими таблицями, а до нього — очікувано застарілі.
Окремо тримай у голові проблему ізоляції. Через тригери й дефолти правило «що я записав» ≠ «що лежить у таблиці» стає нормою. Автотест, який вставляє рядок і потім звіряє його повністю, впаде на полі created_at, яке проставив тригер. Тому:
- перевіряй лише ті поля, що справді під контролем тесту, або окремо враховуй згенеровані базою;
- памʼятай, що один
INSERTміг зачепити кілька таблиць — прибирання за тестом теж має це враховувати (механіка сідів, ізоляції та відкату — у /sql/testovi-dani-i-bd-v-avtotestakh; цілісність і міграції — у /sql/testuvannia-bd-tsilisnist-danykh-i-mihratsii).
Логіка в БД vs у застосунку
Питання «де має жити логіка» — класична розвилка архітектури, і єдиної правильної відповіді немає. Для QA важливо не обрати сторону, а знати, де логіка лежить фактично, бо це визначає, який шар тестувати.
| Критерій | Логіка в БД | Логіка в застосунку |
|---|---|---|
| Швидкодія на масивах даних | Висока, поруч із даними | Нижча, мережеві поїздки |
| Тестопридатність | Важче ізолювати й версіонувати | Звичні юніт-тести, git |
| Портативність | Привʼязка до діалекту (PL/pgSQL, T-SQL) | Переносна між СУБД |
| Єдине джерело для всіх клієнтів | Так, правило одне на всіх | Ні, дублюється в кожному застосунку |
| Видимість для команди | «Прихована» в базі | На видноті в репозиторії |
Сучасний мейнстрім тяжіє до «тонкої» бази й логіки в застосунку — так простіше тестувати й масштабувати. Але легасі-системи та важкі за даними операції часто лишають логіку в БД цілком свідомо. Практичний висновок для тестувальника: перш ніж заводити баг «застосунок рахує неправильно», зʼясуй, хто саме рахує. Якщо суму формує збережена функція чи агрегат оновлює тригер — баг і тест мають бути націлені на базу, а не на код застосунку.
Типові помилки
- Виглядає як брудні дані чи баг: «я вставив рядок, а в таблиці зʼявилися поля, яких я не писав». А насправді
BEFORE-тригер абоDEFAULTпідставив значення — це штатна поведінка, а не сміття. - Виглядає як баг підрахунку: після нової оплати дашборд показує стару виручку. А насправді за дашбордом матеріалізована вʼю, яка ще не рефрешнулась.
- Виглядає як недороблена фіча: логіки знижки немає в коді застосунку. А насправді розрахунок живе у збереженій функції в БД.
- Виглядає як гонка чи чужий тест наслідив: видалив запис, а лічильник у сусідній таблиці змінився. А насправді спрацював
AFTER DELETE-тригер. - Виглядає як «вʼю має кешувати результат»: звичайна вʼю гальмує так само, як важкий
JOIN. А насправді вона нічого не зберігає — це запит, що виконується щоразу; кешує лише матеріалізована вʼю.
Підсумок
- Вʼю — це збережений запит без власних даних (завжди актуальна, але рахується щоразу); матеріалізована вʼю — знімок на диску, який може бути застарілим і потребує
REFRESH. - Бізнес-логіка може жити в базі — у функціях, процедурах і тригерах — і бути невидимою в коді застосунку; QA має знати, де вона.
BEFORE-тригер змінює й валідує рядок до запису;AFTER-тригер робить побічні ефекти після. Через них одинINSERTможе змінити кілька таблиць.- Тригери й дефолти означають: «що я записав» ≠ «що лежить у таблиці» — це руйнує наївні асерти й ізоляцію тестів.
- Логіку в БД тестують прямим викликом функції та перевіркою побічних ефектів тригера, а не лише крізь UI.
Що питають на співбесіді
- «Чим вʼю відрізняється від таблиці?» — Дивляться, чи розумієш, що вʼю не зберігає даних і не пришвидшує запит сама собою; бонус — оновлюваність простих вʼю і безпекова роль.
- «Що таке матеріалізована вʼю і в чому підступ?» — Чекають на слова «знімок» і «застарілі дані»: вона швидка, але показує стан на момент
REFRESH, а не поточний. - «
BEFOREvsAFTERтригер — коли який?» —BEFOREдля зміни/валідації самого рядка,AFTERдля побічних ефектів (аудит, агрегати, каскади). - «Як протестувати логіку у збереженій процедурі чи тригері?» — Прямий виклик функції з відомими входами; для тригера — виконати подію і перевірити побічний ефект, не забувши про ізоляцію та згенеровані базою поля.
- «Логіку краще тримати в базі чи в застосунку?» — Правильної відповіді немає; інтервʼюер дивиться на аргументацію тредофів (швидкодія і атомарність проти тестопридатності й портативності) і на розуміння, що для тесту головне — знати, де логіка фактично.
Джерела
- PostgreSQL Documentation — CREATE VIEW — визначення вʼю та автооновлювані прості вʼю.
- PostgreSQL Documentation — Materialized Views — знімок даних і механіка
REFRESH. - PostgreSQL Documentation — Overview of Trigger Behavior —
BEFORE/AFTER, рівні рядка й команди, записиNEW/OLD. - PostgreSQL Documentation — CREATE PROCEDURE — процедури,
CALLі керування транзакціями. - Стандарт SQL (ISO/IEC 9075) фіксує вʼю (
CREATE VIEW,WITH CHECK OPTION) і тригери; конкретні можливості матеріалізованих вʼю та процедурних мов залежать від СУБД.
Чим вʼю (view) відрізняється від таблиці?
Вʼю — це збережений під власним іменем SELECT, а не сховище даних. Ключове: вʼю не має власних даних — це формула, а не копія. Кожне звернення до вʼю змушує СУБД наново виконати прихований за нею запит по живих таблицях, тому результат завжди відбиває поточний стан даних. Таблиця ж фізично тримає рядки на диску. Практичний наслідок для QA: у вʼю немає окремого стану, який міг би «застаріти», — на відміну від матеріалізованої вʼю, з якою її часто плутають.
Навіщо взагалі потрібні вʼю?
Три причини. Перша — інкапсуляція складності: замість JOIN пʼяти таблиць застосунок і аналітики працюють з одним коротким іменем, не занурюючись у внутрішню кухню. Друга — безпека й обмеження доступу: вʼю показує урізаний набір стовпців (скажімо, ховає password_hash і salary) чи рядків, і права видають саме на неї, а не на таблицю. Третя — стабільний інтерфейс: коли схему перейменували чи розбили, достатньо підлаштувати вʼю — зовнішній контракт не змінюється. Для тестувальника особливо важлива безпекова роль: вʼю треба перевіряти не лише на те, що вона показує потрібне, а й на те, що вона не показує зайвого.
Чи пришвидшує вʼю запит?
Ні — і це поширена пастка інтуїції. Вʼю нічого не кешує й не оптимізує: під час виконання оптимізатор розгортає її визначення прямо в зовнішній запит, тож вартість лишається тією самою, що й у написаного руками JOIN. Вʼю дає зручність і безпеку, прискорення — ні. Якщо потрібно саме прискорити важкий підрахунок, це вже робота матеріалізованої вʼю, яка тримає готовий результат. Тому твердження «зробимо вʼю, щоб швидше» — червоний прапорець на код-ревʼю.
Чи можна писати (INSERT/UPDATE) у вʼю?
Залежить від того, що всередині. Якщо вʼю — простий SELECT з однієї таблиці без агрегатів, PostgreSQL вважає її оновлюваною: команди INSERT/UPDATE/DELETE вона транслює в базову таблицю. Щойно у визначенні зʼявляється JOIN, GROUP BY чи DISTINCT, прямий запис стає неможливим — таку вʼю можна зробити записуваною хіба що через INSTEAD OF-тригери, які перехоплюють операцію і самі вирішують, куди її розкласти. Для тестів це означає: перш ніж очікувати, що UPDATE view спрацює, треба знати структуру вʼю, інакше отримаєш помилку «cannot update view».
Що таке матеріалізована вʼю (materialized view) і в чому її головний підступ?
На відміну від звичайної, матеріалізована вʼю фізично зберігає результат на диску — фактично це знімок (snapshot) стану на момент останнього оновлення. Її беруть, коли дорогий запит вигідніше порахувати один раз, а далі лише читати готове. Підступ, через який QA взагалі має знати це слово: вона відстає від життя. У PostgreSQL зміни базових таблиць самі в неї не потрапляють — потрібен явний REFRESH, запущений руками чи планувальником. Поки рефреш не відбувся, вʼю показує стару картину, і це не баг, а її природа.
Автотест створює замовлення через API, одразу відкриває дашборд і бачить стару виручку. Це баг підрахунку?
Найімовірніше — ні. Якщо за дашбордом стоїть матеріалізована вʼю, вона просто ще не рефрешнулась: підрахунок правильний, дані застарілі за побудовою. Відрізнити це від справжнього дефекту можна лише знаючи, що за цифрою стоїть знімок, а не жива таблиця. Втім, буває і реальний баг: упав джоб рефрешу — і цифри застигають на дні чи тижні для всіх користувачів. Тому правильна діагностика — перевірити, коли востаннє відпрацював REFRESH, а не одразу заводити «неправильний підрахунок». У тесті надійніше або викликати рефреш явно, або асертити дані на рівні базових таблиць.
Чим поведінка матеріалізованих вʼю відрізняється між СУБД?
Суттєво, і це варто памʼятати, щоб не переносити звички з однієї бази на іншу. У PostgreSQL матеріалізована вʼю оновлюється лише явним REFRESH (вручну або за розкладом). Oracle уміє робити це автоматично — наприклад, у режимі ON COMMIT, одразу після коміту в базові таблиці. У MySQL такої фічі немає зовсім: команди відтворюють її звичайною таблицею, актуальність якої підтримують тригерами. Тому припущення «матеріалізована вʼю сама оновиться» небезпечне: завжди уточнюй стратегію рефрешу на конкретному проєкті.
Чим функція (function) відрізняється від збереженої процедури (stored procedure)?
Функція повертає значення і вбудовується прямо в запит — у SELECT, WHERE, як стовпець; історично вона транзакцією не керує. Процедуру запускають окремою командою CALL; вона існує заради дій — записів у кілька таблиць — і їй дозволено те, чого функції ні: власні COMMIT/ROLLBACK посеред виконання. Простими словами: функцію ти вбудовуєш у запит заради результату, процедуру викликаєш заради дії. У PostgreSQL процедури зʼявилися лише у версії 11 — до того всю процедурну логіку писали функціями. Для тестів різниця в тому, як запускати: функцію — через SELECT my_func(...), процедуру — через CALL my_proc(...).
Навіщо тягнути бізнес-логіку в базу, а не тримати в застосунку? Які тредофи?
Аргументи «за» реальні: швидкодія на великих обсягах (мільйон рядків обробляється там, де вони лежать, без ганяння даних мережею туди-сюди), атомарність (зміну кількох таблиць природно загорнути в одну транзакцію) і єдине джерело правди (коли до однієї бази ходять кілька застосунків, зашите в БД правило спрацьовує однаково для кожного). Платити доводиться тестопридатністю: код у базі незручно версіонувати в git, складно покривати юніт-тестами й дебажити, до того ж він замикає на конкретний діалект (PL/pgSQL, T-SQL). На співбесіді правильної відповіді «де краще» немає — інтервʼюер дивиться на розуміння тредофів, а для QA головний наслідок один: частину бізнес-логіки в коді застосунку просто не видно.
Логіки знижки немає в коді застосунку. Чи означає це, що фічі немає?
Ні. Податок, бонуси чи перевірка ліміту цілком можуть рахуватися функцією або процедурою всередині бази, а не кодом сервісу. Порожній результат пошуку в репозиторії — ще не доказ відсутності фічі: спершу подивись у БД, чи не рахує це збережена функція, тригер або агрегат. Це прямий практичний навик: до того як заводити баг «застосунок рахує неправильно», зʼясуй, який шар насправді відповідає за підрахунок. Якщо цифру дає збережена функція — і баг, і тест мусять цілитися в базу, а не в код застосунку.
Що таке тригер (trigger) і чому кажуть, що «дані змінюються самі»?
Тригер — це код, що висить на таблиці й запускається самою СУБД, щойно з даними стається подія: INSERT, UPDATE чи DELETE. Явно його ніхто не викликає — він стартує сам у момент події. Звідси й відчуття, що «дані змінилися самі»: ти зробив один INSERT, а десь поруч змінився лічильник, до якого ти не торкався. Для QA це джерело найпідступніших фантомних багів: перевірка, що не знає про тригер, приймає «зайві» зміни за брудні дані чи гонку, хоча це його штатна робота.
BEFORE vs AFTER тригер — коли який?
BEFORE спрацьовує ще до того, як рядок ліг у таблицю, тому може його відредагувати (проставити created_at, привести email до нижнього регістру), перевірити чи взагалі відхилити операцію. AFTER запускається, коли зміну вже застосовано, — правити рядок пізно, тож його ставлять на побічні ефекти: аудит-лог, перерахунок агрегату в сусідній таблиці, каскад у повʼязані сутності. Просте правило: треба змінити чи провалідувати сам рядок — це BEFORE; треба зробити щось стороннє після факту — це AFTER. На співбесіді це майже гарантоване питання, і відповідь «BEFORE для зміни/валідації рядка, AFTER для побічних ефектів» закриває його.
Що таке NEW і OLD усередині тригера?
Це спеціальні записи, доступні в тілі рядкового тригера, які дають доступ до даних зачепленого рядка. NEW — новий рядок (той, що вставляється або яким стане після UPDATE); OLD — старий рядок (яким він був до UPDATE чи DELETE). У BEFORE-тригері можна змінювати поля NEW — наприклад, NEW.email = lower(NEW.email), — і саме це значення піде в таблицю. При INSERT доступний лише NEW, при DELETE — лише OLD, при UPDATE — обидва. Розуміння цих записів пояснює, звідки в таблиці беруться значення, яких тест не писав: їх підставив BEFORE-тригер через NEW.
FOR EACH ROW vs FOR EACH STATEMENT — у чому різниця?
Це рівень спрацювання тригера. FOR EACH ROW спрацьовує на кожен зачеплений рядок: якщо UPDATE змінив 1000 рядків, тіло тригера виконається 1000 разів. FOR EACH STATEMENT виконується рівно один раз на команду, незалежно від того, скільки рядків вона зачепила — хоч нуль. Рядковий рівень потрібен, коли логіка залежить від конкретного рядка (NEW/OLD доступні саме тут); табличний — коли достатньо одного дотику на команду, наприклад записати «таблицю змінили» в аудит. Для тестів це має практичний бік: масовий UPDATE з важким FOR EACH ROW-тригером може бути неочікувано повільним, бо тіло виконується на кожен рядок.
Ти зробив один INSERT, а змінилося дві таблиці. Що сталося і чи це баг?
Це типова робота тригерів, а не баг. Найімовірніше спрацював ланцюжок: BEFORE-тригер доклав у рядок поля, яких у твоєму запиті не було (created_at, нормалізований email), а AFTER-тригер оновив іншу таблицю — наприклад, підняв лічильник замовлень у клієнта. Перевірка, що не знає про тригери, бачить тут брудні дані або гонку, хоча все штатно. Гірше, що ефекти вміють ланцюжитися: зміна таблиці A смикає її тригер, той пише в таблицю B, де висить власний тригер, — і простежити всі наслідки одного запису буває непросто. Практичний висновок: прибирання за тестом теж має враховувати, що один INSERT міг зачепити кілька таблиць.
Як протестувати збережену функцію або процедуру?
Прямим викликом — фактично юніт-тест, що виконується всередині бази. Готуєш вхідні дані, викликаєш (SELECT my_func(...) для функції, CALL my_proc(...) для процедури), звіряєш вихід або побічний ефект з очікуваним. Крізь UI цього робити не варто: там видно лише фінальну цифру без розуміння, який шар її сформував. У Playwright це роблять через клієнт до БД (наприклад, pg): підготував замовлення з відомими позиціями, викликав order_total, перевірив суму. Обовʼязково покрий межові випадки — порожній вхід, NULL, некоректні дані, — бо саме там логіка в БД найчастіше поводиться інакше, ніж очікує застосунок.
Як протестувати тригер?
Запускаєш подію, на яку він підписаний (INSERT/UPDATE/DELETE), і асертиш наслідок: новий рядок в аудит-лозі, збільшений лічильник, автоматично заповнене поле. Тобто тестуєш не сам тригер безпосередньо, а його ефект. Обовʼязково перевір і негативний бік: якщо BEFORE-тригер має відхиляти невалідний рядок, переконайся, що операція справді падає з очікуваною помилкою. Памʼятай про каскади: одна дія може дати кілька ефектів у різних таблицях, і повна перевірка враховує всі. І тримай у голові ізоляцію — тригер міг проставити поля, які ламають наївне порівняння «записав ≠ лежить у таблиці».
Автотест вставляє рядок і звіряє його повністю, але падає на полі created_at, якого не писав. Чому і як правильно?
Тригери й дефолти роблять розрив «що я записав» ≠ «що лежить у таблиці» нормою: created_at проставив BEFORE-тригер або DEFAULT, і повне порівняння вставленого обʼєкта з рядком у базі впало саме на ньому. Це не брудні дані — це штатна поведінка. Правильний підхід — асертити тільки поля, якими керує сам тест, а згенеровані базою враховувати окремо (наприклад, перевірити, що created_at не порожній і близький до «зараз», а не дорівнює конкретному літералу). Наївний асерт «весь обʼєкт дорівнює очікуваному» тут завжди крихкий, і його треба будувати з поправкою на те, що частину полів наповнює база.
Логіку краще тримати в базі чи в застосунку — що відповісти на співбесіді?
Правильної відповіді немає, і інтервʼюер це знає — він дивиться на аргументацію тредофів. Логіка в БД виграє у швидкодії на великих даних, атомарності та ролі єдиного джерела правди для всіх клієнтів; логіка в застосунку — у звичних юніт-тестах, git-версіонуванні, портативності між СУБД і видимості в репозиторії. Мейнстрім сьогодні схиляється до «тонкої» бази, але в легасі й у важких за даними операціях логіку свідомо лишають у БД. Сильна відповідь від QA додає головне: для тесту важливо не обрати табір, а зʼясувати фактичне місце логіки — саме воно визначає, який шар покривати тестами й куди заводити баг.
Три кейси, де логіка ховається в базі, а не в застосунку: прямий тест збереженої функції, перевірка тригера з його побічним ефектом і негативним боком, і діагностика «стара цифра на дашборді» — баг підрахунку чи застаріла матеріалізована вʼю. Скрізь — що дивитися і чому.
Кейс 1. Тест збереженої функції прямим викликом
Розрахунок суми замовлення живе не в коді сервісу, а у функції order_total всередині бази. Тестувати його крізь UI — марно: там видно лише підсумкову цифру на екрані, і незрозуміло, хто її дав. Правильно викликати функцію прямо й звірити результат на відомих даних — це юніт-тест, виконаний у базі.
-- Функція повертає значення і вживається прямо в запиті
CREATE FUNCTION order_total(p_order_id int) RETURNS numeric AS $$
SELECT sum(price * qty) FROM order_items WHERE order_id = p_order_id;
$$ LANGUAGE sql;
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('order_total рахує суму позицій замовлення', async () => {
const db = new Client({ connectionString: process.env.DB_URL });
await db.connect();
// готуємо відоме замовлення: 2 x 50.00 + 1 x 50.00 = 150.00
await db.query(`INSERT INTO orders(id) VALUES (42)`);
await db.query(`INSERT INTO order_items(order_id, price, qty)
VALUES (42, 50.00, 2), (42, 50.00, 1)`);
const { rows } = await db.query('SELECT order_total($1) AS total', [42]);
expect(Number(rows[0].total)).toBe(150);
// межовий випадок: замовлення без позицій -> sum() дає NULL, не 0
const { rows: empty } = await db.query('SELECT order_total($1) AS total', [999]);
expect(empty[0].total).toBeNull();
await db.end();
});
Що дивитися і чому:
- Функцію викликають через
SELECT func(...), процедуру — черезCALL proc(...). Це не деталь синтаксису: функція повертає значення й вбудовується в запит, процедура викликається заради побічного ефекту й може керувати транзакцією. Плутанина в способі виклику — типова помилка новачка в тесті БД. - Межовий випадок часто і є баг.
sum()над порожнім набором повертаєNULL, а не0— якщо застосунок очікує число й підставляєNULLу розрахунок знижки, він тихо зламається. Такі кейси видно лише прямим тестом функції, крізь UI вони маскуються. - Логіка в БД невидима в репозиторії застосунку. Не знайшовши розрахунку в коді сервісу, не пиши «фічі немає» — спершу перевір, чи його не рахує збережена функція чи агрегат. Баг і тест мають бути націлені на той шар, де логіка живе фактично.
Кейс 2. Тест тригера: побічний ефект, негативний бік і пастка ізоляції
Один INSERT у orders тягне за собою більше, ніж видно: BEFORE-тригер проставляє created_at, а AFTER-тригер піднімає лічильник у customers. Наївна перевірка «вставив рядок — звірив рядок повністю» тут падає, і не через баг продукту.
-- AFTER-тригер: після вставки замовлення підняти лічильник у клієнта
CREATE TRIGGER trg_bump_orders_count
AFTER INSERT ON orders
FOR EACH ROW
EXECUTE FUNCTION bump_orders_count();
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('INSERT замовлення піднімає orders_count у клієнта (AFTER-тригер)', async () => {
const db = new Client({ connectionString: process.env.DB_URL });
await db.connect();
const before = await db.query(
'SELECT orders_count FROM customers WHERE id = $1', [7]);
await db.query(
`INSERT INTO orders(customer_id, total) VALUES ($1, $2)`, [7, 100]);
// асертимо ПОБІЧНИЙ ЕФЕКТ в іншій таблиці, а не сам вставлений рядок
const after = await db.query(
'SELECT orders_count FROM customers WHERE id = $1', [7]);
expect(after.rows[0].orders_count).toBe(before.rows[0].orders_count + 1);
await db.end();
});
Обовʼязково перевіряємо і негативний бік — BEFORE-тригер, що має відхиляти невалідний рядок, справді його відхиляє:
test('BEFORE-тригер відхиляє замовлення з відʼємною сумою', async () => {
const db = new Client({ connectionString: process.env.DB_URL });
await db.connect();
await expect(
db.query(`INSERT INTO orders(customer_id, total) VALUES ($1, $2)`, [7, -5])
).rejects.toThrow(); // тригер має скасувати операцію
await db.end();
});
Що дивитися і чому:
- Тестуємо наслідок, а не сам тригер. Тригер не викликають напряму — виконуєш подію (
INSERT) і асертиш побічний ефект: лічильник у сусідній таблиці, рядок в аудиті, проставлене поле. Мета — переконатися, що ефект настав саме той і саме там. created_atламає наївний асерт. ЧерезBEFORE-тригер іDEFAULTправило «що я записав» ≠ «що лежить у таблиці» стає нормою. Не звіряй увесь обʼєкт з очікуваним літералом — перевіряй лише поля під контролем тесту, а згенеровані базою враховуй окремо (created_atне порожній і близький до «зараз»).- Прибирання враховує каскад. Один
INSERTзачепив дві таблиці (ordersіcustomers), а тригери ще й каскадять далі. Teardown, що чистить лишеorders, лишить «хвіст» уcustomers— і наступний прогін впаде на неочікуваному стані.
Кейс 3. Стара цифра на дашборді: баг підрахунку чи застаріла матеріалізована вʼю
Класична ситуація: тест створює оплату через API, одразу відкриває дашборд виручки — і бачить учорашню цифру. Спокуса завести «баг підрахунку» велика, але спершу треба зрозуміти, що стоїть за дашбордом.
CREATE MATERIALIZED VIEW sales_daily AS
SELECT date_trunc('day', created_at) AS day, sum(total) AS revenue
FROM orders
GROUP BY 1;
-- Дані НЕ оновляться самі — потрібен явний рефреш:
REFRESH MATERIALIZED VIEW sales_daily;
| Симптом | Виглядає як | А насправді часто | Як перевірити |
|---|---|---|---|
| Дашборд показує стару виручку одразу після оплати | Баг підрахунку | Матеріалізована вʼю ще не рефрешнулась | Викликати REFRESH, звірити з базовою таблицею |
| Виручка застигла на кілька днів | «Підрахунок зламався» | Джоб REFRESH (cron) упав | Перевірити розклад/логи рефрешу |
| Локально свіжо, на стейджі старе | Різні дані | Різна стратегія рефрешу середовищ | Уточнити, чим і як часто рефрешать вʼю |
У тесті цифру не «ловлять наосліп», а керують рефрешем явно — інакше отримаєш плаваючий флак, що залежить від того, чи встиг спрацювати розклад:
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('після REFRESH матеріалізована вʼю збігається з базовою таблицею', async () => {
const db = new Client({ connectionString: process.env.DB_URL });
await db.connect();
await db.query(`INSERT INTO orders(total, created_at) VALUES (100, now())`);
// без рефрешу вʼю показує старе — це НЕ баг, а її природа
await db.query('REFRESH MATERIALIZED VIEW sales_daily');
const view = await db.query(
`SELECT revenue FROM sales_daily WHERE day = date_trunc('day', now())`);
const table = await db.query(
`SELECT sum(total) AS revenue FROM orders WHERE created_at >= date_trunc('day', now())`);
expect(Number(view.rows[0].revenue)).toBe(Number(table.rows[0].revenue));
await db.end();
});
Що дивитися і чому:
- Знімок ≠ жива таблиця. Матеріалізована вʼю зберігає результат на диску на момент останнього
REFRESH. Між рефрешами вона за побудовою показує старе — це не дефект, поки джоб рефрешу працює за розкладом. - Відрізнити застарілість від справжнього бага можна лише знаючи механізм. Стара цифра одразу після дії — майже завжди відкладений рефреш; застигла на дні — привід перевірити, чи не впав
REFRESH. Обидва діагнози починаються з питання «за дашбордом жива таблиця чи знімок?». REFRESH ... CONCURRENTLYне блокує читання, але вимагає унікального індексу. Якщо в тесті рефреш конкурує з іншими читачами, звичайнийREFRESHбере блокування; конкурентний варіант його знімає ціною вимоги унікального індексу на вʼю — це варто знати, коли рефреш у тесті раптом «завис».
Вʼю і матеріалізована вʼю
- Розумію, що вʼю (view) — це іменований
SELECTбез власних даних (не копія, не кеш), тож завжди повертає актуальний зріз; знаю три ролі: інкапсуляція складногоJOIN, безпека (лише частина стовпців/рядків, права на вʼю) і стабільний зовнішній інтерфейс. - Знаю пастку: вʼю сама собою нічого не пришвидшує — оптимізатор підставляє її визначення, і важка вʼю рахується так само важко, як руками написаний
JOIN. - Розумію оновлюваність: проста вʼю над однією таблицею в PostgreSQL приймає
INSERT/UPDATE, а вʼю зJOIN/GROUP BY/DISTINCT— лише для читання безINSTEAD OF-тригерів. - Можу пояснити матеріалізовану вʼю (materialized view): вона зберігає результат фізично на диску (знімок на момент оновлення); у PostgreSQL дані застарівають і не оновлюються самі — потрібен явний
REFRESHвручну або за розкладом. - Знаю відмінності СУБД: Oracle уміє
ON COMMIT-оновлення, MySQL нативних матеріалізованих вʼю не має (імітує таблицею + тригерами);REFRESH ... CONCURRENTLYне блокує читання ціною унікального індексу на вʼю.
Функції, процедури і логіка в БД
- Можу пояснити різницю функції та процедури: функція повертає значення й вживається в запиті; процедура викликається
CALL, робить побічні ефекти і може керувати транзакцією (COMMIT/ROLLBACK); у PostgreSQL процедури зʼявилися у версії 11. - Розумію, навіщо логіку тримають у БД: швидкодія на масивах даних, атомарність зміни кількох таблиць, єдине джерело правди для всіх клієнтів.
- Знаю ціну логіки в БД: важче версіонувати в git, важче покрити юніт-тестами, важче дебажити, привʼязка до діалекту (PL/pgSQL, T-SQL).
- Памʼятаю головний наслідок для QA: бізнес-логіка (податок, бонуси, ліміт) може бути невидимою в коді застосунку — перш ніж казати «фічі немає», глянь у БД.
Тригери
- Розумію, що тригер (trigger) — код, привʼязаний до таблиці, який СУБД викликає автоматично на
INSERT/UPDATE/DELETE; ніхто не викликає його явно. - Можу пояснити
BEFOREvsAFTER:BEFOREзмінює/валідує сам рядок до запису,AFTERробить побічні ефекти (аудит, агрегати, каскади) після. - Знаю записи
NEW(новий рядок) іOLD(старий), коли який доступний і що правитиNEWможна лише вBEFORE; розрізняю рівніFOR EACH ROW(на кожен рядок) vsFOR EACH STATEMENT(раз на команду). - Памʼятаю, що тригери каскадять: тригер на таблиці A міняє B, у якої свій тригер, — один
INSERTможе зачепити кілька таблиць.
Тестування логіки в БД
- Знаю, що логіку в БД тестують на її рівні (прямий виклик), а не лише крізь UI, бо UI показує лише кінцевий результат.
- Можу протестувати функцію/процедуру: підготував вхід, викликав
SELECT/CALL, звірив вихід або побічний ефект; покрив межові випадки (NULL, порожній вхід). - Можу протестувати тригер: виконати подію і асертити побічний ефект, не забувши негативний бік (
BEFORE, що має відхилити невалідний рядок, справді відхиляє); матеріалізовану вʼю тестую у звʼязці зREFRESH. - Розумію проблему ізоляції: через тригери й дефолти «що я записав» ≠ «що лежить у таблиці», тому наївний асерт падає на згенерованих полях (
created_at), а прибирання за тестом має враховувати каскад одногоINSERT.
Де живе логіка
- Розумію, що вибір «логіка в БД чи в застосунку» — тредоф без єдиної правильної відповіді (швидкодія/атомарність проти тестопридатності/портативності).
- Головне для тесту — знати, де логіка лежить фактично: це визначає, який шар тестувати й куди заводити баг.
- Перш ніж заводити «застосунок рахує неправильно», зʼясовую, хто саме рахує — код застосунку, збережена функція чи агрегат у тригері.
Що вʼю (view) зберігає у себе?
Питання
Вʼю (view) — що це і що вона зберігає?