You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/06 23:07:38 UTC
(grails-core) 03/11: test: guard the internal Hibernate API the generated-class binding relies on
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 974cb291a085bde2dd33662f3709843b541eaa56
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 15:06:47 2026 -0500
test: guard the internal Hibernate API the generated-class binding relies on
GeneratedDomainClassBinder binds the generated classes with
AnnotationMetadataSourceProcessorImpl.processAdditionalMappings and
InFlightMetadataCollectorImpl.processSecondPasses, and the configuration
subclasses ClassLoaderServiceImpl. All are public but sit in internal
packages, so a Hibernate upgrade can remove them. The spec checks every
signature and says which code to revisit, naming the Hibernate version it
was verified against (7.4.10); the version itself is informational only.
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
...edDomainClassBinderHibernateContractSpec.groovy | 183 +++++++++++++++++++++
1 file changed, 183 insertions(+)
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinderHibernateContractSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinderHibernateContractSpec.groovy
new file mode 100644
index 0000000000..d2a057c394
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinderHibernateContractSpec.groovy
@@ -0,0 +1,183 @@
+/*
+ * 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.cfg.domainbinding.jpa
+
+import java.lang.reflect.Constructor
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+
+import org.hibernate.Version
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import spock.lang.Specification
+
+/**
+ * A structural guard on the Hibernate classes the generated-class binding uses that are public but sit in {@code internal}
+ * packages or are otherwise not part of Hibernate's supported API. Hibernate may change or remove them in any release; this
+ * spec turns that into one failure that says what to revisit, instead of a {@code NoSuchMethodError} at application start.
+ *
+ * <p>This is deliberately the one place that inspects non-public-API signatures: the signatures are the contract. The
+ * behaviour they provide is exercised by the generated-mode specs ({@code GeneratedDomainClassesContributorSpec},
+ * {@code GeneratedModeEnversSpec} and the rest of the suite).</p>
+ */
+class GeneratedDomainClassBinderHibernateContractSpec extends Specification {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GeneratedDomainClassBinderHibernateContractSpec)
+
+ /** The Hibernate version the signatures below were verified against. */
+ private static final String VERIFIED_AGAINST = '7.4.10'
+
+ private static final String BIND_NOW = 'GeneratedDomainClassBinder.bindNow: it binds the generated classes itself, as ' +
+ 'AdditionalMappingContributions.complete() does, so that an AdditionalMappingContributor that runs after the binder ' +
+ '(Envers) sees the bound entities; revisit it, and how the binder falls back to contributions.contributeEntity'
+ private static final String LOADER_SERVICE = 'HibernateMappingContextConfiguration.newClassLoaderService: it subclasses ' +
+ 'the class loader service to put the GORM binder and the GeneratedDomainClassBinder into the Java services Hibernate ' +
+ 'loads; revisit how those two are contributed'
+ private static final String ALIGN = 'GeneratedDomainClassBinder.align and the methods it calls: after the mappings are ' +
+ 'bound they re-point the bound entities at the real domain classes and restore what the domain binder would have ' +
+ 'bound; revisit how the same state is set on the mapping model'
+
+ def setupSpec() {
+ String actual = Version.versionString
+ LOG.info('GeneratedDomainClassBinder Hibernate contract verified against {}, running against {}', VERIFIED_AGAINST, actual)
+ if (!actual.startsWith(VERIFIED_AGAINST)) {
+ LOG.warn('Hibernate is {} but the generated-class binding contract was verified against {}: if a feature of ' +
+ 'this spec fails, that is the reason', actual, VERIFIED_AGAINST)
+ }
+ }
+
+ def "the Hibernate version is reported for information only and never fails the contract"() {
+ expect: 'a patch upgrade passes as long as the signatures checked by the other features still exist'
+ Version.versionString != null
+ }
+
+ def "the internal Hibernate method #owner.#member still has the signature the generated-class binding calls"() {
+ expect:
+ requireMethod(owner, member, parameters, isStatic, revisit)
+
+ where:
+ owner | member | parameters | isStatic | revisit
+ 'org.hibernate.boot.model.source.internal.annotations.AnnotationMetadataSourceProcessorImpl' | 'processAdditionalMappings' | ['java.util.List', 'java.util.List', 'java.util.List', 'org.hibernate.boot.internal.MetadataBuildingContextRootImpl', 'org.hibernate.boot.spi.MetadataBuildingOptions'] | true | BIND_NOW
+ 'org.hibernate.boot.internal.InFlightMetadataCollectorImpl' | 'processSecondPasses' | ['org.hibernate.boot.spi.MetadataBuildingContext'] | false | BIND_NOW
+ 'org.hibernate.boot.registry.classloading.internal.ClassLoaderServiceImpl' | 'loadJavaServices' | ['java.lang.Class'] | false | LOADER_SERVICE
+ }
+
+ def "the classes the generated-class binding tests with instanceof and subclasses are still related as it assumes"() {
+ expect: 'bindNow only takes its direct path when the context is the root implementation and the collector the in-flight one'
+ isAssignable('org.hibernate.boot.spi.MetadataBuildingContext', 'org.hibernate.boot.internal.MetadataBuildingContextRootImpl', BIND_NOW)
+ isAssignable('org.hibernate.boot.spi.InFlightMetadataCollector', 'org.hibernate.boot.internal.InFlightMetadataCollectorImpl', BIND_NOW)
+ isAssignable('org.hibernate.boot.spi.MetadataBuildingOptions', typeOf('org.hibernate.boot.spi.MetadataBuildingContext', 'getBuildingOptions'), BIND_NOW)
+ isAssignable('org.hibernate.boot.spi.InFlightMetadataCollector', typeOf('org.hibernate.boot.spi.MetadataBuildingContext', 'getMetadataCollector'), BIND_NOW)
+ }
+
+ def "the class loader service implementation can still be subclassed and constructed with a class loader"() {
+ when:
+ Class<?> type = load('org.hibernate.boot.registry.classloading.internal.ClassLoaderServiceImpl', LOADER_SERVICE)
+ Constructor<?> constructor = findConstructor(type, [ClassLoader])
+
+ then: 'the binder-mode and generated-mode services are anonymous subclasses created with this constructor'
+ !Modifier.isFinal(type.modifiers)
+ Modifier.isPublic(type.modifiers)
+ constructor != null
+ Modifier.isPublic(constructor.modifiers)
+ }
+
+ def "the mapping model mutators used to align the bound entities with the real classes still exist"() {
+ expect:
+ requireMethod(owner, member, parameters, false, ALIGN)
+
+ where:
+ owner | member | parameters
+ 'org.hibernate.mapping.PersistentClass' | 'setClassName' | ['java.lang.String']
+ 'org.hibernate.mapping.PersistentClass' | 'setProxyInterfaceName' | ['java.lang.String']
+ 'org.hibernate.mapping.PersistentClass' | 'getDeclaredProperties' | []
+ 'org.hibernate.mapping.PersistentClass' | 'getRecursiveProperty' | ['java.lang.String']
+ 'org.hibernate.mapping.RootClass' | 'setForceDiscriminator' | ['boolean']
+ 'org.hibernate.mapping.Property' | 'setPropertyAccessorName' | ['java.lang.String']
+ 'org.hibernate.mapping.Property' | 'setLazy' | ['boolean']
+ 'org.hibernate.mapping.Component' | 'setComponentClassName' | ['java.lang.String']
+ 'org.hibernate.mapping.Component' | 'getProperties' | []
+ 'org.hibernate.mapping.Collection' | 'setExtraLazy' | ['boolean']
+ 'org.hibernate.mapping.Collection' | 'createAllKeys' | []
+ 'org.hibernate.mapping.Table' | 'setPrimaryKey' | ['org.hibernate.mapping.PrimaryKey']
+ 'org.hibernate.mapping.UniqueKey' | 'setNameExplicit' | ['boolean']
+ 'org.hibernate.mapping.UniqueKey' | 'setExplicit' | ['boolean']
+ 'org.hibernate.mapping.SimpleValue' | 'setCustomIdGeneratorCreator' | ['org.hibernate.mapping.GeneratorCreator']
+ 'org.hibernate.boot.Metadata' | 'getEntityBindings' | []
+ 'org.hibernate.boot.Metadata' | 'getFilterDefinitions' | []
+ 'org.hibernate.boot.spi.MetadataImplementor' | 'getTypeConfiguration' | []
+ 'org.hibernate.type.descriptor.java.spi.JavaTypeRegistry' | 'resolveEntityTypeDescriptor' | ['java.lang.Class']
+ }
+
+ private static boolean requireMethod(String owner, String member, List<String> parameters, boolean isStatic, String revisit) {
+ Class<?> type = load(owner, revisit)
+ List<Class<?>> parameterTypes = parameters.collect { String name -> load(name, revisit) }
+ Method method
+ try {
+ method = type.getMethod(member, parameterTypes as Class<?>[])
+ } catch (NoSuchMethodException ignored) {
+ throw new AssertionError(failure("${owner}.${member}(${parameters.join(', ')}) no longer exists", revisit))
+ }
+ if (Modifier.isStatic(method.modifiers) != isStatic) {
+ throw new AssertionError(failure(
+ "${owner}.${member} is ${isStatic ? 'no longer' : 'now'} static, the binder calls it as " +
+ "${isStatic ? 'a static method' : 'an instance method'}", revisit))
+ }
+ return true
+ }
+
+ private static boolean isAssignable(String supertype, String subtype, String revisit) {
+ return isAssignable(supertype, load(subtype, revisit), revisit)
+ }
+
+ private static boolean isAssignable(String supertype, Class<?> subtype, String revisit) {
+ if (!load(supertype, revisit).isAssignableFrom(subtype)) {
+ throw new AssertionError(failure("${subtype.name} is no longer a ${supertype}", revisit))
+ }
+ return true
+ }
+
+ private static Class<?> typeOf(String owner, String getter) {
+ return load(owner, 'GeneratedDomainClassBinder').getMethod(getter).returnType
+ }
+
+ private static Constructor<?> findConstructor(Class<?> type, List<Class<?>> parameterTypes) {
+ try {
+ return type.getConstructor(parameterTypes as Class<?>[])
+ } catch (NoSuchMethodException ignored) {
+ throw new AssertionError(failure("${type.name} has no public constructor taking ${parameterTypes*.simpleName}", LOADER_SERVICE))
+ }
+ }
+
+ private static Class<?> load(String name, String revisit) {
+ if (name == 'boolean') {
+ return boolean
+ }
+ try {
+ return Class.forName(name, false, GeneratedDomainClassBinderHibernateContractSpec.classLoader)
+ } catch (ClassNotFoundException ignored) {
+ throw new AssertionError(failure("class ${name} no longer exists", revisit))
+ }
+ }
+
+ private static String failure(String what, String revisit) {
+ return "Hibernate ${Version.versionString} changed the API the generated-class binding relies on (the contract was " +
+ "written against Hibernate ${VERIFIED_AGAINST}): ${what}. Revisit ${revisit}."
+ }
+}