Files
hibernate-demo/docs/16-criteria-queries.md

12 KiB

16 — Criteria API: type-safe queries with the real generated metamodel

← Previous: 15 — HQL queries | Back to README → | Next: 17 — Bootstrapping EntityManager →

Backs ankurm.com post 4880 (Criteria API).

Everything below comes from CriteriaQueryTest, sharing the same Employee/Department entities as chapter 15. Employee_ and Department_ are real, generated static metamodel classes, produced at build time by hibernate-jpamodelgen, wired into pom.xml's maven-compiler-plugin via annotationProcessorPaths (not a plain dependency -- that's the setting that actually triggers annotation processing during javac, confirmed by inspecting target/generated-sources/annotations/ after a build and finding real Employee_.java/Department_.java files there, not hand-written stand-ins).

String paths work, but they're not type-checked

The most basic Criteria query builds a predicate from a plain string field name, exactly like a map lookup:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> 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")));
stringPathPredicates: [Byron, Hopper, Torvalds]

Read that result carefully: salary > 90000 alone would already return exactly Byron, Hopper, and Torvalds -- lastName LIKE '%o%' doesn't narrow the set further here, because all three of those last names genuinely contain an o. Both predicates are doing real work in general, they just happen to agree on this particular seed data; the test asserts the actual combined result, not an assumption about which predicate "mattered."

root.get("salary") compiles even if salary were misspelled -- the typo would only surface at runtime, as an IllegalArgumentException when Hibernate tries to resolve the path. That's the whole reason the static metamodel exists.

Source: CriteriaQueryTest. Raw output: docs/output/criteria-predicates-and-metamodel.txt.

Going deeper:

The static metamodel is compiler-checked, and it's real

The identical query, rewritten through Employee_:

cr.select(root)
        .where(cb.gt(root.get(Employee_.salary), 90_000.0))
        .orderBy(cb.asc(root.get(Employee_.lastName)));
staticMetamodel: [Byron, Hopper, Torvalds]

Same result as the string-path version above -- proving Employee_.salary and Employee_.lastName are genuinely wired to the same underlying attributes, not just present and unused. Misspell Employee_.salery and the build fails at javac, not at test time three months later when someone renames the salary field and forgets the string literal thirty call sites away.

Joins work the same way, through the metamodel's generated association fields:

Join<Employee, Department> department = root.join(Employee_.department, JoinType.INNER);
cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering"));
joinViaMetamodel: 3 engineering employees

Source: CriteriaQueryTest. Raw output: docs/output/criteria-predicates-and-metamodel.txt.

The one-time setup cost -- one annotationProcessorPaths block -- is the whole tradeoff. Once it's in place, every entity gets its _-suffixed metamodel class for free on every build; nothing in application code has to opt in per-entity.

Going deeper:

root.join() vs root.fetch() -- the same trap as HQL's bare JOIN

This is chapter 15's JOIN vs JOIN FETCH distinction, in Criteria API form, and it's just as easy to get wrong here because root.join() looks like it should load the association:

root.join(Employee_.department, JoinType.INNER); // filters, does NOT eagerly load
// ... after the query runs and results are touched:
rootJoinVsFetch: join+touch=3 statements, fetch+touch=1 statement

root.join() alone still costs one extra SELECT per distinct department touched afterward -- identical N+1 shape to HQL's bare JOIN. root.fetch() is the actual fix, and it needs .distinct(true) on the query to avoid duplicate rows when the fetched association is a collection (harmless but wasteful here since department is @ManyToOne, kept for the habit):

root2.fetch(Employee_.department, JoinType.INNER);
cr2.select(root2).distinct(true);

That second query costs exactly one statement total, even after touching every returned entity's department.

Source: CriteriaQueryTest. Raw output: docs/output/criteria-predicates-and-metamodel.txt.

Trap: Join and Fetch are different interfaces in the Criteria API (root.join() returns a Join, root.fetch() returns a Fetch), which is part of why it's easy to reach for the wrong one -- your IDE will happily autocomplete either. Reach for fetch() specifically when you intend to read the association afterward; reach for join() when it's purely a filter.

Aggregation and subqueries

CriteriaBuilder.avg() on a metamodel path, against a Double-typed query:

CriteriaQuery<Double> avgQuery = cb.createQuery(Double.class);
Root<Employee> avgRoot = avgQuery.from(Employee.class);
avgQuery.select(cb.avg(avgRoot.get(Employee_.salary)));
aggregation: average salary = 85600.0

Hand-computed from the seed data ((95000+98000+92000+72000+71000)/5 = 85600) and asserted to match, not just observed.

A correlated-by-value subquery, finding employees above the company-wide average:

Subquery<Double> sub = mainQuery.subquery(Double.class);
Root<Employee> subRoot = sub.from(Employee.class);
sub.select(cb.avg(subRoot.get(Employee_.salary)));
mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub));
subquery: above-average earners (avg=85600) = [Byron, Hopper, Torvalds]

Worth reading twice: the naive assumption is "only the single highest earner clears the average" -- that's wrong here. The two Marketing salaries (72000 and 71000) pull the company-wide average down to 85600, well below every Engineering salary, so all three Engineering employees clear it, not just Grace Hopper at the top. This is a real example of why "above average" queries need the actual average computed, not eyeballed -- an earlier draft of this test asserted the wrong single-employee result and was caught by actually computing the average by hand and comparing.

or() combines predicates with a varargs overload -- worth calling out only because it's easy to reach for cb.equal(...).or(...) chaining instead and get confused about operator precedence:

cb.or(cb.equal(root.get(Employee_.status), "INACTIVE"), cb.equal(root.get(Employee_.lastName), "Hamilton"))
orPredicate: [Torvalds, Hamilton]

Source: CriteriaQueryTest. Raw output: docs/output/criteria-aggregation-and-subquery.txt.

CriteriaUpdate and CriteriaDelete -- bulk operations, type-safe

The same bulk-operation semantics as chapter 15's HQL UPDATE/DELETE (single SQL statement, bypasses the persistence context for already-loaded entities), expressed through CriteriaBuilder.createCriteriaUpdate()/createCriteriaDelete() instead of a query string:

CriteriaUpdate<Employee> update = cb.createCriteriaUpdate(Employee.class);
Root<Employee> 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();
criteriaUpdate: 3 rows updated, Ada's new salary = 104500.00000000001

cb.prod(...) is Criteria's typed multiplication (salary * 1.1), and the trailing .00000000001 on the result is ordinary double floating-point representation, not a bug -- 95000.0 * 1.1 doesn't land on an exact binary fraction, which is exactly why the assertion in the test uses an offset-based comparison instead of exact equality.

CriteriaDelete<Employee> delete = cb.createCriteriaDelete(Employee.class);
Root<Employee> delRoot = delete.from(Employee.class);
delete.where(cb.isNull(delRoot.get(Employee_.department)));
int deleted = em.createQuery(delete).executeUpdate();
criteriaDelete: deleted=1, remaining=5

Source: CriteriaQueryTest. Raw output: docs/output/criteria-bulk-update-delete.txt.

Trap: CriteriaUpdate/CriteriaDelete need their own Root, created via update.from(...)/delete.from(...) -- you cannot reuse a Root from a CriteriaQuery built earlier in the same method, even against the same entity type. They're different root instances tied to different query objects.

Going deeper:

When to reach for Criteria over HQL

Neither API is strictly "better" -- they solve different problems:

HQL Criteria API
Readability for a fixed, known query Higher -- reads like SQL Lower -- more ceremony per query
Compile-time safety None -- typos in path expressions fail at runtime Full, with the generated metamodel
Dynamically building predicates (search filters, optional criteria) Painful -- string concatenation or conditional clause-building Natural -- build up Predicates in a loop, combine with cb.and()/cb.or()
Startup validation via @NamedQuery Yes (see chapter 14) No equivalent

The dynamic-predicate case is where Criteria earns its ceremony: a search endpoint with five optional filter fields is a genuinely painful HQL string-building exercise and a clean loop of if (filter != null) predicates.add(cb.equal(...)) in Criteria.

Summary

Claim Verified value
root.get("string") vs root.get(Employee_.field) Both produce identical results; only the metamodel version is compiler-checked
Employee_/Department_ Real, generated by hibernate-jpamodelgen via annotationProcessorPaths, not hand-written
root.join() Filters via SQL join; does NOT eagerly load the association
root.fetch() Loads the association in the same SELECT
cb.avg() Matches hand-computed average exactly
Subquery for "above average" All three Engineering employees qualify -- Marketing salaries pull the average down further than expected
CriteriaUpdate/CriteriaDelete Same bulk-operation semantics as HQL UPDATE/DELETE: single statement, bypasses the persistence context

← Previous: 15 — HQL queries | Back to README → | Next: 17 — Bootstrapping EntityManager →