From 2183c370dcca0052285a09e6d8ef29925c64c839 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 3 Jun 2026 21:38:12 +0800 Subject: [PATCH] fix(export): handle table export edge cases --- .../src/components/objects/ObjectBrowser.vue | 65 +++++++------ .../src/components/sidebar/TreeItem.vue | 20 ++-- crates/dbx-core/src/transfer.rs | 91 ++++++++++++++++--- 3 files changed, 126 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 878a7b8f3..affa1aa11 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -74,7 +74,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import { formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { useConnectionStore } from "@/stores/connectionStore"; -import { useExportTracker } from "@/composables/useExportTracker"; +import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { useSettingsStore } from "@/stores/settingsStore"; import { useQueryStore } from "@/stores/queryStore"; import QueryEditor from "@/components/editor/QueryEditor.vue"; @@ -861,38 +861,43 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") { filePath = `__web_export_${webExportId}.${format}`; } - // Register task in export tracker (background) - const task = addExportTask(row.name, format, filePath); - - // Get columns for neo4j only - const queryColumns = - props.connection.db_type === "neo4j" - ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( - (column) => column.name, - ) - : undefined; - - const request: api.TableExportRequest = { - exportId: task.exportId, - connectionId: props.connection.id, - database: props.database, - schema, - tableName: row.name, - filePath, - format, - columns: queryColumns, - batchSize: settingsStore.editorSettings.exportBatchSize, - }; - + let task: ExportTask | null = null; try { - await api.startTableExport(request, (progress) => { - task.rowsExported = progress.rowsExported; - task.totalRows = progress.totalRows; - task.status = progress.status; - task.errorMessage = progress.errorMessage || null; + const queryColumns = + props.connection.db_type === "neo4j" + ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( + (column) => column.name, + ) + : undefined; + + task = addExportTask(row.name, format, filePath); + const currentTask = task; + const request: api.TableExportRequest = { + exportId: currentTask.exportId, + connectionId: props.connection.id, + database: props.database, + schema, + tableName: row.name, + filePath, + format, + columns: queryColumns, + batchSize: settingsStore.editorSettings.exportBatchSize, + }; + + const terminalProgress = await api.startTableExport(request, (progress) => { + currentTask.rowsExported = progress.rowsExported; + currentTask.totalRows = progress.totalRows; + currentTask.status = progress.status; + currentTask.errorMessage = progress.errorMessage || null; }); - toast(t("grid.exported")); + if (terminalProgress.status === "Done") { + toast(t("grid.exported")); + } } catch (e: any) { + if (task) { + task.status = "Error"; + task.errorMessage = e?.message || String(e); + } toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); } } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 671191147..64a9e3385 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -126,7 +126,7 @@ import { } from "@/lib/sidebarTreeSelection"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; -import { useExportTracker } from "@/composables/useExportTracker"; +import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { copyToClipboard } from "@/lib/clipboard"; import { formatShortcut } from "@/lib/shortcutRegistry"; @@ -2122,6 +2122,7 @@ async function exportTableData(format: "csv" | "xlsx") { const config = connectionStore.getConfig(node.connectionId); if (!config) return; + let task: ExportTask | null = null; try { await connectionStore.ensureConnected(connectionId); @@ -2138,7 +2139,8 @@ async function exportTableData(format: "csv" | "xlsx") { } // Step 2: Register task in export tracker (background) - const task = addExportTask(node.label, format, outputPath); + task = addExportTask(node.label, format, outputPath); + const currentTask = task; // Step 3: Get query columns for neo4j const queryColumns = @@ -2148,7 +2150,7 @@ async function exportTableData(format: "csv" | "xlsx") { // Step 4: Start streaming export (background, non-blocking) const request: api.TableExportRequest = { - exportId: task.exportId, + exportId: currentTask.exportId, connectionId, database, schema: node.schema || undefined, @@ -2160,10 +2162,10 @@ async function exportTableData(format: "csv" | "xlsx") { }; await api.startTableExport(request, (progress) => { - task.rowsExported = progress.rowsExported; - task.totalRows = progress.totalRows; - task.status = progress.status; - task.errorMessage = progress.errorMessage || null; + currentTask.rowsExported = progress.rowsExported; + currentTask.totalRows = progress.totalRows; + currentTask.status = progress.status; + currentTask.errorMessage = progress.errorMessage || null; if (progress.status === "Done") { toast(t("grid.exported")); } else if (progress.status === "Error") { @@ -2171,6 +2173,10 @@ async function exportTableData(format: "csv" | "xlsx") { } }); } catch (e: any) { + if (task) { + task.status = "Error"; + task.errorMessage = e?.message || String(e); + } toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); } } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 0dd96e621..6848988de 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1267,21 +1267,13 @@ pub fn keyset_pagination_sql( let order = primary_keys.iter().map(|pk| format!("{} ASC", quote_identifier(pk, db_type))).collect::>().join(", "); - let where_clause = if primary_keys.is_empty() || last_pk_values.is_empty() { - String::new() - } else { - let pk_list = primary_keys.iter().map(|pk| quote_identifier(pk, db_type)).collect::>().join(", "); - let vals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::>().join(", "); - if primary_keys.len() == 1 { - format!(" WHERE {} > {}", pk_list, vals) - } else { - format!(" WHERE ({}) > ({})", pk_list, vals) - } - }; + let where_clause = keyset_where_clause(primary_keys, last_pk_values, db_type); match db_type { DatabaseType::SqlServer | DatabaseType::Oracle => { - format!("SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} FETCH NEXT {limit} ROWS ONLY") + format!( + "SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} OFFSET 0 ROWS FETCH NEXT {limit} ROWS ONLY" + ) } _ => { format!("SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} LIMIT {limit}") @@ -1289,7 +1281,44 @@ pub fn keyset_pagination_sql( } } -fn value_to_sql_literal(value: &serde_json::Value, db_type: &DatabaseType) -> String { +fn keyset_where_clause( + primary_keys: &[String], + last_pk_values: &[serde_json::Value], + db_type: &DatabaseType, +) -> String { + if primary_keys.is_empty() || last_pk_values.is_empty() { + return String::new(); + } + + let quoted_keys = primary_keys.iter().map(|pk| quote_identifier(pk, db_type)).collect::>(); + let literals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::>(); + let comparison_count = quoted_keys.len().min(literals.len()); + if comparison_count == 0 { + return String::new(); + } + + let mut clauses = Vec::with_capacity(comparison_count); + for index in 0..comparison_count { + let mut parts = Vec::with_capacity(index + 1); + for prefix_index in 0..index { + parts.push(format!("{} = {}", quoted_keys[prefix_index], literals[prefix_index])); + } + parts.push(format!("{} > {}", quoted_keys[index], literals[index])); + if parts.len() == 1 { + clauses.push(parts.remove(0)); + } else { + clauses.push(format!("({})", parts.join(" AND "))); + } + } + + if clauses.len() == 1 { + format!(" WHERE {}", clauses[0]) + } else { + format!(" WHERE ({})", clauses.join(" OR ")) + } +} + +fn value_to_sql_literal(value: &serde_json::Value, _db_type: &DatabaseType) -> String { match value { serde_json::Value::Null => "NULL".to_string(), serde_json::Value::Bool(b) => { @@ -2798,6 +2827,42 @@ mod tests { assert_eq!(sql, "SELECT \"id\", \"name\" FROM \"public\".\"users\" ORDER BY \"id\" LIMIT 100 OFFSET 200"); } + #[test] + fn sqlserver_keyset_pagination_includes_offset_fetch() { + let sql = keyset_pagination_sql( + &[String::from("id"), String::from("name")], + "users", + "dbo", + &DatabaseType::SqlServer, + &[String::from("id")], + &[], + 100, + ); + + assert_eq!( + sql, + "SELECT [id], [name] FROM [dbo].[users] ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY" + ); + } + + #[test] + fn composite_keyset_pagination_uses_portable_lexicographic_predicate() { + let sql = keyset_pagination_sql( + &[String::from("tenant_id"), String::from("id"), String::from("name")], + "users", + "dbo", + &DatabaseType::SqlServer, + &[String::from("tenant_id"), String::from("id")], + &[json!(10), json!(25)], + 100, + ); + + assert_eq!( + sql, + "SELECT [tenant_id], [id], [name] FROM [dbo].[users] WHERE ([tenant_id] > 10 OR ([tenant_id] = 10 AND [id] > 25)) ORDER BY [tenant_id] ASC, [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY" + ); + } + #[test] fn postgres_generates_index_and_foreign_key_sql() { let indexes = vec![db::IndexInfo {