Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/gui/sidebar/tools-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -20,6 +20,13 @@ export default function SettingSidebar() {
}}
icon={StackMinus}
/>
<ListButtonItem
text="Dump Database to File"
onClick={() => {
scc.tabs.openBuiltinDumpDatabase({});
}}
icon={DownloadSimple}
/>
</div>
);
}
119 changes: 119 additions & 0 deletions src/components/gui/tabs/dump-database-tab.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b pb-1">
<h1 className="text-primary mb-1 border-b p-4 text-lg font-semibold">Dump Database to File</h1>
<div className="px-4 pb-3 text-sm text-muted-foreground">
Download a full backup of your database as a file.
</div>
<div className="px-4 pb-4">
<Button disabled={loading || !supported} onClick={onDump}>
{loading ? "Preparing dump..." : supported ? "Dump to file" : "Not supported"}
</Button>
</div>
</div>
{error && (
<div className="p-4 text-sm text-red-500">
{error}
</div>
)}
</div>
);
}
13 changes: 13 additions & 0 deletions src/core/builtin-tab/open-dump-database.tsx
Original file line number Diff line number Diff line change
@@ -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: <DumpDatabaseTab />,
icon: DownloadSimple,
}),
});
2 changes: 2 additions & 0 deletions src/core/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/drivers/base-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ export interface DriverFlags {
supportRowId: boolean;
supportCreateUpdateDatabase: boolean;
supportCreateUpdateTrigger: boolean;

// Optional capabilities
supportDumpDatabase?: boolean;
}

export interface DatabaseTableColumnChange {
Expand Down Expand Up @@ -354,4 +357,12 @@ export abstract class BaseDriver {
abstract dropView(schemaName: string, name: string): string;

abstract view(schemaName: string, name: string): Promise<DatabaseViewSchema>;

// 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;
}>
}
19 changes: 12 additions & 7 deletions src/drivers/mysql/mysql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export default class MySQLLikeDriver extends CommonSQLImplement {
supportInsertReturning: false,
supportUpdateReturning: false,
supportCreateUpdateTrigger: true,
supportDumpDatabase: false,
};
}

Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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.");
}
}
5 changes: 5 additions & 0 deletions src/drivers/postgres/postgres-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export default class PostgresLikeDriver extends CommonSQLImplement {
supportUpdateReturning: true,
supportCreateUpdateTrigger: false,
supportUseStatement: true,
supportDumpDatabase: false,
};
}

Expand Down Expand Up @@ -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");
}
}
82 changes: 82 additions & 0 deletions src/drivers/sqlite-base-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class SqliteLikeBaseDriver extends CommonSQLImplement {
supportCreateUpdateTable: true,
supportCreateUpdateDatabase: false,
supportCreateUpdateTrigger: true,
supportDumpDatabase: true,
dialect: "sqlite",
};
}
Expand Down Expand Up @@ -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'
};
}
}