fix(config): bind nested settings maps under Spring 7 - #16160
fix(config): bind nested settings maps under Spring 7#16160jamesfredley wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes configuration binding for nested settings maps under Spring Framework 7, where Spring no longer auto-converts Map values into Groovy @Builder(builderStrategy = SimpleStrategy) types (leading to ConverterNotFoundException during settings binding).
Changes:
- Adds a defensive fallback in
ConfigurationBuilderto instantiate and populate target types fromMapwhen (and only when) Spring genuinely has no converter. - Preserves strictness and inheritance semantics in the fallback path (unknown keys, malformed values, explicit nulls, map-backed settings, flattened descendant keys).
- Expands
ConfigurationBuilderSpecwith targeted regression coverage for Spring 7 conversion failure boundaries and the new fallback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy | Implements Spring 7 Map-to-builder-type fallback binding and related conversion/strictness handling. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/config/ConfigurationBuilderSpec.groovy | Adds extensive regression tests covering nested-map fallback binding, strictness, inheritance, and flattened key behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (mapBacked) { | ||
| ((Map) instance).put(key, val) | ||
| return | ||
| } |
|
The observation is correct. In the ((Map) instance).put(key, val)If the grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16160 +/- ##
==================================================
+ Coverage 52.3252% 53.1571% +0.8320%
- Complexity 18536 19390 +854
==================================================
Files 2039 2080 +41
Lines 97498 99093 +1595
Branches 17138 17387 +249
==================================================
+ Hits 51016 52675 +1659
+ Misses 38998 38850 -148
- Partials 7484 7568 +84
🚀 New features to boost your workflow:
|
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
5c99b41 to
7d5e684
Compare
✅ All tests passed ✅🏷️ Commit: 7d5e684 Learn more about TestLens at testlens.app. |
| } 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. |
There was a problem hiding this comment.
@Builder is RUNTIME-retained on Groovy 5.0.8, so an argType annotated with @Builder(builderStrategy = SimpleStrategy) is intercepted by the argType.getAnnotation(Builder) branch earlier in buildRecurse and never reaches this getProperty call. The types that actually land here are ones with no runtime-visible @Builder (like the spec's synthetic settings classes). Two asks:
- Reword this comment (and the
handleConverterNotFoundExceptionjavadoc) so it doesn't name@Builder(SimpleStrategy)types as the case being handled here. - ConfigurationBuilder fails to bind nested settings under Spring 7 #16159 states the Hibernate and connection-source settings trees are real consumers, but
HibernateConnectionSourceSettings, its nested types,ConnectionSourceSettings, andMultiTenancySettingsare all annotated and bind through the recursion branch (the pre-existing specs in this file exercise that path and passed on8.0.xbefore this change). Which shipped configuration reproduces the startup failure on8.0.x? Worth capturing in the issue/PR so the affected surface is clear.
| // 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) { |
There was a problem hiding this comment.
This branch and resolveClassValue aren't exercised by the new specs: MapBackedSettings.configClass is declared as String, and no test settings type has a Class-typed property, so the guard listed in the PR table ("Resolve Class entries via the thread context class loader") has no regression test. Could you add coverage for a nested Class-typed property — a Class literal value, a String class name, and an invalid class name?
While adding that, worth pinning the fallback behavior: the top-level argType == Class handling falls back to fallBackValue when no usable value is found, but resolveClassValue returns null, so a value that is neither a Class nor a CharSequence would silently clear an inherited fallback through the setter instead of retaining it (or erroring).
| } | ||
| try { | ||
| Object value = propertyResolver.getProperty(propertyPath, propertyType) | ||
| Object rawPropertyValue = propertyResolver.getProperty(propertyPath, Object) |
There was a problem hiding this comment.
The raw lookup runs even when the typed lookup on the previous line already returned a non-null value, where its result is discarded. It's only needed in the value == null case, so it can move inside that branch and save a resolver pass per nested scalar.
Fixes #16159.
The problem
Spring Framework 7 no longer converts a configuration
Mapinto a type annotated@Builder(builderStrategy = SimpleStrategy).ConfigurationBuilderdepends on that conversion to bind nested settings fromapplication.yml/application.groovy, so those settings now fail withConverterNotFoundExceptionat configuration-binding time - that is, at application startup.Grails 8 is the first affected version, because it is the first on Spring 7:
8.0.xpins Spring Boot4.1.0, which resolvesspring-core7.0.8.This targets
8.0.xso the fix flows up the maintenance chain (8.0.x->8.1.x->9.0.x) instead of landing only on the newest line. Both branches pin the same Spring Boot and Groovy versions, andConfigurationBuilder.groovyplus its spec are byte-identical on each, so one fix covers both.The gap is demonstrable rather than theoretical: with the previous
ConfigurationBuilderand only this PR's spec applied, six scenarios fail withThe fix
ConfigurationBuildernow instantiates the target type and populates it from the Map when, and only when, Spring genuinely has no converter.Two independent reviewers examined the handling over five rounds and found nine distinct defects. Every guard below exists because removing it produced an observable failure, and each has a regression test:
ConverterNotFoundExceptionConfigurationExceptionmultiTenancy.mode: databasewas rejected whereDATABASEworkedClassentries via the thread context class loaderhibernate.configClassand other application classes were left unbound, because that converter resolves against the framework class loaderMap-backed types keep arbitrary entriesHibernateSettings extends LinkedHashMapexists to carry keys such ashibernate.hbm2ddl.auto; strict binding rejected themStrictness is preserved where it belongs: a dotted key whose first segment is unknown is still rejected, and non-
Maptypes still reject unknown keys. Both are covered by guard specs so a future change cannot quietly relax them.Testing
ConfigurationBuilderSpecgrows from 10 to 22 specs; the module total goes from 108 to 120, all passing.Verified on the
8.0.xbase, not only on the branch this was originally written against.Some specs use a narrow
PropertyResolverproxy to reproduce the Spring 7 failure boundary, because the real resolver auto-converts the top-level bean and would bypass the handler entirely. Raw-map shape and dotted lookups still go through the realDatastoreUtils.createPropertyResolver.Known limitation
A
PropertyResolverthat exposes only an aggregate map, without its entries addressable as dotted properties, can still yield null for a configured scalar. Grails' ownDatastoreUtils.createPropertyResolverflattens and is unaffected. Binding the raw value unconditionally was considered and rejected because it would bypass the type conversion and case-insensitive enum handling above; both reviewers agreed that would be a net regression. A follow-up could use the raw value only when it is already assignable.Provenance
This was first written on the Groovy 6 canary branch (#15558), which is simply where the failure was noticed. It is not Groovy-version-specific - both branches are on Groovy
5.0.8and the code carries its own note to that effect - so it belongs here. Once this reaches9.0.xthrough the merge chain, the same change is dropped from #15558.