diff --git a/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java b/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java index 14aafbe6260..a0b1aa1a232 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java @@ -16,6 +16,7 @@ package org.springframework.boot.context.properties.bind; +import java.util.Collection; import java.util.Map; import java.util.Properties; @@ -117,6 +118,11 @@ class MapBinder extends AggregateBinder> { private ConfigurationPropertyName getEntryName(ConfigurationPropertySource source, ConfigurationPropertyName name) { + Class resolved = this.valueType.resolve(); + if (Collection.class.isAssignableFrom(resolved) + || this.valueType.isArray()) { + return chopNameAtNumericIndex(name); + } if (!this.root.isParentOf(name) && (isValueTreatedAsNestedMap() || !isScalarValue(source, name))) { return name.chop(this.root.getNumberOfElements() + 1); @@ -124,6 +130,17 @@ class MapBinder extends AggregateBinder> { return name; } + private ConfigurationPropertyName chopNameAtNumericIndex(ConfigurationPropertyName name) { + int start = this.root.getNumberOfElements() + 1; + int size = name.getNumberOfElements(); + for (int i = start; i < size; i++) { + if (name.IsNumericIndex(i)) { + return name.chop(i); + } + } + return name; + } + private boolean isValueTreatedAsNestedMap() { return Object.class.equals(this.valueType.resolve(Object.class)); } diff --git a/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java b/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java index 2b07bf8ce5d..a11e0d6a596 100644 --- a/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java +++ b/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java @@ -97,12 +97,21 @@ public final class ConfigurationPropertyName /** * Return if the an element in the name is indexed. * @param elementIndex the index of the element - * @return {@code true} if the last element is indexed + * @return {@code true} if the element is indexed */ boolean isIndexed(int elementIndex) { return isIndexed(this.elements[elementIndex]); } + /** + * Return if the an element in the name is indexed and numeric. + * @param elementIndex the index of the element + * @return {@code true} if the element is indexed and numeric + */ + public boolean IsNumericIndex(int elementIndex) { + return isIndexed(elementIndex) && isNumeric(getElement(elementIndex, Form.ORIGINAL)); + } + /** * Return the last element in the name in the given form. * @param form the form to return @@ -392,6 +401,16 @@ public final class ConfigurationPropertyName && element.charAt(length - 1) == ']'; } + private static boolean isNumeric(CharSequence element) { + int length = element.length(); + for (int i = 0; i < length; i++) { + if (!Character.isDigit(element.charAt(i))) { + return false; + } + } + return true; + } + /** * Returns if the given name is valid. If this method returns {@code true} then the * name may be used with {@link #of(CharSequence)} without throwing an exception. diff --git a/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java b/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java index ef11f56e168..d333883ba8e 100644 --- a/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java +++ b/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; @@ -429,6 +430,109 @@ public class MapBinderTests { eq(target), any(), isA(Map.class)); } + @Test + public void bindToMapStringArrayWithDotKeysShouldPreserveDot() throws Exception { + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo.bar.baz[0]", "a"); + mockSource.put("foo.bar.baz[1]", "b"); + mockSource.put("foo.bar.baz[2]", "c"); + this.sources + .add(mockSource); + Map map = this.binder.bind("foo", STRING_ARRAY_MAP).get(); + assertThat(map.get("bar.baz")).containsExactly("a", "b", "c"); + } + + @Test + public void bindToMapStringArrayWithDotKeysAndCommaSeparatedShouldPreserveDot() throws Exception { + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo.bar.baz", "a,b,c"); + this.sources + .add(mockSource); + Map map = this.binder.bind("foo", STRING_ARRAY_MAP).get(); + assertThat(map.get("bar.baz")).containsExactly("a", "b", "c"); + } + + @Test + public void bindToMapStringCollectionWithDotKeysShouldPreserveDot() throws Exception { + Bindable> valueType = Bindable.listOf(String.class); + Bindable>> target = getMapBindable(String.class, valueType.getType()); + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo.bar.baz[0]", "a"); + mockSource.put("foo.bar.baz[1]", "b"); + mockSource.put("foo.bar.baz[2]", "c"); + this.sources + .add(mockSource); + Map> map = this.binder.bind("foo", target).get(); + List values = map.get("bar.baz"); + assertThat(values).containsExactly("a", "b", "c"); + } + + @Test + public void bindToMapNonScalarCollectionWithDotKeysShouldBind() throws Exception { + Bindable> valueType = Bindable.listOf(JavaBean.class); + Bindable>> target = getMapBindable(String.class, valueType.getType()); + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo.bar.baz[0].value", "a"); + mockSource.put("foo.bar.baz[1].value", "b"); + mockSource.put("foo.bar.baz[2].value", "c"); + this.sources + .add(mockSource); + Map> map = this.binder.bind("foo", target).get(); + List values = map.get("bar.baz"); + assertThat(values.stream().map(JavaBean::getValue).collect(Collectors.toList())).containsExactly("a", "b", "c"); + } + + @Test + public void bindToListOfMaps() throws Exception { + Bindable> listBindable = Bindable.listOf(Integer.class); + Bindable>> mapBindable = getMapBindable(String.class, listBindable.getType()); + Bindable>>> target = getListBindable(mapBindable.getType()); + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo[0].a", "1,2,3"); + mockSource.put("foo[1].b", "4,5,6"); + this.sources + .add(mockSource); + List>> list = this.binder.bind("foo", target).get(); + assertThat(list.get(0).get("a")).containsExactly(1, 2, 3); + assertThat(list.get(1).get("b")).containsExactly(4, 5, 6); + } + + @Test + public void bindToMapWithNumberKeyAndCommaSeparated() throws Exception { + Bindable> listBindable = Bindable.listOf(String.class); + Bindable>> target = getMapBindable(Integer.class, listBindable.getType()); + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo[0]", "a,b,c"); + mockSource.put("foo[1]", "e,f,g"); + this.sources + .add(mockSource); + Map> map = this.binder.bind("foo", target).get(); + assertThat(map.get(0)).containsExactly("a", "b", "c"); + assertThat(map.get(1)).containsExactly("e", "f", "g"); + } + + @Test + public void bindToMapWithNumberKeyAndIndexed() throws Exception { + Bindable> listBindable = Bindable.listOf(Integer.class); + Bindable>> target = getMapBindable(Integer.class, listBindable.getType()); + MockConfigurationPropertySource mockSource = new MockConfigurationPropertySource(); + mockSource.put("foo[0][0]", "8"); + mockSource.put("foo[0][1]", "9"); + this.sources + .add(mockSource); + Map> map = this.binder.bind("foo", target).get(); + assertThat(map.get(0)).containsExactly(8, 9); + } + + private Bindable> getMapBindable(Class keyGeneric, ResolvableType valueType) { + ResolvableType keyType = ResolvableType.forClass(keyGeneric); + return Bindable.of(ResolvableType.forClassWithGenerics(Map.class, keyType, valueType)); + } + + private Bindable> getListBindable(ResolvableType type) { + return Bindable.of(ResolvableType.forClassWithGenerics(List.class, type)); + } + public static class Foo { private String pattern;