Skip to content

Commit 5291fe3

Browse files
authored
Set up removing tables from namespaces in analytics buckets (supabase#40244)
* Set up removing tables from namespaces in analytics buckets * Nit * Clean up based on comments
1 parent 00d8c49 commit 5291fe3

File tree

6 files changed

+232
-15
lines changed

6 files changed

+232
-15
lines changed

apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export const getAnalyticsBucketS3KeyName = (bucketId: string) => {
1111
export const getAnalyticsBucketFDWName = (bucketId: string) => {
1212
return `${snakeCase(bucketId)}_fdw`
1313
}
14+
15+
export const getAnalyticsBucketFDWServerName = (bucketId: string) => {
16+
return `${snakeCase(bucketId)}_fdw_server`
17+
}

apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { snakeCase } from 'lodash'
2-
import { MoreVertical, Pause, Play } from 'lucide-react'
1+
import { snakeCase, uniq } from 'lodash'
2+
import { MoreVertical, Pause, Play, Trash } from 'lucide-react'
33
import Link from 'next/link'
44
import { useState } from 'react'
55
import { toast } from 'sonner'
66

77
import { useParams } from 'common'
8+
import {
9+
convertKVStringArrayToJson,
10+
formatWrapperTables,
11+
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
12+
import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils'
13+
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
814
import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation'
915
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
1016
import { useReplicationTablesQuery } from 'data/etl/tables-query'
17+
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
18+
import { useIcebergNamespaceTableDeleteMutation } from 'data/storage/iceberg-namespace-table-delete-mutation'
1119
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
1220
import { SqlEditor } from 'icons'
1321
import {
@@ -21,37 +29,52 @@ import {
2129
TableRow,
2230
} from 'ui'
2331
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
32+
import { getAnalyticsBucketFDWServerName } from '../AnalyticsBucketDetails.utils'
2433
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
34+
import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance'
35+
36+
interface TableRowComponentProps {
37+
index: number
38+
table: { id: number; name: string; isConnected: boolean }
39+
schema: string
40+
namespace: string
41+
token: string
42+
isLoading?: boolean
43+
}
2544

2645
export const TableRowComponent = ({
2746
index,
2847
table,
2948
schema,
49+
namespace,
50+
token,
3051
isLoading,
31-
}: {
32-
index: number
33-
table: { id: number; name: string; isConnected: boolean }
34-
schema: string
35-
isLoading?: boolean
36-
}) => {
52+
}: TableRowComponentProps) => {
3753
const { ref: projectRef, bucketId } = useParams()
3854
const { data: project } = useSelectedProjectQuery()
3955

4056
const [showStopReplicationModal, setShowStopReplicationModal] = useState(false)
4157
const [showStartReplicationModal, setShowStartReplicationModal] = useState(false)
58+
const [showRemoveTableModal, setShowRemoveTableModal] = useState(false)
4259
const [isUpdatingReplication, setIsUpdatingReplication] = useState(false)
60+
const [isRemovingTable, setIsRemovingTable] = useState(false)
4361

4462
const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({
4563
projectRef,
4664
bucketId,
4765
})
4866

4967
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
68+
const { data: wrapperInstance, meta: wrapperMeta } = useAnalyticsBucketWrapperInstance({
69+
bucketId: bucketId,
70+
})
5071

72+
const { mutateAsync: updateFDW } = useFDWUpdateMutation()
73+
const { mutateAsync: deleteNamespaceTable } = useIcebergNamespaceTableDeleteMutation()
5174
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
5275
const { mutateAsync: startPipeline } = useStartPipelineMutation()
5376

54-
const isReplicating = publication?.tables.find(
77+
const isReplicating = !!publication?.tables.find(
5578
(x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`)
5679
)
5780

@@ -117,6 +140,66 @@ export const TableRowComponent = ({
117140
}
118141
}
119142

143+
const onConfirmRemoveTable = async () => {
144+
if (!bucketId) return console.error('Bucket ID is required')
145+
if (!wrapperInstance || !wrapperMeta) return toast.error('Unable to find wrapper')
146+
147+
try {
148+
setIsRemovingTable(true)
149+
150+
const serverName = getAnalyticsBucketFDWServerName(bucketId)
151+
const serverOptions = await getDecryptedParameters({
152+
ref: project?.ref,
153+
connectionString: project?.connectionString ?? undefined,
154+
wrapper: wrapperInstance,
155+
})
156+
const formValues: Record<string, string> = {
157+
wrapper_name: wrapperInstance.name,
158+
server_name: wrapperInstance.server_name,
159+
...serverOptions,
160+
}
161+
const targetSchemas = (formValues['supabase_target_schema'] || '')
162+
.split(',')
163+
.map((s) => s.trim())
164+
const wrapperTables = formatWrapperTables(wrapperInstance, wrapperMeta).filter(
165+
(x) => x.table_name !== table.name
166+
)
167+
168+
// [Joshen] Once Ivan's PR goes through, swap these out to just use useFDWDropForeignTableMutation
169+
// https://github.com/supabase/supabase/pull/40206
170+
await updateFDW({
171+
projectRef: project?.ref,
172+
connectionString: project?.connectionString,
173+
wrapper: wrapperInstance,
174+
wrapperMeta,
175+
formState: {
176+
...formValues,
177+
server_name: serverName,
178+
supabase_target_schema: uniq([...targetSchemas])
179+
.filter(Boolean)
180+
.join(','),
181+
},
182+
tables: wrapperTables,
183+
})
184+
185+
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
186+
await deleteNamespaceTable({
187+
token,
188+
catalogUri: wrapperValues.catalog_uri,
189+
warehouse: wrapperValues.warehouse,
190+
namespace: namespace,
191+
table: table.name,
192+
})
193+
194+
toast.success('Successfully removed table!')
195+
setShowRemoveTableModal(false)
196+
} catch (error: any) {
197+
toast.error(`Failed to remove table: ${error.message}`)
198+
} finally {
199+
setIsRemovingTable(false)
200+
}
201+
}
202+
120203
return (
121204
<>
122205
<TableRow>
@@ -180,8 +263,6 @@ export const TableRowComponent = ({
180263
</Link>
181264
</DropdownMenuItem>
182265

183-
<DropdownMenuSeparator />
184-
185266
{isReplicating ? (
186267
<DropdownMenuItem
187268
className="flex items-center gap-x-2"
@@ -199,6 +280,23 @@ export const TableRowComponent = ({
199280
<p>Start replication</p>
200281
</DropdownMenuItem>
201282
)}
283+
284+
<DropdownMenuSeparator />
285+
286+
<DropdownMenuItemTooltip
287+
disabled={isReplicating}
288+
className="flex items-center gap-x-2"
289+
onClick={() => setShowRemoveTableModal(true)}
290+
tooltip={{
291+
content: {
292+
side: 'left',
293+
text: 'Stop replication on this table before removing',
294+
},
295+
}}
296+
>
297+
<Trash size={12} className="text-foreground-lighter" />
298+
<p>Remove table</p>
299+
</DropdownMenuItemTooltip>
202300
</DropdownMenuContent>
203301
</DropdownMenu>
204302
</>
@@ -220,6 +318,7 @@ export const TableRowComponent = ({
220318
restarting replication on the table will clear and re-sync all data in it. Are you sure?
221319
</p>
222320
</ConfirmationModal>
321+
223322
<ConfirmationModal
224323
size="medium"
225324
variant="warning"
@@ -235,6 +334,20 @@ export const TableRowComponent = ({
235334
Are you sure?
236335
</p>
237336
</ConfirmationModal>
337+
338+
<ConfirmationModal
339+
size="small"
340+
variant="warning"
341+
visible={showRemoveTableModal}
342+
loading={isRemovingTable}
343+
title="Confirm to remove table"
344+
description="Data from the analytics table will be lost"
345+
confirmLabel="Remove table"
346+
onCancel={() => setShowRemoveTableModal(false)}
347+
onConfirm={() => onConfirmRemoveTable()}
348+
>
349+
<p className="text-sm text-foreground-light">Are you sure? This action cannot be undone.</p>
350+
</ConfirmationModal>
238351
</>
239352
)
240353
}

apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ export const NamespaceWithTables = ({
238238
<TableRowComponent
239239
key={table.name}
240240
table={table}
241+
namespace={namespace}
242+
token={token}
241243
schema={displaySchema}
242244
isLoading={isImportingForeignSchema || isLoadingNamespaceTables}
243245
index={index}

apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { snakeCase } from 'lodash'
21
import { useMemo } from 'react'
32

43
import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
@@ -8,6 +7,7 @@ import {
87
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
98
import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query'
109
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
10+
import { getAnalyticsBucketFDWName } from './AnalyticsBucketDetails.utils'
1111

1212
export const useAnalyticsBucketWrapperInstance = (
1313
{ bucketId }: { bucketId?: string },
@@ -36,7 +36,7 @@ export const useAnalyticsBucketWrapperInstance = (
3636
wrapper
3737
)
3838
)
39-
.find((w) => w.name === snakeCase(`${bucketId}_fdw`))
39+
.find((w) => w.name === getAnalyticsBucketFDWName(bucketId ?? ''))
4040
}, [data, bucketId])
4141

4242
const icebergWrapperMeta = getWrapperMetaForWrapper(icebergWrapper)

apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { zodResolver } from '@hookform/resolvers/zod'
2-
import { snakeCase, uniq } from 'lodash'
2+
import { uniq } from 'lodash'
33
import { useEffect, useState } from 'react'
44
import { SubmitHandler, useForm } from 'react-hook-form'
55
import { toast } from 'sonner'
@@ -17,6 +17,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
1717
import type { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types'
1818
import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils'
1919
import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor'
20+
import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils'
2021
import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils'
2122

2223
export interface ImportForeignSchemaDialogProps {
@@ -79,7 +80,7 @@ export const ImportForeignSchemaDialog = ({
7980
})
8081

8182
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
82-
const serverName = `${snakeCase(values.bucketName)}_fdw_server`
83+
const serverName = getAnalyticsBucketFDWServerName(values.bucketName)
8384

8485
if (!ref) return console.error('Project ref is required')
8586
setLoading(true)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import { toast } from 'sonner'
3+
4+
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
5+
import type { ResponseError, UseCustomMutationOptions } from 'types'
6+
import { storageKeys } from './keys'
7+
8+
type DeleteIcebergNamespaceTableVariables = {
9+
catalogUri: string
10+
warehouse: string
11+
token: string
12+
namespace: string
13+
table: string
14+
}
15+
16+
// [Joshen] Investigate if we can use the temp API keys here
17+
async function deleteIcebergNamespaceTable({
18+
catalogUri,
19+
warehouse,
20+
token,
21+
namespace,
22+
table,
23+
}: DeleteIcebergNamespaceTableVariables) {
24+
let headers = new Headers()
25+
// handle both secret key and service role key
26+
if (token.startsWith('sb_secret_')) {
27+
headers = await constructHeaders({
28+
'Content-Type': 'application/json',
29+
apikey: `${token}`,
30+
})
31+
headers.delete('Authorization')
32+
} else {
33+
headers = await constructHeaders({
34+
'Content-Type': 'application/json',
35+
Authorization: `Bearer ${token}`,
36+
})
37+
}
38+
39+
const url =
40+
`${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables/${table}?purgeRequested=true`.replaceAll(
41+
/(?<!:)\/\//g,
42+
'/'
43+
)
44+
45+
try {
46+
const response = await fetchHandler(url, {
47+
headers,
48+
method: 'DELETE',
49+
})
50+
return response.status === 204
51+
} catch (error) {
52+
handleError(error)
53+
}
54+
}
55+
56+
type IcebergNamespaceTableDeleteData = Awaited<ReturnType<typeof deleteIcebergNamespaceTable>>
57+
58+
export const useIcebergNamespaceTableDeleteMutation = ({
59+
onSuccess,
60+
onError,
61+
...options
62+
}: Omit<
63+
UseCustomMutationOptions<
64+
IcebergNamespaceTableDeleteData,
65+
ResponseError,
66+
DeleteIcebergNamespaceTableVariables
67+
>,
68+
'mutationFn'
69+
> = {}) => {
70+
const queryClient = useQueryClient()
71+
72+
return useMutation<
73+
IcebergNamespaceTableDeleteData,
74+
ResponseError,
75+
DeleteIcebergNamespaceTableVariables
76+
>({
77+
mutationFn: (vars) => deleteIcebergNamespaceTable(vars),
78+
async onSuccess(data, variables, context) {
79+
await queryClient.invalidateQueries({
80+
queryKey: storageKeys.icebergNamespace(
81+
variables.catalogUri,
82+
variables.warehouse,
83+
variables.namespace
84+
),
85+
})
86+
await onSuccess?.(data, variables, context)
87+
},
88+
async onError(data, variables, context) {
89+
if (onError === undefined) {
90+
toast.error(`Failed to delete Iceberg namespace table: ${data.message}`)
91+
} else {
92+
onError(data, variables, context)
93+
}
94+
},
95+
...options,
96+
})
97+
}

0 commit comments

Comments
 (0)