mirror of
https://github.com/supabase/supabase.git
synced 2026-06-05 20:32:43 +08:00
feat/export-as-sql (#28670)
* feat/export-as-sql * Rename export items * EntityListItem change dropdown menu items for exporting data to a sub menu * Set width * Pull out SQL formatting logic into formatTableRowsToSQL and start a test * Fix export function * Do not stringify null values * Add comment * Add blank test cases * Wrap up formatTableRowsToSQL * Add max export row count validation to exporting actions in EntityListItem + add link to pg _dump docs --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
@@ -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(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
|
||||
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(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
|
||||
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) => {
|
||||
</div>
|
||||
<div className="h-[20px] border-r border-strong" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
icon={<Download />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
onClick={onRowsExportCSV}
|
||||
>
|
||||
Export to CSV
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
icon={<Download />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuItem onClick={onRowsExportCSV}>
|
||||
<span className="text-foreground-light">Export to CSV</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export to SQL</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{editable && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -187,7 +187,7 @@ const UtilityActions = ({
|
||||
{isFavorite ? 'Remove from' : 'Add to'} favorites
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={prettifyQuery}>
|
||||
<AlignLeft size={14} strokeWidth={2} />
|
||||
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
|
||||
Prettify SQL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -243,7 +243,7 @@ const UtilityActions = ({
|
||||
type="text"
|
||||
onClick={prettifyQuery}
|
||||
className="px-1"
|
||||
icon={<AlignLeft strokeWidth={2} />}
|
||||
icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />}
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">Prettify SQL</TooltipContent_Shadcn_>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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};`
|
||||
}
|
||||
|
||||
@@ -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<Entity, EntityListItemProps> = ({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
if (table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) {
|
||||
return toast.error(
|
||||
<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />,
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
|
||||
const supaTable =
|
||||
table &&
|
||||
parseSupaTable(
|
||||
@@ -140,6 +158,64 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />,
|
||||
{ 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<Entity, EntityListItemProps> = ({
|
||||
<DropdownMenuTrigger className="text-foreground-lighter transition-all hover:text-foreground data-[state=open]:text-foreground">
|
||||
<MoreHorizontal size={14} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
key="edit-table"
|
||||
className="space-x-2"
|
||||
@@ -324,21 +400,40 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
<span>View Policies</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="download-table-csv"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsCSV()
|
||||
}}
|
||||
>
|
||||
<Download size={12} />
|
||||
<span>Export as CSV</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-x-2">
|
||||
<Download size={12} />
|
||||
Export Data
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
key="download-table-csv"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsCSV()
|
||||
}}
|
||||
>
|
||||
<span>Export table as CSV</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="download-table-sql"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsSQL()
|
||||
}}
|
||||
>
|
||||
<span>Export table as SQL</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
key="delete-table"
|
||||
className="space-x-2"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onDeleteTable()
|
||||
|
||||
Reference in New Issue
Block a user