TaxonomySchemaMigrationConfig.java

package com.taxonomy.dsl.storage;

import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.configuration.Configuration;
import org.flywaydb.core.api.configuration.FluentConfiguration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;

/**
 * Runs Taxonomy application schema migrations after the independently versioned
 * JGit Core schema and before Hibernate validates the persistence unit.
 */
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "spring.flyway.enabled", havingValue = "true")
public class TaxonomySchemaMigrationConfig {

    static final String HISTORY_TABLE = "taxonomy_schema_history";
    static final String POSTGRES_LOCATION =
            "classpath:db/migration/taxonomy/postgresql";

    private static final Set<String> LEGACY_MARKERS = Set.of(
            "app_user",
            "architecture_dsl_document",
            "taxonomy_node",
            "taxonomy_relation");

    /** Every post-baseline table required by the current JPA model. */
    private static final Set<String> REQUIRED_PORTFOLIO_TABLES = Set.of(
            "arch_project",
            "project_requirement",
            "project_req_version",
            "req_analysis_job",
            "req_analysis_item",
            "req_analysis_snapshot",
            "req_element_mapping",
            "req_relation_mapping",
            "solution_definition",
            "solution_taxonomy",
            "project_solution",
            "req_solution_link",
            "product_catalog",
            "product_taxonomy",
            "solution_product",
            "project_conflict",
            "webdav_application_credential",
            "editor_workspace",
            "editor_operation",
            "editor_checkpoint");

    @Bean
    @Primary
    public FlywayMigrationStrategy taxonomyFlywayMigrationStrategy(
            @Value("${taxonomy.jgit-storage.legacy-adoption:false}")
            boolean legacyAdoptionEnabled) {
        return flyway -> {
            JgitStorageSchemaMigrationConfig.migrateCoreSchema(
                    flyway, legacyAdoptionEnabled);
            migrateApplicationSchema(flyway.getConfiguration());
        };
    }

    static void migrateApplicationSchema(Configuration source) {
        DataSource dataSource = source.getDataSource();
        if (!isPostgreSql(dataSource)) {
            return;
        }

        SchemaState state = SchemaState.inspect(dataSource);
        FluentConfiguration configuration = new FluentConfiguration(source.getClassLoader())
                .dataSource(dataSource)
                .locations(POSTGRES_LOCATION)
                .table(HISTORY_TABLE);

        if (!state.hasHistory()) {
            LegacyState legacyState = classifyLegacyState(state.tables());
            configuration
                    .baselineOnMigrate(true)
                    .baselineVersion(legacyState == LegacyState.FRESH ? "0" : "1")
                    .baselineDescription(legacyState == LegacyState.FRESH
                            ? "before Taxonomy application schema"
                            : "verified pre-portfolio Taxonomy schema");
        }

        configuration.load().migrate();
        requireCurrentSchema(SchemaState.inspect(dataSource));
    }

    private static LegacyState classifyLegacyState(Set<String> tables) {
        long present = LEGACY_MARKERS.stream().filter(tables::contains).count();
        if (present == 0) {
            return LegacyState.FRESH;
        }
        if (present == LEGACY_MARKERS.size()) {
            return LegacyState.EXISTING;
        }
        Set<String> missing = new LinkedHashSet<>(LEGACY_MARKERS);
        missing.removeAll(tables);
        throw new IllegalStateException(
                "Unsafe partial Taxonomy application schema; missing legacy markers "
                        + missing + ". Restore a complete backup before migration.");
    }

    private static void requireCurrentSchema(SchemaState state) {
        Set<String> required = new LinkedHashSet<>(LEGACY_MARKERS);
        required.addAll(REQUIRED_PORTFOLIO_TABLES);
        required.add(HISTORY_TABLE);
        Set<String> missing = new LinkedHashSet<>(required);
        missing.removeAll(state.tables());
        if (!missing.isEmpty()) {
            throw new IllegalStateException(
                    "Taxonomy application migration completed without required tables: "
                            + missing);
        }
    }

    private static boolean isPostgreSql(DataSource dataSource) {
        try (Connection connection = dataSource.getConnection()) {
            return connection.getMetaData().getDatabaseProductName()
                    .toLowerCase(Locale.ROOT)
                    .contains("postgresql");
        } catch (SQLException exception) {
            throw new IllegalStateException(
                    "Could not identify the database for Taxonomy schema migrations",
                    exception);
        }
    }

    private enum LegacyState {
        FRESH,
        EXISTING
    }

    private record SchemaState(Set<String> tables) {

        boolean hasHistory() {
            return tables.contains(HISTORY_TABLE);
        }

        static SchemaState inspect(DataSource dataSource) {
            try (Connection connection = dataSource.getConnection()) {
                DatabaseMetaData metadata = connection.getMetaData();
                String schema = connection.getSchema();
                Set<String> tables = new LinkedHashSet<>();
                try (ResultSet resultSet = metadata.getTables(
                        connection.getCatalog(), schema, "%", new String[] {"TABLE"})) {
                    while (resultSet.next()) {
                        tables.add(resultSet.getString("TABLE_NAME")
                                .toLowerCase(Locale.ROOT));
                    }
                }
                return new SchemaState(Set.copyOf(tables));
            } catch (SQLException exception) {
                throw new IllegalStateException(
                        "Could not inspect the Taxonomy application schema",
                        exception);
            }
        }
    }
}