Skip to content

Commit f5196eb

Browse files
committed
fix(postgres): make a schema switch visible instead of silent
Postgres ships `search_path = "$user", public` everywhere, RDS and Aurora included, and the "$user" entry is inert only while no schema matches the connecting role's name. Create one — per-tenant layouts, or the per-user pattern the Postgres docs recommend and which spread after PG15 hardened `public` — and current_schema() silently becomes that schema. Verified on a stock instance: SHOW search_path; -> "$user", public SELECT current_schema(); -> public CREATE SCHEMA postgres; -- schema named after the connecting role SELECT current_schema(); -> postgres So a connection that had been reading `public` can start reading an empty user schema and report a healthy, empty brain — the exact failure the search_path change was written to fix, inverted. resolveSchema()'s fallback does not catch it, because current_schema() returned a perfectly valid schema. Two guards, both diagnostic only: - announceSchema() logs the schema at INFO when it is not `public`, and DEBUG when it is, so the unchanged historical case stays quiet while a switch is always on the record. Called from getDatabaseObjects and scanSchema rather than from resolveSchema, which runs once per table via getTableColumns — a log there would emit a line per table. - warnIfEmptyWhilePublicHasTables() fires on the fingerprint of an accidental "$user" match: nothing found in the resolved schema while `public`, where this provider used to look unconditionally, still holds tables. It names the ALTER ROLE ... SET search_path remedy. Best-effort; a failed count never breaks introspection. Default RDS/Aurora installs are unaffected either way: with no role-named schema current_schema() is `public`, exactly as before.
1 parent cff1e73 commit f5196eb

1 file changed

Lines changed: 64 additions & 0 deletions

File tree

backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ private List<DatabaseObject> getTablesAndViews(Connection connection) throws SQL
7777
""";
7878

7979
String schema = resolveSchema(connection);
80+
announceSchema(schema);
8081

8182
try (Statement stmt = connection.createStatement();
8283
ResultSet rs = stmt.executeQuery(query)) {
@@ -93,6 +94,7 @@ private List<DatabaseObject> getTablesAndViews(Connection connection) throws SQL
9394
objects.add(obj);
9495
}
9596
}
97+
warnIfEmptyWhilePublicHasTables(connection, schema, objects.size());
9698
return objects;
9799
}
98100

@@ -318,6 +320,7 @@ public SchemaMetadata scanSchema(Connection connection, String database) throws
318320

319321
Map<String, TableMetadata> tableMap = new HashMap<>();
320322
String schemaName = resolveSchema(connection);
323+
announceSchema(schemaName);
321324

322325
try (Statement stmt = connection.createStatement()) {
323326
applyStatementSettings(stmt);
@@ -338,6 +341,8 @@ public SchemaMetadata scanSchema(Connection connection, String database) throws
338341
}
339342
}
340343

344+
warnIfEmptyWhilePublicHasTables(connection, schemaName, schema.getTables().size());
345+
341346
// Batch load all columns and indexes in single queries (eliminates N+1)
342347
scanPostgreSQLColumnsBatch(connection, tableMap);
343348
scanPostgreSQLIndexesBatch(connection, tableMap);
@@ -808,6 +813,65 @@ private String resolveSchema(Connection connection) {
808813
return DEFAULT_SCHEMA;
809814
}
810815

816+
/**
817+
* Say which schema this pass is reading, so a switch is never silent.
818+
*
819+
* <p>Postgres ships {@code search_path = "$user", public} everywhere, RDS and
820+
* Aurora included. The {@code "$user"} entry is inert only while no schema
821+
* matches the connecting role's name — create one (per-tenant layouts, or the
822+
* per-user pattern the Postgres docs recommend and which spread after PG15
823+
* hardened {@code public}) and {@code current_schema()} silently becomes that
824+
* schema. A connection that had been reading {@code public} would then read an
825+
* empty user schema and report a healthy, empty brain: the very failure this
826+
* class was changed to fix, inverted.
827+
*
828+
* <p>Logged at INFO only when it is not {@code public}, because {@code public}
829+
* is the unchanged historical case and every connection would otherwise emit a
830+
* line per introspection pass.
831+
*
832+
* <p>Called from the two pass-level entry points rather than from
833+
* {@link #resolveSchema}, which runs once per table via
834+
* {@link #getTableColumns} — logging there would produce one line per table.
835+
*/
836+
private void announceSchema(String schema) {
837+
if (!DEFAULT_SCHEMA.equals(schema)) {
838+
log.info("Introspecting schema '{}' (from the session search_path, not '{}')",
839+
schema, DEFAULT_SCHEMA);
840+
} else {
841+
log.debug("Introspecting schema '{}'", schema);
842+
}
843+
}
844+
845+
/**
846+
* Warn on the fingerprint of an accidental schema switch: nothing found here,
847+
* while {@code public} — where this provider used to look unconditionally —
848+
* still holds tables.
849+
*
850+
* <p>Without this the outcome is a successful-looking run over an empty schema.
851+
* Best-effort: a failure to count is never allowed to break introspection.
852+
*/
853+
private void warnIfEmptyWhilePublicHasTables(Connection connection, String schema, int found) {
854+
if (found > 0 || DEFAULT_SCHEMA.equals(schema)) {
855+
return;
856+
}
857+
try (PreparedStatement stmt = connection.prepareStatement(
858+
"SELECT count(*) FROM pg_tables WHERE schemaname = ?")) {
859+
stmt.setString(1, DEFAULT_SCHEMA);
860+
try (ResultSet rs = stmt.executeQuery()) {
861+
if (rs.next() && rs.getLong(1) > 0) {
862+
log.warn("Schema '{}' contains no tables, but '{}' has {}. The session "
863+
+ "search_path resolves to '{}' — if that is not intended, check for a "
864+
+ "schema named after the connecting role (search_path starts with "
865+
+ "\"$user\"), or set it explicitly: "
866+
+ "ALTER ROLE <user> IN DATABASE <db> SET search_path = <schema>, public;",
867+
schema, DEFAULT_SCHEMA, rs.getLong(1), schema);
868+
}
869+
}
870+
} catch (SQLException e) {
871+
log.debug("Could not compare '{}' against '{}': {}", schema, DEFAULT_SCHEMA, e.getMessage());
872+
}
873+
}
874+
811875
private String quoteIdentifier(String identifier) {
812876
return "\"" + identifier.replace("\"", "\"\"") + "\"";
813877
}

0 commit comments

Comments
 (0)