From 4bc7f5c2675abb8b08205b9a9ad955badc3e8dae Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 6 Jul 2026 18:16:35 -0400 Subject: [PATCH 1/5] Various fixes for not unwrapping Hibernate proxies --- grails-data-hibernate5/core/build.gradle | 5 - .../HibernateMappingContextConfiguration.java | 9 ++ .../proxy/ByteBuddyGroovyInterceptor.java | 84 ++++++++++++ .../proxy/ByteBuddyGroovyProxyFactory.java | 113 ++++++++++++++++ .../proxy/GrailsBytecodeProvider.java | 38 ++++++ .../proxy/GrailsProxyFactoryFactory.java | 64 +++++++++ .../proxy/GroovyProxyInterceptorLogic.java | 125 ++++++++++++++++++ .../proxy/HibernateProxyHandler.java | 6 + .../tests/proxy/ByteBuddyProxySpec.groovy | 50 +++++-- ...rnateProxyHandlerMetaClassProbeSpec.groovy | 109 +++++++++++++++ grails-data-hibernate7/core/build.gradle | 6 - .../proxy/HibernateProxyHandler.java | 19 +-- .../tests/proxy/ByteBuddyProxySpec.groovy | 46 +++++-- ...rnateProxyHandlerMetaClassProbeSpec.groovy | 111 ++++++++++++++++ 14 files changed, 745 insertions(+), 40 deletions(-) create mode 100644 grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java create mode 100644 grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java create mode 100644 grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsBytecodeProvider.java create mode 100644 grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java create mode 100644 grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java create mode 100644 grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy create mode 100644 grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy diff --git a/grails-data-hibernate5/core/build.gradle b/grails-data-hibernate5/core/build.gradle index f38cf2b4a78..b087b8fc816 100644 --- a/grails-data-hibernate5/core/build.gradle +++ b/grails-data-hibernate5/core/build.gradle @@ -73,11 +73,6 @@ dependencies { testImplementation 'org.apache.groovy:groovy-json' testImplementation 'org.apache.tomcat:tomcat-jdbc' testImplementation 'org.spockframework:spock-core' - testImplementation 'org.yakworks:hibernate-groovy-proxy', { - // groovy proxy fixes bytebuddy to be a bit smarter when it comes to groovy metaClass - exclude group: 'org.codehaus.groovy', module: 'groovy' - } - testRuntimeOnly 'org.hibernate:hibernate-ehcache', { // exclude javax variant of hibernate-core 5.6 exclude group: 'org.hibernate', module: 'hibernate-core' diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java index 16e1185d3f6..045c7e40f78 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java @@ -48,6 +48,7 @@ import org.hibernate.boot.registry.classloading.spi.ClassLoaderService; import org.hibernate.boot.registry.selector.spi.StrategySelector; import org.hibernate.boot.spi.MetadataContributor; +import org.hibernate.bytecode.spi.ProxyFactoryFactory; import org.hibernate.cfg.AvailableSettings; import org.hibernate.cfg.Configuration; import org.hibernate.cfg.Environment; @@ -77,6 +78,7 @@ import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.orm.hibernate.EventListenerIntegrator; import org.grails.orm.hibernate.GrailsSessionContext; +import org.grails.orm.hibernate.proxy.GrailsBytecodeProvider; import org.grails.orm.hibernate.HibernateEventListeners; import org.grails.orm.hibernate.MetadataIntegrator; import org.grails.orm.hibernate.access.TraitPropertyAccessStrategy; @@ -315,6 +317,13 @@ public void sessionFactoryClosed(SessionFactory factory) { StandardServiceRegistryBuilder standardServiceRegistryBuilder = createStandardServiceRegistryBuilder(bootstrapServiceRegistry) .applySettings(getProperties()); + // Groovy-aware entity proxies: without this, any Groovy MOP dispatch through a proxy + // (proxy.id, getMetaClass(), truthy checks) initializes it. PojoEntityTuplizer resolves + // the ProxyFactoryFactory from the service registry, so a provided service wins over the + // default initiator. Hibernate 7 gets the same behavior via GrailsBytecodeProvider. + standardServiceRegistryBuilder.addService(ProxyFactoryFactory.class, + new GrailsBytecodeProvider().getProxyFactoryFactory()); + StandardServiceRegistry serviceRegistry = standardServiceRegistryBuilder.build(); sessionFactory = super.buildSessionFactory(serviceRegistry); this.serviceRegistry = serviceRegistry; diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java new file mode 100644 index 00000000000..00224acb78b --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy; + +import java.io.Serializable; +import java.lang.reflect.Method; + +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor; +import org.hibernate.type.CompositeType; + +/** + * A ByteBuddy interceptor that avoids initializing the proxy for Groovy-specific methods. + * Mirrors the Hibernate 7 implementation in grails-data-hibernate7. + * + * @since 8.0 + */ +public class ByteBuddyGroovyInterceptor extends ByteBuddyInterceptor { + + private static final String GET_ID_METHOD = "getId"; + private static final String GET_IDENTIFIER_METHOD = "getIdentifier"; + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public ByteBuddyGroovyInterceptor( + String entityName, + Class persistentClass, + Class[] interfaces, + Serializable id, + Method getIdentifierMethod, + Method setIdentifierMethod, + CompositeType componentIdType, + SharedSessionContractImplementor session, + boolean overridesEquals) { + super( + entityName, + (Class) persistentClass, + (Class[]) interfaces, + id, + getIdentifierMethod, + setIdentifierMethod, + componentIdType, + session, + overridesEquals); + } + + @Override + public Object intercept(Object proxy, Method method, Object[] args) throws Throwable { + String methodName = method.getName(); + + // Answer identifier access from the LazyInitializer so it never initializes the proxy + if ((getIdentifierMethod != null && methodName.equals(getIdentifierMethod.getName())) || + GET_ID_METHOD.equals(methodName) || + GET_IDENTIFIER_METHOD.equals(methodName)) { + return getIdentifier(); + } + + if (isUninitialized()) { + GroovyProxyInterceptorLogic.InterceptorState state = new GroovyProxyInterceptorLogic.InterceptorState( + getEntityName(), persistentClass, getIdentifier()); + Object result = GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, args); + if (result != GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION) { // NOPMD: sentinel comparison + return result; + } + } + + return super.intercept(proxy, method, args); + } +} diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java new file mode 100644 index 00000000000..1b4fbdace37 --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy; + +import java.io.Serial; +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.Set; + +import org.hibernate.HibernateException; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.internal.util.ReflectHelper; +import org.hibernate.proxy.HibernateProxy; +import org.hibernate.proxy.ProxyConfiguration; +import org.hibernate.proxy.pojo.bytebuddy.ByteBuddyProxyFactory; +import org.hibernate.proxy.pojo.bytebuddy.ByteBuddyProxyHelper; +import org.hibernate.type.CompositeType; + +import static org.hibernate.internal.util.collections.ArrayHelper.EMPTY_CLASS_ARRAY; + +/** + * A ProxyFactory implementation for ByteBuddy that uses {@link ByteBuddyGroovyInterceptor}. + * Mirrors the Hibernate 7 implementation in grails-data-hibernate7. + * + * @since 8.0 + */ +public class ByteBuddyGroovyProxyFactory extends ByteBuddyProxyFactory { + + @Serial + private static final long serialVersionUID = 1L; + + private final transient ByteBuddyProxyHelper byteBuddyProxyHelper; + private Class persistentClass; + private String entityName; + private Class[] interfaces; + private transient Method getIdentifierMethod; + private transient Method setIdentifierMethod; + private transient CompositeType componentIdType; + private boolean overridesEquals; + private Class proxyClass; + + public ByteBuddyGroovyProxyFactory(ByteBuddyProxyHelper byteBuddyProxyHelper) { + super(byteBuddyProxyHelper); + this.byteBuddyProxyHelper = byteBuddyProxyHelper; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void postInstantiate( + String entityName, + Class persistentClass, + Set interfaces, + Method getIdentifierMethod, + Method setIdentifierMethod, + CompositeType componentIdType) + throws HibernateException { + this.entityName = entityName; + this.persistentClass = persistentClass; + this.interfaces = interfaces == null ? EMPTY_CLASS_ARRAY : (Class[]) interfaces.toArray(EMPTY_CLASS_ARRAY); + this.getIdentifierMethod = getIdentifierMethod; + this.setIdentifierMethod = setIdentifierMethod; + this.componentIdType = componentIdType; + this.overridesEquals = ReflectHelper.overridesEquals(persistentClass); + + this.proxyClass = byteBuddyProxyHelper.buildProxy(persistentClass, this.interfaces); + + // do NOT call super.postInstantiate: the stock factory keeps private state for its own + // getProxy(), which is fully replaced here + } + + @Override + public HibernateProxy getProxy(Serializable id, SharedSessionContractImplementor session) throws HibernateException { + try { + final ByteBuddyGroovyInterceptor interceptor = new ByteBuddyGroovyInterceptor( + entityName, + persistentClass, + interfaces, + id, + getIdentifierMethod, + setIdentifierMethod, + componentIdType, + session, + overridesEquals); + + final HibernateProxy hibernateProxy = + (HibernateProxy) proxyClass.getDeclaredConstructor().newInstance(); + + if (hibernateProxy instanceof ProxyConfiguration) { + ((ProxyConfiguration) hibernateProxy).$$_hibernate_set_interceptor(interceptor); + } + + return hibernateProxy; + } catch (Exception e) { + throw new HibernateException("Unable to generate proxy for " + entityName, e); + } + } +} diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsBytecodeProvider.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsBytecodeProvider.java new file mode 100644 index 00000000000..3785a72e1bb --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsBytecodeProvider.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy; + +import org.hibernate.bytecode.internal.bytebuddy.BytecodeProviderImpl; +import org.hibernate.bytecode.spi.ProxyFactoryFactory; + +/** + * A {@link org.hibernate.bytecode.spi.BytecodeProvider} for Hibernate 5 that provides + * Groovy-aware entity proxies via {@link ByteBuddyGroovyProxyFactory}. Extends the stock + * ByteBuddy provider so enhancement and reflection optimization behave exactly as standard + * Hibernate. Mirrors the Hibernate 7 implementation in grails-data-hibernate7. + * + * @since 8.0 + */ +public class GrailsBytecodeProvider extends BytecodeProviderImpl { + + @Override + public ProxyFactoryFactory getProxyFactoryFactory() { + return new GrailsProxyFactoryFactory(this, super.getProxyFactoryFactory()); + } +} diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java new file mode 100644 index 00000000000..0cb77ba71a2 --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy; + +import java.io.Serial; + +import org.hibernate.bytecode.spi.BasicProxyFactory; +import org.hibernate.bytecode.spi.ProxyFactoryFactory; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.proxy.ProxyFactory; + +/** + * A {@link ProxyFactoryFactory} implementation for Hibernate 5 that provides Groovy-aware + * entity proxies. Basic (non-entity) proxies are delegated to the stock implementation. + * Mirrors the Hibernate 7 implementation in grails-data-hibernate7. + * + * @since 8.0 + */ +public class GrailsProxyFactoryFactory implements ProxyFactoryFactory, java.io.Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final GrailsBytecodeProvider grailsBytecodeProvider; + private final transient ProxyFactoryFactory basicProxyDelegate; + + public GrailsProxyFactoryFactory(GrailsBytecodeProvider grailsBytecodeProvider, ProxyFactoryFactory basicProxyDelegate) { + this.grailsBytecodeProvider = grailsBytecodeProvider; + this.basicProxyDelegate = basicProxyDelegate; + } + + @Override + public ProxyFactory buildProxyFactory(SessionFactoryImplementor sessionFactory) { + return new ByteBuddyGroovyProxyFactory(grailsBytecodeProvider.getByteBuddyProxyHelper()); + } + + @Override + @SuppressWarnings("rawtypes") + public BasicProxyFactory buildBasicProxyFactory(Class superClass, Class[] interfaces) { + return basicProxyDelegate.buildBasicProxyFactory(superClass, interfaces); + } + + @Override + @SuppressWarnings("rawtypes") + public BasicProxyFactory buildBasicProxyFactory(Class superClassOrInterface) { + return basicProxyDelegate.buildBasicProxyFactory(superClassOrInterface); + } +} diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java new file mode 100644 index 00000000000..1701c3c037b --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy; + +import java.io.Serializable; + +import groovy.lang.GroovyObject; +import groovy.lang.MetaClass; +import org.codehaus.groovy.runtime.HandleMetaClass; +import org.codehaus.groovy.runtime.InvokerHelper; + +import org.grails.datastore.gorm.proxy.ProxyInstanceMetaClass; + +/** + * Pure logic for Groovy proxy interception and handling, decoupled from Hibernate. + * + * @author Graeme Rocher + * @since 8.0 + */ +public class GroovyProxyInterceptorLogic { + + public static final Object INVOKE_IMPLEMENTATION = new Object(); + + private static final String GET_META_CLASS = "getMetaClass"; + private static final String SET_META_CLASS = "setMetaClass"; + private static final String META_CLASS_PROPERTY = "metaClass"; + private static final String GET_PROPERTY = "getProperty"; + private static final String ID_PROPERTY = "id"; + private static final String IDENT_METHOD = "ident"; + private static final String IS_DIRTY = "isDirty"; + private static final String HAS_CHANGED = "hasChanged"; + private static final String TO_STRING = "toString"; + + public static Object handleUninitialized(InterceptorState state, String methodName, Object... args) { + if ((GET_META_CLASS.equals(methodName) || methodName.endsWith("getStaticMetaClass")) && + (args == null || args.length == 0)) { + return InvokerHelper.getMetaClass(state.persistentClass()); + } + if (GET_PROPERTY.equals(methodName) && args.length == 1) { + if (ID_PROPERTY.equals(args[0])) { + return state.identifier(); + } + if (META_CLASS_PROPERTY.equals(args[0])) { + return InvokerHelper.getMetaClass(state.persistentClass()); + } + } + if (IDENT_METHOD.equals(methodName) && (args == null || args.length == 0)) { + return state.identifier(); + } + if ((IS_DIRTY.equals(methodName) || HAS_CHANGED.equals(methodName)) && (args == null || args.length == 0)) { + return false; + } + if (TO_STRING.equals(methodName) && (args == null || args.length == 0)) { + return state.entityName() + ":" + state.identifier(); + } + return INVOKE_IMPLEMENTATION; + } + + public static boolean isGroovyMethod(String methodName) { + return "getMetaClass".equals(methodName) || + "setMetaClass".equals(methodName) || + "getProperty".equals(methodName) || + "setProperty".equals(methodName) || + "invokeMethod".equals(methodName); + } + + public static ProxyInstanceMetaClass getProxyInstanceMetaClass(Object o) { + if (o instanceof GroovyObject go) { + MetaClass mc = go.getMetaClass(); + if (mc instanceof HandleMetaClass hmc) { + mc = hmc.getAdaptee(); + } + if (mc instanceof ProxyInstanceMetaClass pmc) { + return pmc; + } + } + return null; + } + + public static Object unwrap(Object object) { + ProxyInstanceMetaClass proxyMc = getProxyInstanceMetaClass(object); + if (proxyMc != null) { + return proxyMc.getProxyTarget(); + } + return null; + } + + public static Serializable getIdentifier(Object o) { + ProxyInstanceMetaClass proxyMc = getProxyInstanceMetaClass(o); + if (proxyMc != null) { + return proxyMc.getKey(); + } + return null; + } + + /** + * Check if a Groovy proxy is initialized. + * @return {@code true} if initialized, {@code false} if not initialized, or {@code null} if the object is not a Groovy proxy + */ + public static Boolean isInitialized(Object o) { + ProxyInstanceMetaClass proxyMc = getProxyInstanceMetaClass(o); + if (proxyMc != null) { + return proxyMc.isProxyInitiated(); + } + return null; + } + + public record InterceptorState(String entityName, Class persistentClass, Object identifier) {} +} diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java index fc4fa25e672..50c670ace10 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java @@ -181,6 +181,12 @@ public void initialize(Object o) { } private ProxyInstanceMetaClass getProxyInstanceMetaClass(Object o) { + if (o instanceof HibernateProxy) { + // A Hibernate proxy never carries a ProxyInstanceMetaClass, and calling getMetaClass() + // on one is intercepted like any other method, initializing the proxy (or throwing + // LazyInitializationException when detached) before the identifier shortcut can apply + return null; + } if (o instanceof GroovyObject) { MetaClass mc = ((GroovyObject) o).getMetaClass(); if (mc instanceof HandleMetaClass) { diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy index 1966d356b79..81c3ea37518 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy @@ -22,9 +22,7 @@ import grails.gorm.tests.entities.Club import grails.gorm.tests.entities.Team import org.apache.grails.data.hibernate5.core.GrailsDataHibernate5TckManager import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec -import org.grails.datastore.mapping.reflect.ClassUtils import org.grails.orm.hibernate.proxy.HibernateProxyHandler -import spock.lang.PendingFeatureIf import spock.lang.Shared /** @@ -39,10 +37,6 @@ class ByteBuddyProxySpec extends GrailsDataTckSpecAll interaction with the fake proxy happens in {@link StaticProbe} under + * {@code @CompileStatic}: dynamic Groovy dispatch itself resolves methods through + * {@code getMetaClass()}, so touching the proxy from dynamic spec code would trip the + * trap before the handler is even involved. + */ +class HibernateProxyHandlerMetaClassProbeSpec extends Specification { + + void "getIdentifier reads the identifier from the LazyInitializer without dispatching through the proxy"() { + given: + LazyInitializer initializer = Stub(LazyInitializer) { + getIdentifier() >> 42L + isUninitialized() >> true + } + + expect: + StaticProbe.identifierOf(initializer) == 42L + } + + void "isProxy and isInitialized do not dispatch through the proxy"() { + given: + LazyInitializer initializer = Stub(LazyInitializer) { + isUninitialized() >> true + } + + expect: + StaticProbe.isProxy(initializer) + !StaticProbe.isInitialized(initializer) + } + + @CompileStatic + static class StaticProbe { + + static Serializable identifierOf(LazyInitializer initializer) { + new HibernateProxyHandler().getIdentifier(new MetaClassSensitiveProxy(initializer)) + } + + static boolean isProxy(LazyInitializer initializer) { + new HibernateProxyHandler().isProxy(new MetaClassSensitiveProxy(initializer)) + } + + static boolean isInitialized(LazyInitializer initializer) { + new HibernateProxyHandler().isInitialized(new MetaClassSensitiveProxy(initializer)) + } + } + + /** + * Simulates an uninitialized Hibernate proxy of a Groovy entity: any method dispatched + * through the proxy instance - getMetaClass() included - is intercepted and would + * initialize it, so this stand-in throws where a real detached proxy would throw + * LazyInitializationException. + */ + @CompileStatic + static class MetaClassSensitiveProxy implements HibernateProxy, GroovyObject { + + private final LazyInitializer initializer + + MetaClassSensitiveProxy(LazyInitializer initializer) { + this.initializer = initializer + } + + @Override + LazyInitializer getHibernateLazyInitializer() { + initializer + } + + @Override + Object writeReplace() { + this + } + + @Override + MetaClass getMetaClass() { + throw new IllegalStateException('getMetaClass() was dispatched through the proxy - a real proxy would initialize here') + } + } +} diff --git a/grails-data-hibernate7/core/build.gradle b/grails-data-hibernate7/core/build.gradle index a895f18d2c8..a9d7a481ac9 100644 --- a/grails-data-hibernate7/core/build.gradle +++ b/grails-data-hibernate7/core/build.gradle @@ -114,12 +114,6 @@ dependencies { testImplementation "org.hibernate.orm:hibernate-core" // groovy proxy fixes bytebuddy to be a bit smarter when it comes to groovy metaClass - testImplementation 'org.yakworks:hibernate-groovy-proxy', { - exclude group: 'org.codehaus.groovy', module: 'groovy' - exclude group: 'org.hibernate', module: 'hibernate-core' - exclude group: 'org.hibernate.orm', module: 'hibernate-core' - } - testImplementation 'org.apache.tomcat:tomcat-jdbc' testImplementation 'org.spockframework:spock-core' diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java index 334e222864f..fccde1af299 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandler.java @@ -101,16 +101,15 @@ public Serializable getIdentifier(Object o) { return ep.getProxyKey(); } - Serializable identifier = GroovyProxyInterceptorLogic.getIdentifier(o); - if (identifier != null) { - return identifier; - } - + // check HibernateProxy before the Groovy metaClass probe: probing the metaClass of a + // proxy whose interceptor is not Groovy-aware would initialize it (or throw + // LazyInitializationException when detached), while the LazyInitializer holds the + // identifier without needing a session if (o instanceof HibernateProxy hp) { return (Serializable) hp.getHibernateLazyInitializer().getIdentifier(); } - return null; + return GroovyProxyInterceptorLogic.getIdentifier(o); } @Override @@ -120,10 +119,12 @@ public Class getProxiedClass(Object o) { @Override public boolean isProxy(Object o) { - return GroovyProxyInterceptorLogic.getProxyInstanceMetaClass(o) != null || - o instanceof EntityProxy || + // instanceof checks first: the Groovy metaClass probe initializes a proxy whose + // interceptor is not Groovy-aware + return o instanceof EntityProxy || o instanceof HibernateProxy || - o instanceof PersistentCollection; + o instanceof PersistentCollection || + GroovyProxyInterceptorLogic.getProxyInstanceMetaClass(o) != null; } @Override diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy index fd24dd01da7..cb2d6605b8d 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy @@ -22,9 +22,7 @@ import grails.gorm.tests.entities.Club import grails.gorm.tests.entities.Team import org.apache.grails.data.hibernate7.core.GrailsDataHibernate7TckManager import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec -import org.grails.datastore.mapping.reflect.ClassUtils import org.grails.orm.hibernate.proxy.HibernateProxyHandler -import spock.lang.PendingFeatureIf import spock.lang.Shared /** @@ -39,10 +37,6 @@ class ByteBuddyProxySpec extends GrailsDataTckSpecAll interaction with the fake proxy happens in {@link StaticProbe} under + * {@code @CompileStatic}: dynamic Groovy dispatch itself resolves methods through + * {@code getMetaClass()}, so touching the proxy from dynamic spec code would trip the + * trap before the handler is even involved. + */ +class HibernateProxyHandlerMetaClassProbeSpec extends Specification { + + void "getIdentifier reads the identifier from the LazyInitializer without dispatching through the proxy"() { + given: + LazyInitializer initializer = Stub(LazyInitializer) { + getIdentifier() >> 42L + isUninitialized() >> true + } + + expect: + StaticProbe.identifierOf(initializer) == 42L + } + + void "isProxy and isInitialized do not dispatch through the proxy"() { + given: + LazyInitializer initializer = Stub(LazyInitializer) { + isUninitialized() >> true + } + + expect: + StaticProbe.isProxy(initializer) + !StaticProbe.isInitialized(initializer) + } + + @CompileStatic + static class StaticProbe { + + static Serializable identifierOf(LazyInitializer initializer) { + new HibernateProxyHandler().getIdentifier(new MetaClassSensitiveProxy(initializer)) + } + + static boolean isProxy(LazyInitializer initializer) { + new HibernateProxyHandler().isProxy(new MetaClassSensitiveProxy(initializer)) + } + + static boolean isInitialized(LazyInitializer initializer) { + new HibernateProxyHandler().isInitialized(new MetaClassSensitiveProxy(initializer)) + } + } + + /** + * Simulates an uninitialized Hibernate proxy of a Groovy entity: any method dispatched + * through the proxy instance - getMetaClass() included - is intercepted and would + * initialize it, so this stand-in throws where a real detached proxy would throw + * LazyInitializationException. + */ + @CompileStatic + static class MetaClassSensitiveProxy implements HibernateProxy, GroovyObject { + + private final LazyInitializer initializer + + MetaClassSensitiveProxy(LazyInitializer initializer) { + this.initializer = initializer + } + + @Override + LazyInitializer getHibernateLazyInitializer() { + initializer + } + + @Override + Object writeReplace() { + this + } + + @Override + MetaClass getMetaClass() { + throw new IllegalStateException('getMetaClass() was dispatched through the proxy - a real proxy would initialize here') + } + } +} From 1642084d68fdb92f075f344470c1873e11df89d5 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 6 Jul 2026 23:14:56 -0400 Subject: [PATCH 2/5] Fix code style violation --- .../orm/hibernate/cfg/HibernateMappingContextConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java index 045c7e40f78..68764073020 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java @@ -78,10 +78,10 @@ import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.orm.hibernate.EventListenerIntegrator; import org.grails.orm.hibernate.GrailsSessionContext; -import org.grails.orm.hibernate.proxy.GrailsBytecodeProvider; import org.grails.orm.hibernate.HibernateEventListeners; import org.grails.orm.hibernate.MetadataIntegrator; import org.grails.orm.hibernate.access.TraitPropertyAccessStrategy; +import org.grails.orm.hibernate.proxy.GrailsBytecodeProvider; /** * A Configuration that uses a MappingContext to configure Hibernate From 2fe90aad52dcf2380ddd560f2d4c358b220a1998 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 7 Jul 2026 16:03:05 -0400 Subject: [PATCH 3/5] Address review feedback: remove author tag, add GroovyProxyInterceptorLogicSpec - Remove incorrect @author attribution from the new h5 GroovyProxyInterceptorLogic - Add a direct unit test for GroovyProxyInterceptorLogic (ported from the hibernate7 spec, plus isInitialized coverage) --- .../proxy/GroovyProxyInterceptorLogic.java | 1 - .../GroovyProxyInterceptorLogicSpec.groovy | 138 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java index 1701c3c037b..056f3c4cf98 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java @@ -30,7 +30,6 @@ /** * Pure logic for Groovy proxy interception and handling, decoupled from Hibernate. * - * @author Graeme Rocher * @since 8.0 */ public class GroovyProxyInterceptorLogic { diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy new file mode 100644 index 00000000000..e2e4b298819 --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate.proxy + +import spock.lang.Specification +import spock.lang.Unroll +import org.grails.orm.hibernate.proxy.GroovyProxyInterceptorLogic.InterceptorState +import org.grails.datastore.gorm.proxy.ProxyInstanceMetaClass + +class GroovyProxyInterceptorLogicSpec extends Specification { + + static class TestGroovyObject implements GroovyObject { + MetaClass metaClass + Object invokeMethod(String name, Object args) { null } + Object getProperty(String name) { null } + void setProperty(String name, Object value) {} + } + + def "handleUninitialized handles Groovy metadata methods"() { + given: + def state = new InterceptorState("TestEntity", String, 123L) + + when: + def result = GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, [] as Object[]) + + then: + result != GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION + result != null + + where: + methodName << ["getMetaClass", "getStaticMetaClass"] + } + + def "handleUninitialized handles identifier access"() { + given: + def state = new InterceptorState("TestEntity", Object, 123L) + + expect: + GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, args) == 123L + + where: + methodName | args + "getProperty" | ["id"] as Object[] + "ident" | [] as Object[] + } + + def "handleUninitialized handles toString"() { + given: + def state = new InterceptorState("Book", Object, 1L) + + expect: + GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", [] as Object[]) == "Book:1" + } + + def "handleUninitialized handles dirty checking methods"() { + given: + def state = new InterceptorState("TestEntity", Object, 1L) + + expect: + GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, [] as Object[]) == false + + where: + methodName << ["isDirty", "hasChanged"] + } + + @Unroll + def "isGroovyMethod identifies #methodName as #expected"() { + expect: + GroovyProxyInterceptorLogic.isGroovyMethod(methodName) == expected + + where: + methodName | expected + "getMetaClass" | true + "setMetaClass" | true + "getProperty" | true + "setProperty" | true + "invokeMethod" | true + "getTitle" | false + "save" | false + } + + def "unwrap handles ProxyInstanceMetaClass"() { + given: + def target = "real value" + def proxyMc = Mock(ProxyInstanceMetaClass) { + getProxyTarget() >> target + } + def proxy = new TestGroovyObject(metaClass: proxyMc) + + expect: + GroovyProxyInterceptorLogic.unwrap(proxy) == target + GroovyProxyInterceptorLogic.unwrap(new Object()) == null + } + + def "getIdentifier handles ProxyInstanceMetaClass"() { + given: + def id = 456L + def proxyMc = Mock(ProxyInstanceMetaClass) { + getKey() >> id + } + def proxy = new TestGroovyObject(metaClass: proxyMc) + + expect: + GroovyProxyInterceptorLogic.getIdentifier(proxy) == id + GroovyProxyInterceptorLogic.getIdentifier(new Object()) == null + } + + def "isInitialized reports Groovy proxy state and null for non-proxies"() { + given: + def proxyMc = Mock(ProxyInstanceMetaClass) { + isProxyInitiated() >> initiated + } + def proxy = new TestGroovyObject(metaClass: proxyMc) + + expect: + GroovyProxyInterceptorLogic.isInitialized(proxy) == initiated + GroovyProxyInterceptorLogic.isInitialized(new Object()) == null + + where: + initiated << [true, false] + } +} From 8a16363ba722168a9b0152742532bcbf92e85dde Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 7 Jul 2026 16:36:17 -0400 Subject: [PATCH 4/5] Completely remove org.yakworks:hibernate-groovy-proxy --- dependencies.gradle | 2 -- gradle.properties | 1 - .../hibernate5/grails-hibernate-groovy-proxy/build.gradle | 3 --- .../src/test/groovy/example/ProxySpec.groovy | 2 +- .../hibernate7/grails-hibernate-groovy-proxy/build.gradle | 4 ---- .../src/test/groovy/example/ProxySpec.groovy | 2 +- 6 files changed, 2 insertions(+), 12 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index cdfadd2ee05..01187a564c5 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -83,7 +83,6 @@ ext { 'graphql-java-extended-scalars.version': '24.0', 'groovy.version' : '5.0.7', 'guava.version' : '33.6.0-jre', - 'hibernate-groovy-proxy.version': '1.1', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', // Security: overrides the transitive com.fasterxml Jackson 2.x. 2.21.5 fixes CVE-2026-54515 flagged against 2.21.4. @@ -174,7 +173,6 @@ ext { 'groovy-xml' : "org.apache.groovy:groovy-xml:${bomDependencyVersions['groovy.version']}", 'groovy-yaml' : "org.apache.groovy:groovy-yaml:${bomDependencyVersions['groovy.version']}", 'guava' : "com.google.guava:guava:${bomDependencyVersions['guava.version']}", - 'hibernate-groovy-proxy' : "org.yakworks:hibernate-groovy-proxy:${bomDependencyVersions['hibernate-groovy-proxy.version']}", // Security override of spring-boot-dependencies - see httpcore5.version 'httpcore5' : "org.apache.httpcomponents.core5:httpcore5:${bomDependencyVersions['httpcore5.version']}", 'httpcore5-h2' : "org.apache.httpcomponents.core5:httpcore5-h2:${bomDependencyVersions['httpcore5.version']}", diff --git a/gradle.properties b/gradle.properties index cad115808ce..93b3d8d308d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,7 +48,6 @@ openTest4jVersion=1.3.0 picocliVersion=4.7.6 slf4jVersion=2.0.17 spyMemcachedVersion=2.12.3 -yakworksHibernateGroovyProxyVersion=1.1 # Build dependency versions not managed by BOMs apacheRatVersion=0.8.1 diff --git a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/build.gradle b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/build.gradle index e7b213edb6d..70dd149613a 100644 --- a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/build.gradle +++ b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/build.gradle @@ -33,9 +33,6 @@ dependencies { implementation 'org.apache.grails:grails-data-hibernate5' implementation 'org.apache.grails:grails-core' - implementation 'org.yakworks:hibernate-groovy-proxy', { - exclude group: 'org.codehaus.groovy', module: 'groovy' - } runtimeOnly 'com.h2database:h2' runtimeOnly 'com.zaxxer:HikariCP' diff --git a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy index 79a691d3a53..ed9d28de1a9 100644 --- a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy +++ b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy @@ -24,7 +24,7 @@ import grails.gorm.transactions.Rollback import grails.test.hibernate.HibernateSpec /** - * Tests Proxy with hibernate-groovy-proxy + * Tests that GORM's Groovy-aware entity proxies stay uninitialized for id and metaClass access */ class ProxySpec extends HibernateSpec { diff --git a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/build.gradle b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/build.gradle index 3f084e0a09f..ff10fa742cc 100644 --- a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/build.gradle +++ b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/build.gradle @@ -32,10 +32,6 @@ dependencies { implementation 'org.apache.grails:grails-data-hibernate7' implementation 'org.apache.grails:grails-core' - implementation 'org.yakworks:hibernate-groovy-proxy', { - exclude group: 'org.codehaus.groovy', module: 'groovy' - exclude group: 'org.hibernate', module: 'hibernate-core' - } runtimeOnly 'com.h2database:h2' runtimeOnly 'com.zaxxer:HikariCP' diff --git a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy index 4f7c629aca4..2371da556ee 100644 --- a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy +++ b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy @@ -24,7 +24,7 @@ import grails.gorm.transactions.Rollback import grails.test.hibernate.HibernateSpec /** - * Tests Proxy with hibernate-groovy-proxy + * Tests that GORM's Groovy-aware entity proxies stay uninitialized for id and metaClass access */ class ProxySpec extends HibernateSpec { From 96c0aedc357012c864182adffe1e549c405c81cc Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 7 Jul 2026 23:25:13 -0400 Subject: [PATCH 5/5] Ensure toString() unwraps proxy like it did historically and allow opt in to not --- .../proxy/ByteBuddyGroovyInterceptor.java | 8 ++- .../proxy/ByteBuddyGroovyProxyFactory.java | 9 ++- .../proxy/GrailsProxyFactoryFactory.java | 8 ++- .../proxy/GroovyProxyInterceptorLogic.java | 17 ++++- .../spring-configuration-metadata.json | 10 +++ .../tests/proxy/ByteBuddyProxySpec.groovy | 19 ++++++ .../LazyToStringByteBuddyProxySpec.groovy | 67 +++++++++++++++++++ .../GroovyProxyInterceptorLogicSpec.groovy | 22 +++++- .../configuration/configurationReference.adoc | 19 ++++++ .../proxy/ByteBuddyGroovyInterceptor.java | 8 ++- .../proxy/ByteBuddyGroovyProxyFactory.java | 9 ++- .../proxy/GrailsProxyFactoryFactory.java | 8 ++- .../proxy/GroovyProxyInterceptorLogic.java | 17 ++++- .../spring-configuration-metadata.json | 10 +++ .../tests/proxy/ByteBuddyProxySpec.groovy | 19 ++++++ .../LazyToStringByteBuddyProxySpec.groovy | 65 ++++++++++++++++++ .../ByteBuddyGroovyInterceptorSpec.groovy | 11 +-- .../GroovyProxyInterceptorLogicSpec.groovy | 22 +++++- .../configuration/configurationReference.adoc | 19 ++++++ .../src/test/groovy/example/ProxySpec.groovy | 3 - .../src/test/groovy/example/ProxySpec.groovy | 3 - 21 files changed, 347 insertions(+), 26 deletions(-) create mode 100644 grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java index 00224acb78b..73a2b6e1eda 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java @@ -36,6 +36,8 @@ public class ByteBuddyGroovyInterceptor extends ByteBuddyInterceptor { private static final String GET_ID_METHOD = "getId"; private static final String GET_IDENTIFIER_METHOD = "getIdentifier"; + private final boolean lazyToString; + @SuppressWarnings({ "unchecked", "rawtypes" }) public ByteBuddyGroovyInterceptor( String entityName, @@ -46,7 +48,8 @@ public ByteBuddyGroovyInterceptor( Method setIdentifierMethod, CompositeType componentIdType, SharedSessionContractImplementor session, - boolean overridesEquals) { + boolean overridesEquals, + boolean lazyToString) { super( entityName, (Class) persistentClass, @@ -57,6 +60,7 @@ public ByteBuddyGroovyInterceptor( componentIdType, session, overridesEquals); + this.lazyToString = lazyToString; } @Override @@ -72,7 +76,7 @@ public Object intercept(Object proxy, Method method, Object[] args) throws Throw if (isUninitialized()) { GroovyProxyInterceptorLogic.InterceptorState state = new GroovyProxyInterceptorLogic.InterceptorState( - getEntityName(), persistentClass, getIdentifier()); + getEntityName(), persistentClass, getIdentifier(), lazyToString); Object result = GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, args); if (result != GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION) { // NOPMD: sentinel comparison return result; diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java index 1b4fbdace37..861ae2ad3fb 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java @@ -54,10 +54,16 @@ public class ByteBuddyGroovyProxyFactory extends ByteBuddyProxyFactory { private transient CompositeType componentIdType; private boolean overridesEquals; private Class proxyClass; + private final boolean lazyToString; public ByteBuddyGroovyProxyFactory(ByteBuddyProxyHelper byteBuddyProxyHelper) { + this(byteBuddyProxyHelper, false); + } + + public ByteBuddyGroovyProxyFactory(ByteBuddyProxyHelper byteBuddyProxyHelper, boolean lazyToString) { super(byteBuddyProxyHelper); this.byteBuddyProxyHelper = byteBuddyProxyHelper; + this.lazyToString = lazyToString; } @Override @@ -96,7 +102,8 @@ public HibernateProxy getProxy(Serializable id, SharedSessionContractImplementor setIdentifierMethod, componentIdType, session, - overridesEquals); + overridesEquals, + lazyToString); final HibernateProxy hibernateProxy = (HibernateProxy) proxyClass.getDeclaredConstructor().newInstance(); diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java index 0cb77ba71a2..48e0a1cdd57 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java @@ -47,7 +47,13 @@ public GrailsProxyFactoryFactory(GrailsBytecodeProvider grailsBytecodeProvider, @Override public ProxyFactory buildProxyFactory(SessionFactoryImplementor sessionFactory) { - return new ByteBuddyGroovyProxyFactory(grailsBytecodeProvider.getByteBuddyProxyHelper()); + return new ByteBuddyGroovyProxyFactory(grailsBytecodeProvider.getByteBuddyProxyHelper(), + isLazyToString(sessionFactory)); + } + + private static boolean isLazyToString(SessionFactoryImplementor sessionFactory) { + Object value = sessionFactory.getProperties().get(GroovyProxyInterceptorLogic.PROPERTY_LAZY_TO_STRING); + return value != null && Boolean.parseBoolean(value.toString()); } @Override diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java index 056f3c4cf98..781f1aea0b3 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java @@ -36,6 +36,14 @@ public class GroovyProxyInterceptorLogic { public static final Object INVOKE_IMPLEMENTATION = new Object(); + /** + * Setting that controls {@code toString()} on an uninitialized proxy. When {@code false} + * (the default, matching Grails 7 and earlier), {@code toString()} initializes the proxy + * and delegates to the entity's own implementation. When {@code true}, the proxy answers + * {@code entityName:id} without initializing. + */ + public static final String PROPERTY_LAZY_TO_STRING = "hibernate.grails.proxy.lazy_to_string"; + private static final String GET_META_CLASS = "getMetaClass"; private static final String SET_META_CLASS = "setMetaClass"; private static final String META_CLASS_PROPERTY = "metaClass"; @@ -65,7 +73,7 @@ public static Object handleUninitialized(InterceptorState state, String methodNa if ((IS_DIRTY.equals(methodName) || HAS_CHANGED.equals(methodName)) && (args == null || args.length == 0)) { return false; } - if (TO_STRING.equals(methodName) && (args == null || args.length == 0)) { + if (state.lazyToString() && TO_STRING.equals(methodName) && (args == null || args.length == 0)) { return state.entityName() + ":" + state.identifier(); } return INVOKE_IMPLEMENTATION; @@ -120,5 +128,10 @@ public static Boolean isInitialized(Object o) { return null; } - public record InterceptorState(String entityName, Class persistentClass, Object identifier) {} + public record InterceptorState(String entityName, Class persistentClass, Object identifier, boolean lazyToString) { + + public InterceptorState(String entityName, Class persistentClass, Object identifier) { + this(entityName, persistentClass, identifier, false); + } + } } diff --git a/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json index 7a512604174..3f7709ecd51 100644 --- a/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -3,6 +3,10 @@ { "name": "hibernate.cache", "description": "Hibernate" + }, + { + "name": "hibernate.grails.proxy", + "description": "Grails entity proxy behavior" } ], "properties": [ @@ -23,6 +27,12 @@ "type": "java.lang.Boolean", "description": "Whether to enable Hibernate's query cache.", "defaultValue": false + }, + { + "name": "hibernate.grails.proxy.lazy_to_string", + "type": "java.lang.Boolean", + "description": "Whether toString() on an uninitialized entity proxy returns entityName:id without initializing it. When false (the default, matching Grails 7 and earlier), toString() initializes the proxy and delegates to the domain class's own implementation.", + "defaultValue": false } ] } diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy index 81c3ea37518..d1c4bbb831d 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy @@ -151,6 +151,25 @@ needs the identifier must use id/ident()/getIdentifier() instead of unwrapping" !proxyHandler.isInitialized(team.club) } + void "toString initializes the proxy and delegates to the entity implementation by default"() { + when:"load proxy" + Team team = createATeam() + def clubId = team.club.id + manager.session.clear() + Club club = Club.load(clubId) + + then:"the proxy starts uninitialized" + proxyHandler.isProxy(club) + !proxyHandler.isInitialized(club) + + when:"toString is called" + String value = club.toString() + + then:"the entity's own toString runs, which requires initialization" + value == "DOOM Club" + proxyHandler.isInitialized(club) + } + void "isDirty should not intialize the association proxy"() { when:"load instance" Team team = createATeam() diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy new file mode 100644 index 00000000000..e835497a2a6 --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.gorm.tests.proxy + +import grails.gorm.tests.entities.Club +import grails.gorm.tests.entities.Team +import org.apache.grails.data.hibernate5.core.GrailsDataHibernate5TckManager +import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec +import org.grails.orm.hibernate.proxy.HibernateProxyHandler +import spock.lang.Shared + +/** + * Verifies proxy toString behavior when the {@code hibernate.grails.proxy.lazy_to_string} + * setting is enabled: toString on an uninitialized proxy answers {@code entityName:id} + * without initializing it, while an initialized proxy delegates to the entity implementation. + */ +class LazyToStringByteBuddyProxySpec extends GrailsDataTckSpec { + + void setupSpec() { + // nested map: only nested config reaches the arbitrary hibernate settings map, + // producing the flattened hibernate.grails.proxy.lazy_to_string property + manager.grailsConfig = [ + hibernate: [grails: [proxy: [lazy_to_string: 'true']]] + ] + manager.registerDomainClasses(Team, Club) + } + + @Shared + HibernateProxyHandler proxyHandler = new HibernateProxyHandler() + + void "toString does not initialize the proxy when lazy toString is enabled"() { + when: "a proxy is loaded" + Club c = new Club(name: "DOOM Club").save(failOnError: true, flush: true) + def id = c.id + manager.session.clear() + Club club = Club.load(id) + + then: "toString answers entityName:id without initializing the proxy" + proxyHandler.isProxy(club) + !proxyHandler.isInitialized(club) + club.toString() == "${Club.name}:${id}" + !proxyHandler.isInitialized(club) + + when: "the proxy is initialized by accessing a regular property" + club.name + + then: "toString delegates to the entity implementation" + proxyHandler.isInitialized(club) + club.toString() == "DOOM Club" + } +} diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy index e2e4b298819..1ee3cee9245 100644 --- a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy @@ -60,14 +60,32 @@ class GroovyProxyInterceptorLogicSpec extends Specification { "ident" | [] as Object[] } - def "handleUninitialized handles toString"() { - given: + def "handleUninitialized delegates toString to the implementation by default"() { + given: "state without lazy toString enabled, matching pre-Grails 8 behavior" def state = new InterceptorState("Book", Object, 1L) + expect: "the proxy implementation is invoked so the entity's own toString runs" + GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", [] as Object[]) == + GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION + } + + def "handleUninitialized answers toString without initialization when lazy toString is enabled"() { + given: + def state = new InterceptorState("Book", Object, 1L, true) + expect: GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", [] as Object[]) == "Book:1" } + def "handleUninitialized delegates toString with arguments even when lazy toString is enabled"() { + given: + def state = new InterceptorState("Book", Object, 1L, true) + + expect: + GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", ["arg"] as Object[]) == + GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION + } + def "handleUninitialized handles dirty checking methods"() { given: def state = new InterceptorState("TestEntity", Object, 1L) diff --git a/grails-data-hibernate5/docs/src/docs/asciidoc/configuration/configurationReference.adoc b/grails-data-hibernate5/docs/src/docs/asciidoc/configuration/configurationReference.adoc index 2eed1a22456..b6083621f43 100644 --- a/grails-data-hibernate5/docs/src/docs/asciidoc/configuration/configurationReference.adoc +++ b/grails-data-hibernate5/docs/src/docs/asciidoc/configuration/configurationReference.adoc @@ -60,8 +60,27 @@ name,description,default value `hibernate.cache.use_query_cache`, Enables the query cache, `false` `hibernate.configLocations`, Location of additional Hibernate XML configuration files `hibernate.packagesToScan`, Specify packages to search for autodetection of your entity classes in the classpath +`hibernate.grails.proxy.lazy_to_string`, Whether `toString()` on an uninitialized proxy avoids initializing it (see Proxies and toString()), `false` |=== In addition, any additional settings that start with `hibernate.` are passed through to Hibernate, so if there is any specific feature of Hibernate you wish to configure that is possible. + +==== Proxies and toString() + + +Grails proxies answer identifier access (`id`, `getId()`, `ident()`) and `metaClass` access without initializing the proxy. By default, calling `toString()` on an uninitialized proxy initializes it and delegates to the domain class's own `toString()` implementation. + +If you prefer `toString()` to also avoid initialization — for example to keep log statements from triggering database access — enable lazy `toString()` in `application.yml`: + +[source,yaml] +---- +hibernate: + grails: + proxy: + lazy_to_string: true +---- + +With this setting enabled, `toString()` on an uninitialized proxy returns `entityName:id` (for example `com.example.Author:1`) without initializing it. Once the proxy has been initialized, `toString()` always delegates to the domain class's implementation. + TIP: The above table covers the common configuration options. For all configuration refer to properties of the link:../api/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.html[HibernateConnectionSourceSettings] class. diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java index 8d913d745f6..bab4ac1fd52 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java @@ -37,6 +37,8 @@ public class ByteBuddyGroovyInterceptor extends ByteBuddyInterceptor { protected final Method getIdentifierMethod; + private final boolean lazyToString; + public ByteBuddyGroovyInterceptor( String entityName, Class persistentClass, @@ -46,7 +48,8 @@ public ByteBuddyGroovyInterceptor( Method setIdentifierMethod, CompositeType componentIdType, SharedSessionContractImplementor session, - boolean overridesEquals) { + boolean overridesEquals, + boolean lazyToString) { super( entityName, persistentClass, @@ -58,6 +61,7 @@ public ByteBuddyGroovyInterceptor( session, overridesEquals); this.getIdentifierMethod = getIdentifierMethod; + this.lazyToString = lazyToString; } @Override @@ -72,7 +76,7 @@ public Object intercept(Object proxy, Method method, Object[] args) throws Throw } GroovyProxyInterceptorLogic.InterceptorState state = new GroovyProxyInterceptorLogic.InterceptorState( - getEntityName(), getPersistentClass(), getIdentifier()); + getEntityName(), getPersistentClass(), getIdentifier(), lazyToString); if (isUninitialized()) { Object result = GroovyProxyInterceptorLogic.handleUninitialized(state, methodName, args); diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java index 77b30fd78cf..0c6d4a7e3ce 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java @@ -52,10 +52,16 @@ public class ByteBuddyGroovyProxyFactory extends ByteBuddyProxyFactory { private transient CompositeType componentIdType; private boolean overridesEquals; private Class proxyClass; + private final boolean lazyToString; public ByteBuddyGroovyProxyFactory(ByteBuddyProxyHelper byteBuddyProxyHelper) { + this(byteBuddyProxyHelper, false); + } + + public ByteBuddyGroovyProxyFactory(ByteBuddyProxyHelper byteBuddyProxyHelper, boolean lazyToString) { super(byteBuddyProxyHelper); this.byteBuddyProxyHelper = byteBuddyProxyHelper; + this.lazyToString = lazyToString; } @Override @@ -95,7 +101,8 @@ public HibernateProxy getProxy(Object id, SharedSessionContractImplementor sessi setIdentifierMethod, componentIdType, session, - overridesEquals); + overridesEquals, + lazyToString); // 1. Create the instance final HibernateProxy hibernateProxy = diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java index 2d85f7e20d2..371c18a6bf4 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java @@ -44,7 +44,13 @@ public GrailsProxyFactoryFactory(GrailsBytecodeProvider grailsBytecodeProvider) @Override public ProxyFactory buildProxyFactory(SessionFactoryImplementor sessionFactory) { - return new ByteBuddyGroovyProxyFactory(grailsBytecodeProvider.getProxyHelper()); + return new ByteBuddyGroovyProxyFactory(grailsBytecodeProvider.getProxyHelper(), + isLazyToString(sessionFactory)); + } + + private static boolean isLazyToString(SessionFactoryImplementor sessionFactory) { + Object value = sessionFactory.getProperties().get(GroovyProxyInterceptorLogic.PROPERTY_LAZY_TO_STRING); + return value != null && Boolean.parseBoolean(value.toString()); } @Override diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java index af593e0c856..46354f007b9 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java @@ -37,6 +37,14 @@ public class GroovyProxyInterceptorLogic { public static final Object INVOKE_IMPLEMENTATION = new Object(); + /** + * Setting that controls {@code toString()} on an uninitialized proxy. When {@code false} + * (the default, matching Grails 7 and earlier), {@code toString()} initializes the proxy + * and delegates to the entity's own implementation. When {@code true}, the proxy answers + * {@code entityName:id} without initializing. + */ + public static final String PROPERTY_LAZY_TO_STRING = "hibernate.grails.proxy.lazy_to_string"; + private static final String GET_META_CLASS = "getMetaClass"; private static final String SET_META_CLASS = "setMetaClass"; private static final String META_CLASS_PROPERTY = "metaClass"; @@ -66,7 +74,7 @@ public static Object handleUninitialized(InterceptorState state, String methodNa if ((IS_DIRTY.equals(methodName) || HAS_CHANGED.equals(methodName)) && (args == null || args.length == 0)) { return false; } - if (TO_STRING.equals(methodName) && (args == null || args.length == 0)) { + if (state.lazyToString() && TO_STRING.equals(methodName) && (args == null || args.length == 0)) { return state.entityName() + ":" + state.identifier(); } return INVOKE_IMPLEMENTATION; @@ -121,5 +129,10 @@ public static Boolean isInitialized(Object o) { return null; } - public record InterceptorState(String entityName, Class persistentClass, Object identifier) {} + public record InterceptorState(String entityName, Class persistentClass, Object identifier, boolean lazyToString) { + + public InterceptorState(String entityName, Class persistentClass, Object identifier) { + this(entityName, persistentClass, identifier, false); + } + } } diff --git a/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json index 7a512604174..3f7709ecd51 100644 --- a/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -3,6 +3,10 @@ { "name": "hibernate.cache", "description": "Hibernate" + }, + { + "name": "hibernate.grails.proxy", + "description": "Grails entity proxy behavior" } ], "properties": [ @@ -23,6 +27,12 @@ "type": "java.lang.Boolean", "description": "Whether to enable Hibernate's query cache.", "defaultValue": false + }, + { + "name": "hibernate.grails.proxy.lazy_to_string", + "type": "java.lang.Boolean", + "description": "Whether toString() on an uninitialized entity proxy returns entityName:id without initializing it. When false (the default, matching Grails 7 and earlier), toString() initializes the proxy and delegates to the domain class's own implementation.", + "defaultValue": false } ] } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy index cb2d6605b8d..4f0ac5f3943 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/ByteBuddyProxySpec.groovy @@ -151,6 +151,25 @@ needs the identifier must use id/ident()/getIdentifier() instead of unwrapping" !proxyHandler.isInitialized(team.club) } + void "toString initializes the proxy and delegates to the entity implementation by default"() { + when:"load proxy" + Team team = createATeam() + def clubId = team.club.id + manager.session.clear() + Club club = Club.load(clubId) + + then:"the proxy starts uninitialized" + proxyHandler.isProxy(club) + !proxyHandler.isInitialized(club) + + when:"toString is called" + String value = club.toString() + + then:"the entity's own toString runs, which requires initialization" + value == "DOOM Club" + proxyHandler.isInitialized(club) + } + void "isDirty should not intialize the association proxy"() { when:"load instance" Team team = createATeam() diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy new file mode 100644 index 00000000000..8aa04552322 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/proxy/LazyToStringByteBuddyProxySpec.groovy @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.gorm.tests.proxy + +import grails.gorm.tests.entities.Club +import grails.gorm.tests.entities.Team +import org.apache.grails.data.hibernate7.core.GrailsDataHibernate7TckManager +import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec +import org.grails.orm.hibernate.proxy.HibernateProxyHandler +import spock.lang.Shared + +/** + * Verifies proxy toString behavior when the {@code hibernate.grails.proxy.lazy_to_string} + * setting is enabled: toString on an uninitialized proxy answers {@code entityName:id} + * without initializing it, while an initialized proxy delegates to the entity implementation. + */ +class LazyToStringByteBuddyProxySpec extends GrailsDataTckSpec { + + void setupSpec() { + // nested ConfigObject path: only nested config reaches the arbitrary hibernate settings + // map, producing the flattened hibernate.grails.proxy.lazy_to_string property + manager.grailsConfig.hibernate.grails.proxy.lazy_to_string = 'true' + manager.registerDomainClasses(Team, Club) + } + + @Shared + HibernateProxyHandler proxyHandler = new HibernateProxyHandler() + + void "toString does not initialize the proxy when lazy toString is enabled"() { + when: "a proxy is loaded" + Club c = new Club(name: "DOOM Club").save(failOnError: true, flush: true) + def id = c.id + manager.session.clear() + Club club = Club.load(id) + + then: "toString answers entityName:id without initializing the proxy" + proxyHandler.isProxy(club) + !proxyHandler.isInitialized(club) + club.toString() == "${Club.name}:${id}" + !proxyHandler.isInitialized(club) + + when: "the proxy is initialized by accessing a regular property" + club.name + + then: "toString delegates to the entity implementation" + proxyHandler.isInitialized(club) + club.toString() == "DOOM Club" + } +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptorSpec.groovy index 9282abcf6b2..a57906724f8 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptorSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptorSpec.groovy @@ -57,16 +57,19 @@ class ByteBuddyGroovyInterceptorSpec extends HibernateGormDatastoreSpec { !Hibernate.isInitialized(proxy) } - void "toString() on uninitialized proxy returns entityName:id without initialization"() { + void "toString() on uninitialized proxy initializes it and delegates to the entity by default"() { given: def proxy = manager.hibernateSession.getReference(Location, savedId) + expect: + !Hibernate.isInitialized(proxy) + when: String s = proxy.toString() - then: - !Hibernate.isInitialized(proxy) - s.contains(savedId.toString()) + then: "matching pre-Grails 8 behavior, toString initializes the proxy" + Hibernate.isInitialized(proxy) + s != null } void "isDirty() on uninitialized proxy returns false without initialization"() { diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy index 7f2982ecd18..4dc81bef555 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy @@ -60,14 +60,32 @@ class GroovyProxyInterceptorLogicSpec extends Specification { "ident" | [] as Object[] } - def "handleUninitialized handles toString"() { - given: + def "handleUninitialized delegates toString to the implementation by default"() { + given: "state without lazy toString enabled, matching pre-Grails 8 behavior" def state = new InterceptorState("Book", Object, 1L) + expect: "the proxy implementation is invoked so the entity's own toString runs" + GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", [] as Object[]) == + GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION + } + + def "handleUninitialized answers toString without initialization when lazy toString is enabled"() { + given: + def state = new InterceptorState("Book", Object, 1L, true) + expect: GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", [] as Object[]) == "Book:1" } + def "handleUninitialized delegates toString with arguments even when lazy toString is enabled"() { + given: + def state = new InterceptorState("Book", Object, 1L, true) + + expect: + GroovyProxyInterceptorLogic.handleUninitialized(state, "toString", ["arg"] as Object[]) == + GroovyProxyInterceptorLogic.INVOKE_IMPLEMENTATION + } + def "handleUninitialized handles dirty checking methods"() { given: def state = new InterceptorState("TestEntity", Object, 1L) diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc index 2eed1a22456..b6083621f43 100644 --- a/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc +++ b/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc @@ -60,8 +60,27 @@ name,description,default value `hibernate.cache.use_query_cache`, Enables the query cache, `false` `hibernate.configLocations`, Location of additional Hibernate XML configuration files `hibernate.packagesToScan`, Specify packages to search for autodetection of your entity classes in the classpath +`hibernate.grails.proxy.lazy_to_string`, Whether `toString()` on an uninitialized proxy avoids initializing it (see Proxies and toString()), `false` |=== In addition, any additional settings that start with `hibernate.` are passed through to Hibernate, so if there is any specific feature of Hibernate you wish to configure that is possible. + +==== Proxies and toString() + + +Grails proxies answer identifier access (`id`, `getId()`, `ident()`) and `metaClass` access without initializing the proxy. By default, calling `toString()` on an uninitialized proxy initializes it and delegates to the domain class's own `toString()` implementation. + +If you prefer `toString()` to also avoid initialization — for example to keep log statements from triggering database access — enable lazy `toString()` in `application.yml`: + +[source,yaml] +---- +hibernate: + grails: + proxy: + lazy_to_string: true +---- + +With this setting enabled, `toString()` on an uninitialized proxy returns `entityName:id` (for example `com.example.Author:1`) without initializing it. Once the proxy has been initialized, `toString()` always delegates to the domain class's implementation. + TIP: The above table covers the common configuration options. For all configuration refer to properties of the link:../api/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.html[HibernateConnectionSourceSettings] class. diff --git a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy index ed9d28de1a9..eec7ee77704 100644 --- a/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy +++ b/grails-test-examples/hibernate5/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy @@ -51,9 +51,6 @@ class ProxySpec extends HibernateSpec { proxy.getId() == 1 proxy["id"] == 1 !Hibernate.isInitialized(proxy) - // gorms trait implements in the class so no way to tell - // proxy.toString() == "Customer : 1 (proxy)" - // !Hibernate.isInitialized(proxy) } } diff --git a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy index 2371da556ee..2280673895f 100644 --- a/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy +++ b/grails-test-examples/hibernate7/grails-hibernate-groovy-proxy/src/test/groovy/example/ProxySpec.groovy @@ -54,9 +54,6 @@ class ProxySpec extends HibernateSpec { proxy.getId() == 1 proxy["id"] == 1 !Hibernate.isInitialized(proxy) - // gorms trait implements in the class so no way to tell - // proxy.toString() == "Customer : 1 (proxy)" - // !Hibernate.isInitialized(proxy) } }