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
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
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 @@
## 9.0.x #16160 +/- ##
==================================================
+ Coverage 53.1404% 53.1430% +0.0026%
- Complexity 19370 19384 +14
==================================================
Files 2080 2080
Lines 99000 99093 +93
Branches 17363 17387 +24
==================================================
+ Hits 52609 52661 +52
- Misses 38831 38863 +32
- Partials 7560 7569 +9
🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: 5c99b41 Learn more about TestLens at testlens.app. |
Fixes #16159.
The problem
Spring Framework 7 no longer converts a configuration
Mapinto a type annotated@Builder(builderStrategy = SimpleStrategy), so nested settings fail to bind withConverterNotFoundException.9.0.xresolvesspring-core7.0.8 via Spring Boot 4.1.0, so the affected version is already here.The gap is demonstrable rather than theoretical: with the previous
ConfigurationBuilderand only this PR's spec applied, six scenarios fail withConfigurationBuildernow instantiates the target type and populates it from the Map when, and only when, Spring genuinely has no converter.Why the fallback is this defensive
This code originated on the Groovy 6 canary branch (#15558), where it is not a Groovy 6 workaround at all - its own docstring says it is "independent of the Groovy version". Extracting it here removes 261 insertions from that branch and puts the fix where it belongs.
Extraction put it through two independent reviewers over five rounds, which surfaced nine 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 unboundMap-backed types keep arbitrary entriesHibernateSettings extends LinkedHashMapexists to carry keys likehibernate.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 on Groovy 5.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, and not its entries 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 listed above - the reviewers agreed that would be a net regression. Worth a follow-up that uses the raw value only when it is already assignable.Follow-up
Once this lands, the same change should be dropped from #15558, which then carries only genuine Groovy 6 workarounds.