Add database SQL export action
This commit is contained in:
parent
5f8f7f3cde
commit
56a24b2bc7
|
|
@ -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'">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ export default {
|
|||
closeOtherTabs: "关闭其他标签页",
|
||||
closeAllTabs: "关闭全部标签页",
|
||||
copyName: "复制名称",
|
||||
exportDatabase: "导出数据库",
|
||||
exportDatabaseSuccess: "已导出 {count} 张表,每表最多 {limit} 行",
|
||||
exportDatabaseFailed: "导出数据库失败:{message}",
|
||||
exportData: "导出数据",
|
||||
exportStructure: "导出表结构",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue