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.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.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.";

    public static ConfigurationModel loadConfigurationFromEnvironment() {
        final Map<String, String> environmentVariables = System.getenv()
            .entrySet()
            .stream()
            .filter(e -> !e.getKey().equals("FLYWAY_CONFIG_FILES"))
            .filter(e -> e.getKey().startsWith("flyway_") || e.getKey().startsWith("environments_") || (e.getKey()
                .startsWith("FLYWAY_") && ConfigUtils.convertKey(e.getKey()) != null))
            .collect(Collectors.toMap(k -> {
                final String prop = k.getKey().startsWith("FLYWAY_") || k.getKey()
                    .toUpperCase(Locale.ENGLISH)
                    .startsWith("FLYWAY_JDBC_PROPERTIES_") ? ConfigUtils.convertKey(k.getKey()
                    .toUpperCase(Locale.ENGLISH)) : k.getKey().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)
                .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 -> "";
        };
    }
}