codeconsole opened a new pull request, #15650:
URL: https://github.com/apache/grails-core/pull/15650

   # 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:
   
   ```groovy
   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:
   
   ```java
   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:
   
   ```diff
        @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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to