fix(studio): filter on every column when viewing a composite FK record (#50256)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

In the Table Editor, the "View referencing record" arrow on a foreign
key cell builds its filter from the table's `relationships` list. That
list is one entry per source-by-target column combination, and the
formatter takes the first entry whose source column matches the clicked
cell, then filters the referenced table on that single target column.

For a composite foreign key this fails in two ways:

- The value from the clicked column can be applied to the wrong target
column (whichever target column happens to be listed first for that
source column).
- Only one of the key columns is ever filtered on, so the peek and the
"Open table" link return zero rows or too many rows.

This is distinct from #41068 / #41080, which fixed the cartesian
expansion in the shared `tables.ts` introspection query. The bug
reproduces with that fix in place because the mis-pairing happens in
Studio when building the filter. Reported by a customer via support.

### Reproduction

Run this in the SQL editor. The referenced column list `(org_id,
bucket_id)` is deliberately not in the referenced table's physical
column order, which is what exposes the bug.

```sql
create schema if not exists dcs;

-- bucket_id declared first, org_id second
create table dcs.org_metering_buckets (
  bucket_id bigint not null,
  org_id    bigint not null,
  primary key (org_id, bucket_id)
);

create table dcs.machine_storage_usage_buckets (
  org_id                 bigint not null,
  org_metering_bucket_id bigint not null,
  constraint machine_storage_usage_buckets_org_metering_bucket_fkey
    foreign key (org_id, org_metering_bucket_id)
    references dcs.org_metering_buckets (org_id, bucket_id)
);

insert into dcs.org_metering_buckets (bucket_id, org_id) values
  (901, 1), (902, 1), (903, 2);

insert into dcs.machine_storage_usage_buckets (org_id, org_metering_bucket_id) values
  (1, 901), (1, 902), (2, 903);
```

1. Open `dcs.machine_storage_usage_buckets` in the Table Editor (select
the `dcs` schema).
2. Hover the `org_id` cell on the row where `org_id = 2`.
3. Click the "View referencing record" arrow.
4. Click "Open table" in the popover.

**Before this PR:** the popover shows "No results were returned". "Open
table" opens `dcs.org_metering_buckets` with a single filter `bucket_id
= 2`, which matches nothing.

**After this PR:** the popover shows the one row `(bucket_id = 903,
org_id = 2)`. "Open table" opens `dcs.org_metering_buckets` with two
filters, `org_id = 2` and `bucket_id = 903`, and the URL carries two
`filter=` params. Clicking the arrow on the `org_metering_bucket_id`
cell of the same row produces the same result.

Extra checks worth doing while you are there:

- Set one of the two key columns to NULL on a row. The arrow should
disappear for both key cells on that row, since a row with a null key
column does not reference anything under MATCH SIMPLE.
- A single-column foreign key (any existing table) should behave exactly
as before.

## What is the new behavior?

- New `ForeignKeyFormatter.utils.ts` with two pure functions.
`findColumnForeignKeyConstraint` locates the constraint on the current
table that contains the clicked column. `getReferencingRecordFilters`
pairs each source column with the target column at the same ordinal
position and builds one equality filter per pair from the row's values,
keeping the existing bytea-to-hex handling per column. It returns no
filters when any key column is null.
- `ForeignKeyFormatter` now reads the foreign key constraints query,
which returns ordinally paired source and target column arrays, instead
of the `relationships` list. The grid already fetches that query for the
same schema, so it is served from the React Query cache.
- `ReferenceRecordPeek` takes a `filters` array instead of a single
column and value. Both the peek query and the "Open table" link use the
full set, and the link emits one URI-encoded `filter=` param per column.
- Unit tests cover the reproduction above, single-column keys, bytea
values, null and missing values, falsy-but-valid values such as `0`, and
malformed constraints.

## Additional context

The table-editor introspection SQL in
`packages/pg-meta/src/sql/studio/table-editor/table.ts` and
`tables-paginated.ts` still expands composite foreign keys as a
cartesian product. #41080 only fixed the shared `tables.ts` query. The
arrow no longer depends on that data, but it can still mislabel the
referenced column elsewhere in Studio, so that is left for a follow-up
rather than widening this change into pg-meta SQL.

Before:
<img width="836" height="504" alt="Screenshot 2026-09-14 at 11 22 49"
src="https://github.com/user-attachments/assets/4645d64f-9bcb-44b0-b5b1-ab11ba7436db"
/>

After:
<img width="873" height="570" alt="Screenshot 2026-09-14 at 11 23 14"
src="https://github.com/user-attachments/assets/f2c47121-fd75-4efe-a370-28015c1b674e"
/>



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

https://claude.ai/code/session_01SkH86UV1KD4Xs5k9vt43tW

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved foreign-key record previews to correctly identify referenced
records across schemas and tables.
* Added support for composite foreign keys, ensuring previews and “Open
table” links apply all required column filters.
* Improved handling of binary values and incomplete or null foreign-key
data.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrew ValleteauandClaude Fable 5.1 authored and GitHub committed 2026-09-15 19:22:04 +02:00
1 parent 0a409db31c
commit 10eaab766f
4 files changed
+352 -26

No files matched your search

@@ -8,9 +8,14 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import type { SupaRow } from '../../types'
import { isColumnMasked } from '../../utils/sensitive-data'
import { NullValue } from '../common/NullValue'
import {
findColumnForeignKeyConstraint,
getReferencingRecordFilters,
} from './ForeignKeyFormatter.utils'
import { ReferenceRecordPeek } from './ReferenceRecordPeek'
import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query'
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
import { isTableLike } from '@/data/table-editor/table-editor-types'
import { useTableQuery } from '@/data/tables/table-retrieve-query'
@@ -39,41 +44,55 @@ export const ForeignKeyFormatter = (props: Props) => {
const foreignKeyColumn = data?.columns.find((x) => x.name === column.key)
const selectedTable = isTableLike(data) ? data : undefined
const relationship = (selectedTable?.relationships ?? []).find(
(r) =>
r.source_schema === selectedTable?.schema &&
r.source_table_name === selectedTable?.name &&
r.source_column_name === column.name
)
// The constraints query returns source/target columns as ordinally paired
// arrays, which is what a composite foreign key needs to filter correctly.
const { data: foreignKeys, isPending: isLoadingForeignKeys } = useForeignKeyConstraintsQuery({
projectRef: project?.ref,
schema: selectedTable?.schema,
})
const foreignKey =
selectedTable !== undefined
? findColumnForeignKeyConstraint({
foreignKeys: foreignKeys ?? [],
schema: selectedTable.schema,
table: selectedTable.name,
columnName: column.key,
})
: undefined
const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery<PGTable>(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: relationship?.target_table_schema ?? '',
name: relationship?.target_table_name ?? '',
schema: foreignKey?.target_schema ?? '',
name: foreignKey?.target_table ?? '',
},
{
enabled:
!!project?.ref && !!relationship?.target_table_schema && !!relationship?.target_table_name,
}
{ enabled: !!project?.ref && foreignKey !== undefined }
)
const value = row[column.key]
const formattedValue =
foreignKeyColumn?.format === 'bytea' && !!value ? convertByteaToHex(value) : value
const filters =
foreignKey !== undefined
? getReferencingRecordFilters({ foreignKey, row, columns: data?.columns ?? [] })
: []
const hasReferencingRecord = filters.length > 0
const isLoadingMetadata = isLoading || (selectedTable !== undefined && isLoadingForeignKeys)
return (
<div className="flex w-full items-center justify-between flex justify-between">
<span className="m-0 grow overflow-hidden text-ellipsis">
{formattedValue === null ? <NullValue /> : isMasked ? '••••••••' : formattedValue}
</span>
{isLoading && formattedValue !== null && (
{isLoadingMetadata && formattedValue !== null && (
<div className="w-6 h-6 flex items-center justify-center">
<ShimmeringLoader className="w-4 h-4" />
</div>
)}
{!isLoading && relationship !== undefined && formattedValue !== null && (
{!isLoadingMetadata && hasReferencingRecord && (
<>
{isLoadingTargetTable && (
<div className="w-6 h-6 flex items-center justify-center">
@@ -103,11 +122,7 @@ export const ForeignKeyFormatter = (props: Props) => {
e.stopPropagation()
}}
>
<ReferenceRecordPeek
table={targetTable}
column={relationship.target_column_name}
value={formattedValue}
/>
<ReferenceRecordPeek table={targetTable} filters={filters} />
</PopoverContent>
</Popover>
)}
@@ -0,0 +1,235 @@
import { describe, expect, test } from 'vitest'
import {
findColumnForeignKeyConstraint,
getReferencingRecordFilters,
} from './ForeignKeyFormatter.utils'
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
const compositeForeignKey: ForeignKeyConstraint = {
id: 1,
constraint_name: 'machine_storage_usage_buckets_org_metering_bucket_fkey',
deletion_action: 'a',
update_action: 'a',
source_id: 10,
source_schema: 'dcs',
source_table: 'machine_storage_usage_buckets',
source_columns: ['org_id', 'org_metering_bucket_id'],
target_id: 11,
target_schema: 'dcs',
target_table: 'org_metering_buckets',
// Deliberately not in the target table's physical column order
target_columns: ['org_id', 'bucket_id'],
}
const simpleForeignKey: ForeignKeyConstraint = {
id: 2,
constraint_name: 'orders_customer_id_fkey',
deletion_action: 'a',
update_action: 'a',
source_id: 20,
source_schema: 'public',
source_table: 'orders',
source_columns: ['customer_id'],
target_id: 21,
target_schema: 'public',
target_table: 'customers',
target_columns: ['id'],
}
const bigintColumns = [
{ name: 'org_id', format: 'int8' },
{ name: 'org_metering_bucket_id', format: 'int8' },
]
describe('findColumnForeignKeyConstraint', () => {
const foreignKeys = [simpleForeignKey, compositeForeignKey]
test('finds the constraint containing the column for the given table', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys,
schema: 'dcs',
table: 'machine_storage_usage_buckets',
columnName: 'org_metering_bucket_id',
})
).toBe(compositeForeignKey)
})
test('finds the constraint for any column of a composite key', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys,
schema: 'dcs',
table: 'machine_storage_usage_buckets',
columnName: 'org_id',
})
).toBe(compositeForeignKey)
})
test('ignores constraints from other tables with the same column name', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys,
schema: 'public',
table: 'orders',
columnName: 'org_id',
})
).toBeUndefined()
})
test('ignores constraints from the same table name in another schema', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys,
schema: 'public',
table: 'machine_storage_usage_buckets',
columnName: 'org_id',
})
).toBeUndefined()
})
test('returns undefined for a column that is not part of any constraint', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys,
schema: 'public',
table: 'orders',
columnName: 'total',
})
).toBeUndefined()
})
test('returns undefined when there are no constraints', () => {
expect(
findColumnForeignKeyConstraint({
foreignKeys: [],
schema: 'public',
table: 'orders',
columnName: 'customer_id',
})
).toBeUndefined()
})
})
describe('getReferencingRecordFilters', () => {
test('pairs each composite key column with the target column at the same position', () => {
const filters = getReferencingRecordFilters({
foreignKey: compositeForeignKey,
row: { idx: 0, org_id: 2, org_metering_bucket_id: 903 },
columns: bigintColumns,
})
expect(filters).toStrictEqual([
{ column: 'org_id', operator: '=', value: 2 },
{ column: 'bucket_id', operator: '=', value: 903 },
])
})
test('builds the same filters regardless of which source column was clicked', () => {
const row = { idx: 0, org_id: 1, org_metering_bucket_id: 901 }
const filters = getReferencingRecordFilters({
foreignKey: compositeForeignKey,
row,
columns: bigintColumns,
})
expect(filters).toStrictEqual([
{ column: 'org_id', operator: '=', value: 1 },
{ column: 'bucket_id', operator: '=', value: 901 },
])
})
test('builds a single filter for a single-column foreign key', () => {
expect(
getReferencingRecordFilters({
foreignKey: simpleForeignKey,
row: { idx: 0, customer_id: 'abc' },
columns: [{ name: 'customer_id', format: 'text' }],
})
).toStrictEqual([{ column: 'id', operator: '=', value: 'abc' }])
})
test('converts bytea source values to hex', () => {
expect(
getReferencingRecordFilters({
foreignKey: simpleForeignKey,
row: { idx: 0, customer_id: { type: 'Buffer', data: [222, 173, 190, 239] } },
columns: [{ name: 'customer_id', format: 'bytea' }],
})
).toStrictEqual([{ column: 'id', operator: '=', value: '\\xdeadbeef' }])
})
test('keeps non-bytea values untouched when the column format is unknown', () => {
expect(
getReferencingRecordFilters({
foreignKey: simpleForeignKey,
row: { idx: 0, customer_id: 42 },
columns: [],
})
).toStrictEqual([{ column: 'id', operator: '=', value: 42 }])
})
test('returns no filters when any composite key value is null', () => {
expect(
getReferencingRecordFilters({
foreignKey: compositeForeignKey,
row: { idx: 0, org_id: 2, org_metering_bucket_id: null },
columns: bigintColumns,
})
).toStrictEqual([])
})
test('returns no filters when a source value is missing from the row', () => {
expect(
getReferencingRecordFilters({
foreignKey: compositeForeignKey,
row: { idx: 0, org_id: 2 },
columns: bigintColumns,
})
).toStrictEqual([])
})
test('returns no filters when the single key value is null', () => {
expect(
getReferencingRecordFilters({
foreignKey: simpleForeignKey,
row: { idx: 0, customer_id: null },
columns: [{ name: 'customer_id', format: 'text' }],
})
).toStrictEqual([])
})
test('keeps falsy but non-null values such as 0 and empty strings', () => {
expect(
getReferencingRecordFilters({
foreignKey: compositeForeignKey,
row: { idx: 0, org_id: 0, org_metering_bucket_id: '' },
columns: bigintColumns,
})
).toStrictEqual([
{ column: 'org_id', operator: '=', value: 0 },
{ column: 'bucket_id', operator: '=', value: '' },
])
})
test('returns no filters when source and target column counts differ', () => {
expect(
getReferencingRecordFilters({
foreignKey: { source_columns: ['org_id', 'bucket_id'], target_columns: ['org_id'] },
row: { idx: 0, org_id: 2, bucket_id: 903 },
columns: bigintColumns,
})
).toStrictEqual([])
})
test('returns no filters for a constraint without columns', () => {
expect(
getReferencingRecordFilters({
foreignKey: { source_columns: [], target_columns: [] },
row: { idx: 0 },
columns: [],
})
).toStrictEqual([])
})
})
@@ -0,0 +1,71 @@
import type { Filter, SupaRow } from '@/components/grid/types'
import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
type ForeignKeyColumns = Pick<ForeignKeyConstraint, 'source_columns' | 'target_columns'>
/**
* Finds the foreign key constraint on `schema.table` that includes `columnName`
* among its source columns.
*/
export function findColumnForeignKeyConstraint({
foreignKeys,
schema,
table,
columnName,
}: {
foreignKeys: ForeignKeyConstraint[]
schema: string
table: string
columnName: string
}): ForeignKeyConstraint | undefined {
return foreignKeys.find(
(foreignKey) =>
foreignKey.source_schema === schema &&
foreignKey.source_table === table &&
foreignKey.source_columns.includes(columnName)
)
}
/**
* Builds the filters that identify the record `row` references through
* `foreignKey`. Source and target columns are paired by their ordinal position
* in the constraint, so a composite foreign key filters on every column of the
* referenced table, each with the value from its own paired source column.
*
* Returns an empty array when any source value is null or missing: with the
* default MATCH SIMPLE semantics, a row with a null foreign key column does not
* reference any record.
*/
export function getReferencingRecordFilters({
foreignKey,
row,
columns,
}: {
foreignKey: ForeignKeyColumns
row: SupaRow
columns: { name: string; format: string }[]
}): Filter[] {
if (
foreignKey.source_columns.length === 0 ||
foreignKey.source_columns.length !== foreignKey.target_columns.length
) {
return []
}
const filters: Filter[] = []
for (const [index, sourceColumn] of foreignKey.source_columns.entries()) {
const value = row[sourceColumn]
if (value === null || value === undefined) return []
const format = columns.find((column) => column.name === sourceColumn)?.format
filters.push({
column: foreignKey.target_columns[index],
operator: '=',
value: format === 'bytea' ? convertByteaToHex(value) : value,
})
}
return filters
}
@@ -13,7 +13,8 @@ import { CellContextMenuWrapper } from './CellContextMenuWrapper'
import { DefaultFormatter } from './DefaultFormatter'
import { JsonFormatter } from './JsonFormatter'
import { COLUMN_MIN_WIDTH } from '@/components/grid/constants'
import type { SupaColumn, SupaRow } from '@/components/grid/types'
import { filtersToUrlParams } from '@/components/grid/SupabaseGrid.utils'
import type { Filter, SupaColumn, SupaRow } from '@/components/grid/types'
import {
ESTIMATED_CHARACTER_PIXEL_WIDTH,
getColumnDefaultWidth,
@@ -30,11 +31,11 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface ReferenceRecordPeekProps {
table: PGTable
column: string
value: string | number | Record<string, unknown>
/** One equality filter per foreign key column, identifying the referenced record */
filters: Filter[]
}
export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPeekProps) => {
export const ReferenceRecordPeek = ({ table, filters }: ReferenceRecordPeekProps) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
@@ -48,13 +49,17 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
{
projectRef: project?.ref,
tableId: table.id,
filters: [{ column, operator: '=', value }],
filters,
page: 1,
limit: 10,
},
{ placeholderData: keepPreviousData }
)
const filterSearchParams = filtersToUrlParams(filters)
.map((filter) => `filter=${encodeURIComponent(filter)}`)
.join('&')
const rows = useMemo(() => data?.rows ?? [], [data?.rows])
const selectedCellRef = useRef<{ idx: number; rowIdx: number } | null>(null)
@@ -159,10 +164,10 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
</div>
<div className="flex items-center justify-end px-2 py-1">
<EditorTablePageLink
href={`/project/${ref}/editor/${table.id}?schema=${table.schema}&filter=${column}%3Aeq%3A${value}`}
href={`/project/${ref}/editor/${table.id}?schema=${table.schema}&${filterSearchParams}`}
projectRef={ref}
id={String(table.id)}
filters={[{ column, operator: '=', value: String(value) }]}
filters={filters}
>
<Button>Open table</Button>
</EditorTablePageLink>