Skip to content

Hibernate 7: fix subquery correlation with top-level association blocks - #16773

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

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

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

Fixes #16762

Problem

In Hibernate 7 criteria queries, top-level association blocks (such as owner { eq('firstName', 'Ned') }) and un-aliased association joins record the join path in detachedCriteria.getJoinTypes(). During JPA criteria translation, JpaCriteriaQueryCreator registered these join paths as aliases in AliasRegistry and tracked the materialized joins in JoinTracker. When an inner subquery was created via JpaQueryContext.forSubquery(parentContext, ...) and projected a property through an association (such as property('owner.firstName')), the subquery context delegated to the parent query's AliasRegistry and JoinTracker, resolving the projection path against the parent query's physical join rather than creating an independent join on the subquery's root entity. This caused the subquery to unintentionally correlate with the outer query.

Additionally, when an explicit alias was declared on an outer query and referenced inside a subquery, ExpressionResolver was attempting to materialize the join on the subquery's root rather than delegating materialization to the parent context that owns the alias.

Solution

  • Differentiate explicit user-defined aliases from internal implicit joins by adding an explicit flag to HibernateAlias.
  • Restrict AliasRegistry parent delegation (isDefined, getDefinition, getRealized, hasRealized) so that child subquery contexts only inherit explicit aliases from the parent query.
  • Update ExpressionResolver to resolve and materialize parent-defined explicit aliases on the parent context that owns them.
  • Isolate the physical join tracking of subqueries in JpaQueryContext so that subquery contexts do not delegate physical joins to the parent's JoinTracker.
  • Add test coverage in SubqueryAssociationCorrelationSpec covering projections, criteria, nested association blocks, and correlated outer aliases, as well as unit tests in AliasRegistrySpec and JpaQueryContextSpec.
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.

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

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.

homeAddress { eq('country.name', 'USA') } then workAddress { eq('country.name', 'Canada') } should return the person with that pair of addresses. registerAlias publishes the implicit country join to the shared parent, so the second predicate reuses the home country and the person is excluded. Keep that implicit path on the association that declared it.

@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.

if (def != null) {
return def.explicit();
}
if (realizedExpressions.containsKey(alias)) {

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 outer DetachedCriteria on the pet does owner { eq('firstName', 'Ned'); eqProperty('firstName', 'owner.firstName') }, then an in('name', ...) subquery that should project its own owner's first name. The comparison realizes the tracked owner join with no definition, isExplicit() becomes true, and the subquery projects the outer Ned instead of joining its owner. The pet that should match is missing. Keep sibling isolation, and keep an implicit join implicit when it is realized.

…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.
@testlens-app

testlens-app Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🚨 TestLens detected 13 failed tests 🚨

Failed Jobs without Test Failures

❌ CI / Build Grails Forge (Java 21, indy=true)
❌ CI / Build Grails-Core (Windows JDK 25 shard 1)
❌ CI / Build Grails-Core (Windows JDK 25 shard 2)
❌ Coverage / Coverage - grails-core (ubuntu-24.04)
❌ Groovy Snapshot Canary Build / Build Grails (shard 1)

Test Summary

Tip

Inspect all details with the TestLens CLI: testlens pr 16773

CI / Build Grails Forge (Java 21, indy=false) > :grails-forge-core:test [grails-forge]

Test Runs Flakiness
GraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation ❌ 0% 🟢

CI / Build Grails Forge (Java 25, indy=false) > :test-core:test [grails-forge]

Test Runs Flakiness
AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support ❌ 1% 🟡

CI / Build Grails-Core (Ubuntu JDK 21) > :grails-web-url-mappings:test

Test Runs Flakiness
LinkGeneratorSpec > cache key should use identity of resource value ❌ 0% 🟢
LinkGeneratorSpec > resource links should use ident and allow controller override ❌ 0% 🟢

CI / Build Grails-Core (Ubuntu JDK 25) > :grails-openapi:test

Test Runs Flakiness
PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context ❌ 0% 🟢

CI / Build Grails-Core (Ubuntu JDK 25) > :grails-web-url-mappings:test

Test Runs Flakiness
LinkGeneratorSpec > cache key should use identity of resource value ❌ 0% 🟢
LinkGeneratorSpec > resource links should use ident and allow controller override ❌ 0% 🟢

CI / Build Grails-Core (macOS JDK 21) > :grails-openapi:test

Test Runs Flakiness
PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context ❌ 0% 🟢

CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 1) > :grails-openapi:test

Test Runs Flakiness
PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context ❌ 0% 🟢

CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 2) > :grails-web-url-mappings:test

Test Runs Flakiness
LinkGeneratorSpec > cache key should use identity of resource value ❌ 0% 🟢
LinkGeneratorSpec > resource links should use ident and allow controller override ❌ 0% 🟢

Groovy Snapshot Canary Build / Build Grails (shard 2) > :grails-web-url-mappings:test

Test Runs Flakiness
LinkGeneratorSpec > cache key should use identity of resource value ❌ 0% 🟢
LinkGeneratorSpec > resource links should use ident and allow controller override ❌ 0% 🟢

🏷️ Commit: 39c1816
▶️ Tests: 31619 executed
⚪️ Checks: 96/96 completed

Test Failures (first 10 of 13)

LinkGeneratorSpec > cache key should use identity of resource value (:grails-web-url-mappings:test in Groovy Snapshot Canary Build / Build Grails (shard 2))
Condition not satisfied:

cacheKey == "somePrefix[resource:org.grails.web.mapping.Widget->1]"
|        |
|        false
|        11 differences (82% similarity)
|        somePrefix[resource:org.grails.web.mapping.Widget->(Some Widget)]
|        somePrefix[resource:org.grails.web.mapping.Widget->(1----------)]
somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]

	at org.grails.web.mapping.LinkGeneratorSpec.cache key should use identity of resource value(LinkGeneratorSpec.groovy:308)
expected actual
somePrefix[resource:org.grails.web.mapping.Widget->1] somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]
LinkGeneratorSpec > resource links should use ident and allow controller override (:grails-web-url-mappings:test in Groovy Snapshot Canary Build / Build Grails (shard 2))
Condition not satisfied:

link == "/bar/widget/show/1"
|    |
|    false
|    6 differences (66% similarity)
|    /bar/widget/(index-)
|    /bar/widget/(show/1)
/bar/widget/index

	at org.grails.web.mapping.LinkGeneratorSpec.resource links should use ident and allow controller override(LinkGeneratorSpec.groovy:326)
expected actual
/bar/widget/show/1 /bar/widget/index
GraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation (:grails-forge-core:test [grails-forge] in CI / Build Grails Forge (Java 21, indy=false))
Condition not satisfied:

readme.contains("[Grails GORM for GraphQL documentation](https://grails.apache.org/docs/${VersionInfo.documentationVersion}/grails-data/graphql/manual/)")
|      |                                                                                  |           |
|      false                                                                              |           snapshot
|                                                                                         class org.grails.forge.util.VersionInfo
## Grails 8.0.1-SNAPSHOT Documentation
 
- [User Guide](https://grails.apache.org/docs/snapshot/guide/index.html)
- [API Reference](https://grails.apache.org/docs/snapshot/api/index.html)
- [Grails Guides](https://guides.grails.org/index.html)
---
 
## Feature scaffolding documentation
 
- [Grails Scaffolding documentation](https://grails.apache.org/docs/snapshot/guide/scaffolding.html)
 
## Feature spring-boot-devtools documentation
 
- [Grails SpringBoot Developer Too…
AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support (:test-core:test [grails-forge] in CI / Build Grails Forge (Java 25, indy=false))
org.gradle.testkit.runner.UnexpectedBuildFailure: Unexpected build execution failure in /tmp/testasciidoctor17909021156311840412 with arguments [asciidoctor]

Output:
Calculating task graph as no cached configuration is available for tasks: asciidoctor

FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring root project 'testasciidoctor17909021156311840412'.
> Could not resolve all artifacts for configuration 'classpath'.
   > Could not download asciidoctor-gradle-jvm-4.0.5.jar (org.asciidoctor:asciidoctor-gradle-jvm:4.0.5): Repository maven2 is disabled due to earlier error below:
      > Could not download org.asciidoctor.jvm.convert.gradle.plugin-4.0.5.jar (org.asciidoctor.jvm.convert:org.asciidoctor.jvm.convert.gradle.plugin:4.0.5)
         > Could not get resource 'https://repo.grails.org/grails/restricted/org/asciidoctor/jvm/convert/org.asciidoctor.jvm.convert.gradle.plugin/4.0.5/org.asciidoctor.jvm.convert.gradle.plugin-4.0.5.jar'.
          …
LinkGeneratorSpec > cache key should use identity of resource value (:grails-web-url-mappings:test in CI / Build Grails-Core (Ubuntu JDK 21))
Condition not satisfied:

cacheKey == "somePrefix[resource:org.grails.web.mapping.Widget->1]"
|        |
|        false
|        11 differences (82% similarity)
|        somePrefix[resource:org.grails.web.mapping.Widget->(Some Widget)]
|        somePrefix[resource:org.grails.web.mapping.Widget->(1----------)]
somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]

	at org.grails.web.mapping.LinkGeneratorSpec.cache key should use identity of resource value(LinkGeneratorSpec.groovy:308)
expected actual
somePrefix[resource:org.grails.web.mapping.Widget->1] somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]
LinkGeneratorSpec > resource links should use ident and allow controller override (:grails-web-url-mappings:test in CI / Build Grails-Core (Ubuntu JDK 21))
Condition not satisfied:

link == "/bar/widget/show/1"
|    |
|    false
|    6 differences (66% similarity)
|    /bar/widget/(index-)
|    /bar/widget/(show/1)
/bar/widget/index

	at org.grails.web.mapping.LinkGeneratorSpec.resource links should use ident and allow controller override(LinkGeneratorSpec.groovy:326)
expected actual
/bar/widget/show/1 /bar/widget/index
LinkGeneratorSpec > cache key should use identity of resource value (:grails-web-url-mappings:test in CI / Build Grails-Core (Ubuntu JDK 25))
Condition not satisfied:

cacheKey == "somePrefix[resource:org.grails.web.mapping.Widget->1]"
|        |
|        false
|        11 differences (82% similarity)
|        somePrefix[resource:org.grails.web.mapping.Widget->(Some Widget)]
|        somePrefix[resource:org.grails.web.mapping.Widget->(1----------)]
somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]

	at org.grails.web.mapping.LinkGeneratorSpec.cache key should use identity of resource value(LinkGeneratorSpec.groovy:308)
expected actual
somePrefix[resource:org.grails.web.mapping.Widget->1] somePrefix[resource:org.grails.web.mapping.Widget->Some Widget]
LinkGeneratorSpec > resource links should use ident and allow controller override (:grails-web-url-mappings:test in CI / Build Grails-Core (Ubuntu JDK 25))
Condition not satisfied:

link == "/bar/widget/show/1"
|    |
|    false
|    6 differences (66% similarity)
|    /bar/widget/(index-)
|    /bar/widget/(show/1)
/bar/widget/index

	at org.grails.web.mapping.LinkGeneratorSpec.resource links should use ident and allow controller override(LinkGeneratorSpec.groovy:326)
expected actual
/bar/widget/show/1 /bar/widget/index
PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context (:grails-openapi:test in CI / Build Grails-Core (Ubuntu JDK 25))
Condition not satisfied:

!openApi.components.schemas['Widget'].properties.id?.readOnly
||       |          |      |          |          |   |
||       |          |      |          |          |   true
||       |          |      |          |          class IntegerSchema {
||       |          |      |          |              class Schema {
||       |          |      |          |                  type: integer
||       |          |      |          |                  format: int64
||       |          |      |          |                  $ref: null
||       |          |      |          |                  description: null
||       |          |      |          |                  title: null
||       |          |      |          |                  multipleOf: null
||       |          |      |          |                  maximum: null
||       |          |      |          |                  exclusiveMaximum: null
||       |          |      |          |                  minimum: null
||       |…
PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context (:grails-openapi:test in CI / Build Grails-Core (macOS JDK 21))
Condition not satisfied:

!openApi.components.schemas['Widget'].properties.id?.readOnly
||       |          |      |          |          |   |
||       |          |      |          |          |   true
||       |          |      |          |          class IntegerSchema {
||       |          |      |          |              class Schema {
||       |          |      |          |                  type: integer
||       |          |      |          |                  format: int64
||       |          |      |          |                  $ref: null
||       |          |      |          |                  description: null
||       |          |      |          |                  title: null
||       |          |      |          |                  multipleOf: null
||       |          |      |          |                  maximum: null
||       |          |      |          |                  exclusiveMaximum: null
||       |          |      |          |                  minimum: null
||       |…

Rerun Controls

Select tests to mute in this pull request:

  • AsciidoctorBuildSpec > an app with asciidoctor builds its guide, without the configuration cache the plugin does not support
  • GraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation
  • LinkGeneratorSpec > cache key should use identity of resource value
  • LinkGeneratorSpec > resource links should use ident and allow controller override
  • PersistentEntitySchemaSpec > describes the resource as a plain type when the application has no mapping context

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

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

3 participants