Files
supabase/apps/studio/components/interfaces/Database/Tables/TableList.tsx
T
Charis d79a276824 studio: ColumnTypeRef cascade + FK type comparison fixes (2/7) (#45903)
## 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?

Refactor + bug fixes (part of the SafeSql migration stack — PR 2 of 7,
stacks on top of #45897).

## What is the current behavior?

- `pgMeta.columns.create` and the table-editor SQL builder take column
type as a string with array suffix and schema baked in (e.g.
`'private.test_enum'`, `'int4[]'`).
- The studio table-editor SQL emits the legacy schema-embedded `format`
string for enums in non-public schemas, while the pg-meta columns SQL
already returns the new shape (bare `format` + separate
`format_schema`). The two queries disagree on how to represent the same
column, surfacing as a false-positive type mismatch in the FK selector
when both ends are an enum from a non-public schema.
- The FK selector compares column types by `format` alone — same-named
enums in different schemas appear equal, and arrays vs. scalars of the
same base type pass the family check.
- `displayColumnType` renders arrays as the raw `_typname` pg-meta emits
(e.g. `_int4` instead of `int4[]`).

## What is the new behavior?

**pg-meta**

- Introduce `ColumnTypeRef` (`{ schema?, name, isArray? }`) for column
type input, replacing the legacy string-with-array-suffix format.
`pgMeta.columns.create` and the table-editor SQL builder consume the new
shape.
- Add `format_schema` to the column zod schema; pg-meta SQL emits the
type's schema for the table editor's ColumnType dropdown.
- `pgMeta.columns.create` returns a `SafeSqlFragment`.
- Studio table-editor SQL now emits bare `format` + `format_schema`,
matching pg-meta's columns SQL.

**Studio**

- `SafePostgresColumn`/`SafePostgresTable` extend the new `PG*` types
(master dropped postgres-meta).
- Pipe `ColumnTypeRef` through `SidePanelEditor` → `ColumnEditor` →
`TableEditor`, along with the column-create mutation, table
retrieve/list queries, and the `TableList`/`ColumnList` surfaces.
- `displayColumnType` helper renders arrays as `type[]` (or
`schema.type[]`) and handles non-implicit schemas.
- FK selector now carries `sourceIsArray`/`targetIsArray` and compares
the full `(format, format_schema, isArray)` triple. Family checks for
numeric/text/uuid skip when either side is an array (FKs across array
boundaries are never compatible).
- Type-mismatch and type-notice alerts pass `isArray` to the display
helper.
- Bundle `Policies.utils` + `Policies.types` + `sql-policy-mutation`,
`PolicyEditorModal`, and `SchemaGraph` here because `SidePanelEditor`
consumes `acceptGeneratedPolicy`/`AcceptedGeneratedPolicy` — splitting
requires temporary overloads with no architectural payoff.

## Additional context

Part of the SafeSql migration stack. Stacks on top of #45897.

### Manual test checklist

Surfaces touched by this PR — please exercise each:

**Table editor**
- [x] Create a new table with a mix of column types (scalar, array,
enum, foreign key)
- [x] Add a column to an existing table; verify the type dropdown lists
scalars + arrays separately and shows schema-qualified names for
non-public enums
- [x] Edit an existing column's type (scalar ↔ array, switch between
enums in different schemas) and save
- [x] Verify enum types from a non-public schema (e.g.
`private.my_enum`) display as `private.my_enum` in the column list

**Foreign key selector**
- [x] Open the FK selector for a column and pick a target column with a
matching type — no mismatch warning
- [x] Pick a target column whose type differs only by schema (two
same-named enums in different schemas) — should show a type-mismatch
alert
- [x] Pick a target column where one side is an array and the other is a
scalar of the same base type — should show a type-mismatch alert (no
auto-cast across array boundary)
- [x] When FK target sets the column type, verify `format_schema` and
`isArray` are preserved on the source column
- [x] Type-mismatch and type-notice alert messages render array types as
`type[]` (not `_type`)

**Column list / table list**
- [x] Schema-qualified type names display correctly for columns whose
type lives in a non-public schema
- [x] Array columns display as `type[]` (or `schema.type[]`)

**Policies (bundled due to import dependency)**
- [x] Open the Policies page; create/edit/delete a row-level policy via
the modal
- [x] Generate a policy via the AI assistant and accept it through
`SidePanelEditor` — verify the accepted policy lands in the editor
correctly

**Schema visualizer**
- [x] Open the Schemas → Schema Visualizer page; verify it renders
without type errors and shows tables/relationships

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

* **Improvements**
* Support for column types in non-public schemas and richer column type
presentation (includes schema and array info).
* Stronger SQL safety around policies and constraints; draft policy SQL
is now promoted explicitly on save.
* Improved foreign-key type validation and compatibility checks using
enhanced type metadata.

* **Tests**
* Updated snapshots and tests to reflect new column metadata and SQL
fragment handling.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45903)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 15:12:08 -04:00

600 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { noop } from 'lodash'
import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsString, useQueryState } from 'nuqs'
import { useRef, useState } from 'react'
import {
Button,
Card,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Label_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
import { formatAllEntities } from './Tables.utils'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import AlertError from '@/components/ui/AlertError'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
import SchemaSelector from '@/components/ui/SchemaSelector'
import { Shortcut } from '@/components/ui/Shortcut'
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import { useForeignTablesQuery } from '@/data/foreign-tables/foreign-tables-query'
import { useMaterializedViewsQuery } from '@/data/materialized-views/materialized-views-query'
import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useViewsQuery } from '@/data/views/views-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import { onSearchInputEscape } from '@/lib/keyboard'
import type { SafePostgresTable } from '@/lib/postgres-types'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface TableListProps {
onAddTable: () => void
onEditTable: (table: SafePostgresTable) => void
onDeleteTable: (table: SafePostgresTable) => void
onDuplicateTable: (table: SafePostgresTable) => void
}
export const TableList = ({
onDuplicateTable,
onAddTable = noop,
onEditTable = noop,
onDeleteTable = noop,
}: TableListProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const prefetchEditorTablePage = usePrefetchEditorTablePage()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const { can: canUpdateTables } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'tables'
)
const {
data: tables,
error: tablesError,
isError: isErrorTables,
isPending: isLoadingTables,
isSuccess: isSuccessTables,
} = useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
sortByProperty: 'name',
includeColumns: true,
},
{
select(tables) {
return filterString.length === 0
? tables
: tables.filter((table) => table.name.toLowerCase().includes(filterString.toLowerCase()))
},
}
)
const {
data: views,
error: viewsError,
isError: isErrorViews,
isPending: isLoadingViews,
isSuccess: isSuccessViews,
} = useViewsQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
},
{
select(views) {
return filterString.length === 0
? views
: views.filter((view) => view.name.toLowerCase().includes(filterString.toLowerCase()))
},
}
)
const {
data: materializedViews,
error: materializedViewsError,
isError: isErrorMaterializedViews,
isPending: isLoadingMaterializedViews,
isSuccess: isSuccessMaterializedViews,
} = useMaterializedViewsQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
},
{
select(materializedViews) {
return filterString.length === 0
? materializedViews
: materializedViews.filter((view) =>
view.name.toLowerCase().includes(filterString.toLowerCase())
)
},
}
)
const {
data: foreignTables,
error: foreignTablesError,
isError: isErrorForeignTables,
isPending: isLoadingForeignTables,
isSuccess: isSuccessForeignTables,
} = useForeignTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
},
{
select(foreignTables) {
return filterString.length === 0
? foreignTables
: foreignTables.filter((table) =>
table.name.toLowerCase().includes(filterString.toLowerCase())
)
},
}
)
const { data: publications } = useDatabasePublicationsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const realtimePublication = (publications ?? []).find(
(publication) => publication.name === 'supabase_realtime'
)
const entities = formatAllEntities({ tables, views, materializedViews, foreignTables }).filter(
(x) => visibleTypes.includes(x.type)
)
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
const canAddTables = canUpdateTables && !isSchemaLocked
useShortcut(
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
},
{ label: 'Search tables' }
)
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
setVisibleTypes(Object.values(ENTITY_TYPE))
setFilterString('')
})
const error = tablesError || viewsError || materializedViewsError || foreignTablesError
const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables
const isLoading =
isLoadingTables || isLoadingViews || isLoadingMaterializedViews || isLoadingForeignTables
const isSuccess =
isSuccessTables && isSuccessViews && isSuccessMaterializedViews && isSuccessForeignTables
const formatTooltipText = (entityType: string) => {
const text =
Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entityType)?.[0]
?.toLowerCase()
?.split('_')
?.join(' ') || ''
// Return sentence case (capitalize first letter only)
return text.charAt(0).toUpperCase() + text.slice(1)
}
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
<div className="flex gap-2 items-center">
<Shortcut
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
onTrigger={() => setSchemaSelectorOpen(true)}
side="bottom"
tooltipOpen={schemaSelectorOpen ? false : undefined}
>
<SchemaSelector
className="grow lg:grow-0 w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
open={schemaSelectorOpen}
onOpenChange={setSchemaSelectorOpen}
/>
</Shortcut>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="tiny"
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="px-1"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
<div key={key} className="group flex items-center justify-between py-0.5">
<div className="flex items-center gap-x-2">
<Checkbox
id={key}
name={key}
checked={visibleTypes.includes(value)}
onCheckedChange={() => {
if (visibleTypes.includes(value)) {
setVisibleTypes(visibleTypes.filter((y) => y !== value))
} else {
setVisibleTypes(visibleTypes.concat([value]))
}
}}
/>
<Label_Shadcn_ htmlFor={key} className="capitalize text-xs">
{key.toLowerCase().replace('_', ' ')}
</Label_Shadcn_>
</div>
<Button
size="tiny"
type="default"
onClick={() => setVisibleTypes([value])}
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
>
Select only
</Button>
</div>
))}
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</div>
<div className="flex grow justify-between gap-2 items-center">
<Input
ref={searchInputRef}
size="tiny"
containerClassName="grow lg:grow-0 w-52"
placeholder="Search for a table"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
icon={<Search />}
/>
{!isSchemaLocked &&
(canAddTables ? (
<Shortcut
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
label="Create new table"
onTrigger={() => onAddTable()}
side="bottom"
>
<Button className="w-auto ml-auto" icon={<Plus />} onClick={() => onAddTable()}>
New table
</Button>
</Shortcut>
) : (
<ButtonTooltip
className="w-auto ml-auto"
icon={<Plus />}
disabled
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to create tables',
},
}}
>
New table
</ButtonTooltip>
))}
</div>
</div>
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="tables" />}
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<div className="w-full">
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead key="icon" className="w-0 px-0!" />
<TableHead key="name" className="max-w-[160px] sm:max-w-[280px]">
Name
</TableHead>
<TableHead key="columns">Columns</TableHead>
<TableHead key="rows">Rows (Estimated)</TableHead>
<TableHead key="size">Size (Estimated)</TableHead>
<TableHead key="realtime">Realtime</TableHead>
<TableHead key="buttons"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{entities.length === 0 && filterString.length === 0 && (
<TableRow key={selectedSchema}>
<TableCell colSpan={7}>
{visibleTypes.length === 0 ? (
<>
<p className="text-sm text-foreground">
Please select at least one entity type to filter with
</p>
<p className="text-sm text-foreground-light">
There are currently no results based on the filter that you have
applied
</p>
</>
) : (
<>
<p className="text-sm text-foreground">No tables created yet</p>
<p className="text-sm text-foreground-light">
There are no{' '}
{visibleTypes.length === 5
? 'tables'
: visibleTypes.length === 1
? `${formatTooltipText(visibleTypes[0])}s`
: `${visibleTypes
.slice(0, -1)
.map((x) => `${formatTooltipText(x)}s`)
.join(
', '
)}, and ${formatTooltipText(visibleTypes[visibleTypes.length - 1])}s`}{' '}
found in the schema "{selectedSchema}"
</p>
</>
)}
</TableCell>
</TableRow>
)}
{entities.length === 0 && filterString.length > 0 && (
<TableRow key={selectedSchema}>
<TableCell colSpan={7}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</TableCell>
</TableRow>
)}
{entities.length > 0 &&
entities.map((x) => (
<TableRow key={x.id}>
<TableCell className="w-0 pl-5! pr-1!">
<Tooltip>
<TooltipTrigger asChild className="cursor-default">
<div className="flex w-4 justify-center">
<EntityTypeIcon type={x.type} />
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{formatTooltipText(x.type)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="max-w-[160px] sm:max-w-[280px]">
<div className="flex min-w-0 flex-col">
{/* only show tooltips if required, to reduce noise */}
{x.name.length > 20 ? (
<Tooltip disableHoverableContent={true}>
<TooltipTrigger
asChild
className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
>
<p>{x.name}</p>
</TooltipTrigger>
<TooltipContent side="bottom">{x.name}</TooltipContent>
</Tooltip>
) : (
<p>{x.name}</p>
)}
{x.comment !== null ? (
<span
className="max-w-md truncate text-foreground-lighter"
title={x.comment}
>
{x.comment}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<p className="text-foreground-light">
{x.columns.length.toLocaleString()}
</p>
</TableCell>
<TableCell>
{x.rows !== undefined ? (
<p className="text-foreground-light">{x.rows.toLocaleString()}</p>
) : (
<p className="text-foreground-muted">–</p>
)}
</TableCell>
<TableCell>
{x.size !== undefined ? (
<p className="text-foreground-light">{x.size}</p>
) : (
<p className="text-foreground-muted">–</p>
)}
</TableCell>
<TableCell>
{(realtimePublication?.tables ?? []).find(
(table) => table.id === x.id
) ? (
<div className="flex items-center gap-x-2">
<Check size={16} strokeWidth={2} className="text-brand-link" />
<p className="text-foreground-light">Enabled</p>
</div>
) : (
<div className="flex items-center gap-x-2">
<X size={16} strokeWidth={2} className="text-foreground-muted" />
<p className="text-foreground-lighter">Disabled</p>
</div>
)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button asChild type="default">
<Link href={`/project/${ref}/database/tables/${x.id}`}>
View columns
</Link>
</Button>
{!isSchemaLocked && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2"
onClick={() =>
router.push(
buildTableEditorUrl({
projectRef: project?.ref,
tableId: x.id,
schema: x.schema,
})
)
}
onMouseEnter={() =>
prefetchEditorTablePage({
id: x.id ? String(x.id) : undefined,
})
}
>
<Eye size={12} />
<p>View in Table Editor</p>
</DropdownMenuItem>
{x.type === ENTITY_TYPE.TABLE && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdateTables}
onClick={() => {
if (canUpdateTables) onEditTable(x)
}}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to edit this table',
},
}}
>
<Edit size={12} />
<p>Edit table</p>
</DropdownMenuItemTooltip>
<DropdownMenuItemTooltip
key="duplicate-table"
className="gap-x-2"
disabled={!canUpdateTables}
onClick={() => {
if (canUpdateTables) onDuplicateTable(x)
}}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to duplicate tables',
},
}}
>
<Copy size={12} />
<span>Duplicate Table</span>
</DropdownMenuItemTooltip>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
disabled={!canUpdateTables || isSchemaLocked}
className="gap-x-2"
onClick={() => {
if (canUpdateTables && !isSchemaLocked) {
onDeleteTable({ ...x, schema: selectedSchema })
}
}}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete tables',
},
}}
>
<Trash size={12} />
<p>Delete table</p>
</DropdownMenuItemTooltip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TableCell>
</TableRow>
))}
</>
</TableBody>
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={7} className="text-foreground-muted">
{entities.length} {entities.length === 1 ? 'table' : 'tables'}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
</div>
)}
</div>
)
}