package com.ankurm.hibernatedemo.query; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import java.time.LocalDate; import java.util.List; import org.hibernate.SessionFactory; import org.hibernate.query.SemanticException; import org.hibernate.stat.Statistics; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; /** * Backs ankurm.com post 4879 (HQL). Docs: docs/15-hql-queries.md. * *

Run with {@code ./mvnw -Dtest=HqlQueryTest test}. Every assertion here was first observed by * running the same query and reading the log, then pinned down as an assertion. */ @SpringBootTest class HqlQueryTest { private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); @Autowired private EntityManagerFactory emf; private Long engineeringId; private Long marketingId; @BeforeEach void seed() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); // This method-scoped H2 database (jdbc:h2:mem:hibernate-demo) is shared across every // test in the class run, not reset between tests, so each test must clear its own // tables first -- otherwise counts creep upward test by test. em.createQuery("DELETE FROM Employee").executeUpdate(); em.createQuery("DELETE FROM QueryDept").executeUpdate(); Department engineering = new Department("Engineering"); Department marketing = new Department("Marketing"); em.persist(engineering); em.persist(marketing); em.persist(new Employee("Ada", "Byron", 95_000.0, "ACTIVE", LocalDate.of(2019, 3, 1), engineering)); em.persist(new Employee("Grace", "Hopper", 98_000.0, "ACTIVE", LocalDate.of(2018, 6, 15), engineering)); em.persist(new Employee("Linus", "Torvalds", 92_000.0, "INACTIVE", LocalDate.of(2015, 1, 10), engineering)); em.persist(new Employee("Margaret", "Hamilton", 72_000.0, "ACTIVE", LocalDate.of(2020, 9, 1), marketing)); em.persist(new Employee("Katherine", "Johnson", 71_000.0, "ACTIVE", LocalDate.of(2021, 2, 20), marketing)); em.getTransaction().commit(); engineeringId = engineering.getId(); marketingId = marketing.getId(); em.close(); } private Statistics stats() { return emf.unwrap(SessionFactory.class).getStatistics(); } @Test void selectAll_returnsEveryRow() { EntityManager em = emf.createEntityManager(); List all = em.createQuery("FROM Employee", Employee.class).getResultList(); assertThat(all).hasSize(5); em.close(); } @Test void whereWithNamedParameter_filtersCorrectly() { EntityManager em = emf.createEntityManager(); List active = em.createQuery( "SELECT e FROM Employee e WHERE e.status = :status", Employee.class) .setParameter("status", "ACTIVE") .getResultList(); assertThat(active).hasSize(4).allMatch(e -> "ACTIVE".equals(e.getStatus())); DEMO.info("whereWithNamedParameter: {} active employees", active.size()); em.close(); } @Test void columnNameInsteadOfFieldName_failsAtQueryTimeNotSilently() { // The classic HQL pitfall: writing the database column name (first_name) instead of // the entity field name (firstName). This is NOT a silent wrong-result bug -- HQL // resolves against the entity model, so an unknown path fails loudly before any SQL // is sent. EntityManager em = emf.createEntityManager(); assertThatThrownBy(() -> em.createQuery("SELECT e FROM Employee e WHERE e.first_name = 'Ada'", Employee.class)) .isInstanceOfAny(IllegalArgumentException.class, SemanticException.class) .satisfies(ex -> DEMO.info("columnNameInsteadOfFieldName: {}: {}", ex.getClass().getSimpleName(), ex.getMessage())); em.close(); } @Test void joinWithoutFetch_causesOneQueryPerDepartmentAccess() { EntityManager em = emf.createEntityManager(); stats().clear(); List withDept = em.createQuery( "SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class) .setParameter("deptName", "Engineering") .getResultList(); long afterQuery = stats().getPrepareStatementCount(); assertThat(afterQuery).as("plain JOIN issues one SELECT for the employees").isEqualTo(1); // Touching the association now triggers a SEPARATE select per distinct department -- // this is the N+1 that JOIN alone (without FETCH) does not prevent. withDept.forEach(e -> e.getDepartment().getName()); long afterTouch = stats().getPrepareStatementCount(); assertThat(afterTouch).as("touching the lazy association after a plain JOIN fires extra SELECTs") .isGreaterThan(afterQuery); DEMO.info("joinWithoutFetch: {} statements for the query, {} after touching department", afterQuery, afterTouch); em.close(); } @Test void joinFetch_loadsAssociationInOneQuery() { EntityManager em = emf.createEntityManager(); stats().clear(); List withFetch = em.createQuery( "SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class) .getResultList(); withFetch.forEach(e -> e.getDepartment().getName()); long total = stats().getPrepareStatementCount(); assertThat(total).as("JOIN FETCH loads employees AND departments in exactly one SELECT, even after touching department") .isEqualTo(1); DEMO.info("joinFetch: {} statement total, {} rows", total, withFetch.size()); em.close(); } @Test void aggregateCount_matchesSeedSize() { EntityManager em = emf.createEntityManager(); Long count = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); assertThat(count).isEqualTo(5L); DEMO.info("aggregateCount: {}", count); em.close(); } @Test void avgSalaryGroupByDepartment_matchesHandComputedAverages() { EntityManager em = emf.createEntityManager(); List rows = em.createQuery( "SELECT d.name, AVG(e.salary) FROM Employee e JOIN e.department d GROUP BY d.name ORDER BY d.name", Object[].class) .getResultList(); assertThat(rows).hasSize(2); assertThat((String) rows.get(0)[0]).isEqualTo("Engineering"); assertThat((Double) rows.get(0)[1]).isCloseTo((95_000.0 + 98_000.0 + 92_000.0) / 3, org.assertj.core.data.Offset.offset(0.01)); assertThat((String) rows.get(1)[0]).isEqualTo("Marketing"); assertThat((Double) rows.get(1)[1]).isCloseTo((72_000.0 + 71_000.0) / 2, org.assertj.core.data.Offset.offset(0.01)); rows.forEach(r -> DEMO.info("avgSalaryGroupByDepartment: {} -> {}", r[0], r[1])); em.close(); } @Test void pagination_returnsCorrectSlice() { EntityManager em = emf.createEntityManager(); List page1 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class) .setFirstResult(0) .setMaxResults(2) .getResultList(); List page2 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class) .setFirstResult(2) .setMaxResults(2) .getResultList(); assertThat(page1).hasSize(2); assertThat(page2).hasSize(2); assertThat(page1).extracting(Employee::getLastName).doesNotContainAnyElementsOf( page2.stream().map(Employee::getLastName).toList()); DEMO.info("pagination: page1={}, page2={}", page1.stream().map(Employee::getLastName).toList(), page2.stream().map(Employee::getLastName).toList()); em.close(); } @Test void bulkUpdate_changesRowsButNotAlreadyLoadedEntityInSameSession() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); // Load one INACTIVE-bound employee into the persistence context first. Employee loaded = em.createQuery( "SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class) .getSingleResult(); assertThat(loaded.getStatus()).isEqualTo("INACTIVE"); int updated = em.createQuery( "UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'") .executeUpdate(); assertThat(updated).isEqualTo(1); // The row changed in the database, but the already-loaded managed entity was never // touched by the bulk UPDATE -- it still shows the old in-memory value until refreshed. assertThat(loaded.getStatus()).as("bulk UPDATE bypasses the persistence context for already-loaded entities") .isEqualTo("INACTIVE"); em.clear(); Employee reloaded = em.find(Employee.class, loaded.getId()); assertThat(reloaded.getStatus()).as("a fresh load after clear() sees the bulk UPDATE").isEqualTo("ARCHIVED"); em.getTransaction().commit(); DEMO.info("bulkUpdate: updated={} rows, stale in-memory status={}, reloaded status={}", updated, loaded.getStatus(), reloaded.getStatus()); em.close(); } @Test void bulkDelete_removesMatchingRowsOnly() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); int deleted = em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId") .setParameter("deptId", marketingId) .executeUpdate(); assertThat(deleted).isEqualTo(2); long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); assertThat(remaining).isEqualTo(3L); em.getTransaction().commit(); DEMO.info("bulkDelete: deleted={} rows, remaining={}", deleted, remaining); em.close(); } // ---- Flush-mode tests use an UPDATE to an already-managed entity, not a new insert. // An earlier version of these tests persisted a brand-new Employee and expected FlushMode // to control when it became visible -- that was confounded by GenerationType.IDENTITY // (chapter 03's own finding: IDENTITY forces an immediate INSERT to obtain the generated // key, on every persist(), regardless of flush mode). A dirty UPDATE on a loaded managed // entity is unaffected by id-generation strategy and isolates what flush mode actually // controls. @Test void defaultFlushMode_autoFlushesDirtyUpdateBeforeQuery() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Employee ada = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Byron'", Employee.class).getSingleResult(); ada.setSalary(999_999.0); // dirtied, not explicitly flushed Double salaryFromFreshQuery = em.createQuery( "SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class) .getSingleResult(); assertThat(salaryFromFreshQuery).as("default AUTO flush mode flushes the dirty UPDATE before the query runs") .isEqualTo(999_999.0); em.getTransaction().rollback(); DEMO.info("defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = {}", salaryFromFreshQuery); em.close(); } @Test void jakartaCommitFlushMode_suppressesAutoFlushForThisQuery() { // jakarta.persistence.FlushModeType.COMMIT's javadoc only says a provider is // "permitted, but not required" to flush before a query -- it's implementation // defined. Hibernate 7.4.5's actual choice, verified here: it does NOT flush, so the // query sees the pre-update value. EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Employee grace = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Hopper'", Employee.class).getSingleResult(); grace.setSalary(111_111.0); Double salaryFromFreshQuery = em.createQuery( "SELECT e.salary FROM Employee e WHERE e.lastName = 'Hopper'", Double.class) .setFlushMode(jakarta.persistence.FlushModeType.COMMIT) .getSingleResult(); assertThat(salaryFromFreshQuery).as("Hibernate 7.4.5 chooses not to flush under FlushModeType.COMMIT -- the query still sees the old value") .isEqualTo(98_000.0); em.getTransaction().rollback(); DEMO.info("jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = {} (pre-update value was 98000.0)", salaryFromFreshQuery); em.close(); } @Test void nativeManualFlushMode_actuallySuppressesAutoFlush() { // Hibernate's own native org.hibernate.FlushMode has a fourth value the jakarta enum // doesn't: MANUAL, which means never auto-flush, full stop -- the one that genuinely // suppresses it, matching the "set FlushMode.MANUAL for performance-sensitive loops" // advice, as long as what you're deferring is an UPDATE, not an IDENTITY-generated // INSERT (see the note above this block). EntityManager em = emf.createEntityManager(); org.hibernate.Session session = em.unwrap(org.hibernate.Session.class); em.getTransaction().begin(); Employee linus = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class).getSingleResult(); session.setHibernateFlushMode(org.hibernate.FlushMode.MANUAL); linus.setSalary(123_123.0); Double beforeFlush = em.createQuery( "SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class) .getSingleResult(); assertThat(beforeFlush).as("FlushMode.MANUAL genuinely suppresses auto-flush before this query -- the old salary is still what the DB has") .isEqualTo(92_000.0); session.flush(); Double afterFlush = em.createQuery( "SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class) .getSingleResult(); assertThat(afterFlush).as("an explicit flush() makes the change visible").isEqualTo(123_123.0); em.getTransaction().rollback(); DEMO.info("nativeManualFlushMode: before explicit flush={}, after={}", beforeFlush, afterFlush); em.close(); } }