Repository navigation
Hibernate 7: join nested createAlias paths and add subquery correlation regression tests - #16773
borinquenkid wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Alias resolution still has correctness issues, and several regression tests do not distinguish correct from incorrect correlation.
5 open issues
Materialize nested aliases in their declaration scope · New Prevent aliased expression fallback to implicit parent aliases · New Make correlation regression fixture distinguish inner and outer owners · New Detect association-block join leakage with distinct owner predicates · New Make subquery-local alias test detect incorrect root materialization · New
Review effort: Balanced
| 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); |
| when: | ||
| def results = SubqueryCorrPet.createCriteria().list { | ||
| owner { | ||
| eq('firstName', 'Ned') | ||
| } | ||
| 'in'('name', new DetachedCriteria(SubqueryCorrPet).build { | ||
| eq('owner.firstName', 'Ned') |
| when: | ||
| def results = SubqueryCorrPet.createCriteria().list { | ||
| owner { | ||
| eq('firstName', 'Ned') | ||
| } | ||
| 'in'('name', new DetachedCriteria(SubqueryCorrPet).build { | ||
| owner { | ||
| eq('firstName', 'Ned') | ||
| } |
| property('name') | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| then: | ||
| results.size() == 2 | ||
| results*.name.sort() == ["Homer", "Santa's Little Helper"] |
jamesfredley
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
61fc4ed to
89207e3
Compare
|
Updated and rebased on The issue with dotted association properties in sibling blocks (such as In 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
left a comment
There was a problem hiding this comment.
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.
89207e3 to
39c1816
Compare
|
Updated with clean alias classification and scope isolation. Architectural Context & Resolution1. Explicit User Aliases vs. Implicit Navigation Joins
2. Why Realization Never Confers Explicit Status
3. ExpressionResolver Isolation
4. Test Coverage
|
borinquenkid
left a comment
There was a problem hiding this comment.
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.
|
@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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| // 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! |
There was a problem hiding this comment.
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.
|
@jamesfredley @borinquenkid three commits have been pushed since the approved head (742729d). The branch is now at 71dd94e:
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
…ts, and pin it in all three scopes
|
Pushed b4704e4 (three commits on top of 0d375c3), by thread: Left join dropped when an outer alias has the same name (4fcbc14): Nested prefix alias and a block joined twice (4fcbc14): Outer alias and a dotted path in a block (b4704e4): New: Verified locally: the full Known gap, in both modules: |
jamesfredley
left a comment
There was a problem hiding this comment.
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); | |||
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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())) { |
There was a problem hiding this comment.
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
|
Fixed in ac1398d, on top of b4704e4. One cause each, and one feature each in
The Hibernate 7 core suite, the |
✅ All tests passed ✅🏷️ Commit: ac1398d Learn more about TestLens at testlens.app/docs. |


Fixes #16762
Problem
Three Hibernate 7 criteria problems around association paths and aliases:
owner { eq('firstName', 'Ned') }, a subquery that used a path through the same association, such asproperty('owner.firstName'), tookownerfrom the outer query's join instead of its own root, so the subquery came out correlated.createAlias('owner.address', 'a')failed withPathElementException: 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.createAlias('favorite', 'f')followed bycreateAlias('f.tag', 't')failed withIllegalArgumentException: 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
SubqueryAssociationCorrelationSpecpass, so the production changes this PR originally carried are dropped in favour of the 8.0.x versions, together with theAliasRegistrySpecandJpaQueryContextSpecadditions that pinned theisSubqueryflag 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
SubqueryAssociationCorrelationSpecstays 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.ExpressionResolverjoins the path of an alias one association at a time:team.likesandteam.likes.tag, the tag is the tag of the like the first alias refers to, as on Hibernate 5. AddingcreateAlias('favorite.tag', 't', 1)does not turn the inner join of a restriction onfavorite.labelinto a left join.join('favorite.tag', JoinType.LEFT)and a restriction onfavorite.tagfailed with the samePathElementException.QueryAliasRegistrarregisters 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, afavorite { ... }block declared after a left alias onfavorite.tagno longer takes the left join of the alias.HibernateCriteriaBuilder.createAliasaccepts a path that starts with an alias declared earlier in the same criteria or association block: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.createAlias('likes', 'a')andcreateAlias('likes', 'b'),createAlias('b.tag', 't')is the tag of the likebrefers to.DetachedCriteriastill takes a path that starts with an association, with both Hibernate versions, because GORM core validates itscreateAlias.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:testpassed locally on 742729d: 4481 tests, 0 failures, 19 skipped. On 71dd94e,codeStylepasses locally, as do the specs that usecreateAliasand the specs oforg.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.