diff --git a/src/components/gui/sidebar/tools-sidebar.tsx b/src/components/gui/sidebar/tools-sidebar.tsx index bfb1b30d..8a2c37fd 100644 --- a/src/components/gui/sidebar/tools-sidebar.tsx +++ b/src/components/gui/sidebar/tools-sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import { scc } from "@/core/command"; import ListButtonItem from "../list-button-item"; -import { StackMinus, TreeStructure } from "@phosphor-icons/react"; +import { DownloadSimple, StackMinus, TreeStructure } from "@phosphor-icons/react"; export default function SettingSidebar() { return ( @@ -20,6 +20,13 @@ export default function SettingSidebar() { }} icon={StackMinus} /> + { + scc.tabs.openBuiltinDumpDatabase({}); + }} + icon={DownloadSimple} + /> ); } diff --git a/src/components/gui/tabs/dump-database-tab.tsx b/src/components/gui/tabs/dump-database-tab.tsx new file mode 100644 index 00000000..b5c00d8e --- /dev/null +++ b/src/components/gui/tabs/dump-database-tab.tsx @@ -0,0 +1,119 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { useStudioContext } from "@/context/driver-provider"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +function downloadBlob( + data: Blob | Uint8Array | ArrayBuffer | string, + filename: string, + mimeType?: string +) { + + let blob: Blob; + if (data instanceof Blob) { + blob = mimeType && data.type !== mimeType ? new Blob([data], { type: mimeType }) : data; + } else if (data instanceof Uint8Array) { + const bytes = new Uint8Array(data); // ensure ArrayBuffer-backed copy + blob = new Blob([bytes.buffer], { type: mimeType || "application/octet-stream" }); + } else if (data instanceof ArrayBuffer) { + blob = new Blob([data], { type: mimeType || "application/octet-stream" }); + } else { + blob = new Blob([data], { type: mimeType || "text/plain;charset=utf-8" }); + } + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + a.style.display = "none"; + document.body.appendChild(a); + + // Some browsers (Firefox, Safari) require the element to be in the DOM + // and the revocation to occur after the event loop tick. + const supportsDownload = typeof a.download !== "undefined"; + if (!supportsDownload) { + window.open(url, "_blank"); + // Best-effort cleanup later if we can't rely on download attribute + setTimeout(() => URL.revokeObjectURL(url), 60_000); + document.body.removeChild(a); + return; + } + + a.click(); + + // Cleanup on next tick to avoid revoking too early + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 0); +} + +export default function DumpDatabaseTab() { + const { databaseDriver, name } = useStudioContext(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const supported = useMemo(() => { + try { + const flags = databaseDriver.getFlags?.(); + return !!(flags && (flags as any).supportDumpDatabase); + } catch { + return false; + } + }, [databaseDriver]); + + const onDump = useCallback(async () => { + setError(null); + setLoading(true); + try { + // Prefer driver-provided dump if available + const anyDriver = databaseDriver as unknown as { + dumpDatabase?: () => Promise<{ data: Blob | Uint8Array | ArrayBuffer | string; filename?: string; mimeType?: string }>; + }; + + if (!anyDriver.dumpDatabase) { + throw new Error("Dump is not supported for this database"); + } + + const result = await anyDriver.dumpDatabase(); + const ts = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .replace("T", "_") + .replace("Z", ""); + const filename = result.filename || `${name || "database"}-${ts}.db`; + + downloadBlob(result.data, filename, result.mimeType); + toast.success("Database dump downloaded"); + } catch (e: any) { + const msg = e?.message || "Failed to dump database"; + setError(msg); + toast.error(msg); + } finally { + setLoading(false); + } + }, [databaseDriver, name]); + + return ( +
+
+

Dump Database to File

+
+ Download a full backup of your database as a file. +
+
+ +
+
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/src/core/builtin-tab/open-dump-database.tsx b/src/core/builtin-tab/open-dump-database.tsx new file mode 100644 index 00000000..4a7d1434 --- /dev/null +++ b/src/core/builtin-tab/open-dump-database.tsx @@ -0,0 +1,13 @@ +import DumpDatabaseTab from "@/components/gui/tabs/dump-database-tab"; +import { DownloadSimple } from "@phosphor-icons/react"; +import { createTabExtension } from "../extension-tab"; + +export const builtinOpenDumpDatabaseTab = createTabExtension({ + name: "dump-database", + key: () => "", + generate: () => ({ + title: "Dump Database", + component: , + icon: DownloadSimple, + }), +}); diff --git a/src/core/command/index.ts b/src/core/command/index.ts index 58d6b6d6..60d218b7 100644 --- a/src/core/command/index.ts +++ b/src/core/command/index.ts @@ -5,6 +5,7 @@ import { builtinOpenERDTab } from "../builtin-tab/open-erd-tab"; import { builtinMassDropTableTab } from "../builtin-tab/open-mass-drop-table"; +import { builtinOpenDumpDatabaseTab } from "../builtin-tab/open-dump-database"; import { builtinOpenQueryTab } from "../builtin-tab/open-query-tab"; import { builtinOpenSchemaTab } from "../builtin-tab/open-schema-tab"; import { builtinOpenTableTab } from "../builtin-tab/open-table-tab"; @@ -17,6 +18,7 @@ export const scc = { openBuiltinSchema: builtinOpenSchemaTab.open, openBuiltinERD: builtinOpenERDTab.open, openBuiltinMassDropTable: builtinMassDropTableTab.open, + openBuiltinDumpDatabase: builtinOpenDumpDatabaseTab.open, close: (keys: string[]) => { tabCloseChannel.send(keys); diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index f9c70fce..5b83d10a 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -244,6 +244,9 @@ export interface DriverFlags { supportRowId: boolean; supportCreateUpdateDatabase: boolean; supportCreateUpdateTrigger: boolean; + + // Optional capabilities + supportDumpDatabase?: boolean; } export interface DatabaseTableColumnChange { @@ -354,4 +357,12 @@ export abstract class BaseDriver { abstract dropView(schemaName: string, name: string): string; abstract view(schemaName: string, name: string): Promise; + + // Optional: Drivers that support full database dump can override this. + // Default implementation throws to indicate unsupported capability. + abstract dumpDatabase(): Promise<{ + data: Blob | Uint8Array | ArrayBuffer | string; + filename?: string; + mimeType?: string; + }> } diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index 6bbb2b64..ccb97208 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -181,6 +181,7 @@ export default class MySQLLikeDriver extends CommonSQLImplement { supportInsertReturning: false, supportUpdateReturning: false, supportCreateUpdateTrigger: true, + supportDumpDatabase: false, }; } @@ -448,13 +449,13 @@ export default class MySQLLikeDriver extends CommonSQLImplement { foreignKey: constraint.CONSTRAINT_TYPE === "FOREIGN KEY" ? { - columns: columnList.map((c) => c.COLUMN_NAME), - foreignColumns: columnList.map( - (c) => c.REFERENCED_COLUMN_NAME - ), - foreignSchemaName: columnList[0].REFERENCED_TABLE_SCHEMA, - foreignTableName: columnList[0].REFERENCED_TABLE_NAME, - } + columns: columnList.map((c) => c.COLUMN_NAME), + foreignColumns: columnList.map( + (c) => c.REFERENCED_COLUMN_NAME + ), + foreignSchemaName: columnList[0].REFERENCED_TABLE_SCHEMA, + foreignTableName: columnList[0].REFERENCED_TABLE_NAME, + } : undefined, }; } @@ -550,4 +551,8 @@ export default class MySQLLikeDriver extends CommonSQLImplement { inferTypeFromHeader(): ColumnType | undefined { return undefined; } + + dumpDatabase(): Promise<{ data: Blob | Uint8Array | ArrayBuffer | string; filename?: string; mimeType?: string; }> { + throw new Error("MySQL dump is not supported."); + } } diff --git a/src/drivers/postgres/postgres-driver.ts b/src/drivers/postgres/postgres-driver.ts index bf401ac1..7e1bcd0c 100644 --- a/src/drivers/postgres/postgres-driver.ts +++ b/src/drivers/postgres/postgres-driver.ts @@ -106,6 +106,7 @@ export default class PostgresLikeDriver extends CommonSQLImplement { supportUpdateReturning: true, supportCreateUpdateTrigger: false, supportUseStatement: true, + supportDumpDatabase: false, }; } @@ -435,4 +436,8 @@ WHERE inferTypeFromHeader(): ColumnType | undefined { return undefined; } + + dumpDatabase(): Promise<{ data: Blob | Uint8Array | ArrayBuffer | string; filename?: string; mimeType?: string; }> { + throw new Error("Not implemented"); + } } diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index 388b8efd..15f80c6b 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -92,6 +92,7 @@ export class SqliteLikeBaseDriver extends CommonSQLImplement { supportCreateUpdateTable: true, supportCreateUpdateDatabase: false, supportCreateUpdateTrigger: true, + supportDumpDatabase: true, dialect: "sqlite", }; } @@ -384,4 +385,85 @@ export class SqliteLikeBaseDriver extends CommonSQLImplement { schema, }; } + + async dumpDatabase(): Promise<{ data: Blob | Uint8Array | ArrayBuffer | string; filename?: string; mimeType?: string; }> { + const schemas = await this.schemas(); + const sqlStatements: string[] = []; + + // Add header comment + sqlStatements.push('-- SQLite database dump'); + sqlStatements.push('-- Generated by SQLite Base Driver'); + sqlStatements.push(''); + + // Process each schema + for (const [schemaName, schemaItems] of Object.entries(schemas)) { + if (schemaName !== 'main') { + sqlStatements.push(`-- Schema: ${schemaName}`); + } + + // Export tables first + for (const item of schemaItems.filter(i => i.type === 'table')) { + try { + // Get table schema and data + const tableSchema = await this.tableSchema(schemaName, item.name); + + // Add CREATE TABLE statement + if (tableSchema.createScript) { + sqlStatements.push(tableSchema.createScript + ';'); + } + + // Export table data + const result = await this.query( + `SELECT * FROM ${this.escapeId(schemaName)}.${this.escapeId(item.name)}` + ); + + if (result.rows.length > 0) { + const columnNames = result.headers.map(h => this.escapeId(h.name)).join(', '); + + for (const row of result.rows) { + const values = Object.values(row).map(val => escapeSqlValue(val)).join(', '); + sqlStatements.push( + `INSERT INTO ${this.escapeId(item.name)} (${columnNames}) VALUES (${values});` + ); + } + } + + sqlStatements.push(''); + } catch (error) { + sqlStatements.push(`-- Error exporting table ${item.name}: ${error}`); + } + } + + // Export views + for (const item of schemaItems.filter(i => i.type === 'view')) { + try { + const viewSchema = await this.view(schemaName, item.name); + sqlStatements.push(this.createView(viewSchema) + ';'); + sqlStatements.push(''); + } catch (error) { + sqlStatements.push(`-- Error exporting view ${item.name}: ${error}`); + } + } + + // Export triggers + for (const item of schemaItems.filter(i => i.type === 'trigger')) { + try { + const triggerSchema = await this.trigger(schemaName, item.name); + sqlStatements.push(this.createTrigger(triggerSchema) + ';'); + sqlStatements.push(''); + } catch (error) { + sqlStatements.push(`-- Error exporting trigger ${item.name}: ${error}`); + } + } + } + + const sqlContent = sqlStatements.join('\n'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + return { + data: sqlContent, + filename: `database-dump-${timestamp}.sql`, + mimeType: 'application/sql' + }; + } }