-
-
Notifications
You must be signed in to change notification settings - Fork 974
fix(config): bind nested settings maps under Spring 7 #16160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 8.0.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,9 @@ | |
| */ | ||
| package org.grails.datastore.mapping.config | ||
|
|
||
| import java.beans.Introspector | ||
| import java.beans.PropertyDescriptor | ||
| import java.lang.reflect.InvocationTargetException | ||
| import java.lang.reflect.Method | ||
| import java.lang.reflect.Modifier | ||
|
|
||
|
|
@@ -28,6 +31,7 @@ import groovy.transform.builder.SimpleStrategy | |
| import groovy.util.logging.Slf4j | ||
|
|
||
| import org.springframework.core.convert.ConversionFailedException | ||
| import org.springframework.core.convert.ConverterNotFoundException | ||
| import org.springframework.core.env.PropertyResolver | ||
| import org.springframework.util.ClassUtils | ||
| import org.springframework.util.ReflectionUtils | ||
|
|
@@ -393,23 +397,12 @@ abstract class ConfigurationBuilder<B, C> { | |
| try { | ||
| value = propertyResolver.getProperty(propertyPathForArg, argType, fallBackValue) | ||
| } catch (ConversionFailedException e) { | ||
| if (argType.isEnum()) { | ||
| value = propertyResolver.getProperty(propertyPathForArg, String) | ||
| if (value != null) { | ||
| try { | ||
| value = Enum.valueOf((Class) argType, value.toUpperCase()) | ||
| } catch (Throwable e2) { | ||
| // ignore e2 and throw original | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| } | ||
| else { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| } | ||
| else { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| value = handleConversionException(e, argType, propertyPathForArg, fallBackValue) | ||
| } catch (ConverterNotFoundException e) { | ||
| // Spring 7 nested-map conversion fallback: handle types with | ||
| // @Builder(builderStrategy = SimpleStrategy) where Spring cannot | ||
| // auto-convert from Map. Independent of the Groovy version. | ||
| value = handleConverterNotFoundException(e, argType, propertyPathForArg, fallBackValue) | ||
| } | ||
| if (value != null) { | ||
| log.debug('Resolved value [{}] for setting [{}]', value, propertyPathForArg) | ||
|
|
@@ -463,4 +456,196 @@ abstract class ConfigurationBuilder<B, C> { | |
| protected void startBuild(Object builder, String configurationPath) { | ||
| // no-op | ||
| } | ||
| /** | ||
| * Handle ConversionFailedException - for enums, try case-insensitive conversion | ||
| */ | ||
| private Object handleConversionException(ConversionFailedException e, Class argType, String propertyPathForArg, Object fallBackValue) { | ||
| if (argType.isEnum()) { | ||
| def value = propertyResolver.getProperty(propertyPathForArg, String) | ||
| if (value != null) { | ||
| try { | ||
| return Enum.valueOf((Class) argType, value.toUpperCase()) | ||
| } catch (IllegalArgumentException e2) { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| } | ||
| else { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| } | ||
| else { | ||
| ConverterNotFoundException converterNotFoundException = findConverterNotFoundException(e) | ||
| if (converterNotFoundException != null) { | ||
| return handleConverterNotFoundException(converterNotFoundException, argType, propertyPathForArg, fallBackValue) | ||
| } | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| } | ||
|
|
||
| private static ConverterNotFoundException findConverterNotFoundException(Throwable exception) { | ||
| Throwable cause = exception | ||
| while (cause != null) { | ||
| if (cause instanceof ConverterNotFoundException) { | ||
| return (ConverterNotFoundException) cause | ||
| } | ||
| cause = cause.getCause() | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Handle ConverterNotFoundException - for nested configuration types, | ||
| * try to instantiate and populate from Map. This handles Spring 7 compatibility where | ||
| * Spring can't auto-convert from LinkedHashMap to these types. This is independent of the | ||
| * Groovy version and is required regardless of @Builder annotation retention. | ||
| */ | ||
| @CompileDynamic | ||
| private Object handleConverterNotFoundException(ConverterNotFoundException e, Class argType, String propertyPathForArg, Object fallBackValue, Object rawValue = null) { | ||
| if (rawValue == null) { | ||
| try { | ||
| // Use Object.class to prevent Spring's MapToMapConverter from deep-converting values | ||
| rawValue = propertyResolver.getProperty(propertyPathForArg, Object) | ||
| } catch (ConfigurationException e2) { | ||
| throw e2 | ||
| } catch (Exception e2) { | ||
| throw new ConfigurationException("Cannot read configuration for path [$propertyPathForArg]: $e2.message", e2) | ||
| } | ||
| } | ||
|
|
||
| if (rawValue instanceof Map) { | ||
| try { | ||
| Map<String, PropertyDescriptor> writableProperties = [:] | ||
| Introspector.getBeanInfo(argType).propertyDescriptors.each { PropertyDescriptor property -> | ||
| if (property.name != 'metaClass' && property.writeMethod != null) { | ||
| writableProperties[property.name] = property | ||
| } | ||
| } | ||
|
|
||
| def instance = argType.getDeclaredConstructor().newInstance() | ||
| if (fallBackValue != null && argType.isInstance(fallBackValue)) { | ||
| // A map-backed settings type carries arbitrary entries as well as declared | ||
| // properties, so the inherited entries have to come across too or overriding | ||
| // one nested value would silently drop the rest. | ||
| if (instance instanceof Map && fallBackValue instanceof Map) { | ||
| ((Map) instance).putAll((Map) fallBackValue) | ||
| } | ||
| writableProperties.values().each { PropertyDescriptor property -> | ||
| if (property.readMethod != null && property.readMethod.parameterCount == 0) { | ||
| Object fallbackPropertyValue = property.readMethod.invoke(fallBackValue) | ||
| property.writeMethod.invoke(instance, [fallbackPropertyValue] as Object[]) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| boolean mapBacked = instance instanceof Map | ||
| Set<String> resolvedProperties = [] as Set<String> | ||
| ((Map) rawValue).each { key, val -> | ||
| String propertyName = key.toString() | ||
| PropertyDescriptor property = writableProperties[propertyName] | ||
| if (property != null) { | ||
| Object fallBackPropertyValue = getFallBackValue(fallBackValue, propertyName) | ||
| Object value = resolveMapValue(property.propertyType, "$propertyPathForArg.$propertyName", fallBackPropertyValue, val) | ||
| property.writeMethod.invoke(instance, [value] as Object[]) | ||
| resolvedProperties.add(propertyName) | ||
| return | ||
| } | ||
| int nestedPropertySeparator = propertyName.indexOf('.') | ||
| if (nestedPropertySeparator > 0) { | ||
| String nestedPropertyName = propertyName.substring(0, nestedPropertySeparator) | ||
| PropertyDescriptor nestedProperty = writableProperties[nestedPropertyName] | ||
| if (nestedProperty != null) { | ||
| if (resolvedProperties.add(nestedPropertyName)) { | ||
| Object fallBackPropertyValue = getFallBackValue(fallBackValue, nestedPropertyName) | ||
| Object value = resolveMapValue(nestedProperty.propertyType, "$propertyPathForArg.$nestedPropertyName", fallBackPropertyValue, val) | ||
| nestedProperty.writeMethod.invoke(instance, [value] as Object[]) | ||
| } | ||
| return | ||
| } | ||
| } | ||
| // Types that are themselves a Map (HibernateSettings extends LinkedHashMap, for | ||
| // example) exist precisely to carry arbitrary keys such as hibernate.hbm2ddl.auto, | ||
| // so an entry that is not a declared bean property belongs in the map rather than | ||
| // being rejected. Only types with a fixed set of properties reject unknown keys. | ||
| if (mapBacked) { | ||
| ((Map) instance).put(key, val) | ||
| return | ||
| } | ||
|
Comment on lines
+569
to
+572
|
||
| throw new ConfigurationException("Unknown setting [$propertyPathForArg.$propertyName]") | ||
| } | ||
| return instance | ||
| } catch (ConfigurationException e2) { | ||
| throw e2 | ||
| } catch (InvocationTargetException e2) { | ||
| Throwable cause = e2.targetException | ||
| if (cause instanceof Error) { | ||
| throw (Error) cause | ||
| } | ||
| if (cause instanceof ConfigurationException) { | ||
| throw (ConfigurationException) cause | ||
| } | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $cause.message", cause) | ||
| } catch (Exception e2) { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e2.message", e2) | ||
| } | ||
| } | ||
|
|
||
| if (rawValue != null) { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: cannot convert value [$rawValue] to required type [$argType.name]", e) | ||
| } | ||
|
|
||
| // If we have a fallback value, return it | ||
| if (fallBackValue != null) { | ||
| return fallBackValue | ||
| } | ||
|
|
||
| if (e != null) { | ||
| throw new ConfigurationException("Invalid value for setting [$propertyPathForArg]: $e.message", e) | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| private Object resolveClassValue(String propertyPath) { | ||
| Object rawValue = propertyResolver.getProperty(propertyPath, Object) | ||
| if (rawValue instanceof Class) { | ||
| return rawValue | ||
| } | ||
| String className = rawValue instanceof CharSequence ? rawValue.toString().trim() : null | ||
| if (!className) { | ||
| return null | ||
| } | ||
| ClassLoader classLoader = Thread.currentThread().contextClassLoader ?: getClass().classLoader | ||
| try { | ||
| return ClassUtils.forName(className, classLoader) | ||
| } catch (ClassNotFoundException | LinkageError e) { | ||
| throw new ConfigurationException("Invalid class name [$className] for setting [$propertyPath]: ${e.message}", e) | ||
| } | ||
| } | ||
|
|
||
| private Object resolveMapValue(Class propertyType, String propertyPath, Object fallBackValue, Object rawValue) { | ||
| // Class-typed entries must use the same thread context class loader route as the | ||
| // top-level Class handling above, because the resolver's String->Class converter | ||
| // resolves against the framework class loader and silently leaves an | ||
| // application-defined class (hibernate.configClass, for example) unbound. | ||
| if (propertyType == Class) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This branch and While adding that, worth pinning the fallback behavior: the top-level |
||
| return resolveClassValue(propertyPath) | ||
| } | ||
| if (rawValue instanceof Map && !propertyType.isInstance(rawValue)) { | ||
| return handleConverterNotFoundException(null, propertyType, propertyPath, fallBackValue, rawValue) | ||
| } | ||
| try { | ||
| Object value = propertyResolver.getProperty(propertyPath, propertyType) | ||
| Object rawPropertyValue = propertyResolver.getProperty(propertyPath, Object) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The raw lookup runs even when the typed lookup on the previous line already returned a non-null value, where its result is discarded. It's only needed in the |
||
| if (value == null && rawPropertyValue instanceof Map) { | ||
| if (propertyType.isInstance(rawPropertyValue)) { | ||
| return rawPropertyValue | ||
| } | ||
| return handleConverterNotFoundException(null, propertyType, propertyPath, fallBackValue, rawPropertyValue) | ||
| } | ||
| return value | ||
| } catch (ConversionFailedException e) { | ||
| return handleConversionException(e, propertyType, propertyPath, fallBackValue) | ||
| } catch (ConverterNotFoundException e) { | ||
| return handleConverterNotFoundException(e, propertyType, propertyPath, fallBackValue) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Builderis RUNTIME-retained on Groovy 5.0.8, so an argType annotated with@Builder(builderStrategy = SimpleStrategy)is intercepted by theargType.getAnnotation(Builder)branch earlier inbuildRecurseand never reaches thisgetPropertycall. The types that actually land here are ones with no runtime-visible@Builder(like the spec's synthetic settings classes). Two asks:handleConverterNotFoundExceptionjavadoc) so it doesn't name@Builder(SimpleStrategy)types as the case being handled here.HibernateConnectionSourceSettings, its nested types,ConnectionSourceSettings, andMultiTenancySettingsare all annotated and bind through the recursion branch (the pre-existing specs in this file exercise that path and passed on8.0.xbefore this change). Which shipped configuration reproduces the startup failure on8.0.x? Worth capturing in the issue/PR so the affected surface is clear.