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:
- Jakarta Persistence 3.2 Criteria API spec, §6 (
rel="nofollow")
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:
- Chapter 04's annotations-vs-XML chapter if metamodel generation from XML-mapped entities matters for your setup
hibernate-jpamodelgenon Maven Central (rel="nofollow")
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:
JoinandFetchare different interfaces in the Criteria API (root.join()returns aJoin,root.fetch()returns aFetch), which is part of why it's easy to reach for the wrong one -- your IDE will happily autocomplete either. Reach forfetch()specifically when you intend to read the association afterward; reach forjoin()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/CriteriaDeleteneed their ownRoot, created viaupdate.from(...)/delete.from(...)-- you cannot reuse aRootfrom aCriteriaQuerybuilt earlier in the same method, even against the same entity type. They're different root instances tied to different query objects.
Going deeper:
- Chapter 15's bulk
UPDATE/DELETEbypass the persistence context -- identical caveat, different API surface
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 →