diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx index 10c152d69ee98..812ad66d7b83d 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -5,12 +5,15 @@ import { toast } from 'sonner' import * as z from 'zod' import { useParams } from 'common' +import { InlineLink } from 'components/ui/InlineLink' +import { useCheckPrimaryKeysExists } from 'data/database/primary-keys-exists-query' import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation' import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query' import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' import { useReplicationPublicationsQuery } from 'data/replication/publications-query' import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PipelineStatusRequestStatus, usePipelineRequestStatus, @@ -44,6 +47,7 @@ import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import NewPublicationPanel from './NewPublicationPanel' import PublicationsComboBox from './PublicationsComboBox' +import { ReplicationDisclaimerDialog } from './ReplicationDisclaimerDialog' const formId = 'destination-editor' const types = ['BigQuery'] as const @@ -80,10 +84,15 @@ export const DestinationPanel = ({ existingDestination, }: DestinationPanelProps) => { const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() const { setRequestStatus } = usePipelineRequestStatus() const editMode = !!existingDestination const [publicationPanelVisible, setPublicationPanelVisible] = useState(false) + const [showDisclaimerDialog, setShowDisclaimerDialog] = useState(false) + const [pendingFormValues, setPendingFormValues] = useState | null>( + null + ) const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } = useCreateDestinationPipelineMutation({ @@ -142,9 +151,21 @@ export const DestinationPanel = ({ const isSelectedPublicationMissing = isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName) - const isSubmitDisabled = isSaving || isSelectedPublicationMissing + const selectedPublication = publications.find((pub) => pub.name === publicationName) + const { data: checkPrimaryKeysExistsData, isLoading: isLoadingCheck } = useCheckPrimaryKeysExists( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + tables: selectedPublication?.tables ?? [], + }, + { enabled: visible && !!selectedPublication } + ) + const hasTablesWithNoPrimaryKeys = (checkPrimaryKeysExistsData?.offendingTables ?? []).length > 0 - const onSubmit = async (data: z.infer) => { + const isSubmitDisabled = + isSaving || isSelectedPublicationMissing || isLoadingCheck || hasTablesWithNoPrimaryKeys + + const submitPipeline = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!sourceId) return console.error('Source id is required') if (isSelectedPublicationMissing) { @@ -236,6 +257,32 @@ export const DestinationPanel = ({ } } + const onSubmit = async (data: z.infer) => { + if (!editMode) { + setPendingFormValues(data) + setShowDisclaimerDialog(true) + return + } + + await submitPipeline(data) + } + + const handleDisclaimerDialogChange = (open: boolean) => { + setShowDisclaimerDialog(open) + if (!open) { + setPendingFormValues(null) + } + } + + const handleDisclaimerConfirm = async () => { + if (!pendingFormValues) return + + const values = pendingFormValues + setPendingFormValues(null) + setShowDisclaimerDialog(false) + await submitPipeline(values) + } + useEffect(() => { if (editMode && destinationData && pipelineData) { form.reset(defaultValues) @@ -303,12 +350,13 @@ export const DestinationPanel = ({ setPublicationPanelVisible(true)} /> - {isSelectedPublicationMissing && ( + {isSelectedPublicationMissing ? (

The publication{' '} @@ -317,7 +365,29 @@ export const DestinationPanel = ({ another one.

- )} + ) : hasTablesWithNoPrimaryKeys ? ( + +

+ Replication requires every table in the publication to have a + primary key to work, which these tables are missing: +

+
    + {(checkPrimaryKeysExistsData?.offendingTables ?? []).map((x) => { + const value = `${x.schema}.${x.name}` + return ( +
  • + + {value} + +
  • + ) + })} +
+

+ Ensure that these tables have primary keys first. +

+
+ ) : null} )} /> @@ -487,6 +557,13 @@ export const DestinationPanel = ({ + + void field: ControllerRenderProps } const PublicationsComboBox = ({ publications, - loading, + isLoadingPublications, + isLoadingCheck, onNewPublicationClick, field, }: PublicationsComboBoxProps) => { @@ -62,13 +65,16 @@ const PublicationsComboBox = ({ + + + + + ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts index 4b96763edfa4d..97de6b437b1af 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts @@ -62,4 +62,9 @@ export const QUERY_PERFORMANCE_ROLE_DESCRIPTION = [ description: 'An internal role Supabase uses for administrative tasks, such as running upgrades and automations.', }, + { + name: 'pgbouncer', + description: + 'PgBouncer is a lightweight connection pooler for PostgreSQL. Available on paid plans only.', + }, ] as const diff --git a/apps/studio/data/database/keys.ts b/apps/studio/data/database/keys.ts index a71e1032ac3c6..7aff4ecb5ecb7 100644 --- a/apps/studio/data/database/keys.ts +++ b/apps/studio/data/database/keys.ts @@ -37,4 +37,8 @@ export const databaseKeys = { ['projects', projectRef, 'pgbouncer', 'status'] as const, pgbouncerConfig: (projectRef: string | undefined) => ['projects', projectRef, 'pgbouncer', 'config'] as const, + checkPrimaryKeysExists: ( + projectRef: string | undefined, + tables: { name: string; schema: string }[] + ) => ['projects', projectRef, 'check-primary-keys', tables] as const, } diff --git a/apps/studio/data/database/primary-keys-exists-query.ts b/apps/studio/data/database/primary-keys-exists-query.ts new file mode 100644 index 0000000000000..af72d7d61a146 --- /dev/null +++ b/apps/studio/data/database/primary-keys-exists-query.ts @@ -0,0 +1,57 @@ +import { getCheckPrimaryKeysExistsSQL } from '@supabase/pg-meta/src/sql/studio/check-primary-keys-exists' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' +import { databaseKeys } from './keys' + +type CheckPrimaryKeysExistsVariables = { + projectRef?: string + connectionString?: string | null + tables: { name: string; schema: string }[] +} + +type CheckPrimaryKeysExistResponse = { + id: string + name: string + schema: string + has_primary_key: boolean +}[] + +export async function checkPrimaryKeysExists({ + projectRef, + connectionString, + tables, +}: CheckPrimaryKeysExistsVariables) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getCheckPrimaryKeysExistsSQL(tables), + }) + + return { + offendingTables: (result as CheckPrimaryKeysExistResponse).filter((x) => !x.has_primary_key), + } +} + +export type CheckPrimaryKeysExistsData = Awaited> +export type CheckPrimaryKeysExistsError = ResponseError + +export const useCheckPrimaryKeysExists = ( + { projectRef, connectionString, tables }: CheckPrimaryKeysExistsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + databaseKeys.checkPrimaryKeysExists(projectRef, tables), + () => checkPrimaryKeysExists({ projectRef, connectionString, tables }), + { + retry: false, + enabled: enabled && typeof projectRef !== 'undefined' && tables.length > 0, + ...options, + } + ) diff --git a/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts b/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts new file mode 100644 index 0000000000000..203e72bf4230c --- /dev/null +++ b/packages/pg-meta/src/sql/studio/check-primary-keys-exists.ts @@ -0,0 +1,20 @@ +export const getCheckPrimaryKeysExistsSQL = (tables: { name: string; schema: string }[]) => { + const formattedTables = tables.map((table) => `'${table.schema}.${table.name}'`).join(',') + + return /* SQL */ ` +WITH targets(rel) AS ( + SELECT unnest(ARRAY[${formattedTables}]::regclass[]) +) +SELECT + c.oid AS id, + n.nspname AS schema, + c.relname AS name, + (con.conrelid IS NOT NULL) AS has_primary_key +FROM targets t +JOIN pg_class c ON c.oid = t.rel +JOIN pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_constraint con + ON con.conrelid = c.oid AND con.contype = 'p' +ORDER BY n.nspname, c.relname; +` +}