package com.ankurm.hibernatedemo.query; import static org.assertj.core.api.Assertions.assertThat; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaDelete; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.CriteriaUpdate; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Subquery; import java.time.LocalDate; import java.util.List; import org.hibernate.stat.Statistics; import org.hibernate.SessionFactory; 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 4880 (Criteria API). Docs: docs/16-criteria-queries.md. * *

Shares {@link Employee}/{@link Department} with {@link HqlQueryTest}. The static metamodel * classes ({@link Employee_}, {@link Department_}) are real, generated by {@code * hibernate-jpamodelgen} at build time (see the {@code annotationProcessorPaths} entry in * pom.xml) -- not hand-written stand-ins. * *

Run with {@code ./mvnw -Dtest=CriteriaQueryTest test}. */ @SpringBootTest class CriteriaQueryTest { private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); @Autowired private EntityManagerFactory emf; private Long engineeringId; @BeforeEach void seed() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); 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(); em.close(); } private Statistics stats() { return emf.unwrap(SessionFactory.class).getStatistics(); } @Test void basicSelection_returnsEveryRow() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(Employee.class); Root root = cr.from(Employee.class); cr.select(root); List results = em.createQuery(cr).getResultList(); assertThat(results).hasSize(5); em.close(); } @Test void stringPathPredicates_andCombined_filterCorrectly() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(Employee.class); Root root = cr.from(Employee.class); Predicate salaryGreater = cb.gt(root.get("salary"), 90_000.0); Predicate nameLike = cb.like(root.get("lastName"), "%o%"); cr.select(root).where(cb.and(salaryGreater, nameLike)).orderBy(cb.asc(root.get("lastName"))); List filtered = em.createQuery(cr).getResultList(); // salary > 90000 keeps Byron/Hopper/Torvalds; lastName LIKE '%o%' does NOT narrow that // set further -- "Byron", "Hopper" and "Torvalds" all contain an 'o'. assertThat(filtered).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); DEMO.info("stringPathPredicates: {}", filtered.stream().map(Employee::getLastName).toList()); em.close(); } @Test void staticMetamodel_isTypeSafeAndProducesTheSameResultAsStringPath() { // The same query as above, but built through the generated Employee_ metamodel // instead of root.get("salary") string paths. Proves the metamodel classes are real // and functional, not just present on the classpath. EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(Employee.class); Root root = cr.from(Employee.class); cr.select(root) .where(cb.gt(root.get(Employee_.salary), 90_000.0)) .orderBy(cb.asc(root.get(Employee_.lastName))); List results = em.createQuery(cr).getResultList(); assertThat(results).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); DEMO.info("staticMetamodel: {}", results.stream().map(Employee::getLastName).toList()); em.close(); } @Test void joinViaMetamodel_filtersByRelatedEntity() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(Employee.class); Root root = cr.from(Employee.class); Join department = root.join(Employee_.department, JoinType.INNER); cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering")); List engineeringStaff = em.createQuery(cr).getResultList(); assertThat(engineeringStaff).hasSize(3); DEMO.info("joinViaMetamodel: {} engineering employees", engineeringStaff.size()); em.close(); } @Test void rootJoin_doesNotForceEagerLoad_rootFetchDoes() { // root.join() builds a SQL join for filtering but does NOT initialize the association // for the returned entities -- touching it afterward is a separate SELECT per entity, // the same N+1 risk as HQL's plain JOIN. root.fetch() actually populates it. EntityManager em1 = emf.createEntityManager(); CriteriaBuilder cb1 = em1.getCriteriaBuilder(); CriteriaQuery cr1 = cb1.createQuery(Employee.class); Root root1 = cr1.from(Employee.class); root1.join(Employee_.department, JoinType.INNER); cr1.select(root1); stats().clear(); List viaJoin = em1.createQuery(cr1).getResultList(); long afterSelect = stats().getPrepareStatementCount(); viaJoin.forEach(e -> e.getDepartment().getName()); long afterTouch = stats().getPrepareStatementCount(); assertThat(afterTouch).as("root.join() alone still requires extra SELECTs to read the association") .isGreaterThan(afterSelect); em1.close(); EntityManager em2 = emf.createEntityManager(); CriteriaBuilder cb2 = em2.getCriteriaBuilder(); CriteriaQuery cr2 = cb2.createQuery(Employee.class); Root root2 = cr2.from(Employee.class); root2.fetch(Employee_.department, JoinType.INNER); cr2.select(root2).distinct(true); stats().clear(); List viaFetch = em2.createQuery(cr2).getResultList(); viaFetch.forEach(e -> e.getDepartment().getName()); long totalWithFetch = stats().getPrepareStatementCount(); assertThat(totalWithFetch).as("root.fetch() loads the association in the same SELECT") .isEqualTo(1L); DEMO.info("rootJoinVsFetch: join+touch={} statements, fetch+touch={} statement", afterTouch, totalWithFetch); em2.close(); } @Test void aggregation_avgSalary_matchesHandComputedValue() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery avgQuery = cb.createQuery(Double.class); Root avgRoot = avgQuery.from(Employee.class); avgQuery.select(cb.avg(avgRoot.get(Employee_.salary))); Double averageSalary = em.createQuery(avgQuery).getSingleResult(); double expected = (95_000.0 + 98_000.0 + 92_000.0 + 72_000.0 + 71_000.0) / 5; assertThat(averageSalary).isCloseTo(expected, org.assertj.core.data.Offset.offset(0.01)); DEMO.info("aggregation: average salary = {}", averageSalary); em.close(); } @Test void subquery_findsAboveAverageEarners() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery mainQuery = cb.createQuery(Employee.class); Root empRoot = mainQuery.from(Employee.class); Subquery sub = mainQuery.subquery(Double.class); Root subRoot = sub.from(Employee.class); sub.select(cb.avg(subRoot.get(Employee_.salary))); mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub)).orderBy(cb.asc(empRoot.get(Employee_.lastName))); List highEarners = em.createQuery(mainQuery).getResultList(); // Company-wide average is 85600 (428000/5) -- the two Marketing salaries (72000, // 71000) drag it well below every Engineering salary, so all three Engineering // employees clear it, not just the single highest earner. assertThat(highEarners).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); DEMO.info("subquery: above-average earners (avg=85600) = {}", highEarners.stream().map(Employee::getLastName).toList()); em.close(); } @Test void criteriaUpdate_bulkRaisesSalaryForOneDepartment() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaUpdate update = cb.createCriteriaUpdate(Employee.class); Root updRoot = update.from(Employee.class); update.set(Employee_.salary, cb.prod(updRoot.get(Employee_.salary), 1.1)) .where(cb.equal(updRoot.get(Employee_.department).get(Department_.id), engineeringId)); int updated = em.createQuery(update).executeUpdate(); assertThat(updated).isEqualTo(3); em.clear(); Double newAdaSalary = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class) .getSingleResult(); assertThat(newAdaSalary).isCloseTo(95_000.0 * 1.1, org.assertj.core.data.Offset.offset(0.01)); em.getTransaction().commit(); DEMO.info("criteriaUpdate: {} rows updated, Ada's new salary = {}", updated, newAdaSalary); em.close(); } @Test void criteriaDelete_removesEmployeesWithNoDepartment() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); // Insert one departmentless employee to have something real for CriteriaDelete to remove. em.persist(new Employee("Orphan", "NoDept", 50_000.0, "ACTIVE", LocalDate.now(), null)); em.flush(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaDelete delete = cb.createCriteriaDelete(Employee.class); Root delRoot = delete.from(Employee.class); delete.where(cb.isNull(delRoot.get(Employee_.department))); int deleted = em.createQuery(delete).executeUpdate(); assertThat(deleted).isEqualTo(1); long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); assertThat(remaining).isEqualTo(5L); em.getTransaction().commit(); DEMO.info("criteriaDelete: deleted={}, remaining={}", deleted, remaining); em.close(); } @Test void orPredicate_combinesConditionsWithVarargsOverload() { EntityManager em = emf.createEntityManager(); CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(Employee.class); Root root = cr.from(Employee.class); cr.select(root).where(cb.or( cb.equal(root.get(Employee_.status), "INACTIVE"), cb.equal(root.get(Employee_.lastName), "Hamilton"))); List results = em.createQuery(cr).getResultList(); assertThat(results).extracting(Employee::getLastName).containsExactlyInAnyOrder("Torvalds", "Hamilton"); DEMO.info("orPredicate: {}", results.stream().map(Employee::getLastName).toList()); em.close(); } }