ExternalCoreStartupGuard.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *******************************************************************************/
package org.eclipse.jgit.server.config;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;

import io.github.carstenartur.jgit.storage.hibernate.schema.CoreSchemaMigrations;

/**
 * Read-only startup gate for the released Core persistence context.
 *
 * <p>The external backend is accepted only after a normal Core Flyway history
 * exists without failed rows and Hibernate is configured for schema validation.
 * The guard performs no migration and starts no Hibernate services.</p>
 */
final class ExternalCoreStartupGuard {

	private static final String CONNECTION_URL= "hibernate.connection.url"; //$NON-NLS-1$
	private static final String CONNECTION_USER= "hibernate.connection.username"; //$NON-NLS-1$
	private static final String CONNECTION_PASSWORD= "hibernate.connection.password"; //$NON-NLS-1$
	private static final String CONNECTION_DRIVER= "hibernate.connection.driver_class"; //$NON-NLS-1$
	private static final String DEFAULT_SCHEMA= "hibernate.default_schema"; //$NON-NLS-1$
	private static final String DDL_AUTO= "hibernate.hbm2ddl.auto"; //$NON-NLS-1$

	private ExternalCoreStartupGuard() {
	}

	/** Require migration evidence and validate-only Hibernate startup. */
	static void requireReady(Properties properties) {
		Objects.requireNonNull(properties, "properties"); //$NON-NLS-1$
		String ddlAuto= properties.getProperty(DDL_AUTO);
		if (ddlAuto == null || !"validate".equalsIgnoreCase(ddlAuto.strip())) { //$NON-NLS-1$
			throw new IllegalStateException(
					"External Core requires hibernate.hbm2ddl.auto=validate; " //$NON-NLS-1$
							+ "run the matching Flyway adoption or installation first."); //$NON-NLS-1$
		}
		try {
			inspectHistory(properties);
		} catch (ClassNotFoundException | SQLException exception) {
			throw new IllegalStateException(
					"Could not verify the released Core Flyway history before Hibernate startup.", //$NON-NLS-1$
					exception);
		}
	}

	static void inspectHistory(Properties properties)
			throws ClassNotFoundException, SQLException {
		Objects.requireNonNull(properties, "properties"); //$NON-NLS-1$
		String url= requireProperty(properties, CONNECTION_URL);
		String driver= properties.getProperty(CONNECTION_DRIVER);
		if (driver != null && !driver.isBlank()) {
			Class.forName(driver);
		}
		String user= properties.getProperty(CONNECTION_USER, ""); //$NON-NLS-1$
		String password= properties.getProperty(CONNECTION_PASSWORD, ""); //$NON-NLS-1$
		try (Connection connection= DriverManager.getConnection(url, user, password)) {
			try {
				connection.setReadOnly(true);
			} catch (SQLFeatureNotSupportedException exception) {
				// The guard only executes SELECT statements even when a driver cannot
				// advertise the connection as read-only.
			}
			requireSuccessfulHistory(connection,
					properties.getProperty(DEFAULT_SCHEMA));
		}
	}

	static void requireSuccessfulHistory(Connection connection,
			String preferredSchema) throws SQLException {
		Objects.requireNonNull(connection, "connection"); //$NON-NLS-1$
		DatabaseMetaData metadata= connection.getMetaData();
		TableReference history= findHistoryTable(connection, metadata,
				preferredSchema);
		ValidatedHistoryQuery query= validatedHistoryQuery(metadata, history,
				columns(metadata, history));
		HistoryEvidence evidence= readValidatedHistory(connection, query);
		if (!evidence.sawRow() || !evidence.sawVersionedSuccess()) {
			throw new IllegalStateException(
					"Core Flyway history contains no successful versioned migration."); //$NON-NLS-1$
		}
	}

	private static ValidatedHistoryQuery validatedHistoryQuery(
			DatabaseMetaData metadata, TableReference history,
			Map<String, String> columns) throws SQLException {
		if (!CoreSchemaMigrations.SCHEMA_HISTORY_TABLE
				.equalsIgnoreCase(history.name())) {
			throw new IllegalStateException(
					"Refusing to query an unexpected schema-history table: " //$NON-NLS-1$
							+ history.name());
		}
		String installedRank= requiredColumn(columns, "installed_rank"); //$NON-NLS-1$
		String version= requiredColumn(columns, "version"); //$NON-NLS-1$
		String success= requiredColumn(columns, "success"); //$NON-NLS-1$
		return new ValidatedHistoryQuery(
				"SELECT " + quote(metadata, version) + ", " //$NON-NLS-1$ //$NON-NLS-2$
						+ quote(metadata, success) + " FROM " //$NON-NLS-1$
						+ qualifiedName(metadata, history) + " ORDER BY " //$NON-NLS-1$
						+ quote(metadata, installedRank));
	}

	/**
	 * Execute a query produced only by {@link #validatedHistoryQuery}.
	 *
	 * <p>The SQL contains no value supplied by a caller. Its table and column
	 * names are exact allow-listed Flyway identifiers obtained from JDBC metadata;
	 * the optional schema also comes from metadata, and every identifier is quoted
	 * with the driver's quote string with embedded quotes doubled.</p>
	 */
	private static HistoryEvidence readValidatedHistory(Connection connection,
			ValidatedHistoryQuery query) throws SQLException {
		boolean sawRow= false;
		boolean sawVersionedSuccess= false;
		try (Statement statement= connection.createStatement();
				ResultSet rows= statement.executeQuery(query.sql())) {
			while (rows.next()) {
				sawRow= true;
				String migrationVersion= rows.getString(1);
				boolean successful= rows.getBoolean(2);
				if (rows.wasNull() || !successful) {
					throw new IllegalStateException(
							"Core Flyway history contains an unsuccessful migration row."); //$NON-NLS-1$
				}
				if (migrationVersion != null && !migrationVersion.isBlank()) {
					sawVersionedSuccess= true;
				}
			}
		}
		return new HistoryEvidence(sawRow, sawVersionedSuccess);
	}

	private static TableReference findHistoryTable(Connection connection,
			DatabaseMetaData metadata, String preferredSchema) throws SQLException {
		String catalog= connection.getCatalog();
		String schema= preferredSchema;
		if (schema == null || schema.isBlank()) {
			try {
				schema= connection.getSchema();
			} catch (SQLFeatureNotSupportedException exception) {
				schema= null;
			}
		}

		Map<String, TableReference> matches= new LinkedHashMap<>();
		for (String pattern : tableNamePatterns()) {
			collectTables(metadata, catalog, schema, pattern, matches);
		}
		if (matches.isEmpty() && schema != null && !schema.isBlank()) {
			for (String pattern : tableNamePatterns()) {
				collectTables(metadata, catalog, null, pattern, matches);
			}
		}
		if (matches.isEmpty()) {
			throw new IllegalStateException(
					"Missing Core Flyway history table " //$NON-NLS-1$
							+ CoreSchemaMigrations.SCHEMA_HISTORY_TABLE + '.');
		}
		if (matches.size() > 1) {
			throw new IllegalStateException(
					"Core Flyway history table is ambiguous across schemas: " //$NON-NLS-1$
							+ matches.values());
		}
		return matches.values().iterator().next();
	}

	private static void collectTables(DatabaseMetaData metadata, String catalog,
			String schema, String pattern,
			Map<String, TableReference> matches) throws SQLException {
		try (ResultSet tables= metadata.getTables(catalog, schema, pattern,
				new String[] { "TABLE" })) { //$NON-NLS-1$
			while (tables.next()) {
				String tableName= tables.getString("TABLE_NAME"); //$NON-NLS-1$
				if (!CoreSchemaMigrations.SCHEMA_HISTORY_TABLE
						.equalsIgnoreCase(tableName)) {
					continue;
				}
				TableReference reference= new TableReference(
						tables.getString("TABLE_CAT"), //$NON-NLS-1$
						tables.getString("TABLE_SCHEM"), tableName); //$NON-NLS-1$
				String key= String.valueOf(reference.schema()).toLowerCase(Locale.ROOT)
						+ '.' + tableName.toLowerCase(Locale.ROOT);
				matches.putIfAbsent(key, reference);
			}
		}
	}

	private static List<String> tableNamePatterns() {
		String name= CoreSchemaMigrations.SCHEMA_HISTORY_TABLE;
		return List.of(name, name.toLowerCase(Locale.ROOT),
				name.toUpperCase(Locale.ROOT));
	}

	private static Map<String, String> columns(DatabaseMetaData metadata,
			TableReference table) throws SQLException {
		Map<String, String> columns= new LinkedHashMap<>();
		try (ResultSet result= metadata.getColumns(table.catalog(), table.schema(),
				table.name(), null)) {
			while (result.next()) {
				String name= result.getString("COLUMN_NAME"); //$NON-NLS-1$
				columns.putIfAbsent(name.toLowerCase(Locale.ROOT), name);
			}
		}
		return columns;
	}

	private static String requiredColumn(Map<String, String> columns,
			String logicalName) {
		String actualName= columns.get(logicalName.toLowerCase(Locale.ROOT));
		if (actualName == null) {
			throw new IllegalStateException(
					"Core Flyway history table is missing required column " //$NON-NLS-1$
							+ logicalName + '.');
		}
		if (!logicalName.equalsIgnoreCase(actualName)) {
			throw new IllegalStateException(
					"Core Flyway history column has an unexpected identifier: " //$NON-NLS-1$
							+ actualName);
		}
		return actualName;
	}

	private static String qualifiedName(DatabaseMetaData metadata,
			TableReference table) throws SQLException {
		if (table.schema() == null || table.schema().isBlank()) {
			return quote(metadata, table.name());
		}
		return quote(metadata, table.schema()) + '.'
				+ quote(metadata, table.name());
	}

	private static String quote(DatabaseMetaData metadata, String identifier)
			throws SQLException {
		String quote= metadata.getIdentifierQuoteString();
		if (quote == null || quote.isBlank()) {
			return identifier;
		}
		return quote + identifier.replace(quote, quote + quote) + quote;
	}

	private static String requireProperty(Properties properties, String name) {
		String value= properties.getProperty(name);
		if (value == null || value.isBlank()) {
			throw new IllegalArgumentException(
					"Missing required database property: " + name); //$NON-NLS-1$
		}
		return value;
	}

	private record HistoryEvidence(boolean sawRow,
			boolean sawVersionedSuccess) {
	}

	private record TableReference(String catalog, String schema, String name) {
	}

	private record ValidatedHistoryQuery(String sql) {
	}
}