jdaugherty commented on code in PR #16732:
URL: https://github.com/apache/grails-core/pull/16732#discussion_r4379785534
##########
grails-core/src/main/groovy/org/grails/config/NavigableMapPropertySource.groovy:
##########
@@ -42,62 +44,97 @@ import org.springframework.util.StringUtils
@CompileStatic
class NavigableMapPropertySource extends MapPropertySource {
- final String[] propertyNames
- final String[] navigablePropertyNames
-
- private final Set<String> indexedLists
- private final Map<String, Object> elementProperties
+ private final Map<String, Object> wrappedElements
NavigableMapPropertySource(String name, NavigableMap source) {
super(name, source)
- Set<String> lists = new LinkedHashSet<>()
- Map<String, Object> elements = new LinkedHashMap<>()
+ Map<String, Object> wrapped = new LinkedHashMap<>()
for (String key : source.keySet()) {
- Object value = unwrap(source.get(key))
- if (value instanceof List && !((List) value).isEmpty()) {
- lists << key
- flatten(elements, key, value)
+ Object held = source.get(key)
+ if (held instanceof OriginTrackedValue && isList(unwrap(held))) {
+ // a list held wrapped whole cannot be stepped into or changed
through the configuration, so its
+ // elements are presented as they were when this source was
made
+ flatten(wrapped, key, unwrap(held))
}
}
- this.indexedLists = lists
- this.elementProperties = elements
+ this.wrappedElements = wrapped
+ }
+ /**
+ * The names of the values the source holds and of the elements of its
lists, read from the source on each
+ * call: a block is presented through its entries, which the source holds
under their own flat keys, a list
+ * through its elements and a map written by a map subscript, such as
{@code grails.cors.mappings[/api/**]},
+ * through its entries under their bracketed keys, so none of the three is
a name.
+ */
+ @Override
+ String[] getPropertyNames() {
Review Comment:
`getPropertyNames()` now rebuilds the names on every call, and Spring Boot
calls it on every environment lookup that this source does not answer. On a
miss, `SpringIterableConfigurationPropertySource.getConfigurationProperty`
falls back to `getCache()`. Outside `Binder.bind` that cache has no
time-to-live, so it is refreshed on every access. `Cache.tryUpdate` then calls
`getPropertyNames()` and compares the result with the last array using
`Arrays.equals`. While the array was built once, that comparison returned at
the identity check. Now each miss walks the whole source, and the `keySet()`
view snapshots every entry, just to build an array equal to the last one.
To measure it I loaded the `app1` `application.yml` into a
`StandardEnvironment` through `YamlPropertySourceLoader`, plus synthetic keys
for the larger rows, attached with `ConfigurationPropertySources.attach`. Each
figure is the best of seven runs of 2,000 calls:
| property names | `environment.getProperty('no.such.key')` on 9.0.x | this
PR |
|---|---|---|
| 56 | 6.9 µs | 12.4 µs |
| 400 | 3.6 µs | 24.9 µs |
| 1,500 | 1.7 µs | 90.4 µs |
`getPropertyNames()` alone takes 0.3 to 0.7 µs on 9.0.x, against 11, 23 and
88 µs here. The cost grows with the size of the configuration, and every
`@ConditionalOnProperty`, `@Value` or `environment.getProperty` outside a bind
pays it when the key is not in `application.yml`, at startup and at runtime.
Please return the same array while nothing has changed. One way: build the
names once, as 9.0.x does, and keep reading each element's value from its list
when it is asked for, which keeps the in-place reads this PR added. If a list
that grows or shrinks in place has to be enumerated as well, check something
cheap first, such as the size of the source and of the lists it holds, and
rebuild only when that has changed. Please add a spec that two calls with no
change in between return the same array, and that a change produces the new
names.
##########
grails-bootstrap/src/main/groovy/org/grails/config/NavigableMap.groovy:
##########
@@ -190,41 +537,325 @@ class NavigableMap implements Map<String, Object>,
Cloneable {
else {
subMap = new NavigableMap(targetMap.getRootConfig(),
newPathList.asImmutable())
if (currentValue instanceof Map) {
- subMap.putAll((Map) currentValue)
+ subMap.delegateMap.putAll((Map) currentValue)
}
}
String newPath = path ? "${path}.${sourceKey}" : sourceKey
- mergeMaps(rootMap, newPath , subMap, (Map) sourceValue,
parseFlatKeys)
+ // the entries of a map written in dot notation are written as
dot notation writes them, so a value
+ // replaces a block of the map it is merged into and a null
removes it
+ mergeMaps(rootMap, newPath, subMap, (Map) sourceValue,
parseFlatKeys, isNestedSet)
newValue = subMap
} else {
- newValue = sourceValue
+ newValue = merged(sourceValue)
}
if (isNestedSet && newValue == null) {
- if (path) {
-
- def subMap = rootMap.get(path)
- if (subMap instanceof Map) {
- subMap.remove(sourceKey)
- }
- def keysToRemove = rootMap.keySet().findAll() { String key
->
- key.startsWith("${path}.")
- }
- for (key in keysToRemove) {
- rootMap.remove(key)
- }
+ // the flat key of the removed property and those below it go
with it; the flat keys of its siblings stay
+ String removedKey = path ? "${path}.${sourceKey}".toString() :
sourceKey
+ removeKeysBelow(rootMap, removedKey)
+ if (rootMap != null) {
+ rootMap.delegateMap.remove(removedKey)
}
- targetMap.remove(sourceKey)
+ targetMap.delegateMap.remove(sourceKey)
} else {
- if (path) {
- rootMap.put("${path}.${sourceKey}".toString(), newValue)
+ if (isNestedSet && !(newValue instanceof NavigableMap) &&
targetMap.delegateMap.get(sourceKey) instanceof NavigableMap) {
+ // a value written over a block replaces it, so no flat
key of its entries is read any more; a merge
+ // keeps them, as the environment reads a key a
lower-precedence property source sets
+ removeKeysBelow(rootMap, path ?
"${path}.${sourceKey}".toString() : sourceKey)
+ }
+ if (path && rootMap != null) {
+ mergeMapEntry(rootMap, "${path}.${sourceKey}".toString(),
newValue)
}
mergeMapEntry(targetMap, sourceKey, newValue)
}
}
}
+ /**
+ * An entry of a map a subscript wrote merged over the one it meets. A map
is merged into the map a subscript
+ * wrote that it meets, in place, so every holder of it sees the merge, or
into a copy of any other map, entry
+ * by entry, so that a property source above another adds to an element as
Spring Boot binds both and the
+ * configuration holds no map of a property source. A block is merged into
it by the dotted names of its
+ * entries, as dotted keys are. Anything else is stored as a merge stores
it; a {@code null} written in dot
+ * notation removes the entry.
+ */
+ private Object mergedElement(Object current, Object value, boolean
isNestedSet) {
+ boolean currentIsMap = current instanceof Map && !(current instanceof
NavigableMap)
+ if (!(value instanceof Map)) {
+ return merged(value)
+ }
+ Map held
+ if (currentIsMap && current instanceof HashMap && (current instanceof
SubscriptMap || !(value instanceof SubscriptMap))) {
+ held = (Map) current
+ } else {
+ held = value instanceof SubscriptMap || current instanceof
SubscriptMap ? new SubscriptMap() : new LinkedHashMap()
+ if (currentIsMap) {
+ held.putAll((Map) current)
+ if (current instanceof SubscriptMap) {
+ ((SubscriptMap)
held).getMarkedKeys().addAll(((SubscriptMap) current).getMarkedKeys())
+ }
+ }
+ }
+ if (value instanceof NavigableMap) {
+ mergeBlockInto(held, '', (NavigableMap) value, isNestedSet)
+ return held
+ }
+ for (Map.Entry entry in ((Map) value).entrySet()) {
+ String key = String.valueOf(entry.key)
+ if (entry.value == null && isNestedSet) {
+ held.remove(key)
+ } else {
+ held.put(key, mergedElement(held.get(key), entry.value,
isNestedSet))
+ }
+ if (held instanceof SubscriptMap && value instanceof SubscriptMap)
{
+ if (((SubscriptMap) value).marks(entry.key)) {
+ ((SubscriptMap) held).mark(key)
+ } else {
+ ((SubscriptMap) held).unmark(key)
+ }
+ }
+ }
+ held
+ }
+
+ /**
+ * Merges the entries of a block into a map a subscript wrote under the
names their dotted paths give them, as
+ * dotted keys are written there.
+ */
+ private void mergeBlockInto(Map held, String prefix, NavigableMap block,
boolean isNestedSet) {
+ for (Map.Entry<String, Object> entry in
block.getDelegateMap().entrySet()) {
+ String name = prefix + entry.key
+ if (entry.value instanceof NavigableMap) {
+ mergeBlockInto(held, name + '.', (NavigableMap) entry.value,
isNestedSet)
+ } else {
+ writeBelowSubscriptMap(held, name, entry.value, isNestedSet)
+ }
+ }
+ }
+
+ /** Removes the flat keys below a flat key, those of the entries of the
block it named. */
+ private void removeKeysBelow(NavigableMap rootMap, String flatKey) {
+ if (rootMap == null) {
+ return
+ }
+ String prefix = flatKey + '.'
+ List<String> keysBelow = rootMap.keySet().findAll { String key ->
key.startsWith(prefix) } as List<String>
+ for (String key in keysBelow) {
+ rootMap.delegateMap.remove(key)
+ }
+ }
+
+ /** The index of the first subscript of a key such as {@code
rows[0].name}, or -1 when the key has none. */
+ private static int subscriptStart(String key) {
+ int start = key.indexOf('[')
+ start > -1 && key.indexOf(']', start) > start ? start : -1
+ }
+
+ /**
+ * Merges a key with a subscript, such as {@code rows[0].name}, {@code
groups[0][1]} or {@code mappings[/api/**]}.
+ * The container named before the first subscript, a list for a numeric
index and a map otherwise, is held by
+ * the nested map that owns it and, under its flat key, by the root, as
one object in both places. Nothing is
+ * held per element: {@code a.rows[0]} and {@code a.rows[0].name} are read
through the container, so they
+ * answer from the list as it is when they are read. The dots of the
container's name are a path only when
+ * the merge parses flat keys, as for any other key.
+ */
+ private void mergeIndexedEntry(NavigableMap rootMap, String path,
NavigableMap targetMap, String containerName,
+ String subscripts, Object sourceValue,
boolean parseFlatKeys, boolean isNestedSet) {
+ NavigableMap containerParent = targetMap
+ String containerKey = containerName
+ if (parseFlatKeys && containerName.contains('.')) {
+ String[] containerParts = containerName.split(/\./)
+ Object[] below = subscriptMapOnPath(targetMap, containerParts,
containerParts.length - 1)
+ if (below != null) {
+ // a container below a map a subscript wrote is an entry of
that map, written in place
+ Object[] slot = slotBelow((Map) below[0], (String) below[1],
true)
+ Map holder = (Map) slot[0]
+ int end = subscripts.indexOf(']')
+ holder.put(slot[1], setSubscriptValue(holder.get(slot[1]),
subscripts.substring(1, end), subscripts.substring(end + 1),
+ merged(sourceValue)))
+ return
+ }
+ List<String> nameParts = containerName.split(/\./) as List<String>
+ containerParent = targetMap.navigateSubMap(nameParts[0..-2], true,
rootMap)
+ containerKey = nameParts[-1]
+ }
+ String containerPath = path ? "${path}.${containerName}".toString() :
containerName
+ int subscriptEnd = subscripts.indexOf(']')
+ Object previousContainer = containerParent.get(containerKey)
+ Object value = merged(sourceValue)
+ Object container = setSubscriptValue(previousContainer,
subscripts.substring(1, subscriptEnd),
+ subscripts.substring(subscriptEnd + 1), value)
+ if (isNestedSet && previousContainer instanceof NavigableMap &&
!container.is(previousContainer)) {
+ // a list written over a block replaces it, as a value written
over a block does
+ removeKeysBelow(rootMap, containerPath)
+ }
+ mergeMapEntry(containerParent, containerKey, container)
+ if (rootMap != null && !containerParent.is(rootMap)) {
+ mergeMapEntry(rootMap, containerPath, container)
+ }
+ }
+
+ /** Marks an indexed key that names no element. */
+ private static final Object ABSENT = new Object()
+
+ /**
+ * The element an indexed key names, such as {@code a.rows[0].name}: read
through the container this map holds
+ * under the name before the first subscript, then the element each
subscript and each name after it steps
+ * through, so that the key answers from the list as it is now, however
the list was written and whatever
+ * was set, added or removed in it since. A name after a subscript is
matched against the longest key the
+ * element holds, since a merge stores what follows the first subscript of
an indexed key, {@code tags[0]} or
+ * {@code meta.old}, as one key of the element. {@link #ABSENT} when the
key has no subscript or names nothing.
+ */
+ private Object elementAt(String key) {
+ Object[] slot = elementSlot(key)
+ if (slot == null) {
+ return ABSENT
+ }
+ Object container = slot[0]
+ String step = (String) slot[1]
+ container instanceof List ? ((List) container).get(step.toInteger()) :
((Map) container).get(step)
+ }
+
+ /**
+ * The container the last step of an indexed key reads from and that step,
as {@code [list, index]} or
+ * {@code [map, key]}, reached by stepping through the containers and
elements before it; null when the key
+ * has no subscript or steps through nothing.
+ */
+ private Object[] elementSlot(String key) {
+ int subscript = subscriptStart(key)
+ if (subscript < 0) {
+ return null
+ }
+ String containerName = key.substring(0, subscript)
+ Object current = delegateMap.get(containerName)
+ if (current == null) {
+ Object atPath = atPath(containerName)
+ current = ABSENT.is(atPath) ? null : atPath
+ }
+ String remaining = key.substring(subscript)
+ while (current != null) {
+ Object next
+ String step
+ String rest
+ if (remaining.startsWith('[')) {
+ int end = remaining.indexOf(']')
+ if (end < 0) {
+ return null
+ }
+ step = remaining.substring(1, end)
+ rest = remaining.substring(end + 1)
+ next = subscriptOf(current, step)
+ } else if (remaining.startsWith('.') && current instanceof Map) {
+ String name = remaining.substring(1)
+ int split = longestKeyHeld((Map) current, name)
+ if (split < 0) {
+ return null
+ }
+ step = name.substring(0, split)
+ rest = name.substring(split)
+ next = ((Map) current).get(step)
+ } else {
+ return null
+ }
+ if (ABSENT.is(next)) {
+ return null
+ }
+ if (!rest) {
+ return [current, step] as Object[]
+ }
+ current = next
+ remaining = rest
+ }
+ null
+ }
+
+ /** The element at a subscript, of a list for a numeric index and of a map
otherwise; {@link #ABSENT} when there is none. */
+ private static Object subscriptOf(Object container, String index) {
+ if (index.isInteger() && !(container instanceof Map)) {
+ if (!(container instanceof List)) {
+ return ABSENT
+ }
+ List list = (List) container
+ int i = index.toInteger()
+ return i >= 0 && i < list.size() ? list.get(i) : ABSENT
+ }
+ container instanceof Map && ((Map) container).containsKey(index) ?
((Map) container).get(index) : ABSENT
+ }
+
+ /**
+ * The length of the longest prefix of {@code rest} that the map holds as
a key, ending where the rest ends or
+ * before one of its separators, a dot or a subscript; -1 when it holds
none.
+ */
+ private static int longestKeyHeld(Map map, String rest) {
+ int split = rest.length()
+ while (split > 0) {
+ if (map.containsKey(rest.substring(0, split))) {
+ return split
+ }
+ split = Math.max(rest.lastIndexOf('.', split - 1),
rest.lastIndexOf('[', split - 1))
+ }
+ -1
+ }
+
+ /**
+ * The value a merge stores for a scalar, in the nested entry and in the
flat key alike, and for each element
+ * of a list and each entry of a plain map it stores. A config resolves
placeholders here, so that a value
+ * such as {@code ${random.uuid}} is resolved once and both
representations hold the same result. A nested map
+ * stores a value as the root of its configuration does, so a value is
stored alike whichever map it is
+ * written through.
+ */
+ protected Object mergedValue(Object value) {
+ value
+ }
+
+ /**
+ * The value a merge stores: a scalar as {@link #mergedValue} returns it,
and a list or a plain map with each
+ * element or entry stored the same way, in place, so that a placeholder
inside a list element is resolved as
+ * one in an entry of a block is. A container that refuses the write, as a
list built with
+ * {@code asImmutable()} does, is replaced by a copy that holds the
results; one whose elements are stored as
+ * they are is left as it is.
+ */
+ private Object merged(Object value) {
Review Comment:
`merged` resolves placeholders in place, inside the list or map it is given,
so a merge now writes into objects the configuration does not own. Two cases
from the probe:
```groovy
def rows = [[name: '${app.name}']]
def config = new PropertySourcesConfig([app: [name: 'demo']])
config.merge([app: [rows: rows]])
rows // 9.0.x: [[name:${app.name}]] this PR: [[name:demo]]
```
With `app.rows: [{name: '${app.name}'}]` in a YAML file loaded into an
environment, `new PropertySourcesConfig(environment.propertySources)` rewrites
the list held by the file's `NavigableMapPropertySource`.
`propertySource.getProperty('app.rows[0].name')` is `${app.name}` before the
config is built and `demo` after it. Building a config, which only reads the
environment, now writes into it. From then on the environment answers that
element with the value the config resolved when it was built, instead of
resolving the placeholder against its property sources at lookup time.
Please resolve into a copy whenever an element changes, as `writeElement`
already does for an immutable list, so that neither the caller's containers nor
a property source's are written. A spec that merges a list and a map holding
placeholders and asserts the originals are unchanged would cover it.
##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -217,67 +222,29 @@ public <T> T getProperty(String key, Class<T> targetType,
T defaultValue, List<T
return value;
}
+ /**
+ * Reads the process environment again, so that a variable installed after
this config was
+ * created, as a test can do reflectively, is seen by the lookups that
follow.
+ */
+ protected void refreshSystemEnvironment() {
+ systemEnvironment = new SystemEnvironmentIndex(System.getenv());
+ }
+
@Override
public <T> T getProperty(String key, Class<T> targetType, T defaultValue) {
- Object value = findInSystemEnvironment(key);
+ Object value = systemEnvironment.get(key);
if (value == null) {
- value = getValueWithDotNotatedKeySupport(configMap, key);
+ // merge and dot-notation assignment write the flat key as well as
the nested entry, so the flat
+ // key answers nearly every lookup without a walk; where the two
disagree the flat key wins.
+ value = configMap.get(key);
Review Comment:
Confirmed at 92ea847952. After `merge(['server.url': 'x'])` then
`merge(['server.url.port': 'y'])`, `getProperty('server.url', Map)` is
`[port:y]`, `getProperty('server.url')` is `null`, and `navigate('server',
'url')` agrees.
##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -1151,3 +1151,18 @@ When upgrading an affected application:
----
See xref:theWebLayer#commandObjects[Command Objects] for a trait-based
alternative to sharing a non-domain superclass with a domain class.
+
+==== 17. Configuration Lookups Stop at a Value That Is Not a Map and Read the
Flat Key First
+
+`getProperty` on `grailsApplication.config` resolves a dotted key by walking
the nested maps of the configuration. In Grails 8 a key that ran past a value
that is not a map, such as `server.url.port` when `server.url` is a string, was
answered with that value, and `containsProperty` of such a key was `true`. The
same held for a key that ran past a list, or past a closure from
`application.groovy` such as `grails.gorm.default.constraints.nullable`. In
Grails 9 such a key is absent: `getProperty` returns `null` or the default,
`containsProperty` returns `false`, and `navigate` on the configuration answers
the same way.
+
+Grails 9 also reads the flat key that `merge` writes for every nested value
before it walks the nested maps. Every documented way of writing configuration,
that is the configuration files, `merge` and dot-notation assignment such as
`config.a.b = value`, writes both the flat key and the nested entry, so nothing
changes for them. What changes is which value `getProperty` answers with once
the two have been made to disagree:
+
+- A dotted key written directly, with `config.put('a.b', value)` or
`config['a.b'] = value`, is now read by `getProperty('a.b')`, as it always was
by `config['a.b']`.
+- A value written directly into a nested map, with `config.a.put('b', value)`
or `config.a['b'] = value`, is no longer read by `getProperty('a.b')`; dot
navigation, `config.a.b`, still reads it.
Review Comment:
Confirmed at 92ea847952. `config.a['b'] = 'x'` is read back as `'x'` by both
`getProperty('a.b')` and `config.a.b`.
##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -1229,3 +1229,26 @@ See <<asyncPromises,Promises>> and <<events,Events>>.
`grails-async-rxjava3` carries a `null` value as the RxJava 1 factory and
every other promise factory do: `createPromise()` and `createPromise(Class)`
give a promise whose `get()` returns `null`, a task that returns `null`
resolves to `null`, and a `null` value keeps its place in the list that
`onComplete`, `onError` and `waitAll` deliver.
`grails-async-rxjava2` does not: there `createPromise()` and `onComplete` over
a list holding a bound promise of `null` fail with a `NullPointerException`,
and a task that returns `null` resolves to the `java.lang.Void` class.
+
+==== 20. Configuration Lookups Stop at a Value That Is Not a Map and Read the
Flat Key First
Review Comment:
Most of this section is written in terms of `NavigableMap`'s internals: the
flat key, the nested entry, the two representations, blocks, `keySet()`, and
`clone()` of a nested map. An application that is upgrading cannot see or act
on those, and `NavigableMap` is deprecated. What an application can observe is
much shorter:
- a child of a string, list or closure is absent;
- a write through a nested map, such as `config.a.b = v`, `config.a.put('b',
v)`, `config.a.merge(...)` or `replaceAll`, is now read by `getProperty`;
- `toProperties()` names the entries of a map written with map subscripts by
their bracketed keys.
Please cut the section down to the observable changes, each with its
required action. Please also drop "Read the Flat Key First" from the title, and
the matching sentence in `whatsNew.adoc`: both describe how the lookup works
rather than what changes for the reader.
##########
grails-skills/upgrade-guide-9/skills/grails-9-upgrade/SKILL.md:
##########
@@ -438,6 +451,7 @@ A datastore implementation of RxGORM compiled for Grails 8
or earlier, such as t
- [ ] No build file declares `grails-async-gpars` or `grails-events-gpars`,
and no source file imports `groovyx.gpars`
- [ ] No source file calls `GParsPool.withPool` or `GParsConfig`
- [ ] No build file sets `grails { indy = true }`, and build logic reads
`grails.indy` with `getOrElse` or `present`
+- [ ] No code reads a child of a string, list or closure configuration value
expecting the parent, and none writes into a nested configuration map with
`put` and reads the value back through `getProperty`
Review Comment:
This item now contradicts the guide. `put` on a nested map writes both
representations in this PR, so `config.a.put('b', value)` followed by
`getProperty('a.b')` reads the value back. Only the first half of the item is
still something to check.
##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -1151,3 +1151,18 @@ When upgrading an affected application:
----
See xref:theWebLayer#commandObjects[Command Objects] for a trait-based
alternative to sharing a non-domain superclass with a domain class.
+
+==== 17. Configuration Lookups Stop at a Value That Is Not a Map and Read the
Flat Key First
+
+`getProperty` on `grailsApplication.config` resolves a dotted key by walking
the nested maps of the configuration. In Grails 8 a key that ran past a value
that is not a map, such as `server.url.port` when `server.url` is a string, was
answered with that value, and `containsProperty` of such a key was `true`. The
same held for a key that ran past a list, or past a closure from
`application.groovy` such as `grails.gorm.default.constraints.nullable`. In
Grails 9 such a key is absent: `getProperty` returns `null` or the default,
`containsProperty` returns `false`, and `navigate` on the configuration answers
the same way.
+
+Grails 9 also reads the flat key that `merge` writes for every nested value
before it walks the nested maps. Every documented way of writing configuration,
that is the configuration files, `merge` and dot-notation assignment such as
`config.a.b = value`, writes both the flat key and the nested entry, so nothing
changes for them. What changes is which value `getProperty` answers with once
the two have been made to disagree:
+
+- A dotted key written directly, with `config.put('a.b', value)` or
`config['a.b'] = value`, is now read by `getProperty('a.b')`, as it always was
by `config['a.b']`.
+- A value written directly into a nested map, with `config.a.put('b', value)`
or `config.a['b'] = value`, is no longer read by `getProperty('a.b')`; dot
navigation, `config.a.b`, still reads it.
+
+A dotted key inside a nested block, such as `org.springframework` under
`logging.level`, used to be written a second time as a key at the root, where
it could overwrite a real `org.springframework` property; it is now written
only under its parent, as `logging.level.org.springframework`.
Review Comment:
Confirmed at 92ea847952. Section 20 now names `config.merge(...)` with a
nested map as the case, and says the configuration files are flattened before
they are merged.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]