JavaRush /Курси /Spring Data JPA /Дві моделі зміни даних

Дві моделі зміни даних

Spring Data JPA
Рівень 14 , Лекція 0
Відкрита

1. Розрізняємо зміни: сутності та bulk

Коли розробник тільки починає, здається, що зміна даних — це завжди одне й те саме: «знайшов обʼєкт, змінив поле, зберіг». Але в реальному backend дуже швидко з’являються масові операції: «усім товарам категорії вимкнути статус», «видалити все старе», «обнулити знижки», «закрити замовлення в помилковому стані». На рівні результату це схоже, а на рівні механіки — два різні світи.

Уявіть, що ви ведете наш mini-shop і вам прилітає завдання: «Від сьогодні товари категорії CLEARANCE (розпродаж) мають стати неактивними». Можна чесно завантажити кожен товар як обʼєкт і оновити його. А можна сказати базі: «Постав потрібне значення всім рядкам, які підпадають під умову». Обидва способи законні, але вони по-різному виглядають у коді, по-різному виконуються в SQL і по-різному поводяться в застосунку.

Мініісторія з mini-shop: «Потрібно деактивувати товари»

Прив’яжімо розмову до проєкту, щоб це не звучало відірвано від практики. У нас є Product, у нього є status (наприклад, ACTIVE і INACTIVE), і є зв’язок із категорією. Бізнес-операція звучить просто: «частина товарів має стати INACTIVE». Питання не в тому, чи можемо ми це зробити, а в тому, як саме ми будемо це робити: через обʼєктний шлях (сутності) чи через масовий шлях (bulk).

Для контексту згадаємо короткий фрагмент enum статусу (можливо, він у вас уже є, але нехай буде орієнтир):

package com.example.shopdatajpa.catalog.entity;

// Статус товару в каталозі mini-shop
public enum ProductStatus {
    ACTIVE,   // товар активний і доступний
    INACTIVE  // товар вимкнений/прихований
}

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

2. Entity-based: працюємо з обʼєктами

Спочатку розберімо «класичний» шлях, який інтуїтивно зрозумілий: ми працюємо із сутностями як з обʼєктами. Ми їх завантажуємо, змінюємо поля, видаляємо, зберігаємо. Цей шлях дуже схожий на те, як ви працюєте зі звичайними Java-обʼєктами — і саме тому він такий зручний для новачка. Але важливо розуміти його ціну: якщо обʼєктів багато, то й роботи стає багато.

В обʼєктному, entity-based, підході головний герой — конкретна сутність. Ми мислимо не «умовою», а «ось цим товаром», «ось цим замовленням». Це часто збігається з тим, як мислить бізнес: «онови цей товар», «скасуй це замовлення», «перейменуй цю категорію». І в таких кейсах обʼєктний шлях зазвичай найчитабельніший.

Один обʼєкт: знайти, змінити, зберегти

Найпростіший і найбільш «людський» сценарій — змінити статус конкретного товару за id. Тут ніхто не сперечається: обʼєктний шлях дуже прозорий. Ми читаємо одну сутність, змінюємо поле і зберігаємо.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

public void deactivateOne(Product product) {
    // Змінюємо стан конкретного обʼєкта в памʼяті
    product.setStatus(ProductStatus.INACTIVE);

    // Далі зазвичай буде збереження через repository,
    // щоб зміна дійшла до бази
}

У реальному сервісі це виглядатиме приблизно так — коротко і по суті:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

public void deactivateOne(Long productId) {
    // Завантажуємо сутність із бази (або кидаємо виняток, якщо не знайшли)
    Product product = productRepository.findById(productId).orElseThrow();

    // Змінюємо поле в сутності
    product.setStatus(ProductStatus.INACTIVE);

    // Зберігаємо зміни (у термінах JPA це призведе до UPDATE)
    productRepository.save(product);
}

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

Багато обʼєктів: цикл по сутностях

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

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

import java.util.List;

public void deactivateAllInCategory(Long categoryId) {
    // Спочатку завантажуємо всі товари категорії в памʼять (List<Product>)
    List<Product> items = productRepository.findByCategoryId(categoryId);

    // Потім проходимося по них як по звичайних обʼєктах
    for (Product product : items) {
        // Змінюємо поле в кожній сутності окремо
        product.setStatus(ProductStatus.INACTIVE);

        // І зберігаємо кожну сутність (часто це означає багато операцій на рівні ORM/SQL)
        productRepository.save(product);
    }
}

Якщо ви дивитеся на цей код і думаєте: «Начебто коротко», — це нормально. Новачки часто оцінюють складність за довжиною методу. Але реальна вартість тут не в рядках Java, а в тому, що ви робите з базою: спочатку завантажуєте список, а отже, тягнете дані в памʼять; потім потенційно відправляєте багато UPDATE-ів або принаймні багато дій в ORM, які призводять до множини SQL. Навіть якщо це не катастрофа, це інша модель роботи.

І тут з’являється перша важлива розвилка: ми справді хочемо проходитися по товарах як по обʼєктах, тому що в кожного товару є індивідуальна логіка? Чи нам потрібен один масовий крок — «усім поставити статус»?

Переваги та ціна entity-based підходу

Тепер важливо не зводити порівняння до формули «bulk завжди кращий». Ні. Entity-based підхід цінний тим, що він природно підтримує логіку «на кожен обʼєкт — своє правило». Наприклад, один товар ви деактивуєте, інший пропускаєте, третій деактивуєте і пишете лог із причиною, четвертий переводите в інший статус. Щойно з’являється розгалуження й індивідуальність, обʼєктний шлях стає читабельним і безпечним.

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

3. Bulk: один запит за умовою

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

Bulk-підхід — це коли ви думаєте так: «Мені не потрібен кожен товар як обʼєкт. Мені потрібна операція над набором рядків». Тобто ви описуєте критерій і одну дію, яка однаково застосовується до всіх відповідних даних. Саме тому bulk так добре підходить для сценаріїв «масово вимкнути», «масово видалити», «масово оновити поле».

Bulk update: усім рядкам поставити значення

Уявімо, що ми хочемо деактивувати товари категорії одним SQL. На рівні бази це виглядає майже банально:

-- Масове оновлення: один UPDATE за умовою
update product
set status = 'INACTIVE'
where category_id = 5;

Ключова деталь: це один UPDATE. Не цикл із оновлень і не сценарій «спочатку вибрали, потім оновили». Просто один запит, який одразу змінює потрібний набір рядків.

На рівні Java-коду, поки без технічних деталей реалізації, логіка виглядає так: ми викликаємо метод, який повертає число змінених рядків.

public int deactivateProductsInCategory(Long categoryId) {
    // Запускаємо bulk-операцію та отримуємо рівно те, що вона "чесно" повертає: кількість рядків
    int changed = productRepository.deactivateByCategoryId(categoryId);

    // Зазвичай це йде в логування або метрики, а не в бізнес-логіку
    System.out.println("Деактивовано рядків = " + changed); // наприклад: Деактивовано рядків = 42

    return changed;
}

Зверніть увагу, як змінюється мислення. Тут нам не потрібен List<Product>. Нам не потрібно робити for. Ми не «працюємо з товарами». Ми «виконуємо масову операцію за критерієм». І природний результат такої дії — кількість рядків, які справді змінилися.

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

Bulk delete: видалити все за умовою

Bulk delete — це та сама ідея, тільки замість зміни поля ми видаляємо рядки. На SQL-рівні це знову один запит:

-- Масове видалення: один DELETE за умовою
delete from product
where status = 'INACTIVE';

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

public int deleteAllInactiveProducts() {
    // У bulk-delete результат — це кількість видалених рядків
    int deleted = productRepository.deleteInactiveInBulk();

    // Корисно щонайменше занести в лог факт масового видалення
    System.out.println("Видалено рядків = " + deleted); // наприклад: Видалено рядків = 120

    return deleted;
}

Зверніть увагу, наскільки це відрізняється від видалення через productRepository.delete(product). Там у вас є обʼєкт, і ви видаляєте конкретний обʼєкт. Тут у вас є умова, і ви видаляєте набір даних.

Що повертає bulk: число рядків

Для новачка це частий момент роздратування: «Чому я не можу одразу отримати список оновлених товарів?». Можете, але це вже буде інша операція: після bulk update вам доведеться ще раз виконати SELECT, щоб отримати актуальні дані. І це, насправді, нормально: bulk і читання — різні дії.

У bulk-моделі головне, що ви можете дізнатися одразу, це «скільки рядків база справді зачепила». Іноді це потрібно тільки для логів. Іноді — для бізнес-рішення. Наприклад, якщо ви очікуєте деактивувати рівно один товар, а деактивувалося 0, це привід замислитися: можливо, товар не знайдено або він уже був INACTIVE. Якщо ви очікували деактивувати 1000, а деактивувалося 12, — теж привід.

Ось мінімальний «перевірочний» приклад:

public void deactivateAllActiveBefore(Long categoryId) {
    // Bulk-метод повертає лічильник зачеплених рядків
    int changed = productRepository.bulkDeactivate(categoryId);

    // Якщо нічого не змінилося — це окремий і корисний сигнал
    if (changed == 0) {
        System.out.println("Нічого деактивувати"); // Нічого деактивувати
    }
}

Знову ж таки: реалізацію bulkDeactivate ми тут ще не пишемо. Ми зараз вчимося правильно очікувати результат.

4. Порівняння підходів на одному прикладі

Тепер зберімо все в одну картину й закріпімо головне: обидва підходи можуть приводити до однакового «бізнес-результату» (наприклад, товари стали INACTIVE), але вони принципово відрізняються за формою коду і за тим, що вважається «природним результатом» операції. Якщо ви це не розрізняєте, то робитимете дивні речі: чекатимете від bulk «оновлених обʼєктів» або намагатиметеся реалізувати bulk через for і save().

Щоб мозок не тримав це як розмите відчуття, зручніше подивитися на порівняння в одній таблиці. Таблиця — це не шпаргалка на іспиті, а спосіб швидко побачити, де саме підходи розходяться.

Питання Entity-based підхід Bulk-підхід
Як ми думаємо? «Ось конкретні товари як обʼєкти» «Ось умова і масова дія»
Що пишемо в коді? find... → цикл → save/delete один метод «update/delete where ...»
Що природно повернути? сутності (обʼєкти) або void число зачеплених рядків (int)
Коли зручно? коли логіка різна для кожного обʼєкта коли всім потрібно одне й те саме за умовою
Ціна багато обʼєктів у памʼяті, багато дій не тримаємо обʼєкти, один запит, але інша семантика

Найважливіше тут навіть не «що швидше», а «що чесніше відображає намір». Код — це комунікація. Коли ви пишете цикл по товарах, ви повідомляєте читачеві: «Мені важливо пройтися по кожному обʼєкту». Коли ви пишете bulk-метод, ви повідомляєте: «Мені важливо швидко застосувати одну зміну до набору рядків».

Схема виконання: від Java-коду до SQL

Щоб остаточно закріпити думку, давайте намалюємо дві мінісхеми. Це корисно, тому що багато помилок у JPA починаються саме з того, що розробник не уявляє, скільки реальних кроків відбувається між рядками Java-коду та базою.

Entity-based (багато обʼєктів):

flowchart TD
    A["Java: findBy..."] --> B["Отримали List<Product>"]
    B --> C["Цикл по кожному Product"]
    C --> D["setStatus / delete"]
    D --> E["save / delete"]
    E --> F["SQL: серія UPDATE/DELETE"]

Bulk (один запит):

flowchart TD
    A["Java: bulkUpdate(criteria)"] --> B["SQL: один UPDATE/DELETE за умовою"]
    B --> C["Результат: changedRows"]

Виглядає майже смішно просто — і це хороший знак. Саме за таку прямолінійність bulk і цінують: один запит, зрозумілий критерій, вимірюваний ефект.

Рішення зазвичай не про «красу», а про зміст операції. Якщо вам потрібно пройти по кожному товару і для кожного прийняти окреме рішення, наприклад деактивувати тільки ті, у кого імʼя починається з "TEMP-", а решту залишити, то bulk перестає бути природним. Bulk любить правила виду «всім однаково». Він не любить «кожному по-своєму», тому що тоді ви починаєте намагатися запхати складну логіку в один запит, і це швидко перетворюється або на SQL-акробатику, або на код, який ніхто не хоче супроводжувати.

Якщо ж правило звучить як «усім товарам категорії X поставити статус Y» або «усім товарам старшим за дату D поставити статус INACTIVE», то обʼєктний шлях стає зайвим. Ви не хочете тримати в памʼяті тисячу товарів заради того, щоб однаково змінити одне поле. Тут критерій і масова дія — чесна модель.

І ще один тонкий, але важливий момент: в entity-based підході ви часто можете одразу після зміни продовжити роботу з обʼєктом. У bulk-підході краще вважати, що ви працювали з базою безпосередньо через умову, а не з обʼєктами. Якщо після bulk вам потрібні актуальні обʼєкти, це буде окремий крок читання. Від цієї розвилки далі прямо залежать і спосіб оголошення modifying query, і очікуваний результат операції, і те, чи можна після неї довіряти вже завантаженим обʼєктам.

5. Типові помилки під час вибору моделі зміни

Помилки тут майже ніколи не про синтаксис. Вони про неправильні очікування. І хороша новина: щойно ви навчилися ставити собі запитання «я зараз працюю обʼєктами чи критерієм?», половина проблем просто перестає виникати.

Помилка №1: вважати, що for + save() — це bulk update.
Цикл по списку сутностей може змінювати багато даних, але це все ще обʼєктна модель. Ви завантажили обʼєкти, ви тримаєте їх у памʼяті, ви оновлюєте їх по одному. Bulk — це «один запит за умовою», а не «багато маленьких збережень». Якщо ви не розрізняєте ці моделі, то будете неправильно оцінювати вартість операції та плутати семантику.

Помилка №2: обирати bulk лише тому, що код коротший.
Короткий код — не завжди правильний код. Якщо бізнес-правило вимагає обробити кожен обʼєкт окремо, перевірити кілька умов, сформувати лог або обчислити значення індивідуально, то bulk почне або ламатися за змістом, або ви перетворите запит на монстра. У таких кейсах обʼєктний шлях — нормальний вибір, навіть якщо він довший.

Помилка №3: очікувати від bulk-операції «оновлені обʼєкти».
У bulk-моделі природний результат — число рядків. Якщо вам потрібні обʼєкти, отже вам потрібне окреме читання після bulk. Щойно ви приймаєте це як норму, зникає багато дивних очікувань на кшталт «чому я не бачу нові значення в уже наявному списку». Ми ще розберемо це глибше далі, але вже зараз важливо: bulk не зобовʼязаний повертати вам сутності.

Помилка №4: порівнювати підходи тільки за бізнес-результатом і ігнорувати механіку.
«Ну і там, і там статус став INACTIVE» — звучить як аргумент. Але механіка визначає наслідки: кількість SQL, кількість завантажених обʼєктів, форму результату, що повертається, підтримуваність коду. Уміння бачити механіку — це і є дорослішання як backend-розробника. Не обовʼязково одразу ставати майстром Hibernate, але розрізняти дві моделі зміни даних потрібно вже на цьому рівні.

Помилка №5: забувати, що bulk може залишити «застарілі» сутності в памʼяті.
Якщо ви до bulk-операції вже завантажили Product як сутності (вони потрапили в persistence context), а потім зробили bulk UPDATE напряму по таблиці, то в памʼяті у вас можуть залишитися старі значення полів. У результаті код може «дивитися на обʼєкт» і бачити одне, а база вже зберігає інше. Це вирішується дисципліною: не змішувати бездумно bulk і роботу з уже завантаженими сутностями, а також продумувати синхронізацію або повторне читання там, де це важливо.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ