Skip to content

Commit 9ecd534

Browse files
saltcodjoshenlim
andauthored
feat/export-as-sql (supabase#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>
1 parent 644525e commit 9ecd534

File tree

5 files changed

+316
-32
lines changed

5 files changed

+316
-32
lines changed

apps/studio/components/grid/components/header/Header.tsx

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import toast from 'react-hot-toast'
99

1010
import { useDispatch, useTrackedState } from 'components/grid/store/Store'
1111
import type { Filter, Sort, SupaTable } from 'components/grid/types'
12+
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
1213
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
1314
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
1415
import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query'
@@ -21,19 +22,21 @@ import {
2122
import { useTableEditorStateSnapshot } from 'state/table-editor'
2223
import {
2324
Button,
25+
cn,
2426
DropdownMenu,
2527
DropdownMenuContent,
2628
DropdownMenuItem,
2729
DropdownMenuTrigger,
28-
cn,
2930
} from 'ui'
3031
import FilterPopover from './filter/FilterPopover'
3132
import { SortPopover } from './sort'
33+
import { Markdown } from 'components/interfaces/Markdown'
3234

3335
// [Joshen] CSV exports require this guard as a fail-safe if the table is
3436
// just too large for a browser to keep all the rows in memory before
3537
// exporting. Either that or export as multiple CSV sheets with max n rows each
36-
const MAX_EXPORT_ROW_COUNT = 500000
38+
export const MAX_EXPORT_ROW_COUNT = 500000
39+
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.`
3740

3841
export type HeaderProps = {
3942
table: SupaTable
@@ -288,9 +291,7 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
288291
setIsExporting(true)
289292

290293
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
291-
toast.error(
292-
`Sorry! We're unable to support exporting of CSV for row counts larger than ${MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment.`
293-
)
294+
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
294295
return setIsExporting(false)
295296
}
296297

@@ -327,6 +328,35 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
327328
setIsExporting(false)
328329
}
329330

331+
async function onRowsExportSQL() {
332+
setIsExporting(true)
333+
334+
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
335+
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
336+
return setIsExporting(false)
337+
}
338+
339+
if (!project) {
340+
toast.error('Project is required')
341+
return setIsExporting(false)
342+
}
343+
344+
const rows = allRowsSelected
345+
? await fetchAllTableRows({
346+
projectRef: project.ref,
347+
connectionString: project.connectionString,
348+
table,
349+
filters,
350+
sorts,
351+
impersonatedRole: roleImpersonationState.role,
352+
})
353+
: allRows.filter((x) => selectedRows.has(x.idx))
354+
355+
const sqlStatements = formatTableRowsToSQL(table, rows)
356+
const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' })
357+
saveAs(sqlData, `${state.table!.name}_rows.sql`)
358+
setIsExporting(false)
359+
}
330360
function deselectRows() {
331361
dispatch({
332362
type: 'SELECTED_ROWS_CHANGE',
@@ -363,16 +393,26 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
363393
</div>
364394
<div className="h-[20px] border-r border-strong" />
365395
<div className="flex items-center gap-2">
366-
<Button
367-
type="primary"
368-
size="tiny"
369-
icon={<Download />}
370-
loading={isExporting}
371-
disabled={isExporting}
372-
onClick={onRowsExportCSV}
373-
>
374-
Export to CSV
375-
</Button>
396+
<DropdownMenu>
397+
<DropdownMenuTrigger>
398+
<Button
399+
type="primary"
400+
size="tiny"
401+
icon={<Download />}
402+
loading={isExporting}
403+
disabled={isExporting}
404+
>
405+
Export
406+
</Button>
407+
</DropdownMenuTrigger>
408+
<DropdownMenuContent className="w-40">
409+
<DropdownMenuItem onClick={onRowsExportCSV}>
410+
<span className="text-foreground-light">Export to CSV</span>
411+
</DropdownMenuItem>
412+
<DropdownMenuItem onClick={onRowsExportSQL}>Export to SQL</DropdownMenuItem>
413+
</DropdownMenuContent>
414+
</DropdownMenu>
415+
376416
{editable && (
377417
<Tooltip.Root delayDuration={0}>
378418
<Tooltip.Trigger asChild>

apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ const UtilityActions = ({
187187
{isFavorite ? 'Remove from' : 'Add to'} favorites
188188
</DropdownMenuItem>
189189
<DropdownMenuItem className="gap-x-2" onClick={prettifyQuery}>
190-
<AlignLeft size={14} strokeWidth={2} />
190+
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
191191
Prettify SQL
192192
</DropdownMenuItem>
193193
</DropdownMenuContent>
@@ -243,7 +243,7 @@ const UtilityActions = ({
243243
type="text"
244244
onClick={prettifyQuery}
245245
className="px-1"
246-
icon={<AlignLeft strokeWidth={2} />}
246+
icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />}
247247
/>
248248
</TooltipTrigger_Shadcn_>
249249
<TooltipContent_Shadcn_ side="bottom">Prettify SQL</TooltipContent_Shadcn_>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { formatTableRowsToSQL } from './TableEntity.utils'
2+
3+
describe('TableEntity.utils: formatTableRowsToSQL', () => {
4+
it('should format rows into a single SQL INSERT statement', () => {
5+
const table = {
6+
id: 1,
7+
columns: [
8+
{ name: 'id', dataType: 'bigint', format: 'int8', position: 0 },
9+
{ name: 'name', dataType: 'text', format: 'text', position: 1 },
10+
],
11+
name: 'people',
12+
schema: 'public',
13+
comment: undefined,
14+
estimateRowCount: 1,
15+
}
16+
const rows = [
17+
{ id: 1, name: 'Person 1' },
18+
{ id: 2, name: 'Person 2' },
19+
{ id: 3, name: 'Person 3' },
20+
]
21+
22+
const result = formatTableRowsToSQL(table, rows)
23+
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2'), ('3', 'Person 3');`
24+
expect(result).toBe(expected)
25+
})
26+
27+
it('should not stringify null values', () => {
28+
const table = {
29+
id: 1,
30+
columns: [
31+
{ name: 'id', dataType: 'bigint', format: 'int8', position: 0 },
32+
{ name: 'name', dataType: 'text', format: 'text', position: 1 },
33+
],
34+
name: 'people',
35+
schema: 'public',
36+
comment: undefined,
37+
estimateRowCount: 1,
38+
}
39+
const rows = [
40+
{ id: 1, name: 'Person 1' },
41+
{ id: 2, name: null },
42+
{ id: 3, name: 'Person 3' },
43+
]
44+
45+
const result = formatTableRowsToSQL(table, rows)
46+
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', null), ('3', 'Person 3');`
47+
expect(result).toBe(expected)
48+
})
49+
50+
it('should handle PG JSON and array columns', () => {
51+
const table = {
52+
id: 1,
53+
columns: [
54+
{ name: 'id', dataType: 'bigint', format: 'int8', position: 0 },
55+
{ name: 'name', dataType: 'text', format: 'text', position: 1 },
56+
{ name: 'tags', dataType: 'ARRAY', format: '_text', position: 2 },
57+
{ name: 'metadata', dataType: 'jsonb', format: 'jsonb', position: 3 },
58+
],
59+
name: 'demo',
60+
schema: 'public',
61+
comment: undefined,
62+
estimateRowCount: 1,
63+
}
64+
const rows = [
65+
{
66+
idx: 1,
67+
id: 2,
68+
name: 'Person 1',
69+
tags: ['tag-a', 'tag-c'],
70+
metadata: '{"version": 1}',
71+
},
72+
]
73+
const result = formatTableRowsToSQL(table, rows)
74+
const expected = `INSERT INTO "public"."demo" ("id", "name", "tags", "metadata") VALUES ('2', 'Person 1', '{"tag-a","tag-c"}', '{"version": 1}');`
75+
expect(result).toBe(expected)
76+
})
77+
78+
it('should return an empty string for empty rows', () => {
79+
const table = {
80+
id: 1,
81+
columns: [
82+
{ name: 'id', dataType: 'bigint', format: 'int8', position: 0 },
83+
{ name: 'name', dataType: 'text', format: 'text', position: 1 },
84+
],
85+
name: 'people',
86+
schema: 'public',
87+
comment: undefined,
88+
estimateRowCount: 1,
89+
}
90+
const result = formatTableRowsToSQL(table, [])
91+
expect(result).toBe('')
92+
})
93+
94+
it('should remove the idx property', () => {
95+
const table = {
96+
id: 1,
97+
columns: [
98+
{ name: 'id', dataType: 'bigint', format: 'int8', position: 0 },
99+
{ name: 'name', dataType: 'text', format: 'text', position: 1 },
100+
],
101+
name: 'people',
102+
schema: 'public',
103+
comment: undefined,
104+
estimateRowCount: 1,
105+
}
106+
const rows = [
107+
{ idx: 0, id: 1, name: 'Person 1' },
108+
{ idx: 1, id: 2, name: 'Person 2' },
109+
]
110+
111+
const result = formatTableRowsToSQL(table, rows)
112+
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2');`
113+
expect(result).toBe(expected)
114+
})
115+
})

apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SupaTable } from 'components/grid/types'
12
import { Lint } from '../../../data/lint/lint-query'
23

34
export const getEntityLintDetails = (
@@ -22,3 +23,36 @@ export const getEntityLintDetails = (
2223
matchingLint,
2324
}
2425
}
26+
27+
export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => {
28+
if (rows.length === 0) return ''
29+
30+
const columns = table.columns.map((col) => `"${col.name}"`).join(', ')
31+
32+
const valuesSets = rows
33+
.map((row) => {
34+
const filteredRow = { ...row }
35+
if ('idx' in filteredRow) delete filteredRow.idx
36+
37+
const values = Object.entries(filteredRow).map(([key, val]) => {
38+
const { dataType, format } = table.columns.find((col) => col.name === key) ?? {}
39+
40+
// We only check for NULL, array and JSON types, everything else we stringify
41+
// given that Postgres can implicitly cast the right type based on the column type
42+
if (val === null) {
43+
return 'null'
44+
} else if (dataType === 'ARRAY') {
45+
return `'${JSON.stringify(val).replace('[', '{').replace(/.$/, '}')}'`
46+
} else if (format?.includes('json')) {
47+
return `${JSON.stringify(val).replace(/\\"/g, '"').replace('"', "'").replace(/.$/, "'")}`
48+
} else {
49+
return `'${val}'`
50+
}
51+
})
52+
53+
return `(${values.join(', ')})`
54+
})
55+
.join(', ')
56+
57+
return `INSERT INTO "${table.schema}"."${table.name}" (${columns}) VALUES ${valuesSets};`
58+
}

0 commit comments

Comments
 (0)