diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 8e6a88a3c93..925b4db10a9 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -9,6 +9,7 @@ import toast from 'react-hot-toast' import { useDispatch, useTrackedState } from 'components/grid/store/Store' import type { Filter, Sort, SupaTable } from 'components/grid/types' +import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query' @@ -21,19 +22,21 @@ import { import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - cn, } from 'ui' import FilterPopover from './filter/FilterPopover' import { SortPopover } from './sort' +import { Markdown } from 'components/interfaces/Markdown' // [Joshen] CSV exports require this guard as a fail-safe if the table is // just too large for a browser to keep all the rows in memory before // exporting. Either that or export as multiple CSV sheets with max n rows each -const MAX_EXPORT_ROW_COUNT = 500000 +export const MAX_EXPORT_ROW_COUNT = 500000 +export const MAX_EXPORT_ROW_COUNT_MESSAGE = `Sorry! We're unable to support exporting row counts larger than ${MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using [pg_dump](https://supabase.com/docs/reference/cli/supabase-db-dump) via our CLI instead.` export type HeaderProps = { table: SupaTable @@ -288,9 +291,7 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => { setIsExporting(true) if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) { - toast.error( - `Sorry! We're unable to support exporting of CSV for row counts larger than ${MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment.` - ) + toast.error() return setIsExporting(false) } @@ -327,6 +328,35 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => { setIsExporting(false) } + async function onRowsExportSQL() { + setIsExporting(true) + + if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) { + toast.error() + return setIsExporting(false) + } + + if (!project) { + toast.error('Project is required') + return setIsExporting(false) + } + + const rows = allRowsSelected + ? await fetchAllTableRows({ + projectRef: project.ref, + connectionString: project.connectionString, + table, + filters, + sorts, + impersonatedRole: roleImpersonationState.role, + }) + : allRows.filter((x) => selectedRows.has(x.idx)) + + const sqlStatements = formatTableRowsToSQL(table, rows) + const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' }) + saveAs(sqlData, `${state.table!.name}_rows.sql`) + setIsExporting(false) + } function deselectRows() { dispatch({ type: 'SELECTED_ROWS_CHANGE', @@ -363,16 +393,26 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
- + + + + + + + Export to CSV + + Export to SQL + + + {editable && ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 3956521c7d2..939f49a82c6 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -187,7 +187,7 @@ const UtilityActions = ({ {isFavorite ? 'Remove from' : 'Add to'} favorites - + Prettify SQL @@ -243,7 +243,7 @@ const UtilityActions = ({ type="text" onClick={prettifyQuery} className="px-1" - icon={} + icon={} /> Prettify SQL diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts new file mode 100644 index 00000000000..e1869ef36e2 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts @@ -0,0 +1,115 @@ +import { formatTableRowsToSQL } from './TableEntity.utils' + +describe('TableEntity.utils: formatTableRowsToSQL', () => { + it('should format rows into a single SQL INSERT statement', () => { + const table = { + id: 1, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'name', dataType: 'text', format: 'text', position: 1 }, + ], + name: 'people', + schema: 'public', + comment: undefined, + estimateRowCount: 1, + } + const rows = [ + { id: 1, name: 'Person 1' }, + { id: 2, name: 'Person 2' }, + { id: 3, name: 'Person 3' }, + ] + + const result = formatTableRowsToSQL(table, rows) + const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2'), ('3', 'Person 3');` + expect(result).toBe(expected) + }) + + it('should not stringify null values', () => { + const table = { + id: 1, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'name', dataType: 'text', format: 'text', position: 1 }, + ], + name: 'people', + schema: 'public', + comment: undefined, + estimateRowCount: 1, + } + const rows = [ + { id: 1, name: 'Person 1' }, + { id: 2, name: null }, + { id: 3, name: 'Person 3' }, + ] + + const result = formatTableRowsToSQL(table, rows) + const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', null), ('3', 'Person 3');` + expect(result).toBe(expected) + }) + + it('should handle PG JSON and array columns', () => { + const table = { + id: 1, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'name', dataType: 'text', format: 'text', position: 1 }, + { name: 'tags', dataType: 'ARRAY', format: '_text', position: 2 }, + { name: 'metadata', dataType: 'jsonb', format: 'jsonb', position: 3 }, + ], + name: 'demo', + schema: 'public', + comment: undefined, + estimateRowCount: 1, + } + const rows = [ + { + idx: 1, + id: 2, + name: 'Person 1', + tags: ['tag-a', 'tag-c'], + metadata: '{"version": 1}', + }, + ] + const result = formatTableRowsToSQL(table, rows) + const expected = `INSERT INTO "public"."demo" ("id", "name", "tags", "metadata") VALUES ('2', 'Person 1', '{"tag-a","tag-c"}', '{"version": 1}');` + expect(result).toBe(expected) + }) + + it('should return an empty string for empty rows', () => { + const table = { + id: 1, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'name', dataType: 'text', format: 'text', position: 1 }, + ], + name: 'people', + schema: 'public', + comment: undefined, + estimateRowCount: 1, + } + const result = formatTableRowsToSQL(table, []) + expect(result).toBe('') + }) + + it('should remove the idx property', () => { + const table = { + id: 1, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'name', dataType: 'text', format: 'text', position: 1 }, + ], + name: 'people', + schema: 'public', + comment: undefined, + estimateRowCount: 1, + } + const rows = [ + { idx: 0, id: 1, name: 'Person 1' }, + { idx: 1, id: 2, name: 'Person 2' }, + ] + + const result = formatTableRowsToSQL(table, rows) + const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2');` + expect(result).toBe(expected) + }) +}) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts index 0bc8e258908..e464c8a17bc 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts @@ -1,3 +1,4 @@ +import { SupaTable } from 'components/grid/types' import { Lint } from '../../../data/lint/lint-query' export const getEntityLintDetails = ( @@ -22,3 +23,36 @@ export const getEntityLintDetails = ( matchingLint, } } + +export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => { + if (rows.length === 0) return '' + + const columns = table.columns.map((col) => `"${col.name}"`).join(', ') + + const valuesSets = rows + .map((row) => { + const filteredRow = { ...row } + if ('idx' in filteredRow) delete filteredRow.idx + + const values = Object.entries(filteredRow).map(([key, val]) => { + const { dataType, format } = table.columns.find((col) => col.name === key) ?? {} + + // We only check for NULL, array and JSON types, everything else we stringify + // given that Postgres can implicitly cast the right type based on the column type + if (val === null) { + return 'null' + } else if (dataType === 'ARRAY') { + return `'${JSON.stringify(val).replace('[', '{').replace(/.$/, '}')}'` + } else if (format?.includes('json')) { + return `${JSON.stringify(val).replace(/\\"/g, '"').replace('"', "'").replace(/.$/, "'")}` + } else { + return `'${val}'` + } + }) + + return `(${values.join(', ')})` + }) + .join(', ') + + return `INSERT INTO "${table.schema}"."${table.name}" (${columns}) VALUES ${valuesSets};` +} diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 38ea786d890..891aa5c975f 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -16,8 +16,15 @@ import Papa from 'papaparse' import toast from 'react-hot-toast' import { IS_PLATFORM } from 'common' +import { + MAX_EXPORT_ROW_COUNT, + MAX_EXPORT_ROW_COUNT_MESSAGE, +} from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' -import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' +import { + formatTableRowsToSQL, + getEntityLintDetails, +} from 'components/interfaces/TableGridEditor/TableEntity.utils' import type { ItemRenderer } from 'components/ui/InfiniteList' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import type { Entity } from 'data/entity-types/entity-type-query' @@ -26,14 +33,18 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { getTable } from 'data/tables/table-query' import { useTableEditorStateSnapshot } from 'state/table-editor' import { + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, - cn, } from 'ui' import { useProjectContext } from '../ProjectLayout/ProjectContext' +import { Markdown } from 'components/interfaces/Markdown' export interface EntityListItemProps { id: number @@ -100,6 +111,13 @@ const EntityListItem: ItemRenderer = ({ projectRef, connectionString: project?.connectionString, }) + if (table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) { + return toast.error( + , + { id: toastId } + ) + } + const supaTable = table && parseSupaTable( @@ -140,6 +158,64 @@ const EntityListItem: ItemRenderer = ({ } } + const exportTableAsSQL = async () => { + if (IS_PLATFORM && !project?.connectionString) { + return console.error('Connection string is required') + } + const toastId = toast.loading(`Exporting ${entity.name} as SQL...`) + + try { + const table = await getTable({ + id: entity.id, + projectRef, + connectionString: project?.connectionString, + }) + + if (table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) { + return toast.error( + , + { id: toastId } + ) + } + + const supaTable = + table && + parseSupaTable( + { + table: table, + columns: table.columns ?? [], + primaryKeys: table.primary_keys, + relationships: table.relationships, + }, + [] + ) + + const rows = await fetchAllTableRows({ + projectRef, + connectionString: project?.connectionString, + table: supaTable, + }) + const formattedRows = rows.map((row) => { + const formattedRow = row + Object.keys(row).map((column) => { + if (typeof row[column] === 'object' && row[column] !== null) + formattedRow[column] = JSON.stringify(formattedRow[column]) + }) + return formattedRow + }) + + if (formattedRows.length > 0) { + const sqlStatements = formatTableRowsToSQL(supaTable, formattedRows) + const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' }) + saveAs(sqlData, `${entity!.name}_rows.sql`) + } + + toast.success(`Successfully exported ${entity.name} as SQL`, { id: toastId }) + } catch (error: any) { + toast.error(`Failed to export table: ${error.message}`, { id: toastId }) + } + } + const EntityTooltipTrigger = ({ entity }: { entity: Entity }) => { let tooltipContent = null @@ -292,7 +368,7 @@ const EntityListItem: ItemRenderer = ({ - + = ({ View Policies - { - e.stopPropagation() - exportTableAsCSV() - }} - > - - Export as CSV - + + + + + Export Data + + + { + e.stopPropagation() + exportTableAsCSV() + }} + > + Export table as CSV + + { + e.stopPropagation() + exportTableAsSQL() + }} + > + Export table as SQL + + + + { e.stopPropagation() snap.onDeleteTable()