From c736fe372a744b975513c59c694a79fb6677ff71 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 22 May 2026 14:56:40 +0800 Subject: [PATCH] feat(export): move csv and xlsx generation to Rust backend --- .../src/components/sidebar/TreeItem.vue | 41 ++- .../src/composables/useDataGridExport.ts | 70 ++-- apps/desktop/src/lib/api.ts | 2 + apps/desktop/src/lib/http.ts | 42 +++ apps/desktop/src/lib/tauri.ts | 32 ++ crates/dbx-core/src/csv_export.rs | 45 +++ crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/xlsx_export.rs | 302 ++++++++++++++++++ packages/app-tests/csvBackendExport.test.ts | 35 ++ packages/app-tests/xlsxBackendExport.test.ts | 33 ++ src-tauri/src/commands/csv_export.rs | 17 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/xlsx_export.rs | 22 ++ src-tauri/src/lib.rs | 1 + 14 files changed, 591 insertions(+), 53 deletions(-) create mode 100644 crates/dbx-core/src/csv_export.rs create mode 100644 crates/dbx-core/src/xlsx_export.rs create mode 100644 packages/app-tests/csvBackendExport.test.ts create mode 100644 packages/app-tests/xlsxBackendExport.test.ts create mode 100644 src-tauri/src/commands/csv_export.rs create mode 100644 src-tauri/src/commands/xlsx_export.rs diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 80a26c3d6..93e7e455e 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -92,7 +92,7 @@ import { treeNodeRowAction, treeNodeRowDoubleClickAction, } from "@/lib/treeNodeClick"; -import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats"; +import { formatJson, formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { buildCreateDatabaseSql, @@ -1266,13 +1266,26 @@ async function exportData(format: "csv" | "json" | "sql") { executePage: (sql) => api.executeQuery(connectionId, database, sql), }); + if (format === "csv") { + let outputPath = `${node.label}.csv`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultCsv(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + return; + } + let content: string; let ext: string; - if (format === "csv") { - ext = "csv"; - content = formatCsv(result.columns, result.rows); - } else if (format === "json") { + if (format === "json") { ext = "json"; content = formatJson(result.columns, result.rows); } else { @@ -1311,13 +1324,17 @@ async function exportDataXlsx() { executePage: (sql) => api.executeQuery(connectionId, database, sql), }); - const { buildXlsxWorkbook } = await import("@/lib/xlsxExport"); - const workbook = buildXlsxWorkbook({ - sheetName: node.label, - columns: result.columns, - rows: result.rows, - }); - await saveBinaryFileContent(workbook, `${node.label}.xlsx`, "Excel", "xlsx"); + let outputPath = `${node.label}.xlsx`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "Excel", extensions: ["xlsx"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.rows); toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index e2a756a71..9454b8c22 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -1,7 +1,8 @@ import { computed, type ComputedRef, type Ref } from "vue"; import { useI18n } from "vue-i18n"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -import { formatCsv, formatJson } from "@/lib/exportFormats"; +import { formatJson } from "@/lib/exportFormats"; +import * as api from "@/lib/api"; import { formatSelectionAsCsv, formatSelectionAsJson, @@ -266,36 +267,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } } - async function saveBinaryFileContent( - content: Uint8Array, - defaultFileName: string, - filterName: string, - filterExt: string, - ): Promise { - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeFile } = await import("@tauri-apps/plugin-fs"); - const path = await save({ - defaultPath: defaultFileName, - filters: [{ name: filterName, extensions: [filterExt] }], - }); - if (!path) return false; - await writeFile(path, content); - return true; - } else { - const blob = new Blob([content], { - type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = defaultFileName; - a.click(); - URL.revokeObjectURL(url); - return true; - } - } - // --- Export functions --- async function runExclusiveExport(action: () => Promise) { const finish = tryStartExclusiveActivation(exportGuard); @@ -311,9 +282,18 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await runExclusiveExport(async () => { try { const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c))); - if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) { - toast(t("grid.exported")); + let outputPath = "export.csv"; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!path) return; + outputPath = path as string; } + await api.exportQueryResultCsv(outputPath, columns.value, rows); + toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); } @@ -352,15 +332,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function exportXlsx(rowIds?: number[]) { await runExclusiveExport(async () => { try { - const { buildXlsxWorkbook } = await import("@/lib/xlsxExport"); - const workbook = buildXlsxWorkbook({ - sheetName: tableMeta.value?.tableName || "Export", - columns: columns.value, - rows: rowsToExport(rowIds).map((item) => item.data), - }); - if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) { - toast(t("grid.exported")); + let outputPath = "export.xlsx"; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "Excel", extensions: ["xlsx"] }], + }); + if (!path) return; + outputPath = path as string; } + await api.exportQueryResultXlsx( + outputPath, + tableMeta.value?.tableName || "Export", + columns.value, + rowsToExport(rowIds).map((item) => item.data), + ); + toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 53723c62c..88a6666b1 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -128,6 +128,8 @@ export const cancelTableImport = forward("cancelTableImport"); // Database Export export const exportDatabaseSql = forward("exportDatabaseSql"); export const cancelDatabaseExport = forward("cancelDatabaseExport"); +export const exportQueryResultCsv = forward("exportQueryResultCsv"); +export const exportQueryResultXlsx = forward("exportQueryResultXlsx"); // Redis export const redisListDatabases = forward("redisListDatabases"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 78dd95203..2af6074f4 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -47,6 +47,7 @@ import type { TableImportProgress, DatabaseExportRequest, ExportProgress, + XlsxCellValue, } from "./tauri"; // --------------------------------------------------------------------------- @@ -680,6 +681,47 @@ export async function cancelDatabaseExport(exportId: string): Promise { await post("/api/export/database/cancel", { exportId }); } +export async function exportQueryResultCsv( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + const { formatCsv } = await import("./exportFormats"); + const content = formatCsv(columns, rows as (string | number | boolean | null)[][]); + const fileName = filePath.split(/[\\/]/).pop() || "export.csv"; + const blob = new Blob(["\uFEFF", content], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + +export async function exportQueryResultXlsx( + filePath: string, + sheetName: string | undefined, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + const { buildXlsxWorkbook } = await import("./xlsxExport"); + const workbook = buildXlsxWorkbook({ + sheetName: sheetName || "Export", + columns, + rows, + }); + const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx"; + const blob = new Blob([workbook], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + // --------------------------------------------------------------------------- // Redis // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 50d41cb94..5d6ac64f4 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -45,6 +45,8 @@ export interface DesktopSettings { show_tray_icon: boolean; } +export type XlsxCellValue = string | number | boolean | null; + export interface DriverInstallProgress { step: string; downloaded?: number; @@ -974,3 +976,33 @@ export async function exportDatabaseSql( export async function cancelDatabaseExport(exportId: string): Promise { await invoke("cancel_database_export", { exportId }); } + +export async function exportQueryResultCsv( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + return invoke("export_query_result_csv", { + request: { + filePath, + columns, + rows, + }, + }); +} + +export async function exportQueryResultXlsx( + filePath: string, + sheetName: string | undefined, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + return invoke("export_query_result_xlsx", { + request: { + filePath, + sheetName, + columns, + rows, + }, + }); +} diff --git a/crates/dbx-core/src/csv_export.rs b/crates/dbx-core/src/csv_export.rs new file mode 100644 index 000000000..38b9f3a2b --- /dev/null +++ b/crates/dbx-core/src/csv_export.rs @@ -0,0 +1,45 @@ +use serde_json::Value; + +fn escape_csv(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn value_to_csv_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(v) => v.to_string(), + Value::Number(v) => v.to_string(), + Value::String(v) => v.clone(), + other => other.to_string(), + } +} + +pub fn format_csv(columns: &[String], rows: &[Vec]) -> String { + let header = columns.iter().map(|col| escape_csv(col)).collect::>().join(","); + let body = rows + .iter() + .map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::>().join(",")) + .collect::>() + .join("\n"); + format!("{header}\n{body}") +} + +#[cfg(test)] +mod tests { + use super::format_csv; + use serde_json::json; + + #[test] + fn formats_csv_with_header_and_escaped_values() { + let out = format_csv(&["id".to_string(), "name".to_string()], &[vec![json!(1), json!("Ada \"Lovelace\"")]]); + assert_eq!(out, "\"id\",\"name\"\n\"1\",\"Ada \"\"Lovelace\"\"\""); + } + + #[test] + fn formats_null_as_empty_cell() { + let out = format_csv(&["id".to_string(), "note".to_string()], &[vec![json!(1), Value::Null]]); + assert_eq!(out, "\"id\",\"note\"\n\"1\",\"\""); + } + + use serde_json::Value; +} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 850e8b36d..71d96bd50 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod agent_service; pub mod ai; pub mod connection; pub mod connection_secrets; +pub mod csv_export; pub mod database_capabilities; pub mod database_export; pub mod db; diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs new file mode 100644 index 000000000..08dcf8cb0 --- /dev/null +++ b/crates/dbx-core/src/xlsx_export.rs @@ -0,0 +1,302 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{Cursor, Write}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct XlsxWorksheetData { + pub sheet_name: Option, + pub columns: Vec, + pub rows: Vec>, +} + +fn escape_xml(value: &str) -> String { + value + .chars() + .filter(|ch| { + let code = *ch as u32; + code == 9 || code == 10 || code == 13 || code >= 32 + }) + .flat_map(|ch| match ch { + '&' => "&".chars().collect::>(), + '<' => "<".chars().collect::>(), + '>' => ">".chars().collect::>(), + '"' => """.chars().collect::>(), + _ => vec![ch], + }) + .collect() +} + +fn column_name(index: usize) -> String { + let mut out = String::new(); + let mut n = index + 1; + while n > 0 { + let rem = (n - 1) % 26; + out.insert(0, (b'A' + rem as u8) as char); + n = (n - 1) / 26; + } + out +} + +fn cell_ref(row_index: usize, col_index: usize) -> String { + format!("{}{}", column_name(col_index), row_index + 1) +} + +fn sheet_range(column_count: usize, row_count: usize) -> String { + if column_count == 0 || row_count == 0 { + return "A1".to_string(); + } + format!("A1:{}{}", column_name(column_count - 1), row_count) +} + +fn normalize_sheet_name(input: Option<&str>) -> String { + let base = input.unwrap_or("Sheet1"); + let name: String = base + .chars() + .map(|ch| match ch { + '[' | ']' | ':' | '*' | '?' | '/' | '\\' => ' ', + _ => ch, + }) + .collect::() + .trim() + .to_string(); + let fallback = if name.is_empty() { "Sheet1" } else { &name }; + fallback.chars().take(31).collect() +} + +fn value_text(value: Option<&Value>) -> String { + match value { + Some(Value::Null) | None => String::new(), + Some(Value::Bool(v)) => { + if *v { + "true".to_string() + } else { + "false".to_string() + } + } + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + } +} + +fn estimate_column_widths(columns: &[String], rows: &[Vec]) -> Vec { + columns + .iter() + .enumerate() + .map(|(col_index, column)| { + let values = rows.iter().take(100).map(|row| value_text(row.get(col_index))); + let max_len = std::iter::once(column.clone()) + .chain(values) + .map(|v| v.chars().count().min(60)) + .fold(8usize, usize::max); + (max_len + 2).clamp(10, 60) + }) + .collect() +} + +fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Option) -> String { + let reference = cell_ref(row_index, col_index); + let style_attr = style.map_or(String::new(), |s| format!(" s=\"{s}\"")); + match value { + Some(Value::Null) | None => format!(""), + Some(Value::Bool(v)) => { + let bool_v = if *v { 1 } else { 0 }; + format!("{bool_v}") + } + Some(Value::Number(n)) => { + if n.as_f64().map_or(false, |f| f.is_finite()) { + format!("{}", n) + } else { + format!( + "{}", + escape_xml(&n.to_string()) + ) + } + } + Some(Value::String(s)) => { + format!("{}", escape_xml(s)) + } + Some(other) => format!( + "{}", + escape_xml(&other.to_string()) + ), + } +} + +fn worksheet_xml(data: &XlsxWorksheetData) -> String { + let total_rows = data.rows.len() + 1; + let range = sheet_range(data.columns.len(), total_rows); + let widths = estimate_column_widths(&data.columns, &data.rows); + + let cols_xml = widths + .iter() + .enumerate() + .map(|(index, width)| { + format!("", index + 1, index + 1, width) + }) + .collect::(); + + let header_xml = format!( + "{}", + data.columns + .iter() + .enumerate() + .map(|(index, col)| cell_xml(Some(&Value::String(col.clone())), 0, index, Some(1))) + .collect::() + ); + + let body_xml = data + .rows + .iter() + .enumerate() + .map(|(row_index, row)| { + let excel_row = row_index + 2; + let cells = data + .columns + .iter() + .enumerate() + .map(|(col_index, _)| cell_xml(row.get(col_index), excel_row - 1, col_index, None)) + .collect::(); + format!("{cells}") + }) + .collect::(); + + format!( + concat!( + "", + "", + "", + "", + "", + "{cols_xml}", + "{header_xml}{body_xml}", + "", + "" + ), + range = range, + cols_xml = cols_xml, + header_xml = header_xml, + body_xml = body_xml, + ) +} + +fn content_types_xml() -> &'static str { + concat!( + "", + "", + "", + "", + "", + "", + "", + "" + ) +} + +fn root_rels_xml() -> &'static str { + concat!( + "", + "", + "", + "" + ) +} + +fn workbook_xml(sheet_name: &str) -> String { + format!( + concat!( + "", + "", + "", + "" + ), + escape_xml(sheet_name) + ) +} + +fn workbook_rels_xml() -> &'static str { + concat!( + "", + "", + "", + "", + "" + ) +} + +fn styles_xml() -> &'static str { + concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "" + ) +} + +pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result, String> { + let sheet_name = normalize_sheet_name(data.sheet_name.as_deref()); + let files = vec![ + ("[Content_Types].xml", content_types_xml().to_string()), + ("_rels/.rels", root_rels_xml().to_string()), + ("xl/workbook.xml", workbook_xml(&sheet_name)), + ("xl/_rels/workbook.xml.rels", workbook_rels_xml().to_string()), + ("xl/styles.xml", styles_xml().to_string()), + ("xl/worksheets/sheet1.xml", worksheet_xml(data)), + ]; + + let cursor = Cursor::new(Vec::::new()); + let mut zip = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + + for (path, content) in files { + zip.start_file(path, options).map_err(|err| err.to_string())?; + zip.write_all(content.as_bytes()).map_err(|err| err.to_string())?; + } + + let output = zip.finish().map_err(|err| err.to_string())?; + Ok(output.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::{build_xlsx_workbook, XlsxWorksheetData}; + use serde_json::json; + + #[test] + fn builds_xlsx_zip_with_sheet_data() { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: Some("Users".to_string()), + columns: vec!["id".to_string(), "name".to_string(), "active".to_string()], + rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]], + }) + .expect("build workbook"); + let text = String::from_utf8_lossy(&workbook); + + assert_eq!(workbook[0], 0x50); + assert_eq!(workbook[1], 0x4b); + assert!(text.contains("[Content_Types].xml")); + assert!(text.contains("xl/worksheets/sheet1.xml")); + assert!(text.contains("name=\"Users\"")); + assert!(text.contains("1")); + assert!(text.contains("Ada & Bob")); + assert!(text.contains("1")); + } + + #[test] + fn sanitizes_invalid_sheet_name() { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: Some("bad/name:with*chars?and-a-very-long-tail".to_string()), + columns: vec!["value".to_string()], + rows: vec![vec![json!("ok")]], + }) + .expect("build workbook"); + let text = String::from_utf8_lossy(&workbook); + assert!(text.contains("name=\"bad name with chars and-a-very-\"")); + } +} diff --git a/packages/app-tests/csvBackendExport.test.ts b/packages/app-tests/csvBackendExport.test.ts new file mode 100644 index 000000000..0b6b932a4 --- /dev/null +++ b/packages/app-tests/csvBackendExport.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { strict as assert } from "node:assert"; +import test from "node:test"; + +const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8"); +const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8"); +const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8"); +const gridExportSource = readFileSync("apps/desktop/src/composables/useDataGridExport.ts", "utf8"); +const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); +const tauriCommandsSource = readFileSync("src-tauri/src/commands/mod.rs", "utf8"); +const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8"); +const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8"); + +const backendFn = "exportQueryResultCsv"; + +test("frontend API exposes backend CSV export function", () => { + assert.match(apiSource, new RegExp(`export const ${backendFn} = forward\\("${backendFn}"\\)`)); + assert.match(tauriSource, /export async function exportQueryResultCsv\(/); + assert.match(tauriSource, /invoke\("export_query_result_csv"/); + assert.match(httpSource, /export async function exportQueryResultCsv\(/); +}); + +test("CSV export entrypoints use backend API instead of frontend CSV formatter", () => { + assert.match(gridExportSource, /api\.exportQueryResultCsv\(/); + assert.doesNotMatch(gridExportSource, /formatCsv\(/); + + assert.match(treeItemSource, /api\.exportQueryResultCsv\(/); + assert.doesNotMatch(treeItemSource, /content = formatCsv/); +}); + +test("Rust backend registers CSV export modules and command", () => { + assert.match(rustCoreLibSource, /pub mod csv_export/); + assert.match(tauriCommandsSource, /pub mod csv_export/); + assert.match(tauriLibSource, /commands::csv_export::export_query_result_csv/); +}); diff --git a/packages/app-tests/xlsxBackendExport.test.ts b/packages/app-tests/xlsxBackendExport.test.ts new file mode 100644 index 000000000..4cec02391 --- /dev/null +++ b/packages/app-tests/xlsxBackendExport.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { strict as assert } from "node:assert"; +import test from "node:test"; + +const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8"); +const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8"); +const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8"); +const gridExportSource = readFileSync("apps/desktop/src/composables/useDataGridExport.ts", "utf8"); +const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); +const tauriCommandsSource = readFileSync("src-tauri/src/commands/mod.rs", "utf8"); +const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8"); + +const backendFn = "exportQueryResultXlsx"; + +test("frontend API exposes backend XLSX export function", () => { + assert.match(apiSource, new RegExp(`export const ${backendFn} = forward\\("${backendFn}"\\)`)); + assert.match(tauriSource, /export async function exportQueryResultXlsx\(/); + assert.match(tauriSource, /invoke\("export_query_result_xlsx"/); + assert.match(httpSource, /export async function exportQueryResultXlsx\(/); +}); + +test("UI uses backend XLSX export instead of frontend workbook builder", () => { + assert.match(gridExportSource, /api\.exportQueryResultXlsx\(/); + assert.doesNotMatch(gridExportSource, /buildXlsxWorkbook/); + + assert.match(treeItemSource, /api\.exportQueryResultXlsx\(/); + assert.doesNotMatch(treeItemSource, /buildXlsxWorkbook/); +}); + +test("Tauri registers backend XLSX export command", () => { + assert.match(tauriCommandsSource, /pub mod xlsx_export/); + assert.match(tauriLibSource, /commands::xlsx_export::export_query_result_xlsx/); +}); diff --git a/src-tauri/src/commands/csv_export.rs b/src-tauri/src/commands/csv_export.rs new file mode 100644 index 000000000..7072290f5 --- /dev/null +++ b/src-tauri/src/commands/csv_export.rs @@ -0,0 +1,17 @@ +use dbx_core::csv_export::format_csv; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultCsvExportRequest { + pub file_path: String, + pub columns: Vec, + pub rows: Vec>, +} + +#[tauri::command] +pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> { + let csv = format_csv(&request.columns, &request.rows); + std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 267ca8925..46aa9ec3b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod app_settings; pub mod connection; #[allow(dead_code, unused_imports)] mod connection_secrets; +pub mod csv_export; pub mod database_export; pub mod deep_link; pub mod external_sql; diff --git a/src-tauri/src/commands/xlsx_export.rs b/src-tauri/src/commands/xlsx_export.rs new file mode 100644 index 000000000..f6ca0cf99 --- /dev/null +++ b/src-tauri/src/commands/xlsx_export.rs @@ -0,0 +1,22 @@ +use dbx_core::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultXlsxExportRequest { + pub file_path: String, + pub sheet_name: Option, + pub columns: Vec, + pub rows: Vec>, +} + +#[tauri::command] +pub fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: request.sheet_name, + columns: request.columns, + rows: request.rows, + })?; + std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bb2ce1b25..3d5a3f5d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -337,6 +337,7 @@ pub fn run() { commands::transfer::cancel_transfer, commands::database_export::export_database_sql, commands::database_export::cancel_database_export, + commands::csv_export::export_query_result_csv, commands::xlsx_export::export_query_result_xlsx, commands::agents::list_installed_agents, commands::agents::list_installed_agents_local,