mirror of
https://github.com/supabase/supabase.git
synced 2026-06-18 13:43:53 +08:00
## 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 -->
436 lines
12 KiB
TypeScript
436 lines
12 KiB
TypeScript
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, 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 {
|
|
Breadcrumb,
|
|
CommandHeader,
|
|
CommandMenuInput,
|
|
CommandWrapper,
|
|
escapeAttributeSelector,
|
|
generateCommandClassNames,
|
|
PageType,
|
|
useCommandFilterState,
|
|
useCommandMenuOpen,
|
|
useRegisterCommands,
|
|
useRegisterPage,
|
|
useSetCommandMenuSize,
|
|
useSetPage,
|
|
} from 'ui-patterns/CommandMenu'
|
|
|
|
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 {
|
|
useInfiniteTablesQuery,
|
|
usePrefetchTables,
|
|
type TablesData,
|
|
} from '@/data/tables/tables-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useProfile } from '@/lib/profile'
|
|
|
|
export function useSqlEditorGotoCommands(options?: CommandOptions) {
|
|
let { ref } = useParams()
|
|
ref ||= '_'
|
|
|
|
useRegisterCommands(
|
|
COMMAND_MENU_SECTIONS.NAVIGATE,
|
|
[
|
|
{
|
|
id: 'nav-sql-editor',
|
|
name: 'SQL Editor',
|
|
route: `/project/${ref}/sql`,
|
|
defaultHidden: true,
|
|
},
|
|
],
|
|
{ ...options, deps: [ref] }
|
|
)
|
|
}
|
|
|
|
const SNIPPET_PAGE_NAME = 'Snippets'
|
|
|
|
export function useSnippetCommands() {
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const setPage = useSetPage()
|
|
|
|
useRegisterPage(
|
|
SNIPPET_PAGE_NAME,
|
|
{
|
|
type: PageType.Component,
|
|
component: () => <RunSnippetPage />,
|
|
},
|
|
{ enabled: !!project }
|
|
)
|
|
|
|
useRegisterCommands(
|
|
COMMAND_MENU_SECTIONS.SQL,
|
|
[
|
|
{
|
|
id: 'run-snippet',
|
|
name: 'Run snippet...',
|
|
icon: () => <Code />,
|
|
action: () => setPage(SNIPPET_PAGE_NAME),
|
|
},
|
|
],
|
|
{
|
|
enabled: !!project,
|
|
orderSection: orderCommandSectionsByPriority,
|
|
sectionMeta: { priority: 3 },
|
|
}
|
|
)
|
|
}
|
|
|
|
function RunSnippetPage() {
|
|
const { ref } = useParams()
|
|
const {
|
|
data: snippetPages,
|
|
isPending: isLoading,
|
|
isError,
|
|
isSuccess,
|
|
} = useSqlSnippetsQuery({
|
|
projectRef: ref,
|
|
})
|
|
|
|
const snippets = snippetPages?.pages.flatMap((page) => page.contents)
|
|
|
|
const { profile } = useProfile()
|
|
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
|
|
PermissionAction.CREATE,
|
|
'user_content',
|
|
{
|
|
resource: { type: 'sql', owner_id: profile?.id },
|
|
subject: { id: profile?.id },
|
|
}
|
|
)
|
|
|
|
useSetCommandMenuSize('xlarge')
|
|
|
|
return (
|
|
<CommandWrapper>
|
|
<CommandHeader>
|
|
<Breadcrumb />
|
|
<CommandMenuInput autoFocus />
|
|
</CommandHeader>
|
|
{isLoading && <LoadingState />}
|
|
{isError && <ErrorState />}
|
|
{isSuccess && (!snippets || snippets.length === 0) && (
|
|
<EmptyState projectRef={ref} canCreateNew={canCreateSQLSnippet} />
|
|
)}
|
|
{isSuccess && !!snippets && snippets.length > 0 && (
|
|
<SnippetSelector projectRef={ref} canCreateNew={canCreateSQLSnippet} snippets={snippets} />
|
|
)}
|
|
</CommandWrapper>
|
|
)
|
|
}
|
|
|
|
function LoadingState() {
|
|
return (
|
|
<div className="p-6">
|
|
<p className="text-center">
|
|
<Loader2 className="inline-block mr-2 animate-spin" />
|
|
Loading...
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ErrorState() {
|
|
return (
|
|
<div className="p-6">
|
|
<p className="text-center">
|
|
<AlertTriangle className="inline-block mr-2" />
|
|
Couldn't load snippets
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyState({
|
|
projectRef,
|
|
canCreateNew,
|
|
}: {
|
|
projectRef: string | undefined
|
|
canCreateNew: boolean
|
|
}) {
|
|
const router = useRouter()
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<p className="mb-2 text-center">No snippets found.</p>
|
|
<CommandList className="py-2">
|
|
<CommandGroup>
|
|
<CommandItem
|
|
id="create-snippet"
|
|
className={generateCommandClassNames(false)}
|
|
onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
|
|
>
|
|
{canCreateNew ? 'Create new snippet' : 'Run new SQL'}
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SnippetSelector({
|
|
projectRef,
|
|
snippets,
|
|
canCreateNew,
|
|
}: {
|
|
projectRef: string | undefined
|
|
snippets: Array<SqlSnippet> | undefined
|
|
canCreateNew: boolean
|
|
}) {
|
|
const router = useRouter()
|
|
|
|
const selectedValue = useCommandFilterState((state) => state.value)
|
|
const selectedSnippet = snippets?.find((snippet) => snippetValue(snippet) === selectedValue)
|
|
const isSQLSnippet = selectedSnippet?.type === 'sql'
|
|
|
|
return (
|
|
<div className="w-full grow min-h-0 grid gap-4 md:grid-cols-2">
|
|
<CommandList
|
|
className={cn(
|
|
'h-full! min-h-0 max-h-[unset] py-2 overflow-hidden',
|
|
'*:[[cmdk-list-sizer]]:h-full *:[[cmdk-list-sizer]]:flex *:[[cmdk-list-sizer]]:flex-col'
|
|
)}
|
|
>
|
|
{!!snippets && snippets.length > 0 && (
|
|
<CommandGroup className="grow min-h-0 overflow-auto">
|
|
{snippets.map((snippet) => (
|
|
<CommandItem
|
|
key={snippet.id}
|
|
id={`${snippet.id}-${snippet.name}`}
|
|
className={generateCommandClassNames(false)}
|
|
value={snippetValue(snippet)}
|
|
onSelect={() => void router.push(`/project/${projectRef ?? '_'}/sql/${snippet.id}`)}
|
|
>
|
|
{snippet.name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
{canCreateNew && (
|
|
<div className="min-h-fit grow-0">
|
|
<hr className="mt-4 mb-2 mx-2" />
|
|
<CommandGroup forceMount={true}>
|
|
<CommandItem
|
|
id="create-snippet"
|
|
className={generateCommandClassNames(false)}
|
|
onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
|
|
forceMount={true}
|
|
>
|
|
Create new snippet
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</div>
|
|
)}
|
|
</CommandList>
|
|
<CodeBlock
|
|
language="sql"
|
|
value={isSQLSnippet ? selectedSnippet?.content?.unchecked_sql : ''}
|
|
wrapperClassName="hidden md:block"
|
|
className="w-full h-full border-0 [&>code]:overflow-scroll [&>code]:block [&>code]:w-full [&>code]:h-full"
|
|
hideCopy
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function snippetValue(snippet: SqlSnippet) {
|
|
if (snippet.type !== 'sql') return ''
|
|
return escapeAttributeSelector(
|
|
`${snippet.id}-${snippet.name}-${snippet?.content?.unchecked_sql.slice(0, 30)}`
|
|
).toLowerCase()
|
|
}
|
|
|
|
const QUERY_TABLE_PAGE_NAME = 'Query a table'
|
|
|
|
export function useQueryTableCommands(options?: CommandOptions) {
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const setPage = useSetPage()
|
|
|
|
const commandMenuOpen = useCommandMenuOpen()
|
|
const commandMenuPreviouslyOpen = useRef(commandMenuOpen)
|
|
const commandMenuJustOpened = commandMenuOpen && !commandMenuPreviouslyOpen.current
|
|
commandMenuPreviouslyOpen.current = commandMenuOpen
|
|
|
|
const prefetchTables = usePrefetchTables({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
useEffect(() => {
|
|
if (project && commandMenuJustOpened) {
|
|
prefetchTables(undefined, true)
|
|
}
|
|
}, [project, prefetchTables, commandMenuJustOpened])
|
|
|
|
useRegisterPage(
|
|
QUERY_TABLE_PAGE_NAME,
|
|
{
|
|
type: PageType.Component,
|
|
component: TableSelector,
|
|
},
|
|
{ enabled: !!project }
|
|
)
|
|
|
|
useRegisterCommands(
|
|
COMMAND_MENU_SECTIONS.SQL,
|
|
[
|
|
{
|
|
id: 'query-table',
|
|
name: 'Query a table...',
|
|
icon: () => <Table2 />,
|
|
action: () => setPage(QUERY_TABLE_PAGE_NAME),
|
|
},
|
|
],
|
|
{ ...options, enabled: (options?.enabled ?? true) && !!project }
|
|
)
|
|
}
|
|
|
|
function TableSelector() {
|
|
const router = useRouter()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const [filterString, setFilterString] = useState('')
|
|
const debouncedFilterString = useDebounce(filterString, 300)
|
|
|
|
const [sentinelRef, sentinelEntry] = useIntersectionObserver({
|
|
threshold: 0,
|
|
rootMargin: '200px 0px 200px 0px',
|
|
})
|
|
|
|
const {
|
|
data: tablesData,
|
|
isError,
|
|
isSuccess,
|
|
isPending: isLoading,
|
|
hasNextPage: hasNextTablesPage,
|
|
isFetchingNextPage: isFetchingNextTablesPage,
|
|
fetchNextPage: fetchNextTablesPage,
|
|
} = useInfiniteTablesQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
includeColumns: true,
|
|
pageSize: 50,
|
|
nameFilter: debouncedFilterString,
|
|
})
|
|
const tables = tablesData?.pages.flat() ?? []
|
|
|
|
useEffect(() => {
|
|
if (
|
|
sentinelEntry?.isIntersecting &&
|
|
hasNextTablesPage &&
|
|
!isFetchingNextTablesPage &&
|
|
isSuccess
|
|
) {
|
|
fetchNextTablesPage()
|
|
}
|
|
}, [
|
|
isSuccess,
|
|
sentinelEntry?.isIntersecting,
|
|
hasNextTablesPage,
|
|
isFetchingNextTablesPage,
|
|
fetchNextTablesPage,
|
|
])
|
|
|
|
return (
|
|
<CommandWrapper shouldFilter={false}>
|
|
<CommandHeader>
|
|
<Breadcrumb />
|
|
<CommandMenuInput
|
|
autoFocus
|
|
placeholder="Search for schema or a table to query"
|
|
value={filterString}
|
|
onValueChange={setFilterString}
|
|
/>
|
|
</CommandHeader>
|
|
<CommandList className="pb-9">
|
|
{isLoading && <LoadingState />}
|
|
{isError && <ErrorState />}
|
|
{isSuccess && (
|
|
<>
|
|
<CommandGroup className="py-2">
|
|
{tables?.map((table) => (
|
|
<CommandItem
|
|
key={table.id}
|
|
className={generateCommandClassNames(false)}
|
|
value={escapeAttributeSelector(`${table.schema}.${table.name}`)}
|
|
onSelect={() => {
|
|
router.push(
|
|
`/project/${project?.ref ?? '_'}/sql/new?content=${encodeURIComponent(generateSelectStatement(table))}`
|
|
)
|
|
}}
|
|
>
|
|
{`${table.schema}.${table.name}`}
|
|
</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>
|
|
</CommandWrapper>
|
|
)
|
|
}
|
|
|
|
function generateSelectStatement(table: TablesData[number] & { columns?: Array<PGColumn> }) {
|
|
return `
|
|
select ${
|
|
!table.columns
|
|
? '*'
|
|
: `
|
|
${table.columns.map((column) => `\t${column.name}`).join(',\n')}`
|
|
}
|
|
from ${formatTableIdentifier(table)}
|
|
-- where
|
|
-- order by
|
|
-- limit
|
|
;
|
|
`.trim()
|
|
}
|
|
|
|
// Not a perfectly spec-compliant regex , since Postgres also allows non-Latin
|
|
// letters and letters with diacritical marks, but quoting them defensively
|
|
// is easier than writing the regex. ¯\_(ツ)_/¯
|
|
const VALID_UNQUOTED_IDENTIFIER_REGEX = /^[a-z_][a-z0-9_$]*$/
|
|
function formatTableIdentifier(table: TablesData[number]) {
|
|
const schema = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.schema)
|
|
? table.schema
|
|
: `"${table.schema}"`
|
|
const tableName = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.name)
|
|
? table.name
|
|
: `"${table.name}"`
|
|
return `${schema}.${tableName}`
|
|
}
|