diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index be97e1cefa5..04990fd8d49 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -77,7 +77,6 @@ export const SupabaseGrid = ({ } = useTableRowsQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId, sorts, filters, diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 50d3b92f8b9..4a4b2914742 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -9,7 +9,7 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' -import { ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' +import { AlertCircle, ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' @@ -90,7 +90,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } = useTableRowsCountQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId: snap.table.id, filters, enforceExactCount: snap.enforceExactCount, @@ -102,6 +101,8 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } ) const count = data?.count ?? 0 + const hasCountData = count >= 0 + const isEstimateCount = data?.is_estimate ?? false const countString = data?.is_estimate ? formatEstimatedCount(count) : count.toLocaleString() const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage) const totalPages = count > 0 ? maxPages : 1 @@ -113,7 +114,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const { data: rowsData, isPending: isLoadingRows } = useTableRowsQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId: id, sorts, filters, @@ -144,12 +144,10 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } const onNextPage = () => { - if (page < maxPages) { - if (snap.selectedRows.size >= 1) { - setIsConfirmNextModalOpen(true) - } else { - goToNextPage() - } + if (snap.selectedRows.size >= 1) { + setIsConfirmNextModalOpen(true) + } else { + goToNextPage() } } @@ -176,6 +174,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const onRowsPerPageChange = (value: string | number) => { const rowsPerPage = Number(value) tableEditorSnap.setRowsPerPage(isNaN(rowsPerPage) ? 100 : rowsPerPage) + snap.setPage(1) } // keep input value in-sync with actual page @@ -183,20 +182,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = setValue(String(page)) }, [page]) - useEffect(() => { - if (!isForeignTableSelected && page && page > totalPages) { - snap.setPage(totalPages) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isForeignTableSelected, page, totalPages]) - - useEffect(() => { - if (id !== undefined) { - snap.setEnforceExactCount(rowsCountEstimate !== null && rowsCountEstimate <= THRESHOLD_COUNT) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) - useEffect(() => { // If the count query encountered a timeout error with exact count // turn off the exact count to rely on approximate @@ -206,6 +191,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = // eslint-disable-next-line react-hooks/exhaustive-deps }, [isError, snap.enforceExactCount, error?.code]) + // [Joshen] One to revisit if we can consolidate this and the main return statement if (isForeignTableSelected) { return (
@@ -251,102 +237,107 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = return (
- {isLoading && ( -
- -

Loading records count...

-
- )} +
+
+ + {isLoading ? ( +
- - {!isForeignTableSelected && ( -
-

- {`${countString} ${count === 0 || count > 1 ? `records` : 'record'}`}{' '} - {data.is_estimate ? '(estimated)' : ''} -

- - {data.is_estimate && ( - - -
+ + Failed to retrieve count: {error?.message} + + ) : !isForeignTableSelected ? ( +
+ {hasCountData && ( +

+ {`${countString} ${count === 0 || count > 1 ? `records` : 'record'}`}{' '} + {data.is_estimate ? '(estimated)' : ''} +

)} - - )} - {isError && ( -

- Error fetching records count. Please refresh the page. -

- )} + {data.is_estimate && ( + + +
+ ) : null} ( - { projectRef, connectionString, schema }: ForeignKeyConstraintsVariables, + { + projectRef, + connectionString: connectionStringOverride, + schema, + }: ForeignKeyConstraintsVariables, { enabled = true, ...options }: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: databaseKeys.foreignKeyConstraints(projectRef, schema), +) => { + const { connectionString: connectionStringReadOps } = useConnectionStringForReadOps() + const connectionString = connectionStringOverride || connectionStringReadOps + + return useQuery({ + queryKey: databaseKeys.foreignKeyConstraints(projectRef, schema, { connectionString }), queryFn: ({ signal }) => getForeignKeyConstraints({ projectRef, connectionString, schema }, signal), enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined' && + (!IS_PLATFORM || typeof connectionString !== 'undefined') && schema.length > 0, ...options, }) +} export function prefetchForeignKeyConstraints( client: QueryClient, diff --git a/apps/studio/data/database/keys.ts b/apps/studio/data/database/keys.ts index 941609fe0f0..e0e1b9bbc53 100644 --- a/apps/studio/data/database/keys.ts +++ b/apps/studio/data/database/keys.ts @@ -27,8 +27,8 @@ export const databaseKeys = { ['projects', projectRef, 'index-advisor', { query }] as const, tableConstraints: (projectRef: string | undefined, id?: number) => ['projects', projectRef, 'table-constraints', id] as const, - foreignKeyConstraints: (projectRef: string | undefined, schema?: string) => - ['projects', projectRef, 'foreign-key-constraints', schema] as const, + foreignKeyConstraints: (projectRef: string | undefined, schema?: string, options = {}) => + ['projects', projectRef, 'foreign-key-constraints', schema, options] as const, databaseSize: (projectRef: string | undefined) => ['projects', projectRef, 'database-size'] as const, maxConnections: (projectRef: string | undefined) => diff --git a/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx index 36e8cb25162..254098c9029 100644 --- a/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx +++ b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx @@ -1,7 +1,4 @@ import { QueryClient, useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/router' -import { PropsWithChildren, useCallback } from 'react' - import { formatFilterURLParams, formatSortURLParams, @@ -13,8 +10,12 @@ import { prefetchTableEditor } from 'data/table-editor/table-editor-query' import { prefetchTableRows } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' +import { useRouter } from 'next/router' +import { PropsWithChildren, useCallback } from 'react' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE } from 'state/table-editor' + +import { useConnectionStringForReadOps } from '../read-replicas/replicas-query' import PrefetchableLink, { PrefetchableLinkProps } from './PrefetchableLink' interface PrefetchEditorTablePageArgs { @@ -65,6 +66,7 @@ export function usePrefetchEditorTablePage() { const router = useRouter() const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() + const { connectionString } = useConnectionStringForReadOps() const roleImpersonationState = useRoleImpersonationStateSnapshot() return useCallback( @@ -79,7 +81,7 @@ export function usePrefetchEditorTablePage() { prefetchEditorTablePage({ queryClient, projectRef: project.ref, - connectionString: project.connectionString, + connectionString, id, sorts, filters, @@ -88,7 +90,7 @@ export function usePrefetchEditorTablePage() { // eat prefetching errors as they are not critical }) }, - [project, queryClient, roleImpersonationState, router] + [connectionString, project, queryClient, roleImpersonationState, router] ) } diff --git a/apps/studio/data/read-replicas/replicas-query.ts b/apps/studio/data/read-replicas/replicas-query.ts index 30214522541..e4dc14ba655 100644 --- a/apps/studio/data/read-replicas/replicas-query.ts +++ b/apps/studio/data/read-replicas/replicas-query.ts @@ -1,9 +1,11 @@ import { useQuery } from '@tanstack/react-query' - +import { useFeatureFlags, useFlag } from 'common' import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' + import { replicaKeys } from './keys' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' export const MAX_REPLICAS_BELOW_XL = 2 export const MAX_REPLICAS_ABOVE_XL = 5 @@ -55,3 +57,44 @@ export const usePrimaryDatabase = ({ projectRef }: { projectRef?: string }) => { const primaryDatabase = databases.find((x) => x.identifier === projectRef) return { database: primaryDatabase, error, isLoading, isError, isSuccess } } + +/** + * [Joshen] JFYI this logic here can and should be optimized + * Returns the connection string of read replica if available, otherwise default to project's (primary) + * If multiple read replicas available, (naively) prioritise replica in the same region as primary + * to minimize any latency. Otherwise just use the first available read replica + */ +export const useConnectionStringForReadOps = (): { + type: 'replica' | 'primary' | undefined + identifier: string | undefined + connectionString: string | undefined | null +} => { + const { hasLoaded: flagsLoaded } = useFeatureFlags() + const defaultToReadReplicaConnectionString = useFlag('defaultToReadReplicaConnectionString') + + const { data: project, isSuccess: isSuccessProject } = useSelectedProjectQuery() + const { data: databases = [], isLoading: isLoadingDatabases } = useReadReplicasQuery({ + projectRef: project?.ref, + }) + + const readReplicas = databases.filter( + (x) => x.identifier !== project?.ref && x.status === 'ACTIVE_HEALTHY' + ) + const readReplica = readReplicas.some((x) => x.region === project?.region) + ? readReplicas.find((x) => x.region === project?.region) + : readReplicas[0] + + if (!isSuccessProject || isLoadingDatabases || !flagsLoaded) { + return { connectionString: undefined, type: undefined, identifier: undefined } + } + + if (!defaultToReadReplicaConnectionString) { + return { type: 'primary', identifier: project.ref, connectionString: project.connectionString } + } + + return { + type: !!readReplica ? 'replica' : 'primary', + identifier: !!readReplica ? readReplica.identifier : project.ref, + connectionString: !!readReplica ? readReplica.connectionString : project.connectionString, + } +} diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index 4016f7949b5..481787c29bc 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -1,86 +1,23 @@ -import { Query } from '@supabase/pg-meta/src/query' -import { - COUNT_ESTIMATE_SQL, - THRESHOLD_COUNT, -} from '@supabase/pg-meta/src/sql/studio/get-count-estimate' import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' - +import { IS_PLATFORM } from 'common' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' import type { Filter, SupaTable } from 'components/grid/types' import { prefetchTableEditor } from 'data/table-editor/table-editor-query' import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation' import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' -import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' -import { tableRowKeys } from './keys' -import { formatFilterValue } from './utils' import { UseCustomQueryOptions } from 'types' -type GetTableRowsCountArgs = { +import { useConnectionStringForReadOps } from '../read-replicas/replicas-query' +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' +import { tableRowKeys } from './keys' +import { getTableRowsCountSql } from './table-rows.sql' + +export type GetTableRowsCountArgs = { table?: SupaTable filters?: Filter[] enforceExactCount?: boolean } -export const getTableRowsCountSql = ({ - table, - filters = [], - enforceExactCount = false, -}: GetTableRowsCountArgs) => { - if (!table) return `` - - if (enforceExactCount) { - const query = new Query() - let queryChains = query.from(table.name, table.schema ?? undefined).count() - filters - .filter((x) => x.value && x.value !== '') - .forEach((x) => { - const value = formatFilterValue(table, x) - queryChains = queryChains.filter(x.column, x.operator, value) - }) - return `select (${queryChains.toSql().slice(0, -1)}), false as is_estimate;` - } else { - const selectQuery = new Query() - let selectQueryChains = selectQuery.from(table.name, table.schema ?? undefined).select('*') - filters - .filter((x) => x.value && x.value != '') - .forEach((x) => { - const value = formatFilterValue(table, x) - selectQueryChains = selectQueryChains.filter(x.column, x.operator, value) - }) - const selectBaseSql = selectQueryChains.toSql() - - const countQuery = new Query() - let countQueryChains = countQuery.from(table.name, table.schema ?? undefined).count() - filters - .filter((x) => x.value && x.value != '') - .forEach((x) => { - const value = formatFilterValue(table, x) - countQueryChains = countQueryChains.filter(x.column, x.operator, value) - }) - const countBaseSql = countQueryChains.toSql().slice(0, -1) - - const sql = ` -${COUNT_ESTIMATE_SQL} - -with approximation as ( - select reltuples as estimate - from pg_class - where oid = ${table.id} -) -select - case - when estimate = -1 then (select pg_temp.count_estimate('${selectBaseSql.replaceAll("'", "''")}')) - when estimate > ${THRESHOLD_COUNT} then ${filters.length > 0 ? `pg_temp.count_estimate('${selectBaseSql.replaceAll("'", "''")}')` : 'estimate'} - else (${countBaseSql}) - end as count, - estimate = -1 or estimate > ${THRESHOLD_COUNT} as is_estimate -from approximation; -`.trim() - - return sql - } -} - export type TableRowsCount = { count?: number is_estimate?: boolean @@ -106,7 +43,8 @@ export async function getTableRowsCount( filters, roleImpersonationState, enforceExactCount, - }: TableRowsCountVariables, + isUsingReadReplica = false, + }: TableRowsCountVariables & { isUsingReadReplica?: boolean }, signal?: AbortSignal ) { const entity = await prefetchTableEditor(queryClient, { @@ -121,7 +59,7 @@ export async function getTableRowsCount( const table = parseSupaTable(entity) const sql = wrapWithRoleImpersonation( - getTableRowsCountSql({ table, filters, enforceExactCount }), + getTableRowsCountSql({ table, filters, enforceExactCount, isUsingReadReplica }), roleImpersonationState ) const { result } = await executeSql( @@ -142,18 +80,44 @@ export async function getTableRowsCount( } export const useTableRowsCountQuery = ( - { projectRef, connectionString, tableId, ...args }: Omit, + { + projectRef, + connectionString: connectionStringOverride, + tableId, + ...args + }: Omit, { enabled = true, ...options }: UseCustomQueryOptions = {} ) => { const queryClient = useQueryClient() + const { connectionString: connectionStringReadOps, type } = useConnectionStringForReadOps() + const connectionString = connectionStringOverride || connectionStringReadOps + return useQuery({ - queryKey: tableRowKeys.tableRowsCount(projectRef, { table: { id: tableId }, ...args }), + queryKey: tableRowKeys.tableRowsCount(projectRef, { + table: { id: tableId }, + connectionString, + ...args, + }), queryFn: ({ signal }) => - getTableRowsCount({ queryClient, projectRef, connectionString, tableId, ...args }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof tableId !== 'undefined', + getTableRowsCount( + { + queryClient, + projectRef, + connectionString, + tableId, + isUsingReadReplica: type === 'replica', + ...args, + }, + signal + ), + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof tableId !== 'undefined' && + (!IS_PLATFORM || typeof connectionString !== 'undefined'), ...options, }) } diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 12e72673096..2b76076672f 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -16,6 +16,7 @@ import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' import { ResponseError, UseCustomQueryOptions } from 'types' import { handleError } from '../fetchers' +import { useConnectionStringForReadOps } from '../read-replicas/replicas-query' import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { tableRowKeys } from './keys' import { formatFilterValue } from './utils' @@ -390,10 +391,17 @@ async function getTableRows( } export const useTableRowsQuery = ( - { projectRef, connectionString, tableId, ...args }: Omit, + { + projectRef, + connectionString: connectionStringOverride, + tableId, + ...args + }: Omit, { enabled = true, ...options }: UseCustomQueryOptions = {} ) => { const queryClient = useQueryClient() + const { connectionString: connectionStringReadOps } = useConnectionStringForReadOps() + const connectionString = connectionStringOverride || connectionStringReadOps // [Joshen] Exclude preflightCheck from query key const { preflightCheck, ...othersArgs } = args @@ -401,11 +409,16 @@ export const useTableRowsQuery = ( return useQuery({ queryKey: tableRowKeys.tableRows(projectRef, { table: { id: tableId }, + connectionString, ...othersArgs, }), queryFn: ({ signal }) => getTableRows({ queryClient, projectRef, connectionString, tableId, ...args }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof tableId !== 'undefined', + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof tableId !== 'undefined' && + (!IS_PLATFORM || typeof connectionString !== 'undefined'), ...options, }) } @@ -417,6 +430,7 @@ export function prefetchTableRows( return client.fetchQuery({ queryKey: tableRowKeys.tableRows(projectRef, { table: { id: tableId }, + connectionString, ...args, }), queryFn: ({ signal }) => diff --git a/apps/studio/data/table-rows/table-rows.sql.ts b/apps/studio/data/table-rows/table-rows.sql.ts new file mode 100644 index 00000000000..5c5d0947bc5 --- /dev/null +++ b/apps/studio/data/table-rows/table-rows.sql.ts @@ -0,0 +1,93 @@ +import { Query } from '@supabase/pg-meta/src/query' +import { + COUNT_ESTIMATE_SQL, + THRESHOLD_COUNT, +} from '@supabase/pg-meta/src/sql/studio/get-count-estimate' + +import { GetTableRowsCountArgs } from './table-rows-count-query' +import { formatFilterValue } from './utils' + +/** + * [Joshen] Initially check reltuples from pg_class for an estimate of row count on the table + * - If reltuples = -1, table never been analyzed, assume small table -> return exact count + * - If reltuples exceeds threshold, return estimate count + * - Else return exact count + */ +export const getTableRowsCountSql = ({ + table, + filters = [], + enforceExactCount = false, + isUsingReadReplica = false, +}: GetTableRowsCountArgs & { isUsingReadReplica?: boolean }) => { + if (!table) return `` + + if (enforceExactCount) { + const query = new Query() + let queryChains = query.from(table.name, table.schema ?? undefined).count() + filters + .filter((x) => x.value && x.value !== '') + .forEach((x) => { + const value = formatFilterValue(table, x) + queryChains = queryChains.filter(x.column, x.operator, value) + }) + return `select (${queryChains.toSql().slice(0, -1)}), false as is_estimate;` + } else { + const selectQuery = new Query() + let selectQueryChains = selectQuery.from(table.name, table.schema ?? undefined).select('*') + filters + .filter((x) => x.value && x.value != '') + .forEach((x) => { + const value = formatFilterValue(table, x) + selectQueryChains = selectQueryChains.filter(x.column, x.operator, value) + }) + const selectBaseSql = selectQueryChains.toSql() + + const countQuery = new Query() + let countQueryChains = countQuery.from(table.name, table.schema ?? undefined).count() + filters + .filter((x) => x.value && x.value != '') + .forEach((x) => { + const value = formatFilterValue(table, x) + countQueryChains = countQueryChains.filter(x.column, x.operator, value) + }) + const countBaseSql = countQueryChains.toSql().slice(0, -1) + + if (isUsingReadReplica) { + const sql = ` +with approximation as ( + select reltuples as estimate + from pg_class + where oid = ${table.id} +) +select + case + when estimate > ${THRESHOLD_COUNT} then (select -1) + else (${countBaseSql}) + end as count, + estimate > ${THRESHOLD_COUNT} as is_estimate +from approximation; +`.trim() + + return sql + } else { + const sql = ` +${COUNT_ESTIMATE_SQL} + +with approximation as ( + select reltuples as estimate + from pg_class + where oid = ${table.id} +) +select + case + when estimate > ${THRESHOLD_COUNT} then ${filters.length > 0 ? `pg_temp.count_estimate('${selectBaseSql.replaceAll("'", "''")}')` : 'estimate'} + else (${countBaseSql}) + end as count, + estimate > ${THRESHOLD_COUNT} as is_estimate +from approximation; +`.trim() + + return sql + } + } +} diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index 76473f85a60..7229a55e252 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -282,3 +282,15 @@ div[data-radix-portal]:not(.portal--toast) { height: 6px !important; @apply border-strong dark:border-r-2 dark:border-b-2 dark:border-overlay #{!important}; } + +@keyframes typewriter { + from { + width: 0; + } +} + +@keyframes blink-caret { + 50% { + border-color: transparent; + } +} diff --git a/apps/studio/tailwind.config.js b/apps/studio/tailwind.config.js index ace71e440be..b5fdcf67abc 100644 --- a/apps/studio/tailwind.config.js +++ b/apps/studio/tailwind.config.js @@ -99,12 +99,6 @@ module.exports = config({ transform: 'rotate(10deg) scale(1.5) translateY(2rem)', }, }, - typewriter: { - from: { width: '0' }, - }, - 'blink-caret': { - '50%': { borderColor: 'transparent' }, - }, }, }, },