You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/08/16 20:22:30 UTC
(grails-core) 01/01: fix(config): bind nested settings maps under Spring 7
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch fix/spring7-nested-map-conversion
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 5c99b411ff95ba5faf4afcfe4e9f5f40509f75d1
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun Aug 16 16:21:57 2026 -0400
fix(config): bind nested settings maps under Spring 7
Spring Framework 7 no longer converts a configuration Map into a type
annotated @Builder(builderStrategy = SimpleStrategy), so nested settings
failed to bind with ConverterNotFoundException. ConfigurationBuilder now
instantiates the target type and populates it from the Map.
The gap is demonstrable on this branch: with the previous ConfigurationBuilder
and only the new spec applied, six scenarios fail with "Expected exception of
type 'ConfigurationException', but got 'ConverterNotFoundException'". 9.0.x
resolves spring-core 7.0.8 via Spring Boot 4.1.0.
The fallback is deliberately narrow, and every guard below exists because
removing it produced an observable failure:
- It engages only when the cause chain contains ConverterNotFoundException,
so a converter that deliberately rejects a Map is not bypassed.
- ConfigurationException is never suppressed, so unknown-key and
malformed-value failures still surface instead of being masked by the
original conversion exception.
- A failure while resolving the raw value throws rather than silently
falling back, so configuration whose lookup failed is not quietly accepted.
- The instance inherits from the fallback before overrides are applied, and
each nested level receives its own fallback child, so overriding one field
does not discard the rest.
- Values are converted to the target property type, including the
case-insensitive enum path, so multiTenancy.mode: database still binds.
- Class-typed entries resolve through the thread context class loader, the
same route the top-level Class handling uses, because the resolver's
converter resolves against the framework class loader and would leave an
application class such as hibernate.configClass unbound.
- Types that are themselves a Map keep arbitrary entries. HibernateSettings
extends LinkedHashMap precisely to carry keys like hibernate.hbm2ddl.auto,
which strict property-only binding would have rejected.
- Flattened descendant keys are bound once through their parent rather than
rejected, since the resolver flattens nested configuration; a dotted key
whose first segment is unknown is still rejected.
- Setters are invoked with an explicit single-element argument array so an
explicit null clears an inherited value.
ConfigurationBuilderSpec grows from 10 to 22 specs covering each of the above.
Known limitation: a PropertyResolver that exposes only an aggregate map, and
not its entries as dotted properties, can still yield null for a configured
scalar. Grails' own DatastoreUtils.createPropertyResolver flattens and is
unaffected. Binding the raw value unconditionally was rejected as a fix
because it would bypass the type conversion above.
Assisted-by: claude-code:claude-opus-5
---
.../mapping/config/ConfigurationBuilder.groovy | 219 +++++++++-
.../mapping/config/ConfigurationBuilderSpec.groovy | 442 +++++++++++++++++++++
2 files changed, 644 insertions(+), 17 deletions(-)
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy
index a6c43d37b0..248b36dbe0 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy
@@ -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
+ }
+ 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) {
+ 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)
+ 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)
+ }
+ }
}
diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/config/ConfigurationBuilderSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/config/ConfigurationBuilderSpec.groovy
index ccc2ec6810..45fefe7182 100644
--- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/config/ConfigurationBuilderSpec.groovy
+++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/config/ConfigurationBuilderSpec.groovy
@@ -20,15 +20,23 @@
package org.grails.datastore.mapping.config
import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode
+import org.springframework.core.convert.ConversionFailedException
+import org.springframework.core.convert.ConverterNotFoundException
+import org.springframework.core.convert.TypeDescriptor
import org.grails.datastore.mapping.multitenancy.resolvers.FixedTenantResolver
import org.springframework.core.env.PropertyResolver
import org.springframework.util.ReflectionUtils
import spock.lang.Specification
import jakarta.persistence.FlushModeType
+import java.lang.reflect.InvocationHandler
+import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
+import java.lang.reflect.Proxy
import java.util.concurrent.TimeUnit
/**
@@ -111,6 +119,326 @@ class ConfigurationBuilderSpec extends Specification {
config.idleTimeBeforeConnectionTest == 600000
}
+ void "Test nested map conversion does not default malformed configuration"() {
+
+ given: "A malformed nested configuration map"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + ".strictNested"): [value: 'bad']
+ )
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The malformed nested value is rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('strictNested')
+ }
+
+ void "Test nested map conversion does not use fallback for malformed configuration"() {
+
+ given: "A fallback and a malformed nested configuration map"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + ".strictNested"): [value: 'bad']
+ )
+ def fallback = new StrictNestedConfig(strictNested: new StrictNestedSettings(value: 'fallback'))
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "The malformed nested value is rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('strictNested')
+ }
+
+ void "Test nested map conversion does not use fallback for scalar malformed configuration"() {
+
+ given: "A fallback and a scalar nested configuration value"
+ PropertyResolver config = Mock()
+ config.getProperty(Settings.PREFIX + ".strictNested", StrictNestedSettings, _) >> {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(StrictNestedSettings))
+ }
+ config.getProperty(Settings.PREFIX + ".strictNested", Object) >> 'bad'
+ def fallback = new StrictNestedConfig(strictNested: new StrictNestedSettings(value: 'fallback'))
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "The malformed nested value is rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('strictNested')
+ }
+
+ void "Test nested map conversion populates simple configuration types"() {
+
+ given: "A nested configuration map"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + ".strictNested"): [value: 'ok']
+ )
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The nested object is populated"
+ configuration.strictNested.value == 'ok'
+ }
+
+ void "Test nested map conversion rejects unknown properties"() {
+
+ given: "A nested configuration map with an unknown property"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + ".strictNested"): [valu: 'configured']
+ )
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The unknown property is rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('strictNested')
+ e.message.contains('valu')
+ }
+
+ void "Test nested map conversion preserves empty map defaults"() {
+
+ given: "An empty nested configuration map that Spring cannot convert directly"
+ PropertyResolver config = Mock()
+ config.getProperty(Settings.PREFIX + ".strictNested", StrictNestedSettings, null) >> {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(StrictNestedSettings))
+ }
+ config.getProperty(Settings.PREFIX + ".strictNested", Object) >> [:]
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The nested object is created with defaults"
+ configuration.strictNested != null
+ configuration.strictNested.value == null
+ }
+
+ void "Test nested map conversion handles ConversionFailedException wrapping ConverterNotFoundException"() {
+
+ given: "A wrapped converter-not-found failure and nested map"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ config.getProperty(propertyPath, StrictNestedSettings, _) >> {
+ throw conversionFailed(new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(StrictNestedSettings)))
+ }
+ config.getProperty(propertyPath, Object) >> [value: 'ok']
+ config.getProperty(propertyPath + '.value', String) >> 'ok'
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The map fallback is used"
+ configuration.strictNested.value == 'ok'
+ }
+
+ void "Test nested map conversion does not handle unrelated ConversionFailedException"() {
+
+ given: "A conversion failure not caused by a missing converter"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ config.getProperty(propertyPath, StrictNestedSettings, _) >> {
+ throw conversionFailed(new IllegalArgumentException('converter rejected value'))
+ }
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The original conversion failure is not bypassed"
+ def e = thrown(ConfigurationException)
+ e.message.contains('strictNested')
+ 0 * config.getProperty(propertyPath, Object)
+ }
+
+ void "Test nested map conversion propagates strict failure through wrapped converter exception"() {
+
+ given: "A wrapped converter-not-found failure and unknown nested property"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ config.getProperty(propertyPath, StrictNestedSettings, _) >> {
+ throw conversionFailed(new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(StrictNestedSettings)))
+ }
+ config.getProperty(propertyPath, Object) >> [valu: 'configured']
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The strict population failure is preserved"
+ def e = thrown(ConfigurationException)
+ e.message.contains('valu')
+ e.cause == null
+ }
+
+ void "Test nested map conversion rejects raw lookup failure instead of returning fallback"() {
+
+ given: "A fallback and a raw lookup failure"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ config.getProperty(propertyPath, StrictNestedSettings, _) >> {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(StrictNestedSettings))
+ }
+ config.getProperty(propertyPath, Object) >> {
+ throw new IllegalStateException('raw lookup failed')
+ }
+ def fallback = new StrictNestedConfig(strictNested: new StrictNestedSettings(value: 'fallback'))
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "The raw lookup failure is reported"
+ def e = thrown(ConfigurationException)
+ e.message.contains('raw lookup failed')
+ }
+
+ void "Test nested map conversion retains unspecified fallback fields"() {
+
+ given: "A fallback nested value and an override for one field"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + '.strictNested'): [value: 'configured']
+ )
+ def fallback = new StrictNestedConfig(strictNested: new StrictNestedSettings(value: 'fallback', inherited: 'retained'))
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "Only the configured field changes"
+ configuration.strictNested.value == 'configured'
+ configuration.strictNested.inherited == 'retained'
+ fallback.strictNested.value == 'fallback'
+ fallback.strictNested.inherited == 'retained'
+ }
+
+ void "Test nested map conversion retains unknown keys on map-backed types"() {
+
+ given: "A map-backed nested type with a declared property and an arbitrary key"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.mapBacked'
+ config.getProperty(propertyPath, MapBackedSettings, _) >> {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(MapBackedSettings))
+ }
+ config.getProperty(propertyPath, Object) >> [configClass: 'com.example.MyConfig', 'hibernate.hbm2ddl.auto': 'update']
+ config.getProperty(propertyPath + '.configClass', String) >> 'com.example.MyConfig'
+
+ when: "The configuration is built"
+ MapBackedConfig configuration = new MapBackedConfigurationBuilder(config).build()
+
+ then: "The declared property is bound via its setter and the arbitrary key is kept as a map entry"
+ configuration.mapBacked.configClass == 'com.example.MyConfig'
+ !configuration.mapBacked.containsKey('configClass')
+ configuration.mapBacked['hibernate.hbm2ddl.auto'] == 'update'
+ }
+
+ void "Test nested map conversion still rejects unknown keys on non-map types"() {
+
+ given: "A plain nested configuration map with a valid property and an unknown key"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + '.strictNested'): [value: 'ok', unknownKey: 'configured']
+ )
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The unknown key is still rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('Unknown setting')
+ e.message.contains('unknownKey')
+ }
+
+ void "Test nested map conversion applies explicit null over fallback value"() {
+
+ given: "A fallback nested value and an explicit null override"
+ PropertyResolver config = Mock()
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ config.getProperty(propertyPath, StrictNestedSettings, _) >> {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(StrictNestedSettings))
+ }
+ config.getProperty(propertyPath, Object) >> [inherited: null]
+ config.getProperty(propertyPath + '.inherited', String) >> null
+ def fallback = new StrictNestedConfig(strictNested: new StrictNestedSettings(value: 'fallback', inherited: 'retained'))
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "The explicit null replaces the inherited value"
+ configuration.strictNested.inherited == null
+ configuration.strictNested.value == 'fallback'
+ fallback.strictNested.inherited == 'retained'
+ }
+
+ void "Test nested map conversion supports lowercase enum values"() {
+
+ given: "A nested map with a lowercase enum value"
+ def config = DatastoreUtils.createPropertyResolver(
+ (Settings.PREFIX + '.strictNested'): [mode: 'database']
+ )
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The enum is converted case-insensitively"
+ configuration.strictNested.mode == MultiTenancyMode.DATABASE
+ }
+
+ void "Test nested map conversion accepts flattened descendants of known properties"() {
+
+ given: "A two-level nested configuration map flattened by the property resolver"
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ def config = converterNotFoundFor(
+ DatastoreUtils.createPropertyResolver((propertyPath): [nested: [value: 'configured']]),
+ propertyPath,
+ StrictNestedSettings
+ )
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The nested value is bound without treating its flattened key as unknown"
+ configuration.strictNested.nested.value == 'configured'
+ }
+
+ void "Test nested map conversion rejects flattened descendants of unknown properties"() {
+
+ given: "A two-level nested configuration map whose first segment is unknown"
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ def config = converterNotFoundFor(
+ DatastoreUtils.createPropertyResolver((propertyPath): [unknown: [value: 'configured']]),
+ propertyPath,
+ StrictNestedSettings
+ )
+
+ when: "The configuration is built"
+ new StrictNestedConfigurationBuilder(config).build()
+
+ then: "The unknown first segment is rejected"
+ def e = thrown(ConfigurationException)
+ e.message.contains('Unknown setting')
+ e.message.contains('unknown')
+ }
+
+ void "Test nested map conversion retains fallback fields at every level"() {
+
+ given: "A nested fallback value and an override for one child field"
+ String propertyPath = Settings.PREFIX + '.strictNested'
+ def config = converterNotFoundFor(
+ DatastoreUtils.createPropertyResolver((propertyPath): [nested: [value: 'configured']]),
+ propertyPath,
+ StrictNestedSettings
+ )
+ def fallback = new StrictNestedConfig(
+ strictNested: new StrictNestedSettings(nested: new NestedStrictSettings(value: 'fallback', inherited: 'retained'))
+ )
+
+ when: "The configuration is built"
+ StrictNestedConfig configuration = new StrictNestedConfigurationBuilder(config, fallback).build()
+
+ then: "The configured child field changes while its unspecified fallback field remains"
+ configuration.strictNested.nested.value == 'configured'
+ configuration.strictNested.nested.inherited == 'retained'
+ fallback.strictNested.nested.value == 'fallback'
+ fallback.strictNested.nested.inherited == 'retained'
+ }
+
static class TestConfigurationBuilder extends ConfigurationBuilder<ConnectionSourceSettings, ConnectionSourceSettings> {
TestConfigurationBuilder(PropertyResolver propertyResolver) {
@@ -132,6 +460,120 @@ class ConfigurationBuilderSpec extends Specification {
}
}
+ static class StrictNestedConfigurationBuilder extends ConfigurationBuilder<StrictNestedConfig, StrictNestedConfig> {
+
+ StrictNestedConfigurationBuilder(PropertyResolver propertyResolver) {
+ super(propertyResolver, Settings.PREFIX)
+ }
+
+ StrictNestedConfigurationBuilder(PropertyResolver propertyResolver, StrictNestedConfig fallback) {
+ super(propertyResolver, Settings.PREFIX, fallback)
+ }
+
+ @Override
+ protected StrictNestedConfig createBuilder() {
+ return new StrictNestedConfig()
+ }
+
+ @Override
+ protected StrictNestedConfig toConfiguration(StrictNestedConfig builder) {
+ return builder
+ }
+ }
+
+ static class StrictNestedConfig {
+
+ StrictNestedSettings strictNested
+
+ StrictNestedConfig strictNested(StrictNestedSettings strictNested) {
+ this.strictNested = strictNested
+ return this
+ }
+ }
+
+ static class StrictNestedSettings {
+
+ String value
+
+ String inherited
+
+ MultiTenancyMode mode
+
+ NestedStrictSettings nested
+
+ void setValue(String value) {
+ if (value == 'bad') {
+ throw new IllegalArgumentException('bad value')
+ }
+ this.value = value
+ }
+ }
+
+ static class NestedStrictSettings {
+
+ String value
+
+ String inherited
+ }
+
+ static class MapBackedConfigurationBuilder extends ConfigurationBuilder<MapBackedConfig, MapBackedConfig> {
+
+ MapBackedConfigurationBuilder(PropertyResolver propertyResolver) {
+ super(propertyResolver, Settings.PREFIX)
+ }
+
+ @Override
+ protected MapBackedConfig createBuilder() {
+ return new MapBackedConfig()
+ }
+
+ @Override
+ protected MapBackedConfig toConfiguration(MapBackedConfig builder) {
+ return builder
+ }
+ }
+
+ static class MapBackedConfig {
+
+ MapBackedSettings mapBacked
+
+ MapBackedConfig mapBacked(MapBackedSettings mapBacked) {
+ this.mapBacked = mapBacked
+ return this
+ }
+ }
+
+ static class MapBackedSettings extends LinkedHashMap<String, String> {
+
+ String configClass
+ }
+
+ private static ConversionFailedException conversionFailed(Throwable cause) {
+ return new ConversionFailedException(
+ TypeDescriptor.valueOf(Map),
+ TypeDescriptor.valueOf(StrictNestedSettings),
+ [:],
+ cause
+ )
+ }
+
+ private static PropertyResolver converterNotFoundFor(PropertyResolver delegate, String propertyPath, Class propertyType) {
+ return Proxy.newProxyInstance(
+ PropertyResolver.classLoader,
+ [PropertyResolver] as Class[],
+ { Object proxy, Method method, Object[] arguments ->
+ if (method.name == 'getProperty' && arguments.length == 3 && arguments[0] == propertyPath && arguments[1] == propertyType) {
+ throw new ConverterNotFoundException(TypeDescriptor.valueOf(Map), TypeDescriptor.valueOf(propertyType))
+ }
+ try {
+ return method.invoke(delegate, arguments)
+ } catch (InvocationTargetException e) {
+ throw e.targetException
+ }
+ } as InvocationHandler
+ ) as PropertyResolver
+ }
+
static class WithBuilderConfigurationBuilder extends ConfigurationBuilder<Config.ConfigBuilder, Config> {
WithBuilderConfigurationBuilder(PropertyResolver propertyResolver) {