Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

import java.sql.Connection;
import java.sql.SQLException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
Expand Down Expand Up @@ -48,6 +51,54 @@ public interface IntrospectionProvider {
*/
List<TableIndex> getTableIndexes(Connection connection, String database, String tableName) throws SQLException;

/**
* Get indexes for every table in a schema in one round trip.
*
* <p>The per-table {@link #getTableIndexes} above is a round trip each, which turns
* enrichment of a wide schema into hundreds of serial queries — painful on any link
* with real latency (an SSH tunnel to a replica, say). This mirrors what
* {@link #getForeignKeys} already does for constraints: fetch the whole schema once
* and group in memory.
*
* <p>Results are keyed by the caller's own table name, lower-cased — whatever was
* passed in, qualified or not — so a caller can look up what it asked for.
*
* <p>Two distinct outcomes, and callers must treat them differently:
* <ul>
* <li><b>Present, empty list</b> — the table was scanned and genuinely has no
* indexes. Nothing further to do.</li>
* <li><b>Absent</b> — this provider declined to answer for that name, and the
* caller must fall back to {@link #getTableIndexes}. An implementation is free
* to decline any name it cannot answer precisely; the Postgres one declines
* schema-qualified names rather than risk merging indexes across schemas.</li>
* </ul>
*
* <p>The default implementation just loops {@link #getTableIndexes}, so a provider
* that does not override this behaves exactly as before.
*
* @param connection The database connection
* @param database The database/schema name
* @param tableNames Tables the caller cares about (used only by the default fallback)
* @return Map of lower-cased caller-supplied table name to that table's indexes;
* names the provider declined are absent rather than empty
* @throws SQLException If a database error occurs
*/
default Map<String, List<TableIndex>> getAllTableIndexes(
Connection connection, String database, Collection<String> tableNames
) throws SQLException {
Map<String, List<TableIndex>> byTable = new HashMap<>();
for (String tableName : tableNames) {
if (tableName == null) {
continue;
}
byTable.put(
tableName.toLowerCase(Locale.ROOT),
getTableIndexes(connection, database, tableName)
);
}
return byTable;
}

/**
* Get table statistics (size, row count, etc.).
* @param connection The database connection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,78 @@ public List<ColumnInfo> getTableColumns(Connection connection, String database,
return columns;
}

/**
* One query for the whole schema instead of one per table.
*
* <p>INFORMATION_SCHEMA.STATISTICS is not cheap on MySQL, and paying for it 567 times
* in a row across a tunnel is what made schema enrichment take minutes. Scoped to a
* single TABLE_SCHEMA so this stays bounded on servers hosting many databases.
*/
@Override
public Map<String, List<TableIndex>> getAllTableIndexes(
Connection connection, String database, Collection<String> tableNames
) throws SQLException {
String query = """
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, NON_UNIQUE, INDEX_TYPE, SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
""";

// table -> index name -> index, so multi-column indexes accumulate their columns
// in SEQ_IN_INDEX order the same way the per-table path builds them.
Map<String, Map<String, TableIndex>> byTable = new HashMap<>();

try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setString(1, database);

try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String tableName = rs.getString("TABLE_NAME");
if (tableName == null) {
continue;
}
String indexName = rs.getString("INDEX_NAME");
String columnName = rs.getString("COLUMN_NAME");
boolean nonUnique = rs.getBoolean("NON_UNIQUE");
String indexType = rs.getString("INDEX_TYPE");

Map<String, TableIndex> indexMap =
byTable.computeIfAbsent(tableName.toLowerCase(Locale.ROOT), k -> new LinkedHashMap<>());

TableIndex index = indexMap.get(indexName);
if (index == null) {
index = new TableIndex();
index.setName(indexName);
index.setType(indexType);
index.setUnique(!nonUnique);
index.setPrimary("PRIMARY".equals(indexName));
index.setColumns(new ArrayList<>());
indexMap.put(indexName, index);
}
index.getColumns().add(columnName);
}
}
}

// Tables with no indexes at all must still be present, so callers can tell an
// unindexed table from one this scan never covered.
Map<String, List<TableIndex>> result = new HashMap<>();
for (String tableName : tableNames) {
if (tableName == null) {
continue;
}
// Callers look up by the name they passed in, but STATISTICS returns bare
// TABLE_NAMEs — so match on the bare name and key the result by the original.
String key = tableName.toLowerCase(Locale.ROOT);
int dot = key.lastIndexOf('.');
String bare = dot > 0 ? key.substring(dot + 1) : key;
Map<String, TableIndex> indexMap = byTable.get(bare);
result.put(key, indexMap == null ? new ArrayList<>() : new ArrayList<>(indexMap.values()));
}
return result;
}

@Override
public List<TableIndex> getTableIndexes(Connection connection, String database, String tableName) throws SQLException {
List<TableIndex> indexes = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,104 @@ LEFT JOIN (
return columns;
}

/**
* One query for every requested table instead of one per table.
*
* <p>Same joins and filters as the per-table variant below; only the predicate
* changes, from a single relname to an array of them. Matching on bare relname
* across schemas is deliberate — it is exactly what the per-table path does for an
* unqualified name, so this stays behaviour-preserving.
*
* <p>Schema-qualified names are declined (left out of the result) so the caller
* falls back to the per-table query, which filters on schema. Matching them here
* would be wrong either way: pg_class.relname is bare, so `s.t` matches nothing,
* and stripping the qualifier would match that name in every schema and merge
* their indexes.
*/
@Override
public Map<String, List<TableIndex>> getAllTableIndexes(
Connection connection, String database, Collection<String> tableNames
) throws SQLException {
String query = """
SELECT
t.relname AS table_name,
i.relname AS index_name,
a.attname AS column_name,
ix.indisunique AS is_unique,
ix.indisprimary AS is_primary,
am.amname AS index_type
FROM pg_class t
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
JOIN pg_am am ON i.relam = am.oid
WHERE t.relkind IN ('r', 'p', 'm', 'v')
AND t.relname = ANY(?)
ORDER BY t.relname, i.relname, a.attnum
""";

Map<String, Map<String, TableIndex>> byTable = new HashMap<>();
String[] names = tableNames.stream()
.filter(Objects::nonNull)
.toArray(String[]::new);

// Only unqualified names are answered here. relname is bare, so a `schema.table`
// name matches nothing as written, and stripping the qualifier would be worse:
// it would match that table name in *every* schema and merge their indexes. Such
// names are simply left out of the result, which sends the caller to the
// per-table path that filters on schema properly.
String[] bareNames = Arrays.stream(names)
.filter(n -> n.lastIndexOf('.') <= 0)
.toArray(String[]::new);
if (bareNames.length == 0) {
return new HashMap<>();
}

try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setArray(1, connection.createArrayOf("text", bareNames));

try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String tableName = rs.getString("table_name");
if (tableName == null) {
continue;
}
String indexName = rs.getString("index_name");
String columnName = rs.getString("column_name");
boolean isUnique = rs.getBoolean("is_unique");
boolean isPrimary = rs.getBoolean("is_primary");
String indexType = rs.getString("index_type");

Map<String, TableIndex> indexMap =
byTable.computeIfAbsent(tableName.toLowerCase(Locale.ROOT), k -> new LinkedHashMap<>());

TableIndex index = indexMap.get(indexName);
if (index == null) {
index = new TableIndex();
index.setName(indexName);
index.setType(indexType);
index.setUnique(isUnique);
index.setPrimary(isPrimary);
index.setColumns(new ArrayList<>());
indexMap.put(indexName, index);
}
index.getColumns().add(columnName);
}
}
}

// Every requested table gets an entry, so an unindexed table is distinguishable
// from one this scan did not cover.
Map<String, List<TableIndex>> result = new HashMap<>();
for (String tableName : bareNames) {
String key = tableName.toLowerCase(Locale.ROOT);
Map<String, TableIndex> indexMap = byTable.get(key);
result.put(key, indexMap == null ? new ArrayList<>() : new ArrayList<>(indexMap.values()));
}
return result;
}

@Override
public List<TableIndex> getTableIndexes(Connection connection, String database, String tableName) throws SQLException {
List<TableIndex> indexes = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,41 @@ private void enrichColumnsWithKeyAndIndexMetadata(
? loadForeignKeyColumns(connection, connRequest.getDatabase(), provider, connectionId)
: Collections.emptySet();

// Indexes for every table up front, in one round trip. Fetching them
// table-by-table inside the loop below meant one query per table — on a
// 567-table schema behind a tunnel that was minutes, and it ran again for
// every caller that missed the cache.
//
// Not every name is necessarily answered: a provider returns no entry for
// one it cannot resolve precisely, and an object qualified with a database
// other than this connection's is never offered in the first place. Either
// way the loop below sees no entry and falls back to the per-table query,
// which reads from the schema the name actually points at.
Map<String, List<TableIndex>> indexesByTable = Collections.emptyMap();
if (connection != null && provider != null) {
List<String> bulkTables = new ArrayList<>();
for (DatabaseObject obj : objects) {
if (isBulkIndexable(obj, connRequest.getDatabase())) {
bulkTables.add(obj.getName());
}
}
if (!bulkTables.isEmpty()) {
try {
indexesByTable = provider.getAllTableIndexes(
connection, connRequest.getDatabase(), bulkTables
);
} catch (Exception e) {
// Fall back to the per-table path rather than losing index flags.
log.warn(
"Bulk index fetch failed for connection {} ({}); falling back to per-table",
connectionId,
e.getMessage()
);
indexesByTable = Collections.emptyMap();
}
}
}

for (DatabaseObject obj : objects) {
if (obj.getColumns() == null || obj.getColumns().isEmpty()) {
continue;
Expand All @@ -181,9 +216,15 @@ private void enrichColumnsWithKeyAndIndexMetadata(
&& obj.getType() != null
&& "table".equalsIgnoreCase(obj.getType())) {
try {
List<TableIndex> indexes = provider.getTableIndexes(
connection, connRequest.getDatabase(), obj.getName()
);
String key = obj.getName() == null
? null
: obj.getName().toLowerCase(Locale.ROOT);
List<TableIndex> indexes = key == null ? null : indexesByTable.get(key);
if (indexes == null) {
indexes = provider.getTableIndexes(
connection, connRequest.getDatabase(), obj.getName()
);
}
applyIndexFlags(obj, indexes);
} catch (Exception e) {
log.debug(
Expand Down Expand Up @@ -240,6 +281,22 @@ private Set<String> loadForeignKeyColumns(
return fkColumns;
}

/**
* True when the bulk (single-schema) index fetch can answer for this object.
* A name qualified with a different schema must not be answered from the
* connection database's index list.
*/
private boolean isBulkIndexable(DatabaseObject obj, String database) {
if (obj == null || obj.getName() == null || !"table".equalsIgnoreCase(obj.getType())) {
return false;
}
int dot = obj.getName().lastIndexOf('.');
if (dot <= 0) {
return true;
}
return obj.getName().substring(0, dot).equalsIgnoreCase(database);
}

private void applyIndexFlags(DatabaseObject obj, List<TableIndex> indexes) {
if (indexes == null || indexes.isEmpty()) {
return;
Expand Down
Loading