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 new file mode 100644 index 000000000000..0d2e9dde87e9 --- /dev/null +++ b/module/spring-boot-liquibase/src/main/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProvider.java @@ -0,0 +1,96 @@ +/* + * 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 java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 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.}. + * + * @author Dylan Miska + */ +public final class EnvironmentConfigurationValueProvider extends AbstractConfigurationValueProvider { + + private static final String PREFIX = "spring.liquibase.properties."; + + public static final String SPRING_ENV_ID_KEY = "spring.environment.id"; + + 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 + public int getPrecedence() { + return 100; + } + + @Override + public @Nullable ProvidedValue getProvidedValue(String... keyAndAliases) { + 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) { + 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 4fd57b3af172..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,6 +16,8 @@ package org.springframework.boot.liquibase.autoconfigure; +import java.util.Objects; + import javax.sql.DataSource; import liquibase.Liquibase; @@ -45,6 +47,7 @@ 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; @@ -71,6 +74,7 @@ * @author Evgeniy Cheban * @author Moritz Halbritter * @author Ahmed Ashour + * @author Dylan Miska * @since 4.0.0 */ @AutoConfiguration(after = DataSourceAutoConfiguration.class) @@ -101,7 +105,9 @@ PropertiesLiquibaseConnectionDetails liquibaseConnectionDetails(LiquibasePropert @Bean SpringLiquibase liquibase(ObjectProvider dataSource, @LiquibaseDataSource ObjectProvider liquibaseDataSource, LiquibaseProperties properties, - ObjectProvider customizers, LiquibaseConnectionDetails connectionDetails) { + ObjectProvider customizers, LiquibaseConnectionDetails connectionDetails, + ApplicationContext applicationContext) { + registerLiquibaseConfigurationValueProvider(applicationContext); SpringLiquibase liquibase = createSpringLiquibase(liquibaseDataSource.getIfAvailable(), dataSource.getIfUnique(), connectionDetails); liquibase.setChangeLog(properties.getChangeLog()); @@ -147,12 +153,28 @@ 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(ApplicationContext applicationContext) { + liquibase.configuration.LiquibaseConfiguration liquibaseConfiguration = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + boolean providerExists = liquibaseConfiguration.getProviders() + .stream() + .anyMatch((provider) -> provider.getClass() == EnvironmentConfigurationValueProvider.class); + + if (!providerExists) { + liquibaseConfiguration.registerProvider(new EnvironmentConfigurationValueProvider()); + } + + EnvironmentConfigurationValueProvider.registerEnvironment( + Objects.requireNonNull(applicationContext.getId()), applicationContext.getEnvironment()); + } + private DataSource getMigrationDataSource(@Nullable DataSource liquibaseDataSource, @Nullable DataSource dataSource, LiquibaseConnectionDetails connectionDetails) { if (liquibaseDataSource != null) { 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..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) @@ -123,6 +124,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 +304,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/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 new file mode 100644 index 000000000000..fd9250c81b0c --- /dev/null +++ b/module/spring-boot-liquibase/src/test/java/org/springframework/boot/liquibase/autoconfigure/EnvironmentConfigurationValueProviderTests.java @@ -0,0 +1,139 @@ +/* + * 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.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() { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); + assertThat(provider.getPrecedence()).isEqualTo(100); + } + + @Test + 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 returnsNullWhenNoMatchingProperty() throws Exception { + MockEnvironment env = new MockEnvironment(); + runInScope(env, () -> { + EnvironmentConfigurationValueProvider provider = new EnvironmentConfigurationValueProvider(); + + ProvidedValue value = provider.getProvidedValue("liquibase.searchPath"); + + assertThat(value).isNull(); + }); + } + + @Test + 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 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); + + Scope.child(scopeValues, () -> { + ProvidedValue value = provider.getProvidedValue("liquibase.duplicateFileMode"); + + assertThat(value).isNull(); + }); + } + + @Test + 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"); + + 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 bdcff998ca11..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 { @@ -569,30 +574,122 @@ void shouldRegisterHints() { } @Test + @WithResource(name = "db/create-custom-schema.sql", content = "CREATE SCHEMA \"CustomSchema\";") @WithDbChangelogMasterYamlResource - void whenCustomizerBeanIsDefinedThenItIsConfiguredOnSpringLiquibase() { - this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class, CustomizerConfiguration.class) - .run(assertLiquibase((liquibase) -> assertThat(liquibase.getCustomizer()).isNotNull())); + 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 whenAnalyticsEnabledIsFalseThenSpringLiquibaseHasAnalyticsDisabled() { + void springLiquibaseTakesPrecedenceOverLiquibaseDefaults() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.analytics-enabled=false") - .run((context) -> assertThat(context.getBean(SpringLiquibase.class)) - .extracting(SpringLiquibase::getAnalyticsEnabled) - .isEqualTo(Boolean.FALSE)); + .withPropertyValues("spring.liquibase.properties.liquibase.duplicateFileMode=WARN") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + 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"); + }); + }); + } + + @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); + + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + 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"); + }); + }); } @Test @WithDbChangelogMasterYamlResource - void whenLicenseKeyIsSetThenSpringLiquibaseHasLicenseKey() { + void arbitraryLiquibaseKeyIsPassedThroughAsIs() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.license-key=a1b2c3d4") - .run((context) -> assertThat(context.getBean(SpringLiquibase.class)) - .extracting(SpringLiquibase::getLicenseKey) - .isEqualTo("a1b2c3d4")); + .withPropertyValues("spring.liquibase.properties.my.extension.custom.option=true") + .run((context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + + liquibase.configuration.LiquibaseConfiguration liquibaseConfig = liquibase.Scope.getCurrentScope() + .getSingleton(liquibase.configuration.LiquibaseConfiguration.class); + + liquibase.configuration.ConfigurationValueProvider provider = liquibaseConfig.getProviders() + .stream() + .filter(p -> p.getClass() + .getName() + .equals("org.springframework.boot.liquibase.autoconfigure.EnvironmentConfigurationValueProvider")) + .findFirst() + .orElseThrow(); + + runInLiquibaseScopeWithContextId(context, () -> { + liquibase.configuration.ProvidedValue provided = provider + .getProvidedValue("my.extension.custom.option"); + assertThat(provided).isNotNull(); + assertThat(String.valueOf(provided.getValue())).isEqualTo("true"); + }); + }); } private ContextConsumer assertLiquibase(Consumer consumer) { @@ -603,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 { @@ -741,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 { }