feat(desktop): show batch drop progress
This commit is contained in:
11 files changed
+453
-37
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function queryResult(overrides: Partial<QueryResult> = {}): QueryResult {
|
||||
return { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, ...overrides };
|
||||
}
|
||||
|
||||
const plan = [
|
||||
{ target: "orders", sql: "DROP TABLE orders" },
|
||||
{ target: "customers", sql: "DROP TABLE customers" },
|
||||
{ target: "events", sql: "DROP TABLE events" },
|
||||
];
|
||||
|
||||
describe("batch table drop", () => {
|
||||
it("keeps SQL Server sequential so progress and partial failure stay attributable", async () => {
|
||||
const executeStatement = vi.fn(async (sql: string) => {
|
||||
if (sql.includes("customers")) throw new Error("permission denied");
|
||||
});
|
||||
const executeBatch = vi.fn();
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const result = await runBatchTableDrop({ databaseType: "sqlserver", plan, executeStatement, executeBatch, onProgress });
|
||||
|
||||
expect(executeStatement.mock.calls.map(([sql]) => sql)).toEqual(["DROP TABLE orders", "DROP TABLE customers"]);
|
||||
expect(executeBatch).not.toHaveBeenCalled();
|
||||
expect(result.succeeded).toEqual(["orders"]);
|
||||
expect(result.failed?.message).toBe("permission denied");
|
||||
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
|
||||
{ completed: 1, total: 3, success: true },
|
||||
{ completed: 2, total: 3, success: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps indexed batch results back to their exact targets", async () => {
|
||||
const result = await runBatchTableDrop({
|
||||
databaseType: "mysql",
|
||||
plan,
|
||||
executeStatement: vi.fn(),
|
||||
executeBatch: async () => [queryResult({ statement_index: 0 }), queryResult({ statement_index: 1, execution_error: true, columns: ["Error"], rows: [["locked"]] })],
|
||||
onProgress: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.succeeded).toEqual(["orders"]);
|
||||
expect(result.failed?.message).toBe("locked");
|
||||
});
|
||||
|
||||
it("treats a successful atomic HTTP SQLite batch as all-or-nothing", async () => {
|
||||
const onProgress = vi.fn();
|
||||
const result = await runBatchTableDrop({
|
||||
databaseType: "turso",
|
||||
plan,
|
||||
executeStatement: vi.fn(),
|
||||
executeBatch: async () => [queryResult()],
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(result.succeeded).toEqual(["orders", "customers", "events"]);
|
||||
expect(result.failed).toBeUndefined();
|
||||
expect(onProgress).toHaveBeenLastCalledWith({ completed: 3, total: 3, success: true });
|
||||
});
|
||||
|
||||
it("fails closed when a non-atomic batch omits statement indexes", async () => {
|
||||
const result = await runBatchTableDrop({
|
||||
databaseType: "postgres",
|
||||
plan,
|
||||
executeStatement: vi.fn(),
|
||||
executeBatch: async () => [queryResult()],
|
||||
onProgress: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.succeeded).toEqual([]);
|
||||
expect(result.failed?.message).toBe("Batch drop did not report a result for every statement");
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,7 @@ export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
|
||||
// Query
|
||||
export const executeQuery = forward("executeQuery");
|
||||
export const executeMulti = forward("executeMulti");
|
||||
export const executeMultiWithProgress = forward("executeMultiWithProgress");
|
||||
export const executeBatch = forward("executeBatch");
|
||||
export const executeScript = forward("executeScript");
|
||||
export const executeInTransaction = forward("executeInTransaction");
|
||||
|
||||
@@ -820,6 +820,37 @@ export async function executeMulti(
|
||||
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||
}
|
||||
|
||||
export interface ExecuteMultiProgress {
|
||||
executionId: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function executeMultiWithProgress(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
sql: string,
|
||||
onProgress: (progress: ExecuteMultiProgress) => void,
|
||||
schema?: string,
|
||||
options?: {
|
||||
maxRows?: number;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
useTransaction?: boolean;
|
||||
continueOnError?: boolean;
|
||||
executionMode?: "simple";
|
||||
},
|
||||
): Promise<QueryResult[]> {
|
||||
const executionId = crypto.randomUUID();
|
||||
const results = await executeMulti(connectionId, database, sql, schema, executionId, options);
|
||||
onProgress({ executionId, completed: results.length, total: results.length, success: !results.some((result) => result.execution_error === true) });
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string): Promise<boolean> {
|
||||
return post("/api/query/close-session", { connectionId, database, sessionId, clientSessionId });
|
||||
}
|
||||
|
||||
@@ -939,6 +939,42 @@ export async function executeMulti(
|
||||
return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||
}
|
||||
|
||||
export interface ExecuteMultiProgress {
|
||||
executionId: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function executeMultiWithProgress(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
sql: string,
|
||||
onProgress: (progress: ExecuteMultiProgress) => void,
|
||||
schema?: string,
|
||||
options?: {
|
||||
maxRows?: number;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
useTransaction?: boolean;
|
||||
continueOnError?: boolean;
|
||||
executionMode?: "simple";
|
||||
},
|
||||
): Promise<QueryResult[]> {
|
||||
const executionId = crypto.randomUUID();
|
||||
const unlisten = await listen<ExecuteMultiProgress>("query-batch-progress", (event) => {
|
||||
if (event.payload.executionId === executionId) onProgress(event.payload);
|
||||
});
|
||||
try {
|
||||
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshConnections(): Promise<void> {
|
||||
return invoke("refresh_connections");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface SidebarDangerDialogOption {
|
||||
onChange?: (checked: boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SidebarDangerDialogProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SidebarDangerDialogRequest {
|
||||
target: SidebarActionTarget;
|
||||
title: string;
|
||||
@@ -17,6 +22,7 @@ export interface SidebarDangerDialogRequest {
|
||||
detailsText?: string;
|
||||
loading?: boolean;
|
||||
closeOnConfirm?: boolean;
|
||||
progress?: SidebarDangerDialogProgress;
|
||||
option?: SidebarDangerDialogOption;
|
||||
confirm: () => void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export interface BatchTableDropPlanItem<T> {
|
||||
target: T;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface BatchTableDropProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface BatchTableDropResult<T> {
|
||||
succeeded: T[];
|
||||
failed?: Error;
|
||||
}
|
||||
|
||||
interface RunBatchTableDropOptions<T> {
|
||||
databaseType: DatabaseType | undefined;
|
||||
plan: readonly BatchTableDropPlanItem<T>[];
|
||||
executeStatement: (sql: string) => Promise<unknown>;
|
||||
executeBatch: (sql: string, onProgress: (progress: BatchTableDropProgress) => void) => Promise<QueryResult[]>;
|
||||
onProgress: (progress: BatchTableDropProgress) => void;
|
||||
}
|
||||
|
||||
function batchDropError(result: QueryResult): Error {
|
||||
return new Error(String(result.rows[0]?.[0] ?? "Batch drop failed"));
|
||||
}
|
||||
|
||||
function isAtomicIndexlessBatch(databaseType: DatabaseType | undefined): boolean {
|
||||
return databaseType === "turso" || databaseType === "cloudflare-d1";
|
||||
}
|
||||
|
||||
export async function runBatchTableDrop<T>({ databaseType, plan, executeStatement, executeBatch, onProgress }: RunBatchTableDropOptions<T>): Promise<BatchTableDropResult<T>> {
|
||||
if (databaseType === "sqlserver") {
|
||||
const succeeded: T[] = [];
|
||||
for (let index = 0; index < plan.length; index += 1) {
|
||||
try {
|
||||
await executeStatement(plan[index]!.sql);
|
||||
succeeded.push(plan[index]!.target);
|
||||
onProgress({ completed: index + 1, total: plan.length, success: true });
|
||||
} catch (error) {
|
||||
onProgress({ completed: index + 1, total: plan.length, success: false });
|
||||
return { succeeded, failed: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
}
|
||||
return { succeeded };
|
||||
}
|
||||
|
||||
const results = await executeBatch(plan.map(({ sql }) => sql).join(";\n"), onProgress);
|
||||
const failedResult = results.find((result) => result.execution_error === true);
|
||||
if (!failedResult && isAtomicIndexlessBatch(databaseType) && results.every((result) => !Number.isInteger(result.statement_index))) {
|
||||
onProgress({ completed: plan.length, total: plan.length, success: true });
|
||||
return { succeeded: plan.map(({ target }) => target) };
|
||||
}
|
||||
|
||||
const succeededIndexes = new Set(results.filter((result) => result.execution_error !== true && Number.isInteger(result.statement_index)).map((result) => result.statement_index!));
|
||||
const succeeded = plan.filter((_, index) => succeededIndexes.has(index)).map(({ target }) => target);
|
||||
if (failedResult) return { succeeded, failed: batchDropError(failedResult) };
|
||||
if (succeeded.length !== plan.length) {
|
||||
return { succeeded, failed: new Error("Batch drop did not report a result for every statement") };
|
||||
}
|
||||
return { succeeded };
|
||||
}
|
||||
Reference in new issue
Block a user