mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
## 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
508 lines
16 KiB
TypeScript
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
|
|
}
|