Files
hibernate-demo/docs/14-named-queries.md
T

211 lines
12 KiB
Markdown
Executable File

# 14 — Named queries: what startup validation, caching, and "faster" actually mean
[← Previous: 13 — Date and time mapping](13-date-and-time-mapping.md) | [Back to README →](../README.md) | [Next: 15 — HQL queries →](15-hql-queries.md)
Backs [ankurm.com: Hibernate 7 named queries](https://ankurm.com/master-hibernate-7-named-queries-clean-efficient-and-maintainable-data-access/).
Everything below comes from JUnit tests in [`src/test/java/com/ankurm/hibernatedemo/namedquery/`](../src/test/java/com/ankurm/hibernatedemo/namedquery/)
(and one deliberately-broken entity, [`BrokenNamedQueryEmployee`](../src/test/java/com/ankurm/brokenprobe/BrokenNamedQueryEmployee.java), kept outside the app's scanned package, explained below),
run against Hibernate 7.4.5.Final / H2 2.4.240.
## Startup validation
`hibernate.query.startup_check` is a real setting -- confirmed present as
`org.hibernate.cfg.QuerySettings.QUERY_STARTUP_CHECKING` -- and it does exactly what the name
suggests.
A deliberately broken `@NamedQuery` (`e.firsNam` instead of `e.firstName`) fails
`SessionFactory` construction with the check enabled (Hibernate's default):
```
wrapper class: org.hibernate.query.NamedQueryValidationException
verbatim message: Errors in named queries:
[1] Error in query named 'BrokenNamedQueryEmployee.badProperty': Could not resolve attribute
'firsNam' of 'com.ankurm.brokenprobe.BrokenNamedQueryEmployee'
[SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name]
```
With `hibernate.query.startup_check=false`, the identical broken entity builds a
`SessionFactory` successfully. The same broken query only fails once it is actually executed --
and with a **different** exception:
```
class: java.lang.IllegalArgumentException
message: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'firsNam' ...
```
`NamedQueryValidationException` at boot vs. `IllegalArgumentException` (wrapping
`UnknownPathException`) at call time -- the contrast is the article's best argument for leaving
the check on: the failure mode changes from "the deploy pipeline stops" to "a user's request
throws in production."
**Engineering note on how this was reproduced safely**: the broken entity
(`com.ankurm.brokenprobe.BrokenNamedQueryEmployee`) lives outside the
`com.ankurm.hibernatedemo` package tree on purpose. Spring Boot's default JPA entity scan walks
every subpackage under the `@SpringBootApplication` class's package
(`com.ankurm.hibernatedemo`), so a broken `@NamedQuery` anywhere in that tree would fail
`@SpringBootTest` context bootstrap for *every* test in this shared repository, not just this
one. [`NamedQueryStartupValidationTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryStartupValidationTest.java) instead uses a fully standalone Hibernate bootstrap
(`StandardServiceRegistryBuilder` + `MetadataSources`, no Spring involved at all) so the broken
entity never touches the shared application context.
Raw output: [`docs/output/namedquery-startup-validation.txt`](output/namedquery-startup-validation.txt).
## `jakarta.persistence.NamedQuery` vs `org.hibernate.annotations.NamedQuery`
Both exist and are usable in Hibernate 7.4.5 / Jakarta Persistence 3.2, confirmed via `javap`.
The JPA-standard annotation is minimal:
```
jakarta.persistence.NamedQuery: name(), query(), resultClass(), lockMode(), hints()
```
Hibernate's own extends that meaningfully, exercised on [`HibernateExtraEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/HibernateExtraEmployee.java):
```
org.hibernate.annotations.NamedQuery: name(), query(), resultClass(), flush(), flushMode(),
cacheable(), cacheRegion(), fetchSize(), timeout(), comment(),
cacheStoreMode(), cacheRetrieveMode(), cacheMode(), readOnly()
```
`cacheable`, `flush`/`flushMode`, `timeout`, and `readOnly` have no JPA-standard equivalent on
`@NamedQuery` itself (JPA's `hints()` array can express some of these indirectly via magic
strings, but Hibernate's annotation gives typed attributes).
**One of these extras demonstrated actually taking effect**: `cacheable = true` on
`HibernateExtraEmployee.cacheableFindAll` genuinely populates the second-level query cache (JCache
+ Ehcache configured explicitly for this test, since it is off by default -- see
[chapter 09's writeup of the classpath-pollution trap](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
for why that's off by default repo-wide). Two separate
`EntityManager`s, same query, `Statistics` counters:
```
cacheable=true named query: puts after 1st run = 1, cache hits after 2nd run = 1
```
The put on the first call and the hit on the second are both real, measured, not assumed.
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
## `@NamedNativeQuery` + `@SqlResultSetMapping`, and the JPA 3.2 alternative
A `@NamedNativeQuery` mapped via `@SqlResultSetMapping` with `@ConstructorResult` into a plain
DTO class ([`EmployeeDto`](../src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeDto.java), on [`NqEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/NqEmployee.java)) works exactly as documented, per [`NamedQueryExecutionTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryExecutionTest.java):
```
Employee.byNativeDto(ACTIVE): [EmployeeDto{id=1, firstName=Native1}]
```
**Jakarta Persistence 3.2 does allow a `record` as a JPQL constructor-expression target** --
tested directly, not merely inferred from the spec text:
```java
record EmployeeRecordDto(Long id, String firstName) {}
SELECT NEW com.ankurm.hibernatedemo.namedquery.EmployeeRecordDto(e.id, e.firstName)
FROM NqEmployee e WHERE e.firstName = :name
```
```
JPQL constructor expression into a record: [EmployeeRecordDto[id=3, firstName=RecordTest]]
```
No special configuration needed -- a canonical [`EmployeeRecordDto`](../src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeRecordDto.java) constructor is matched exactly like any
other multi-argument constructor.
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
## Named queries in `orm.xml`
A named query defined purely in [`META-INF/orm.xml`](../src/main/resources/META-INF/orm.xml) (no annotation at all,
[`XmlQueryEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/XmlQueryEmployee.java)`.findBySalaryAboveXml`) is picked up automatically by Spring Boot's default JPA
bootstrap -- **no `persistence.xml` and no explicit `<mapping-file>` registration required**; it
is discovered simply by being at the conventional `META-INF/orm.xml` classpath location. This is
the same "orm.xml just works with zero registration" theme chapter 04 documents for
`spring.jpa.mapping-resources`-driven entities -- see
[`04 — Annotations vs. XML mappings`](04-annotations-vs-xml.md#ormxml-really-can-define-an-entire-entity-annotation-free).
It works side by side with an annotation-defined named query on the same entity, per [`OrmXmlNamedQueryTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/OrmXmlNamedQueryTest.java):
```
annotation-defined named query result: 1 rows
orm.xml-defined named query result: 1 rows
```
And when `orm.xml` defines a named query with the **same name** as one already declared via
`@NamedQuery` on the entity, the XML definition wins -- proven by giving the annotated version a
deliberately wrong predicate (`salary < 0`) and the XML version the correct one:
```
XmlQueryEmployee.overridden (annotation says salary<0, orm.xml says salary>:min): 1 rows
```
If the annotation had won, this would have returned 0 rows.
Raw output: [`docs/output/namedquery-ormxml.txt`](output/namedquery-ormxml.txt).
## Does pre-parsing actually help? (Measured, not assumed)
The common claim is that named queries are faster because they are "pre-parsed." Hibernate's own
query-plan cache is keyed by the **query string**, not by whether the string came from a
`@NamedQuery` or an inline JPQL literal -- so after the very first execution of either, both
paths hit the same cached AST/plan. [`NamedQueryPerformanceTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryPerformanceTest.java) measures this directly: 500
warmup iterations, then 5000 measured iterations of a named query and the identical inline JPQL
string, interleaved call-by-call (to cancel out JIT/GC ordering bias) on a shared, otherwise-idle
in-memory H2 database:
```
run 1: named avg=124.18 us/call, inline avg=113.77 us/call, ratio (named/inline)=1.09
run 2: named avg=136.74 us/call, inline avg=128.27 us/call, ratio (named/inline)=1.07
```
**Honest finding**: across two runs the ratio stayed within ~10% either direction of 1.0, which
is noise for a shared sandbox container, not a real effect. We could not measure a performance
advantage for named queries over the identical inline JPQL string once both have been warmed up.
The commonly repeated "named queries are faster because they're pre-parsed" claim should be
retired as stated -- the real, verifiable benefits of named queries are the ones demonstrated
above: fail-fast startup validation, a place to attach Hibernate-specific extras like
`cacheable`, and centralizing query text -- not raw per-call execution speed.
Raw output: [`docs/output/namedquery-preparse-performance.txt`](output/namedquery-preparse-performance.txt).
## `getSingleResultOrNull()` vs `getSingleResult()`
Both confirmed present via `javap jakarta.persistence.Query` (Jakarta Persistence 3.2.0):
```
public abstract java.lang.Object getSingleResult();
public abstract java.lang.Object getSingleResultOrNull();
```
Behavior on zero rows, from a real run:
```
getSingleResultOrNull() on zero rows returned: null
getSingleResult() on zero rows threw: jakarta.persistence.NoResultException:
No result found for query [SELECT e FROM NqEmployee e WHERE e.firstName = :n]
```
`getSingleResultOrNull()` (added in Jakarta Persistence 3.2) is the null-returning alternative
that avoids a try/catch around `NoResultException` for the common "may or may not exist" lookup.
Chapter 05 covers the same method on the more specific `TypedQuery` interface -- see
[`05 — JPA persistence annotations`](05-jpa-persistence-annotations.md#whats-actually-new-in-jakarta-persistence-32-verified-via-javap-on-jakartapersistence-api-320jar).
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
## Summary
| Claim | Verified value |
|---|---|
| `hibernate.query.startup_check` | Exists (`QuerySettings.QUERY_STARTUP_CHECKING`); default behavior fails fast at boot |
| Broken named query, check enabled | `NamedQueryValidationException` at `SessionFactory` build |
| Broken named query, check disabled | Boots fine; fails at call time with `IllegalArgumentException`/`UnknownPathException` |
| `jakarta.persistence.NamedQuery` vs Hibernate's | Hibernate's adds cacheable/flush/timeout/readOnly/comment/cache* |
| `cacheable=true` | Measurably populates and hits the 2nd-level query cache |
| Record as JPQL constructor target | Works directly, JPA 3.2 |
| `orm.xml` named queries | Auto-discovered with no `persistence.xml`; override same-named annotations |
| Named query vs inline JPQL speed | No measurable difference after warmup (ratio ~1.0-1.1 across runs) |
| `getSingleResultOrNull()` | Present since JPA 3.2; returns null instead of throwing `NoResultException` |
[← Previous: 13 — Date and time mapping](13-date-and-time-mapping.md) | [Back to README →](../README.md) | [Next: 15 — HQL queries →](15-hql-queries.md)