Skip to content

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

Merged
gregnr merged 4 commits into
supabase:mainfrom
anp0429:fix/composite-fk-cartesian-product
Jul 17, 2026
Merged

fix(pg-meta): pair composite FK columns positionally to avoid cartesi…#317
gregnr merged 4 commits into
supabase:mainfrom
anp0429:fix/composite-fk-cartesian-product

Conversation

@anp0429

@anp0429 anp0429 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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?

Each foreign key is now returned as one constraint object with the grouped shape discussed in the review below:

{
  "name": "child_parent_fk",
  "source_table": "public.child",
  "source_columns": ["a", "b"],
  "target_table": "public.parent",
  "target_columns": ["a", "b"]
}
  • Columns are walked in lockstep with unnest(conkey, confkey) with ordinality, so wrong pairings are impossible by construction, and both arrays aggregate ordered by constraint ordinality - source_columns[i] pairs with target_columns[i], in constraint-definition order (not attnum, not alphabetical).
  • source_table is kept in the output because relationships includes constraints where the listed table is the referenced target.
  • Single-column FKs are emitted as one-element arrays for a uniform shape.
  • Adds a regression test using deliberately non-alphabetical column order (foreign key (b, a) references parent (y, x)) so any future regression to attnum or name ordering fails immediately.

Additional context

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

@anp0429

anp0429 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor 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?

@anp0429

anp0429 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@gregnr Yeah, agreed : grouping is the right call. The per-pair rows kind of have the same problem as the original bug, just one level up: nothing tells you those two rows are one atomic constraint.
One thing I want to be careful with in the SQL: the aggregated arrays need to be ordered by the constraint's column position, not attnum or name, so the source/target pairing stays positional. I'll add a test with non-alphabetical column order to lock that in.
Quick question : do you want single-column FKs moved to the array shape too for uniformity, or should only composites change? Fine either way, will update the PR once I know.

anp8729 added 2 commits July 16, 2026 15:09
…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 force-pushed the fix/composite-fk-cartesian-product branch from c47537d to d17cdfb Compare July 16, 2026 19:54
@anp0429
anp0429 requested a review from a team as a code owner July 16, 2026 19:54
@anp0429

anp0429 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@gregnr Went ahead and pushed the grouped shape so there's something concrete to review - went with unnest(conkey, confkey) with ordinality so the positional pairing is structural, plus the non-alphabetical column-order test mentioned above. For the single-column question I defaulted to one-element arrays for a uniform shape, but it's a two-line change either way if you'd rather keep those as-is.

@anp0429

anp0429 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

To sanity-check the grouping change beyond the committed tests, I ran an automated review over this branch : an LLM proposes edge-case tests, then a deterministic gate actually executes each one against the code (no model in the pass/fail path). 9 additional cases, all passing:

  • constraint-definition order preserved even when it differs from the declared/physical column order
  • two independent composite FKs between the same table pair stay separate (no over-merging)
  • self-referential composite FKs reported once per side
  • cross-schema FK visibility from either table's schema
  • single-column FKs correctly emitted as one-element arrays
  • repeated verbose calls return identical FK output

0 gaps found. Happy to commit any of these as tests if you want the coverage in the suite.
(Still open from above: keep single-column FKs as one-element arrays for uniformity, or preserve their old shape? Defaulted to arrays, two-line change either way.)

@gregnr

gregnr commented Jul 17, 2026

Copy link
Copy Markdown
Member

@anp0429 this is great work, nice job. All of those additional test scenarios look valuable - please commit them if you can! I'll merge this once those are in. We'll keep it as you have it where the FK columns are always arrays, even for non-composite FKs.

…-PK unique ref, multi-FK, 3-column, single-column arrays)
@anp0429

anp0429 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Done, added all of those: self-referential, cross-schema, non-PK unique reference, multiple FKs between the same tables, three-column ordering, and single-column arrays. Full suite green (110/110). Thanks for the review, glad this is landing.

@anp0429

anp0429 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the Biome formatting. The remaining failures look unrelated to this PR, the e2e tests are failing on x-api-key header is required (missing API key in CI), and the Management API types check is flagging pre-existing upstream type drift. My unit tests (including the new FK ones) pass. Let me know if you'd like me to do anything else.

@gregnr
gregnr merged commit 10af00b into supabase:main Jul 17, 2026
3 of 6 checks passed
@gregnr

gregnr commented Jul 17, 2026

Copy link
Copy Markdown
Member

Merged. Thanks again @anp0429!

@anp0429

anp0429 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @gregnr, glad it was helpful. Enjoyed working through this one.

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