vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

    В'ю, збережені процедури і тригери

    Зміст

    Досі ми дивилися на базу як на пасивне сховище: є таблиці, ти пишеш 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, планувальник). Між двома рефрешами вʼю показує стару картину.

    Таблиця

    Звичайна в'ю

    Матеріалізована в'ю

    Запит SELECT

    Що читаємо?

    Дані лежать фізично
    завжди актуальні

    Підзапит виконується наживо
    актуально, але рахується щоразу

    Готовий знімок на диску
    швидко, але дані на момент REFRESH

    Таблиця

    Звичайна в'ю

    Матеріалізована в'ю

    Запит SELECT

    Що читаємо?

    Дані лежать фізично
    завжди актуальні

    Підзапит виконується наживо
    актуально, але рахується щоразу

    Готовий знімок на диску
    швидко, але дані на момент REFRESH

    Поведінка різних СУБД тут відрізняється, і це варто памʼятати: 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 (один раз на всю команду, скільки б рядків вона не зачепила).

    Ось де це болить у тестах. Наочна послідовність:

    Таблиця customersAFTER-тригерBEFORE-тригерТаблиця ordersТест / застосунокТаблиця customersAFTER-тригерBEFORE-тригерТаблиця ordersТест / застосунокрядок записанотест вставив 1 рядок,а змінилося дві таблиціINSERT замовленняспрацьовує до записупідставляє created_at, валідуєспрацьовує після записуUPDATE orders_count + 1Таблиця customersAFTER-тригерBEFORE-тригерТаблиця ordersТест / застосунокТаблиця customersAFTER-тригерBEFORE-тригерТаблиця ordersТест / застосунокрядок записанотест вставив 1 рядок,а змінилося дві таблиціINSERT замовленняспрацьовує до записупідставляє created_at, валідуєспрацьовує після записуUPDATE orders_count + 1

    Ти зробив один 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, а не поточний.
    • «BEFORE vs AFTER тригер — коли який?»BEFORE для зміни/валідації самого рядка, AFTER для побічних ефектів (аудит, агрегати, каскади).
    • «Як протестувати логіку у збереженій процедурі чи тригері?» — Прямий виклик функції з відомими входами; для тригера — виконати подію і перевірити побічний ефект, не забувши про ізоляцію та згенеровані базою поля.
    • «Логіку краще тримати в базі чи в застосунку?» — Правильної відповіді немає; інтервʼюер дивиться на аргументацію тредофів (швидкодія і атомарність проти тестопридатності й портативності) і на розуміння, що для тесту головне — знати, де логіка фактично.

    Джерела