From 0545d3a65f4d8df24d291b847383ea5cc83e7f41 Mon Sep 17 00:00:00 2001 From: Dylan Miska Date: Sat, 20 Sep 2025 16:34:32 -0500 Subject: [PATCH 1/3] Add EnvironmentConfigurationValueProvider for Liquibase property pass-through Signed-off-by: Dylan Miska --- ...EnvironmentConfigurationValueProvider.java | 80 ++++++++++++++++ .../LiquibaseAutoConfiguration.java | 20 ++++ .../autoconfigure/LiquibaseProperties.java | 17 ++++ ...onmentConfigurationValueProviderTests.java | 94 +++++++++++++++++++ .../LiquibaseAutoConfigurationTests.java | 86 +++++++++++++++++ 5 files changed, 297 insertions(+) create mode 100644 module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java create mode 100644 module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java new file mode 100644 index 000000000000..7b8f480ea46d --- /dev/null +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-present 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.liquibase.autoconfigure; + +import liquibase.configuration.AbstractConfigurationValueProvider; +import liquibase.configuration.ProvidedValue; +import org.jspecify.annotations.Nullable; +import org.springframework.core.env.Environment; +import org.springframework.util.Assert; + +/** + * A Liquibase {@code ConfigurationValueProvider} that passes through properties defined + * in the Spring {@link Environment} using the exact-name convention: + * + *
+ * spring.liquibase.properties.<liquibaseKey> = <value>
+ * 
+ * + * For example:
+ * spring.liquibase.properties.liquibase.duplicateFileMode = WARN
+ * spring.liquibase.properties.liquibase.searchPath = classpath:/db,file:./external
+ * 
+ * + * No relaxed binding or key transformation is performed. Keys are looked up exactly as + * provided by Liquibase (including dots and casing), prefixed with + * {@code spring.liquibase.properties.}. + */ +final class EnvironmentConfigurationValueProvider extends AbstractConfigurationValueProvider { + + private static final String PREFIX = "spring.liquibase.properties."; + + private final Environment environment; + + EnvironmentConfigurationValueProvider(Environment environment) { + Assert.notNull(environment, "Environment must not be null"); + this.environment = environment; + } + + @Override + public int getPrecedence() { + return 100; + } + + @Override + public @Nullable ProvidedValue getProvidedValue(String... keyAndAliases) { + if (keyAndAliases == null) { + return null; + } + for (String requestedKey : keyAndAliases) { + if (requestedKey == null) { + continue; + } + // Exact pass-through: no rewriting of the Liquibase key + String propertyName = PREFIX + requestedKey; + String value = this.environment.getProperty(propertyName); + if (value != null) { + // requestedKey: the key Liquibase asked for + // propertyName: the exact Spring Environment property we read + return new ProvidedValue(requestedKey, requestedKey, value, + "Spring Environment property '" + propertyName + "'", this); + } + } + return null; + } + +} diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java index 4fd57b3af172..acc65ec62447 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java @@ -30,6 +30,7 @@ import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; @@ -50,6 +51,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.core.env.Environment; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.datasource.SimpleDriverDataSource; import org.springframework.util.Assert; @@ -188,6 +190,24 @@ private void applyConnectionDetails(LiquibaseConnectionDetails connectionDetails } + @Bean + static BeanFactoryPostProcessor liquibaseConfigurationValueProviderRegistrar(Environment environment) { + + return (beanFactory) -> { + var liquibaseConfiguration = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + // Remove any previously registered instance of our provider class + liquibaseConfiguration.getProviders() + .stream() + .filter((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class) + .toList() + .forEach(liquibaseConfiguration::unregisterProvider); + + liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider(environment)); + }; + } + @ConditionalOnClass(Customizer.class) @Configuration(proxyBeanMethods = false) static class CustomizerConfiguration { diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java index 8b0dbd9a766e..cc3af66d5b3b 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java @@ -123,6 +123,15 @@ public class LiquibaseProperties { */ private @Nullable Map parameters; + /** + * Liquibase global configuration properties. Properties must be set with liquibase's + * full propertiesFile dot format. For example: + * {@code spring.liquibase.properties.liquibase.duplicateFileMode}. Note that + * Liquibase’s normal precedence still applies (env variables and jvm system + * properties can override values set here). + */ + private @Nullable Map properties; + /** * File to which rollback SQL is written when an update is performed. */ @@ -294,6 +303,14 @@ public void setParameters(@Nullable Map parameters) { this.parameters = parameters; } + public @Nullable Map getProperties() { + return this.properties; + } + + public void setProperties(@Nullable Map properties) { + this.properties = properties; + } + public @Nullable File getRollbackFile() { return this.rollbackFile; } diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java new file mode 100644 index 000000000000..92cf88e135c4 --- /dev/null +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-present 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.liquibase.autoconfigure; + +import liquibase.configuration.ProvidedValue; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnvironmentConfigurationValueProviderTests { + + @Test + void precedenceIsBetweenDefaultsAndEnvVars() { + var env = new MockEnvironment(); + var provider = new EnvironmentConfigurationValueProvider(env); + assertThat(provider.getPrecedence()).isEqualTo(100); + } + + @Test + void returnsProvidedValueWhenExactPropertyPresent() { + var env = new MockEnvironment().withProperty("spring.liquibase.properties.liquibase.duplicateFileMode", "WARN"); + var provider = new EnvironmentConfigurationValueProvider(env); + + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNotNull(); + assertThat(String.valueOf(value.getValue())).isEqualTo("WARN"); + assertThat(value.getActualKey()).isEqualTo("liquibase.duplicateFileMode"); + assertThat(value.getSourceDescription()) + .contains("Spring Environment property 'spring.liquibase.properties.liquibase.duplicateFileMode'"); + } + + @Test + void returnsNullWhenNoKeysProvided() { + var env = new MockEnvironment(); + var provider = new EnvironmentConfigurationValueProvider(env); + + ProvidedValue value = provider.getProvidedValue((String[]) null); + + assertThat(value).isNull(); + } + + @Test + void returnsNullWhenNoMatchingProperty() { + var env = new MockEnvironment(); + var provider = new EnvironmentConfigurationValueProvider(env); + + ProvidedValue value = provider.getProvidedValue("liquibase.searchPath"); + + assertThat(value).isNull(); + } + + @Test + void skipsNullKeysAndResolvesFirstMatchingNonNull() { + var env = new MockEnvironment() + // Only the second alias is present + .withProperty("spring.liquibase.properties.liquibase.searchPath", "classpath:/db"); + var provider = new EnvironmentConfigurationValueProvider(env); + + ProvidedValue value = provider.getProvidedValue(null, "liquibase.searchPath"); + + assertThat(value).isNotNull(); + assertThat(String.valueOf(value.getValue())).isEqualTo("classpath:/db"); + assertThat(value.getActualKey()).isEqualTo("liquibase.searchPath"); + } + + @Test + void doesNotApplyRelaxedBinding_exactKeyOnly() { + var env = new MockEnvironment().withProperty("spring.liquibase.properties.liquibase.duplicate-file-mode", + "WARN"); + var provider = new EnvironmentConfigurationValueProvider(env); + + // Request camelCase; should not match the kebab-case property + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNull(); + } + +} diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java index bdcff998ca11..b95256030cc6 100644 --- a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java @@ -568,6 +568,92 @@ void shouldRegisterHints() { assertThat(RuntimeHintsPredicates.resource().forResource("db/changelog/tables/init.sql")).accepts(hints); } + @Test + @WithDbChangelogMasterYamlResource + void springLiquibaseTakesPrecedenceOverLiquibaseDefaults() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.liquibase.properties.liquibase.duplicateFileMode=WARN") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + + var liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + var provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + var provided = provider.getProvidedValue("liquibase.duplicateFileMode"); + assertThat(provided).isNotNull(); + assertThat(String.valueOf(provided.getValue())).isEqualTo("WARN"); + + // Now check the resolved value, which should be different from the + // default 'ERROR' + var resolved = liquibaseConfig.getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); + assertThat(String.valueOf(resolved.getValue())).isEqualTo("WARN"); + }); + } + + @Test + @WithDbChangelogMasterYamlResource + void systemPropertyTakesPrecedenceOverSpringLiquibaseProperties() { + this.contextRunner.withSystemProperties("liquibase.duplicateFileMode=ERROR") + .withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.liquibase.properties.liquibase.duplicateFileMode=WARN") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + + var liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + var provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + // Our provider should return the value set through Spring property + var providedBySpring = provider.getProvidedValue("liquibase.duplicateFileMode"); + assertThat(providedBySpring).isNotNull(); + assertThat(String.valueOf(providedBySpring.getValue())).isEqualTo("WARN"); + + // Now check the resolved value, which should be the system property + var resolved = liquibaseConfig.getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); + assertThat(String.valueOf(resolved.getValue())).isEqualTo("ERROR"); + }); + } + + @Test + @WithDbChangelogMasterYamlResource + void arbitraryLiquibaseKeyIsPassedThroughAsIs() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.liquibase.properties.my.extension.custom.option=true") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + + var liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + var provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + var provided = provider.getProvidedValue("my.extension.custom.option"); + assertThat(provided).isNotNull(); + assertThat(String.valueOf(provided.getValue())).isEqualTo("true"); + }); + } + @Test @WithDbChangelogMasterYamlResource void whenCustomizerBeanIsDefinedThenItIsConfiguredOnSpringLiquibase() { From 9ffd14426fec1aca58b74f0e79bcec8f896a5536 Mon Sep 17 00:00:00 2001 From: Dylan Miska Date: Tue, 23 Sep 2025 19:39:25 -0500 Subject: [PATCH 2/3] Move liquibase provider registration to during SpringLiquibase creation and align code standards to match Spring Boot convention. Signed-off-by: Dylan Miska --- ...EnvironmentConfigurationValueProvider.java | 3 -- .../LiquibaseAutoConfiguration.java | 38 +++++++++---------- ...onmentConfigurationValueProviderTests.java | 3 ++ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java index 7b8f480ea46d..b3bee1c863fd 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java @@ -64,12 +64,9 @@ public int getPrecedence() { if (requestedKey == null) { continue; } - // Exact pass-through: no rewriting of the Liquibase key String propertyName = PREFIX + requestedKey; String value = this.environment.getProperty(propertyName); if (value != null) { - // requestedKey: the key Liquibase asked for - // propertyName: the exact Spring Environment property we read return new ProvidedValue(requestedKey, requestedKey, value, "Spring Environment property '" + propertyName + "'", this); } diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java index acc65ec62447..cffcfd4073d3 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java @@ -19,6 +19,7 @@ import javax.sql.DataSource; import liquibase.Liquibase; +import liquibase.Scope; import liquibase.UpdateSummaryEnum; import liquibase.UpdateSummaryOutputEnum; import liquibase.change.DatabaseChange; @@ -30,7 +31,6 @@ import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; @@ -103,7 +103,9 @@ PropertiesLiquibaseConnectionDetails liquibaseConnectionDetails(LiquibasePropert @Bean SpringLiquibase liquibase(ObjectProvider dataSource, @LiquibaseDataSource ObjectProvider liquibaseDataSource, LiquibaseProperties properties, - ObjectProvider customizers, LiquibaseConnectionDetails connectionDetails) { + ObjectProvider customizers, LiquibaseConnectionDetails connectionDetails, + Environment environment) { + registerLiquibaseConfigurationValueProvider(environment); SpringLiquibase liquibase = createSpringLiquibase(liquibaseDataSource.getIfAvailable(), dataSource.getIfUnique(), connectionDetails); liquibase.setChangeLog(properties.getChangeLog()); @@ -155,6 +157,20 @@ private SpringLiquibase createSpringLiquibase(@Nullable DataSource liquibaseData return liquibase; } + private void registerLiquibaseConfigurationValueProvider(Environment environment) { + liquibase.configuration.LiquibaseConfiguration liquibaseConfiguration = Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + // Remove any previously registered instance of our provider class + liquibaseConfiguration.getProviders() + .stream() + .filter((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class) + .toList() + .forEach(liquibaseConfiguration::unregisterProvider); + + liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider(environment)); + } + private DataSource getMigrationDataSource(@Nullable DataSource liquibaseDataSource, @Nullable DataSource dataSource, LiquibaseConnectionDetails connectionDetails) { if (liquibaseDataSource != null) { @@ -190,24 +206,6 @@ private void applyConnectionDetails(LiquibaseConnectionDetails connectionDetails } - @Bean - static BeanFactoryPostProcessor liquibaseConfigurationValueProviderRegistrar(Environment environment) { - - return (beanFactory) -> { - var liquibaseConfiguration = liquibase.Scope.getCurrentScope() - .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); - - // Remove any previously registered instance of our provider class - liquibaseConfiguration.getProviders() - .stream() - .filter((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class) - .toList() - .forEach(liquibaseConfiguration::unregisterProvider); - - liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider(environment)); - }; - } - @ConditionalOnClass(Customizer.class) @Configuration(proxyBeanMethods = false) static class CustomizerConfiguration { diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java index 92cf88e135c4..bf66402dc0d6 100644 --- a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java @@ -22,6 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Tests for {@link EnvironmentConfigurationValueProvider}. + */ class EnvironmentConfigurationValueProviderTests { @Test From 8664d53e527bdcc585396120f0672da75a3cc957 Mon Sep 17 00:00:00 2001 From: Dylan Miska Date: Mon, 6 Oct 2025 21:07:11 -0500 Subject: [PATCH 3/3] Refactor Liquibase integration to support environment-aware property pass-through Signed-off-by: Dylan Miska --- .../DataSourceClosingSpringLiquibase.java | 7 +- .../EnvironmentAwareSpringLiquibase.java | 70 +++++++++ ...EnvironmentConfigurationValueProvider.java | 47 ++++-- .../LiquibaseAutoConfiguration.java | 30 ++-- .../autoconfigure/LiquibaseProperties.java | 1 + .../liquibase/endpoint/LiquibaseEndpoint.java | 30 ++-- .../endpoint/LiquibaseEndpointTests.java | 21 +++ ...onmentConfigurationValueProviderTests.java | 124 ++++++++++----- .../LiquibaseAutoConfigurationTests.java | 143 +++++++++++------- 9 files changed, 341 insertions(+), 132 deletions(-) create mode 100644 module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentAwareSpringLiquibase.java diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/DataSourceClosingSpringLiquibase.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/DataSourceClosingSpringLiquibase.java index 8e92cfdf01b0..281551017f20 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/DataSourceClosingSpringLiquibase.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/DataSourceClosingSpringLiquibase.java @@ -23,7 +23,6 @@ import liquibase.exception.LiquibaseException; import liquibase.integration.spring.SpringLiquibase; -import org.springframework.beans.factory.DisposableBean; import org.springframework.util.ReflectionUtils; /** @@ -31,9 +30,10 @@ * {@link DataSource} once the database has been migrated. * * @author Andy Wilkinson + * @author Dylan Miska * @since 4.0.0 */ -public class DataSourceClosingSpringLiquibase extends SpringLiquibase implements DisposableBean { +public class DataSourceClosingSpringLiquibase extends EnvironmentAwareSpringLiquibase { private volatile boolean closeDataSourceOnceMigrated = true; @@ -58,7 +58,8 @@ private void closeDataSource() { } @Override - public void destroy() throws Exception { + public void destroy() { + super.destroy(); if (!this.closeDataSourceOnceMigrated) { closeDataSource(); } diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentAwareSpringLiquibase.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentAwareSpringLiquibase.java new file mode 100644 index 000000000000..9c9d3969473d --- /dev/null +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentAwareSpringLiquibase.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-present 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.liquibase.autoconfigure; + +import liquibase.Scope; +import liquibase.exception.LiquibaseException; +import liquibase.integration.spring.SpringLiquibase; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link SpringLiquibase} subclass that creates a Liquibase child scope with a + * reference to the Spring Environment, allowing the + * {@link EnvironmentConfigurationValueProvider} to access the correct Environment for + * configuration values. + * + * @author Dylan Miska + */ +class EnvironmentAwareSpringLiquibase extends SpringLiquibase implements ApplicationContextAware, DisposableBean { + + private @Nullable String environmentId; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.environmentId = applicationContext.getId(); + } + + @Override + public void afterPropertiesSet() throws LiquibaseException { + try { + Map scopeValues = new HashMap<>(); + scopeValues.put(EnvironmentConfigurationValueProvider.SPRING_ENV_ID_KEY, this.environmentId); + Scope.child(scopeValues, EnvironmentAwareSpringLiquibase.super::afterPropertiesSet); + } + catch (Exception ex) { + if (ex instanceof LiquibaseException) { + throw (LiquibaseException) ex; + } + throw new LiquibaseException(ex); + } + } + + @Override + public void destroy() { + if (this.environmentId != null) { + EnvironmentConfigurationValueProvider.unregisterEnvironment(this.environmentId); + } + } + +} diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java index b3bee1c863fd..0d2e9dde87e9 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java @@ -20,7 +20,9 @@ import liquibase.configuration.ProvidedValue; import org.jspecify.annotations.Nullable; import org.springframework.core.env.Environment; -import org.springframework.util.Assert; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * A Liquibase {@code ConfigurationValueProvider} that passes through properties defined @@ -38,16 +40,23 @@ * No relaxed binding or key transformation is performed. Keys are looked up exactly as * provided by Liquibase (including dots and casing), prefixed with * {@code spring.liquibase.properties.}. + * + * @author Dylan Miska */ -final class EnvironmentConfigurationValueProvider extends AbstractConfigurationValueProvider { +public final class EnvironmentConfigurationValueProvider extends AbstractConfigurationValueProvider { private static final String PREFIX = "spring.liquibase.properties."; - private final Environment environment; + public static final String SPRING_ENV_ID_KEY = "spring.environment.id"; - EnvironmentConfigurationValueProvider(Environment environment) { - Assert.notNull(environment, "Environment must not be null"); - this.environment = environment; + private static final Map environments = new ConcurrentHashMap<>(); + + static void registerEnvironment(String environmentId, Environment environment) { + environments.put(environmentId, environment); + } + + static void unregisterEnvironment(String environmentId) { + environments.remove(environmentId); } @Override @@ -60,15 +69,25 @@ public int getPrecedence() { if (keyAndAliases == null) { return null; } + + String environmentId = liquibase.Scope.getCurrentScope().get(SPRING_ENV_ID_KEY, String.class); + if (environmentId == null) { + return null; + } + + Environment environment = environments.get(environmentId); + if (environment == null) { + return null; + } + for (String requestedKey : keyAndAliases) { - if (requestedKey == null) { - continue; - } - String propertyName = PREFIX + requestedKey; - String value = this.environment.getProperty(propertyName); - if (value != null) { - return new ProvidedValue(requestedKey, requestedKey, value, - "Spring Environment property '" + propertyName + "'", this); + if (requestedKey != null) { + String propertyName = PREFIX + requestedKey; + String value = environment.getProperty(propertyName); + if (value != null) { + return new ProvidedValue(requestedKey, requestedKey, value, + "Spring Environment property '" + propertyName + "'", this); + } } } return null; diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java index cffcfd4073d3..ddd5d80ebc1a 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfiguration.java @@ -16,10 +16,11 @@ package org.springframework.boot.liquibase.autoconfigure; +import java.util.Objects; + import javax.sql.DataSource; import liquibase.Liquibase; -import liquibase.Scope; import liquibase.UpdateSummaryEnum; import liquibase.UpdateSummaryOutputEnum; import liquibase.change.DatabaseChange; @@ -46,12 +47,12 @@ import org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration.LiquibaseAutoConfigurationRuntimeHints; import org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration.LiquibaseDataSourceCondition; import org.springframework.boot.sql.init.dependency.DatabaseInitializationDependencyConfigurer; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.ImportRuntimeHints; -import org.springframework.core.env.Environment; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.datasource.SimpleDriverDataSource; import org.springframework.util.Assert; @@ -73,6 +74,7 @@ * @author Evgeniy Cheban * @author Moritz Halbritter * @author Ahmed Ashour + * @author Dylan Miska * @since 4.0.0 */ @AutoConfiguration(after = DataSourceAutoConfiguration.class) @@ -104,8 +106,8 @@ PropertiesLiquibaseConnectionDetails liquibaseConnectionDetails(LiquibasePropert SpringLiquibase liquibase(ObjectProvider dataSource, @LiquibaseDataSource ObjectProvider liquibaseDataSource, LiquibaseProperties properties, ObjectProvider customizers, LiquibaseConnectionDetails connectionDetails, - Environment environment) { - registerLiquibaseConfigurationValueProvider(environment); + ApplicationContext applicationContext) { + registerLiquibaseConfigurationValueProvider(applicationContext); SpringLiquibase liquibase = createSpringLiquibase(liquibaseDataSource.getIfAvailable(), dataSource.getIfUnique(), connectionDetails); liquibase.setChangeLog(properties.getChangeLog()); @@ -151,24 +153,26 @@ private SpringLiquibase createSpringLiquibase(@Nullable DataSource liquibaseData @Nullable DataSource dataSource, LiquibaseConnectionDetails connectionDetails) { DataSource migrationDataSource = getMigrationDataSource(liquibaseDataSource, dataSource, connectionDetails); SpringLiquibase liquibase = (migrationDataSource == liquibaseDataSource - || migrationDataSource == dataSource) ? new SpringLiquibase() + || migrationDataSource == dataSource) ? new EnvironmentAwareSpringLiquibase() : new DataSourceClosingSpringLiquibase(); liquibase.setDataSource(migrationDataSource); return liquibase; } - private void registerLiquibaseConfigurationValueProvider(Environment environment) { - liquibase.configuration.LiquibaseConfiguration liquibaseConfiguration = Scope.getCurrentScope() + private void registerLiquibaseConfigurationValueProvider(ApplicationContext applicationContext) { + liquibase.configuration.LiquibaseConfiguration liquibaseConfiguration = liquibase.Scope.getCurrentScope() .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); - // Remove any previously registered instance of our provider class - liquibaseConfiguration.getProviders() + boolean providerExists = liquibaseConfiguration.getProviders() .stream() - .filter((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class) - .toList() - .forEach(liquibaseConfiguration::unregisterProvider); + .anyMatch((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class); + + if (!providerExists) { + liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider()); + } - liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider(environment)); + EnvironmentConfigurationValueProvider.registerEnvironment( + Objects.requireNonNull(applicationContext.getId()), applicationContext.getEnvironment()); } private DataSource getMigrationDataSource(@Nullable DataSource liquibaseDataSource, diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java index cc3af66d5b3b..e9c797441dce 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseProperties.java @@ -36,6 +36,7 @@ * @author Eddú Meléndez * @author Ferenc Gratzer * @author Evgeniy Cheban + * @author Dylan Miska * @since 4.0.0 */ @ConfigurationProperties(prefix = "spring.liquibase", ignoreUnknownFields = false) diff --git a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/endpoint/LiquibaseEndpoint.java b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/endpoint/LiquibaseEndpoint.java index d44a73fc313e..b266bf98a297 100644 --- a/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/endpoint/LiquibaseEndpoint.java +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/endpoint/LiquibaseEndpoint.java @@ -20,10 +20,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.sql.DataSource; +import liquibase.Scope; import liquibase.changelog.ChangeSet.ExecType; import liquibase.changelog.RanChangeSet; import liquibase.changelog.StandardChangeLogHistoryService; @@ -36,6 +38,7 @@ import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider; import org.springframework.context.ApplicationContext; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -45,6 +48,7 @@ * * @author Eddú Meléndez * @author Nabil Fawwaz Elqayyim + * @author Dylan Miska * @since 4.0.0 */ @Endpoint(id = "liquibase") @@ -58,18 +62,26 @@ public LiquibaseEndpoint(ApplicationContext context) { } @ReadOperation - public LiquibaseBeansDescriptor liquibaseBeans() { + public LiquibaseBeansDescriptor liquibaseBeans() throws Exception { ApplicationContext target = this.context; Map<@Nullable String, ContextLiquibaseBeansDescriptor> contextBeans = new HashMap<>(); while (target != null) { - Map liquibaseBeans = new HashMap<>(); - DatabaseFactory factory = DatabaseFactory.getInstance(); - target.getBeansOfType(SpringLiquibase.class) - .forEach((name, liquibase) -> liquibaseBeans.put(name, createReport(liquibase, factory))); - ApplicationContext parent = target.getParent(); - contextBeans.put(target.getId(), - new ContextLiquibaseBeansDescriptor(liquibaseBeans, (parent != null) ? parent.getId() : null)); - target = parent; + Map scopeValues = new HashMap<>(); + scopeValues.put(EnvironmentConfigurationValueProvider.SPRING_ENV_ID_KEY, + Objects.requireNonNull(target.getId())); + ApplicationContext currentContext = target; + Scope.child(scopeValues, () -> { + Map liquibaseBeans = new HashMap<>(); + DatabaseFactory factory = DatabaseFactory.getInstance(); + currentContext.getBeansOfType(SpringLiquibase.class) + .forEach((name, liquibase) -> liquibaseBeans.put(name, createReport(liquibase, factory))); + ApplicationContext parent = currentContext.getParent(); + contextBeans.put(currentContext.getId(), + new ContextLiquibaseBeansDescriptor(liquibaseBeans, (parent != null) ? parent.getId() : null)); + return null; + }); + + target = target.getParent(); } return new LiquibaseBeansDescriptor(contextBeans); } diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/actuate/endpoint/LiquibaseEndpointTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/actuate/endpoint/LiquibaseEndpointTests.java index 17da0a520d64..954f22aef5e2 100644 --- a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/actuate/endpoint/LiquibaseEndpointTests.java +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/actuate/endpoint/LiquibaseEndpointTests.java @@ -53,6 +53,7 @@ * @author Andy Wilkinson * @author Stephane Nicoll * @author Leo Li + * @author Dylan Miska */ @WithResource(name = "db/changelog/db.changelog-master.yaml", content = """ databaseChangeLog: @@ -176,6 +177,26 @@ void whenMultipleLiquibaseBeansArePresentChangeSetsAreCorrectlyReportedForEachBe }); } + @Test + @WithResource(name = "db/create-custom-schema.sql", content = "CREATE SCHEMA \"CustomSchema\";") + void customLiquibasePropertyIsAppliedDuringEndpointCall() { + this.contextRunner.withUserConfiguration(Config.class, DataSourceWithSchemaConfiguration.class) + .withPropertyValues("spring.liquibase.default-schema=CustomSchema", + "spring.liquibase.properties.liquibase.preserveSchemaCase=true") + .run((context) -> { + Map liquibaseBeans = context.getBean(LiquibaseEndpoint.class) + .liquibaseBeans() + .getContexts() + .get(context.getId()) + .getLiquibaseBeans(); + // If preserveSchemaCase wasn't applied, Liquibase would fail to find the + // mixed-case schema + assertThat(liquibaseBeans.get("liquibase").getChangeSets()).hasSize(1); + assertThat(liquibaseBeans.get("liquibase").getChangeSets().get(0).getChangeLog()) + .isEqualTo("db/changelog/db.changelog-master.yaml"); + }); + } + private boolean getAutoCommit(DataSource dataSource) throws SQLException { try (Connection connection = dataSource.getConnection()) { return connection.getAutoCommit(); diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java index bf66402dc0d6..fd9250c81b0c 100644 --- a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java @@ -16,82 +16,124 @@ package org.springframework.boot.liquibase.autoconfigure; +import liquibase.Scope; import liquibase.configuration.ProvidedValue; import org.junit.jupiter.api.Test; +import org.springframework.core.env.Environment; import org.springframework.mock.env.MockEnvironment; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; /** * Tests for {@link EnvironmentConfigurationValueProvider}. + * + * @author Dylan Miska */ class EnvironmentConfigurationValueProviderTests { @Test void precedenceIsBetweenDefaultsAndEnvVars() { - var env = new MockEnvironment(); - var provider = new EnvironmentConfigurationValueProvider(env); + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); assertThat(provider.getPrecedence()).isEqualTo(100); } @Test - void returnsProvidedValueWhenExactPropertyPresent() { - var env = new MockEnvironment().withProperty("spring.liquibase.properties.liquibase.duplicateFileMode", "WARN"); - var provider = new EnvironmentConfigurationValueProvider(env); - - ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); - - assertThat(value).isNotNull(); - assertThat(String.valueOf(value.getValue())).isEqualTo("WARN"); - assertThat(value.getActualKey()).isEqualTo("liquibase.duplicateFileMode"); - assertThat(value.getSourceDescription()) - .contains("Spring Environment property 'spring.liquibase.properties.liquibase.duplicateFileMode'"); + void returnsProvidedValueWhenExactPropertyPresent() throws Exception { + MockEnvironment env = new MockEnvironment() + .withProperty("spring.liquibase.properties.liquibase.duplicateFileMode", "WARN"); + runInScope(env, () -> { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); + + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNotNull(); + assertThat(String.valueOf(value.getValue())).isEqualTo("WARN"); + assertThat(value.getActualKey()).isEqualTo("liquibase.duplicateFileMode"); + assertThat(value.getSourceDescription()) + .contains("Spring Environment property 'spring.liquibase.properties.liquibase.duplicateFileMode'"); + }); } @Test - void returnsNullWhenNoKeysProvided() { - var env = new MockEnvironment(); - var provider = new EnvironmentConfigurationValueProvider(env); + void returnsNullWhenNoMatchingProperty() throws Exception { + MockEnvironment env = new MockEnvironment(); + runInScope(env, () -> { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); - ProvidedValue value = provider.getProvidedValue((String[]) null); + ProvidedValue value = provider.getProvidedValue("liquibase.searchPath"); - assertThat(value).isNull(); + assertThat(value).isNull(); + }); } @Test - void returnsNullWhenNoMatchingProperty() { - var env = new MockEnvironment(); - var provider = new EnvironmentConfigurationValueProvider(env); - - ProvidedValue value = provider.getProvidedValue("liquibase.searchPath"); - - assertThat(value).isNull(); + void returnsNullWhenCalledOutsideOfLiquibaseScope() { + // Register an environment, but call getProvidedValue outside of a Liquibase scope + MockEnvironment env = new MockEnvironment() + .withProperty("spring.liquibase.properties.liquibase.duplicateFileMode", "WARN"); + String environmentId = UUID.randomUUID().toString(); + EnvironmentConfigurationValueProvider.registerEnvironment(environmentId, env); + try { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); + + // Call outside of Scope.child - no SPRING_ENV_ID_KEY in current scope + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNull(); + } + finally { + EnvironmentConfigurationValueProvider.unregisterEnvironment(environmentId); + } } @Test - void skipsNullKeysAndResolvesFirstMatchingNonNull() { - var env = new MockEnvironment() - // Only the second alias is present - .withProperty("spring.liquibase.properties.liquibase.searchPath", "classpath:/db"); - var provider = new EnvironmentConfigurationValueProvider(env); + void returnsNullWhenInsideLiquibaseScopeButEnvironmentNotRegistered() throws Exception { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); + + // Create a Liquibase scope with an environment ID that doesn't exist in the + // registry + String nonExistentId = UUID.randomUUID().toString(); + Map scopeValues = new HashMap<>(); + scopeValues.put(EnvironmentConfigurationValueProvider.SPRING_ENV_ID_KEY, nonExistentId); - ProvidedValue value = provider.getProvidedValue(null, "liquibase.searchPath"); + Scope.child(scopeValues, () -> { + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); - assertThat(value).isNotNull(); - assertThat(String.valueOf(value.getValue())).isEqualTo("classpath:/db"); - assertThat(value.getActualKey()).isEqualTo("liquibase.searchPath"); + assertThat(value).isNull(); + }); } @Test - void doesNotApplyRelaxedBinding_exactKeyOnly() { - var env = new MockEnvironment().withProperty("spring.liquibase.properties.liquibase.duplicate-file-mode", - "WARN"); - var provider = new EnvironmentConfigurationValueProvider(env); + void doesNotApplyRelaxedBinding_exactKeyOnly() throws Exception { + MockEnvironment env = new MockEnvironment() + .withProperty("spring.liquibase.properties.liquibase.duplicate-file-mode", "WARN"); + runInScope(env, () -> { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); - // Request camelCase; should not match the kebab-case property - ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + // Request camelCase; should not match the kebab-case property + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNull(); + }); + } - assertThat(value).isNull(); + private void runInScope(Environment environment, Runnable runnable) throws Exception { + String environmentId = UUID.randomUUID().toString(); + EnvironmentConfigurationValueProvider.registerEnvironment(environmentId, environment); + try { + Map scopeValues = new HashMap<>(); + scopeValues.put(EnvironmentConfigurationValueProvider.SPRING_ENV_ID_KEY, environmentId); + Scope.child(scopeValues, () -> { + runnable.run(); + }); + } + finally { + EnvironmentConfigurationValueProvider.unregisterEnvironment(environmentId); + } } } diff --git a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java index b95256030cc6..7c1af59a79a5 100644 --- a/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/LiquibaseAutoConfigurationTests.java @@ -27,7 +27,9 @@ import java.sql.Connection; import java.sql.Driver; import java.sql.SQLException; +import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; @@ -51,10 +53,12 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; import org.springframework.boot.jdbc.autoconfigure.EmbeddedDataSourceConfiguration; import org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails; import org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration; import org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration.LiquibaseAutoConfigurationRuntimeHints; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -90,6 +94,7 @@ * @author Moritz Halbritter * @author Phillip Webb * @author Ahmed Ashour + * @author Dylan Miska */ @ExtendWith(OutputCaptureExtension.class) class LiquibaseAutoConfigurationTests { @@ -568,6 +573,28 @@ void shouldRegisterHints() { assertThat(RuntimeHintsPredicates.resource().forResource("db/changelog/tables/init.sql")).accepts(hints); } + @Test + @WithResource(name = "db/create-custom-schema.sql", content = "CREATE SCHEMA \"CustomSchema\";") + @WithDbChangelogMasterYamlResource + void customLiquibasePropertyIsAppliedDuringExecution() { + this.contextRunner.withUserConfiguration(DataSourceWithSchemaConfiguration.class) + .withPropertyValues("spring.liquibase.default-schema=CustomSchema", + "spring.liquibase.properties.liquibase.preserveSchemaCase=true") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + SpringLiquibase liquibase = context.getBean(SpringLiquibase.class); + + // If preserveSchemaCase wasn't applied, this would have failed during + // context startup + assertThat(liquibase.getDefaultSchema()).isEqualTo("CustomSchema"); + + JdbcTemplate jdbcTemplate = new JdbcTemplate(liquibase.getDataSource()); + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM \"CustomSchema\".DATABASECHANGELOG", + Integer.class); + assertThat(count).isGreaterThan(0); + }); + } + @Test @WithDbChangelogMasterYamlResource void springLiquibaseTakesPrecedenceOverLiquibaseDefaults() { @@ -576,10 +603,10 @@ void springLiquibaseTakesPrecedenceOverLiquibaseDefaults() { .run((context) -> { assertThat(context).hasSingleBean(SpringLiquibase.class); - var liquibaseConfig = liquibase.Scope.getCurrentScope() + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); - var provider = liquibaseConfig.getProviders() + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() .stream() .filter(p -> p.getClass() .getName() @@ -587,14 +614,18 @@ void springLiquibaseTakesPrecedenceOverLiquibaseDefaults() { .findFirst() .orElseThrow(); - var provided = provider.getProvidedValue("liquibase.duplicateFileMode"); - assertThat(provided).isNotNull(); - assertThat(String.valueOf(provided.getValue())).isEqualTo("WARN"); - - // Now check the resolved value, which should be different from the - // default 'ERROR' - var resolved = liquibaseConfig.getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); - assertThat(String.valueOf(resolved.getValue())).isEqualTo("WARN"); + runInLiquibaseScopeWithContextId(context, () -> { + liquibase.configuration.ProvidedValue provided = provider + .getProvidedValue("liquibase.duplicateFileMode"); + assertThat(provided).isNotNull(); + assertThat(String.valueOf(provided.getValue())).isEqualTo("WARN"); + + // Now check the resolved value, which should be different from the + // default 'ERROR' + liquibase.configuration.ConfiguredValue resolved = liquibaseConfig + .getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); + assertThat(String.valueOf(resolved.getValue())).isEqualTo("WARN"); + }); }); } @@ -607,10 +638,10 @@ void systemPropertyTakesPrecedenceOverSpringLiquibaseProperties() { .run((context) -> { assertThat(context).hasSingleBean(SpringLiquibase.class); - var liquibaseConfig = liquibase.Scope.getCurrentScope() + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); - var provider = liquibaseConfig.getProviders() + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() .stream() .filter(p -> p.getClass() .getName() @@ -618,14 +649,18 @@ void systemPropertyTakesPrecedenceOverSpringLiquibaseProperties() { .findFirst() .orElseThrow(); - // Our provider should return the value set through Spring property - var providedBySpring = provider.getProvidedValue("liquibase.duplicateFileMode"); - assertThat(providedBySpring).isNotNull(); - assertThat(String.valueOf(providedBySpring.getValue())).isEqualTo("WARN"); - - // Now check the resolved value, which should be the system property - var resolved = liquibaseConfig.getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); - assertThat(String.valueOf(resolved.getValue())).isEqualTo("ERROR"); + runInLiquibaseScopeWithContextId(context, () -> { + // Our provider should return the value set through Spring property + liquibase.configuration.ProvidedValue providedBySpring = provider + .getProvidedValue("liquibase.duplicateFileMode"); + assertThat(providedBySpring).isNotNull(); + assertThat(String.valueOf(providedBySpring.getValue())).isEqualTo("WARN"); + + // Now check the resolved value, which should be the system property + liquibase.configuration.ConfiguredValue resolved = liquibaseConfig + .getCurrentConfiguredValue(null, null, "liquibase.duplicateFileMode"); + assertThat(String.valueOf(resolved.getValue())).isEqualTo("ERROR"); + }); }); } @@ -637,10 +672,10 @@ void arbitraryLiquibaseKeyIsPassedThroughAsIs() { .run((context) -> { assertThat(context).hasSingleBean(SpringLiquibase.class); - var liquibaseConfig = liquibase.Scope.getCurrentScope() + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); - var provider = liquibaseConfig.getProviders() + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() .stream() .filter(p -> p.getClass() .getName() @@ -648,39 +683,15 @@ void arbitraryLiquibaseKeyIsPassedThroughAsIs() { .findFirst() .orElseThrow(); - var provided = provider.getProvidedValue("my.extension.custom.option"); - assertThat(provided).isNotNull(); - assertThat(String.valueOf(provided.getValue())).isEqualTo("true"); + runInLiquibaseScopeWithContextId(context, () -> { + liquibase.configuration.ProvidedValue provided = provider + .getProvidedValue("my.extension.custom.option"); + assertThat(provided).isNotNull(); + assertThat(String.valueOf(provided.getValue())).isEqualTo("true"); + }); }); } - @Test - @WithDbChangelogMasterYamlResource - void whenCustomizerBeanIsDefinedThenItIsConfiguredOnSpringLiquibase() { - this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class, CustomizerConfiguration.class) - .run(assertLiquibase((liquibase) -> assertThat(liquibase.getCustomizer()).isNotNull())); - } - - @Test - @WithDbChangelogMasterYamlResource - void whenAnalyticsEnabledIsFalseThenSpringLiquibaseHasAnalyticsDisabled() { - this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.analytics-enabled=false") - .run((context) -> assertThat(context.getBean(SpringLiquibase.class)) - .extracting(SpringLiquibase::getAnalyticsEnabled) - .isEqualTo(Boolean.FALSE)); - } - - @Test - @WithDbChangelogMasterYamlResource - void whenLicenseKeyIsSetThenSpringLiquibaseHasLicenseKey() { - this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.license-key=a1b2c3d4") - .run((context) -> assertThat(context.getBean(SpringLiquibase.class)) - .extracting(SpringLiquibase::getLicenseKey) - .isEqualTo("a1b2c3d4")); - } - private ContextConsumer assertLiquibase(Consumer consumer) { return (context) -> { assertThat(context).hasSingleBean(SpringLiquibase.class); @@ -689,6 +700,15 @@ private ContextConsumer assertLiquibase(Consumer scopeValues = new HashMap<>(); + scopeValues.put(EnvironmentConfigurationValueProvider.SPRING_ENV_ID_KEY, context.getId()); + liquibase.Scope.child(scopeValues, runnable::run); + } + @Configuration(proxyBeanMethods = false) static class LiquibaseDataSourceConfiguration { @@ -827,6 +847,25 @@ Customizer customizer() { } + @Configuration(proxyBeanMethods = false) + static class DataSourceWithSchemaConfiguration { + + @Bean + DataSource dataSource() { + DataSource dataSource = new EmbeddedDatabaseBuilder() + .setType(Objects.requireNonNull(EmbeddedDatabaseConnection.get(getClass().getClassLoader()).getType())) + .setName(UUID.randomUUID().toString()) + .build(); + DatabaseInitializationSettings settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(java.util.Arrays.asList("classpath:/db/create-custom-schema.sql")); + org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer initializer = new org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer( + dataSource, settings); + initializer.initializeDatabase(); + return dataSource; + } + + } + static class CustomH2Driver extends org.h2.Driver { }