Skip to content

Hibernate 7: join nested createAlias paths and add subquery correlation regression tests - #16773

Open
borinquenkid wants to merge 11 commits into
8.0.xfrom
fix/h7-subquery-alias-correlation-8.0.x
Open

borinquenkid wants to merge 11 commits into
8.0.xfrom
fix/h7-subquery-alias-correlation-8.0.x

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Fixes #16762

Problem

Three Hibernate 7 criteria problems around association paths and aliases:

  1. Hibernate 7: a subquery projecting a path through an association correlates with a top-level association block of createCriteria #16762: with a top-level association block such as owner { eq('firstName', 'Ned') }, a subquery that used a path through the same association, such as property('owner.firstName'), took owner from the outer query's join instead of its own root, so the subquery came out correlated.
  2. Nested alias paths: createAlias('owner.address', 'a') failed with PathElementException: Could not resolve attribute 'owner.address'. GORM accepts such a path as a chain of associations, but the whole path was passed to a single JPA join, which takes one attribute.
  3. Alias paths that start with an alias: createAlias('favorite', 'f') followed by createAlias('f.tag', 't') failed with IllegalArgumentException: Argument [f.tag] is not an association. This is how a nested alias is declared with Hibernate 5, where a path from the entity only works when every association before the last one has an alias, so a criteria query written for Hibernate 5 broke on Hibernate 7.

What changed since this PR was opened

#16573 (the fix for #16562) has been merged to 8.0.x. It reworked the same classes this PR changed (AliasRegistry, ExpressionResolver, HibernateAlias, JpaCriteriaQueryCreator, JpaQueryContext, PredicateGenerator) and scopes a subquery's association paths to its own root, which is what #16762 needs.

This branch is now merged with 8.0.x. Run against 8.0.x alone, 11 of the 12 features of SubqueryAssociationCorrelationSpec pass, so the production changes this PR originally carried are dropped in favour of the 8.0.x versions, together with the AliasRegistrySpec and JpaQueryContextSpec additions that pinned the isSubquery flag 8.0.x does not have. The one feature that still failed is the nested alias path. The production changes left here are that fix and the alias path changes that followed from it.

Solution

  • SubqueryAssociationCorrelationSpec stays as the regression guard for Hibernate 7: a subquery projecting a path through an association correlates with a top-level association block of createCriteria #16762. It covers a subquery that projects, restricts, counts or opens an association block on a path the outer query also uses, correlation through an explicit outer alias, aliases declared inside an association block, sibling association blocks that join the same association name, and a comparison through an inherited join.
  • ExpressionResolver joins the path of an alias one association at a time:
    • Every segment is an association. The path starts from the entity being queried, as GORM already validates, or from an alias the criteria already declare.
    • Each association before the last one shares the join the query already has for it (the first alias of that path, an association block or a dotted property path) and keeps the join type of that join. With aliases on team.likes and team.likes.tag, the tag is the tag of the like the first alias refers to, as on Hibernate 5. Adding createAlias('favorite.tag', 't', 1) does not turn the inner join of a restriction on favorite.label into a left join.
    • The last association uses the join type of the alias, as does an association before it when nothing else in the query joins it.
    • The path is relative to the root of the query that declares the alias, so a subquery does not pick up the outer query's join.
    • A nested path that has a join type and no alias is joined the same way. A detached criteria with join('favorite.tag', JoinType.LEFT) and a restriction on favorite.tag failed with the same PathElementException.
  • QueryAliasRegistrar registers an association block that has no alias with its inner join before the aliases are joined, as the criteria builder already did by recording a join type for each block. In a detached criteria, a favorite { ... } block declared after a left alias on favorite.tag no longer takes the left join of the alias.
  • HibernateCriteriaBuilder.createAlias accepts a path that starts with an alias declared earlier in the same criteria or association block:
    • The first alias of an association shares the join of its path, so createAlias('f.tag', 't') is registered as that path continued (favorite.tag) and shares its joins, however the paths of the other aliases are written.
    • Hibernate 7 allows several aliases of one association, each with its own join. A path that starts with a later one is joined from the join of that alias: after createAlias('likes', 'a') and createAlias('likes', 'b'), createAlias('b.tag', 't') is the tag of the like b refers to.
    • A first segment that is both an alias and an association refers to the alias, as with Hibernate 5 and as for a property path.
    • A path whose other segments are not associations is rejected with the same message as before.
    • A DetachedCriteria still takes a path that starts with an association, with both Hibernate versions, because GORM core validates its createAlias.
  • The GORM for Hibernate 7 guide describes nested alias paths in the Association Aliases section of the criteria chapter (grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc).

Tests

  • SubqueryAssociationCorrelationSpec: the 12 original features, plus an alias on a path through two associations (owner.homeAddress.country).
  • CreateAliasJoinSpec: a nested alias path joins each association once, on its own or next to an alias of the first association; each alias keeps its join type; a restricted first association keeps its join type; a detached criteria uses its configured join types; a nested alias path in a subquery joins from the subquery's root; an alias on a three-segment path shares the join of an alias on its two-segment prefix, for a to-many and a to-one prefix, in either declaration order; an association block keeps its inner join whether a left nested alias is declared before or after it, in the criteria builder and in a detached criteria; a nested path with a join type and no alias; an alias path that starts with an earlier alias joins each association once, keeps the join type of each alias, shares joins with paths written from the entity, continues from a later alias of the same association, prefers an alias over an association of the same name, works inside an association block with a paged total, and is rejected when it is not a chain of associations.
  • ExpressionResolverSpec: nested alias paths share the join of each association before their last, in either resolution order; an association before the last keeps its configured join type; a nested association path without an alias is joined one association at a time; a path that names an alias is joined from the join of that alias.

./gradlew :grails-data-hibernate7-core:codeStyle :grails-data-hibernate7-core:test passed locally on 742729d: 4481 tests, 0 failures, 19 skipped. On 71dd94e, codeStyle passes locally, as do the specs that use createAlias and the specs of org.grails.orm.hibernate.query (1196 tests).

The same 40 criteria queries were run with Hibernate 5 and Hibernate 7. Every query that works with Hibernate 5 returns the same rows with Hibernate 7. The other 16 fail with Hibernate 5, or it leaves the alias out of the query, and they work with Hibernate 7: an alias on a nested path whose earlier associations have no alias, a nested alias inside an association block, and a dotted property path.

Copilot AI balanced review requested due to automatic review settings October 27, 2026 02:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Alias resolution still has correctness issues, and several regression tests do not distinguish correct from incorrect correlation.

5 open issues

Review effort: Balanced

Comment on lines +119 to +120
if (!aliasRegistry.hasLocalDefinition(alias) && context != null && context.getParent() != null) {
Expression<?> parentExpr = context.getParent().getFullyQualifiedExpression(alias);

public boolean hasAlias(String alias) {
return aliasRegistry.isDefined(alias) || aliasRegistry.hasRealized(alias) || (parent != null && parent.hasAlias(alias));
return aliasRegistry.isDefined(alias) || aliasRegistry.hasRealized(alias);
Comment on lines +68 to +74
when:
def results = SubqueryCorrPet.createCriteria().list {
owner {
eq('firstName', 'Ned')
}
'in'('name', new DetachedCriteria(SubqueryCorrPet).build {
eq('owner.firstName', 'Ned')
Comment on lines +87 to +95
when:
def results = SubqueryCorrPet.createCriteria().list {
owner {
eq('firstName', 'Ned')
}
'in'('name', new DetachedCriteria(SubqueryCorrPet).build {
owner {
eq('firstName', 'Ned')
}
Comment on lines +114 to +121
property('name')
}
})
}

then:
results.size() == 2
results*.name.sort() == ["Homer", "Santa's Little Helper"]

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approving. A subquery that correlates through a nested association loses the outer join, and an alias declared inside an association block is materialized on the wrong root.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested createAlias is fixed. The alias stays on the association that declared it. That thread is resolved. Not approving. A comparison through an inherited join is still dropped, and two sibling blocks that join the same name share one join.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An inherited comparison is kept, and homeAddress { country { } } no longer shares its join with workAddress { country { } }. Not approving. The same two blocks written as eq('country.name', ...) still both constrain the home country, so a USA home and a Canadian workplace match nothing. 8.0.x can keep this bugfix.

@borinquenkid
borinquenkid force-pushed the fix/h7-subquery-alias-correlation-8.0.x branch from 61fc4ed to 89207e3 Compare October 30, 2026 01:54
@borinquenkid

Copy link
Copy Markdown
Member Author

Updated and rebased on 8.0.x.

The issue with dotted association properties in sibling blocks (such as homeAddress { eq('country.name', 'USA') } and workAddress { eq('country.name', 'Canada') }) was caused by JpaQueryContext.registerAlias unconditionally propagating realized aliases to parent.registerAlias(...) for non-subquery contexts. When homeAddress realized the implicit country alias, it was registered on the root query context, causing subsequent sibling blocks like workAddress to resolve country from the parent rather than creating an independent join on their own association path.

In JpaQueryContext.registerAlias, propagation to the parent context is now restricted strictly to explicit aliases:

public void registerAlias(String alias, Expression<?> expression) {
    aliasRegistry.realize(alias, expression);
    if (!isSubquery && parent != null && aliasRegistry.isExplicit(alias)) {
        parent.registerAlias(alias, expression);
    }
}

Only explicit user-declared aliases propagate upward, ensuring implicit association navigation aliases remain scoped to their declaring block whether written as nested association closures or dotted property expressions.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eq('country.name', ...) on two sibling blocks no longer constrains the first country. Not approving. Resolving an implicit join marks that join explicit, so a later subquery correlates through it.

…ery correlation to explicit aliases

Differentiates explicit user-declared aliases from implicit joins, restricts
query context alias propagation strictly to explicit aliases, ensures implicit
joins realized during translation do not mark aliases explicit in AliasRegistry,
and prevents subqueries from correlating through outer implicit paths while
preserving outer joins and independent joins across sibling association blocks.
@borinquenkid
borinquenkid force-pushed the fix/h7-subquery-alias-correlation-8.0.x branch from 89207e3 to 39c1816 Compare October 31, 2026 04:56
@borinquenkid

Copy link
Copy Markdown
Member Author

Updated with clean alias classification and scope isolation.

Architectural Context & Resolution

1. Explicit User Aliases vs. Implicit Navigation Joins

  • Explicit Aliases: Aliases declared intentionally by the developer (such as createAlias('homeAddress', 'ha') or homeAddress('ha') { ... }). These represent intentional correlation anchor points that outer queries, subqueries, and nested blocks are permitted to reference.
  • Implicit Navigation Joins: Joins generated automatically when navigating property paths (such as eq('country.name', 'USA') inside an association block or eq('homeAddress.city', 'Springfield') at the top level). These are internal implementation details of query construction and must never be treated as explicit aliases.

2. Why Realization Never Confers Explicit Status

  • In AliasRegistry.isExplicit(alias), presence in realizedExpressions has been removed from determining whether an alias is explicit. An alias is explicit if and only if declared with explicit == true in its definition (or inherited as an explicit definition from an ancestor context).
  • Materializing or caching an implicit join in realizedExpressions no longer elevates it to an explicit alias. As a consequence:
    1. Implicit joins realized inside nested contexts are not propagated upward to the parent context in JpaQueryContext.registerAlias, keeping parent contexts free of unqualified property names (such as country). Sibling blocks like workAddress { eq('country.name', 'Canada') } navigate their own independent joins rather than resolving against a sibling's materialized join.
    2. Subqueries checking parent.isExplicit(...) never see outer implicit joins as correlation candidates, eliminating accidental correlation through outer association paths.

3. ExpressionResolver Isolation

  • In ExpressionResolver, resolving and tracking implicit joins records them locally in JoinTracker and realizedExpressions without calling context.registerAlias(...) unless the alias definition was explicitly declared by the user.

4. Test Coverage

  • In SubqueryAssociationCorrelationSpec, verified:
    • Sibling association blocks with dotted paths independently constrain their own associations without dropping or sharing joins.
    • Subqueries following an outer implicit association join do not correlate through the outer implicit path and resolve independently on their own root.
    • Correlated subqueries with explicit user aliases continue to correlate properly with the outer query.

@borinquenkid borinquenkid added this to the grails:8.0.1 milestone Nov 4, 2026

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequencing note on this PR (Fixes #16762).

#16573 (fix for #16562) has four approvals and CI running, and it overlaps this one heavily. Both add the same explicit flag to HibernateAlias, and both rework AliasRegistry, ExpressionResolver and JpaQueryContext so that a subquery resolves a path from its own root instead of the outer join. A 3-way apply of this patch onto the #16573 head conflicts in AliasRegistry, ExpressionResolver, JpaCriteriaQueryCreator and PredicateGenerator. It would also stop compiling after that merge: PredicateGenerator here calls JpaCriteriaQueryCreator.discoverAliases, which #16573 moves into QueryAliasRegistrar.

The two take different routes to the same result. This PR cuts the parent JoinTracker for subqueries and lets the parent registry answer only for explicit aliases (isSubquery). #16573 keeps the parent chain and scopes the lookups (getOwnJoin, isOwnDefined, and getRealized stopping at an alias the query defines itself). On paper #16573 already covers the case in #16762, a subquery projecting owner.firstName next to a top-level owner { ... } block, but I have not run it.

Plan: hold this PR until #16573 merges, then run SubqueryAssociationCorrelationSpec against the new 8.0.x. If it passes, the production changes here are redundant and I will cut this down to the spec as a regression guard, with a description that says why. If a case still fails, I will rebase and reimplement only that case on top of QueryAliasRegistrar.

I would rather not ask anyone to review the production changes here twice, so please hold off on a full review until then. Cross-reference: #16573, #16559, #16762.

8.0.x now carries #16573, which reworked the same classes this branch changed (AliasRegistry, ExpressionResolver,
HibernateAlias, JpaCriteriaQueryCreator, JpaQueryContext and PredicateGenerator) and moved alias discovery into
QueryAliasRegistrar. The conflicts are resolved by taking the 8.0.x version of those classes and of
AliasRegistrySpec and JpaQueryContextSpec, whose additions on this branch pinned the isSubquery flag that 8.0.x
does not have.

SubqueryAssociationCorrelationSpec is kept. Against 8.0.x alone, 11 of its 12 features pass: #16573 already scopes
a subquery's association paths to its own root, keeps sibling association blocks on independent joins and keeps a
comparison through an inherited join. The remaining feature, an alias on a nested path such as
createAlias('owner.address', 'a'), is fixed in the next commit.
…time

createAlias('owner.address', 'a') passed the whole path to a single JPA join, which takes one attribute, so the
query failed with "Could not resolve attribute 'owner.address'". GORM validates such a path as a chain of
associations from the queried entity, so ExpressionResolver now joins it one association at a time.

The first association shares the join the query already has for it (its first alias, an association block or a
dotted property path) and keeps the join type the query gives that association, so adding a left alias on
'favorite.tag' does not turn the inner join of a restriction on 'favorite.label' into a left join. The
associations after it use the join type of the alias, as does the first one when nothing else joins it. The path
is relative to the root of the query that declares the alias, so a subquery does not pick up the outer query's
join.

This is the one feature of SubqueryAssociationCorrelationSpec that 8.0.x did not already pass after #16573. The
Hibernate 7 guide documents nested alias paths under Association Aliases.
@jdaugherty jdaugherty changed the title Hibernate 7: fix subquery correlation with top-level association blocks Hibernate 7: join nested createAlias paths and add subquery correlation regression tests Nov 4, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor

@borinquenkid please look over the merge & changes on this; i've also updated the PR description/title to reflect what this PR does now.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sibling association blocks keep their own joins, so two eq('country.name', ...) restrictions and a nested createAlias each use the association that declared them. A subquery still correlates through an outer alias.

…lias path goes through

Only the first association of a nested alias path shared a join the query already had. With aliases on
'team.likes' and 'team.likes.tag', the second alias joined 'likes' again, so the tag was not the tag of the
like the first alias refers to: a restriction on both matched across two different likes, and left joins
returned a row for each pair of likes. A to-one association before the last one was joined twice.

Every association before the last one now shares the join the query has for it (the first alias of that path,
an association block or a dotted property path) and keeps the join type of that join, which is what Hibernate 5
does when each of them has an alias. ExpressionResolver.resolveAssociation joins a nested path one association
at a time as well, so a detached criteria with join('favorite.tag', JoinType.LEFT) and a restriction on
'favorite.tag' no longer fails with "Could not resolve attribute 'favorite.tag'".

In a detached criteria, an association block declared after a left alias on a nested path through the same
association took the left join of the alias, so a row without the association could satisfy the block. An
association block without an alias is now registered with its inner join before the aliases are joined, as the
criteria builder already did by recording a join type for each block.

The Hibernate 7 guide describes the shared joins and how to write a path that Hibernate 5 started with an alias.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubqueryAssociationCorrelationSpec guards what it says. On 8.0.x before #16573, 6 of its 13 features fail: the three #16762 queries, the subquery after an implicit join, and the two nested-path features. All 13 pass on this branch.

11a2e18 follows up on my nested-path commit. Every association before the last one of a nested alias path now shares the join the query already has for it, so an alias on team.likes.tag is the tag of the like an alias on team.likes refers to, as it is on Hibernate 5. An association block of a detached criteria also keeps its inner join when a left alias on a nested path through the same association is declared before it.

Comment on lines +367 to +369
// Wait, if outer query matches persons with firstName in ("Bart", "HomerCanada"), but outer person has homeAddress in USA...
// None of the outer persons with homeAddress in USA has firstName "Bart" or "HomerCanada".
// Let's create person4 with firstName "Bart" and homeAddress in USA!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three lines are notes from working the fixture out (Wait, ..., Let's create person4 ...). One sentence saying why person4 exists would read better.

…r alias

createAlias('favorite', 'f') followed by createAlias('f.tag', 't') is how a nested alias is declared with
Hibernate 5, where a path from the entity only works when every association before the last one has an alias.
With Hibernate 7 it failed with "Argument [f.tag] is not an association", because GORM validates every segment
of the path as an association of the queried entity.

HibernateCriteriaBuilder.createAlias now resolves a first segment that names an alias the criteria already
declare. The first alias of an association path shares the join of that path, so the path is registered as the
path of that alias continued ('favorite.tag') and shares its joins, however the paths of the other aliases are
written. Hibernate 7 also allows several aliases of one association, each with a join of its own: a path that
starts with a later one is registered as '<alias>:<rest>', and ExpressionResolver joins it from the join of
that alias. A first segment that is both an alias and an association refers to the alias, as it does with
Hibernate 5 and for a property path. A path whose other segments are not associations is rejected with the same
message as before.

Run with both versions, every one of 40 criteria queries that works with Hibernate 5 now returns the same rows
with Hibernate 7. A DetachedCriteria still takes a path that starts with an association, with both versions,
because GORM core validates its createAlias.
@jdaugherty

Copy link
Copy Markdown
Contributor

@jamesfredley @borinquenkid three commits have been pushed since the approved head (742729d). The branch is now at 71dd94e:

  • 11a2e18: every association before the last one of a nested alias path shares the join the query already has for it. With aliases on team.likes and team.likes.tag, the tag is the tag of the like the first alias refers to. A detached criteria with join('favorite.tag', JoinType.LEFT) and a restriction on favorite.tag no longer throws, and an association block of a detached criteria keeps its inner join when a left nested alias is declared before it.
  • 09c2d58: merge of 8.0.x.
  • 71dd94e: createAlias('f.tag', 't') after createAlias('favorite', 'f') works, as it does with Hibernate 5. The path of an alias can start with an alias declared earlier in the same criteria or association block.

The description is updated to match. Run with both versions, every one of 40 criteria queries that works with Hibernate 5 returns the same rows with Hibernate 7.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sibling association blocks still keep their own joins. Not approving. An association block registers an implicit alias that hides an explicit alias of the same name declared outside the block.

…lared outside its scope

An association block with no alias registers an implicit alias named after its path, so
that its join keeps its join type. The block's context defines it in its own registry, and
a lookup answers from the context's own definitions first, so it hid an explicit alias of
the same name declared outside the block:

    createAlias('tag', 'tag')
    favorite { tag { eqProperty('name', 'tag.name') } }

compared the tag of the favorite with itself, and a team whose tag differs from its
favorite's tag was returned. The guard only asked whether this scope declares the name.

Both places that register an implicit alias, the association block and the join types of the
criteria, now go through one method that registers it only when neither this scope nor an
enclosing one has an alias of that name that is explicit. The block inside a junction and a
block with no alias outside are pinned as well.

Claude-Session: https://claude.ai/code/session_01Dmki4Tgxp7dLBhzpe2xT93

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. A left join requested inside a block is dropped when an outer alias has the same name, so a missing association excludes the row. A nested prefix alias and a block on the same path can join that association twice, and the restrictions then match different elements. createAlias('tag', 'tag') then favorite { tag { eqProperty('name', 'tag.name') } } now uses the outer alias. Thread PRRT_kwDOAAfRJ86z71mW resolved.

… Hibernate 5

AliasMatrixSpec runs 2,225 combinations of the ways a criteria query names an
association (an explicit alias, an association block, a dotted path, a join
type) on the root, in a detached criteria and in a subquery, against a fixture
that tells them apart, and compares the rows with the ones Hibernate 5 returns.
The same file runs on both modules; alias-matrix.expected.txt is recorded from
Hibernate 5 and alias-matrix.accepted.txt lists the differences that stay, each
with its reason.

It found that a bare name in an association block was resolved to an alias or
join of the same name in an enclosing query: likes { isNull('tag') } tested the
team's tag when the team joined tag. A name without a dot that the entity of the
scope has as a property is now that property unless the scope itself aliases or
joins it. A dotted name still reaches the alias of an enclosing query.
…ith the enclosing query

A left join requested in an association block was dropped when an enclosing
query had an explicit alias of the same name, because the block's join type was
registered as an alias that the explicit one suppressed. The join type is now
recorded for the block's association without declaring an alias.

An alias on a nested path such as team.likes.tag joined the association again
instead of using the join of the block team { likes { } }, so two restrictions
could match different elements. A block's context now publishes its joins to
the enclosing context under the prefixed path, and looks there before it joins.

The new resolver unit rows are removed: the behavior is pinned through the
criteria API in CreateAliasJoinSpec and AliasMatrixSpec, and the mock rows
only counted calls.
@borinquenkid

Copy link
Copy Markdown
Member Author

Pushed b4704e4 (three commits on top of 0d375c3), by thread:

Left join dropped when an outer alias has the same name (4fcbc14): createAlias('tag','tag') then favorite { tag(1) { isNull('name') } } keeps the left join, so a favorite with no tag matches. The block's join type is recorded without declaring an alias, so the outer alias still owns the name.

Nested prefix alias and a block joined twice (4fcbc14): createAlias('team.likes.tag','t') with team { likes { eq('label','a') } } now uses one likes join, in either order of alias and block, with and without the team { } wrapper. Before, both clubs matched.

Outer alias and a dotted path in a block (b4704e4): createAlias('tag','tag') then likes { eq('tag.name','a') } starts at the outer alias in a criteria query, a DetachedCriteria and a subquery alike. A feature pins the three scopes, and the Hibernate 5 and 7 criteria guides say so. Hibernate 5 throws a QueryException for the dotted form in a block, so there is nothing to copy there.

New: AliasMatrixSpec (012e124) compares every alias, block and path combination (singles, pairs and 1000 seeded triples, 266 KB golden file recorded from Hibernate 5) between the two modules. Where Hibernate 5 throws or is inconsistent between scopes, a rule in alias-matrix.accepted.txt says why, so a difference cannot slip through unreviewed. It also found the own-property-first case in ExpressionResolver (a property of a block is that property, not an outer alias or join of the same name), fixed in the same commit with a feature in CreateAliasJoinSpec. Reverting the four main files fails exactly the new features. The Hibernate 5 module gains the same spec and golden file, test-only.

Verified locally: the full grails-data-hibernate7-core suite, the Hibernate 5 AliasMatrixSpec, and codeStyle.

Known gap, in both modules: favorite { eqProperty('label','l.label') } with createAlias('likes','l') throws PathElementException on Hibernate 7 detached criteria and subqueries, and on the root with Hibernate 5. Not changed here.

@jamesfredley @jdaugherty

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved. A block that declares its own alias joins the association a second time, so restrictions match different elements. A nested block that needs an inner join reuses a left join created for an outer alias. A left join requested inside a block with the same alias name is kept. A prefix alias and a block on the same path now share one join. Threads PRRT_kwDOAAfRJ860RVHS and PRRT_kwDOAAfRJ860RVHV resolved.

@@ -310,6 +310,7 @@ private Predicate handleAssociationCriteria(
// Create a nested context for this association
JpaQueryContext nestedContext = new QueryAliasRegistrar(associationCriteria, null)
.createContext(fromsByProvider, associationRoot);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createAlias('team.likes.tag', 't') and team { createAlias('likes', 'l'); eq('l.label', 'a') }; eq('t.name', 'B') join likes twice. A team with likes (a, A) and (b, B) matches. Neither like satisfies both restrictions. Share the join before the block's alias is created.

From<?, ?> resolveAssociation(String path) {
if (joinTracker.getOwnJoin(path) == null) {
From<?, ?> shared = inheritedJoin(path);
if (shared != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createAlias('team.likes.tag', 't', 1) and team { likes { isNull('tag') } } reuse the left join made for the alias. A team with no likes is returned. The likes block asked for an inner join. Keep that join type.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0d375c3 fixes the query from the last review. tag.name inside favorite { tag { ... } } is the outer alias again, and the two new features fail on 71dd94e and pass now.

It also drops the join type of the block. The implicit alias that is no longer registered is where that join type is kept, so a block with a left join, or a block declared after a left nested alias, joins differently as soon as an enclosing query has an alias named like the association, even when nothing refers to that alias. The queries are on registerImplicit.

private void registerImplicit(JpaQueryContext context, String path, JoinType joinType) {
AliasRegistry registry = context.getAliasRegistry();
HibernateAlias visible = registry.getDefinition(path);
if (registry.isOwnDefined(path) || (visible != null && visible.explicit())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping the registration keeps the outer alias visible, but the implicit alias also holds the join type of the path in this scope. JpaQueryContext.resolveAssociation and ExpressionResolver.resolvePrefix read it from there, so once an enclosing query has an explicit alias of that name, the block gets the default join instead.

Neither of these refers to the outer alias. Both return the expected rows on 8.0.x and on 71dd94e, and nothing on 0d375c3:

// teams whose favorite has no tag: the left join of the block is now an inner join.
// Without the createAlias line, or with the alias named 't', the team is returned.
CreateAliasTeam.withCriteria {
    createAlias('tag', 'tag')
    favorite {
        tag(1) {
            isNull('id')
        }
    }
}

// teams without a favorite: the join type of the subquery is dropped
CreateAliasTeam.withCriteria {
    createAlias('favorite', 'favorite', 1)
    'in'('name', new DetachedCriteria(CreateAliasTeam).build {
        join('favorite', JoinType.LEFT)
        favorite {
            isNull('id')
        }
        projections {
            property('name')
        }
    })
}

Hibernate 5 returns the team for the first one.

The other direction is lost too. With an outer alias named favorite, team { createAlias('favorite.tag', 't', 1); favorite { isNull('tag') } } on CreateAliasClub also returns the club whose team has no favorite, because the block takes the left join of the nested alias. On 71dd94e it returns only the club whose team has a favorite without a tag.

The name and the join type need to be kept apart: skip the name, keep the join type. I tried recording the join type by path in the AliasRegistry of the scope when the name is skipped, and reading it in those two methods before the fallback. With that, the three queries return the expected rows, the three features added here still pass, and so do the rest of CreateAliasJoinSpec, SubqueryAssociationCorrelationSpec and the specs of org.grails.orm.hibernate.query.

Please add features for a block with a join type and for the subquery next to the new ones.

…ep a block's join type

A block alias for an association the enclosing query already joins, for example through a nested
alias path such as team.likes.tag, joined it a second time, so restrictions matched different elements.
The context of a block now knows its path before its aliases are materialized, so the alias uses the
enclosing join and publishes its own.

The associations before the last one of a nested alias path took the join type of the alias, so a block
that needs an inner join shared a left join and returned rows without the association. The join types of
nested blocks are recorded before the path is joined, and the prefix takes the join type its block asks for.

A block whose association has the name of an alias declared in the same query lost its join type, because
only an alias of an enclosing query kept it. The join type is recorded for both.

Claude-Session: https://claude.ai/code/session_01Dmki4RzWwreEC2GkkyFnvL
@borinquenkid

Copy link
Copy Markdown
Member Author

Fixed in ac1398d, on top of b4704e4. One cause each, and one feature each in CreateAliasJoinSpec; all three fail without the change.

  • jdaugherty, the join type of a block: registerImplicit returned early when the query itself declared an alias with the name of the block's association, so only an alias of an enclosing query kept the block's join type. It is recorded for both. createAlias('favorite', 'likes'); likes(1) { isNull('label') } now returns the team without likes, as Hibernate 5 does.
  • james, an inner block over a left alias path: the associations before the last one of createAlias('team.likes.tag', 't', 1) took the join type of the alias, so team { likes { isNull('tag') } } shared a left join. The join types of nested blocks are recorded before the path is joined, the prefix takes the one its block asks for, and the left join applies to tag. A club whose team has no likes is no longer returned, in a criteria query, in either order, and in a DetachedCriteria; Hibernate 5 returns the same rows for the two criteria forms.
  • james, a block alias on a shared association: the context of a block learned its path after its aliases were materialized, so team { createAlias('likes', 'l') } joined likes a second time. The path is set first, the alias uses the join the enclosing query has for it and publishes its own, so eq('t.name', 'tagB') and eq('l.label', 'a') hold for one like. Hibernate 5 rejects this shape (could not resolve property: likes), so it is a difference and not a regression; criteria.adoc says it.

The Hibernate 7 core suite, the AliasMatrixSpec differential on both modules and codeStyle pass in the push script.

@testlens-app

testlens-app Bot commented Nov 7, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: ac1398d
▶️ Tests: 92967 executed
⚪️ Checks: 96/96 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Hibernate 7: a subquery projecting a path through an association correlates with a top-level association block of createCriteria

4 participants