Commit 5774ea3f authored by Phillip Webb's avatar Phillip Webb

Support profile specific ConfigData imports

Update the `ConfigData` import support to allow individual property
sources to be imported with a higher precedence than profile specific
imports.

Prior to this commit, imported sources would always have a higher
precedence than the file that imported them, but a lower precedence
than any profile-specific variant of the same file.

For example, given an `application.properties` that imports `myconfig`,
the contributor tree would be as follows:

	ROOT
	 +- `application.properties`
	 |    +- myconfig
	 +- `application-<profile>.properties`

The precedence would be:

	1) `application-<profile>.properties`
	2) myconfig
	3) `application.properties`

This works well for most situations, but can be confusing if import is
for a profile-specific property source. For example:

	ROOT
	 +- `application.properties`
	 |    +- myconfig
	 |    +- myconfig-<profile>
	 +- `application-<profile>.properties`

Results in the order precedence of:

	1) `application-<profile>.properties`
	2) myconfig-<profile>
	3) myconfig
	4) `application.properties`

This means that whilst `myconfig` overrides `application.properties`,
`myconfig-profile` does not override `application-<profile>.properties`.

For this specific situation, the preferable order would be:

	1) myconfig-<profile>
	2) `application-<profile>.properties`
	3) myconfig
	4) `application.properties`

To support this alternative ordering a new `PROFILE_SPECIFIC` config
data option has been added. Additionally, options may now be specified
on a per-source basis by using the `PropertySourceOptions` interface.

Fixes gh-25766
parent f289f922
......@@ -43,7 +43,7 @@ public final class ConfigData {
private final List<PropertySource<?>> propertySources;
private final Set<Option> options;
private final PropertySourceOptions propertySourceOptions;
/**
* A {@link ConfigData} instance that contains no data.
......@@ -51,17 +51,30 @@ public final class ConfigData {
public static final ConfigData EMPTY = new ConfigData(Collections.emptySet());
/**
* Create a new {@link ConfigData} instance.
* Create a new {@link ConfigData} instance with the same options applied to each
* source.
* @param propertySources the config data property sources in ascending priority
* order.
* @param options the config data options
* @param options the config data options applied to each source
* @see #ConfigData(Collection, PropertySourceOptions)
*/
public ConfigData(Collection<? extends PropertySource<?>> propertySources, Option... options) {
this(propertySources, PropertySourceOptions.always(Options.of(options)));
}
/**
* Create a new {@link ConfigData} instance with specific property source options.
* @param propertySources the config data property sources in ascending priority
* order.
* @param propertySourceOptions the property source options
* @since 2.4.5
*/
public ConfigData(Collection<? extends PropertySource<?>> propertySources,
PropertySourceOptions propertySourceOptions) {
Assert.notNull(propertySources, "PropertySources must not be null");
Assert.notNull(options, "Options must not be null");
Assert.notNull(propertySourceOptions, "PropertySourceOptions must not be null");
this.propertySources = Collections.unmodifiableList(new ArrayList<>(propertySources));
this.options = Collections.unmodifiableSet(
(options.length != 0) ? EnumSet.copyOf(Arrays.asList(options)) : EnumSet.noneOf(Option.class));
this.propertySourceOptions = propertySourceOptions;
}
/**
......@@ -77,18 +90,167 @@ public final class ConfigData {
/**
* Return a set of {@link Option config data options} for this source.
* @return the config data options
* @deprecated since 2.4.5 in favor of {@link #getOptions(PropertySource)}
*/
@Deprecated
public Set<Option> getOptions() {
return this.options;
Assert.state(this.propertySourceOptions instanceof AlwaysPropertySourceOptions, "No global options defined");
return this.propertySourceOptions.get(null).asSet();
}
/**
* Return the {@link Options config data options} that apply to the given source.
* @param propertySource the property source to check
* @return the options that apply
* @since 2.4.5
*/
public Options getOptions(PropertySource<?> propertySource) {
Options options = this.propertySourceOptions.get(propertySource);
return (options != null) ? options : Options.NONE;
}
/**
* Strategy interface used to supply {@link Options} for a given
* {@link PropertySource}.
*
* @since 2.4.5
*/
@FunctionalInterface
public interface PropertySourceOptions {
/**
* Return the options that should apply for the given property source.
* @param propertySource the property source
* @return the options to apply
*/
Options get(PropertySource<?> propertySource);
/**
* Create a new {@link PropertySourceOptions} instance that always returns the
* same options regardless of the property source.
* @param options the options to return
* @return a new {@link PropertySourceOptions} instance
*/
static PropertySourceOptions always(Option... options) {
return always(Options.of(options));
}
/**
* Create a new {@link PropertySourceOptions} instance that always returns the
* same options regardless of the property source.
* @param options the options to return
* @return a new {@link PropertySourceOptions} instance
*/
static PropertySourceOptions always(Options options) {
return new AlwaysPropertySourceOptions(options);
}
}
/**
* {@link PropertySourceOptions} that always returns the same result.
*/
private static class AlwaysPropertySourceOptions implements PropertySourceOptions {
private final Options options;
AlwaysPropertySourceOptions(Options options) {
this.options = options;
}
@Override
public Options get(PropertySource<?> propertySource) {
return this.options;
}
}
/**
* Option flags that can be applied config data.
* A set of {@link Option} flags.
*
* @since 2.4.5
*/
public static final class Options {
/**
* No options.
*/
public static final Options NONE = Options.of();
private final Set<Option> options;
private Options(Set<Option> options) {
this.options = Collections.unmodifiableSet(options);
}
Set<Option> asSet() {
return this.options;
}
/**
* Returns if the given option is contained in this set.
* @param option the option to check
* @return {@code true} of the option is present
*/
public boolean contains(Option option) {
return this.options.contains(option);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Options other = (Options) obj;
return this.options.equals(other.options);
}
@Override
public int hashCode() {
return this.options.hashCode();
}
@Override
public String toString() {
return this.options.toString();
}
/**
* Create a new {@link Options} instance that contains the options in this set
* excluding the given option.
* @param option the option to exclude
* @return a new {@link Options} instance
*/
Options without(Option option) {
EnumSet<Option> options = EnumSet.noneOf(Option.class);
options.addAll(this.options);
options.remove(option);
return new Options(options);
}
/**
* Create a new instance with the given {@link Option} values.
* @param options the options to include
* @return a new {@link Options} instance
*/
public static Options of(Option... options) {
Assert.notNull(options, "Options must not be null");
return new Options(
(options.length != 0) ? EnumSet.copyOf(Arrays.asList(options)) : EnumSet.noneOf(Option.class));
}
}
/**
* Option flags that can be applied.
*/
public enum Option {
/**
* Ignore all imports properties from the sources.
* Ignore all imports properties from the source.
*/
IGNORE_IMPORTS,
......@@ -96,7 +258,14 @@ public final class ConfigData {
* Ignore all profile activation and include properties.
* @since 2.4.3
*/
IGNORE_PROFILES;
IGNORE_PROFILES,
/**
* Indicates that the source is "profile specific" and should be included after
* profile specific sibling imports.
* @since 2.4.5
*/
PROFILE_SPECIFIC;
}
......
......@@ -269,7 +269,8 @@ class ConfigDataEnvironment {
ConfigDataActivationContext activationContext) {
this.logger.trace("Deducing profiles from current config data environment contributors");
Binder binder = contributors.getBinder(activationContext,
ConfigDataEnvironmentContributor::isNotIgnoringProfiles, BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES),
BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
try {
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles);
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext));
......@@ -291,7 +292,7 @@ class ConfigDataEnvironment {
Set<String> result = new LinkedHashSet<>();
for (ConfigDataEnvironmentContributor contributor : contributors) {
ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
if (source != null && contributor.isNotIgnoringProfiles()) {
if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
Binder binder = new Binder(Collections.singleton(source), placeholdersResolver);
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST).ifBound((includes) -> {
if (!contributor.isActive(activationContext)) {
......
......@@ -117,7 +117,8 @@ public class InvalidConfigDataPropertyException extends ConfigDataException {
logger.warn(getMessage(property, false, replacement, contributor.getResource()));
}
});
if (contributor.isProfileSpecific() && contributor.isNotIgnoringProfiles()) {
if (contributor.isFromProfileSpecificImport()
&& !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
PROFILE_SPECIFIC_ERRORS.forEach((name) -> {
ConfigurationProperty property = propertySource.getConfigurationProperty(name);
if (property != null) {
......
......@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.boot.context.config.ConfigData.Option;
import org.springframework.boot.context.config.ConfigData.PropertySourceOptions;
import org.springframework.boot.context.config.ConfigDataEnvironmentContributor.ImportPhase;
import org.springframework.boot.context.config.ConfigDataEnvironmentContributor.Kind;
import org.springframework.boot.context.properties.bind.Binder;
......@@ -224,6 +225,21 @@ class ConfigDataEnvironmentContributorTests {
assertThat(withChildren.getChildren(ImportPhase.BEFORE_PROFILE_ACTIVATION)).containsExactly(child);
}
@Test
void withChildrenAfterProfileActivationMovesProfileSpecificChildren() {
ConfigDataEnvironmentContributor root = createBoundContributor("root");
ConfigDataEnvironmentContributor child1 = createBoundContributor("child1");
ConfigDataEnvironmentContributor grandchild = createBoundContributor(new TestResource("grandchild"),
new ConfigData(Collections.singleton(new MockPropertySource()),
PropertySourceOptions.always(Option.PROFILE_SPECIFIC)),
0);
child1 = child1.withChildren(ImportPhase.BEFORE_PROFILE_ACTIVATION, Collections.singletonList(grandchild));
root = root.withChildren(ImportPhase.BEFORE_PROFILE_ACTIVATION, Collections.singletonList(child1));
ConfigDataEnvironmentContributor child2 = createBoundContributor("child2");
root = root.withChildren(ImportPhase.AFTER_PROFILE_ACTIVATION, Collections.singletonList(child2));
assertThat(asLocationsList(root.iterator())).containsExactly("grandchild", "child2", "child1", "root");
}
@Test
void withReplacementReplacesChild() {
ConfigDataEnvironmentContributor root = createBoundContributor("root");
......
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.context.config;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.context.config.ConfigData.Option;
import org.springframework.boot.context.config.ConfigData.Options;
import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessorIntegrationTests.Config;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.PropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link ConfigDataEnvironmentPostProcessor} config data imports
* that are combined with profile-specific files.
*
* @author Phillip Webb
*/
class ConfigDataEnvironmentPostProcessorImportCombinedWithProfileSpecificIntegrationTests {
private SpringApplication application;
@TempDir
public File temp;
@BeforeEach
void setup() {
this.application = new SpringApplication(Config.class);
this.application.setWebApplicationType(WebApplicationType.NONE);
}
@Test
void testWithoutProfile() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=configimportwithprofilespecific");
String value = context.getEnvironment().getProperty("prop");
assertThat(value).isEqualTo("fromicwps1");
}
@Test
void testWithProfile() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=configimportwithprofilespecific", "--spring.profiles.active=prod");
String value = context.getEnvironment().getProperty("prop");
assertThat(value).isEqualTo("fromicwps2");
}
static class LocationResolver implements ConfigDataLocationResolver<Resource> {
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return location.hasPrefix("icwps:");
}
@Override
public List<Resource> resolve(ConfigDataLocationResolverContext context, ConfigDataLocation location)
throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException {
return Collections.emptyList();
}
@Override
public List<Resource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
ConfigDataLocation location, Profiles profiles) throws ConfigDataLocationNotFoundException {
return Collections.singletonList(new Resource(profiles));
}
}
static class Loader implements ConfigDataLoader<Resource> {
@Override
public ConfigData load(ConfigDataLoaderContext context, Resource resource)
throws IOException, ConfigDataResourceNotFoundException {
List<PropertySource<?>> propertySources = new ArrayList<>();
Map<PropertySource<?>, Options> propertySourceOptions = new HashMap<>();
propertySources.add(new MapPropertySource("icwps1", Collections.singletonMap("prop", "fromicwps1")));
if (resource.profiles.isAccepted("prod")) {
MapPropertySource profileSpecificPropertySource = new MapPropertySource("icwps2",
Collections.singletonMap("prop", "fromicwps2"));
propertySources.add(profileSpecificPropertySource);
propertySourceOptions.put(profileSpecificPropertySource, Options.of(Option.PROFILE_SPECIFIC));
}
return new ConfigData(propertySources, propertySourceOptions::get);
}
}
private static class Resource extends ConfigDataResource {
private final Profiles profiles;
Resource(Profiles profiles) {
this.profiles = profiles;
}
@Override
public String toString() {
return "icwps:";
}
}
}
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -17,16 +17,22 @@
package org.springframework.boot.context.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.config.ConfigData.Option;
import org.springframework.boot.context.config.ConfigData.Options;
import org.springframework.boot.context.config.ConfigData.PropertySourceOptions;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.PropertySource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ConfigData}.
......@@ -58,7 +64,8 @@ class ConfigDataTests {
}
@Test
void getOptionsReturnsCopyOfOptions() {
@Deprecated
void getDeprecatedOptionsReturnsCopyOfOptions() {
MapPropertySource source = new MapPropertySource("test", Collections.emptyMap());
Option[] options = { Option.IGNORE_IMPORTS };
ConfigData configData = new ConfigData(Collections.singleton(source), options);
......@@ -66,4 +73,67 @@ class ConfigDataTests {
assertThat(configData.getOptions()).containsExactly(Option.IGNORE_IMPORTS);
}
@Test
@Deprecated
void getDeprecatedOptionsWhenUsingPropertySourceOptionsThrowsException() {
MapPropertySource source = new MapPropertySource("test", Collections.emptyMap());
PropertySourceOptions propertySourceOptions = (propertySource) -> Options.NONE;
ConfigData configData = new ConfigData(Collections.singleton(source), propertySourceOptions);
assertThatIllegalStateException().isThrownBy(() -> configData.getOptions())
.withMessage("No global options defined");
}
@Test
void getOptionsWhenOptionsSetAtConstructionAlwaysReturnsSameOptions() {
MapPropertySource source = new MapPropertySource("test", Collections.emptyMap());
ConfigData configData = new ConfigData(Collections.singleton(source), Option.IGNORE_IMPORTS);
assertThat(configData.getOptions(source).asSet()).containsExactly(Option.IGNORE_IMPORTS);
}
@Test
void getOptionsReturnsOptionsFromPropertySourceOptions() {
MapPropertySource source1 = new MapPropertySource("test", Collections.emptyMap());
MapPropertySource source2 = new MapPropertySource("test", Collections.emptyMap());
Options options1 = Options.of(Option.IGNORE_IMPORTS);
Options options2 = Options.of(Option.IGNORE_PROFILES);
PropertySourceOptions propertySourceOptions = (source) -> source == source1 ? options1 : options2;
ConfigData configData = new ConfigData(Arrays.asList(source1, source2), propertySourceOptions);
assertThat(configData.getOptions(source1)).isEqualTo(options1);
assertThat(configData.getOptions(source2)).isEqualTo(options2);
}
@Test
void getOptionsWhenPropertySourceOptionsReturnsNullReturnsNone() {
MapPropertySource source = new MapPropertySource("test", Collections.emptyMap());
PropertySourceOptions propertySourceOptions = (propertySource) -> null;
ConfigData configData = new ConfigData(Collections.singleton(source), propertySourceOptions);
assertThat(configData.getOptions(source)).isEqualTo(Options.NONE);
}
@Test
void optionsOfCreatesOptions() {
Options options = Options.of(Option.IGNORE_IMPORTS, Option.IGNORE_PROFILES);
assertThat(options.asSet()).containsExactly(Option.IGNORE_IMPORTS, Option.IGNORE_PROFILES);
}
@Test
void optionsOfUsesCopyOfOptions() {
Option[] array = { Option.IGNORE_IMPORTS, Option.IGNORE_PROFILES };
Options options = Options.of(array);
array[0] = Option.PROFILE_SPECIFIC;
assertThat(options.asSet()).containsExactly(Option.IGNORE_IMPORTS, Option.IGNORE_PROFILES);
}
@Test
void optionsNoneReturnsEmptyOptions() {
assertThat(Options.NONE.asSet()).isEmpty();
}
@Test
void propertySourceOptionsAlwaysReturnsSameOptionsEachTime() {
PropertySourceOptions options = PropertySourceOptions.always(Option.IGNORE_IMPORTS, Option.IGNORE_PROFILES);
assertThat(options.get(mock(PropertySource.class)).asSet()).containsExactly(Option.IGNORE_IMPORTS,
Option.IGNORE_PROFILES);
}
}
......@@ -16,9 +16,6 @@
package org.springframework.boot.context.config;
import java.util.Arrays;
import java.util.HashSet;
import org.apache.commons.logging.Log;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
......@@ -148,7 +145,7 @@ class InvalidConfigDataPropertyExceptionTests {
propertySource.setProperty(name, "a");
ConfigDataEnvironmentContributor contributor = new ConfigDataEnvironmentContributor(Kind.BOUND_IMPORT, null,
null, true, propertySource, ConfigurationPropertySource.from(propertySource), null,
new HashSet<>(Arrays.asList(configDataOptions)), null);
ConfigData.Options.of(configDataOptions), null);
return contributor;
}
......
......@@ -3,7 +3,9 @@ org.springframework.boot.context.config.TestPropertySourceLoader1,\
org.springframework.boot.context.config.TestPropertySourceLoader2
org.springframework.boot.context.config.ConfigDataLocationResolver=\
org.springframework.boot.context.config.TestConfigDataBootstrap.LocationResolver
org.springframework.boot.context.config.TestConfigDataBootstrap.LocationResolver,\
org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessorImportCombinedWithProfileSpecificIntegrationTests.LocationResolver
org.springframework.boot.context.config.ConfigDataLoader=\
org.springframework.boot.context.config.TestConfigDataBootstrap.Loader
\ No newline at end of file
org.springframework.boot.context.config.TestConfigDataBootstrap.Loader,\
org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessorImportCombinedWithProfileSpecificIntegrationTests.Loader
spring.config.import=icwps:
prop=fromfile
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment