mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Joshen/fe 3613 database tables query should have schema filter wherever appropriate (#46935)
## Context There's certain areas in the dashboard where we're calling `useTablesQuery` without a schema filter, in which case the dashboard then fires a query against the project's database to fetch _all_ tables across _all_ schemas - this could easily be a heavy query if there's a large number of relations in the project's database. Am hence opting to either add a schema filter if appropriate, or otherwise opt to use the infinite loading behaviour ## Changes involved - Add schema filter to `useTablesQuery` in database triggers and publications - Use infinite loading for tables in Cmd K for "Run query on table" and "Search database tables" ## To test - [x] Verify that database triggers + publications still function as expected - [x] Verify that CMD K "Run query on table" and "Search database tables" still function as expected (including search) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Implemented debounced infinite-scrolling table search in the command menu and SQL editor command flow. * Added a schema selector dropdown to publications management for easier navigation. * **Improvements** * Removed the “Schema” column from the publications tables UI. * Updated search guidance and table-picker status (counts/loading) during infinite browsing. * Trigger table listings now follow the selected schema context. * Refined command menu list height and improved the database-tables placeholder text. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
7d4ec098ea
commit
aba4e147eb
18 files changed
+267
-195
No files matched your search
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { Auth, EdgeFunctions, Storage } from 'icons'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ICommand } from 'ui-patterns/CommandMenu'
|
||||
import {
|
||||
CommandHeader,
|
||||
@@ -36,7 +37,7 @@ const SEARCH_CONTEXT_OPTIONS: SearchContextOption[] = [
|
||||
value: 'database-tables',
|
||||
label: 'Database Tables',
|
||||
pageName: 'Search Database Tables',
|
||||
placeholder: 'Search database tables...',
|
||||
placeholder: 'Search database schema or tables...',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
@@ -71,12 +72,23 @@ function ContextSearchPage({
|
||||
}) {
|
||||
const query = useQuery()
|
||||
|
||||
const [filterString, setFilterString] = useState('')
|
||||
const debouncedFilterString = useDebounce(filterString, 300)
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<CommandWrapper shouldFilter={context !== 'database-tables'}>
|
||||
<CommandHeader>
|
||||
<CommandMenuInput placeholder={placeholder} />
|
||||
<CommandMenuInput
|
||||
placeholder={placeholder}
|
||||
value={filterString}
|
||||
onValueChange={setFilterString}
|
||||
/>
|
||||
</CommandHeader>
|
||||
<ContextSearchResults context={context} query={query} />
|
||||
<ContextSearchResults
|
||||
context={context}
|
||||
query={query}
|
||||
debouncedFilterString={debouncedFilterString}
|
||||
/>
|
||||
</CommandWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client'
|
||||
|
||||
import { cn, CommandList } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns'
|
||||
import { TextHighlighter } from 'ui-patterns/CommandMenu'
|
||||
@@ -53,6 +51,7 @@ interface ResultsListProps {
|
||||
onResultClick?: (result: SearchResult) => void
|
||||
getRoute?: (result: SearchResult) => `/${string}` | `http${string}`
|
||||
className?: string
|
||||
infiniteLoadingObserverRef?: (node: Element | null) => void
|
||||
}
|
||||
|
||||
export function ResultsList({
|
||||
@@ -62,6 +61,7 @@ export function ResultsList({
|
||||
onResultClick,
|
||||
getRoute,
|
||||
className,
|
||||
infiniteLoadingObserverRef,
|
||||
}: ResultsListProps) {
|
||||
const commands = results.map((result): IRouteCommand | IActionCommand => {
|
||||
const ResultIcon = getIcon ? getIcon(result) : Icon
|
||||
@@ -92,7 +92,7 @@ export function ResultsList({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CommandMenuGroup>
|
||||
<CommandMenuGroup className="py-2">
|
||||
{commands.map((command) => (
|
||||
<CommandMenuItem key={command.id} command={command}>
|
||||
<div className="flex flex-col min-w-0 text-foreground-light">
|
||||
@@ -106,6 +106,7 @@ export function ResultsList({
|
||||
</CommandMenuItem>
|
||||
))}
|
||||
</CommandMenuGroup>
|
||||
{infiniteLoadingObserverRef && <div ref={infiniteLoadingObserverRef} />}
|
||||
</CommandList>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const StorageSearchResults = dynamic(
|
||||
interface ContextSearchResultsProps {
|
||||
context: SearchContextValue
|
||||
query: string
|
||||
debouncedFilterString: string
|
||||
}
|
||||
|
||||
const CONTEXT_CONFIG: Record<
|
||||
@@ -77,13 +78,17 @@ const CONTEXT_CONFIG: Record<
|
||||
},
|
||||
}
|
||||
|
||||
export function ContextSearchResults({ context, query }: ContextSearchResultsProps) {
|
||||
export function ContextSearchResults({
|
||||
context,
|
||||
query,
|
||||
debouncedFilterString,
|
||||
}: ContextSearchResultsProps) {
|
||||
const config = CONTEXT_CONFIG[context]
|
||||
|
||||
if (context === 'database-tables') {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<TableSearchResults query={query} />
|
||||
<TableSearchResults debouncedFilterString={debouncedFilterString} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useIntersectionObserver } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { Database, Loader2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -10,55 +9,44 @@ import {
|
||||
SkeletonResults,
|
||||
type SearchResult,
|
||||
} from './ContextSearchResults.shared'
|
||||
import { useTablesQuery } from '@/data/tables/tables-query'
|
||||
import { useInfiniteTablesQuery } from '@/data/tables/tables-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
interface TableSearchResultsProps {
|
||||
query: string
|
||||
debouncedFilterString: string
|
||||
}
|
||||
|
||||
export function TableSearchResults({ query }: TableSearchResultsProps) {
|
||||
export function TableSearchResults({ debouncedFilterString }: TableSearchResultsProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
const [sentinelRef, sentinelEntry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
rootMargin: '200px 0px 200px 0px',
|
||||
})
|
||||
|
||||
const {
|
||||
data: tables,
|
||||
isLoading: isLoadingTables,
|
||||
data: tablesData,
|
||||
isSuccess,
|
||||
isError: isErrorTables,
|
||||
} = useTablesQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
includeColumns: false,
|
||||
sortByProperty: 'name',
|
||||
},
|
||||
{
|
||||
enabled: !!project?.ref,
|
||||
}
|
||||
)
|
||||
isPending: isLoadingTables,
|
||||
hasNextPage: hasNextTablesPage,
|
||||
isFetchingNextPage: isFetchingNextTablesPage,
|
||||
fetchNextPage: fetchNextTablesPage,
|
||||
} = useInfiniteTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
includeColumns: false,
|
||||
pageSize: 50,
|
||||
nameFilter: debouncedFilterString,
|
||||
})
|
||||
|
||||
const tables = useMemo(() => tablesData?.pages.flat() ?? [], [tablesData])
|
||||
|
||||
const tableResults: SearchResult[] = useMemo(() => {
|
||||
if (!tables) return []
|
||||
|
||||
const filtered = trimmedQuery
|
||||
? tables.filter((table) => {
|
||||
const searchLower = trimmedQuery.toLowerCase()
|
||||
const tableName = table.name?.toLowerCase() || ''
|
||||
const schemaName = table.schema?.toLowerCase() || ''
|
||||
const fullName = `${schemaName}.${tableName}`
|
||||
|
||||
return (
|
||||
tableName.includes(searchLower) ||
|
||||
schemaName.includes(searchLower) ||
|
||||
fullName.includes(searchLower)
|
||||
)
|
||||
})
|
||||
: tables
|
||||
|
||||
// Limit results for performance
|
||||
return filtered.slice(0, 20).map((table) => {
|
||||
return tables.map((table) => {
|
||||
const displayName =
|
||||
table.schema && table.schema !== 'public'
|
||||
? `${table.schema}.${table.name}`
|
||||
@@ -76,79 +64,71 @@ export function TableSearchResults({ query }: TableSearchResultsProps) {
|
||||
description,
|
||||
}
|
||||
})
|
||||
}, [tables, trimmedQuery])
|
||||
}, [tables])
|
||||
|
||||
const totalTables = tables?.length ?? 0
|
||||
|
||||
const renderFooter = () => (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between min-h-9 h-9 px-4 border-t bg-surface-200 text-xs text-foreground-light z-10">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isLoadingTables ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Total: {totalTables.toLocaleString()} table{totalTables !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLoadingTables) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<SkeletonResults />
|
||||
</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isErrorTables) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="h-full flex flex-col items-center justify-center py-12 px-4 gap-4 text-center text-foreground-lighter">
|
||||
<Database className="h-6 w-6" strokeWidth={1.5} />
|
||||
<p className="text-sm">Failed to load tables</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tableResults.length === 0) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<EmptyState icon={Database} label="Database Tables" query={query} />
|
||||
</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (
|
||||
sentinelEntry?.isIntersecting &&
|
||||
hasNextTablesPage &&
|
||||
!isFetchingNextTablesPage &&
|
||||
isSuccess
|
||||
) {
|
||||
fetchNextTablesPage()
|
||||
}
|
||||
}, [
|
||||
isSuccess,
|
||||
sentinelEntry?.isIntersecting,
|
||||
hasNextTablesPage,
|
||||
isFetchingNextTablesPage,
|
||||
fetchNextTablesPage,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResultsList
|
||||
results={tableResults}
|
||||
icon={Database}
|
||||
getRoute={(result) => {
|
||||
const table = tables?.find((t) => String(t.id) === result.id)
|
||||
if (!table || !projectRef) return `/project/${projectRef}/editor` as `/${string}`
|
||||
{isLoadingTables ? (
|
||||
<SkeletonResults />
|
||||
) : isErrorTables ? (
|
||||
<div className="h-full flex flex-col items-center justify-center py-12 px-4 gap-4 text-center text-foreground-lighter">
|
||||
<Database className="h-6 w-6" strokeWidth={1.5} />
|
||||
<p className="text-sm">Failed to load tables</p>
|
||||
</div>
|
||||
) : tableResults.length === 0 ? (
|
||||
<EmptyState icon={Database} label="Database Tables" query={debouncedFilterString} />
|
||||
) : (
|
||||
<>
|
||||
<ResultsList
|
||||
results={tableResults}
|
||||
icon={Database}
|
||||
getRoute={(result) => {
|
||||
const table = tables?.find((t) => String(t.id) === result.id)
|
||||
if (!table || !projectRef) return `/project/${projectRef}/editor` as `/${string}`
|
||||
|
||||
const schemaParam = table.schema ? `?schema=${table.schema}` : ''
|
||||
return `/project/${projectRef}/editor/${table.id}${schemaParam}` as `/${string}`
|
||||
}}
|
||||
className="pb-9"
|
||||
/>
|
||||
const schemaParam = table.schema ? `?schema=${table.schema}` : ''
|
||||
return `/project/${projectRef}/editor/${table.id}${schemaParam}` as `/${string}`
|
||||
}}
|
||||
infiniteLoadingObserverRef={sentinelRef}
|
||||
className="pb-9"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between min-h-9 h-9 px-4 border-t bg-surface-200 text-xs text-foreground-light z-10">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isLoadingTables ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Total: {totalTables.toLocaleString()} table{totalTables !== 1 ? 's' : ''}
|
||||
{hasNextTablesPage ? ' loaded' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -75,7 +75,6 @@ export const PublicationsTableItem = ({
|
||||
return (
|
||||
<TableRow key={table.id}>
|
||||
<TableCell className="py-3 whitespace-nowrap">{table.name}</TableCell>
|
||||
<TableCell className="py-3 whitespace-nowrap text-foreground-light">{table.schema}</TableCell>
|
||||
<TableCell className="py-3 whitespace-nowrap hidden lg:table-cell max-w-sm truncate text-foreground-light">
|
||||
{table.comment}
|
||||
</TableCell>
|
||||
|
||||
@@ -10,9 +10,12 @@ import { PublicationTablesSkeleton } from './PublicationSkeleton'
|
||||
import { PublicationsTableItem } from './PublicationsTableItem'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { NoSearchResults } from '@/components/ui/NoSearchResults'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
|
||||
import { useTablesQuery } from '@/data/tables/tables-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
@@ -21,7 +24,10 @@ import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
export const PublicationsTables = () => {
|
||||
const { id } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { can: canUpdatePublications, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
|
||||
@@ -53,6 +59,7 @@ export const PublicationsTables = () => {
|
||||
} = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedSchema,
|
||||
})
|
||||
|
||||
const tables = useMemo(() => {
|
||||
@@ -63,7 +70,23 @@ export const PublicationsTables = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap mb-4">
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
<Input
|
||||
size="tiny"
|
||||
ref={searchInputRef}
|
||||
@@ -89,7 +112,6 @@ export const PublicationsTables = () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Description</TableHead>
|
||||
{/*
|
||||
We've disabled All tables toggle for publications.
|
||||
@@ -106,7 +128,7 @@ export const PublicationsTables = () => {
|
||||
|
||||
{isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={3}>
|
||||
<AlertError error={error} subject="Failed to retrieve tables" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -114,7 +136,7 @@ export const PublicationsTables = () => {
|
||||
|
||||
{!isLoading && !isLoadingPermissions && tables.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={3}>
|
||||
<NoSearchResults
|
||||
className="border-none !p-0"
|
||||
searchString={filterString}
|
||||
@@ -135,7 +157,7 @@ export const PublicationsTables = () => {
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={3}>
|
||||
<p>The selected publication with ID {id} cannot be found</p>
|
||||
<p className="text-foreground-light">
|
||||
Head back to the list of publications to select one from there
|
||||
|
||||
@@ -39,6 +39,7 @@ import FormBoxEmpty from '@/components/ui/FormBoxEmpty'
|
||||
import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
|
||||
import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-mutation'
|
||||
import { useTablesQuery } from '@/data/tables/tables-query'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
@@ -89,6 +90,7 @@ export const TriggerSheet = ({
|
||||
onClose,
|
||||
}: TriggerSheetProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { selectedSchema } = useQuerySchemaState()
|
||||
|
||||
const [showFunctionSelector, setShowFunctionSelector] = useState(false)
|
||||
|
||||
@@ -118,6 +120,7 @@ export const TriggerSheet = ({
|
||||
const { data = [], isSuccess: isSuccessTables } = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedSchema,
|
||||
})
|
||||
const { data: protectedSchemas, isSuccess: isSuccessProtectedSchemas } = useProtectedSchemas()
|
||||
const isSuccess = isSuccessTables && isSuccessProtectedSchemas
|
||||
|
||||
@@ -76,6 +76,7 @@ export const TriggersList = () => {
|
||||
const { data = [] } = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedSchema,
|
||||
})
|
||||
const hasTables =
|
||||
data.filter((a) => !protectedSchemas.find((s) => s.name === a.schema)).length > 0
|
||||
|
||||
+6
-6
@@ -437,7 +437,7 @@ export const SidePanelEditor = ({
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, selectedTable?.schema, includeColumns),
|
||||
queryKey: tableKeys.list(project?.ref, selectedTable?.schema, { includeColumns }),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.infiniteListPrefix(project?.ref, selectedTable?.schema),
|
||||
@@ -508,7 +508,7 @@ export const SidePanelEditor = ({
|
||||
// Switch it to individual tables via an array of strings
|
||||
// Refer to PublicationStore for more information about this
|
||||
const publicTables = await queryClient.fetchQuery({
|
||||
queryKey: tableKeys.list(project.ref, 'public', includeColumns),
|
||||
queryKey: tableKeys.list(project.ref, 'public', { includeColumns }),
|
||||
queryFn: ({ signal }) =>
|
||||
getTables(
|
||||
{
|
||||
@@ -646,10 +646,10 @@ export const SidePanelEditor = ({
|
||||
.map(([, data]) => data?.pages?.[0]?.data?.count)
|
||||
.find((count) => typeof count === 'number') ??
|
||||
queryClient.getQueryData<unknown[]>(
|
||||
tableKeys.list(project?.ref, payload.schema, true)
|
||||
tableKeys.list(project?.ref, payload.schema, { includeColumns: true })
|
||||
)?.length ??
|
||||
queryClient.getQueryData<unknown[]>(
|
||||
tableKeys.list(project?.ref, payload.schema, false)
|
||||
tableKeys.list(project?.ref, payload.schema, { includeColumns: false })
|
||||
)?.length
|
||||
|
||||
createTableSpan.setAttributes({
|
||||
@@ -713,7 +713,7 @@ export const SidePanelEditor = ({
|
||||
async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, { includeColumns }),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.infiniteListPrefix(project?.ref, table.schema),
|
||||
@@ -783,7 +783,7 @@ export const SidePanelEditor = ({
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, { includeColumns }),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.infiniteListPrefix(project?.ref, table.schema),
|
||||
|
||||
+3
-1
@@ -908,7 +908,9 @@ export const updateTable = async ({
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: databaseKeys.tableDefinition(projectRef, table.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.list(projectRef, table.schema, true) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(projectRef, table.schema, { includeColumns: true }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
|
||||
])
|
||||
|
||||
|
||||
+2
-2
@@ -478,7 +478,7 @@ export const TableEditor = ({
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2"
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
@@ -496,7 +496,7 @@ export const TableEditor = ({
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2"
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PGColumn } from '@supabase/pg-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { AlertTriangle, Code, Loader2, Table2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { cn, CommandEmpty, CommandGroup, CommandItem, CommandList } from 'ui'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn, CommandGroup, CommandItem, CommandList } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import type { CommandOptions } from 'ui-patterns/CommandMenu'
|
||||
import {
|
||||
@@ -26,10 +27,13 @@ import {
|
||||
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
|
||||
import { orderCommandSectionsByPriority } from '@/components/interfaces/App/CommandMenu/ordering'
|
||||
import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query'
|
||||
import { usePrefetchTables, useTablesQuery, type TablesData } from '@/data/tables/tables-query'
|
||||
import {
|
||||
useInfiniteTablesQuery,
|
||||
usePrefetchTables,
|
||||
type TablesData,
|
||||
} from '@/data/tables/tables-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
|
||||
export function useSqlEditorGotoCommands(options?: CommandOptions) {
|
||||
@@ -294,34 +298,66 @@ export function useQueryTableCommands(options?: CommandOptions) {
|
||||
function TableSelector() {
|
||||
const router = useRouter()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: protectedSchemas } = useProtectedSchemas()
|
||||
|
||||
const [filterString, setFilterString] = useState('')
|
||||
const debouncedFilterString = useDebounce(filterString, 300)
|
||||
|
||||
const [sentinelRef, sentinelEntry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
rootMargin: '200px 0px 200px 0px',
|
||||
})
|
||||
|
||||
const {
|
||||
data: tablesData,
|
||||
isPending: isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useTablesQuery({
|
||||
isPending: isLoading,
|
||||
hasNextPage: hasNextTablesPage,
|
||||
isFetchingNextPage: isFetchingNextTablesPage,
|
||||
fetchNextPage: fetchNextTablesPage,
|
||||
} = useInfiniteTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
includeColumns: true,
|
||||
pageSize: 50,
|
||||
nameFilter: debouncedFilterString,
|
||||
})
|
||||
const tables = useMemo(() => {
|
||||
return tablesData?.filter((table) => !protectedSchemas.find((s) => s.name === table.schema))
|
||||
}, [tablesData, protectedSchemas])
|
||||
const tables = tablesData?.pages.flat() ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
sentinelEntry?.isIntersecting &&
|
||||
hasNextTablesPage &&
|
||||
!isFetchingNextTablesPage &&
|
||||
isSuccess
|
||||
) {
|
||||
fetchNextTablesPage()
|
||||
}
|
||||
}, [
|
||||
isSuccess,
|
||||
sentinelEntry?.isIntersecting,
|
||||
hasNextTablesPage,
|
||||
isFetchingNextTablesPage,
|
||||
fetchNextTablesPage,
|
||||
])
|
||||
|
||||
return (
|
||||
<CommandWrapper>
|
||||
<CommandWrapper shouldFilter={false}>
|
||||
<CommandHeader>
|
||||
<Breadcrumb />
|
||||
<CommandMenuInput autoFocus />
|
||||
<CommandMenuInput
|
||||
autoFocus
|
||||
placeholder="Search for schema or a table to query"
|
||||
value={filterString}
|
||||
onValueChange={setFilterString}
|
||||
/>
|
||||
</CommandHeader>
|
||||
<CommandList>
|
||||
<CommandList className="pb-9">
|
||||
{isLoading && <LoadingState />}
|
||||
{isError && <ErrorState />}
|
||||
{isSuccess && (
|
||||
<>
|
||||
<CommandEmpty />
|
||||
<CommandGroup>
|
||||
<CommandGroup className="py-2">
|
||||
{tables?.map((table) => (
|
||||
<CommandItem
|
||||
key={table.id}
|
||||
@@ -337,6 +373,30 @@ function TableSelector() {
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{tables.length === 0 && debouncedFilterString && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No tables found based on your search
|
||||
</p>
|
||||
)}
|
||||
{tables.length > 0 && (
|
||||
<>
|
||||
<div ref={sentinelRef} />
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between min-h-9 h-9 px-4 border-t bg-surface-200 text-xs text-foreground-light z-10">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isFetchingNextTablesPage ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Total: {tables.length} {tables.length === 1 ? 'table' : 'tables'}
|
||||
{hasNextTablesPage ? ' loaded' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
@@ -129,7 +129,10 @@ export const getContextualInvalidationKeys = ({
|
||||
'auth/users': [authKeys.usersInfinite(ref)],
|
||||
'auth/policies': [databasePoliciesKeys.list(ref)],
|
||||
'database/functions': [databaseKeys.databaseFunctions(ref)],
|
||||
'database/tables': [tableKeys.list(ref, schema, true), tableKeys.list(ref, schema, false)],
|
||||
'database/tables': [
|
||||
tableKeys.list(ref, schema, { includeColumns: true }),
|
||||
tableKeys.list(ref, schema, { includeColumns: false }),
|
||||
],
|
||||
'database/triggers': [databaseTriggerKeys.list(ref)],
|
||||
'database/types': [enumeratedTypesKeys.list(ref)],
|
||||
'database/extensions': [databaseExtensionsKeys.list(ref)],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const tableKeys = {
|
||||
names: (projectRef: string | undefined) => ['projects', projectRef, 'table-names'] as const,
|
||||
list: (projectRef: string | undefined, schema?: string, includeColumns?: boolean) =>
|
||||
['projects', projectRef, 'tables', schema, includeColumns].filter(Boolean),
|
||||
list: (
|
||||
projectRef: string | undefined,
|
||||
schema: string | undefined,
|
||||
options?: { includeColumns?: boolean }
|
||||
) => ['projects', projectRef, 'tables', schema, options].filter(Boolean),
|
||||
infiniteListPrefix: (projectRef: string | undefined, schema?: string) =>
|
||||
['projects', projectRef, 'tables', 'infinite', schema].filter(
|
||||
(part) => part !== undefined && part !== null && part !== ''
|
||||
|
||||
@@ -52,10 +52,10 @@ export const useTableCreateMutation = ({
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(projectRef, payload.schema, true),
|
||||
queryKey: tableKeys.list(projectRef, payload.schema, { includeColumns: true }),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(projectRef, payload.schema, false),
|
||||
queryKey: tableKeys.list(projectRef, payload.schema, { includeColumns: false }),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.tablePrivilegesList(projectRef),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getTablesPaginatedSql, type PGTable } from '@supabase/pg-meta'
|
||||
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
|
||||
import pgMeta, { getTablesPaginatedSql, type PGTable } from '@supabase/pg-meta'
|
||||
import { InfiniteData, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { tableKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||||
import type { SafePostgresTable } from '@/lib/postgres-types'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions, UseCustomQueryOptions } from '@/types'
|
||||
@@ -14,13 +13,14 @@ export type TablesVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
schema?: string
|
||||
/**
|
||||
* Defaults to false
|
||||
*/
|
||||
/** Defaults to false */
|
||||
includeColumns?: boolean
|
||||
sortByProperty?: keyof PGTable
|
||||
}
|
||||
|
||||
const pgMetaTablesList = pgMeta.tables.list()
|
||||
export type TablesData = z.infer<typeof pgMetaTablesList.zod>
|
||||
|
||||
export async function getTables(
|
||||
{
|
||||
projectRef,
|
||||
@@ -31,55 +31,31 @@ export async function getTables(
|
||||
}: TablesVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) {
|
||||
throw new Error('projectRef is required')
|
||||
}
|
||||
|
||||
let headers = new Headers()
|
||||
if (connectionString) headers.set('x-connection-encrypted', connectionString)
|
||||
|
||||
let queryParams: Record<string, string> = {
|
||||
//include_columns is a string, even though it's true or false
|
||||
include_columns: `${includeColumns}`,
|
||||
}
|
||||
if (schema) {
|
||||
queryParams.included_schemas = schema
|
||||
}
|
||||
|
||||
const { data, error } = await get('/platform/pg-meta/{ref}/tables', {
|
||||
params: {
|
||||
header: {
|
||||
'x-connection-encrypted': connectionString!,
|
||||
'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME,
|
||||
},
|
||||
path: { ref: projectRef },
|
||||
query: queryParams as any,
|
||||
},
|
||||
headers,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!Array.isArray(data) && error) handleError(error)
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
const sql = pgMeta.tables.list({
|
||||
includeColumns,
|
||||
includedSchemas: schema ? [schema] : undefined,
|
||||
}).sql
|
||||
const queryKey = ['tables', schema]
|
||||
|
||||
const { result } = await executeSql({ projectRef, connectionString, sql, queryKey }, signal)
|
||||
// Sort the data if the sortByName option is true
|
||||
if (Array.isArray(data) && sortByProperty) {
|
||||
return sortBy(data, (t) => t[sortByProperty]) as SafePostgresTable[]
|
||||
if (Array.isArray(result) && sortByProperty) {
|
||||
return sortBy(result, (t) => t[sortByProperty]) as SafePostgresTable[]
|
||||
}
|
||||
|
||||
return data as SafePostgresTable[]
|
||||
return result as SafePostgresTable[]
|
||||
}
|
||||
|
||||
export type TablesData = Awaited<ReturnType<typeof getTables>>
|
||||
export type TablesError = ResponseError
|
||||
|
||||
export const useTablesQuery = <TData = TablesData>(
|
||||
{ projectRef, connectionString, schema, includeColumns }: TablesVariables,
|
||||
vars: TablesVariables,
|
||||
{ enabled = true, ...options }: UseCustomQueryOptions<TablesData, TablesError, TData> = {}
|
||||
) => {
|
||||
const { projectRef, schema, includeColumns } = vars
|
||||
return useQuery<TablesData, TablesError, TData>({
|
||||
queryKey: tableKeys.list(projectRef, schema, includeColumns),
|
||||
queryFn: ({ signal }) =>
|
||||
getTables({ projectRef, connectionString, schema, includeColumns }, signal),
|
||||
queryKey: tableKeys.list(projectRef, schema, { includeColumns }),
|
||||
queryFn: ({ signal }) => getTables(vars, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -98,7 +74,7 @@ export function useGetTables({
|
||||
return useCallback(
|
||||
(schema?: TablesVariables['schema'], includeColumns?: TablesVariables['includeColumns']) => {
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: tableKeys.list(projectRef, schema, includeColumns),
|
||||
queryKey: tableKeys.list(projectRef, schema, { includeColumns }),
|
||||
queryFn: ({ signal }) =>
|
||||
getTables({ projectRef, connectionString, schema, includeColumns }, signal),
|
||||
})
|
||||
@@ -116,7 +92,7 @@ export function usePrefetchTables({
|
||||
return useCallback(
|
||||
(schema?: TablesVariables['schema'], includeColumns?: TablesVariables['includeColumns']) => {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: tableKeys.list(projectRef, schema, includeColumns),
|
||||
queryKey: tableKeys.list(projectRef, schema, { includeColumns }),
|
||||
queryFn: ({ signal }) =>
|
||||
getTables({ projectRef, connectionString, schema, includeColumns }, signal),
|
||||
})
|
||||
@@ -176,7 +152,7 @@ export async function getTablesPage(
|
||||
return result as SafePostgresTable[]
|
||||
}
|
||||
|
||||
export const useInfiniteTablesQuery = <TData = InfiniteData<TablesData>>(
|
||||
export const useInfiniteTablesQuery = <TData = InfiniteData<SafePostgresTable[]>>(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
|
||||
@@ -37,7 +37,12 @@ export const getTablesPaginatedSql = ({
|
||||
const schemaFilter = filter ? safeSql`and nc.nspname ${filter}` : safeSql``
|
||||
const nameFilterClause =
|
||||
nameFilter && nameFilter.length > 0
|
||||
? safeSql`and c.relname ilike ${literal(`%${escapeIlikeLiteral(nameFilter)}%`)}`
|
||||
? schema
|
||||
? safeSql`and c.relname ilike ${literal(`%${escapeIlikeLiteral(nameFilter)}%`)}`
|
||||
: safeSql`and (
|
||||
c.relname ilike ${literal(`%${escapeIlikeLiteral(nameFilter)}%`)}
|
||||
or nc.nspname ilike ${literal(`%${escapeIlikeLiteral(nameFilter)}%`)}
|
||||
)`
|
||||
: safeSql``
|
||||
|
||||
const columnsCte = includeColumns
|
||||
|
||||
@@ -90,7 +90,7 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
className={cn('max-h-full overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Reference in new issue
Block a user