matrei commented on code in PR #16798:
URL: https://github.com/apache/grails-core/pull/16798#discussion_r4431793058
##########
grails-doc/src/en/guide/conf/config/ymlPropertyOptions.adoc:
##########
@@ -34,6 +34,20 @@ production:
Similarly system arguments can be accessed.
+A placeholder is resolved wherever it appears in a value, including in an
element of a list or in a map inside one:
Review Comment:
This reads as if placeholders in list elements are now resolved everywhere,
but the change only covers `grailsApplication.config`. With Grails' YAML
loader, `NavigableMapPropertySource` also exposes the whole list under
`app.origins`. Both `Environment.getProperty('app.origins', List)` and Boot's
`Binder` return that list as it is, unresolved. The `Environment` resolves only
the by-index read (`app.origins[1]`).
I checked with:
```yaml
grails:
cors:
enabled: true
allowedOrigins:
- https://app.example.com
- '${EXTRA_ORIGIN:https://admin.example.com}'
```
- `config.getProperty('grails.cors.allowedOrigins', List)` gives
`[https://app.example.com, https://admin.example.com]` with this PR.
- `GrailsCorsConfiguration` bound through
`Binder.get(env).bind('grails.cors', ...)` gives `allowedOrigins ==
[https://app.example.com, ${EXTRA_ORIGIN:https://admin.example.com}]`, both on
7.0.x and with this PR.
- With Spring Boot's own YAML loader, the `Binder` gives
`https://admin.example.com`, because that loader only exposes the indexed keys.
So with this PR, `config` and `@ConfigurationProperties` beans disagree
about the same list. That doesn't block the code here. But I'd limit the docs
to `grailsApplication.config`, drop "as Spring's `Environment` does" for the
whole-list read (also in the table in the PR description), and open a follow-up
for the binder case. People reading this page will probably try it with
`grails.cors.allowedOrigins` first. Something like:
```suggestion
A placeholder in an element of a list, or in a map inside one, is resolved
when the list is read through `grailsApplication.config`:
```
##########
grails-doc/src/en/guide/conf/config/ymlPropertyOptions.adoc:
##########
@@ -34,6 +34,20 @@ production:
Similarly system arguments can be accessed.
+A placeholder is resolved wherever it appears in a value, including in an
element of a list or in a map inside one:
+
+[source,yaml]
+----
+app:
+ origins:
+ - https://app.example.com
+ - '${EXTRA_ORIGIN:https://admin.example.com}'
+----
+
+`grailsApplication.config` gives the resolved element whether the list is read
whole, with `config.getProperty('app.origins', List)`, or by index, with
`config.getProperty('app.origins[1]')`, as Spring's `Environment` does.
Review Comment:
```suggestion
`grailsApplication.config` gives the resolved element whether the list is
read whole, with `config.getProperty('app.origins', List)`, or by index, with
`config.getProperty('app.origins[1]')`.
NOTE: A list bound with `@ConfigurationProperties`, such as
`grails.cors.allowedOrigins`, still receives the placeholder as written when
`application.yml` is read through Grails' YAML loader.
```
##########
grails-core/src/test/groovy/org/grails/config/PropertySourcesConfigSpec.groovy:
##########
@@ -203,6 +207,194 @@ 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"
Review Comment:
The description says the copy means "the property source keeps what it holds
and resolves again when the config is refreshed", but no feature calls
`refresh()`. I tried it: change the referenced value, call `config.refresh()`,
and the list element follows while the source stays as written. Since that's
the reason for the copy, could you add a feature for it?
##########
grails-core/src/main/groovy/org/grails/config/PropertySourcesConfig.java:
##########
@@ -115,15 +115,13 @@ private void
mergeEnumerablePropertySource(EnumerablePropertySource enumerablePr
final String[] propertyNames =
enumerablePropertySource.getPropertyNames();
for (String propertyName : propertyNames) {
Object value =
enumerablePropertySource.getProperty(propertyName);
- if (value instanceof ConfigObject) {
- if (((ConfigObject) value).isEmpty()) continue;
- } else {
- value = processAndEvaluate(value);
+ if (value instanceof ConfigObject && ((ConfigObject)
value).isEmpty()) {
+ continue;
}
- map.put(propertyName, value);
+ map.put(propertyName, processAndEvaluate(value));
}
- configMap.merge(map, true);
+ mergeResolved(map);
Review Comment:
The merge hook now resolves strings, lists and maps under indexed keys, and
it recurses into plain maps. Is `processAndEvaluate()` still needed here? I
tried merging the raw values instead:
```java
map.put(propertyName, value);
// ...
configMap.merge(map, true);
```
All config specs in grails-core still pass: 240 tests, including this PR's
spec and the escaped-placeholder feature. That would let us drop
`mergeResolved()` and the `resolvingPlaceholders` flag that is toggled around a
merge. Ordinary sources would then get the same per-entry cache as the YAML
path, so there would be one resolver instead of two.
The trade-off: `processAndEvaluate()` copies every list and map, so the
config never holds an ordinary source's own list instance. Without it, a list
with no placeholder from a `MapPropertySource` would be held as given. A later
indexed write from a higher-priority source (`app.list[1]`) would then change
it in place, which already happens today for lists from Grails' YAML loader. If
that copy matters, I'd rather keep it explicit than get it as a side effect of
a second resolver. I see the #16771 merge-up note relies on the flag, so let me
know if there's a reason I'm missing.
##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -53,15 +57,116 @@
@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();
+
+ /**
+ * Whether the merge resolves the placeholders in the values it writes. It
does not while it merges values that are
+ * resolved already, so a placeholder an escape kept as text is not
resolved a second time.
+ */
+ private boolean resolvingPlaceholders = true;
+
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
- protected Object mergeMapEntry(NavigableMap targetMap, String
sourceKey, Object newValue) {
- if (newValue instanceof CharSequence) {
- newValue = resolvePlaceholders(newValue.toString());
+ 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 void mergeMapEntry(NavigableMap rootMap, String path,
NavigableMap targetMap, String sourceKey,
+ Object sourceValue, boolean parseFlatKeys, boolean
isNestedSet) {
+ Object value = sourceValue;
+ if (resolvingPlaceholders && (sourceValue instanceof CharSequence
|| sourceValue instanceof List || sourceValue instanceof Map &&
isIndexed(sourceKey))) {
Review Comment:
Nit: `a || b || c && d` relies on operator precedence. Parentheses around
`sourceValue instanceof Map && isIndexed(sourceKey)` would make it easier to
read, and wrapping the line would help too.
##########
grails-core/src/test/groovy/org/grails/config/PropertySourcesConfigSpec.groovy:
##########
@@ -203,6 +207,194 @@ 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 = """\
Review Comment:
Nit: `'''\` as in the later feature would avoid escaping `\${...}` here.
--
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]