You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/06/17 14:52:02 UTC

(grails-core) branch test/h7-functional-coverage updated: Fix Hibernate 7 proxy and duplicate-entity id rendering in JSON views

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch test/h7-functional-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git


The following commit(s) were added to refs/heads/test/h7-functional-coverage by this push:
     new cc6acc3804 Fix Hibernate 7 proxy and duplicate-entity id rendering in JSON views
cc6acc3804 is described below

commit cc6acc3804e921576d9f9a816442ca2c994a1aad
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Wed Jun 17 10:51:24 2026 -0400

    Fix Hibernate 7 proxy and duplicate-entity id rendering in JSON views
    
    When a JSON view renders a domain entity loaded via a Hibernate proxy, or the
    same entity instance more than once in an object graph, Hibernate 7 dropped the
    entity id (and rendered a duplicate as an empty object), while Hibernate 5
    rendered it correctly. Two independent causes:
    
    - DefaultJsonViewHelper.findEntity used object.getClass(), which for a Hibernate
      proxy is the generated subclass (Player$HibernateProxy$xxx) and is not a
      registered persistent entity. findEntity returned null and the proxy was
      rendered as a plain POGO (via processSimple, losing its id) instead of as a
      domain entity. Unwrap the proxy before resolving the entity class.
    
    - DefaultGrailsJsonViewHelper.preProcessedOutput returned a cached JsonWritable
      for any object already seen. That writable is bound to the first occurrence's
      builder delegate, path and deep state, so reusing it for a later occurrence
      (for example the same player as both a team's captain and a member of its
      players collection) rendered the wrong context and dropped the id. Render each
      occurrence fresh; true cyclic associations are still broken earlier in
      process() by the circular-association handling, so this does not recurse.
    
    Verified by the full grails-views-gson unit suite (175 tests) and the Hibernate 7
    views-functional-tests integration suite (52 tests, including the previously
    failing TeamSpec deep-association and composite-id cases); Hibernate 5 behaviour
    is unchanged.
    
    Assisted-by: claude-code:claude-4.8-opus
---
 .../view/api/internal/DefaultGrailsJsonViewHelper.groovy    | 13 +++++--------
 .../json/view/api/internal/DefaultJsonViewHelper.groovy     |  8 +++++++-
 2 files changed, 12 insertions(+), 9 deletions(-)

diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultGrailsJsonViewHelper.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultGrailsJsonViewHelper.groovy
index 385d009ba4..a95969b8de 100644
--- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultGrailsJsonViewHelper.groovy
+++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultGrailsJsonViewHelper.groovy
@@ -115,18 +115,15 @@ class DefaultGrailsJsonViewHelper extends DefaultJsonViewHelper implements Grail
 
     private JsonOutput.JsonWritable preProcessedOutput(Object object, Map<Object, JsonOutput.JsonWritable> processedObjects) {
         JsonView jsonView = (JsonView) view
-        boolean rootRender = processedObjects.isEmpty()
         object = jsonView.proxyHandler?.unwrapIfProxy(object) ?: object
         if (object == null) {
             return NULL_OUTPUT
         }
-
-        if (!rootRender && processedObjects.containsKey(object)) {
-            def existingOutput = processedObjects.get(object)
-            if (!NULL_OUTPUT.equals(existingOutput)) {
-                return existingOutput
-            }
-        }
+        // Each occurrence of an entity in the object graph must be rendered fresh in its own
+        // context so it gets its own id (a previously cached JsonWritable is bound to the first
+        // occurrence's delegate/path/deep state and would drop the id or render an empty object
+        // for siblings or proxy-loaded instances). True cyclic associations are broken earlier
+        // by the circular-association handling in process(), so this never recurses infinitely.
         return null
     }
 
diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultJsonViewHelper.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultJsonViewHelper.groovy
index ce5fe6181e..5407b8080e 100644
--- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultJsonViewHelper.groovy
+++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultJsonViewHelper.groovy
@@ -95,7 +95,13 @@ class DefaultJsonViewHelper extends DefaultGrailsViewHelper {
     }
 
     protected PersistentEntity findEntity(Object object) {
-        def clazz = object.getClass()
+        // A Hibernate proxy reports its generated subclass (e.g. Player$HibernateProxy$xxx) from
+        // getClass(), which is not a registered persistent entity. Resolve the real entity class
+        // by unwrapping the proxy first, otherwise the proxy renders as a plain POGO (losing its
+        // id) instead of as a domain entity.
+        def proxyHandler = ((JsonView) view)?.proxyHandler
+        Object target = (proxyHandler != null && proxyHandler.isProxy(object)) ? proxyHandler.unwrapIfProxy(object) : object
+        def clazz = (target != null ? target : object).getClass()
         try {
             return GormEnhancer.findEntity(clazz)
         } catch (Throwable e) {