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) {