fix(grails-datamapping-core): GroovyProxyFactory.getProxiedClass returns Object instead of entity class #15650
Conversation
…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.
✅ All tests passed ✅🏷️ Commit: ac24d22 Learn more about TestLens at testlens.app. |
|
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. |
To me this looks like a bug fix for GroovyProxyFactory:
|
|
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 |
|
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. |
|
The root cause analysis here is correct — for My concern is the risk profile of this change landing in History of proxy regressions in this area This is the fourth patch to the
Two patterns from this history are directly relevant here:
The test doesn't cover the actual failure path The new Blast radius concern for 7.x
What would make me more comfortable merging this in 7.x Either of these would address the concern:
Can you confirm whether |
|
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. |
|
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 ? |
|
@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. |
|
@codeconsole |
fix(grails-datamapping-core): GroovyProxyFactory.getProxiedClass walks past entity to Object
Summary
GroovyProxyFactory.getProxiedClass()returnsjava.lang.Objectfor proxies that this same factory created, instead of the proxied entity class. Any caller that feeds the result intoMappingContext.getPersistentEntity(name)then receivesnulland NPEs on the next dereference. The most user-visible symptom is a cascade-validationNullPointerExceptionfromUniqueConstraint.processValidatewhen 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
GroovyProxyFactoryandJavassistProxyFactoryuse different proxy strategies, but they ship the samegetProxiedClass()implementation:createProxy()returnsproxy.getClass()isproxy.getClass().getSuperclass()isJavassistProxyFactoryProxy_$$_javassist extends EntityGroovyProxyFactoryEntityinstance with aProxyInstanceMetaClassattachedjava.lang.Object— wrongThe
getSuperclass()walk is correct for Javassist's runtime-subclass strategy. Copy-pasted intoGroovyProxyFactory, 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 — tojava.lang.Objectfor any entity extendingObjectdirectly, which is essentially all of them.Why this surfaces on NoSQL stacks
AbstractMappingContext.getProxyFactory()picks the factory by classpath probe:Hibernate / SQL stacks pull Javassist transitively, so they get
JavassistProxyFactoryand never exercise the buggy code path. MongoDB and Neo4j do not pull Javassist, so they fall through toGroovyProxyFactoryand hit the bug whenever a proxy reaches a constraint validator (or any other consumer ofProxyHandler.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:
Diagnostic logging captured at the crash site confirmed the chain end-to-end:
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:
o.getClass()is the correct answer for both branches:o.getClass()is the entity class.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@Overrideannotation 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.groovywith two cases:getProxiedClassreturns the entity class for a proxy created by this factory — fails on the unmodified code (getProxiedClass(proxy)returnsjava.lang.Object), passes after the fix.getProxiedClassreturns 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 +ProxyInstanceMetaClassattached), so they exercise the same shapeMappingContextproduces at runtime.Verification
Test totals on the patched code (
groovy-proxy-getproxiedclass-fixbranch off7.2.x):grails-datamapping-core(incl. newGroovyProxyFactorySpec)grails-datamapping-core-test(TCK against simple datastore — runsGroovyProxySpecfor bothuseGroovyProxyFactory: trueandfalse)grails-datamapping-validation,tck,support,asyncgrails-datastore-core(incl.JavassistProxyFactorySpec)grails-datastore-webgrails-data-mongodb-core(real Mongo, full GORM cascade + proxy paths)grails-data-mongodb-bsongrails-data-hibernate5/core(Javassist path — sanity check that the alternate proxy strategy is unaffected)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 ofGroovyProxyFactorybenefit.