ExportAllRows.tsx 14 KB

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