TomlUtils.java
/*-
* ========================LICENSE_START=================================
* flyway-core
* ========================================================================
* Copyright (C) 2010 - 2026 Red Gate Software Ltd
* ========================================================================
* 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
*
* http://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.
* =========================LICENSE_END==================================
*/
package org.flywaydb.core.internal.configuration;
import tools.jackson.core.JacksonException.Reference;
import tools.jackson.core.JsonToken;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.exc.MismatchedInputException;
import tools.jackson.databind.module.SimpleModule;
import tools.jackson.dataformat.toml.TomlStreamReadException;
import java.util.Map.Entry;
import lombok.CustomLog;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.configuration.ClassicConfiguration;
import org.flywaydb.core.internal.configuration.models.ConfigurationModel;
import org.flywaydb.core.internal.configuration.models.EnvironmentModel;
import org.flywaydb.core.internal.util.FileUtils;
import org.flywaydb.core.internal.util.ObjectMapperFactory;
import org.flywaydb.core.internal.util.Pair;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@CustomLog
public class TomlUtils {
private static final String MSG = "Using both new Environment variable %1$s and old Environment variable %2$s. Please remove %2$s.";
private static final String ENVIRONMENTS_PREFIX = "environments_";
private static boolean hasEnvironmentsPrefix(final String key) {
return key.toLowerCase(Locale.ENGLISH).startsWith(ENVIRONMENTS_PREFIX);
}
// The environments_<name>_<setting> prefix is case-insensitive, but "environments" is a case-sensitive
// Map<String, EnvironmentModel> key, so a shouted name must be resolved against the environments already
// known from TOML rather than blindly lowercased, or it would silently create a second environment.
private static String resolveEnvironmentsCasing(final String key, final Collection<String> knownEnvironmentNames) {
final String lowered = key.toLowerCase(Locale.ENGLISH);
final String remainder = lowered.substring(ENVIRONMENTS_PREFIX.length());
final int nextUnderscore = remainder.indexOf('_');
final String candidateName = nextUnderscore == -1 ? remainder : remainder.substring(0, nextUnderscore);
final List<String> matches = knownEnvironmentNames.stream()
.filter(name -> name.equalsIgnoreCase(candidateName))
.distinct()
.collect(Collectors.toList());
if (matches.size() > 1) {
throw new FlywayException("Environment variable '"
+ key
+ "' matches multiple environments that differ only by case: "
+ String.join(", ", matches)
+ ". Rename one of them, or set this value using its exact casing.");
}
if (matches.size() == 1 && !matches.get(0).equals(candidateName)) {
return ENVIRONMENTS_PREFIX + matches.get(0) + remainder.substring(candidateName.length());
}
return lowered;
}
public static ConfigurationModel loadConfigurationFromEnvironment() {
return loadConfigurationFromEnvironment(Collections.emptySet());
}
public static ConfigurationModel loadConfigurationFromEnvironment(final Collection<String> knownEnvironmentNames) {
final Map<String, String> environmentVariables = System.getenv()
.entrySet()
.stream()
.filter(e -> !e.getKey().equals("FLYWAY_CONFIG_FILES"))
.filter(e -> e.getKey().startsWith("flyway_") || hasEnvironmentsPrefix(e.getKey()) || (e.getKey()
.startsWith("FLYWAY_") && ConfigUtils.convertKey(e.getKey()) != null))
.collect(Collectors.toMap(k -> {
final String prop;
if (k.getKey().startsWith("FLYWAY_") || k.getKey()
.toUpperCase(Locale.ENGLISH)
.startsWith("FLYWAY_JDBC_PROPERTIES_")) {
prop = ConfigUtils.convertKey(k.getKey().toUpperCase(Locale.ENGLISH));
} else {
final String key = hasEnvironmentsPrefix(k.getKey()) && !k.getKey().startsWith(ENVIRONMENTS_PREFIX)
? resolveEnvironmentsCasing(k.getKey(), knownEnvironmentNames)
: k.getKey();
prop = key.replace("_", ".");
}
if (prop != null && prop.startsWith("flyway.")) {
final String p = prop.substring("flyway.".length());
if (Arrays.stream((EnvironmentModel.class).getDeclaredFields())
.anyMatch(x -> x.getName().equals(p.split("\\.")[0]))) {
return "environments." + ClassicConfiguration.TEMP_ENVIRONMENT_NAME + "." + p;
}
}
return prop;
}, v -> Pair.of(v.getKey(), v.getValue()), (e1, e2) -> {
if (e1.getLeft().startsWith("FLYWAY_")) {
LOG.warn(String.format(MSG, e2.getLeft(), e1.getLeft()));
return e2;
} else {
LOG.warn(String.format(MSG, e1.getLeft(), e2.getLeft()));
return e1;
}
}))
.entrySet()
.stream()
.collect(Collectors.toMap(Entry::getKey, v -> v.getValue().getRight()));
return toConfiguration(unflattenMap(environmentVariables));
}
public static ConfigurationModel loadConfigurationFromCommandlineArgs(final Map<String, String> commandLineArguments) {
return toConfiguration(unflattenMap(commandLineArguments));
}
private static ConfigurationModel toConfiguration(final Map<String, Object> properties) {
final ObjectMapper objectMapper = new ObjectMapper();
final SimpleModule simpleModule = new SimpleModule();
final JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, String.class);
//noinspection unchecked
simpleModule.addDeserializer((Class<List<String>>) type.getRawClass(), new ListDeserializer());
try {
return objectMapper.rebuild()
.addModule(simpleModule)
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
.build()
.convertValue(properties, ConfigurationModel.class);
} catch (IllegalArgumentException e) {
throw new FlywayException("Unable to parse command line params.");
}
}
public static Map<String, Object> unflattenMap(final Map<String, String> map) {
final Map<String, Object> result = new HashMap<>();
for (final Entry<String, String> entry : map.entrySet()) {
final String[] parts = entry.getKey().split("\\.");
Map<String, Object> currentMap = result;
for (int i = 0; i < parts.length; i++) {
if (i != parts.length - 1) {
//noinspection unchecked
currentMap = (Map<String, Object>) currentMap.computeIfAbsent(parts[i],
(x) -> new HashMap<String, Object>());
} else {
currentMap.put(parts[i], entry.getValue());
}
}
}
return result;
}
public static ConfigurationModel loadConfigurationFiles(final List<File> files) {
final ConfigurationModel defaultConfig = ConfigurationModel.defaults();
ConfigUtils.dumpConfigurationModel(defaultConfig, "Default configuration:");
return files.stream().map(TomlUtils::loadConfigurationFile).reduce(defaultConfig, ConfigurationModel::merge);
}
static ConfigurationModel loadConfigurationFile(final File configFile) {
try {
final String configText = FileUtils.readFileAsString(configFile);
final ConfigurationModel tomlConfig = ObjectMapperFactory.getObjectMapper(configFile.toString())
.rebuild()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build()
.readerFor(ConfigurationModel.class)
.readValue(configText);
ConfigUtils.dumpConfigurationModel(tomlConfig, "Loading config file: " + configFile.getAbsolutePath());
return tomlConfig;
} catch (final MismatchedInputException exception) {
final String path = exception.getPath()
.stream()
.map(Reference::getPropertyName)
.collect(Collectors.joining("."));
final String message = "Error parsing config file due to a mismatched data type, caused by `"
+ path
+ "` namespace. Update this from "
+ JsonToken.valueDescFor(exception.getCurrentToken())
+ " to `"
+ exception.getTargetType().getSimpleName()
+ "` to fix the parsing error "
+ "\n\tin "
+ configFile.getAbsolutePath();
//noinspection ThrowInsideCatchBlockWhichIgnoresCaughtException
throw new FlywayException(message);
} catch (final TomlStreamReadException tomlread) {
final String line = FileUtils.readLine(configFile, tomlread.getLocation().getLineNr());
final StringBuilder highlight = new StringBuilder(line);
highlight.insert(tomlread.getLocation().getColumnNr() - 1, "^");
final String message = "Error parsing config file at [line "
+ tomlread.getLocation().getLineNr()
+ ", column "
+ tomlread.getLocation().getColumnNr()
+ "]"
+ getErrorCause(tomlread.getOriginalMessage())
+ ", syntax error shown by ^: "
+ highlight
+ "\n\tin "
+ configFile.getAbsolutePath();
//noinspection ThrowInsideCatchBlockWhichIgnoresCaughtException
throw new FlywayException(message);
} catch (final IOException e) {
throw new FlywayException("Unable to load config file: " + configFile.getAbsolutePath(), e);
}
}
private static String getErrorCause(final String errorMessage) {
final StringBuilder message = new StringBuilder().append(" caused by: ");
return switch (errorMessage) {
case "Duplicate key", "Unknown token" -> message.append(errorMessage).toString();
case "Table redefined" -> message.append("Duplicate table").toString();
default -> "";
};
}
}