fix(export): handle table export edge cases

This commit is contained in:
t8y2 2026-06-03 21:38:12 +08:00
parent 0611ed8e52
commit 2183c370dc
3 changed files with 126 additions and 50 deletions

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -1267,21 +1267,13 @@ pub fn keyset_pagination_sql(
let order =
primary_keys.iter().map(|pk| format!("{} ASC", quote_identifier(pk, db_type))).collect::<Vec<_>>().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::<Vec<_>>().join(", ");
let vals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::<Vec<_>>().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::<Vec<_>>();
let literals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::<Vec<_>>();
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 {