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

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

Summary

GroovyProxyFactory.getProxiedClass() returns java.lang.Object for proxies that this same factory created, instead of the proxied entity class. Any caller that feeds the result into MappingContext.getPersistentEntity(name) then receives null and NPEs on the next dereference. The most user-visible symptom is a cascade-validation NullPointerException from UniqueConstraint.processValidate when saving an entity whose to-one association is a not-yet-unwrapped proxy — happens in production on MongoDB / Neo4j stacks (any datastore whose classpath does not pull Javassist).

Root cause

GroovyProxyFactory and JavassistProxyFactory use different proxy strategies, but they ship the same getProxiedClass() implementation:

Class<?> getProxiedClass(Object o) {
    if (isProxy(o)) {
        return o.getClass().getSuperclass()
    }
    return o.getClass()
}
Factory What createProxy() returns proxy.getClass() is proxy.getClass().getSuperclass() is
JavassistProxyFactory a 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 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 — to java.lang.Object for any entity extending Object directly, which is essentially all of them.

Why this surfaces on NoSQL stacks

AbstractMappingContext.getProxyFactory() picks the factory by classpath probe:

if (ClassUtils.isPresent("javassist.util.proxy.ProxyFactory", classLoader)) {
    proxyFactory = DefaultProxyFactoryCreator.create();   // → JavassistProxyFactory
}
else if (ClassUtils.isPresent(GROOVY_PROXY_FACTORY_NAME, classLoader)) {
    proxyFactory = new GroovyProxyFactory();              // ← fallback
}

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.

Concrete reproduction trace

The bug was first surfaced in a Grails 7 application using the MongoDB plugin. Saving a child entity from a non-request thread (no GORM session bound) triggered:

java.lang.NullPointerException: Cannot invoke
  "org.grails.datastore.mapping.model.PersistentEntity.isRoot()"
  because the return value of "groovy.lang.Reference.get()" is null
    at org.grails.datastore.gorm.validation.constraints.builtin
        .UniqueConstraint.processValidate(UniqueConstraint.groovy:85)
    at grails.gorm.validation.PersistentEntityValidator
        .cascadeValidationToOne(PersistentEntityValidator.groovy:266)
    at grails.gorm.validation.PersistentEntityValidator
        .cascadeToAssociativeProperty(PersistentEntityValidator.groovy:136)
    at grails.gorm.validation.PersistentEntityValidator
        .validate(PersistentEntityValidator.groovy:102)
    ...
    at <Domain>.save(...)

Diagnostic logging captured at the crash site confirmed the chain end-to-end:

proxyHandler.class                = org.grails.datastore.gorm.proxy.GroovyProxyFactory
proxyHandler.isProxy(proj)        = true
proj.class                        = com.example.IssueProject     // real entity instance
proj.class.is(IssueProject)       = true
proxiedClass                      = java.lang.Object             // ← bug: should be IssueProject
ctx.getPersistentEntity(...)      = null                         // ← lookup misses
ctx.persistentEntities count      = 79
registered IssueProject entries   = [IssueProject]               // entity IS registered

Same data on the request thread (where the proxy gets unwrapped before reaching the validator) saved cleanly with cascade validation enabled. After applying this fix, the same async-thread save also succeeds.

Fix

Drop the bogus superclass walk:

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

o.getClass() is the correct answer for both branches:

  • For metaclass-only proxies: o.getClass() is the entity class.
  • For non-proxy instances: o.getClass() is the entity class.

The isProxy(o) branch becomes a no-op for the case it's currently meant to handle, so it has been removed entirely. The duplicate @Override annotation that was already on the method has also been removed (no behavior change, just cleanup).

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.

Both cases mirror what GroovyProxyFactory.createProxy() does internally (real entity instance + ProxyInstanceMetaClass attached), so they exercise the same shape MappingContext produces 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 simple datastore — runs GroovyProxySpec for both useGroovyProxyFactory: true and false) 474 0
grails-datamapping-validation, tck, support, async passing 0
grails-datastore-core (incl. JavassistProxyFactorySpec) passing 0
grails-datastore-web passing 0
grails-data-mongodb-core (real Mongo, full GORM cascade + proxy paths) 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 across the data-mapping, datastore, MongoDB, and Hibernate test suites. The fix is in the agnostic core (grails-datamapping-core), so all consumers of GroovyProxyFactory benefit.

…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?

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