Files
hibernate-demo/docs/15-hql-queries.md
T

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.name where 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: UnknownPathException means "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:

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:

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 bulk DELETE. remove() triggers cascades and lifecycle callbacks per entity; bulk DELETE is one SQL statement against the database and skips both. Choose deliberately -- bulk DELETE is much faster for large sets, remove() is correct when cascades or @PreRemove matter.

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 an UPDATE on an already-managed row, not a persist(). 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:

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 →