matrei commented on code in PR #16798:
URL: https://github.com/apache/grails-core/pull/16798#discussion_r4440130948
##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -53,15 +57,111 @@
@Deprecated
public abstract class NavigableMapConfig implements Config {
protected static final Logger LOG =
LoggerFactory.getLogger(NavigableMapConfig.class);
+
+ /**
+ * A key in brackets that is not an index and holds no dot, {@code [x]} of
{@code routes[x]}.
+ */
+ private static final Pattern NAMED_SUBSCRIPT =
Pattern.compile("\\[([^.\\[\\]]*[^.\\[\\]0-9][^.\\[\\]]*)]");
protected ClassLoader classLoader =
Thread.currentThread().getContextClassLoader();
protected ConfigurableConversionService conversionService = new
DefaultConversionService();
+
protected NavigableMap configMap = new NavigableMap() {
+
+ /**
+ * The texts the merge in progress has resolved, by the full name of
the entry and the text. The merge writes one
+ * entry under several keys: a dotted key under the whole key and in
its nested map, and a list element in its
+ * list in the nested map, in its list under the flat key and under
its indexed key. Every write takes one value,
+ * even for {@code ${random.uuid}}.
+ */
+ private Map<List<String>, String> resolvedTexts;
+
+ @Override
+ public void merge(Map sourceMap, boolean parseFlatKeys) {
+ resolvedTexts = new HashMap<>();
+ try {
+ super.merge(sourceMap, parseFlatKeys);
+ } finally {
+ resolvedTexts = null;
+ }
+ }
+
+ /**
+ * Resolves the placeholders in a value before it is written: in a
string, in the strings of a list, through
+ * the maps and lists it holds, and in a map written under an indexed
key, {@code rows[0]}, which is written into
+ * its list as it is. A map written under a plain key is merged entry
by entry, through this method.
+ */
@Override
- protected Object mergeMapEntry(NavigableMap targetMap, String
sourceKey, Object newValue) {
- if (newValue instanceof CharSequence) {
- newValue = resolvePlaceholders(newValue.toString());
+ protected void mergeMapEntry(NavigableMap rootMap, String path,
NavigableMap targetMap, String sourceKey,
+ Object sourceValue, boolean parseFlatKeys, boolean
isNestedSet) {
+ Object value = sourceValue;
+ if (sourceValue instanceof CharSequence || sourceValue instanceof
List ||
+ (sourceValue instanceof Map && isIndexed(sourceKey))) {
+ value = resolved(path == null || path.isEmpty() ? sourceKey :
path + '.' + sourceKey, sourceValue);
+ }
+ super.mergeMapEntry(rootMap, path, targetMap, sourceKey, value,
parseFlatKeys, isNestedSet);
+ }
+
+ private boolean isIndexed(String sourceKey) {
+ int subscriptStart = sourceKey.indexOf('[');
+ return subscriptStart > -1 && sourceKey.indexOf(']') >
subscriptStart;
+ }
+
+ /**
+ * The value with the placeholders in its strings resolved, through
the lists and maps it holds. A list or map
+ * holding no placeholder is the one given, and any other is a copy of
the same kind, so the property source
Review Comment:
Nit: "a copy of the same kind" holds for maps, but a list with a placeholder
is always copied into an `ArrayList`, so a `LinkedList` from
`application.groovy` becomes an `ArrayList` once it holds a placeholder, while
one without a placeholder stays a `LinkedList` via `copyOf()`. Either reword
the comment, or copy the list the way `shallowCopyOf()` does, if keeping the
kind matters.
##########
grails-core/src/test/groovy/org/grails/config/PropertySourcesConfigSpec.groovy:
##########
@@ -208,6 +209,257 @@ class PropertySourcesConfigSpec extends Specification {
!config.containsKey('gorm.url')
}
+ void 'a placeholder in a list element from application.yml is resolved as
the environment resolves it'() {
+ given: "application.yml with a placeholder in a list of values and in
a list of maps"
+ def yml = '''\
+app:
+ origins:
+ - https://a.example.com
+ - '${EXAMPLE_ORIGIN:https://default.example.com}'
+ rows:
+ - url: '${EXAMPLE_ORIGIN:https://default.example.com}'
+ name: first
+ db:
+ host: '${EXAMPLE_ORIGIN:https://default.example.com}'
+ tags:
+ - '${EXAMPLE_ORIGIN:https://default.example.com}'
+'''
+ def source = new YamlPropertySourceLoader().load('application.yml',
new ByteArrayResource(yml.bytes)).first()
+ def environment = new StandardEnvironment()
+ environment.propertySources.addFirst(source)
+ ConfigurationPropertySources.attach(environment)
+ def config = new PropertySourcesConfig(environment.propertySources)
+
+ expect: "the environment resolves each element"
+ environment.getProperty('app.origins[1]') ==
'https://default.example.com'
+ environment.getProperty('app.rows[0].url') ==
'https://default.example.com'
+
+ and: "the config answers the same, by index and in the whole list"
+ config.getProperty('app.origins[1]') == 'https://default.example.com'
+ config.getProperty('app.origins', List) == ['https://a.example.com',
'https://default.example.com']
+ config.navigate('app', 'origins') == ['https://a.example.com',
'https://default.example.com']
+ config.getProperty('app.rows[0].url') == 'https://default.example.com'
+ config.navigate('app', 'rows')[0].url == 'https://default.example.com'
+ config.navigate('app', 'rows')[0].name == 'first'
+
+ and: "so does a map or a list inside an element"
+ environment.getProperty('app.rows[0].db.host') ==
'https://default.example.com'
+ config.getProperty('app.rows[0].db.host') ==
'https://default.example.com'
+ config.navigate('app', 'rows')[0].db.host ==
'https://default.example.com'
+ config.navigate('app', 'rows')[0].tags ==
['https://default.example.com']
+ }
+
+ void 'a placeholder in a list held whole is resolved'() {
+ given: "a list held whole, as application.groovy gives it, of values,
of maps and of lists"
+ def map = new NavigableMap()
+ map.merge([app: [origins: ['https://a.example.com',
'${EXAMPLE_ORIGIN:https://default.example.com}'],
+ rows : [[url:
'${EXAMPLE_ORIGIN:https://default.example.com}', port: 8080]],
+ groups :
[['${EXAMPLE_ORIGIN:https://default.example.com}']]]], true)
+ def config = new PropertySourcesConfig(new
NavigableMapPropertySource('application.groovy', map))
+
+ expect:
+ config.getProperty('app.origins', List) == ['https://a.example.com',
'https://default.example.com']
+ config.navigate('app', 'origins') == ['https://a.example.com',
'https://default.example.com']
+ config.navigate('app', 'rows') == [[url:
'https://default.example.com', port: 8080]]
+ config.navigate('app', 'groups') == [['https://default.example.com']]
+
+ and: "the property source keeps the list as written"
+ map.navigate('app', 'origins') == ['https://a.example.com',
'${EXAMPLE_ORIGIN:https://default.example.com}']
+ }
+
+ void 'a list element follows the value its placeholder names when the
config is refreshed, and #description keeps the placeholder'() {
+ given: "a list with a placeholder, and a property source holding the
value it names"
+ def values = [exampleHost: 'first']
+ def propertySources = new MutablePropertySources()
+ propertySources.addFirst(new MapPropertySource('values', values))
+ propertySources.addLast(source)
+ def config = new PropertySourcesConfig(propertySources)
+
+ expect:
+ config.getProperty('app.origins', List) == ['http://first']
+
+ when: "the value changes and the config is refreshed"
+ values.exampleHost = 'second'
+ config.refresh()
+
+ then: "the list element follows it, in the whole list and by
navigation"
+ config.getProperty('app.origins', List) == ['http://second']
+ config.navigate('app', 'origins') == ['http://second']
+
+ and: "the property source still holds the placeholder"
+ source.getProperty('app.origins') == ['http://${exampleHost}']
+
+ where:
+ description | source
+ 'application.yml' | new
YamlPropertySourceLoader().load('application.yml', new
ByteArrayResource('app:\n origins:\n -
http://${exampleHost}\n'.bytes)).first()
+ 'application.groovy' | new
NavigableMapPropertySource('application.groovy', new NavigableMap().tap {
merge([app: [origins: ['http://${exampleHost}']]], true) })
+ 'an ordinary property source' | new MapPropertySource('ordinary',
['app.origins': ['http://${exampleHost}']])
+ }
+
+ void 'a list from #description is held as a copy, so an indexed write from
a source of higher precedence leaves the source as it is'() {
+ given:
+ def propertySources = new MutablePropertySources()
+ propertySources.addFirst(new MapPropertySource('higher',
['app.origins[1]': 'https://c.example.com']))
+ propertySources.addLast(source)
+
+ when:
+ def config = new PropertySourcesConfig(propertySources)
+
+ then: "the config holds the written element under each name of the
list"
+ config.getProperty('app.origins', List) == ['https://a.example.com',
'https://c.example.com']
+ config['app.origins'] == ['https://a.example.com',
'https://c.example.com']
+ config.navigate('app', 'origins') == ['https://a.example.com',
'https://c.example.com']
+
+ and: "a list of the same kind, while the source keeps its own"
+ config.navigate('app', 'origins').getClass() ==
source.getProperty('app.origins').getClass()
+ source.getProperty('app.origins') == ['https://a.example.com',
'https://b.example.com']
Review Comment:
About not being able to reproduce the in-place change for `application.yml`:
on 7.0.x it's the source's nested list that changes, not the flat key
`getProperty` reads. The YAML loader's map holds `app.origins` and `app →
origins` as two separate lists, and the indexed write lands in the nested one.
On the next merge the indexed keys write `b` back, which is why a rebuilt
config never shows it. 17ff33c8a3 fixes it either way, but this row passes on
7.0.x as written. Asserting the nested list too makes it fail there and pass
here (I checked both):
```groovy
source.getSource().navigate('app', 'origins') == ['https://a.example.com',
'https://b.example.com']
```
It only applies to the two `NavigableMapPropertySource` rows, so it would
need its own feature or a guard.
--
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]