jdaugherty commented on code in PR #16130:
URL: https://github.com/apache/grails-core/pull/16130#discussion_r4348957914


##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -219,7 +223,7 @@ public <T> T getProperty(String key, Class<T> targetType, T 
defaultValue, List<T
 
     @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);

Review Comment:
   A question rather than an ask. The description measures reading the flat key 
before the walk at 84 ns and leaves it out because `config.a.put('b', x)` 
updates only the nested value. On the description's harness here (126 
variables) the walk costs about 32 ns per segment (substring, uncached hash, 
map read), so the four-segment keys are most of the 160 ns; flat-first measures 
91 ns, and with an allocation-free pre-filter (next comment) 55 ns, about six 
times faster than base.
   
   Is a raw `put` on a sub-map a write path this class supports? `NavigableMap` 
is `@Deprecated`, `NullSafeNavigator.put` throws, and every documented path 
(`merge`, dot-notation assignment, `setAt`) writes the flat key. If it is not 
supported, flat-first looks like the right default. If it is, the description 
should say which raw write wins, because the mirror case exists today: 
`merge([a: [b: 'nested']])` then `config.put('a.b', 'flat')` writes only the 
flat key, and `getProperty('a.b')` keeps returning `nested` on base and on this 
branch.



##########
grails-core/src/test/groovy/org/grails/config/PropertySourcesConfigSpec.groovy:
##########
@@ -116,4 +116,33 @@ class PropertySourcesConfigSpec extends Specification {
         then: 'the second value is not merged with the first value'
         cfg.foo.bar == ['two': '2']
     }
+
+    void 'a dotted key walks plain nested maps and skips empty segments'() {
+        given: "a plain map value that was put without flattened keys"
+        def config = new PropertySourcesConfig()
+        config.put('plain', [nested: [value: 'found']])
+
+        expect: "each segment of the key is resolved against the map it 
reaches"
+        config.getProperty('plain.nested.value') == 'found'
+        config.getProperty('plain.nested', Map) == [value: 'found']
+        config.getProperty('plain.missing.value') == null
+
+        and: "empty segments are ignored as they always were"
+        config.getProperty('plain..nested.value') == 'found'
+        config.getProperty('.plain.nested.value.') == 'found'
+        config.getProperty('...') == null
+    }
+
+    void 'a dotted key that runs past a scalar value is absent'() {
+        given: "a scalar value"
+        def config = new PropertySourcesConfig([server: [url: 
'http://localhost']])
+
+        expect: "the scalar itself resolves"
+        config.getProperty('server.url') == 'http://localhost'
+
+        and: "a key that names a child of the scalar is absent rather than the 
scalar"
+        config.getProperty('server.url.port') == null

Review Comment:
   Commit 9adc51487e changes what existing keys return. A key that ran past a 
non-Map value was answered with that value and is now absent, and 
`containsProperty` flips from true to false for it. It reaches more than 
scalars:
   
   - List parent: `a.b: [1, 2]`, `getProperty('a.b.0')` returned the list and 
now returns null.
   - Closure parent from `application.groovy`: `grails.gorm.default.constraints 
= { ... }`, `getProperty('grails.gorm.default.constraints.nullable')` returned 
the closure and `containsProperty` of it was true; now null and false (probed 
through `GroovyConfigPropertySourceLoader`).
   - The two representations also start to disagree differently: 
`merge(['server.url.port': 'y', 'server.url': 'x'])` in that order returns `x` 
today and `y` with this change; `put('a', 'scalar'); put('a.b', 1)` returns 
`scalar` today and `1`.
   
   Reading through a value like a closure may be relied on, and a change to the 
answer for existing keys is one for a major release, not 8.1.x. Please drop 
this commit from the PR and open it against 9.0.x, where it can carry the List 
and closure cases, the flat-key states above, `navigateMap`/`CodeGenConfig` 
(which still return the parent on this branch, so the two lookup paths would 
otherwise disagree), and an upgrade note.
   
   For 8.1.x the `indexOf` walk keeps the old semantics by leaving `value` 
untouched when a segment lands on a non-Map: the `else` branch at 
`NavigableMapConfig.java` lines 379-382 goes away, and this feature with it. 
The `StringTokenizer` removal and the measured gain stay.



##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -404,27 +360,115 @@ private Object 
getValueWithDotNotatedKeySupport(NavigableMap configMap, String k
             return null;
         }
 
-        List<String> keys = convertTokensIntoArrayList(new 
StringTokenizer(key, "."));
-        if (keys.size() == 0) {
-            return null;
-        }
-
         Object value = null;
-        for (int i = 0; i < keys.size(); i++) {
-            if (i == 0) {
-                value = configMap.get(keys.get(i));
-            } else if (value instanceof Map) {
-                value = ((Map) value).get(keys.get(i));
+        boolean first = true;
+        int length = key.length();
+        int start = 0;
+        while (start < length) {
+            int end = key.indexOf('.', start);
+            if (end < 0) {
+                end = length;
+            }
+            if (end > start) {
+                String segment = key.substring(start, end);
+                if (first) {
+                    value = configMap.get(segment);
+                    first = false;
+                } else if (value instanceof Map) {
+                    value = ((Map<?, ?>) value).get(segment);
+                } else {
+                    // The key names a child of a scalar, which does not exist 
whatever the scalar is.
+                    return null;
+                }
             }
+            start = end + 1;
         }
         return value;
     }
 
-    private List<String> convertTokensIntoArrayList(StringTokenizer st) {
-        List<String> elements = new ArrayList<>();
-        while (st.hasMoreTokens()) {
-            elements.add(st.nextToken());
+    /**
+     * The process environment as it stood when this config was created, 
indexed so that a key no
+     * variable can match is answered with one hash lookup.
+     * <p>
+     * A key matches a variable in any of the spellings Spring's {@code 
SystemEnvironmentPropertySource}
+     * accepts: as written, with dots replaced by underscores, with hyphens 
replaced by underscores,
+     * with both replaced, and the upper-cased form of each. Every one of 
those spellings has the same
+     * canonical name, so a key whose canonical name belongs to no variable 
cannot match and is
+     * rejected without probing. Only a key whose canonical name is present 
runs the spelling-by-spelling
+     * resolution, which keeps the precedence between spellings as it was.
+     * <p>
+     * The environment of a running JVM cannot change, so one snapshot per 
config instance is exact.
+     * The memory it takes is proportional to the number of environment 
variables, not to the size of
+     * the configuration or to the number of keys looked up.
+     */
+    private static final class SystemEnvironmentIndex {
+
+        /**
+         * {@code System.getenv(String)} matches a variable name 
case-insensitively on Windows, where
+         * the operating system does, and exactly everywhere else. The 
snapshot keeps that contract.
+         */
+        private static final boolean IS_WINDOWS = 
System.getProperty("os.name", "").startsWith("Windows");
+
+        private final Map<String, String> variables;
+        private final Set<String> canonicalNames;
+
+        SystemEnvironmentIndex(Map<String, String> environment) {
+            this.variables = IS_WINDOWS ? new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER) : new HashMap<>();
+            this.variables.putAll(environment);
+            this.canonicalNames = new HashSet<>();
+            for (String name : variables.keySet()) {
+                canonicalNames.add(canonicalName(name));
+            }
+        }
+
+        String get(String key) {
+            if (key == null || !canonicalNames.contains(canonicalName(key))) {
+                return null;
+            }
+            return variables.get(resolvePropertyName(key));
+        }
+
+        private static String canonicalName(String name) {
+            return name.toUpperCase(Locale.ROOT).replace('.', 
'_').replace('-', '_');

Review Comment:
   This is the whole cost of a miss, and it is not one hash lookup: 
`toUpperCase` and `replace('.')` each allocate for a typical lowercase dotted 
key, and `contains` then hashes the fresh string. On the description's harness, 
head runs at 160 ns/op here; computing the canonical hash over the key's chars 
without allocating (ASCII fast path, falling back to the 
`toUpperCase(Locale.ROOT)` form for any char above 127) and keeping this set 
lookup for the rare hit runs at 119 ns/op. A single-pass `char[]` build alone 
is 151. The PR's specs pass with either.
   
   The fallback is required: per-char `Character.toUpperCase` differs from 
`String.toUpperCase(Locale.ROOT)` for 102 BMP chars (`ß` → `SS` and friends), 
so a key `straße` would be rejected although `STRASSE` matches through 
`resolvePropertyName`.
   
   Smaller: `new HashSet<>()` at line 418 resizes four times for ~120 names; 
`HashSet.newHashSet(variables.size())` is available on 21.



##########
grails-core/src/main/groovy/org/grails/config/NavigableMapConfig.java:
##########
@@ -55,6 +58,7 @@ public abstract class NavigableMapConfig implements Config {
     protected static final Logger LOG = 
LoggerFactory.getLogger(NavigableMapConfig.class);
     protected ClassLoader classLoader = 
Thread.currentThread().getContextClassLoader();
     protected ConfigurableConversionService conversionService = new 
DefaultConversionService();
+    private final SystemEnvironmentIndex systemEnvironment = new 
SystemEnvironmentIndex(System.getenv());

Review Comment:
   `PropertySourcesConfig.refresh()` re-runs `initializeFromPropertySources`, 
which re-merges Spring's live `systemEnvironment` source into `configMap`, but 
this index is built once here and never rebuilt. After a `refresh()` the raw 
variable name is in the map while the dotted spelling still resolves against 
the old snapshot. Probe: install `PROBE_STUBBED_AFTER` reflectively after 
construction, call `refresh()`, read `probe.stubbed.after`: base returns 
`from-env`, this branch `from-yml`.
   
   Could `refresh()` re-capture the index (say a `protected void 
refreshSystemEnvironment()` on this class, called from 
`PropertySourcesConfig.refresh()`, with the field `volatile`)? Besides closing 
the inconsistency, it gives a test that installs a variable after the context 
exists (system-stubs, JUnit Pioneer) a public way to see it through 
`grailsApplication.config`, which is the one observable thing the snapshot 
takes away, without adding a config knob. One feature in 
`SystemEnvironmentConfigSpec` ("...until `refresh()`") would pin it.



##########
grails-core/src/test/groovy/org/grails/config/SystemEnvironmentConfigSpec.groovy:
##########
@@ -32,25 +33,21 @@ import java.lang.reflect.Field
  */
 class SystemEnvironmentConfigSpec extends Specification {
 
-    @Issue('#10670')
-    void 'configuration properties defined in SystemEnvironment take 
precedence'() {
-        given: 'default configuration in application-yml'
-        Resource resource1 = new ByteArrayResource('''
+    private static final String YAML = '''
 property.with.period: from-yml
 property_with_underscore: from-yml
 property-with-hyphen: from-yml
 property-with_mixed.symbols: from-yml
-'''.bytes, 'test.yml')
-
-        def propertySourceLoader = new YamlPropertySourceLoader()
-        def yamlPropertiesSource = 
propertySourceLoader.load('application.yml', resource1, null)
-        def propertySources = new MutablePropertySources()
-        propertySources.addFirst(yamlPropertiesSource.first())
-        def config = new PropertySourcesConfig(propertySources)
+'''
 
-        and: 'overriding value with system environment variable'
+    @Issue('#10670')
+    void 'configuration properties defined in SystemEnvironment take 
precedence'() {
+        given: 'a value in the system environment'
         modifiableSystemEnvironment.put(systemProperty, value)
 
+        and: 'default configuration in application-yml'
+        def config = configFor(YAML)
+
         expect:
         config.getProperty(propertyToFind) == value

Review Comment:
   Two behaviour changes the description lists have no feature: 
`getProperty(null)`, `getProperty(null, String, 'dflt')` and 
`containsProperty(null)` returning null, `dflt` and false; and the 
`Locale.ROOT` upper-casing. For the latter, `Locale.setDefault(new Locale('tr', 
'TR'))` with `PROPERTY_WITH_ID=from-env` and `property.with.id: from-yml` 
resolves to `from-env` on this branch and to `from-yml` on base (restore the 
locale in `cleanup:`).
   
   Nit on this table: every row uses `from-env`, so a row cannot tell its own 
spelling from a sibling's, or from a variable leaked by an earlier feature. A 
per-row value (`value: systemProperty` works) makes each row self-proving.



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

Reply via email to