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:
Terry Sutton
2024-08-19 05:38:36 -02:30
committed by GitHub
parent 644525e2d1
commit 9ecd5343ce
5 changed files with 316 additions and 32 deletions

View File

@@ -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>

View File

@@ -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_>

View File

@@ -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)
})
})

View File

@@ -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};`
}

View File

@@ -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()