Skip to content

fix(grails-datamapping-core): GroovyProxyFactory.getProxiedClass returns Object instead of entity class #15650

Open
codeconsole wants to merge 1 commit into
apache:7.2.xfrom
codeconsole:groovy-proxy-getproxiedclass-fix
Open

fix(grails-datamapping-core): GroovyProxyFactory.getProxiedClass returns Object instead of entity class #15650
codeconsole wants to merge 1 commit into
apache:7.2.xfrom
codeconsole:groovy-proxy-getproxiedclass-fix

Conversation

@codeconsole
Copy link
Copy Markdown
Contributor

@codeconsole codeconsole commented May 8, 2026

fix(grails-datamapping-core): GroovyProxyFactory.getProxiedClass walks past entity to Object

Summary

GroovyProxyFactory.getProxiedClass() returns java.lang.Object (or the entity's actual superclass, whatever that happens to be) for proxies that this same factory created, instead of the proxied entity class. Any caller that feeds the result back into MappingContext.getPersistentEntity(name) then receives null — at best a confusing failure mode, at worst an NPE on the next dereference (UniqueConstraint.processValidate is one such caller — it goes on to invoke targetEntity.isRoot() without a null guard).

This PR fixes the contract violation inside the factory itself; it does not depend on how any specific consumer is wired.

Root cause

GroovyProxyFactory and JavassistProxyFactory ship a structurally identical getProxiedClass(), but the two factories produce different shapes of proxy:

Factory What createProxy() returns proxy.getClass() is proxy.getClass().getSuperclass() is
JavassistProxyFactory runtime subclass Proxy_$$_javassist extends Entity the proxy class the entity class — correct
GroovyProxyFactory a real Entity instance with a ProxyInstanceMetaClass attached the entity class the entity's superclass (usually java.lang.Object) — wrong

The getSuperclass() walk is correct for Javassist's runtime-subclass strategy. Copy-pasted into GroovyProxyFactory, whose proxies are not subclasses of the entity (they are real entity instances with a custom metaclass), the walk steps one level past the entity. The post-walk null check at line 99 catches the inheritance-walk null but never the initial-lookup null, so callers that feed the result into MappingContext.getPersistentEntity(name) get a silent null back and NPE further down the stack.

The fix is to drop the walk: for the proxies this factory produces, proxy.getClass() is already the entity class in both the proxy and non-proxy branches.

     @Override
-    @Override
     Class<?> getProxiedClass(Object o) {
-        if (isProxy(o)) {
-            return o.getClass().getSuperclass()
-        }
         return o.getClass()
     }

(Also drops the duplicate @Override annotation that was already on the method — pure cleanup, no behavior change.)

Test coverage

Adds grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/proxy/GroovyProxyFactorySpec.groovy with two cases:

  1. getProxiedClass returns the entity class for a proxy created by this factory — fails on the unmodified code (getProxiedClass(proxy) returns java.lang.Object), passes after the fix.
  2. getProxiedClass returns the entity class for a non-proxy instance — passes both before and after; regression guard for the non-proxy branch.

The test mirrors what GroovyProxyFactory.createProxy() does internally (real entity instance + ProxyInstanceMetaClass attached), so it exercises the same proxy shape callers see at runtime.

Verification

Test totals on the patched code (groovy-proxy-getproxiedclass-fix branch off 7.2.x):

Suite Tests Failures Skipped
grails-datamapping-core (incl. new GroovyProxyFactorySpec) passing 0
grails-datamapping-core-test (TCK against the simple in-memory datastore — runs GroovyProxySpec for both useGroovyProxyFactory: true and false) 474 0
grails-datamapping-validation, tck, support, async passing 0
grails-datastore-core (incl. existing JavassistProxyFactorySpec) passing 0
grails-datastore-web passing 0
grails-data-mongodb-core (full cascade + proxy path against real Mongo) 568 0 23
grails-data-mongodb-bson 31 0 0
grails-data-hibernate5/core (Javassist path — sanity check that the alternate proxy strategy is unaffected) 478 0 28

Zero regressions.

Why this is worth fixing even though AbstractMappingContext normally picks JavassistProxyFactory

In a normal Grails 7 application the AbstractMappingContext.getProxyFactory() picker probes the classpath for javassist.util.proxy.ProxyFactory first and uses JavassistProxyFactory whenever Javassist is present, so the buggy GroovyProxyFactory.getProxiedClass() path doesn't fire in most production setups. It does fire for any consumer that explicitly opts in to GroovyProxyFactory — directly (mappingContext.proxyFactory = new GroovyProxyFactory(), as the Neo4j and TCK GroovyProxySpec tests do) or indirectly via dependency configurations that strip Javassist from the classpath (custom builds that exclude grails-datastore-core's transitive Javassist dep, etc.). For those consumers the bug is currently a latent NPE waiting on cascade validation; fixing the factory removes the trap regardless of how it ends up selected.

…s past entity to Object

GroovyProxyFactory.createProxy() builds metaclass-only proxies: a real
entity instance with a ProxyInstanceMetaClass attached. For those,
proxy.getClass() is already the entity class.

getProxiedClass() was copied from JavassistProxyFactory, whose proxies
are runtime subclasses where getSuperclass() correctly recovers the
entity. Applied to metaclass-only proxies, the same call walks one
level past the entity — to java.lang.Object for any entity extending
Object directly.

Downstream callers that feed the result into
MappingContext.getPersistentEntity (notably
UniqueConstraint.processValidate) get null back and NPE on the next
dereference. Surfaces on MongoDB / Neo4j (no Javassist on the
classpath → GroovyProxyFactory wins) when cascade validation hits a
not-yet-unwrapped to-one association.

Drop the superclass walk; proxy.getClass() is the entity class for
both the proxy and non-proxy paths.

Adds GroovyProxyFactorySpec covering both paths to lock the contract.
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 8, 2026

✅ All tests passed ✅

🏷️ Commit: ac24d22
▶️ Tests: 7647 executed
⚪️ Checks: 37/37 completed


Learn more about TestLens at testlens.app.

@jdaugherty
Copy link
Copy Markdown
Contributor

this is way too risky of a change for 7.x, it should go in 8. proxies and the code around it have historically been a land mine field of problems and multiple regressions have occurred in this area on larger apps.

@matrei
Copy link
Copy Markdown
Contributor

matrei commented May 9, 2026

this is way too risky of a change for 7.x, it should go in 8

To me this looks like a bug fix for 7.0.x if the following statements are true:

GroovyProxyFactory:

Proxies created by this factory are real instances of the entity class with a ProxyInstanceMetaClass attached (see createProxy). The entity class therefore IS o.getClass() — no superclass walk needed. Walking up via getSuperclass() would yield java.lang.Object (entity classes typically extend Object directly), which then fails to resolve in MappingContext.getPersistentEntity(name) and breaks cascade validation.

Hibernate / SQL stacks pull Javassist transitively, so they get JavassistProxyFactory and never exercise the buggy code path. MongoDB and Neo4j do not pull Javassist, so they fall through to GroovyProxyFactory and hit the bug whenever a proxy reaches a constraint validator (or any other consumer of ProxyHandler.getProxiedClass()) before being unwrapped.

@jdaugherty
Copy link
Copy Markdown
Contributor

This change should be made as a targeted fix for mongo instead of fixing it in core for 7.x. Proxies are one of the areas where my app has consistently broken over the years.

@codeconsole
Copy link
Copy Markdown
Contributor Author

codeconsole commented May 11, 2026

This change should be made as a targeted fix for mongo instead of fixing it in core for 7.x. Proxies are one of the areas where my app has consistently broken over the years.

@jdaugherty this isn't a fix for Mongo. it fixes everything that doesn't use Javassist. hibernate uses Javassist

@jdaugherty
Copy link
Copy Markdown
Contributor

Why can't mongo have specific config in 7.x? How do you know it doesn't break graphql or the simple data store? I'm just saying this change has a lot of risk based on historical changes, and if we're going to pull this into 7.x, we should take a targeted approach.

@jdaugherty
Copy link
Copy Markdown
Contributor

The root cause analysis here is correct — for GroovyProxyFactory's metaclass-proxy strategy, o.getClass() is already the entity class, so the getSuperclass() walk is wrong. I don't dispute the logic of the fix itself.

My concern is the risk profile of this change landing in 7.x.

History of proxy regressions in this area

This is the fourth patch to the UniqueConstraint + proxy interaction alone (apache/grails-data-mapping#1263, apache/grails-data-mapping#1287, afd8bcd738 Oct 2020, now this). Across Grails 2–7 I can trace roughly 23 distinct proxy-related bugs and regressions:

Issue/Commit Grails Version Date Description
GRAILS-10162 Grails 2.x ~2013 Proxy initialization NPE; poor proxy init error messages
GRAILS-11614 Grails 2.x Oct–Dec 2014 GroovyProxyFactory / JavassistProxyFactory ClassCastExceptions between Integer/Long ids; setMetaClass on proxy needed special handling
GRAILS-11792 Grails 2.x Oct 2014 Hibernate objects required to implement EntityProxy interface; extract EntityProxyMethodHandler from JavassistProxyFactory
apache/grails-data-mapping#808 Grails 3.2 (GORM 6.0.1) Oct 2016 @Resource fails with ClassCastException when proxy involved
apache/grails-data-mapping#813 Grails 3.2 (GORM 6.0.2) Oct 2016 MongoDB proxy creator throws NumberFormatException on String ids
apache/grails-data-mapping#975 Grails 3.3 (GORM 6.x) Jul 2017 Invoking asBoolean on entity proxy throws ClassCastException
apache/grails-data-mapping#1043 Grails 3.3 Dec 2017 lock {} causes NPE via proxy path
apache/grails-data-mapping#1072 Grails 3.3 (GORM 6.1.3) 2018 Lazy associations with inheritance stop unwrapping proxy — handleLazyProxy line accidentally commented out
apache/grails-data-mapping#1104 Grails 3.3 (GORM 6.1.10) 2018 getPropertyType returns Object for to-one associations — proxy wrapping causes type resolution to return Object (direct parallel to this PR)
apache/grails-data-mapping#1112 Grails 3.3 (GORM 6.1.11) 2018 Embedded types also wrapped by proxy closures — regression introduced by the #1072 fix, in the very next point release
apache/grails-data-mapping#1131 Grails 3.x (GORM 6.0.12) Aug 2018 StackOverflowError during save (proxy-related)
apache/grails-data-mapping#1156 Grails 3.x Feb 2019 Add mapping attribute to skip proxy auto-unwrapping — workaround added because unwrapping couldn't be made safe universally
apache/grails-data-mapping#938 Grails 4.0 (GORM 7.0.0) 2019 Remove proxy meta-programming from HibernateUtil — startup perf and memory problems caused by the proxy approach
apache/grails-data-mapping#1263 / #1276 Grails 4.0 (GORM 7.0.3) 2019 First UniqueConstraint + proxy fix
apache/grails-data-mapping#1287 Grails 4.0 (GORM 7.0.4) Apr 2020 UniqueConstraint failing with uninitialized proxies on Neo4j — same datastore category (non-Hibernate, GroovyProxyFactory) and same constraint validation failure mode as this PR; patched at the UniqueConstraint call site rather than in GroovyProxyFactory
apache/grails-data-mapping#1294 Grails 4.0 (GORM 7.0.5) May 2020 OneToOne proxies incorrectly marked dirty
apache/grails-data-mapping#1307 Grails 4.0 (GORM 7.0.5) May 2020 Domain classes marked dirty without changes — proxy $changedProperties sync broken
afd8bcd738 Grails 4.x (GORM 7.x) Oct 2020 Third UniqueConstraint + proxy patch — guard added to processValidate for proxy propertyValue
apache/grails-data-mapping#1468 Grails 4.0.10 (GORM 7.0.5) Jul 2021 handleLazyProxy commented out again — identical regression to #1072 reintroduced in Grails 4, two major versions later
grails/grails-data-hibernate5#464 Grails 5.x 2022 HibernateProxyHandler relied on Javassist removed in Hibernate 5.6 — required proxy handler rewrite
grails/grails-data-hibernate5#624 Grails 5.x / 6.x 2022 ByteBuddy proxy strategy introduced, requiring a new proxy handling layer
apache/grails-data-mapping#1830 Grails 6.x / 7.x Nov 2024 Evaluating whether to add automatic proxy unwrapping back for Grails 7 upgrade path — closed as "not planned"
apache/grails-data-mapping#1949 Grails 7.x 2025 NoClassDefFoundError: HibernateProxyHandler — class moved, callers broken

Two patterns from this history are directly relevant here:

The test doesn't cover the actual failure path

The new GroovyProxyFactorySpec tests getProxiedClass() in isolation, which is a reasonable unit test. But the claimed bug is an NPE in UniqueConstraint.processValidate during cascade validation on a non-request thread. There is no test that exercises PersistentEntityValidator.cascadeValidationToOneUniqueConstraint.processValidate(target=proxy)mappingContext.getPersistentEntity(getProxiedClass(target).getName()) end-to-end. This is exactly the pattern that produced the apache/grails-data-mapping#1104#1112 chain: the unit-level fix was correct, but a related code path was affected in a way the isolated test couldn't catch.

Blast radius concern for 7.x

GroovyProxyFactory is the proxy strategy for all non-Hibernate datastores (MongoDB, Neo4j, simple datastore used in GORM unit tests). getProxiedClass() is consumed from UniqueConstraint.processValidate line 81, but also potentially other callers. The simple datastore underpins the entire GORM unit test suite — subtle behavioral changes there won't be caught without running integration tests against real datastores. apache/grails-data-mapping#938 is a reminder that even removing proxy meta-programming — a clearly beneficial cleanup — required its own dedicated milestone to land safely.

What would make me more comfortable merging this in 7.x

Either of these would address the concern:

  1. An integration test that triggers cascade validation through PersistentEntityValidator with a GroovyProxyFactory proxy as the target entity — the simple datastore (no Docker required) would suffice — and confirms the NPE no longer occurs. The TCK in grails-datamapping-core-test already runs GroovyProxySpec with useGroovyProxyFactory: true/false per the PR description; if it already covers this path, pointing to the specific test would close the concern.

  2. Alternatively, a targeted fix at the call site: UniqueConstraint.processValidate line 81 calls getProxiedClass(target) — unwrapping target there (mirroring what afd8bcd738 did for propertyValue at line 121 to address UniqueConstraint is failing with target which arge ninitialized proxies grails-data-mapping#1287) fixes the specific crash path without touching GroovyProxyFactory itself for 7.x. The core fix can still go into 8.x where the broader blast radius is more acceptable.

Can you confirm whether GroovyProxySpec in grails-datamapping-core-test actually exercises UniqueConstraint.processValidate with a proxy as target, or whether the 568 MongoDB test runs include a cascade-validation scenario with an unwrapped association proxy?

@jdaugherty
Copy link
Copy Markdown
Contributor

I used AI to post this last comment to show why I'm so concerned about this change. I used it to research the history of changes here and why I think this risk is too great for 7.x.

@codeconsole
Copy link
Copy Markdown
Contributor Author

I used AI to post this last comment to show why I'm so concerned about this change. I used it to research the history of changes here and why I think this risk is too great for 7.x.

but this is targeting 7.2.x and we can roll out a 7.2.0-M1 to start.

@jdaugherty
Copy link
Copy Markdown
Contributor

You're assuming we can test all of this, see the above comment where we've tried this exact fix before and had to roll it back.

@codeconsole
Copy link
Copy Markdown
Contributor Author

You're assuming we can test all of this, see the above comment where we've tried this exact fix before and had to roll it back.

Well I can state with certainty that it is clearly broken in 7.x. I have to run a custom built jar in my app as a workaround.

This fix in in 7.2.x If we can't test it in 7.2.x, how are we going to test it in 8.0.x if 8.0.x is going to be released before 7.2.x ?

@jdaugherty
Copy link
Copy Markdown
Contributor

@codeconsole I'm not proposing we don't fix it in 8.x. I'm first wanting a reproducer that isn't a unit test & an isolated (mongo) only fix in 7.x. This way the risk is low.

@matrei
Copy link
Copy Markdown
Contributor

matrei commented May 12, 2026

@codeconsole grails-datastore-core is pulling in org.javassist:javassist.
How come you don't get that dependency on your apps runtime classpath?

@codeconsole
Copy link
Copy Markdown
Contributor Author

@codeconsole grails-datastore-core is pulling in org.javassist:javassist. How come you don't get that dependency on your apps runtime classpath?

@matrei good find, thanks

Here was the issue

configurations {
    all {
        exclude group: "org.apache.grails.data", module: "grails-datastore-core"
        exclude group: "org.apache.grails.data", module: "grails-data-mongodb-bson"
        exclude group: "org.apache.grails.data", module: "grails-data-mongodb-core"
    }
}

List<String> extLibs = [
    'lib/grails-datastore-core-7.1.1-SNAPSHOT.jar',
    'lib/grails-data-mongodb-bson-7.1.1-SNAPSHOT.jar',
    'lib/grails-data-mongodb-core-7.1.1-SNAPSHOT.jar'
}

I wasn't resolving org.javassist:javassist because I was running a custom datastore-core, but the issue still stands that this fails when javaassist is not in the class path.

AbstractMappingContext.getProxyFactory() picks the proxy implementation by classpath probe:

if (ClassUtils.isPresent("javassist.util.proxy.ProxyFactory", classLoader)) {
    proxyFactory = DefaultProxyFactoryCreator.create();      // → JavassistProxyFactory
}
else if (ClassUtils.isPresent("org.grails.datastore.gorm.proxy.GroovyProxyFactory", ...)) {
    proxyFactory = new GroovyProxyFactory();                 // ← fallback which results in failure
}

So the question is do we want to address the edge case when javassist is not in the class path even though it is a transitive dependency of grails-datastore-core ?

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.

3 participants