ExportAllRows.tsx457 lines · main
| 1 | import { useQueryClient, type QueryClient } from '@tanstack/react-query' |
| 2 | import { IS_PLATFORM } from 'common' |
| 3 | import saveAs from 'file-saver' |
| 4 | import Papa from 'papaparse' |
| 5 | import { useCallback, useState, type ReactNode } from 'react' |
| 6 | import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' |
| 7 | |
| 8 | import { |
| 9 | BlobCreationError, |
| 10 | DownloadSaveError, |
| 11 | FetchRowsError, |
| 12 | NoConnectionStringError, |
| 13 | NoRowsToExportError, |
| 14 | NoTableError, |
| 15 | OutputConversionError, |
| 16 | TableDetailsFetchError, |
| 17 | TableTooLargeError, |
| 18 | type ExportAllRowsErrorFamily, |
| 19 | } from './ExportAllRows.errors' |
| 20 | import { useProgressToasts } from './ExportAllRows.progress' |
| 21 | import { parseSupaTable } from '@/components/grid/BrivenGrid.utils' |
| 22 | import type { Filter, Sort, SupaTable } from '@/components/grid/types' |
| 23 | import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' |
| 24 | import { InlineLink } from '@/components/ui/InlineLink' |
| 25 | import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' |
| 26 | import type { Entity } from '@/data/entity-types/entity-types-infinite-query' |
| 27 | import { tableEditorKeys } from '@/data/table-editor/keys' |
| 28 | import { getTableEditor, type TableEditorData } from '@/data/table-editor/table-editor-query' |
| 29 | import { isTableLike } from '@/data/table-editor/table-editor-types' |
| 30 | import { fetchAllTableRows } from '@/data/table-rows/table-rows-query' |
| 31 | import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' |
| 32 | import { DOCS_URL } from '@/lib/constants' |
| 33 | import type { RoleImpersonationState } from '@/lib/role-impersonation' |
| 34 | |
| 35 | // [Joshen] CSV exports require this guard as a fail-safe if the table is |
| 36 | // just too large for a browser to keep all the rows in memory before |
| 37 | // exporting. Either that or export as multiple CSV sheets with max n rows each |
| 38 | const MAX_EXPORT_ROW_COUNT = 500000 |
| 39 | const MAX_EXPORT_ROW_COUNT_MESSAGE = ( |
| 40 | <p> |
| 41 | Sorry! We're unable to support exporting row counts larger than{' '} |
| 42 | {MAX_EXPORT_ROW_COUNT.toLocaleString('en-US')} at the moment. Alternatively, you may consider |
| 43 | using <InlineLink href={`${DOCS_URL}/reference/cli/briven-db-dump`}>pg_dump</InlineLink> via |
| 44 | our CLI instead. |
| 45 | </p> |
| 46 | ) |
| 47 | |
| 48 | type OutputCallbacks = { |
| 49 | convertToOutputFormat: (formattedRows: Record<string, unknown>[], table: SupaTable) => string |
| 50 | convertToBlob: (str: string) => Blob |
| 51 | save: (blob: Blob, table: SupaTable) => void |
| 52 | } |
| 53 | |
| 54 | type FetchAllRowsParams = { |
| 55 | queryClient: QueryClient |
| 56 | projectRef: string |
| 57 | connectionString: string | null |
| 58 | entity: Pick<Entity, 'id' | 'name' | 'type'> |
| 59 | bypassConfirmation: boolean |
| 60 | filters?: Filter[] |
| 61 | sorts?: Sort[] |
| 62 | roleImpersonationState?: RoleImpersonationState |
| 63 | totalRows?: number |
| 64 | startCallback?: () => void |
| 65 | progressCallback?: (progress: number) => void |
| 66 | } & OutputCallbacks |
| 67 | |
| 68 | type FetchAllRowsReturn = |
| 69 | | { status: 'require_confirmation'; reason: string } |
| 70 | | { status: 'error'; error: ExportAllRowsErrorFamily } |
| 71 | | { status: 'success'; rowsExported: number } |
| 72 | |
| 73 | const fetchAllRows = async ({ |
| 74 | queryClient, |
| 75 | projectRef, |
| 76 | connectionString, |
| 77 | entity, |
| 78 | bypassConfirmation, |
| 79 | filters, |
| 80 | sorts, |
| 81 | roleImpersonationState, |
| 82 | totalRows, |
| 83 | startCallback, |
| 84 | progressCallback, |
| 85 | convertToOutputFormat, |
| 86 | convertToBlob, |
| 87 | save, |
| 88 | }: FetchAllRowsParams): Promise<FetchAllRowsReturn> => { |
| 89 | if (IS_PLATFORM && !connectionString) { |
| 90 | return { status: 'error', error: new NoConnectionStringError() } |
| 91 | } |
| 92 | |
| 93 | let table: TableEditorData | undefined |
| 94 | try { |
| 95 | table = await queryClient.ensureQueryData({ |
| 96 | // Query is the same even if connectionString changes |
| 97 | // eslint-disable-next-line @tanstack/query/exhaustive-deps |
| 98 | queryKey: tableEditorKeys.tableEditor(projectRef, entity.id), |
| 99 | queryFn: ({ signal }) => |
| 100 | getTableEditor({ projectRef, connectionString, id: entity.id }, signal), |
| 101 | }) |
| 102 | } catch (error: unknown) { |
| 103 | return { status: 'error', error: new TableDetailsFetchError(entity.name, error) } |
| 104 | } |
| 105 | |
| 106 | if (!table) { |
| 107 | return { status: 'error', error: new NoTableError(entity.name) } |
| 108 | } |
| 109 | |
| 110 | const type = table.entity_type |
| 111 | if (type === ENTITY_TYPE.VIEW && !bypassConfirmation) { |
| 112 | return { |
| 113 | status: 'require_confirmation', |
| 114 | reason: `Exporting a view may cause consistency issues or performance issues on very large views. If possible, we recommend exporting the underlying table instead.`, |
| 115 | } |
| 116 | } else if (type === ENTITY_TYPE.MATERIALIZED_VIEW && !bypassConfirmation) { |
| 117 | return { |
| 118 | status: 'require_confirmation', |
| 119 | reason: `Exporting a materialized view may cause performance issues on very large views. If possible, we recommend exporting the underlying table instead.`, |
| 120 | } |
| 121 | } else if (type === ENTITY_TYPE.FOREIGN_TABLE && !bypassConfirmation) { |
| 122 | return { |
| 123 | status: 'require_confirmation', |
| 124 | reason: `Exporting a foreign table may cause consistency issues or performance issues on very large tables.`, |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | if (totalRows !== undefined) { |
| 129 | if (totalRows > MAX_EXPORT_ROW_COUNT) { |
| 130 | return { |
| 131 | status: 'error', |
| 132 | error: new TableTooLargeError(table.name, totalRows, MAX_EXPORT_ROW_COUNT), |
| 133 | } |
| 134 | } |
| 135 | } else if (isTableLike(table) && table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) { |
| 136 | return { |
| 137 | status: 'error', |
| 138 | error: new TableTooLargeError(table.name, table.live_rows_estimate, MAX_EXPORT_ROW_COUNT), |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | const supaTable = parseSupaTable(table) |
| 143 | |
| 144 | const primaryKey = supaTable.primaryKey |
| 145 | if (!primaryKey && !bypassConfirmation) { |
| 146 | return { |
| 147 | status: 'require_confirmation', |
| 148 | reason: `This table does not have a primary key defined, which may cause performance issues when exporting very large tables.`, |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | startCallback?.() |
| 153 | |
| 154 | let rows: Record<string, unknown>[] |
| 155 | try { |
| 156 | rows = await fetchAllTableRows({ |
| 157 | projectRef, |
| 158 | connectionString, |
| 159 | table: supaTable, |
| 160 | filters, |
| 161 | sorts, |
| 162 | roleImpersonationState, |
| 163 | progressCallback, |
| 164 | }) |
| 165 | } catch (error: unknown) { |
| 166 | return { status: 'error', error: new FetchRowsError(supaTable.name, error) } |
| 167 | } |
| 168 | |
| 169 | if (rows.length === 0) { |
| 170 | return { status: 'error', error: new NoRowsToExportError(entity.name) } |
| 171 | } |
| 172 | const formattedRows = formatRowsForExport(rows, supaTable) |
| 173 | |
| 174 | return convertAndDownload(formattedRows, supaTable, { |
| 175 | convertToOutputFormat, |
| 176 | convertToBlob, |
| 177 | save, |
| 178 | }) |
| 179 | } |
| 180 | |
| 181 | const formatRowsForExport = (rows: Record<string, unknown>[], table: SupaTable) => { |
| 182 | return rows.map((row) => { |
| 183 | const formattedRow = { ...row } |
| 184 | Object.keys(row).map((column) => { |
| 185 | if (column === 'idx' && !table.columns.some((col) => col.name === 'idx')) { |
| 186 | // When we fetch this data from the database, we automatically add an |
| 187 | // 'idx' column if none exists. We shouldn't export this column since |
| 188 | // it's not actually part of the user's table. |
| 189 | delete formattedRow[column] |
| 190 | return |
| 191 | } |
| 192 | |
| 193 | if (typeof row[column] === 'object' && row[column] !== null) |
| 194 | formattedRow[column] = JSON.stringify(formattedRow[column]) |
| 195 | }) |
| 196 | return formattedRow |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | const convertAndDownload = ( |
| 201 | formattedRows: Record<string, unknown>[], |
| 202 | table: SupaTable, |
| 203 | callbacks: OutputCallbacks |
| 204 | ): |
| 205 | | { status: 'error'; error: ExportAllRowsErrorFamily } |
| 206 | | { status: 'success'; rowsExported: number } => { |
| 207 | let output: string |
| 208 | try { |
| 209 | output = callbacks.convertToOutputFormat(formattedRows, table) |
| 210 | } catch (error: unknown) { |
| 211 | return { status: 'error', error: new OutputConversionError(error) } |
| 212 | } |
| 213 | let data: Blob |
| 214 | try { |
| 215 | data = callbacks.convertToBlob(output) |
| 216 | } catch (error: unknown) { |
| 217 | return { status: 'error', error: new BlobCreationError(error) } |
| 218 | } |
| 219 | try { |
| 220 | callbacks.save(data, table) |
| 221 | } catch (error: unknown) { |
| 222 | return { status: 'error', error: new DownloadSaveError(error) } |
| 223 | } |
| 224 | |
| 225 | return { |
| 226 | status: 'success', |
| 227 | rowsExported: formattedRows.length, |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | type UseExportAllRowsParams = |
| 232 | | { enabled: false } |
| 233 | | ({ |
| 234 | enabled: true |
| 235 | projectRef: string |
| 236 | connectionString: string | null |
| 237 | entity: Pick<Entity, 'id' | 'name' | 'type'> |
| 238 | /** |
| 239 | * If known, the total number of rows that will be exported. |
| 240 | * This is used to show progress percentage during export. |
| 241 | */ |
| 242 | totalRows?: number |
| 243 | } & ( |
| 244 | | { |
| 245 | /** |
| 246 | * Rows need to be fetched from the database. |
| 247 | */ |
| 248 | type: 'fetch_all' |
| 249 | filters?: Filter[] |
| 250 | sorts?: Sort[] |
| 251 | roleImpersonationState?: RoleImpersonationState |
| 252 | } |
| 253 | | { |
| 254 | /** |
| 255 | * Rows are already available and provided directly. |
| 256 | */ |
| 257 | type: 'provided_rows' |
| 258 | table: SupaTable |
| 259 | rows: Record<string, unknown>[] |
| 260 | } |
| 261 | )) |
| 262 | |
| 263 | type UseExportAllRowsReturn = { |
| 264 | exportInDesiredFormat: () => Promise<void> |
| 265 | confirmationModal: ReactNode | null |
| 266 | } |
| 267 | |
| 268 | export const useExportAllRowsGeneric = ( |
| 269 | params: UseExportAllRowsParams & OutputCallbacks |
| 270 | ): UseExportAllRowsReturn => { |
| 271 | const queryClient = useQueryClient() |
| 272 | const { |
| 273 | startProgressTracker, |
| 274 | trackPercentageProgress, |
| 275 | stopTrackerWithError, |
| 276 | dismissTrackerSilently, |
| 277 | markTrackerComplete, |
| 278 | } = useProgressToasts() |
| 279 | |
| 280 | const { convertToOutputFormat, convertToBlob, save } = params |
| 281 | |
| 282 | const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null) |
| 283 | |
| 284 | const exportInternal = useStaticEffectEvent( |
| 285 | async ({ bypassConfirmation }: { bypassConfirmation: boolean }): Promise<void> => { |
| 286 | if (!params.enabled) return |
| 287 | |
| 288 | const { projectRef, connectionString, entity, totalRows } = params |
| 289 | |
| 290 | const exportResult = |
| 291 | params.type === 'provided_rows' |
| 292 | ? convertAndDownload(formatRowsForExport(params.rows, params.table), params.table, { |
| 293 | convertToOutputFormat, |
| 294 | convertToBlob, |
| 295 | save, |
| 296 | }) |
| 297 | : await fetchAllRows({ |
| 298 | queryClient, |
| 299 | projectRef: projectRef, |
| 300 | connectionString: connectionString, |
| 301 | entity: entity, |
| 302 | bypassConfirmation, |
| 303 | filters: params.filters, |
| 304 | sorts: params.sorts, |
| 305 | roleImpersonationState: params.roleImpersonationState, |
| 306 | totalRows: params.totalRows, |
| 307 | startCallback: () => { |
| 308 | startProgressTracker({ |
| 309 | id: entity.id, |
| 310 | name: entity.name, |
| 311 | trackPercentage: totalRows !== undefined, |
| 312 | }) |
| 313 | }, |
| 314 | progressCallback: totalRows |
| 315 | ? (value: number) => |
| 316 | trackPercentageProgress({ |
| 317 | id: entity.id, |
| 318 | name: entity.name, |
| 319 | totalRows: totalRows, |
| 320 | value, |
| 321 | }) |
| 322 | : undefined, |
| 323 | convertToOutputFormat, |
| 324 | convertToBlob, |
| 325 | save, |
| 326 | }) |
| 327 | |
| 328 | if (exportResult.status === 'error') { |
| 329 | const error = exportResult.error |
| 330 | if (error instanceof NoRowsToExportError) { |
| 331 | return stopTrackerWithError( |
| 332 | entity.id, |
| 333 | entity.name, |
| 334 | `The table ${entity.name} has no rows to export.` |
| 335 | ) |
| 336 | } |
| 337 | if (error instanceof TableTooLargeError) { |
| 338 | return stopTrackerWithError(entity.id, entity.name, MAX_EXPORT_ROW_COUNT_MESSAGE) |
| 339 | } |
| 340 | console.error( |
| 341 | `Export All Rows > Error: %s%s%s`, |
| 342 | error.message, |
| 343 | error.cause?.message ? `\n${error.cause.message}` : '', |
| 344 | error.cause?.stack ? `:\n${error.cause.stack}` : '' |
| 345 | ) |
| 346 | return stopTrackerWithError(entity.id, entity.name) |
| 347 | } |
| 348 | |
| 349 | if (exportResult.status === 'require_confirmation') { |
| 350 | return setConfirmationMessage(exportResult.reason) |
| 351 | } |
| 352 | |
| 353 | markTrackerComplete(entity.id, exportResult.rowsExported) |
| 354 | } |
| 355 | ) |
| 356 | |
| 357 | const exportInDesiredFormat = useCallback( |
| 358 | () => exportInternal({ bypassConfirmation: false }), |
| 359 | [exportInternal] |
| 360 | ) |
| 361 | |
| 362 | const onConfirmExport = () => { |
| 363 | exportInternal({ |
| 364 | bypassConfirmation: true, |
| 365 | }) |
| 366 | setConfirmationMessage(null) |
| 367 | } |
| 368 | const onCancelExport = () => { |
| 369 | if (!params.enabled) return |
| 370 | |
| 371 | dismissTrackerSilently(params.entity.id) |
| 372 | setConfirmationMessage(null) |
| 373 | } |
| 374 | |
| 375 | return { |
| 376 | exportInDesiredFormat, |
| 377 | confirmationModal: confirmationMessage ? ( |
| 378 | <ConfirmationModal |
| 379 | title="Confirm to export data" |
| 380 | visible={true} |
| 381 | onCancel={onCancelExport} |
| 382 | onConfirm={onConfirmExport} |
| 383 | alert={{ |
| 384 | base: { className: '[&>div>div>h5]:font-normal border-x-0 border-t-0 rounded-none mb-0' }, |
| 385 | title: confirmationMessage, |
| 386 | }} |
| 387 | /> |
| 388 | ) : null, |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | type UseExportAllRowsAsCsvReturn = { |
| 393 | exportCsv: () => Promise<void> |
| 394 | confirmationModal: ReactNode | null |
| 395 | } |
| 396 | |
| 397 | export const useExportAllRowsAsCsv = ( |
| 398 | params: UseExportAllRowsParams |
| 399 | ): UseExportAllRowsAsCsvReturn => { |
| 400 | const { exportInDesiredFormat: exportCsv, confirmationModal } = useExportAllRowsGeneric({ |
| 401 | ...params, |
| 402 | convertToOutputFormat: (formattedRows, table) => |
| 403 | Papa.unparse(formattedRows, { |
| 404 | columns: table.columns.map((col) => col.name), |
| 405 | }), |
| 406 | convertToBlob: (csv) => new Blob([csv], { type: 'text/csv;charset=utf-8;' }), |
| 407 | save: (csvData, table) => saveAs(csvData, `${table.name}_rows.csv`), |
| 408 | }) |
| 409 | |
| 410 | return { |
| 411 | exportCsv, |
| 412 | confirmationModal, |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | type UseExportAllRowsAsSqlReturn = { |
| 417 | exportSql: () => Promise<void> |
| 418 | confirmationModal: ReactNode | null |
| 419 | } |
| 420 | |
| 421 | export const useExportAllRowsAsSql = ( |
| 422 | params: UseExportAllRowsParams |
| 423 | ): UseExportAllRowsAsSqlReturn => { |
| 424 | const { exportInDesiredFormat: exportSql, confirmationModal } = useExportAllRowsGeneric({ |
| 425 | ...params, |
| 426 | convertToOutputFormat: (formattedRows, table) => formatTableRowsToSQL(table, formattedRows), |
| 427 | convertToBlob: (sqlStatements) => |
| 428 | new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' }), |
| 429 | save: (sqlData, table) => saveAs(sqlData, `${table.name}_rows.sql`), |
| 430 | }) |
| 431 | |
| 432 | return { |
| 433 | exportSql, |
| 434 | confirmationModal, |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | type UseExportAllRowsAsJsonReturn = { |
| 439 | exportJson: () => Promise<void> |
| 440 | confirmationModal: ReactNode | null |
| 441 | } |
| 442 | |
| 443 | export const useExportAllRowsAsJson = ( |
| 444 | params: UseExportAllRowsParams |
| 445 | ): UseExportAllRowsAsJsonReturn => { |
| 446 | const { exportInDesiredFormat: exportJson, confirmationModal } = useExportAllRowsGeneric({ |
| 447 | ...params, |
| 448 | convertToOutputFormat: (formattedRows) => JSON.stringify(formattedRows), |
| 449 | convertToBlob: (jsonStr) => new Blob([jsonStr], { type: 'application/json;charset=utf-8;' }), |
| 450 | save: (jsonData, table) => saveAs(jsonData, `${table.name}_rows.json`), |
| 451 | }) |
| 452 | |
| 453 | return { |
| 454 | exportJson, |
| 455 | confirmationModal, |
| 456 | } |
| 457 | } |