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

252 lines
12 KiB
Markdown

# 16 — Criteria API: type-safe queries with the real generated metamodel
[← Previous: 15 — HQL queries](15-hql-queries.md) | [Back to README →](../README.md) | [Next: 17 — Bootstrapping EntityManager →](17-entitymanager-bootstrap.md)
Backs ankurm.com post 4880 (Criteria API).
Everything below comes from [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java),
sharing the same [`Employee`](../src/main/java/com/ankurm/hibernatedemo/query/Employee.java)/[`Department`](../src/main/java/com/ankurm/hibernatedemo/query/Department.java)
entities as chapter 15. `Employee_` and `Department_` are **real, generated** static metamodel
classes, produced at build time by `hibernate-jpamodelgen`, wired into
[`pom.xml`](../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:
```java
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`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](output/criteria-predicates-and-metamodel.txt).
Going deeper:
- [Jakarta Persistence 3.2 Criteria API spec, §6](https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2#criteria-api) (`rel="nofollow"`)
## The static metamodel is compiler-checked, and it's real
The identical query, rewritten through `Employee_`:
```java
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:
```java
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`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](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](04-annotations-vs-xml.md) if metamodel generation from XML-mapped entities matters for your setup
- [`hibernate-jpamodelgen` on Maven Central](https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-jpamodelgen) (`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:
```java
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):
```java
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`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](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:
```java
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:
```java
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:
```java
cb.or(cb.equal(root.get(Employee_.status), "INACTIVE"), cb.equal(root.get(Employee_.lastName), "Hamilton"))
```
```
orPredicate: [Torvalds, Hamilton]
```
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
Raw output: [`docs/output/criteria-aggregation-and-subquery.txt`](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:
```java
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.
```java
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`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
Raw output: [`docs/output/criteria-bulk-update-delete.txt`](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:
- Chapter 15's [bulk `UPDATE`/`DELETE` bypass the persistence context](15-hql-queries.md#bulk-update-and-delete-bypass-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 `Predicate`s 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](15-hql-queries.md) | [Back to README →](../README.md) | [Next: 17 — Bootstrapping EntityManager →](17-entitymanager-bootstrap.md)