From 10eaab766f0492c82ea3c95aecd5e41ce4371a87 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 15 Sep 2026 19:22:04 +0200 Subject: [PATCH] fix(studio): filter on every column when viewing a composite FK record (#50256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: Screenshot 2026-09-14 at 11 22 49 After: Screenshot 2026-09-14 at 11 23 14 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SkH86UV1KD4Xs5k9vt43tW ## 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. Co-authored-by: Claude Fable 5.1 --- .../formatter/ForeignKeyFormatter.tsx | 53 ++-- .../ForeignKeyFormatter.utils.test.ts | 235 ++++++++++++++++++ .../formatter/ForeignKeyFormatter.utils.ts | 71 ++++++ .../formatter/ReferenceRecordPeek.tsx | 19 +- 4 files changed, 352 insertions(+), 26 deletions(-) create mode 100644 apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.test.ts create mode 100644 apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.ts diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 13fc85c673e..55cd23afd4a 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -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( { 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 (
{formattedValue === null ? : isMasked ? '••••••••' : formattedValue} - {isLoading && formattedValue !== null && ( + {isLoadingMetadata && formattedValue !== null && (
)} - {!isLoading && relationship !== undefined && formattedValue !== null && ( + {!isLoadingMetadata && hasReferencingRecord && ( <> {isLoadingTargetTable && (
@@ -103,11 +122,7 @@ export const ForeignKeyFormatter = (props: Props) => { e.stopPropagation() }} > - + )} diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.test.ts b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.test.ts new file mode 100644 index 00000000000..f130654d834 --- /dev/null +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.test.ts @@ -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([]) + }) +}) diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.ts b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.ts new file mode 100644 index 00000000000..52a29f885a4 --- /dev/null +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.utils.ts @@ -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 + +/** + * 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 +} diff --git a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx index 4d0d9674416..0cb40e98dce 100644 --- a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx +++ b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx @@ -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 + /** 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