Files
supabase/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
T
Joshen Lim dfd5461ef9 Opt to use connection string of read replica if available to power the table editor (#42856)
## Context

Part of dashboard scalability project

Opting to use the connection string of the project's read replica (if
available) for read queries on the database.

Trialing with the Table Editor as a first pass - changes involved will
opt to use replica connection string for `useTableRowsQuery`,
`useTableRowsCountQuery`, and `useForeignKeyConstraintsQuery`

There's definitely optimizations to be done for deciding which replica
to use - but am starting off with a rather naive logic to prioritize
replicas in the same region as the project.

## Changes involved

- We're no longer passing `connectionString` as a param into the
affected hooks, the `connectionString` is derived from within those
hooks instead
- Change is feature flagged, so things should be status quo if flag is
off (use primary database's connection string)
- Added `useConnectionStringForReadOps` hook which returns the replica's
connection string if (Otherwise defaults to primary database connection
string)
  - Feature flag is on
  - Project has a replica available

## To test

- [ ] Verify that the table editor works as expected for a project that
has read replicas (There shouldn't be any change really)
- [ ] Also just double check that updating cells in the table editor
works as well (There's no change there, we're using the primary DB's
connection string for mutation ops)
- [ ] ^ Same thing for a project that doesn't have read replicas
- [ ] ^ Same thing for local / self-host
2026-03-04 16:34:36 +08:00

508 lines
16 KiB
TypeScript

import { useParams } from 'common'
import { Copy, Download, Edit, Globe, Lock, MoreVertical, Trash } from 'lucide-react'
import Link from 'next/link'
import { type CSSProperties } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
TreeViewItemVariant,
} from 'ui'
import { useExportAllRowsAsCsv, useExportAllRowsAsSql } from './ExportAllRows'
import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/TableEntity.utils'
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
import { InlineLink } from '@/components/ui/InlineLink'
import { getTableDefinition } from '@/data/database/table-definition-query'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import { Entity } from '@/data/entity-types/entity-types-infinite-query'
import { useProjectLintsQuery } from '@/data/lint/lint-query'
import { EditorTablePageLink } from '@/data/prefetchers/project.$ref.editor.$id'
import type {
TableApiAccessData,
TableApiAccessMap,
} from '@/data/privileges/table-api-access-query'
import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import {
useRoleImpersonationStateSnapshot,
type RoleImpersonationState,
} from '@/state/role-impersonation-state'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
export interface EntityListItemProps {
id: number | string
projectRef: string
item: Entity
isLocked: boolean
isActive?: boolean
style?: CSSProperties
onExportCLI: () => void
apiAccessMap?: TableApiAccessMap
}
// [jordi] Used to determine the entity is a table and not a view or other unsupported entity type
function isTableLikeEntityListItem(entity: { type?: string }) {
return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE
}
export const EntityListItem = ({
id,
projectRef,
item: entity,
isLocked,
isActive: _isActive,
style,
onExportCLI,
apiAccessMap,
}: EntityListItemProps) => {
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
const tabId = createTabId(entity.type, { id: entity.id })
const tabs = useTabsStateSnapshot()
const isPreview = tabs.previewTabId === tabId
const isActive = Number(id) === entity.id
const canEdit = isActive && !isLocked
const { filters } = useTableFilter()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const { data: countData } = useTableRowsCountQuery(
{
projectRef,
tableId: entity.id,
filters,
enforceExactCount: false,
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
},
{
enabled: isTableLikeEntityListItem(entity) && isActive,
}
)
const rowCount = countData?.count
const { data: lints = [] } = useProjectLintsQuery({
projectRef: project?.ref,
})
const tableHasRlsDisabledLint: boolean = getEntityLintDetails(
entity.name,
'rls_disabled_in_public',
['ERROR'],
lints,
selectedSchema
).hasLint
const tableHasRlsEnabledNoPolicyLint: boolean = getEntityLintDetails(
entity.name,
'rls_enabled_no_policy',
['ERROR', 'WARN', 'INFO'],
lints,
selectedSchema
).hasLint
const viewHasLints: boolean = getEntityLintDetails(
entity.name,
'security_definer_view',
['ERROR', 'WARN'],
lints,
selectedSchema
).hasLint
const materializedViewHasLints: boolean = getEntityLintDetails(
entity.name,
'materialized_view_in_api',
['ERROR', 'WARN'],
lints,
selectedSchema
).hasLint
const foreignTableHasLints: boolean = getEntityLintDetails(
entity.name,
'foreign_table_in_api',
['ERROR', 'WARN'],
lints,
selectedSchema
).hasLint
const apiAccessData = apiAccessMap?.[entity.name]
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)
}
const { exportCsv, confirmationModal: exportCsvConfirmationModal } = useExportAllRowsAsCsv({
enabled: true,
projectRef,
connectionString: project?.connectionString ?? null,
entity,
type: 'fetch_all',
totalRows: rowCount,
})
const { exportSql, confirmationModal: exportSqlConfirmationModal } = useExportAllRowsAsSql({
enabled: true,
projectRef,
connectionString: project?.connectionString ?? null,
entity,
type: 'fetch_all',
totalRows: rowCount,
})
return (
<EditorTablePageLink
title={entity.name}
style={style}
id={String(entity.id)}
href={buildTableEditorUrl({ projectRef, tableId: entity.id, schema: entity.schema })}
role="button"
aria-label={`View ${entity.name}`}
className={cn(
TreeViewItemVariant({
isSelected: isActive && !isPreview,
isPreview,
}),
'pl-4 pr-1'
)}
onDoubleClick={(e) => {
e.preventDefault()
const tabId = createTabId(entity.type, { id: entity.id })
tabs.makeTabPermanent(tabId)
}}
>
<>
{isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
<Tooltip disableHoverableContent={true}>
<TooltipTrigger className="min-w-4">
<EntityTypeIcon type={entity.type} isActive={isActive} />
</TooltipTrigger>
<TooltipContent side="bottom">{formatTooltipText(entity.type)}</TooltipContent>
</Tooltip>
<div
className={cn(
'truncate overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
isActive && 'text-foreground'
)}
>
<span
className={cn(
isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
'text-sm transition truncate'
)}
>
{entity.name}
</span>
<EntityTooltipTrigger
entity={entity}
tableHasRlsDisabledLint={tableHasRlsDisabledLint}
tableHasRlsEnabledNoPolicyLint={tableHasRlsEnabledNoPolicyLint}
viewHasLints={viewHasLints}
materializedViewHasLints={materializedViewHasLints}
foreignTableHasLints={foreignTableHasLints}
apiAccessData={apiAccessData}
/>
</div>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="text-foreground-lighter transition-all text-transparent group-hover:text-foreground data-[state=open]:text-foreground"
>
<Button
type="text"
className="w-6 h-6"
icon={<MoreVertical size={14} strokeWidth={2} />}
onClick={(e) => e.preventDefault()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-44">
<DropdownMenuItem
key="copy-name"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
copyToClipboard(entity.name)
}}
>
<Copy size={12} />
<span>Copy name</span>
</DropdownMenuItem>
{isTableLikeEntityListItem(entity) && (
<DropdownMenuItem
key="copy-schema"
className="space-x-2"
onClick={async (e) => {
e.stopPropagation()
const toastId = toast.loading('Getting table schema...')
const formattedSchema = getTableDefinition({
id: entity.id,
projectRef: project?.ref,
connectionString: project?.connectionString,
}).then((tableDefinition) => {
if (!tableDefinition) {
throw new Error('Failed to get table schema')
}
return formatSql(tableDefinition)
})
try {
await copyToClipboard(formattedSchema, () => {
toast.success('Table schema copied to clipboard', { id: toastId })
})
} catch (err: any) {
toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId })
}
}}
>
<Copy size={12} />
<span>Copy table schema</span>
</DropdownMenuItem>
)}
{entity.type === ENTITY_TYPE.TABLE && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
key="edit-table"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onEditTable()
}}
>
<Edit size={12} />
<span>Edit table</span>
</DropdownMenuItem>
<DropdownMenuItem
key="duplicate-table"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onDuplicateTable()
}}
>
<Copy size={12} />
<span>Duplicate table</span>
</DropdownMenuItem>
<DropdownMenuItem key="view-policies" className="space-x-2" asChild>
<Link
key="view-policies"
href={`/project/${projectRef}/auth/policies?schema=${selectedSchema}&search=${entity.id}`}
>
<Lock size={12} />
<span>View policies</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-x-2">
<Download size={12} />
Export data
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
key="download-table-csv"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
exportCsv()
}}
>
<span>Export table as CSV</span>
</DropdownMenuItem>
<DropdownMenuItem
key="download-table-sql"
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
exportSql()
}}
>
<span>Export table as SQL</span>
</DropdownMenuItem>
<DropdownMenuItem
key="download-table-cli"
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
onExportCLI()
}}
>
<span>Export table via CLI</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
key="delete-table"
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onDeleteTable()
}}
>
<Trash size={12} />
<span>Delete table</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
{exportCsvConfirmationModal}
{exportSqlConfirmationModal}
</EditorTablePageLink>
)
}
const EntityTooltipTrigger = ({
entity,
tableHasRlsDisabledLint,
tableHasRlsEnabledNoPolicyLint,
viewHasLints,
materializedViewHasLints,
foreignTableHasLints,
apiAccessData,
}: {
entity: Entity
tableHasRlsDisabledLint: boolean
tableHasRlsEnabledNoPolicyLint: boolean
viewHasLints: boolean
materializedViewHasLints: boolean
foreignTableHasLints: boolean
apiAccessData?: TableApiAccessData
}) => {
const { ref } = useParams()
let tooltipContent = null
const accessWarning = 'Data is publicly accessible via API'
const learnMoreCTA = (
<InlineLink
href={`/project/${ref}/editor/${entity.id}?schema=${entity.schema}&showWarning=true`}
>
Learn more
</InlineLink>
)
switch (entity.type) {
case ENTITY_TYPE.TABLE:
if (tableHasRlsDisabledLint) {
tooltipContent = (
<>
This table can be accessed by anyone via the Data API as RLS is disabled. {learnMoreCTA}
.
</>
)
}
break
case ENTITY_TYPE.VIEW:
if (viewHasLints) {
tooltipContent = (
<>
{accessWarning} as this is a Security definer view. {learnMoreCTA}.
</>
)
}
break
case ENTITY_TYPE.MATERIALIZED_VIEW:
if (materializedViewHasLints) {
tooltipContent = (
<>
{accessWarning} as this is a Security definer view {learnMoreCTA}.
</>
)
}
break
case ENTITY_TYPE.FOREIGN_TABLE:
if (foreignTableHasLints) {
tooltipContent = (
<>
{accessWarning} as RLS is not enforced on foreign tables. {learnMoreCTA}.
</>
)
}
break
default:
break
}
if (tooltipContent) {
return (
<Tooltip>
<TooltipTrigger className="min-w-4">
<Badge variant="destructive">Unrestricted</Badge>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52">
{tooltipContent}
</TooltipContent>
</Tooltip>
)
}
const isRlsEnabledNoPolicies =
entity.type === ENTITY_TYPE.TABLE &&
apiAccessData?.apiAccessType === 'access' &&
tableHasRlsEnabledNoPolicyLint
if (isRlsEnabledNoPolicies) {
return (
<Tooltip>
<TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
<Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52">
This table can be accessed via the Data API but no RLS policies exist so no data will be
returned
</TooltipContent>
</Tooltip>
)
}
const isApiExposedWithRlsAndPolicies =
apiAccessData?.apiAccessType === 'access' && !tableHasRlsEnabledNoPolicyLint
if (isApiExposedWithRlsAndPolicies) {
return (
<Tooltip>
<TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
<Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
</TooltipTrigger>
<TooltipContent side="right">This table can be accessed via the Data API</TooltipContent>
</Tooltip>
)
}
return null
}