diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts index 828b6ec314db6..cce42b5f44bec 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts @@ -11,3 +11,7 @@ export const getAnalyticsBucketS3KeyName = (bucketId: string) => { export const getAnalyticsBucketFDWName = (bucketId: string) => { return `${snakeCase(bucketId)}_fdw` } + +export const getAnalyticsBucketFDWServerName = (bucketId: string) => { + return `${snakeCase(bucketId)}_fdw_server` +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx index 6a1f43de7afc4..dad6fa9afd6bc 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -1,13 +1,21 @@ -import { snakeCase } from 'lodash' -import { MoreVertical, Pause, Play } from 'lucide-react' +import { snakeCase, uniq } from 'lodash' +import { MoreVertical, Pause, Play, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { + convertKVStringArrayToJson, + formatWrapperTables, +} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' import { useReplicationTablesQuery } from 'data/etl/tables-query' +import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' +import { useIcebergNamespaceTableDeleteMutation } from 'data/storage/iceberg-namespace-table-delete-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { SqlEditor } from 'icons' import { @@ -21,25 +29,35 @@ import { TableRow, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { getAnalyticsBucketFDWServerName } from '../AnalyticsBucketDetails.utils' import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities' +import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance' + +interface TableRowComponentProps { + index: number + table: { id: number; name: string; isConnected: boolean } + schema: string + namespace: string + token: string + isLoading?: boolean +} export const TableRowComponent = ({ index, table, schema, + namespace, + token, isLoading, -}: { - index: number - table: { id: number; name: string; isConnected: boolean } - schema: string - isLoading?: boolean -}) => { +}: TableRowComponentProps) => { const { ref: projectRef, bucketId } = useParams() const { data: project } = useSelectedProjectQuery() const [showStopReplicationModal, setShowStopReplicationModal] = useState(false) const [showStartReplicationModal, setShowStartReplicationModal] = useState(false) + const [showRemoveTableModal, setShowRemoveTableModal] = useState(false) const [isUpdatingReplication, setIsUpdatingReplication] = useState(false) + const [isRemovingTable, setIsRemovingTable] = useState(false) const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({ projectRef, @@ -47,11 +65,16 @@ export const TableRowComponent = ({ }) const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + const { data: wrapperInstance, meta: wrapperMeta } = useAnalyticsBucketWrapperInstance({ + bucketId: bucketId, + }) + const { mutateAsync: updateFDW } = useFDWUpdateMutation() + const { mutateAsync: deleteNamespaceTable } = useIcebergNamespaceTableDeleteMutation() const { mutateAsync: updatePublication } = useUpdatePublicationMutation() const { mutateAsync: startPipeline } = useStartPipelineMutation() - const isReplicating = publication?.tables.find( + const isReplicating = !!publication?.tables.find( (x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`) ) @@ -117,6 +140,66 @@ export const TableRowComponent = ({ } } + const onConfirmRemoveTable = async () => { + if (!bucketId) return console.error('Bucket ID is required') + if (!wrapperInstance || !wrapperMeta) return toast.error('Unable to find wrapper') + + try { + setIsRemovingTable(true) + + const serverName = getAnalyticsBucketFDWServerName(bucketId) + const serverOptions = await getDecryptedParameters({ + ref: project?.ref, + connectionString: project?.connectionString ?? undefined, + wrapper: wrapperInstance, + }) + const formValues: Record = { + wrapper_name: wrapperInstance.name, + server_name: wrapperInstance.server_name, + ...serverOptions, + } + const targetSchemas = (formValues['supabase_target_schema'] || '') + .split(',') + .map((s) => s.trim()) + const wrapperTables = formatWrapperTables(wrapperInstance, wrapperMeta).filter( + (x) => x.table_name !== table.name + ) + + // [Joshen] Once Ivan's PR goes through, swap these out to just use useFDWDropForeignTableMutation + // https://github.com/supabase/supabase/pull/40206 + await updateFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper: wrapperInstance, + wrapperMeta, + formState: { + ...formValues, + server_name: serverName, + supabase_target_schema: uniq([...targetSchemas]) + .filter(Boolean) + .join(','), + }, + tables: wrapperTables, + }) + + const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + await deleteNamespaceTable({ + token, + catalogUri: wrapperValues.catalog_uri, + warehouse: wrapperValues.warehouse, + namespace: namespace, + table: table.name, + }) + + toast.success('Successfully removed table!') + setShowRemoveTableModal(false) + } catch (error: any) { + toast.error(`Failed to remove table: ${error.message}`) + } finally { + setIsRemovingTable(false) + } + } + return ( <> @@ -180,8 +263,6 @@ export const TableRowComponent = ({ - - {isReplicating ? ( Start replication

)} + + + + setShowRemoveTableModal(true)} + tooltip={{ + content: { + side: 'left', + text: 'Stop replication on this table before removing', + }, + }} + > + +

Remove table

+
@@ -220,6 +318,7 @@ export const TableRowComponent = ({ restarting replication on the table will clear and re-sync all data in it. Are you sure?

+ + + setShowRemoveTableModal(false)} + onConfirm={() => onConfirmRemoveTable()} + > +

Are you sure? This action cannot be undone.

+
) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx index e8a6637537db3..574d7ffa3800b 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx @@ -238,6 +238,8 @@ export const NamespaceWithTables = ({ w.name === snakeCase(`${bucketId}_fdw`)) + .find((w) => w.name === getAnalyticsBucketFDWName(bucketId ?? '')) }, [data, bucketId]) const icebergWrapperMeta = getWrapperMetaForWrapper(icebergWrapper) diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index 17f536eef04a3..f606d89b0a94e 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { snakeCase, uniq } from 'lodash' +import { uniq } from 'lodash' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -17,6 +17,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import type { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types' import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils' import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor' +import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils' export interface ImportForeignSchemaDialogProps { @@ -79,7 +80,7 @@ export const ImportForeignSchemaDialog = ({ }) const onSubmit: SubmitHandler> = async (values) => { - const serverName = `${snakeCase(values.bucketName)}_fdw_server` + const serverName = getAnalyticsBucketFDWServerName(values.bucketName) if (!ref) return console.error('Project ref is required') setLoading(true) diff --git a/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts b/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts new file mode 100644 index 0000000000000..a80cd0e52919b --- /dev/null +++ b/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { constructHeaders, fetchHandler, handleError } from 'data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from 'types' +import { storageKeys } from './keys' + +type DeleteIcebergNamespaceTableVariables = { + catalogUri: string + warehouse: string + token: string + namespace: string + table: string +} + +// [Joshen] Investigate if we can use the temp API keys here +async function deleteIcebergNamespaceTable({ + catalogUri, + warehouse, + token, + namespace, + table, +}: DeleteIcebergNamespaceTableVariables) { + let headers = new Headers() + // handle both secret key and service role key + if (token.startsWith('sb_secret_')) { + headers = await constructHeaders({ + 'Content-Type': 'application/json', + apikey: `${token}`, + }) + headers.delete('Authorization') + } else { + headers = await constructHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }) + } + + const url = + `${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables/${table}?purgeRequested=true`.replaceAll( + /(?> + +export const useIcebergNamespaceTableDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + IcebergNamespaceTableDeleteData, + ResponseError, + DeleteIcebergNamespaceTableVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + IcebergNamespaceTableDeleteData, + ResponseError, + DeleteIcebergNamespaceTableVariables + >({ + mutationFn: (vars) => deleteIcebergNamespaceTable(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.icebergNamespace( + variables.catalogUri, + variables.warehouse, + variables.namespace + ), + }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete Iceberg namespace table: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +}