13 KiB
15 — HQL: the query language, its pitfalls, and what flush mode actually controls
← Previous: 14 — Named queries | Back to README → | Next: 16 — Criteria API →
Backs ankurm.com post 4879 (HQL queries).
Everything below comes from HqlQueryTest,
run against Hibernate 7.4.5.Final / H2 2.4.240, using two new entities built for this chapter and
the next: Employee and
Department (mapped with
@Entity(name = "QueryDept") -- the plain class name Department was already taken by chapter
06's naturalid.Department, and Hibernate requires entity names to be unique across the whole
persistence unit, not just per package).
FROM and WHERE, and the pitfall that fails loudly
The smallest HQL query skips SELECT entirely:
em.createQuery("FROM Employee", Employee.class).getResultList();
A WHERE clause with a named parameter is the normal shape for anything filtered:
em.createQuery("SELECT e FROM Employee e WHERE e.status = :status", Employee.class)
.setParameter("status", "ACTIVE")
.getResultList();
The classic mistake: writing the database column name instead of the entity field name.
Employee's column is first_name, but its Java field is firstName. HQL resolves against the
entity model, not the schema, so this fails before any SQL is even generated -- not a silent
wrong-result bug, a loud one:
columnNameInsteadOfFieldName: IllegalArgumentException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'first_name' of 'com.ankurm.hibernatedemo.query.Employee' [SELECT e FROM Employee e WHERE e.first_name = 'Ada']
Trap: this only fails loudly because the attribute name is wrong. If you'd written
e.department.namewhere the association happens to share a column name with something on the root entity, HQL still resolves it correctly -- the failure mode above only catches typos, not confusion about what a path actually points to. Read the exception type, not just its presence:UnknownPathExceptionmeans "this attribute doesn't exist," not "this predicate is slow" or "this join is wrong."
Source: HqlQueryTest. Raw
output: docs/output/hql-select-and-joins.txt.
Going deeper:
- Jakarta Persistence 3.2 query language spec, §4 (
rel="nofollow") - Chapter 14's named-query startup validation catches this same class of typo at boot instead of at call time, if you move the query into a
@NamedQuery
JOIN without FETCH does not prevent the N+1 it looks like it prevents
A plain JOIN in HQL is there to filter, not to load. It issues one SELECT for the query
itself, but touching the association afterward still fires a separate SELECT per distinct value:
List<Employee> withDept = em.createQuery(
"SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class)
.setParameter("deptName", "Engineering")
.getResultList();
// 1 statement so far
withDept.forEach(e -> e.getDepartment().getName());
// now more than 1 -- one extra SELECT per distinct department touched
joinWithoutFetch: 1 statements for the query, 2 after touching department
JOIN FETCH is the fix -- it loads the association eagerly in the same query, so touching it
afterward costs nothing extra:
em.createQuery("SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class).getResultList();
joinFetch: 1 statement total, 5 rows
Source: HqlQueryTest. Raw
output: docs/output/hql-select-and-joins.txt.
This is the single most common Hibernate performance bug in production code, and it looks
completely correct on a code review: the JOIN is right there, so it reads as "this loads the
department." It doesn't. The distinction only shows up under a profiler or Statistics counters,
which is exactly what this test uses instead of trusting the query text.
Going deeper:
- Chapter 12 covers the mapping side of this --
@ManyToOne(fetch = LAZY)and when eager beats lazy - Chapter 11's proxy and lazy-initialization chapter is the deeper mechanism: what
getDepartment()actually returns before it's touched
Aggregates, GROUP BY, and pagination
Straightforward HQL: COUNT, AVG with GROUP BY, and setFirstResult/setMaxResults for
paging.
em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();
em.createQuery(
"SELECT d.name, AVG(e.salary) FROM Employee e JOIN e.department d GROUP BY d.name ORDER BY d.name",
Object[].class).getResultList();
aggregateCount: 5
avgSalaryGroupByDepartment: Engineering -> 95000.0
avgSalaryGroupByDepartment: Marketing -> 71500.0
Both averages were hand-computed from the seed data and asserted to match exactly (within a 0.01 offset for floating-point rounding) -- not just "a number came back."
Pagination combines setFirstResult (offset) and setMaxResults (limit), ordered so the pages
are deterministic:
em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class)
.setFirstResult(0).setMaxResults(2).getResultList();
pagination: page1=[Byron, Hamilton], page2=[Hopper, Johnson]
Source: HqlQueryTest. Raw
output: docs/output/hql-aggregation-paging-bulk.txt.
For pagination past a few thousand rows, offset-based paging degrades because the database still has to scan and discard every skipped row -- chapter 16's pagination-adjacent bulk operations and chapter 09's in-memory database chapter both touch on where that starts to matter; keyset pagination is the usual fix, out of scope for this chapter.
Bulk UPDATE and DELETE bypass the persistence context
HQL's UPDATE/DELETE execute directly against the database as a single SQL statement -- they
do not load entities into the persistence context first, and they do not update any
entity that's already loaded there:
Employee loaded = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class)
.getSingleResult(); // status = INACTIVE, now managed
int updated = em.createQuery("UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'")
.executeUpdate();
// updated == 1, and the row in the DATABASE now says ARCHIVED
// but `loaded` -- already in the persistence context -- still says INACTIVE
bulkUpdate: updated=1 rows, stale in-memory status=INACTIVE, reloaded status=ARCHIVED
Only after em.clear() and a fresh find() does the already-loaded entity's Java field catch up
to what the database now holds. This is the same "bulk operations bypass the persistence context"
warning that appears throughout Hibernate's own reference docs, demonstrated here by asserting the
stale value stays stale until a clear-and-reload, not just stating it.
DELETE bulk operations are simpler -- one SQL statement, no persistence-context interaction to
worry about since there's no entity state left to go stale:
em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId")
.setParameter("deptId", marketingId).executeUpdate();
bulkDelete: deleted=2 rows, remaining=3
Source: HqlQueryTest. Raw
output: docs/output/hql-aggregation-paging-bulk.txt.
Trap:
em.remove()on a managed entity is not the same operation as bulkDELETE.remove()triggers cascades and lifecycle callbacks per entity; bulkDELETEis one SQL statement against the database and skips both. Choose deliberately -- bulkDELETEis much faster for large sets,remove()is correct when cascades or@PreRemovematter.
Flush mode: what actually suppresses the auto-flush, and what doesn't
This section exists because of a dead end worth documenting. The first version of these tests
tried to prove flush-mode behavior by persist()-ing a new Employee and checking whether a
later query saw it. That approach is broken by construction: Employee's @Id uses
GenerationType.IDENTITY, and IDENTITY forces an immediate INSERT on persist(), independent
of flush mode entirely -- Hibernate has to round-trip to the database right away to obtain the
generated key, before it can even hand back a usable entity reference. Flush mode never got a
chance to defer anything. (This is the same fact chapter 03 documents from the insert side --
GenerationType.IDENTITY disables JDBC batching for the identical
underlying reason.)
The fix: dirty an already-loaded, already-managed entity with setSalary(...) instead of
inserting a new one. An UPDATE is not tied to id generation, so this correctly isolates what
flush mode controls.
Default (AUTO) flushes the dirty change before a query that could be affected by it runs, so
a fresh query in the same transaction sees the update even though nothing called flush()
explicitly:
Employee ada = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Byron'", Employee.class).getSingleResult();
ada.setSalary(999_999.0); // dirtied, not explicitly flushed
Double seen = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class).getSingleResult();
defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = 999999.0
jakarta.persistence.FlushModeType.COMMIT is implementation-defined, not a guarantee --
its own javadoc says a provider is "permitted, but not required" to flush before a query. Verified
directly against Hibernate 7.4.5: it chooses not to flush, so the same dirty change stays
invisible to a query set to this mode:
.setFlushMode(jakarta.persistence.FlushModeType.COMMIT)
jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = 98000.0 (pre-update value was 98000.0)
Hibernate's own native org.hibernate.FlushMode.MANUAL has no jakarta.persistence
equivalent, and it is the one mode that genuinely, unconditionally suppresses auto-flush until an
explicit session.flush() call:
Session session = em.unwrap(Session.class);
session.setHibernateFlushMode(FlushMode.MANUAL);
linus.setSalary(123_123.0);
// query here still sees the OLD value
session.flush();
// query here sees the NEW value
nativeManualFlushMode: before explicit flush=92000.0, after=123123.0
Source: HqlQueryTest. Raw
output: docs/output/hql-flush-modes.txt.
Trap: if you're trying to prove (or debug) flush-mode behavior and your test entity uses
GenerationType.IDENTITY, use anUPDATEon an already-managed row, not apersist(). This cost real debugging time in this repo before the root cause -- IDENTITY's forced immediate INSERT -- was found by reading the log line ordering rather than assuming flush mode was broken.
This distinction matters most in tight loops: FlushMode.MANUAL plus batched explicit flushes is
a real, measurable performance technique for bulk write-heavy code (see chapter 03's batching
findings), but only for updates -- not for anything that also needs a database-generated identity
key back immediately.
Going deeper:
jakarta.persistence.FlushModeTypejavadoc (rel="nofollow") -- the exact "permitted, but not required" wording- Chapter 03's
GenerationType.IDENTITYand JDBC batching
Summary
| Claim | Verified value |
|---|---|
| Column name instead of field name in HQL | Fails at query-build time with UnknownPathException, not silently |
JOIN alone (no FETCH) |
Filters correctly, but touching the association after still costs N extra SELECTs |
JOIN FETCH |
Loads root + association in exactly one SELECT |
GROUP BY averages |
Match hand-computed values exactly |
Pagination (setFirstResult/setMaxResults) |
Returns correct, non-overlapping slices when ordered |
Bulk UPDATE/DELETE |
Execute directly against the DB; already-loaded managed entities go stale until clear() + reload |
GenerationType.IDENTITY + flush mode |
IDENTITY forces immediate INSERT on persist(), defeating flush-mode-based INSERT deferral entirely |
Default AUTO flush |
Flushes a dirty UPDATE before a query that could see it |
FlushModeType.COMMIT (jakarta) |
Implementation-defined; Hibernate 7.4.5 chooses not to flush |
FlushMode.MANUAL (Hibernate native) |
Genuinely, unconditionally suppresses auto-flush until explicit flush() |
← Previous: 14 — Named queries | Back to README → | Next: 16 — Criteria API →