Skip to content

fix(config): bind nested settings maps under Spring 7 - #16160

Open
jamesfredley wants to merge 1 commit into
9.0.xfrom
fix/spring7-nested-map-conversion
Open

fix(config): bind nested settings maps under Spring 7#16160
jamesfredley wants to merge 1 commit into
9.0.xfrom
fix/spring7-nested-map-conversion

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

Fixes #16159.

The problem

Spring Framework 7 no longer converts a configuration Map into a type annotated @Builder(builderStrategy = SimpleStrategy), so nested settings fail to bind with ConverterNotFoundException. 9.0.x resolves spring-core 7.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 ConfigurationBuilder and only this PR's spec applied, six scenarios fail with

Expected exception of type 'ConfigurationException',
  but got 'org.springframework.core.convert.ConverterNotFoundException'

ConfigurationBuilder now 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:

Guard Failure without it
Engage only when the cause chain contains ConverterNotFoundException A converter that deliberately rejects a Map could be bypassed
Never suppress ConfigurationException Unknown-key and malformed-value failures were masked by the original exception
Throw on raw-lookup failure instead of falling back Configuration whose lookup had failed was silently accepted
Inherit from the fallback before applying overrides Overriding one field discarded every unspecified field
Pass each property's fallback child into nested conversion Inheritance worked only at the first level; deeper children were reset
Convert values to the target property type multiTenancy.mode: database was rejected where DATABASE worked
Resolve Class entries via the thread context class loader hibernate.configClass and other application classes were left unbound
Let Map-backed types keep arbitrary entries HibernateSettings extends LinkedHashMap exists to carry keys like hibernate.hbm2ddl.auto; strict binding rejected them
Bind flattened descendant keys through their parent The resolver flattens config, so anything nested more than one level failed to build
Invoke setters with an explicit single-element argument array The Java null-varargs pitfall meant an explicit null could not clear an inherited value

Strictness is preserved where it belongs: a dotted key whose first segment is unknown is still rejected, and non-Map types still reject unknown keys. Both are covered by guard specs so a future change cannot quietly relax them.

Testing

ConfigurationBuilderSpec grows from 10 to 22 specs; the module total goes from 108 to 120, all passing on Groovy 5.

:grails-datastore-core:compileGroovy   BUILD SUCCESSFUL
:grails-datastore-core:test --rerun-tasks   BUILD SUCCESSFUL, 46 tasks executed
tests=120 failures=0 errors=0
ConfigurationBuilderSpec tests=22 failures=0

Some specs use a narrow PropertyResolver proxy 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 real DatastoreUtils.createPropertyResolver.

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 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.

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
Copilot AI lite review requested due to automatic review settings August 16, 2026 20:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ConfigurationBuilder to instantiate and populate target types from Map when (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 ConfigurationBuilderSpec with 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.

Comment on lines +569 to +572
if (mapBacked) {
((Map) instance).put(key, val)
return
}
@bito-code-review

Copy link
Copy Markdown

The observation is correct. In the handleConverterNotFoundException method, the code iterates over the raw map entries and uses the original key object directly when putting it into the map-backed instance:

((Map) instance).put(key, val)

If the key is not a String (e.g., a GString), this can lead to issues where subsequent lookups using String keys fail, even though the code earlier in the loop correctly normalizes the key to a String for property matching (String propertyName = key.toString()). To ensure consistency and compatibility with String-based lookups, the code should use the normalized propertyName instead of the original key when populating the map.

grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy

if (mapBacked) {
                        ((Map) instance).put(propertyName, val)
                        return
                    }

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.00000% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.1430%. Comparing base (79e4889) to head (5c99b41).

Files with missing lines Patch % Lines
...tastore/mapping/config/ConfigurationBuilder.groovy 59.0000% 30 Missing and 11 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                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     
Files with missing lines Coverage Δ
...tastore/mapping/config/ConfigurationBuilder.groovy 65.2027% <59.0000%> (-3.2702%) ⬇️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Aug 16, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 5c99b41
▶️ Tests: 63161 executed
⚪️ Checks: 78/78 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants