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 a6c43d37b04..248b36dbe0f 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 { 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 { 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 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 resolvedProperties = [] as Set + ((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 ccc2ec68101..45fefe71827 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 { TestConfigurationBuilder(PropertyResolver propertyResolver) { @@ -132,6 +460,120 @@ class ConfigurationBuilderSpec extends Specification { } } + static class StrictNestedConfigurationBuilder extends ConfigurationBuilder { + + 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 { + + 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 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 { WithBuilderConfigurationBuilder(PropertyResolver propertyResolver) {