From 30cd447e4f8e1351e22f3939eb01e257c5695557 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 2 Jun 2026 23:00:58 +0800 Subject: [PATCH] perf(export): stream table CSV from Rust --- Cargo.lock | 31 ++ .../src/components/objects/ObjectBrowser.vue | 21 ++ .../src/components/sidebar/TreeItem.vue | 21 ++ apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/http.ts | 5 + apps/desktop/src/lib/tauri.ts | 47 ++- crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/csv_export.rs | 117 ++++++ crates/dbx-core/src/data_compare.rs | 352 ++++++++++-------- src-tauri/src/commands/csv_export.rs | 14 +- src-tauri/src/lib.rs | 1 + 11 files changed, 444 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 526ea09bef..130fcea697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,6 +1511,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1897,6 +1907,7 @@ dependencies = [ "mysql_async", "percent-encoding", "portpicker", + "rayon", "redis", "regex", "reqwest 0.12.28", @@ -6107,6 +6118,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "recursive" version = "0.1.1" diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 1c60e4d4c0..0df5bdd9a0 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -781,6 +781,27 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") (column) => column.name, ) : undefined; + + if (format === "csv" && isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const outputPath = await save({ + defaultPath: `${row.name}.csv`, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!outputPath) return; + await api.exportTableDataCsv({ + filePath: outputPath as string, + connectionId: props.connection.id, + database: props.database, + schema, + tableName: row.name, + columns: queryColumns, + timeoutSecs: queryTimeoutSecsForConnection(props.connection), + }); + toast(t("grid.exported")); + return; + } + const result = await fetchTableDataForExport({ databaseType: props.connection.db_type, schema, diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index d0be9d4509..9a79ff4b07 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -2058,6 +2058,27 @@ async function exportData(format: "csv" | "json" | "sql") { (column) => column.name, ) : undefined; + + if (format === "csv" && isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const outputPath = await save({ + defaultPath: `${node.label}.csv`, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!outputPath) return; + await api.exportTableDataCsv({ + filePath: outputPath as string, + connectionId, + database, + schema: node.schema, + tableName: node.label, + columns: queryColumns, + timeoutSecs: queryTimeoutSecsForConnection(config), + }); + toast(t("grid.exported")); + return; + } + const result = await fetchTableDataForExport({ databaseType: config.db_type, schema: node.schema, diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index f7f691e75f..2d68896adb 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -184,6 +184,7 @@ export const cancelTableImport = forward("cancelTableImport"); export const exportDatabaseSql = forward("exportDatabaseSql"); export const cancelDatabaseExport = forward("cancelDatabaseExport"); export const exportQueryResultCsv = forward("exportQueryResultCsv"); +export const exportTableDataCsv = forward("exportTableDataCsv"); export const exportQueryResultXlsx = forward("exportQueryResultXlsx"); export const exportQueryResultJson = forward("exportQueryResultJson"); export const exportQueryResultMarkdown = forward("exportQueryResultMarkdown"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 0963547254..86d2718526 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -49,6 +49,7 @@ import type { TableImportProgress, DatabaseExportRequest, ExportProgress, + TableCsvExportOptions, XlsxCellValue, QueryPaginationExecutionPlanOptions, QueryPaginationExecutionPlan, @@ -1087,6 +1088,10 @@ export async function exportQueryResultCsv( URL.revokeObjectURL(url); } +export async function exportTableDataCsv(_options: TableCsvExportOptions): Promise { + throw new Error("Streaming table CSV export is only available in the desktop runtime"); +} + function downloadTextFile(filePath: string, fallbackFileName: string, content: string, mimeType: string): void { const fileName = filePath.split(/[\\/]/).pop() || fallbackFileName; const blob = new Blob(["\uFEFF", content], { type: mimeType }); diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index c0029d554c..0b3a6e49da 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1354,23 +1354,25 @@ export async function startTransfer( request: TransferRequest, onProgress: (progress: TransferProgress) => void, ): Promise { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { let unlisten: UnlistenFn | null = null; - try { - unlisten = await listen("transfer-progress", (event) => { - if (event.payload.transferId !== request.transferId) return; - onProgress(event.payload); - if (event.payload.status === "done" || event.payload.status === "cancelled") { - unlisten?.(); - resolve(); - } - }); + void (async () => { + try { + unlisten = await listen("transfer-progress", (event) => { + if (event.payload.transferId !== request.transferId) return; + onProgress(event.payload); + if (event.payload.status === "done" || event.payload.status === "cancelled") { + unlisten?.(); + resolve(); + } + }); - await invoke("start_transfer", { request }); - } catch (e) { - unlisten?.(); - reject(e); - } + await invoke("start_transfer", { request }); + } catch (e) { + unlisten?.(); + reject(e); + } + })(); }); } @@ -1477,6 +1479,17 @@ export interface ExportProgress { error: string | null; } +export interface TableCsvExportOptions { + filePath: string; + connectionId: string; + database: string; + schema?: string; + tableName: string; + columns?: string[]; + pageSize?: number; + timeoutSecs?: number; +} + export async function exportDatabaseSql( request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void, @@ -1515,6 +1528,10 @@ export async function exportQueryResultCsv( }); } +export async function exportTableDataCsv(options: TableCsvExportOptions): Promise { + return invoke("export_table_data_csv", { request: options }); +} + export async function exportQueryResultXlsx( filePath: string, sheetName: string | undefined, diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 029f0204a6..535aa406a7 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -11,6 +11,7 @@ duckdb-bundled = ["duckdb/bundled"] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" regex = "1" +rayon = "1" percent-encoding = "2" encoding_rs = "0.8" log = "0.4" diff --git a/crates/dbx-core/src/csv_export.rs b/crates/dbx-core/src/csv_export.rs index 38b9f3a2b0..18afb55bb0 100644 --- a/crates/dbx-core/src/csv_export.rs +++ b/crates/dbx-core/src/csv_export.rs @@ -1,4 +1,31 @@ +use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::fs::File; +use std::io::{BufWriter, Write}; + +use crate::connection::AppState; +use crate::models::connection::DatabaseType; +use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions}; +use crate::sql_dialect::{build_table_data_select_sql, TableDataSelectSqlOptions}; + +const TABLE_DATA_EXPORT_PAGE_SIZE: usize = 10_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableCsvExportOptions { + pub file_path: String, + pub connection_id: String, + pub database: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + #[serde(default)] + pub columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, +} fn escape_csv(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) @@ -24,6 +51,96 @@ pub fn format_csv(columns: &[String], rows: &[Vec]) -> String { format!("{header}\n{body}") } +fn write_csv_row(writer: &mut impl Write, values: impl IntoIterator) -> Result<(), String> { + let mut first = true; + for value in values { + if !first { + writer.write_all(b",").map_err(|err| err.to_string())?; + } + first = false; + writer.write_all(escape_csv(&value).as_bytes()).map_err(|err| err.to_string())?; + } + Ok(()) +} + +async fn connection_database_type(state: &AppState, connection_id: &str) -> Result { + state + .configs + .read() + .await + .get(connection_id) + .map(|config| config.db_type) + .ok_or_else(|| format!("Connection config not found: {connection_id}")) +} + +pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExportOptions) -> Result { + let database_type = connection_database_type(state, &options.connection_id).await?; + let page_size = options.page_size.unwrap_or(TABLE_DATA_EXPORT_PAGE_SIZE).max(1); + let mut writer = + BufWriter::new(File::create(&options.file_path).map_err(|err| format!("Failed to write CSV file: {err}"))?); + writer.write_all("\u{FEFF}".as_bytes()).map_err(|err| err.to_string())?; + + let mut offset = 0usize; + let mut rows_exported = 0u64; + let mut wrote_header = false; + + loop { + let sql = build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(database_type), + schema: options.schema.clone(), + table_name: options.table_name.clone(), + primary_keys: Vec::new(), + columns: options.columns.clone(), + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(page_size), + offset: Some(offset), + where_input: None, + include_row_id: false, + }); + let result = execute_sql_statement_with_options( + state, + &options.connection_id, + &options.database, + &sql, + options.schema.as_deref(), + None, + QueryExecutionOptions { + max_rows: Some(page_size), + timeout_secs: options.timeout_secs, + ..Default::default() + }, + ) + .await?; + + if !wrote_header { + write_csv_row(&mut writer, result.columns.into_iter())?; + wrote_header = true; + } + + let fetched = result.rows.len(); + if fetched == 0 { + break; + } + for row in result.rows { + writer.write_all(b"\n").map_err(|err| err.to_string())?; + write_csv_row(&mut writer, row.iter().map(value_to_csv_text))?; + } + + rows_exported += fetched as u64; + if fetched < page_size { + break; + } + offset += fetched; + } + + if rows_exported == 0 { + writer.write_all(b"\n").map_err(|err| err.to_string())?; + } + writer.flush().map_err(|err| err.to_string())?; + Ok(rows_exported) +} + #[cfg(test)] mod tests { use super::format_csv; diff --git a/crates/dbx-core/src/data_compare.rs b/crates/dbx-core/src/data_compare.rs index ab776a7ae8..95f2858507 100644 --- a/crates/dbx-core/src/data_compare.rs +++ b/crates/dbx-core/src/data_compare.rs @@ -1,5 +1,6 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use rayon::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -194,53 +195,53 @@ pub async fn prepare_data_compare_from_tables( let target_count_sql = build_count_table_sql(Some(target_database_type), Some(&options.target_schema), &options.target_table); - let source_count_result = execute_sql_statement_with_options( - state, - &options.source_connection_id, - &options.source_database, - &source_count_sql, - Some(&options.source_schema), - None, - QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, - ) - .await?; - let target_count_result = execute_sql_statement_with_options( - state, - &options.target_connection_id, - &options.target_database, - &target_count_sql, - Some(&options.target_schema), - None, - QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, - ) - .await?; + let (source_count_result, target_count_result) = tokio::try_join!( + execute_sql_statement_with_options( + state, + &options.source_connection_id, + &options.source_database, + &source_count_sql, + Some(&options.source_schema), + None, + QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, + ), + execute_sql_statement_with_options( + state, + &options.target_connection_id, + &options.target_database, + &target_count_sql, + Some(&options.target_schema), + None, + QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, + ) + )?; let source_row_count = first_count(&source_count_result.rows)?; let target_row_count = first_count(&target_count_result.rows)?; - let source_rows = fetch_compare_rows( - state, - &options.source_connection_id, - &options.source_database, - &options.source_schema, - &options.source_table, - &options.columns, - &options.key_columns, - source_database_type, - fetch_batch_size, - ) - .await?; - let target_rows = fetch_compare_rows( - state, - &options.target_connection_id, - &options.target_database, - &options.target_schema, - &options.target_table, - &options.columns, - &options.key_columns, - target_database_type, - fetch_batch_size, - ) - .await?; + let (source_rows, target_rows) = tokio::try_join!( + fetch_compare_rows( + state, + &options.source_connection_id, + &options.source_database, + &options.source_schema, + &options.source_table, + &options.columns, + &options.key_columns, + source_database_type, + fetch_batch_size, + ), + fetch_compare_rows( + state, + &options.target_connection_id, + &options.target_database, + &options.target_schema, + &options.target_table, + &options.columns, + &options.key_columns, + target_database_type, + fetch_batch_size, + ) + )?; let preparation = prepare_data_compare(DataComparePreparationOptions { table_name: options.target_table, @@ -401,82 +402,116 @@ pub fn compare_data_rows(options: CompareDataRowsOptions) -> Result> = HashMap::new(); - let mut target: HashMap> = HashMap::new(); - let mut source_order = Vec::new(); - let mut target_order = Vec::new(); + let source_rows = parallel_compare_rows(&options.columns, &options.key_columns, &options.source_rows); + let target_rows = parallel_compare_rows(&options.columns, &options.key_columns, &options.target_rows); + let (source, source_order) = collect_compare_rows(source_rows, "source")?; + let (target, target_order) = collect_compare_rows(target_rows, "target")?; + let key_columns: HashSet<&str> = options.key_columns.iter().map(String::as_str).collect(); - for row in &options.source_rows { - let item = row_object(&options.columns, row); - let key = key_for(&item, &options.key_columns); - if source.contains_key(&key) { - return Err(format!("Duplicate source key: {key}")); - } - source_order.push(key.clone()); - source.insert(key, item); - } + let source_diffs = source_order + .par_iter() + .map(|key| { + let source_values = source.get(key).expect("source key should exist"); + let Some(target_values) = target.get(key) else { + return Some(SourceDiff::Added(DataCompareRow { + key: key.clone(), + key_values: key_values(source_values, &options.key_columns), + values: source_values.clone(), + })); + }; - for row in &options.target_rows { - let item = row_object(&options.columns, row); - let key = key_for(&item, &options.key_columns); - if target.contains_key(&key) { - return Err(format!("Duplicate target key: {key}")); - } - target_order.push(key.clone()); - target.insert(key, item); - } + let changes: Vec = options + .columns + .iter() + .filter(|column| !key_columns.contains(column.as_str())) + .filter(|column| value_for(source_values, column) != value_for(target_values, column)) + .map(|column| DataCompareChangedCell { + column: column.clone(), + source: value_for(source_values, column), + target: value_for(target_values, column), + }) + .collect(); + + if changes.is_empty() { + None + } else { + Some(SourceDiff::Modified(DataCompareModifiedRow { + key: key.clone(), + key_values: key_values(source_values, &options.key_columns), + source_values: source_values.clone(), + target_values: target_values.clone(), + changes, + })) + } + }) + .collect::>(); let mut added = Vec::new(); - let mut removed = Vec::new(); let mut modified = Vec::new(); - - for key in &source_order { - let source_values = source.get(key).expect("source key should exist"); - let Some(target_values) = target.get(key) else { - added.push(DataCompareRow { - key: key.clone(), - key_values: key_values(source_values, &options.key_columns), - values: source_values.clone(), - }); - continue; - }; - - let changes: Vec = options - .columns - .iter() - .filter(|column| !options.key_columns.contains(column)) - .filter(|column| value_for(source_values, column) != value_for(target_values, column)) - .map(|column| DataCompareChangedCell { - column: column.clone(), - source: value_for(source_values, column), - target: value_for(target_values, column), - }) - .collect(); - - if !changes.is_empty() { - modified.push(DataCompareModifiedRow { - key: key.clone(), - key_values: key_values(source_values, &options.key_columns), - source_values: source_values.clone(), - target_values: target_values.clone(), - changes, - }); + for diff in source_diffs.into_iter().flatten() { + match diff { + SourceDiff::Added(row) => added.push(row), + SourceDiff::Modified(row) => modified.push(row), } } - for key in &target_order { - if let Some(target_values) = target.get(key).filter(|_| !source.contains_key(key)) { - removed.push(DataCompareRow { + let removed = target_order + .par_iter() + .map(|key| { + target.get(key).filter(|_| !source.contains_key(key)).map(|target_values| DataCompareRow { key: key.clone(), key_values: key_values(target_values, &options.key_columns), values: target_values.clone(), - }); - } - } + }) + }) + .collect::>() + .into_iter() + .flatten() + .collect(); Ok(DataCompareResult { added, removed, modified }) } +#[derive(Debug)] +struct CompareRow { + key: String, + values: HashMap, +} + +#[derive(Debug)] +enum SourceDiff { + Added(DataCompareRow), + Modified(DataCompareModifiedRow), +} + +fn parallel_compare_rows(columns: &[String], key_columns: &[String], rows: &[Vec]) -> Vec { + rows.par_iter() + .map(|row| { + let values = row_object(columns, row); + let key = key_for(&values, key_columns); + CompareRow { key, values } + }) + .collect() +} + +fn collect_compare_rows( + rows: Vec, + label: &str, +) -> Result<(HashMap>, Vec), String> { + let mut items = HashMap::with_capacity(rows.len()); + let mut order = Vec::with_capacity(rows.len()); + + for row in rows { + if items.contains_key(&row.key) { + return Err(format!("Duplicate {label} key: {}", row.key)); + } + order.push(row.key.clone()); + items.insert(row.key, row.values); + } + + Ok((items, order)) +} + #[derive(Debug, Clone)] struct GenerateDataSyncSqlOptions { table_name: String, @@ -513,52 +548,67 @@ fn json_stringify(value: &Value) -> String { fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec { let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name); - let mut statements = Vec::new(); - - for row in &options.diff.added { - let columns = options - .columns - .iter() - .map(|column| quote_table_identifier(options.database_type, column)) - .collect::>() - .join(", "); - let values = options - .columns - .iter() - .map(|column| { - format_grid_sql_literal(row.values.get(column).unwrap_or(&Value::Null), options.database_type) - }) - .collect::>() - .join(", "); - statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});")); - } - - for row in &options.diff.modified { - let assignments = row - .changes - .iter() - .map(|change| { - format!( - "{} = {}", - quote_table_identifier(options.database_type, &change.column), - format_grid_sql_literal(&change.source, options.database_type) - ) - }) - .collect::>() - .join(", "); - statements.push(format!( - "UPDATE {table} SET {assignments} WHERE {};", - where_by_key(&row.key_values, &options.key_columns, options.database_type) - )); - } - - for row in &options.diff.removed { - statements.push(format!( - "DELETE FROM {table} WHERE {};", - where_by_key(&row.key_values, &options.key_columns, options.database_type) - )); - } + let columns = options + .columns + .iter() + .map(|column| quote_table_identifier(options.database_type, column)) + .collect::>() + .join(", "); + let added = options + .diff + .added + .par_iter() + .map(|row| { + let values = options + .columns + .iter() + .map(|column| { + format_grid_sql_literal(row.values.get(column).unwrap_or(&Value::Null), options.database_type) + }) + .collect::>() + .join(", "); + format!("INSERT INTO {table} ({columns}) VALUES ({values});") + }) + .collect::>(); + let modified = options + .diff + .modified + .par_iter() + .map(|row| { + let assignments = row + .changes + .iter() + .map(|change| { + format!( + "{} = {}", + quote_table_identifier(options.database_type, &change.column), + format_grid_sql_literal(&change.source, options.database_type) + ) + }) + .collect::>() + .join(", "); + format!( + "UPDATE {table} SET {assignments} WHERE {};", + where_by_key(&row.key_values, &options.key_columns, options.database_type) + ) + }) + .collect::>(); + let removed = options + .diff + .removed + .par_iter() + .map(|row| { + format!( + "DELETE FROM {table} WHERE {};", + where_by_key(&row.key_values, &options.key_columns, options.database_type) + ) + }) + .collect::>(); + let mut statements = Vec::with_capacity(added.len() + modified.len() + removed.len()); + statements.extend(added); + statements.extend(modified); + statements.extend(removed); statements } diff --git a/src-tauri/src/commands/csv_export.rs b/src-tauri/src/commands/csv_export.rs index 7072290f51..3ed996b5b5 100644 --- a/src-tauri/src/commands/csv_export.rs +++ b/src-tauri/src/commands/csv_export.rs @@ -1,6 +1,10 @@ -use dbx_core::csv_export::format_csv; +use std::sync::Arc; + +use crate::commands::connection::AppState; +use dbx_core::csv_export::{export_table_data_csv_core, format_csv, TableCsvExportOptions}; use serde::Deserialize; use serde_json::Value; +use tauri::State; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -15,3 +19,11 @@ pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<( let csv = format_csv(&request.columns, &request.rows); std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) } + +#[tauri::command] +pub async fn export_table_data_csv( + state: State<'_, Arc>, + request: TableCsvExportOptions, +) -> Result { + export_table_data_csv_core(&state, request).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 158aeca3a8..abe2883bdd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -500,6 +500,7 @@ pub fn run() { commands::database_export::export_database_sql, commands::database_export::cancel_database_export, commands::csv_export::export_query_result_csv, + commands::csv_export::export_table_data_csv, commands::xlsx_export::export_query_result_xlsx, commands::text_export::export_query_result_json, commands::text_export::export_query_result_markdown,