Add database SQL export action

This commit is contained in:
SuLe 2026-05-02 13:58:04 +08:00 committed by rarnu
parent 5f8f7f3cde
commit 56a24b2bc7
5 changed files with 373 additions and 5 deletions

View File

@ -15,8 +15,16 @@ import {
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import type { TreeNode, TreeNodeType } from "@/types/database";
import type { DatabaseType, QueryResult, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/tauri";
import {
DATABASE_EXPORT_PAGE_SIZE,
DATABASE_EXPORT_ROW_LIMIT,
buildDatabaseSqlExport,
buildExportPageSql,
type ExportedTableSql,
} from "@/lib/databaseExport";
import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
@ -35,12 +43,30 @@ const props = defineProps<{
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
const isExportingDatabase = ref(false);
function currentDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
}
function quoteIdent(name: string): string {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return config?.db_type === "mysql"
? `\`${name.replace(/`/g, "``")}\``
: `"${name.replace(/"/g, '""')}"`;
return quoteTableIdentifier(currentDatabaseType(), name);
}
function isSchemaAwareDbType(dbType?: DatabaseType): boolean {
return dbType === "postgres" || dbType === "oracle" || dbType === "sqlserver";
}
function qualifiedTableName(tableName: string, schema?: string): string {
return buildQualifiedTableName({
databaseType: currentDatabaseType(),
schema,
tableName,
});
}
function safeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]+/g, "_").trim() || "database";
}
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
@ -239,6 +265,133 @@ function copyName() {
navigator.clipboard.writeText(props.node.label);
}
async function collectDatabaseExportTables(): Promise<Array<{ schema?: string; name: string; displayName: string }>> {
const node = props.node;
if (!node.connectionId || !node.database) return [];
const config = connectionStore.getConfig(node.connectionId);
if (node.type === "schema" && node.schema) {
const tables = await api.listTables(node.connectionId, node.database, node.schema);
return tables.map((table) => ({
schema: node.schema,
name: table.name,
displayName: `${node.schema}.${table.name}`,
}));
}
if (isSchemaAwareDbType(config?.db_type)) {
const schemas = await api.listSchemas(node.connectionId, node.database);
const groups = await Promise.all(
schemas.map(async (schema) => {
const tables = await api.listTables(node.connectionId!, node.database!, schema);
return tables.map((table) => ({
schema,
name: table.name,
displayName: `${schema}.${table.name}`,
}));
}),
);
return groups.flat();
}
const tables = await api.listTables(node.connectionId, node.database, node.database);
return tables.map((table) => ({
name: table.name,
displayName: table.name,
}));
}
async function fetchExportTableRows(
connectionId: string,
database: string,
table: { schema?: string; name: string },
databaseType?: DatabaseType,
): Promise<{ columns: QueryResult["columns"]; rows: QueryResult["rows"]; truncated: boolean }> {
const rows: QueryResult["rows"] = [];
let columns: QueryResult["columns"] = [];
let offset = 0;
while (rows.length < DATABASE_EXPORT_ROW_LIMIT) {
const remaining = DATABASE_EXPORT_ROW_LIMIT - rows.length;
const limit = databaseType === "sqlserver"
? DATABASE_EXPORT_ROW_LIMIT
: Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining);
const sql = buildExportPageSql({
databaseType,
schema: table.schema,
tableName: table.name,
limit,
offset: databaseType === "sqlserver" ? undefined : offset,
});
const result = await api.executeQuery(connectionId, database, sql);
if (columns.length === 0) columns = result.columns;
rows.push(...result.rows);
if (result.rows.length < limit || databaseType === "sqlserver") break;
offset += result.rows.length;
}
return {
columns,
rows,
truncated: rows.length >= DATABASE_EXPORT_ROW_LIMIT,
};
}
async function exportDatabase() {
const node = props.node;
if (!(node.type === "database" || node.type === "schema") || !node.connectionId || !node.database) return;
isExportingDatabase.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const config = connectionStore.getConfig(node.connectionId);
const tables = await collectDatabaseExportTables();
const exportedTables: ExportedTableSql[] = [];
for (const table of tables) {
const querySchema = table.schema || node.database;
const qualifiedName = qualifiedTableName(table.name, table.schema);
const ddl = await api.getTableDdl(node.connectionId, node.database, querySchema, table.name);
const result = await fetchExportTableRows(node.connectionId, node.database, table, config?.db_type);
exportedTables.push({
displayName: table.displayName,
qualifiedTableName: qualifiedName,
ddl,
columns: result.columns,
rows: result.rows,
truncated: result.truncated,
});
}
const scopeName = node.type === "schema" && node.schema
? `${node.database}.${node.schema}`
: node.database;
const content = buildDatabaseSqlExport({
databaseName: scopeName,
tables: exportedTables,
quoteIdentifier: quoteIdent,
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
});
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
defaultPath: `${safeFileName(scopeName)}.sql`,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) return;
await writeTextFile(path, content);
toast(t("contextMenu.exportDatabaseSuccess", { count: exportedTables.length, limit: DATABASE_EXPORT_ROW_LIMIT }), 3000);
} catch (e: any) {
console.error("Export database failed:", e);
toast(t("contextMenu.exportDatabaseFailed", { message: e?.message || String(e) }), 5000);
} finally {
isExportingDatabase.value = false;
}
}
async function exportStructure() {
const node = props.node;
if (!node.connectionId || !node.database) return;
@ -515,6 +668,11 @@ async function showMore() {
<ContextMenuItem @click="openSchemaDiff">
<ArrowRightLeft class="w-4 h-4" /> {{ t('diff.title') }}
</ContextMenuItem>
<ContextMenuItem :disabled="isExportingDatabase" @click="exportDatabase">
<Loader2 v-if="isExportingDatabase" class="w-4 h-4 animate-spin" />
<Download v-else class="w-4 h-4" />
{{ t('contextMenu.exportDatabase') }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'table' || node.type === 'view'">

View File

@ -219,6 +219,9 @@ export default {
closeOtherTabs: "Close Other Tabs",
closeAllTabs: "Close All Tabs",
copyName: "Copy Name",
exportDatabase: "Export Database",
exportDatabaseSuccess: "Exported {count} tables, up to {limit} rows each",
exportDatabaseFailed: "Failed to export database: {message}",
exportData: "Export Data",
exportStructure: "Export Structure",
},

View File

@ -219,6 +219,9 @@ export default {
closeOtherTabs: "关闭其他标签页",
closeAllTabs: "关闭全部标签页",
copyName: "复制名称",
exportDatabase: "导出数据库",
exportDatabaseSuccess: "已导出 {count} 张表,每表最多 {limit} 行",
exportDatabaseFailed: "导出数据库失败:{message}",
exportData: "导出数据",
exportStructure: "导出表结构",
},

108
src/lib/databaseExport.ts Normal file
View File

@ -0,0 +1,108 @@
import type { DatabaseType, QueryResult } from "../types/database.ts";
import { buildTableSelectSql } from "./tableSelectSql.ts";
type SqlValue = QueryResult["rows"][number][number];
export const DATABASE_EXPORT_ROW_LIMIT = 10_000;
export const DATABASE_EXPORT_PAGE_SIZE = 500;
export const DATABASE_EXPORT_INSERT_BATCH_SIZE = 100;
export interface ExportedTableSql {
displayName: string;
qualifiedTableName: string;
ddl?: string;
columns: string[];
rows: QueryResult["rows"];
truncated?: boolean;
}
export interface BuildDatabaseSqlExportOptions {
databaseName: string;
exportedAt?: Date;
tables: ExportedTableSql[];
quoteIdentifier: (name: string) => string;
rowLimitPerTable?: number;
insertBatchSize?: number;
}
export interface BuildExportPageSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
limit?: number;
offset?: number;
}
export function formatSqlLiteral(value: SqlValue): string {
if (value === null) return "NULL";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
return `'${String(value).replace(/'/g, "''")}'`;
}
export function buildInsertStatements(
table: Pick<ExportedTableSql, "qualifiedTableName" | "columns" | "rows"> & {
quoteIdentifier: (name: string) => string;
batchSize?: number;
},
): string[] {
if (table.columns.length === 0 || table.rows.length === 0) return [];
const batchSize = Math.max(1, table.batchSize ?? DATABASE_EXPORT_INSERT_BATCH_SIZE);
const columns = table.columns.map((column) => table.quoteIdentifier(column)).join(", ");
const statements: string[] = [];
for (let start = 0; start < table.rows.length; start += batchSize) {
const values = table.rows
.slice(start, start + batchSize)
.map((row) => `(${row.map(formatSqlLiteral).join(", ")})`)
.join(", ");
statements.push(`INSERT INTO ${table.qualifiedTableName} (${columns}) VALUES ${values};`);
}
return statements;
}
export function buildExportPageSql(options: BuildExportPageSqlOptions): string {
return buildTableSelectSql({
databaseType: options.databaseType,
schema: options.schema,
tableName: options.tableName,
limit: options.limit ?? DATABASE_EXPORT_PAGE_SIZE,
offset: options.offset,
});
}
export function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): string {
const exportedAt = options.exportedAt ?? new Date();
const rowLimit = options.rowLimitPerTable ?? DATABASE_EXPORT_ROW_LIMIT;
const insertBatchSize = options.insertBatchSize ?? DATABASE_EXPORT_INSERT_BATCH_SIZE;
const lines: string[] = [
"-- DBX database export",
`-- Database: ${options.databaseName}`,
`-- Exported at: ${exportedAt.toISOString()}`,
`-- Row limit per table: ${rowLimit}`,
"",
];
for (const table of options.tables) {
if (table.ddl?.trim()) {
lines.push(`-- Structure for ${table.displayName}`);
lines.push(table.ddl.trim().replace(/;*$/, ";"));
lines.push("");
}
lines.push(`-- Data for ${table.displayName}`);
lines.push(table.truncated
? `-- Exported rows: ${table.rows.length} (truncated at ${rowLimit})`
: `-- Exported rows: ${table.rows.length}`);
const inserts = buildInsertStatements({ ...table, quoteIdentifier: options.quoteIdentifier, batchSize: insertBatchSize });
if (inserts.length > 0) {
lines.push(...inserts);
} else {
lines.push("-- No rows");
}
lines.push("");
}
return lines.join("\n");
}

View File

@ -0,0 +1,96 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
DATABASE_EXPORT_INSERT_BATCH_SIZE,
DATABASE_EXPORT_ROW_LIMIT,
buildExportPageSql,
buildDatabaseSqlExport,
buildInsertStatements,
formatSqlLiteral,
} from "../src/lib/databaseExport.ts";
test("formats SQL literals for exported INSERT statements", () => {
assert.equal(formatSqlLiteral(null), "NULL");
assert.equal(formatSqlLiteral(42), "42");
assert.equal(formatSqlLiteral(true), "TRUE");
assert.equal(formatSqlLiteral("O'Hara"), "'O''Hara'");
});
test("builds batched INSERT statements for one exported table", () => {
const statements = buildInsertStatements({
qualifiedTableName: "`users`",
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "O'Hara"],
[3, "Linus"],
],
quoteIdentifier: (name) => `\`${name}\``,
batchSize: 2,
});
assert.deepEqual(statements, [
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');",
"INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');",
]);
});
test("builds capped export page queries", () => {
assert.equal(
buildExportPageSql({
databaseType: "mysql",
tableName: "users",
limit: 500,
offset: 1000,
}),
"SELECT * FROM `users` LIMIT 500 OFFSET 1000;",
);
assert.equal(
buildExportPageSql({
databaseType: "sqlserver",
schema: "dbo",
tableName: "accounts",
limit: DATABASE_EXPORT_ROW_LIMIT,
}),
`SELECT TOP ${DATABASE_EXPORT_ROW_LIMIT} * FROM "dbo"."accounts"`,
);
});
test("builds a database SQL export with DDL before data", () => {
const sql = buildDatabaseSqlExport({
databaseName: "app",
exportedAt: new Date("2026-05-02T00:00:00.000Z"),
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
tables: [
{
displayName: "users",
qualifiedTableName: "`users`",
ddl: "CREATE TABLE `users` (`id` int);",
columns: ["id"],
rows: [[1]],
truncated: true,
},
],
quoteIdentifier: (name) => `\`${name}\``,
insertBatchSize: DATABASE_EXPORT_INSERT_BATCH_SIZE,
});
assert.equal(
sql,
[
"-- DBX database export",
"-- Database: app",
"-- Exported at: 2026-05-02T00:00:00.000Z",
`-- Row limit per table: ${DATABASE_EXPORT_ROW_LIMIT}`,
"",
"-- Structure for users",
"CREATE TABLE `users` (`id` int);",
"",
"-- Data for users",
`-- Exported rows: 1 (truncated at ${DATABASE_EXPORT_ROW_LIMIT})`,
"INSERT INTO `users` (`id`) VALUES (1);",
"",
].join("\n"),
);
});