Repository navigation
Bind flattened dataSource.properties from the Spring Environment - #16736
jamesfredley wants to merge 9 commits into
Conversation
Spring's Environment does not return a nested map for dataSource.properties, so YAML pool settings such as maximumPoolSize never reached HikariCP. Rebuild that map from flattened keys when the direct lookup is empty, and leave an existing map unchanged. Fixes #16567 Assisted-by: Sisyphus:grok-4.7
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
3 open issues
What changed in this PR
Ensures dataSource.properties.* entries defined as flattened Spring Environment properties (e.g., from application.yml) are correctly bound into the datasource configuration map so downstream HikariCP settings are applied.
Changes:
- Add
resolveMapPropertyto reconstruct nestedMapvalues from flattenedEnvironmentkeys when direct map binding is empty/missing. - Use the new map-resolution logic when binding map-typed configuration values.
- Add Spock specs covering flattened binding, property-source precedence, and mixed map/flattened scenarios.
| File | Description |
|---|---|
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy | Rebuilds map config values from flattened Spring Environment property keys when direct map binding yields null/empty. |
| grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/jdbc/connections/DataSourceConnectionSourceFactorySpec.groovy | Adds tests validating flattened datasource pool property binding and precedence behavior for Spring Environment. |
Review effort: Lite
The Hikari assertions no longer assume a fixed two-proxy chain. Assisted-by: Sisyphus:grok-4.7
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16736 +/- ##
==================================================
+ Coverage 62.1821% 62.2391% +0.0570%
- Complexity 26900 26967 +67
==================================================
Files 2241 2241
Lines 110736 110829 +93
Branches 20218 20246 +28
==================================================
+ Hits 68858 68979 +121
+ Misses 32615 32573 -42
- Partials 9263 9277 +14
🚀 New features to boost your workflow:
|
development, test, and production each apply their own pool settings. An unset grails.env uses development. A key the active block does not set stays at the root value. Assisted-by: Sisyphus:grok-4.7
jdaugherty
left a comment
There was a problem hiding this comment.
Verified at c8b26a4:
- The new specs reproduce #16567. Against the base
ConfigurationBuilder, all 4DataSourceEnvironmentYamlSpecfeatures and 10 of the 23DataSourceConnectionSourceFactorySpecfeatures fail. At the PR head, grails-datastore-core (326), the factory spec (23) and the YAML spec (4) pass locally. - Cause confirmed:
NavigableMapPropertySource.getPropertyreturnsnullfor a nested map and leaves it out ofgetPropertyNames, so the directMapread on the Environment is empty. The 7.0.x source hides the map the same way; what changed on 8.0.x is which definition builds the pool.DataSourceGrailsPluginleaves thedataSourcebean toHibernateDatastoreConnectionSourcesRegistrarwhen the hibernate plugin is present, and that SpEL passesenvironment. - The Groovy Snapshot Canary failures on this head are
mongo:8.0container startups, unrelated. The CI workflow is green at c8b26a4.
Inline: one precedence bug when a nested key meets a scalar across property sources, a coverage ask for the @Builder map branch that HibernateSettings takes, and the application.groovy claim in the description, which the Groovy 5 ConfigSlurper never reaches. The dataSources.dataSource note is a follow-up.
|
@jamesfredley reminder this is ready for another pass |
matrei
left a comment
There was a problem hiding this comment.
I checked this locally at c8b26a4 alongside jdaugherty's review, and I agree with the points there. I have one design question and two more edge cases.
The registrar is the only caller that reads the raw Environment. HibernateDatastore already works around the hidden maps. Its constructors pass the configuration through DatastoreUtils.preparePropertyResolver(configuration, 'dataSource', 'hibernate', 'grails'), which rebuilds nested maps from the flattened keys of a ConfigurableEnvironment. In a Grails app it gets grailsApplication.config instead, which that method returns as is. HibernateDatastoreConnectionSourcesRegistrar is the only caller that passes the raw environment to dataSourceConnectionSourceFactory.create. CachedDataSourceConnectionSourceFactory caches the pool by name, so whichever of the two runs first decides the pool for both.
I reverted this PR's ConfigurationBuilder change and ran the issue's YAML through create('default', DatastoreUtils.preparePropertyResolver(environment, 'dataSource')). That gives maximumPoolSize 16, connectionTimeout 2000 and the dataSourceProperties collation, and a higher-precedence dataSource.properties.minimumIdle still wins. It also resolves the dataSources: dataSource: form from the follow-up comment. On this PR's head, that form still falls back to the built-in H2 URL with empty properties.
Building the registrar's bean through the same call HibernateDatastore makes would be a one-line change in the hibernate5 and hibernate7 registrars. It would leave ConfigurationBuilder unchanged. Every datastore shares that class, including the @Builder map branch flagged above. The trade-off is that this PR also fixes callers that pass a raw Environment to a ConfigurationBuilder directly. Was this approach considered? Even if the generic fix stays, I think the registrar and the datastore should resolve the default data source the same way, because the cache hands the pool to whichever one runs first.
The map is now rebuilt from each property source in precedence order, so the highest source wins for every path whatever its shape: - A scalar or map from a higher source is not replaced by a lower nested key, map, or unresolved placeholder. - A map stored at the exact path is read per source instead of being returned early, so higher flattened keys still win over it. - Indexed keys such as connectionInitSqls[0] build a List, copying an existing list instead of mutating it. - Literal dotted keys such as oracle.jdbc.flag are kept as written and block the same nested path from lower sources and from later entries of the same source. - A string the conversion service turns into a map is used at its own precedence, after placeholder resolution. String values still go through Environment.getProperty, so the unresolvable-placeholder setting is kept. ConfigurationBuilderSpec now covers the Environment path, including a map-backed @builder(SimpleStrategy) type and an unchanged DatastoreUtils resolver. The datasource specs cover a YAML list and the dotted application.groovy form. Assisted-by: Sisyphus:grok-4.7 Assisted-by: Sisyphus:claude-opus-5-5
matrei
left a comment
There was a problem hiding this comment.
I rechecked at 84e8282. Locally, grails-datastore-core (354), DataSourceConnectionSourceFactorySpec (23) and DataSourceEnvironmentYamlSpec (6) all pass. Both points from my last review are fixed. A YAML list under dataSource.properties now binds as a list, including on a bean with DBCP2's connectionInitSqls getter and setter. A higher flattened key now wins over a lower map stored at the exact path. Also, a higher list that is shorter than a lower one replaces it whole, as in Spring Boot.
The design question from my last review is still open. resolveMapProperty and its helpers now take about 380 lines of ConfigurationBuilder, which every datastore shares. Most of the helpers resolve conflicts between maps, literal dotted keys, strings that convert to maps, and scalars from different sources. The config loaders Grails ships never put a map at a path. Both the YAML and the Groovy loader produce a NavigableMapPropertySource, which returns null for map and list values and lists their contents as flattened names. Properties files, system properties and the command line are flat. Map values at a path come from a MapPropertySource built in code, as most of the new specs do, and from the ConfigObjectPropertySource in DatastoreUtils.
My earlier comment on the early return offered handling that case as one option, so part of this growth comes from my own suggestion. Now that I see the cost, I'd prefer a smaller fix. One way is to limit the Environment walk to flattened names, including name[n] lists, and document the direct-map case as out of scope. The other is to build the registrar's bean through DatastoreUtils.preparePropertyResolver, the way HibernateDatastore resolves its configuration. Was that considered?
A DatastoreEnvironment still merges prepared sources, so a key that exists only on a lower map is kept. The walk skips the systemProperties and systemEnvironment sources StandardEnvironment adds. Assisted-by: sisyphus-cursor-grok:grok-4.7
|
Fixed in 0b35b1c. A Returning the highest The generic walk stays for a raw |
jdaugherty
left a comment
There was a problem hiding this comment.
Verified at 0b35b1c:
- Locally, grails-datastore-core (355), grails-datamapping-core (1815) and grails-datasource (13) pass, including
ConfigurationBuilderSpec(58),DataSourceConnectionSourceFactorySpec(23) andDataSourceEnvironmentYamlSpec(6). - The five asks from my first review are addressed: a higher scalar now blocks a lower flattened descendant (the
flush.modecase), the@Buildermap branch is covered with aStandardEnvironmentand with aDatastoreUtilsresolver, theObject-typed read is gone, the description shows the dottedapplication.groovyform thatGroovyConfigPropertySourceLoaderloads and names the block form as separate, anddataSources.dataSourceis noted as a follow-up.
On the design question matrei left open, I support the narrowing option, for the reasons in the inline comment on resolveMapProperty: Spring Boot's MapBinder binds a Map from flattened descendants and reads a value stored at the exact path only when no source has a descendant, every loader Grails ships flattens, and 19 of the 24 features this PR adds to ConfigurationBuilderSpec exist to pin map-at-path cases those loaders cannot produce. "Callers that pass a raw environment still need it" holds, and the walk stays for them; what goes is the per-source map merging around it. My reply on the DatastoreEnvironment thread has the base-branch measurement behind the early return.
One pre-existing gap on the datastore path is noted on the YAML list feature as a follow-up, not for this PR.
A raw Spring Environment binds a configuration map from flattened names only. A map stored at the exact path is used only when no flattened name exists. DatastoreEnvironment still returns its prepared map and does not walk JVM or lower configuration sources. Assisted-by: Sisyphus:grok-4.7
A domain map constructor was binding through the generated request white list, so new Widget(id: 1) left id unset and link generation and the OpenAPI schema failed. A Groovy map literal may set id. A request parameter map and request.JSON keep the white list. Assisted-by: Sisyphus:grok-4.7
🚨 TestLens detected 4 failed tests 🚨Failed Jobs without Test FailuresTest SummaryTip Inspect all details with the TestLens CLI: CI / Build Grails Forge (Java 21, indy=false) > :grails-forge-core:test [grails-forge]
CI / Build Grails Forge (Java 21, indy=true) > :grails-forge-core:test [grails-forge]
CI / Build Grails Forge (Java 25, indy=false) > :grails-forge-core:test [grails-forge]
CI / Functional Tests (Java 25, indy=false) > :grails-test-examples-geb:integrationTest
🏷️ Commit: bc3f653 Test FailuresGraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation (:grails-forge-core:test [grails-forge] in CI / Build Grails Forge (Java 21, indy=false))GraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation (:grails-forge-core:test [grails-forge] in CI / Build Grails Forge (Java 21, indy=true))GraphqlGormSpec > test readme.md with feature gorm-graphql contains links to documentation (:grails-forge-core:test [grails-forge] in CI / Build Grails Forge (Java 25, indy=false))PerTestRecordingSpec > the recordings of the previous two tests are different (:grails-test-examples-geb:integrationTest in CI / Functional Tests (Java 25, indy=false))Rerun ControlsSelect tests to mute in this pull request:
Reuse successful test results:
Click the checkbox to trigger a rerun:
Learn more about TestLens at testlens.app/docs. |


Fixes #16567
Expected Behavior
The
dataSource.propertiesblock inapplication.ymlis applied to the HikariCP pool.With the config below, the pool should use
maximumPoolSize16 andminimumIdle8.The dotted
application.groovyform fails the same way:Actual Behaviour
The pool uses Hikari's defaults:
maximumPoolSize10 andminimumIdleequal tomaximumPoolSize.URL, username, and password from the same block are applied. Only the
propertiesmap is dropped.Cause
The
dataSourcebean is created from the SpringEnvironment:Environment.getProperty("dataSource.properties", Map)does not return the nested YAML map. The keys exist only as flattened entries such asdataSource.properties.maximumPoolSize. Because that map is empty, the Hikari binder never runs. The unconfigured pool is then cached and reused by GORM.Fix
A raw Spring
Environmentbinds a map setting from flattened names only, highest source first. The first source to define a key wins.dataSource.properties.maximumPoolSizeand the other flattened pool keys bind, including from anenvironmentsblock.name[n]builds a list, so a YAML list underdataSource.propertiesbinds as a list.flush.mode=COMMITis not overwritten by a lowerflush.mode.something.Environment.getProperty, so the unresolvable-placeholder setting is kept.application.groovyorDatastoreUtilscase, not YAML.DatastoreEnvironmentis prepared configuration. It returns its map unchanged and does not walk, so JVMsystemPropertiesandsystemEnvironmentare not merged in, and a lower configuration map is not merged either. That is the base-branch result. A resolver that is not aConfigurableEnvironmentstill returns its map unchanged. A malformed map value with no flattened children still fails.@Builder(builderStrategy = SimpleStrategy)settings, includingHibernateSettings, use the same binding, so a SpringEnvironmentpicks up their flattened keys.No application change is required. The generated
application.ymlworks as written.The
environments.development,test, andproductionblocks override the rootdataSourcefor the active Grails environment. An unsetgrails.envuses development. A key the active block does not set, such asconnectionTimeoutundertest, stays at the root value.Not in this PR
dataSource { properties { ... } }inapplication.groovydoes not parse under Groovy 5.1.ConfigSlurperthrowsReadOnlyPropertyExceptionforproperties, so only the dotted form above loads.dataSources.dataSourceis still read with a direct map lookup inDataSourceConnectionSourceFactory, so it falls back to the rootdataSourcesettings. The guide documents only the rootdataSource:form.DatastoreUtils.preparePropertyResolveris still kept as literalname[n]keys. This PR does not change that parse. The datasource YAML path does bindconnectionInitSqlsas a list.