Skip to content

fix(postgres): introspect the session search_path, not a hardcoded 'public' - #40

Open
geekypunk wants to merge 5 commits into
mainfrom
fix/pg-introspection-search-path
Open

fix(postgres): introspect the session search_path, not a hardcoded 'public'#40
geekypunk wants to merge 5 commits into
mainfrom
fix/pg-introspection-search-path

Conversation

@geekypunk

Copy link
Copy Markdown
Contributor

Every catalog query in PostgresIntrospectionProvider filtered on the literal
'public', and the Java side tagged every discovered object with a
DEFAULT_SCHEMA constant of the same value. Any database that keeps its tables
elsewhere was therefore completely invisible to DeepSQL.

The failure was silent and looked like success. On a dbt warehouse whose 37
tables live in marts and whose public holds nothing but extension views,
connection init ran all nine stages in 1.1 seconds and wrote
"All set! Brain is ready." at 100% — having produced an empty schema snapshot
("tables":[], totalTables 0), zero rag_documents, zero column profiles and zero
semantic models. Nothing errored, because nothing was found to process.

All 16 predicates now filter on current_schema(), and the schema tags come from
a resolveSchema(Connection) helper reading the same value, so the target schema
becomes a property of the connection — set it on the role, or with the JDBC
currentSchema parameter — instead of a compile-time constant. It falls back to
'public' when current_schema() cannot be read, so existing connections behave
exactly as before.

getDefaultSchema() deliberately keeps returning 'public'. Its four callers
(SchemaIntrospectionService, PgVectorSearchService, AzureSearchService,
TrainingService) use it to decide whether a name needs qualifying; returning
the session schema there would strip the qualifier off precisely the names that
need it, turning marts.dim_person into a bare dim_person.

Verified against a live 25GB warehouse: the same connection that previously
snapshotted 0 tables now snapshots 37 tables and 837 columns, and init proceeds
through real data sampling instead of completing instantly.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

…ublic'

Every catalog query in PostgresIntrospectionProvider filtered on the literal
'public', and the Java side tagged every discovered object with a
DEFAULT_SCHEMA constant of the same value. Any database that keeps its tables
elsewhere was therefore completely invisible to DeepSQL.

The failure was silent and looked like success. On a dbt warehouse whose 37
tables live in `marts` and whose `public` holds nothing but extension views,
connection init ran all nine stages in 1.1 seconds and wrote
"All set! Brain is ready." at 100% — having produced an empty schema snapshot
("tables":[], totalTables 0), zero rag_documents, zero column profiles and zero
semantic models. Nothing errored, because nothing was found to process.

All 16 predicates now filter on current_schema(), and the schema tags come from
a resolveSchema(Connection) helper reading the same value, so the target schema
becomes a property of the connection — set it on the role, or with the JDBC
currentSchema parameter — instead of a compile-time constant. It falls back to
'public' when current_schema() cannot be read, so existing connections behave
exactly as before.

getDefaultSchema() deliberately keeps returning 'public'. Its four callers
(SchemaIntrospectionService, PgVectorSearchService, AzureSearchService,
TrainingService) use it to decide whether a name needs qualifying; returning
the session schema there would strip the qualifier off precisely the names that
need it, turning marts.dim_person into a bare dim_person.

Verified against a live 25GB warehouse: the same connection that previously
snapshotted 0 tables now snapshots 37 tables and 837 columns, and init proceeds
through real data sampling instead of completing instantly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@geekypunk

Copy link
Copy Markdown
Contributor Author

⚠️ Provider unit tests are red on this branch — needs a fixture update

Applied and built clean (mvn clean package → BUILD SUCCESS), but mvn test -Dtest='*Introspection*' fails:

PostgresIntrospectionProviderTest — Tests run: 11, Failures: 0, Errors: 7

Cause — test fixtures, not the production change. resolveSchema() issues a SELECT current_schema() via connection.createStatement(). Several tests stub only prepareStatement, so createStatement() returns the unstubbed-mock default null:

NullPointerException: Cannot invoke "java.sql.Statement.executeQuery(String)" because "stmt" is null
  at PostgresIntrospectionProvider.resolveSchema(...:797)
  at PostgresIntrospectionProvider.getTableColumns(...:174)

and where createStatement is stubbed, the extra query trips strict stubbing:

PotentialStubbingProblem: resultSet.getString(1) at resolveSchema(...:799)

Affected: getTableRowCount_returnsCount, getTableColumns_returnsColumns, getTableStats_returnsStats, getDatabaseObjects_returnsTables, scanSchema_fallsBackToExactCountWhenEstimateMissing, +2.

This does not indicate a production bug — a real JDBC Connection never returns null from createStatement(), and resolveSchema already catches SQLException. The fixtures simply predate the extra catalog round-trip.

I did not touch the test file, per the instruction not to change any other file. It needs a follow-up commit stubbing createStatement() + SELECT current_schema() in the affected tests.

Review note (not a blocker)

resolveSchema() runs one extra round-trip per call, and getTableColumns is called per table inside getDatabaseObjects. On the 37-table warehouse from the description that is 37 additional SELECT current_schema() queries per introspection pass. Caching it per Connection would remove that, but it is a cheap query and correctness comes first.

Verification done here

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.
…gain

resolveSchema() issues `SELECT current_schema()` on its own Statement before
each method's real query. Seven tests predated that call and broke: those
stubbing only prepareStatement got the unstubbed-mock default null from
createStatement()

  NullPointerException: Cannot invoke "java.sql.Statement.executeQuery(String)"
  because "stmt" is null   at resolveSchema(...:797)

and those stubbing createStatement handed resolveSchema the shared ResultSet,
so its getString(1) tripped strict stubbing against getString("tablename").

resolveSchema() is the first createStatement() caller in every method that
reaches the database, so returning a dedicated schemaStatement first and the
shared statement afterwards routes each to the right place. It answers "public",
keeping these fixtures on the historical schema so they go on asserting the
behaviour they were written for rather than the search_path change itself.

setUp() uses lenient() because the pure-Java tests (getDatabaseType,
getDefaultSchema) never touch the Connection and strict stubbing would fail them
over an unused stub. Three tests re-stub createStatement locally and had to
prepend schemaStatement themselves; getForeignKeys_returnsRelationships is left
alone because getForeignKeys does not call resolveSchema.

11/11 pass.
@geekypunk

Copy link
Copy Markdown
Contributor Author

✅ Tests green + Aurora/RDS safety guards added

mvn test -Dtest='*Introspection*'11/11 pass (was 7 errors).

Does this break existing Aurora / RDS connections?

Tested against a real Postgres. No, not for a default install — with one specific exception:

Scenario current_schema() Effect
Stock RDS/Aurora default ("$user", public, no role-named schema) public identical to today ✅
search_path names only a missing schema NULL → fallback public, as before ✅
Explicit search_path = marts, public marts the intended fix ✅
A schema exists matching the connecting role's name that schema ⚠️ silently switches
SHOW search_path;         ->  "$user", public
SELECT current_schema();  ->  public
CREATE SCHEMA postgres;   -- named after the connecting role
SELECT current_schema();  ->  postgres

"$user" is first in the default search_path on every Postgres, RDS and Aurora included — inert only while no schema matches the role name. Per-tenant layouts, or the per-user pattern the Postgres docs recommend (more common since PG15 hardened public), make it live. resolveSchema()'s fallback does not catch this, because current_schema() returned a valid schema.

Guards added (f5196eb)

  • announceSchema() — INFO when the schema is not public, DEBUG when it is. Called from getDatabaseObjects/scanSchema, not from resolveSchema, which runs once per table via getTableColumns (a log there = one line per table).
  • warnIfEmptyWhilePublicHasTables() — fires on the exact fingerprint of an accidental "$user" match: zero tables in the resolved schema while public still has some. Names the ALTER ROLE … SET search_path remedy. Best-effort; a failed count never breaks introspection.

Fixtures repaired (2a23bc4)

resolveSchema() is the first createStatement() caller in every DB-touching method, so a dedicated schemaStatement is returned first and the shared one after. It answers "public", keeping the existing fixtures on the historical schema so they still assert what they were written for.

The original commit cff1e73 is untouched — authorship and message preserved, not amended or squashed.

Still open (not addressed)

resolveSchema() costs one round-trip per call, and getTableColumns runs per table — 37 extra SELECT current_schema() queries per pass on the warehouse from the description. Caching per Connection would remove it.

…umns

The PK subquery joined tc.constraint_name = ku.constraint_name and filtered on
ku.table_name alone, with no schema predicate anywhere. Postgres auto-names
primary keys "<table>_pkey", so two schemas holding a same-named table collide
by construction — the join cross-matches them.

Measured against two schemas each with an `orders` table (s_a's PK is `other`,
s_b's is `name`), asking for s_b.orders:

    column_name | is_primary_key
    name        | t
    name        | t      <- duplicated row
    other       | t      <- false positive: `other` is s_a's PK, not s_b's
    other       | t      <- duplicated row

Ground truth from pg_index: s_b.orders has exactly one PK column, `name`. So the
provider reported every column as a primary key and returned each one twice —
duplicate ColumnInfo entries, and a table with no real single PK. Adding
tc.table_schema = ku.table_schema and ku.table_schema = ? returns the correct
`name | t`, `other | f`.

This predates the search_path change and was mostly latent while the provider
only ever read `public`: a collision needed some other schema to hold a
same-named table. Now that it targets whatever the session resolves to, that is
no longer the exception — staging/marts/public each holding `orders` or
`customers` is the normal shape of the dbt warehouse this change was made for.
So the earlier commit does not introduce this bug, but it does move it from
unlikely to expected, which is why it is fixed on the same branch.

Also hoists resolveSchema() out of the argument list: this method runs once per
table, and each call costs a round-trip.

Tests: 12/12. A mocked ResultSet cannot exercise SQL semantics, so the new test
asserts the two schema predicates are present and that every placeholder is
bound — enough to stop the qualification being dropped again. The correctness
evidence is the measurement above, run against Postgres 18.
@geekypunk

Copy link
Copy Markdown
Contributor Author

Second review — found a third defect, fixed on this branch (f63eea0)

getTableColumns's primary-key subquery joined tc.constraint_name = ku.constraint_name and filtered on ku.table_name alone — no schema predicate anywhere. Postgres auto-names primary keys <table>_pkey, so two schemas holding a same-named table collide by construction.

Measured against two schemas each with an orders table (s_a's PK is other, s_b's is name), asking for s_b:

 column_name | is_primary_key
 name        | t
 name        | t     <- duplicated row
 other       | t     <- FALSE POSITIVE: `other` is s_a's PK, not s_b's
 other       | t     <- duplicated row

Ground truth from pg_index: s_b.orders has exactly one PK column, name. So the provider reported every column as a primary key, each twice — duplicate ColumnInfo entries and a table with no usable PK.

With tc.table_schema = ku.table_schema and ku.table_schema = ?:

 name  | t
 other | f

Why it belongs on this branch

It predates the search_path change and was largely latent while the provider only read public — a collision needed some other schema to hold a same-named table. Now that it targets whatever the session resolves to, that is no longer the exception: staging/marts/public each holding orders or customers is the normal shape of the dbt warehouse this change was made for. The earlier commit doesn't introduce the bug, but it moves it from unlikely to expected.

Worth re-checking the 837 columns figure in the PR description against the fixed build — if any of those 37 tables share a name with a table in another schema, that count was inflated by duplicates.

Also in this commit

resolveSchema() hoisted out of the argument list — this method runs per table and each call costs a round-trip. Partially addresses the per-call overhead noted earlier.

Status

  • 12/12 tests pass.
  • A mocked ResultSet can't exercise SQL semantics, so the new test asserts the two predicates exist and every placeholder is bound. The correctness evidence is the measurement above against Postgres 18.
  • Original commit cff1e73 still untouched — authorship and message preserved.

Honouring current_schema() unconditionally changed behaviour for every Postgres
connection in order to fix the subset that keeps tables outside `public`. That is
the wrong trade, because the risk is not hypothetical: Postgres ships
`search_path = "$user", public` everywhere, RDS and Aurora included, and the
leading "$user" is inert only while no schema matches the connecting role's name.

  SHOW search_path;         ->  "$user", public
  SELECT current_schema();  ->  public
  CREATE SCHEMA postgres;   -- named after the connecting role
  SELECT current_schema();  ->  postgres

Per-tenant layouts, and the per-user pattern the Postgres docs recommend and
which spread after PG15 hardened `public`, make that live. An untouched
connection would then read an empty user schema and report a healthy, empty
brain — the failure this feature exists to fix, inverted. The warning added
earlier makes that visible after the fact; it does not prevent it.

resolveSchema() now reads search_path and current_user alongside
current_schema(), and ignores a schema that was selected by the implicit "$user"
entry: current_schema() equal to current_user while search_path is still exactly
what Postgres ships. Such a connection introspects `public`, bit for bit as
before.

The blast radius is now the connections that asked for this. Nothing changes
unless an operator sets a search_path — `ALTER ROLE … SET search_path`, or the
JDBC currentSchema parameter — which is how the reported warehouse enabled it,
so that case is unaffected.

The check is deliberately narrow: it matches the shipped default exactly. A path
of `"$user", marts` states an intent and is honoured, verified by test.

No new configuration: DatabaseConnection carries no schema field, so a config
route would mean entity, DTO, API and UI. The database-side search_path is
already the opt-in.

Tests: 16/16, covering the implicit "$user" match, a configured path, "$user"
inside a non-default path, and a NULL current_schema().
@geekypunk

Copy link
Copy Markdown
Contributor Author

Opt-in guard added (05ddf85) — this is now safe to merge

The concern was right: honouring current_schema() unconditionally changed behaviour for every Postgres connection to fix the subset that keeps tables outside public. That trade is now gone.

resolveSchema() reads search_path and current_user alongside current_schema(), and ignores a schema picked by the implicit "$user" entry:

search_path current_schema() Result
"$user", public (untouched default) = current_user public — implicit, ignored
"$user", public (untouched default) public public — unchanged
marts, public (configured) marts marts — honoured
"$user", marts (configured) = current_user honoured — a stated intent
names only missing schemas NULL public

An operator who never set a search_path gets the historical behaviour bit for bit. Not "probably fine" — structurally. The blast radius is now exactly the connections that asked for this, and the reported warehouse enabled it with ALTER ROLE … SET search_path = marts, public, so it is unaffected.

The check matches the shipped default exactly, so "$user", marts is treated as deliberate and honoured (covered by test).

No new configuration: DatabaseConnection has no schema field, so a config route would mean entity + DTO + API + UI. The database-side search_path already is the opt-in.

Branch state

16/16 tests pass. Four commits on top of the original, which is untouched:

cff1e73 the original patch — authorship and message preserved
f5196eb INFO log on a non-public schema; WARN when it is empty while public is not
2a23bc4 fixture repair for SELECT current_schema()
f63eea0 schema-qualify the PK subquery (fixed false primary keys + duplicate columns)
05ddf85 this guard

venkateshsakamuri-lab pushed a commit that referenced this pull request Aug 15, 2026
## Problem

Pull requests were sitting on a required check that could never arrive:

```
analyze (${{ matrix.language }})   Expected — Waiting for status to be reported   [Required]
```

Note the name: the **raw, un-interpolated template**. That is not a
check that failed — it is a check nothing will ever report.

## Root cause

`codeql.yml` guarded the `analyze` job with:

```yaml
if: github.event.repository.visibility == 'public'
```

A job-level `if:` is evaluated **before the matrix expands**. So when
the job is skipped, GitHub emits a *single* check run under the literal
`name:` template rather than the two expanded names. While this
repository was private, every PR reported exactly one CodeQL check,
named `analyze (${{ matrix.language }})`, conclusion `skipped` — still
visible on the older open PRs (#40, #36).

That phantom name was the only CodeQL check anyone had seen, so it was
pinned as a required status check in the `Protect main branch` ruleset —
GitHub's suggestion list offers whatever was last reported.

When the repository went public, the job started running for real and
reporting `analyze (java-kotlin)` and `analyze (javascript-typescript)`.
The required phantom was left with nothing to satisfy it, and every PR
became unmergeable with no failing job to point at.

## Fix

Remove the guard. Its own comment named the condition for deleting it —
*"DELETE THIS LINE once the repository is public"* — and that condition
is now met: the repository is public and code scanning is free.

The replacement comment records the failure mode so the `if:` is not
reintroduced, and directs a future private-repository scenario to the
ruleset instead of a job condition.

## Test plan

- [ ] CodeQL runs on this PR and reports **`analyze (java-kotlin)`** and
**`analyze (javascript-typescript)`** — not the template name
- [ ] Both legs pass, satisfying the two required contexts already
configured in the ruleset
- [ ] No new `if:` remains on the job (the two matches in the file are
inside comments)
- [ ] Watch the Monday `27 4 * * 1` cron: scheduled runs on Aug 3 and
Aug 10 both reported `skipped`, correctly, since the repository was
private then. Whether `github.event.repository.visibility` is even
populated on `schedule` events was never tested — removing the guard
makes it moot, and the next cron should now produce a real scan.

## Notes

- The `Protect main branch` ruleset has already been corrected
separately; it now lists only the two expanded contexts. This PR removes
the thing that generated the bad name in the first place.
- Older PRs created while the repository was private still carry the
stale skipped check and report `UNKNOWN` mergeability. A rebase or any
push forces GitHub to recompute them against the corrected ruleset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
`getTablesAndViews()` passes the schema each table was found in, but
`getTableColumns` bound `DEFAULT_SCHEMA` (`'public'`) and discarded the
argument. Every table on a database whose objects live outside `public`
came back with **zero columns**.

Measured against a real dbt warehouse (195 tables across 17 schemas,
nothing in `public`):

```
marts.dim_person, schema bound to 'public' ->  0 columns
                                   'marts' -> 91 columns
```

The PK subquery also had no schema predicate at all. Postgres auto-names
primary keys `<table>_pkey`, so two schemas holding a same-named table
cross-match by construction — producing duplicated `ColumnInfo` rows and
false-positive PK flags. This adds `tc.table_schema = ku.table_schema`
and `ku.table_schema = ?`.

Both problems were latent while the provider only ever read `public`.
#55 made them live: now that introspection walks every non-system
schema, a `staging`/`marts`/`public` collision on `orders` or
`customers` is the normal shape of a dbt warehouse, not the exception.

## Verification

Deployed and measured on a live 195-table warehouse:

| | before | after |
|---|---:|---:|
| schema snapshot | 37 tables / 837 cols | **195 / 4,070** |
| tables returning zero columns | 195 of 195 | **0 of 195** |
| tables with correctly-detected PKs | — | 67 |
| tables with duplicated column rows | — | 0 |
| tables with every column flagged PK | — | 0 |

A full brain re-init on that connection went from a 37-table snapshot to
195 tables / 4,070 columns, lifting `table_classification` 25 → 161 and
`inferred_table_relationship` 17 → 280. The agent went from being blind
to 14 of 18 schemas to correctly describing them.

Unit tests: `PostgresIntrospectionProviderTest` passes. A mocked
`ResultSet` can't exercise SQL semantics, so the correctness evidence is
the measurement above, against Postgres 18.

## Relationship to #40

#40 is open and `CONFLICTING`. It fixed the same class of problem with a
`current_schema()` approach that #55 superseded by scanning all
non-system schemas. This PR sits on top of #55 instead and is
independent of #40#40 can likely be closed once this lands.

Co-authored-by: deepsql-deploy <venkatesh.sakamuri@stayflexi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant