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 b2f3900b663..bfcdd8de1a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -46,7 +46,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-data-hibernate5/core/build.gradle b/grails-data-hibernate5/core/build.gradle index 7109e36dc9f..28dab355f5c 100644 --- a/grails-data-hibernate5/core/build.gradle +++ b/grails-data-hibernate5/core/build.gradle @@ -74,11 +74,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..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 @@ -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; @@ -80,6 +81,7 @@ 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 @@ -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..73a2b6e1eda --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyInterceptor.java @@ -0,0 +1,88 @@ +/* + * 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"; + + private final boolean lazyToString; + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public ByteBuddyGroovyInterceptor( + String entityName, + Class persistentClass, + Class[] interfaces, + Serializable id, + Method getIdentifierMethod, + Method setIdentifierMethod, + CompositeType componentIdType, + SharedSessionContractImplementor session, + boolean overridesEquals, + boolean lazyToString) { + super( + entityName, + (Class) persistentClass, + (Class[]) interfaces, + id, + getIdentifierMethod, + setIdentifierMethod, + componentIdType, + session, + overridesEquals); + this.lazyToString = lazyToString; + } + + @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(), lazyToString); + 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..861ae2ad3fb --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/ByteBuddyGroovyProxyFactory.java @@ -0,0 +1,120 @@ +/* + * 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; + 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 + @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, + lazyToString); + + 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..48e0a1cdd57 --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GrailsProxyFactoryFactory.java @@ -0,0 +1,70 @@ +/* + * 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(), + 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 + @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..781f1aea0b3 --- /dev/null +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogic.java @@ -0,0 +1,137 @@ +/* + * 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. + * + * @since 8.0 + */ +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"; + 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 (state.lazyToString() && 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, boolean lazyToString) { + + public InterceptorState(String entityName, Class persistentClass, Object identifier) { + this(entityName, persistentClass, identifier, false); + } + } +} 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/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 1966d356b79..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 @@ -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 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 new file mode 100644 index 00000000000..1ee3cee9245 --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/GroovyProxyInterceptorLogicSpec.groovy @@ -0,0 +1,156 @@ +/* + * 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 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) + + 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] + } +} diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy new file mode 100644 index 00000000000..6ba65fe7dee --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy @@ -0,0 +1,109 @@ +/* + * 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 groovy.transform.CompileStatic +import org.hibernate.proxy.HibernateProxy +import org.hibernate.proxy.LazyInitializer +import spock.lang.Specification + +/** + * Verifies that {@link HibernateProxyHandler} never dispatches methods through a + * {@link HibernateProxy} while answering identifier and proxy-state questions. On a stock + * (non-Groovy-aware) ByteBuddy proxy every dispatched method - including + * {@code getMetaClass()} - is intercepted and initializes the proxy, which throws + * {@code LazyInitializationException} when the proxy is detached. The handler must answer + * from the {@link LazyInitializer} instead, which needs no session. + * + *

All 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-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/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/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/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/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 fd24dd01da7..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 @@ -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 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/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy new file mode 100644 index 00000000000..e50dce7dcdb --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/proxy/HibernateProxyHandlerMetaClassProbeSpec.groovy @@ -0,0 +1,111 @@ +/* + * 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 groovy.transform.CompileStatic +import org.hibernate.proxy.HibernateProxy +import org.hibernate.proxy.LazyInitializer +import spock.lang.Specification + +/** + * Verifies that {@link HibernateProxyHandler} never dispatches methods through a + * {@link HibernateProxy} while answering identifier and proxy-state questions. On a stock + * (non-Groovy-aware) ByteBuddy proxy every dispatched method - including + * {@code getMetaClass()} - is intercepted and initializes the proxy, which throws + * {@code LazyInitializationException} when the proxy is detached. The handler must answer + * from the {@link LazyInitializer} instead, which needs no session. Hibernate 7 installs a + * Groovy-aware interceptor by default via GrailsBytecodeProvider, but the handler must stay + * safe for proxies produced without it. + * + *

All 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/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/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..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 @@ -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 { @@ -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/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..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 @@ -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 { @@ -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) } }