JPA tömeges műveletek
Utolsó módosítás dátuma: 2026. június 11.
Történt a mai napon, hogy egy újabb felfedezést tettem a JPA tömeges műveleteivel (bulk update and delete) kapcsolatban.
A JPA ugyanis lehetőséget biztosít egyszerre több entitás egyidejű módosítására, ahelyett, hogy az összeset be kéne tölteni, és egyesével módosítani. Ennek működése és szintaktikája hasonló az SQL UPDATE műveletéhez, azzal a különbséggel, hogy itt nem csak egy táblán, hanem a teljes entitáson lehet operálni. Formátuma a következő:
UPDATE <entity name> [[AS] <identification variable>]
SET <update statement> {, <update statement>}*
[WHERE <conditional expression>]
Az update statement esetén az egyenlőségjel bal oldalán egy egyértékű
path kifejezés áll (pl. e.salary), a jobb oldalán egy viszonylag
korlátozott kifejezés (literálra feloldható, egyszerű típusú értékre
feloldható kifejezés, függvény, változónév vagy paraméter).
Erre egy példa:
UPDATE Employee e
SET e.salary = e.salary + 10
A SET clause-ban látható path kifejezés mutatja, hogy nem
SQL-ről van szó, hanem az entitásokon dolgozó JPQL nyelvvel állunk
szemben, ahol megengedett az attribútumok láncolása.
Ehhez hasonlóan létezik a DELETE kifejezés is:
DELETE FROM <entity name> [[AS] <identification variable>]
[WHERE <conditional expression>]
Ennek külön érdekessége, hogy figyelembe veszi az öröklődést, tehát a feltételnek megfelelő osztályok is törlésre kerülnek. Viszont nem veszi figyelembe a kaszkádolást, szóval csak a kifejezésben szereplő, valamint annak alosztályához tartozó típusú entitásokat fogja törölni a kifejezés, nem töröl hozzájuk kapcsolódó objektumokat.
Spring Data JPA-val ezt @Modifying annotációval megjelölt
metódussal lehet futtatni.
Az egyik jelenlegi projektben próbálkozom egy kicsit a teszt vezérelt fejlesztés (Test Driven Development - TDD) megközelítéssel, és gondoltam olyan teszt eseteket használok, melyek függetlenek az adatbázis kezdő állapotától, azaz először mindig inicializálom a tábla tartalmát, lefut a teszt eset, majd hogy minden adat visszaálljon, végrehajtok egy rollback műveletet. A problémák akkor adódtak, mikor egy tömeges műveletet végrehajtó funkciót akartam tesztelni. A teszt a következőképp nézett ki:
- Új tranzakció indítása
- Összes entitás törlése
- Teszt entitások perzisztálása
- Tömeges update műveletek végrehajtása
- Assert - entitások visszatöltése, update ellenőrzése
- Tranzakció rollback
A poszthoz tartozó forráskód elérhető a GitHubon.
A teszt eset kódja:
@SpringBootTest
public class EmployeesServiceIT {
@Autowired
private EmployeesService employeesService;
@BeforeEach
void setUp() {
employeesService.deleteAll();
employeesService.save(new Employee("John Doe", 100));
employeesService.save(new Employee("Jack Doe", 200));
}
@Test
@Transactional
public void raiseSalaries() {
employeesService.raiseSalaries(10);
assertThat(employeesService.findAll())
.extracting(Employee::getName, Employee::getSalary)
.contains(
Tuple.tuple("John Doe", 110),
Tuple.tuple("Jack Doe", 210)
);
}
}
Itt a @Transactional annotációt kell megfigyelni a teszteseten,
mely azt jelzi, hogy a teszteset lefuttatása után rollback kell.
A tesztelendő Spring Data JPA repository:
public interface EmployeesRepository extends JpaRepository<Employee, Long> {
@Transactional
@Modifying
@Query("""
update Employee e
set e.salary = e.salary + :amount
""")
int raiseSalary(@Param("amount") int amount);
}
Figyeljük meg, hogy rá kell tenni a @Transactional annotációt.
És a service:
@Service
@RequiredArgsConstructor
public class EmployeesService {
private final EmployeesRepository employeesRepository;
public void save(Employee employee) {
employeesRepository.save(employee);
}
public void deleteAll() {
employeesRepository.deleteAll();
}
public void raiseSalaries(int amount) {
employeesRepository.raiseSalary(amount);
}
public List<Employee> findAll() {
return employeesRepository.findAll();
}
}
És azt vettem észre, hogy az update műveletnek semmilyen hatása nem volt, az assert elbukott.
A logban az látszik, hogy a végén lefut a findAll()-hoz az SQL.
select e1_0.id,e1_0.name,e1_0.salary from employee e1_0
Ennek megértéséhez kicsit meg kell ismerni a JPA működését. Amikor
ugyanis a teszt entitásokat létrehozzuk, az entitások a perzistence
context által menedzselt állapotba kerülnek, és a memóriában
(persistence contextben) maradnak
addig, míg a tranzakció véget nem ér. A tömeges műveletek viszont
kizárólag a tábla tartalmát módosítják, nem foglalkoznak a már a memóriában
lévő objektumokkal. A findAll() a lekérdezést ugyan lefuttatja,
de az id alapján megnézi, hogy az entitás benne van-e a persistence context-ben.
Mivel már benne voltak, azokat adja vissza.
Ezért először létrejöttek a teszt entitások, majd az update
művelet módosította a tábla tartalmát, de az assert-nél ismét a
memóriában lévő, eredeti, nem módosított objektumokat kaptuk vissza.
Ezen anomália elkerülésére több megoldás is van. Egyrészt takaríthatjuk a persistence contextet a bulk művelet után. Erre egy lehetőség a repository módosítása.
@Transactional
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Employee e
set e.salary = e.salary + :amount
""")
int raiseSalary(@Param("amount") int amount);
Hasonló az eredménye, ha közvetlenül meghívjuk az EntityManager.clear()
metódust, mely szintén törli a persistence context tartalmát.
Másik megoldás, hogy elérjük, hogy a kezdeti adatfeltöltés,
és a bulk update külön tranzakcióban legyen. Ezt úgy érhetjük el, hogy a
kezdeti adatfeltöltést végző setUp() metóduson
kicseréljük a @BeforeEach annotációt a @BeforeTransaction
annotációra. (Vigyázat, a kettő együtt ne legyen rajta, mert akkor kétszer fut le.)
Így már le is fut a teszteset.
Itt arra is vigyázni kell, hogy a bulk műveletek elé nehogy bekeveredjen
olyan utasítás, mely berántja a persistence context-be az entitásokat.
Azaz a teszt metódus első sorába elhelyezett employeesService.findAll();
hívás esetén újra elbukik a teszt.
Ekkor azonban a teszt eset végén a rollback az első tranzakciót nem görgette vissza, így oda az elmélet, hogy olyan teszt esetet írok, mely érintetlenül hagyja az adatbázis állapotát.
De pl. a dbunit legjobb gyakorlatai szerint ez nem is olyan nagy baj. A következőket állítja:
- Minden fejlesztőnek legyen saját adatbázisa (nem feltétlenül a saját gépén). Ez a párhuzamos tesztelés miatt fontos.
- Nem kell a teszt adatokat eltávolítani: a jó teszt induláskor úgyis beállítja a megkívánt adatbázis tartalmat, így nem kell eltávolítani a teszt futásának eredményét. Néha különösen jól jön az adatbázis tartalmának vizsgálata, ha elbukik a teszt.
- Érdemes kisebb adathalmazokkal dolgozni
- Érdemes nem minden teszt előtt inicializálni az adatokat, hanem több teszt előtt egyszer. Ilyenkor persze oda kell figyelni, hogy a teszt esetek ne módosítsák az adatokat.
Ezért a setUp() metódusnál visszacseréltem a @BeforeEach annotációt,
és a tesztről levettem a @Transactional annotációt. Így az adatfeltöltések
külön tranzakciót indítanak, a teszteset nem indít tranzakciót, és a
bulk művelet is saját tranzakciót indít.
Személyes tapasztalatom alapján sok probléma van, ha a teszteset rollbackel, így én mindig tranzakción kívül futtatom, és a teszteset készítse elő az adatokat.