1. @Query по умолчанию — про чтение
Когда вы только-только привыкаете к Spring Data, возникает наивная, но очень человеческая мысль: «Раз @Query — это аннотация для запроса, значит я могу написать там что угодно. Хоть select, хоть update. Фреймворк же умный — догадается». И вот тут Spring Data делает вид, что он не умный, а строго воспитанный: по умолчанию @Query воспринимается как запрос на чтение.
Это важно, потому что чтение и изменение данных в JPA выполняются разными вызовами на уровне API. Для чтения провайдер ожидает, что вы хотите получить результат: список строк, одну строку, агрегат. Для update/delete результат в другом смысле — это количество затронутых строк, и выполняется это через executeUpdate(), а не через getResultList().
Представьте, что вы пришли в кофейню и сказали: «Мне, пожалуйста, латте». Бариста начинает делать латте. А вы внезапно добавляете: «И еще обновите мне прошивку на телефоне». Бариста, конечно, может быть талантливым человеком, но формально вы всё равно обратились не по адресу и не в том режиме. Примерно так же чувствует себя Spring Data, когда видит DML (update/delete) внутри метода, который он продолжает трактовать как read-query.
Чтобы почувствовать проблему, посмотрим на «почти правильный» метод. Он выглядит убедительно, но без @Modifying это не modifying query:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// ВАЖНО: это DML-запрос (update), а не select.
// Если забыть @Modifying, Spring Data попытается выполнить его как запрос на чтение.
@Query("update Product p set p.status = :status where p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") ProductStatus status); // Возвращаем число затронутых строк
}
На вид — конфета. На практике — либо ошибка при выполнении, либо странное поведение, в зависимости от того, как именно Spring попробует исполнить запрос. И это логично: Spring Data не обязан угадывать, что вы имели в виду. Он любит явные контракты. Поэтому мы добавляем ту самую «табличку на двери» — @Modifying.
2. JPQL update и delete: синтаксис
Перед тем как ставить аннотации, полезно на минуту притормозить и вспомнить: мы пишем не SQL, а JPQL, то есть язык запросов по entity-модели. Он действительно похож на SQL, но если вы начнёте писать названия таблиц и колонок, он обидится и не запустится.
В bulk-операциях это особенно заметно: в JPQL update и delete вы указываете имя entity и её поля, а не таблицу product и колонку status. И да, «поля» — это именно p.status, а не p.status_code, даже если в БД колонка называется как-то хитро.
Вот минимальный пример bulk update на языке JPQL, пока без репозитория, просто чтобы увидеть форму:
// Это не Java-код, а просто «набросок JPQL», чтобы увидеть форму запроса.
//
// ВАЖНО: Product — это имя Entity, а не таблицы.
// ВАЖНО: p.status и p.category.id — это поля/связи в модели, а не колонки.
// update Product p set p.status = :newStatus where p.category.id = :categoryId
А вот минимальный bulk delete:
// Это не Java-код, а «идея JPQL-запроса».
//
// ВАЖНО: delete идёт от Entity (Product), критерий — по полю status.
// delete from Product p where p.status = :status
Важно понимать две практические особенности, которые особенно цепляют новичков.
Первая: JPQL update и delete работают «по критерию», и вы можете использовать навигацию по связям в where. Например, p.category.id = :categoryId — это нормальная, легальная вещь. На SQL-уровне это превратится в сравнение по foreign key, но в JPQL вы мыслите объектной моделью.
Вторая: bulk update не превращается в «умную обработку каждого объекта». Это именно массовая операция по данным. То есть вы не можете сказать «всем товарам поставить статус, который зависит от их имени по сложному if». Bulk-запросы хороши, когда вы меняете однотипно и прямолинейно: «всем подходящим — вот это значение».
3. @Modifying: переключатель режима
Если в лекции 1 мы разделили в голове две модели («меняем объекты» и «меняем данные»), то сейчас мы делаем технический шаг: как заставить Spring Data выполнить @Query как изменение, а не как чтение. Именно это и делает @Modifying.
На уровне ощущения @Modifying — это как переключатель режима у дрели: «закручивать» или «сверлить». В обоих случаях вы держите инструмент в руках, но результат и механика совершенно разные. В Spring Data это похоже: и select, и update — это «запрос», но выполняются они разными внутренними путями.
Очень грубо, почти псевдокодом, разница выглядит так:
если это read-query:
выполнить query и получить результат (List/Optional/...)
если это modifying-query:
выполнить query.executeUpdate() и получить число строк
И вот @Modifying как раз говорит Spring Data: «Дорогой фреймворк, это второй случай. Пожалуйста, не пытайся получить список результатов — тут ничего не “выбирают”, тут “меняют”».
Почти всегда @Modifying используется вместе с @Query. И это важная дисциплина: не ставьте @Modifying “на всякий случай”. Аннотация должна означать ровно одно: «этот метод меняет данные через bulk update/delete».
Ещё один аккуратный момент: JPA требует, чтобы update/delete выполнялись в транзакции. Здесь достаточно одного практического правила: bulk update/delete запускаем из transactional service method. Репозиторий описывает запрос, сервис задаёт границу use case. Если вы видите ошибку вида TransactionRequiredException, проблема обычно не в JPQL, а в том, что операция ушла без транзакции или внутри readOnly-режима.
4. Bulk update в репозитории
Теперь соберём «правильный» метод репозитория так, как его приятно видеть в живом проекте. Для нашего сквозного приложения shop-data-jpa логичный пример — массово поменять статус товаров внутри категории. Например, «категорию закрыли на переоценку, все товары временно переводим в INACTIVE».
Начнём с интерфейса репозитория. Договоримся, что он лежит в com.example.shopdatajpa.catalog.repository, а сущности — в com.example.shopdatajpa.catalog.entity.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// @Modifying переключает выполнение с "read-query" на "modifying-query" (executeUpdate()).
@Modifying
// JPQL, а не SQL: Product — это Entity, p.status — поле Entity.
@Query("""
update Product p
set p.status = :newStatus
where p.category.id = :categoryId
""")
int updateStatusByCategoryId(
@Param("categoryId") Long categoryId, // Явно связываем параметр метода с :categoryId
@Param("newStatus") ProductStatus newStatus // Явно связываем параметр метода с :newStatus
); // Возвращаем количество реально обновлённых строк
}
Обратите внимание, что здесь важно не только наличие @Modifying, но и «маленькая гигиена» вокруг запроса.
Мы используем named parameters (:categoryId, :newStatus) и @Param. Это просто удобнее поддерживать, чем позиционные параметры ?1, ?2, особенно когда метод обрастает новыми условиями. Позиционные параметры выглядят невинно, но через месяц легко превращаются в «угадай, что такое ?3», а игра в угадайку — это не лучший формат для продакшн-кода.
Возвращаемый тип — int. Это число строк, которые реально обновились. Если обновить нечего, вернётся 0, и это тоже полезная информация: значит, категория пустая, или товары уже были в нужном статусе, или вы передали не тот categoryId.
Теперь добавим маленький кусочек кода, который показывает, как этим пользоваться. Пусть это будет метод сервиса. Сам запрос живёт в репозитории, а транзакцию открываем на сервисной операции.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public int deactivateProductsInCategory(long categoryId) {
// Внутри — bulk update, снаружи — понятная бизнес-операция.
// Возвращаем "сколько строк реально поменяли".
return productRepository.updateStatusByCategoryId(categoryId, ProductStatus.INACTIVE);
}
}
Это очень «честный» код. Метод называется так, что по нему сразу видно: действие массовое, критерий — категория, результат — число изменённых строк. И главное: мы не делаем вид, что получим «обновлённые объекты». Мы получаем число. Это и есть корректная модель bulk update.
Возвращаемое значение bulk-метода
Новички часто недооценивают возвращаемое значение bulk update/delete. Кажется: «Ну обновили и обновили. Зачем мне число?» А потом наступает момент, когда вы пишете лог: «деактивировали товары категории», а в реальности деактивировали ноль. Или, наоборот, деактивировали 10 тысяч, потому что перепутали id. И вот тут int changed превращается из «лишней цифры» в «спасательный круг».
С bulk-методами есть хорошая привычка: если результат важен бизнесу или хотя бы полезен для логов, возвращайте число и используйте его. Даже если дальше вы ничего не делаете, хотя бы выведите в лог на уровне сервиса.
Простой пример использования, условно в тестовом сценарии или в небольшом админском вызове:
long categoryId = 5L; // Критерий: какая категория
int changed = catalogService.deactivateProductsInCategory(categoryId); // Результат: сколько строк обновили
System.out.println("Changed rows = " + changed); // Changed rows = 12
Психологически это тоже правильно: bulk-метод — это не «я поменял объект», это «я отправил команду базе». А база отвечает: «я реально изменила N строк». Это такой маленький, но честный контракт между вашим кодом и реальными данными.
И тут важно не перепутать: если вы сделаете возвращаемый тип void, bulk-операция будет выполняться, но вы сознательно откажетесь от наблюдаемости. Иногда это допустимо, но для учебного проекта и для junior-практики int чаще полезнее.
5. Bulk delete в репозитории
Bulk delete технически объявляется так же, как bulk update: @Query + @Modifying. Только текст запроса обычно короче, и именно поэтому он психологически опаснее. Удаление в одну строку кода выглядит как «да что там может пойти не так», а потом оказывается, что «пойти не так» может вообще всё, если критерий выбран неаккуратно.
Давайте объявим bulk delete для простого сценария: удалить все товары со статусом INACTIVE (например, в учебной базе накопился мусор).
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// ВАЖНО: это bulk delete, он удаляет строки сразу по критерию.
@Modifying
@Query("delete from Product p where p.status = :status")
int deleteByStatusInBulk(@Param("status") ProductStatus status); // Возвращает число удалённых строк
}
И использование:
// Опасное место: критерий надо выбирать максимально осознанно.
int deleted = productRepository.deleteByStatusInBulk(ProductStatus.INACTIVE);
System.out.println("Deleted rows = " + deleted); // Deleted rows = 3
Здесь всё та же логика: результат — это число строк. И если вы случайно передадите ACTIVE, вы удалите не мусор, а всё живое. Поэтому bulk delete особенно любит две вещи: хорошее имя метода и хорошую дисциплину критерия.
Ещё один нюанс, который важно проговорить вслух: bulk delete — это не «удаление объектов». Это «удаление строк». Как только у сущности есть lifecycle, каскады или уже загруженные экземпляры в памяти, разница между этими двумя режимами перестаёт быть формальностью и начинает влиять на поведение кода.
6. Derived delete: коротко, чтобы не спутать с bulk
Есть и другой способ удалить данные по критерию — derived delete, когда Spring Data выводит запрос из имени метода. Здесь @Modifying не нужна, потому что @Query мы вообще не пишем: по имени deleteBy... фреймворк и так понимает, что это операция удаления.
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived delete: Spring Data выводит удаление из имени метода.
long deleteByStatus(ProductStatus status);
}
Если хочется наблюдаемости, count обычно полезнее, чем void.
Практическое правило для этого места простое: deleteBy... и bulk delete могут выглядеть похоже по критерию, но это не один и тот же инструмент. Если нужен именно один delete ... where ... с количеством затронутых строк, это bulk-путь.
7. Нейминг bulk-методов
Нейминг в bulk-операциях — это не эстетика. Это защита вашего будущего «я» от вашего же прошлого «я». Bulk update/delete может за секунду поменять огромный объём данных. И если метод называется нейтрально, его легко вызвать «по ошибке», перепутать с обычным update сущности или не заметить, что это операция «по критерию».
Хорошее имя bulk-метода обычно отвечает на два вопроса: что меняем и по какому критерию. Плохо updateProducts(...). Лучше updateStatusByCategoryId(...), deactivateProductsInCategory(...), deleteByStatusInBulk(...).
| Как назвали метод | Что в этом имени видно |
|---|---|
| updateProducts(...) | Почти ничего: непонятно, что именно меняем и как выбираем записи |
| updateStatusByCategoryId(categoryId, newStatus) | Видно и поле, и критерий |
| deactivateProductsInCategory(categoryId) | Хорошо передаёт бизнес-намерение массовой операции |
| deleteByStatusInBulk(status) | Сразу предупреждает, что это delete по строкам, а не безобидный helper |
И да, это тот редкий случай, когда немного избыточности в названии делает систему безопаснее. Bulk-операция должна читаться как массовая команда базе, а не как что-то нейтральное и незаметное.
8. Типичные ошибки при работе с @Modifying и @Query
Ошибка №1: написать JPQL update/delete в @Query и забыть @Modifying.
Это самая частая поломка сегодняшней темы. Метод выглядит логично, компилируется, даже IDE подсвечивает строку как query, но при выполнении вы получите exception, потому что Spring Data попытается исполнить запрос как чтение результата. Лекарство простое: если запрос меняет данные и вы пишете его через @Query, рядом обязана стоять @Modifying.
Ошибка №2: ожидать от bulk update «обновлённые объекты».
Bulk update возвращает число строк, а не список Product. Если вы ловите себя на мысли «я сделаю bulk update, а потом продолжу работать со списком товаров, который загрузил раньше», остановитесь. Это другой режим. Bulk — это «команда базе», а не «мутирую объекты в памяти». Именно поэтому после bulk нельзя автоматически считать уже загруженные сущности актуальными.
Ошибка №3: скрывать массовую операцию за слишком нейтральным названием метода.
Метод updateProducts() звучит как «что-то обычное», а не как потенциальная «перекраска половины таблицы». С bulk-методами лучше чуть-чуть перегнуть в сторону честности: упомянуть, что меняем (status) и по чему выбираем (categoryId, createdAt, status). Когда код читают другие люди, или вы сами через неделю, это реально снижает шанс случайной катастрофы.
Ошибка №4: возвращать void там, где число затронутых строк важно для наблюдаемости.
void допустим, но часто это добровольный отказ от полезной диагностики. Для учебного проекта и для junior-уровня куда полезнее возвращать int/long и хотя бы логировать или выводить это значение в отладке. Особенно в bulk delete: один неверный критерий, и вы очень захотите знать, сколько именно строк исчезло.
Ошибка №5: пытаться запихнуть в bulk query сложную «индивидуальную» логику для каждой записи.
Если новое значение реально зависит от каждой строки по разным правилам, bulk update становится либо невозможным, либо превращается в монстра. Bulk-подход хорош, когда действие однотипное. Если логика ветвится «для каждого товара отдельно», не надо насильно загонять её в один запрос — это как пытаться забить гвоздь микроскопом: технически можно, но потом микроскоп грустит.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ