feat: full-result export dialog with progress/cancel + client CSV injection parity (Task 11)
This commit is contained in:
@@ -295,6 +295,15 @@ export async function executeQueryMulti(
|
||||
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function exportQueryToFile(
|
||||
connectionId: string,
|
||||
sql: string,
|
||||
format: string,
|
||||
path: string,
|
||||
): Promise<{ rows_written: number; path: string }> {
|
||||
return invoke("export_query_to_file", { connectionId, sql, format, path });
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string | null,
|
||||
limit: number,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { exportData } from "./exportData";
|
||||
import { exportData, csvCell, qualifiedTableSql } from "./exportData";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
@@ -48,4 +48,26 @@ describe("exportData", () => {
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("csvCell (client CSV parity with streamed export)", () => {
|
||||
it("quotes and guards like the Rust writer", () => {
|
||||
expect(csvCell("plain")).toBe("plain");
|
||||
expect(csvCell("a,b")).toBe('"a,b"');
|
||||
expect(csvCell('a"b')).toBe('"a""b"');
|
||||
expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
|
||||
expect(csvCell("+x")).toBe("'+x");
|
||||
expect(csvCell("-1")).toBe("'-1");
|
||||
expect(csvCell("@cmd")).toBe("'@cmd");
|
||||
expect(csvCell(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifiedTableSql", () => {
|
||||
it("double-quotes for PG/SQLite and backticks for MySQL/MariaDB", () => {
|
||||
expect(qualifiedTableSql("postgresql", "public", "users")).toBe('SELECT * FROM "public"."users"');
|
||||
expect(qualifiedTableSql("sqlite", "main", "users")).toBe('SELECT * FROM "main"."users"');
|
||||
expect(qualifiedTableSql("mysql", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||
expect(qualifiedTableSql("mariadb", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,27 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Quote one CSV cell with formula-injection protection — mirrors the Rust
|
||||
* `csv_cell` from the streaming export (Task 10) byte-for-byte. */
|
||||
export function csvCell(value: unknown): string {
|
||||
const s = value === null || value === undefined ? "" : String(value);
|
||||
const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||
if (/[,"\n\r]/.test(guarded)) {
|
||||
return `"${guarded.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return guarded;
|
||||
}
|
||||
|
||||
/** SQL selecting every row of a table, dialect-quoted. */
|
||||
export function qualifiedTableSql(dbType: string, schema: string, table: string): string {
|
||||
if (dbType === "mysql" || dbType === "mariadb") {
|
||||
const q = (s: string) => s.replace(/`/g, "``");
|
||||
return `SELECT * FROM \`${q(schema)}\`.\`${q(table)}\``;
|
||||
}
|
||||
const q = (s: string) => s.replace(/"/g, '""');
|
||||
return `SELECT * FROM "${q(schema)}"."${q(table)}"`;
|
||||
}
|
||||
|
||||
export function exportData(
|
||||
rows: unknown[][],
|
||||
columns: ColumnInfo[],
|
||||
@@ -34,14 +55,9 @@ export function exportData(
|
||||
break;
|
||||
}
|
||||
case "csv": {
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
const csvRows = [headers.map((h) => csvCell(h)).join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
csvRows.push(row.map((cell) => csvCell(cell)).join(","));
|
||||
}
|
||||
content = csvRows.join("\n");
|
||||
mime = "text/csv";
|
||||
|
||||
Reference in New Issue
Block a user