Skip to content

fix(pg-meta): pair composite FK columns positionally to avoid cartesi…#317

Open
anp0429 wants to merge 1 commit into
supabase:mainfrom
anp0429:fix/composite-fk-cartesian-product
Open

fix(pg-meta): pair composite FK columns positionally to avoid cartesi…#317
anp0429 wants to merge 1 commit into
supabase:mainfrom
anp0429:fix/composite-fk-cartesian-product

Conversation

@anp0429

@anp0429 anp0429 commented Jul 1, 2026

Copy link
Copy Markdown

What kind of change does this PR introduce?

Bug fix : data-integrity issue in list_tables (verbose) foreign-key output.

What is the current behavior?

For any composite (multi-column) foreign key, list_tables (verbose) returns the cartesian product of the source and target columns. An N-column FK produces N² foreign_key_constraints entries instead of N - reporting column pairings that do not exist in the schema.

Repro:

create table public.parent (a int not null, b int not null, primary key (a, b));
create table public.child (
  a int not null, b int not null,
  constraint child_parent_fk foreign key (a, b) references public.parent (a, b)
);

list_tables with verbose: true returns 4 constraints for child_parent_fk:

public.child.a => public.parent.a
public.child.a => public.parent.b   ← does not exist
public.child.b => public.parent.a   ← does not exist
public.child.b => public.parent.b

Only a=>a and b=>b are real. Because this tool exists so an AI agent can reason about database structure, the fabricated relationships are silently trusted - an agent could infer key relationships that don't exist.

Root cause is in pg-meta/tables.sql. The FK subquery joins source and target columns independently:

... on sa.attrelid = c.conrelid  and sa.attnum = any (c.conkey)
... on ta.attrelid = c.confrelid and ta.attnum = any (c.confkey)

any(conkey) against any(confkey) cross-joins every source column with every target column, instead of pairing them positionally.

What is the new behavior?

The two arrays are walked in lockstep with unnest(conkey, confkey) with ordinality, so column i pairs only with column i. The example above now returns exactly the two real pairs. Adds a regression test asserting the exact set of column pairs for a composite FK.

Additional context

The same pg-meta query backs the schema-docs work in #278, so this fix helps that path as well.

…an product

The foreign-key subquery in tables.sql joined source and target columns
independently (attnum = any(conkey) with attnum = any(confkey)), producing the
cartesian product of a composite key's columns. An N-column foreign key yielded
N^2 relationship rows, reporting column pairings that do not exist in the schema.
This surfaces in list_tables (verbose) foreign_key_constraints.

Pair the columns positionally with unnest(conkey, confkey) with ordinality so
column i maps only to column i. Adds a regression test.
@anp0429

anp0429 commented Jul 15, 2026

Copy link
Copy Markdown
Author

Hi @gregnr : no rush on this, but I wanted to check whether you see this as a real issue before it goes further. The short version: for composite foreign keys, list_tables (verbose) returns the cartesian product of columns (N² pairings instead of N), so it reports column relationships that don't exist in the schema , which an AI agent could silently trust. There's a minimal repro in the PR. I might be missing context on why it works this way, so I'd genuinely value your read: is this a bug worth fixing, or intended behavior? Happy to adjust or close if I've misunderstood something.

@gregnr gregnr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anp0429 thanks for the PR and the ping! I just tried this and repro'd the bug on my end - nice find. The fix looks good, though one thing I noticed - for a composite FK, foreign_key_constraints is outputting one row per column pair vs a single row that represents captures both columns. If we keep it as is, nothing really signals that 2 rows belong to one atomic constraint vs 2 independent FKs.

I think we'll likely want to group within a single constraint instead - something like:

{
  "name": "child_parent_fk",
  "source_columns": ["a", "b"],
  "target_table": "public.parent",
  "target_columns": ["a", "b"]
}

so that the composite is explicit. What do you think?

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.

3 participants