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