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) => {
-
}
- loading={isExporting}
- disabled={isExporting}
- onClick={onRowsExportCSV}
- >
- Export to CSV
-
+
+
+ }
+ loading={isExporting}
+ disabled={isExporting}
+ >
+ Export
+
+
+
+
+ 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()