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 <[email protected]>
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) {

Reply via email to