From 8b822e974b7f4cb936a11b9e8f17ea00dd7e9447 Mon Sep 17 00:00:00 2001 From: goodvecn-dev Date: Wed, 23 Sep 2026 10:36:40 +0800 Subject: [PATCH] feat(editor): merge multi-database query results into one view --- apps/desktop/src/App.vue | 6 +- .../editor/MultiDbExecuteDialog.vue | 209 +++++++++- .../editor/MultiSourceMergeView.vue | 385 +++++++++++++++++ .../MultiDbExecuteDialog.mergeReveal.spec.ts | 386 ++++++++++++++++++ .../__tests__/MultiDbExecuteDialog.spec.ts | 2 + .../MultiSourceMergeView.export.spec.ts | 209 ++++++++++ .../__tests__/MultiSourceMergeView.spec.ts | 225 ++++++++++ .../MultiSourceMergeView.transactions.spec.ts | 110 +++++ .../src/components/layout/AppDialogs.vue | 25 +- .../layout/MultiDbDangerConfirmDialog.vue | 89 ++++ .../AppDialogs.multiDbDangerConfirm.spec.ts | 171 ++++++++ .../__tests__/useMultiDbExecution.spec.ts | 178 +++++++- .../__tests__/useSqlExecution.spec.ts | 34 +- .../useSqlExecutionMultiDbFocus.spec.ts | 64 +++ .../src/composables/useMultiDbExecution.ts | 43 +- .../src/composables/useSqlExecution.ts | 121 ++++-- apps/desktop/src/i18n/locales/en.ts | 40 ++ apps/desktop/src/i18n/locales/es.ts | 40 ++ apps/desktop/src/i18n/locales/it.ts | 40 ++ apps/desktop/src/i18n/locales/ja.ts | 40 ++ apps/desktop/src/i18n/locales/ko.ts | 40 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 40 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 40 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 40 ++ .../__tests__/executionConfirmCode.spec.ts | 35 ++ .../query/__tests__/multiSourceResult.spec.ts | 199 +++++++++ .../src/lib/query/executionConfirmCode.ts | 26 ++ .../src/lib/query/multiSourceResult.ts | 209 ++++++++++ .../__tests__/sqlExecutionDangerStore.spec.ts | 67 +++ .../src/stores/sqlExecutionDangerStore.ts | 55 ++- apps/desktop/src/types/sqlExecution.ts | 4 +- 31 files changed, 3085 insertions(+), 87 deletions(-) create mode 100644 apps/desktop/src/components/editor/MultiSourceMergeView.vue create mode 100644 apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.mergeReveal.spec.ts create mode 100644 apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.export.spec.ts create mode 100644 apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.spec.ts create mode 100644 apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.transactions.spec.ts create mode 100644 apps/desktop/src/components/layout/MultiDbDangerConfirmDialog.vue create mode 100644 apps/desktop/src/components/layout/__tests__/AppDialogs.multiDbDangerConfirm.spec.ts create mode 100644 apps/desktop/src/lib/query/__tests__/executionConfirmCode.spec.ts create mode 100644 apps/desktop/src/lib/query/__tests__/multiSourceResult.spec.ts create mode 100644 apps/desktop/src/lib/query/executionConfirmCode.ts create mode 100644 apps/desktop/src/lib/query/multiSourceResult.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 54ecd59889..fdb2f53a6f 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -18,6 +18,8 @@ import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore"; +import type { MultiDbExecutionContext } from "@/composables/useMultiDbExecution"; +import type { MultiDbExecutionTarget } from "@/types/sqlExecution"; import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; @@ -1005,7 +1007,7 @@ function multiExecuteTargetLabel(target: { connectionId: string; catalog?: strin return [connection?.name || target.connectionId, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / "); } -async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number; manualTransaction?: boolean }; isCancellationRequested: () => boolean }) { +async function executeMultiDbTarget(input: { target: MultiDbExecutionTarget; sourceTabId: string; sql: string; scopeId: string; context: Readonly; isCancellationRequested: () => boolean }) { const tab = queryStore.tabs.find((candidate) => candidate.id === input.sourceTabId); const connection = connectionStore.getConfig(input.target.connectionId); if (!tab || !connection) return { status: "failed" as const, errorMessage: t("multiDbExecute.targetMissingConnection") }; @@ -1023,6 +1025,8 @@ async function executeMultiDbTarget(input: { target: { connectionId: string; cat manualTransaction: input.context.manualTransaction, blockDangerousRedisCommands: blockDangerousRedisCommands.value, targetLabel: multiExecuteTargetLabel(input.target), + // The confirmation must list the whole fan-out, not just this target. + batchTargetLabels: input.context.targets.map(multiExecuteTargetLabel), scopeId: input.scopeId, isCancellationRequested: input.isCancellationRequested, targetContext: sqlExecutionTargetCapabilities(connection)?.provider.toExecutionContext(input.target, connection), diff --git a/apps/desktop/src/components/editor/MultiDbExecuteDialog.vue b/apps/desktop/src/components/editor/MultiDbExecuteDialog.vue index 5fa1353813..2180a211c9 100644 --- a/apps/desktop/src/components/editor/MultiDbExecuteDialog.vue +++ b/apps/desktop/src/components/editor/MultiDbExecuteDialog.vue @@ -1,7 +1,7 @@ + + diff --git a/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.mergeReveal.spec.ts b/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.mergeReveal.spec.ts new file mode 100644 index 0000000000..c5622c4e0d --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.mergeReveal.spec.ts @@ -0,0 +1,386 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { computed, createApp, h, nextTick, ref, type App, type Ref } from "vue"; +import { createPinia } from "pinia"; +import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue"; +import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore"; +import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution"; +import type { QueryResult } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined), + exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined), + registerTaskCancelHandler: vi.fn(), + unregisterTaskCancelHandler: vi.fn(), +})); + +// Parameters are rendered so a call that forgot them is visible in the DOM. +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string, params?: Record) => (params ? `${key}(${Object.values(params).join("/")})` : key) }), +})); +// Web runtime: exports skip the save dialog and manual transactions are unavailable. +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); +vi.mock("@/lib/backend/api", () => ({ + exportQueryResultsXlsx: mocks.exportQueryResultsXlsx, + exportQueryResultXlsx: mocks.exportQueryResultXlsx, +})); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/stores/sqlExecutionTargetGroupStore", () => ({ useSqlExecutionTargetGroupStore: () => ({ getGroupsByDatabaseType: () => [], getGroup: () => undefined }) })); +vi.mock("@/composables/useExportTracker", () => ({ + formatDataTransferDuration: String, + useExportTracker: () => ({ addMultiDbExecutionTask: vi.fn(), updateMultiDbExecutionTask: vi.fn(), registerTaskCancelHandler: mocks.registerTaskCancelHandler, unregisterTaskCancelHandler: mocks.unregisterTaskCancelHandler }), +})); +vi.mock("@/composables/useMultiDbTargetSelection", () => ({ + useMultiDbTargetSelection: () => ({ + compatibleConnections: computed(() => []), + connection: () => ({ id: "test", name: "Test", db_type: "oceanbase-oracle" }), + loadConnection: vi.fn().mockResolvedValue(undefined), + validateTarget: async () => ({ state: "valid" }), + validateTargets: async (targets: unknown[]) => targets.map((target) => ({ target, state: "valid" })), + }), +})); + +const SQL = "SELECT id, amount FROM t"; +const TWO_TARGETS = [ + { connectionId: "test", database: "db-a" }, + { connectionId: "test", database: "db-b" }, +]; + +function table(rows: (string | number)[][]): QueryResult { + return { columns: ["id", "amount"], rows, affected_rows: rows.length, execution_time_ms: 1 }; +} + +function write(affectedRows: number): QueryResult { + return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 1 }; +} + +function resultFor(database: string): QueryResult { + return database === "db-a" ? table([[1, 10]]) : table([[2, 20]]); +} + +let app: App | undefined; + +afterEach(() => { + app?.unmount(); + app = undefined; + document.body.innerHTML = ""; + mocks.toast.mockClear(); + mocks.exportQueryResultsXlsx.mockClear(); + mocks.registerTaskCancelHandler.mockClear(); + mocks.unregisterTaskCancelHandler.mockClear(); + vi.restoreAllMocks(); +}); + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); +} + +function button(label: string): HTMLButtonElement { + const found = [...document.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === label); + if (!found) throw new Error(`Missing button ${label}`); + return found; +} + +/** Mounts with a reactive `open` so minimizing and reopening can be observed. */ +interface DialogOptions { + open: Ref; + targets?: Array<{ connectionId: string; database: string }>; + launchId?: Ref; + cancelTarget?: MultiDbExecutionAdapter["cancelTarget"]; + cancelPending?: MultiDbExecutionAdapter["cancelPending"]; +} + +function mountDialog(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) { + const root = document.createElement("div"); + document.body.append(root); + app = createApp({ + render: () => + h(MultiDbExecuteDialog, { + open: options.open.value, + "onUpdate:open": (value: boolean) => (options.open.value = value), + sql: SQL, + sourceTabId: "source", + databaseType: "oceanbase-oracle", + initialTargets: options.targets ?? TWO_TARGETS, + launchId: options.launchId?.value ?? 1, + executeTarget, + cancelTarget: options.cancelTarget, + cancelPending: options.cancelPending, + }), + }); + app.use(createPinia()); + app.mount(root); +} + +async function executeBatch(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) { + mountDialog(executeTarget, options); + await flushPromises(); + button("multiDbExecute.execute").click(); + await flushPromises(); +} + +describe("multi-database merged view reveal paths", () => { + it("unions every source and labels the rows, the statement and the batch timings", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) })); + await executeBatch(executeTarget, { open }); + + button("multiDbExecute.mergeResults").click(); + await nextTick(); + + const view = document.querySelector("[data-multi-source-merge-view]"); + expect(view).not.toBeNull(); + const rows = [...(view?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim())); + expect(rows).toEqual([ + ["Test / db-a", "1", "10"], + ["Test / db-b", "2", "20"], + ]); + + // The statement, the elapsed time and the start time come from the batch. + expect(document.querySelector("[data-merge-sql]")?.textContent?.trim()).toBe(SQL); + expect(document.querySelector("[data-merge-duration]")?.textContent?.trim()).not.toBe(""); + expect(document.querySelector("[data-merge-executed-at]")?.textContent?.trim()).not.toBe(""); + }); + + it("names the merged view in the title instead of the bar's parametrized progress", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) })); + await executeBatch(executeTarget, { open }); + + const title = () => document.querySelector('[data-slot="dialog-title"]')?.textContent?.trim() ?? ""; + expect(title()).toContain("multiDbExecute.progress("); + + button("multiDbExecute.mergeResults").click(); + await nextTick(); + + expect(title()).toBe("multiDbExecute.mergeResults"); + expect(document.body.textContent).toContain("multiDbExecute.progress(2/2)"); + }); + + it("exports the merged rows and the statement through the dialog footer", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) })); + await executeBatch(executeTarget, { open }); + + button("multiDbExecute.mergeResults").click(); + await nextTick(); + (document.querySelector("[data-multi-db-merge-export-all]") as HTMLButtonElement).click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1)); + + const sheets = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Array<{ sheetName: string; rows: unknown[][] }>])[1]; + expect(sheets[0]?.rows).toHaveLength(2); + expect(sheets[1]?.rows.flat().join("\n")).toContain(SQL); + }); + + it("re-runs only the failed target from the per-target list", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "failed" as const, durationMs: 2, errorMessage: "boom" })); + await executeBatch(executeTarget, { open }); + + button("multiDbExecute.mergeResults").click(); + await nextTick(); + expect(document.body.textContent).toContain("boom"); + + executeTarget.mockImplementation(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "success" as const, durationMs: 6, result: write(1) })); + (document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click(); + await flushPromises(); + + // The adapter ran for the retried target only, and the row shows the retry's own outcome. + expect(executeTarget).toHaveBeenCalledTimes(3); + expect(executeTarget.mock.calls[2]?.[0].target.database).toBe("db-b"); + expect(document.querySelector("[data-merge-rerun]")).toBeNull(); + expect(document.body.textContent).not.toContain("boom"); + }); + + it("keeps deferred retries cancellable across reopen and rolls back their late transactions on unmount", async () => { + const open = ref(true); + const launchId = ref(1); + const cancelTarget = vi.fn().mockResolvedValue(undefined); + const cancelPending = vi.fn(); + const finish = vi.fn().mockResolvedValue(undefined); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const executeTarget = vi.fn().mockResolvedValue({ status: "failed", errorMessage: "boom" }); + await executeBatch(executeTarget, { open, launchId, cancelTarget, cancelPending }); + const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id"); + button("multiDbExecute.mergeResults").click(); + await nextTick(); + mocks.registerTaskCancelHandler.mockClear(); + executeTarget.mockImplementationOnce(async () => { + await gate; + return { status: "pending_commit", transaction: { canCommit: true, finish } }; + }); + (document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click(); + await flushPromises(); + + expect(document.querySelector("[data-merge-rerun]")?.disabled).toBe(true); + expect(mocks.registerTaskCancelHandler).toHaveBeenLastCalledWith(batchId, expect.any(Function)); + const cancel = mocks.registerTaskCancelHandler.mock.calls.at(-1)![1] as () => Promise; + open.value = false; + await flushPromises(); + launchId.value += 1; + open.value = true; + await flushPromises(); + expect(document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id")).toBe(batchId); + expect(executeTarget).toHaveBeenCalledTimes(3); + await cancel(); + expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batchId); + expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batchId); + expect(executeTarget.mock.calls[2]![0].isCancellationRequested()).toBe(true); + + app?.unmount(); + app = undefined; + expect(finish).not.toHaveBeenCalled(); + release(); + await vi.waitFor(() => expect(finish).toHaveBeenCalledExactlyOnceWith("rollback")); + }); + + it.each([0, 5000])("resets confirmation timing for a new batch with %i ms wait but preserves same-batch reopen", async (secondWaitMs) => { + let now = 1000; + vi.spyOn(Date, "now").mockImplementation(() => now); + const open = ref(true); + const launchId = ref(1); + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + const executeTarget = vi.fn(async (input) => { + if (input.target.database === "db-a") await gate; + return { status: "success", result: resultFor(input.target.database) }; + }); + await executeBatch(executeTarget, { open, launchId }); + const firstBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id"); + const dangerStore = useSqlExecutionDangerStore(); + dangerStore.pending = { sql: SQL, kind: "sql", scopeId: firstBatchId ?? undefined, targetLabel: "Test / db-a" }; + await nextTick(); + now = 11_000; + dangerStore.pending = undefined; + await nextTick(); + now = 11_100; + release(); + await flushPromises(); + expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)"); + + open.value = false; + await flushPromises(); + open.value = true; + await flushPromises(); + expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)"); + expect(executeTarget).toHaveBeenCalledTimes(2); + + now = 20_000; + gate = new Promise((resolve) => { + release = resolve; + }); + launchId.value += 1; + await flushPromises(); + button("multiDbExecute.execute").click(); + await flushPromises(); + const secondBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id"); + expect(secondBatchId).not.toBe(firstBatchId); + if (secondWaitMs > 0) { + dangerStore.pending = { sql: SQL, kind: "sql", scopeId: secondBatchId ?? undefined, targetLabel: "Test / db-a" }; + await nextTick(); + now += secondWaitMs; + dangerStore.pending = undefined; + await nextTick(); + } + now += 250; + release(); + await flushPromises(); + expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(250)"); + button("multiDbExecute.mergeResults").click(); + await nextTick(); + button("multiDbExecute.exportAllRows").click(); + await flushPromises(); + const sheets = mocks.exportQueryResultsXlsx.mock.calls[0]?.[1] as Array<{ sheetName: string; rows: string[][] }>; + expect( + sheets + .find((sheet) => sheet.sheetName === "SQL") + ?.rows.flat() + .join("\n"), + ).toContain("250 ms"); + }); + + it("keeps merging out of a single-target batch", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) })); + await executeBatch(executeTarget, { open, targets: [{ connectionId: "test", database: "db-a" }] }); + + expect(document.querySelector("[data-multi-db-merge]")).toBeNull(); + }); + + it("parks the clock and labels the target while its confirmation is on screen", async () => { + const open = ref(true); + const releases: Array<() => void> = []; + const executeTarget = vi.fn(async (input) => { + await new Promise((resolve) => releases.push(resolve)); + return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }; + }); + await executeBatch(executeTarget, { open }); + + const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id"); + expect(batchId).toBeTruthy(); + // The prompt the batch is waiting for, as the danger store publishes it. + useSqlExecutionDangerStore().pending = { sql: SQL, kind: "sql", scopeId: batchId ?? undefined, targetLabel: "Test / db-a" }; + await nextTick(); + + // The parked target is waiting for the operator, not running. + expect(document.body.textContent).toContain("multiDbExecute.awaitingConfirmation"); + const elapsedBefore = document.querySelector("[data-multi-db-elapsed]")?.textContent; + expect(elapsedBefore).toBeTruthy(); + await new Promise((resolve) => setTimeout(resolve, 1_200)); + await nextTick(); + expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe(elapsedBefore); + + releases.splice(0).forEach((release) => release()); + }); + + it("announces a batch that finished while minimized with an action into the merged view", async () => { + const open = ref(true); + const releases: Array<() => void> = []; + const executeTarget = vi.fn(async (input) => { + await new Promise((resolve) => releases.push(resolve)); + return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }; + }); + await executeBatch(executeTarget, { open }); + + // Minimize while the targets are still running, then let them finish. + open.value = false; + await nextTick(); + for (let round = 0; round < 4; round += 1) { + releases.splice(0).forEach((release) => release()); + await flushPromises(); + } + + const announcement = mocks.toast.mock.calls.at(-1) as [string, number, { label: string; onClick: () => void }]; + expect(announcement[0]).toBe("multiDbExecute.backgroundBatchDone(2)"); + expect(announcement[1]).toBe(8000); + expect(announcement[2].label).toBe("multiDbExecute.mergeResults"); + + announcement[2].onClick(); + await nextTick(); + expect(open.value).toBe(true); + expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull(); + }); + + it("reopens a settled batch straight in the merged view without re-running it", async () => { + const open = ref(true); + const executeTarget = vi.fn(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) })); + await executeBatch(executeTarget, { open }); + + button("common.close").click(); + await flushPromises(); + expect(open.value).toBe(false); + + open.value = true; + await flushPromises(); + + expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull(); + expect(executeTarget).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.spec.ts b/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.spec.ts index 62f715d929..ed4825423a 100644 --- a/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/MultiDbExecuteDialog.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { computed, createApp, nextTick, type App } from "vue"; +import { createPinia } from "pinia"; import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue"; import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution"; @@ -37,6 +38,7 @@ async function openDialog(executeTarget: MultiDbExecutionAdapter["executeTarget" const root = document.createElement("div"); document.body.append(root); app = createApp(MultiDbExecuteDialog, { open: true, "onUpdate:open": onOpenChange, sql: "INSERT INTO t VALUES (1)", sourceTabId: "source", databaseType: "oceanbase-oracle", initialTargets: [{ connectionId: "test", database: "test" }], launchId: 1, initialManualTransaction: true, executeTarget }); + app.use(createPinia()); app.mount(root); await flushPromises(); const checkbox = document.querySelector('input[type="checkbox"]'); diff --git a/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.export.spec.ts b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.export.spec.ts new file mode 100644 index 0000000000..93ec702b40 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.export.spec.ts @@ -0,0 +1,209 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue"; +import type { QueryResult } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined), + exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined), + toast: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + exportQueryResultsXlsx: mocks.exportQueryResultsXlsx, + exportQueryResultXlsx: mocks.exportQueryResultXlsx, +})); +// The web build assembles the workbook in the browser; the desktop build would +// first ask for a path, which is covered by the shared export code instead. +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); + +interface Sheet { + sheetName: string; + columns: string[]; + rows: Array>; +} + +function result(columns: string[], rows: (string | number | null)[][], affectedRows = rows.length): QueryResult { + return { columns, rows, affected_rows: affectedRows, execution_time_ms: 1 }; +} + +function mountView(props: Record) { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp({ render: () => h(MultiSourceMergeView, props) }); + app.use(i18n); + app.mount(host); + return { host, app }; +} + +let mounted: { host: HTMLElement; app: App } | undefined; + +beforeEach(() => { + mocks.exportQueryResultsXlsx.mockClear(); + mocks.exportQueryResultXlsx.mockClear(); + mocks.toast.mockClear(); +}); + +afterEach(() => { + mounted?.app.unmount(); + mounted?.host.remove(); + mounted = undefined; +}); + +function exportButton(selector: string): HTMLButtonElement { + const button = mounted?.host.querySelector(selector); + if (!button) throw new Error(`Missing export button ${selector}`); + return button; +} + +describe("merged view export", () => { + it.each(["page", "all"])("omits hidden middle columns from the display, summaries and %s export", async (scope) => { + mounted = mountView({ + items: [ + { key: "a", label: "conn-a", status: "success", result: { ...result(["name", "__DBX_PK_0", "amount"], [["Alice", 987654, 10]]), hidden_column_indexes: [1] } }, + { key: "b", label: "conn-b", status: "success", result: result(["amount", "name"], [[20, "Bob"]]) }, + ], + sql: "SELECT name, amount FROM t", + }); + await nextTick(); + + expect([...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim())).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]); + expect([...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()))).toEqual([ + ["conn-a", "Alice", "10"], + ["conn-b", "Bob", "20"], + ]); + expect(mounted.host.querySelector("tfoot")?.textContent).toContain("30"); + expect(mounted.host.textContent).not.toContain("__DBX_PK_0"); + expect(mounted.host.textContent).not.toContain("987654"); + + exportButton(`[data-merge-export-${scope}]`).click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1)); + const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0]; + expect(sheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]); + expect(sheet?.rows).toEqual([ + ["conn-a", "Alice", 10], + ["conn-b", "Bob", 20], + ]); + }); + + it("exports every merged row plus a SQL sheet carrying the statement, the start time and the duration", async () => { + mounted = mountView({ + items: [ + { key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], [[1, 10]]) }, + { key: "b", label: "conn-b", status: "success", result: result(["id", "amount"], [[2, 20]]) }, + ], + sql: "SELECT id, amount FROM t", + durationMs: 1234, + executedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + }); + await nextTick(); + + exportButton("[data-merge-export-all]").click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1)); + + const [outputPath, sheets, ...rest] = mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[], boolean]; + expect(outputPath).toMatch(/^dbx-merged-result_all_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.xlsx$/); + expect(rest).toEqual([true]); + + const [dataSheet, sqlSheet] = sheets; + expect(dataSheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id", "amount"]); + expect(dataSheet?.rows).toEqual([ + ["conn-a", 1, 10], + ["conn-b", 2, 20], + ]); + // The statement and the batch timings travel in their own sheet. + expect(sqlSheet?.sheetName).toBe("SQL"); + const sqlCells = (sqlSheet?.rows ?? []).flat().join("\n"); + expect(sqlCells).toContain("SELECT id, amount FROM t"); + expect(sqlCells).toContain(i18n.global.t("multiDbExecute.durationColumn")); + expect(sqlCells).toContain("1.2 s"); + expect(String(sqlCells)).toMatch(/20\d\d-\d\d-\d\d/); + }); + + it("exports only the rows on screen for the current page", async () => { + const rows = Array.from({ length: 1200 }, (_, index) => [index, index * 2]); + mounted = mountView({ + items: [{ key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], rows) }], + sql: "SELECT id, amount FROM big", + durationMs: 10, + }); + await nextTick(); + + exportButton("[data-merge-export-page]").click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1)); + const pageSheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0]; + expect(pageSheet?.rows).toHaveLength(1000); + + exportButton("[data-merge-export-all]").click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(2)); + const allSheet = (mocks.exportQueryResultsXlsx.mock.calls[1] as [string, Sheet[]])[1][0]; + expect(allSheet?.rows).toHaveLength(1200); + }); + + it("exports the per-target list when the batch returned no result set", async () => { + mounted = mountView({ + items: [ + { key: "a", label: "conn-a", status: "success", durationMs: 12, result: result([], [], 3) }, + { key: "b", label: "conn-b", status: "failed", durationMs: 9, errorMessage: "syntax error" }, + { key: "c", label: "conn-c", status: "pending_commit", durationMs: 5, result: result([], [], 7), transaction: { canCommit: true } }, + ], + transactional: true, + sql: "UPDATE t SET amount = 0", + durationMs: 40, + }); + await nextTick(); + + exportButton("[data-merge-export-all]").click(); + await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1)); + + const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0]; + expect(sheet?.columns).toEqual([ + i18n.global.t("multiDbExecute.mergedSourceColumn"), + i18n.global.t("multiDbExecute.statusColumn"), + i18n.global.t("multiDbExecute.txnColumn"), + i18n.global.t("multiDbExecute.affectedRowsColumn"), + i18n.global.t("multiDbExecute.durationColumn"), + i18n.global.t("multiDbExecute.errorColumn"), + ]); + expect(sheet?.rows.map((row) => row[0])).toEqual(["conn-a", "conn-b", "conn-c"]); + // A statement that ran inside a transaction still reports its row count. + expect(sheet?.rows[0]?.[3]).toBe(3); + expect(sheet?.rows[2]?.[3]).toBe(7); + expect(sheet?.rows[1]?.[3]).toBe(""); + expect(String(sheet?.rows[1]?.[5])).toBe("syntax error"); + expect(sheet?.rows[0]?.[2]).toBe(i18n.global.t("multiDbExecute.txnCommitted")); + expect(sheet?.rows[2]?.[2]).toBe(i18n.global.t("multiDbExecute.txnOpen")); + }); + + it("keeps a single-sheet workbook when the batch carries no SQL context", async () => { + mounted = mountView({ + items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], [[1]]) }], + }); + await nextTick(); + + exportButton("[data-merge-export-all]").click(); + await vi.waitFor(() => expect(mocks.exportQueryResultXlsx).toHaveBeenCalledTimes(1)); + + const [outputPath, sheetName, columns, , , rows] = mocks.exportQueryResultXlsx.mock.calls[0] as [string, string, string[], string[], unknown, unknown[][]]; + expect(outputPath).toMatch(/\.xlsx$/); + expect(sheetName).toBe(i18n.global.t("multiDbExecute.mergedSheetName")); + expect(columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id"]); + expect(rows).toEqual([["conn-a", 1]]); + expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled(); + }); + + it("refuses to export an empty merged table", async () => { + mounted = mountView({ items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], []) }] }); + await nextTick(); + + exportButton("[data-merge-export-all]").click(); + await nextTick(); + + expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled(); + expect(mocks.exportQueryResultXlsx).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith(i18n.global.t("multiDbExecute.mergedEmpty"), 3000); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.spec.ts b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.spec.ts new file mode 100644 index 0000000000..0038e63c5e --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.spec.ts @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick } from "vue"; +import { afterEach, describe, expect, it } from "vitest"; +import i18n from "@/i18n"; +import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue"; +import { buildXlsxWorkbook } from "@/lib/export/xlsxExport"; +import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE, mergeMultiSourceResults } from "@/lib/query/multiSourceResult"; +import type { QueryResult } from "@/types/database"; + +function result(columns: string[], rows: (string | number | null)[][]): QueryResult { + return { columns, rows, affected_rows: rows.length, execution_time_ms: 1 }; +} + +function mountView(items: Array<{ key: string; label: string; result?: QueryResult; status?: "success" | "failed" | "skipped" | "cancelled" | "not_executed" | "running" | "pending"; durationMs?: number; errorMessage?: string }>, props: Record = {}) { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp({ render: () => h(MultiSourceMergeView, { items, ...props }) }); + app.use(i18n); + app.mount(host); + return { host, app }; +} + +let mounted: ReturnType | undefined; + +afterEach(() => { + mounted?.app.unmount(); + mounted?.host.remove(); + mounted = undefined; +}); + +describe("MultiSourceMergeView", () => { + it("unions source columns, labels every row with its source and pads missing columns", async () => { + mounted = mountView([ + { key: "a", label: "conn-a", result: result(["id", "amount"], [[1, 10]]) }, + { key: "b", label: "conn-b", result: result(["amount", "region"], [[20, "north"]]) }, + ]); + await nextTick(); + + const headers = [...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim()); + expect(headers.slice(1)).toEqual(["id", "amount", "region"]); + + const rows = [...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim())); + expect(rows).toHaveLength(2); + expect(rows[0]?.slice(1)).toEqual(["1", "10", ""]); + expect(rows[1]?.slice(1)).toEqual(["", "20", "north"]); + expect(rows[0]?.[0]).toContain("conn-a"); + expect(rows[1]?.[0]).toContain("conn-b"); + }); + + it("sums only all-numeric columns in the footer", async () => { + mounted = mountView([ + { key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) }, + { key: "b", label: "conn-b", result: result(["id", "amount", "note"], [[2, "n/a", "y"]]) }, + ]); + await nextTick(); + + const footer = [...(mounted.host.querySelector("tfoot")?.querySelectorAll("td") ?? [])].map((cell) => cell.textContent?.trim()); + // First cell is the SUM label, then one cell per data column. + expect(footer).toHaveLength(4); + expect(footer[1]).toBe("3"); + // `amount` mixes 10 and "n/a", so its column is not a total. + expect(footer[2]).toBe(""); + expect(footer[3]).toBe(""); + }); + + it("reports the merged source count and row count", async () => { + mounted = mountView([ + { key: "a", label: "conn-a", result: result(["id"], [[1]]) }, + { key: "b", label: "conn-b", result: result(["id"], [[2]]) }, + ]); + await nextTick(); + + const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? ""; + expect(legend).toContain("2"); + }); + + it("states the per-source row cap and warns explicitly when a source hit it", async () => { + const cappedRows = Array.from({ length: MULTI_SOURCE_MAX_ROWS_PER_SOURCE }, (_, index) => [index, index]); + mounted = mountView([ + { key: "a", label: "conn-a", result: result(["id", "amount"], cappedRows) }, + { key: "b", label: "conn-b", result: result(["id", "amount"], [[1, 2]]) }, + ]); + await nextTick(); + + const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? ""; + // The cap is always stated, and reaching it is called out in amber. + expect(legend).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE)); + expect(mounted.host.querySelector("[data-merge-source-cap-reached]")?.textContent).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE)); + }); + + it("does not warn about the per-source cap while every source stays below it", async () => { + mounted = mountView([ + { key: "a", label: "conn-a", result: result(["id"], [[1], [2]]) }, + { key: "b", label: "conn-b", result: result(["id"], [[3]]) }, + ]); + await nextTick(); + + expect(mounted.host.querySelector("[data-merge-source-cap-reached]")).toBeNull(); + expect(mounted.host.querySelector("[data-merge-truncated]")).toBeNull(); + }); + + it("feeds the Excel writer a workbook-ready payload", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) }, + { key: "b", label: "conn-b", result: result(["amount"], [[2.5]]) }, + ], + { sourceColumnLabel: "来源" }, + ); + + // Same call shape the export action uses (no column types available). + const workbook = buildXlsxWorkbook({ + sheetName: "合并结果", + columns: merged.columns, + columnTypes: merged.columns.map(() => ""), + rows: merged.rows, + numericColumnRightAlign: true, + autoFilter: true, + }); + + expect(workbook.byteLength).toBeGreaterThan(0); + // XLSX is a zip container; the local file header magic proves the writer + // accepted the merged rows (nulls and mixed types included). + expect(String.fromCharCode(...workbook.subarray(0, 2))).toBe("PK"); + }); + + it("shows the executed statement and the batch duration in the header", async () => { + mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { sql: "SELECT id FROM sales", durationMs: 1250 }); + await nextTick(); + + const header = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? ""; + expect(header).toContain("SELECT id FROM sales"); + expect(header).toContain("1.3 s"); + expect(mounted.host.querySelector("[data-merge-sql]")?.getAttribute("title")).toBe("SELECT id FROM sales"); + }); + + it("shows the local execution timestamp of the batch", async () => { + const executedAt = new Date(2026, 8, 21, 14, 32, 5).getTime(); + mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { executedAt }); + await nextTick(); + + const chip = mounted.host.querySelector("[data-merge-executed-at]")?.textContent ?? ""; + expect(chip).toContain("2026-09-21 14:32:05"); + // Timezone stays explicit, like the app's other local timestamps. + expect(chip).toMatch(/[+-]\d{2}:\d{2}/); + }); + + it("omits the SQL and duration chips when the host does not provide them", async () => { + mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }]); + await nextTick(); + + expect(mounted.host.querySelector("[data-merge-sql]")).toBeNull(); + expect(mounted.host.querySelector("[data-merge-duration]")).toBeNull(); + expect(mounted.host.querySelector("[data-merge-executed-at]")).toBeNull(); + }); + + describe("write statements without a result set", () => { + function writeItems() { + return [ + { + key: "a", + label: "conn-a", + status: "success" as const, + durationMs: 41, + result: { columns: [], rows: [], affected_rows: 12, execution_time_ms: 41 }, + }, + { + key: "b", + label: "conn-b", + status: "failed" as const, + durationMs: 17, + errorMessage: "no such table: t_order", + result: { columns: ["Error"], rows: [["no such table: t_order"]], affected_rows: 0, execution_time_ms: 17, execution_error: true as const }, + }, + ]; + } + + it("lists one row per target with status, affected rows and the error", async () => { + mounted = mountView(writeItems()); + await nextTick(); + + const table = mounted.host.querySelector("[data-merge-target-results]"); + expect(table).not.toBeNull(); + const rows = [...(table?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/g, " ").trim())); + expect(rows).toHaveLength(2); + expect(rows[0]?.[0]).toBe("conn-a"); + expect(rows[0]?.[2]).toContain("12"); + expect(rows[1]?.[0]).toBe("conn-b"); + expect(rows[1]?.[2]).toBe("—"); + expect(rows[1]?.[4]).toContain("no such table"); + }); + + it("summarizes the batch by targets and affected rows instead of read statistics", async () => { + mounted = mountView(writeItems()); + await nextTick(); + + expect(mounted.host.querySelector("[data-merge-write-summary]")?.textContent?.trim()).toBe(i18n.global.t("multiDbExecute.mergedWriteSummary", { targets: 2, rows: 12 })); + // A write batch must not advertise the per-source read cap it never uses. + expect(mounted.host.textContent).not.toContain(i18n.global.t("multiDbExecute.mergedSourceRowCap", { count: MULTI_SOURCE_MAX_ROWS_PER_SOURCE })); + // The column already names the count, so the cell carries the bare number. + const affected = [...(mounted.host.querySelectorAll("[data-merge-target-results] tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")][2]?.textContent?.trim()); + expect(affected).toEqual(["12", "—"]); + }); + + it("offers a re-run only for targets that did not succeed", async () => { + const rerun: string[] = []; + mounted = mountView(writeItems(), { onRerunTarget: (key: string) => rerun.push(key) }); + await nextTick(); + + const buttons = [...mounted.host.querySelectorAll("[data-merge-rerun]")]; + expect(buttons).toHaveLength(1); + (buttons[0] as HTMLButtonElement).click(); + await nextTick(); + expect(rerun).toEqual(["b"]); + }); + + it("blocks re-running while the batch is still busy", async () => { + mounted = mountView(writeItems(), { rerunDisabled: true }); + await nextTick(); + + const rerunButton = mounted.host.querySelector("[data-merge-rerun]") as HTMLButtonElement | null; + expect(rerunButton?.disabled).toBe(true); + }); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.transactions.spec.ts b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.transactions.spec.ts new file mode 100644 index 0000000000..3d89f4d9cc --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/MultiSourceMergeView.transactions.spec.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick } from "vue"; +import { afterEach, describe, expect, it } from "vitest"; +import i18n from "@/i18n"; +import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue"; +import type { QueryResult } from "@/types/database"; + +function writeResult(affectedRows: number): QueryResult { + return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 7 }; +} + +/** One batch that ran inside per-target transactions. */ +function items() { + return [ + { key: "a", label: "conn-a", status: "pending_commit" as const, durationMs: 12, result: writeResult(3), transaction: { canCommit: true } }, + // A target whose session survived a rollback failure: it can still be rolled back. + { key: "b", label: "conn-b", status: "failed" as const, durationMs: 9, errorMessage: "syntax error", transaction: { canCommit: false } }, + { key: "c", label: "conn-c", status: "success" as const, durationMs: 5, result: writeResult(0) }, + { key: "d", label: "conn-d", status: "rolled_back" as const, durationMs: 4 }, + ]; +} + +function mountView(props: Record) { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp({ render: () => h(MultiSourceMergeView, props) }); + app.use(i18n); + app.mount(host); + return { host, app }; +} + +let mounted: ReturnType | undefined; + +afterEach(() => { + mounted?.app.unmount(); + mounted?.host.remove(); + mounted = undefined; +}); + +describe("MultiSourceMergeView transactions", () => { + it("shows the transaction state per target and offers commit/rollback only while open", async () => { + mounted = mountView({ items: items(), transactional: true }); + await nextTick(); + + const states = [...mounted.host.querySelectorAll("[data-merge-txn-state]")].map((cell) => cell.textContent?.trim()); + expect(states).toEqual([i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnCommitted"), i18n.global.t("multiDbExecute.txnRolledBack")]); + + // Two open sessions: each offers one commit and one rollback button. + expect(mounted.host.querySelectorAll("[data-merge-commit]")).toHaveLength(2); + expect(mounted.host.querySelectorAll("[data-merge-rollback]")).toHaveLength(2); + }); + + it("blocks committing a target that errored and counts only committable targets", async () => { + mounted = mountView({ items: items(), transactional: true }); + await nextTick(); + + const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[]; + expect(commitButtons[0]?.disabled).toBe(false); + // conn-b failed, so its transaction has nothing to write but can be discarded. + expect(commitButtons[1]?.disabled).toBe(true); + + const commitAll = mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement; + expect(commitAll.textContent).toContain("1"); + }); + + it("blocks every transaction control while one of them is settling", async () => { + const settling = items(); + settling[0] = { ...settling[0]!, transaction: { canCommit: true, settling: true } } as (typeof settling)[number]; + mounted = mountView({ items: settling, transactional: true }); + await nextTick(); + + const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[]; + const rollbackButtons = [...mounted.host.querySelectorAll("[data-merge-rollback]")] as HTMLButtonElement[]; + expect(commitButtons[0]?.disabled).toBe(true); + expect(rollbackButtons[0]?.disabled).toBe(true); + }); + + it("emits per-target and batch transaction actions", async () => { + const events: string[] = []; + mounted = mountView({ + items: items(), + transactional: true, + onCommitTarget: (key: string) => events.push(`commit:${key}`), + onRollbackTarget: (key: string) => events.push(`rollback:${key}`), + onCommitAll: () => events.push("commit-all"), + onRollbackAll: () => events.push("rollback-all"), + }); + await nextTick(); + + (mounted.host.querySelectorAll("[data-merge-commit]")[0] as HTMLButtonElement).click(); + (mounted.host.querySelectorAll("[data-merge-rollback]")[1] as HTMLButtonElement).click(); + (mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement).click(); + (mounted.host.querySelector("[data-merge-rollback-all]") as HTMLButtonElement).click(); + await nextTick(); + + expect(events).toEqual(["commit:a", "rollback:b", "commit-all", "rollback-all"]); + }); + + it("hides every transaction control for an auto-commit batch", async () => { + const autoCommit = items().map(({ transaction: _transaction, ...item }) => ({ ...item, status: item.status === "pending_commit" ? ("success" as const) : item.status })); + mounted = mountView({ items: autoCommit, transactional: false }); + await nextTick(); + + expect(mounted.host.querySelector("[data-merge-txn-state]")).toBeNull(); + expect(mounted.host.querySelector("[data-merge-commit-all]")).toBeNull(); + expect(mounted.host.querySelector("[data-merge-rollback-all]")).toBeNull(); + // A failed target without a session keeps its re-run affordance instead. + expect(mounted.host.querySelector("[data-merge-rerun]")).not.toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 3117e99658..a01216dfa8 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; const ConnectionDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionDialog.vue")); const DangerConfirmDialog = defineAsyncComponent(() => import("@/components/editor/DangerConfirmDialog.vue")); +const MultiDbDangerConfirmDialog = defineAsyncComponent(() => import("@/components/layout/MultiDbDangerConfirmDialog.vue")); const SqlParameterDialog = defineAsyncComponent(() => import("@/components/editor/SqlParameterDialog.vue")); const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue")); const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue")); @@ -126,16 +127,6 @@ watch( if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS; }, ); -const multiDbDangerDetails = computed(() => { - const request = sqlExecutionDangerStore.pending; - if (!request) return ""; - return [request.targetLabel || request.connectionName, request.database].filter(Boolean).join("\n"); -}); -const multiDbDangerMessage = computed(() => { - const request = sqlExecutionDangerStore.pending; - return request?.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message"); -}); - const editConfig = computed(() => { const id = connectionStore.editingConnectionId; if (!id) return undefined; @@ -230,19 +221,7 @@ watch( - + +import { computed, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { Check, Copy } from "@lucide/vue"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; +import { copyToClipboard } from "@/lib/common/clipboard"; +import { createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode"; +import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore"; + +/** + * Confirmation for one multi-database batch. A batch runs the same statement + * against every data source, so the operator retypes a code that is generated + * per prompt: a stray click or a held Enter cannot fan a destructive statement + * out to all connections. + */ +const props = defineProps<{ request: SqlExecutionDangerRequest }>(); +const emit = defineEmits<{ confirm: []; cancel: [] }>(); + +const { t } = useI18n(); + +const details = computed(() => { + const headline = [props.request.targetLabel || props.request.connectionName, props.request.database].filter(Boolean).join(" · "); + const targets = props.request.targets ?? []; + if (targets.length <= 1) return headline; + return [headline, "", t("multiDbExecute.dangerTargets", { count: targets.length }), ...targets.map((target) => `• ${target}`)].join("\n"); +}); + +const message = computed(() => (props.request.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message"))); + +const code = ref(""); +const codeInput = ref(""); +const codeCopied = ref(false); +const confirmDisabled = computed(() => !matchesExecutionConfirmCode(codeInput.value, code.value)); + +// Every prompt gets its own code, and the previous attempt is cleared. +watch( + () => props.request, + () => { + code.value = createExecutionConfirmCode(); + codeInput.value = ""; + codeCopied.value = false; + }, + { immediate: true }, +); + +async function copyCode(): Promise { + await copyToClipboard(code.value); + codeCopied.value = true; + window.setTimeout(() => { + codeCopied.value = false; + }, 1500); +} + + + diff --git a/apps/desktop/src/components/layout/__tests__/AppDialogs.multiDbDangerConfirm.spec.ts b/apps/desktop/src/components/layout/__tests__/AppDialogs.multiDbDangerConfirm.spec.ts new file mode 100644 index 0000000000..d10485f53d --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/AppDialogs.multiDbDangerConfirm.spec.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import MultiDbDangerConfirmDialog from "@/components/layout/MultiDbDangerConfirmDialog.vue"; +import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore"; + +const mocks = vi.hoisted(() => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); + +vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard })); + +function request(overrides: Partial = {}): SqlExecutionDangerRequest { + return { + sql: "UPDATE accounts SET balance = 0", + kind: "sql", + connectionName: "prod-a", + database: "shop", + targetLabel: "prod-a / shop", + ...overrides, + }; +} + +function mountDialog(props: Record) { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp({ render: () => h(MultiDbDangerConfirmDialog, props) }); + app.use(i18n); + app.mount(host); + return { host, app }; +} + +let mounted: { host: HTMLElement; app: App } | undefined; + +afterEach(() => { + mounted?.app.unmount(); + mounted?.host.remove(); + mounted = undefined; + mocks.copyToClipboard.mockClear(); +}); + +/** The dialog content is portaled to the document body. */ +function codeValue(): string { + return document.body.querySelector("[data-multi-db-danger-code-value]")?.textContent?.trim() ?? ""; +} + +function codeInput(): HTMLInputElement { + const input = document.body.querySelector("[data-multi-db-danger-code]"); + if (!input) throw new Error("Missing code input"); + return input; +} + +function confirmButton(): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("multiDbExecute.dangerConfirm")); + if (!button) throw new Error("Missing confirm button"); + return button; +} + +async function typeCode(value: string) { + const input = codeInput(); + input.value = value; + input.dispatchEvent(new Event("input")); + await nextTick(); +} + +describe("multi-database batch confirmation", () => { + it("lists every data source of the batch next to the target that prompted", async () => { + mounted = mountDialog({ request: request({ targets: ["prod-a / shop", "prod-b / shop", "prod-c / shop"] }) }); + await nextTick(); + + const text = document.body.textContent ?? ""; + expect(text).toContain("prod-a / shop"); + expect(text).toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 3 })); + expect(text).toContain("• prod-b / shop"); + expect(text).toContain("• prod-c / shop"); + expect(document.body.textContent).toContain("UPDATE accounts SET balance = 0"); + }); + + it("keeps the headline only when the statement touches a single source", async () => { + mounted = mountDialog({ request: request({ targets: ["prod-a / shop"] }) }); + await nextTick(); + + expect(document.body.textContent).not.toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 1 })); + expect(document.body.textContent).toContain("prod-a / shop"); + }); + + it("unlocks the confirm button only for the code of this prompt", async () => { + mounted = mountDialog({ request: request() }); + await nextTick(); + + const code = codeValue(); + expect(code).toHaveLength(6); + expect(confirmButton().disabled).toBe(true); + + await typeCode("WRONG1"); + expect(confirmButton().disabled).toBe(true); + + await typeCode(code); + expect(confirmButton().disabled).toBe(false); + }); + + it("accepts a code typed in lower case", async () => { + mounted = mountDialog({ request: request() }); + await nextTick(); + + await typeCode(codeValue().toLowerCase()); + expect(confirmButton().disabled).toBe(false); + }); + + it("copies the displayed code and clears the typed attempt for a new prompt", async () => { + mounted = mountDialog({ request: request() }); + await nextTick(); + + const firstCode = codeValue(); + await typeCode(firstCode); + (document.body.querySelector("[data-multi-db-danger-code-copy]") as HTMLButtonElement).click(); + await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(firstCode)); + + // The next prompt is a fresh confirmation, not the previous one re-armed. + mounted.app.unmount(); + mounted.host.remove(); + mounted = mountDialog({ request: request({ sql: "DELETE FROM accounts" }) }); + await nextTick(); + const nextCode = codeValue(); + expect(nextCode).toHaveLength(6); + expect(codeInput().value).toBe(""); + expect(confirmButton().disabled).toBe(true); + }); + + it("reports confirm and cancel back to the batch", async () => { + const events: string[] = []; + mounted = mountDialog({ + request: request(), + onConfirm: () => events.push("confirm"), + onCancel: () => events.push("cancel"), + }); + await nextTick(); + + // Cancelling the prompt answers the batch without running it. + const cancel = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.cancel")); + cancel?.click(); + await nextTick(); + expect(events).toEqual(["cancel"]); + + await typeCode(codeValue()); + confirmButton().click(); + await nextTick(); + expect(events).toEqual(["cancel", "confirm"]); + }); + + it("still confirms the single-connection danger dialog without any code", async () => { + const { default: DangerConfirmDialog } = await import("@/components/editor/DangerConfirmDialog.vue"); + const host = document.createElement("div"); + document.body.appendChild(host); + const confirms: number[] = []; + const app = createApp({ render: () => h(DangerConfirmDialog, { open: true, title: "Drop table", message: "This cannot be undone", onConfirm: () => confirms.push(1) }) }); + app.use(i18n); + app.mount(host); + try { + await nextTick(); + expect(document.body.querySelector("[data-multi-db-danger-code]")).toBeNull(); + const confirm = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.confirm")); + expect(confirm).toBeDefined(); + confirm?.click(); + await nextTick(); + expect(confirms).toEqual([1]); + } finally { + app.unmount(); + host.remove(); + } + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useMultiDbExecution.spec.ts b/apps/desktop/src/composables/__tests__/useMultiDbExecution.spec.ts index e1ca963322..5370158423 100644 --- a/apps/desktop/src/composables/__tests__/useMultiDbExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useMultiDbExecution.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { ref } from "vue"; -import { useMultiDbExecution } from "@/composables/useMultiDbExecution"; +import { useMultiDbExecution, type MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution"; +import type { MultiDbTargetExecutionResult } from "@/types/sqlExecution"; const targets = [ { connectionId: "conn-1", database: "app" }, @@ -9,6 +10,181 @@ const targets = [ ] as const; describe("useMultiDbExecution", () => { + it.each(["serial", "parallel"] as const)("tracks a single-target retry in a %s batch while preserving successful targets and manual transactions", async (mode) => { + let release!: (result: MultiDbTargetExecutionResult) => void; + const retryResult = new Promise((resolve) => { + release = resolve; + }); + const finish = vi.fn().mockResolvedValue(undefined); + const validateTarget = vi.fn().mockResolvedValue({ valid: true }); + const executeTarget = vi.fn(async ({ target }) => (target.connectionId === "conn-1" ? { status: "pending_commit", transaction: { canCommit: true, finish } } : { status: "failed", durationMs: 999, errorMessage: "original failure" })); + const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" }); + const batch = (await executor.start("UPDATE t SET n = 1", targets.slice(0, 2), { manualTransaction: true, sourceOffset: 17 }, mode))!; + const [successful, failed] = batch.items; + const originalContext = executeTarget.mock.calls[1]![0].context; + const originalStartedAt = batch.startedAt; + executeTarget.mockImplementationOnce(() => retryResult); + + const retry = executor.retry(failed!.id); + expect(executor.isRunning.value).toBe(true); + expect(batch.status).toBe("running"); + expect(batch.completedAt).toBeUndefined(); + expect(batch.durationMs).toBeUndefined(); + expect(failed).toMatchObject({ status: "running", errorMessage: undefined, durationMs: undefined, completedAt: undefined }); + executor.reset(); + expect(executor.batch.value?.id).toBe(batch.id); + expect(await executor.start("SELECT 2", targets)).toBeUndefined(); + await executor.retry(failed!.id); + expect(await executor.finishTransaction(successful!.id, "commit")).toBe(false); + await Promise.resolve(); + expect(executeTarget).toHaveBeenCalledTimes(3); + expect(validateTarget).toHaveBeenLastCalledWith(targets[1]); + expect(executeTarget.mock.calls[2]![0]).toMatchObject({ target: targets[1], sourceTabId: "source", sql: batch.sql, scopeId: batch.id }); + expect(executeTarget.mock.calls[2]![0].context).toBe(originalContext); + expect(finish).not.toHaveBeenCalled(); + + release({ status: "pending_commit", durationMs: 12, transaction: { canCommit: true, finish } }); + await retry; + expect(batch.startedAt).toBe(originalStartedAt); + expect(batch.status).toBe("completed"); + expect(batch.items.map((item) => item.status)).toEqual(["pending_commit", "pending_commit"]); + expect(failed!.durationMs).toBe(12); + await executor.retry(failed!.id); + expect(executeTarget).toHaveBeenCalledTimes(3); + expect(await executor.finishTransaction(failed!.id, "commit")).toBe(true); + expect(await executor.finishTransaction(successful!.id, "rollback")).toBe(true); + expect(finish.mock.calls.map(([action]) => action)).toEqual(["commit", "rollback"]); + executor.reset(); + expect(executor.batch.value).toBeUndefined(); + }); + + it.each(["cancelAndRollback", "dispose"] as const)("%s waits for a deferred retry and rolls back its late transaction", async (cleanup) => { + let release!: (result: MultiDbTargetExecutionResult) => void; + const retryResult = new Promise((resolve) => { + release = resolve; + }); + const finish = vi.fn().mockResolvedValue(undefined); + const executeTarget = vi + .fn() + .mockResolvedValueOnce({ status: "failed" }) + .mockImplementationOnce(() => retryResult); + const cancelPending = vi.fn().mockRejectedValue(new Error("confirmation already closed")); + const cancelTarget = vi.fn().mockRejectedValue(new Error("cancel unavailable")); + const executor = useMultiDbExecution({ executeTarget, cancelPending, cancelTarget }, { sourceTabId: "source" }); + const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!; + const retry = executor.retry(batch.items[0]!.id); + const isCancellationRequested = executeTarget.mock.calls[1]![0].isCancellationRequested; + expect(isCancellationRequested()).toBe(false); + let cleaned = false; + const cleaning = executor[cleanup]().then((result) => { + cleaned = true; + return result; + }); + await vi.waitFor(() => expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batch.id)); + expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batch.id); + expect(isCancellationRequested()).toBe(true); + expect(batch.status).toBe("cancelling"); + executor.reset(); + expect(executor.batch.value?.id).toBe(batch.id); + expect(await executor.start("SELECT 2", targets)).toBeUndefined(); + expect(cleaned).toBe(false); + expect(finish).not.toHaveBeenCalled(); + + release({ status: "pending_commit", transaction: { canCommit: true, finish } }); + await retry; + expect(await cleaning).toBe(true); + expect(finish).toHaveBeenCalledExactlyOnceWith("rollback"); + expect(batch.status).toBe("cancelled"); + expect(batch.items[0]!.status).toBe("rolled_back"); + expect(executor.hasTransactions.value).toBe(false); + if (cleanup === "dispose") { + await executor.retry(batch.items[0]!.id); + expect(await executor.start("SELECT 2", targets)).toBeUndefined(); + expect(executeTarget).toHaveBeenCalledTimes(2); + } + }); + + it("retains a retry transaction when disposal cannot roll it back", async () => { + let release!: (result: MultiDbTargetExecutionResult) => void; + const retryResult = new Promise((resolve) => { + release = resolve; + }); + const finish = vi.fn().mockRejectedValue(new Error("rollback unavailable")); + const executeTarget = vi + .fn() + .mockResolvedValueOnce({ status: "failed" }) + .mockImplementationOnce(() => retryResult); + const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" }); + const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!; + const retry = executor.retry(batch.items[0]!.id); + const disposing = executor.dispose(); + release({ status: "failed", transaction: { canCommit: false, finish } }); + await retry; + expect(await disposing).toBe(false); + expect(batch.items[0]!.errorMessage).toBe("rollback unavailable"); + expect(executor.hasTransactions.value).toBe(true); + executor.reset(); + expect(executor.batch.value?.id).toBe(batch.id); + await executor.retry(batch.items[0]!.id); + expect(executeTarget).toHaveBeenCalledTimes(2); + expect(await executor.finishTransaction(batch.items[0]!.id, "commit")).toBe(false); + finish.mockResolvedValue(undefined); + expect(await executor.rollbackPending()).toBe(true); + }); + + it("clears cancellation when retrying one target without executing the cancelled queue", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const executeTarget = vi.fn(async () => { + await gate; + return { status: "failed" }; + }); + const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" }); + const run = executor.start("SELECT 1", targets); + await executor.cancel(); + release(); + const batch = (await run)!; + expect(batch.status).toBe("cancelled"); + executeTarget.mockImplementationOnce(async ({ isCancellationRequested }) => { + expect(isCancellationRequested()).toBe(false); + return { status: "success" }; + }); + await executor.retry(batch.items[0]!.id); + expect(batch.cancelRequested).toBe(false); + expect(batch.status).toBe("completed"); + expect(batch.items.map((item) => item.status)).toEqual(["success", "not_executed", "not_executed"]); + await executor.retry(batch.items[0]!.id); + await executor.retry("missing"); + expect(executeTarget).toHaveBeenCalledTimes(2); + }); + + it.each(["invalid", "throws", "cancelled"] as const)("revalidates retries and does not execute when validation is %s", async (outcome) => { + let release!: (result: { valid: boolean; errorMessage?: string }) => void; + const validation = new Promise<{ valid: boolean; errorMessage?: string }>((resolve) => { + release = resolve; + }); + const validateTarget = vi.fn().mockResolvedValueOnce({ valid: true }); + const executeTarget = vi.fn().mockResolvedValue({ status: "failed", durationMs: 999, errorMessage: "old failure" }); + const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" }); + const batch = (await executor.start("SELECT 1", [targets[0]]))!; + if (outcome === "throws") validateTarget.mockRejectedValueOnce(new Error("validation unavailable")); + else validateTarget.mockImplementationOnce(() => validation); + const retry = executor.retry(batch.items[0]!.id); + if (outcome === "cancelled") await executor.cancel(); + release({ valid: outcome === "cancelled", errorMessage: "target unavailable" }); + await retry; + expect(executeTarget).toHaveBeenCalledTimes(1); + expect(validateTarget).toHaveBeenCalledTimes(2); + expect(batch.items[0]!.status).toBe(outcome === "cancelled" ? "cancelled" : "failed"); + expect(batch.items[0]!.durationMs).not.toBe(999); + expect(batch.items[0]!.errorMessage).toBe(outcome === "throws" ? "validation unavailable" : outcome === "invalid" ? "target unavailable" : undefined); + expect(executor.isRunning.value).toBe(false); + executor.reset(); + expect(executor.batch.value).toBeUndefined(); + }); + it("retains separate transactions until each target is explicitly settled", async () => { const commits = targets.map(() => vi.fn().mockResolvedValue(undefined)); const executor = useMultiDbExecution( diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 72659cf26f..0089c9014a 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -1,7 +1,7 @@ import { computed, nextTick, ref } from "vue"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isDangerousSql, requiresDatabaseSelection, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution"; +import { isDangerousSql, requiresDatabaseSelection, snapshotResultForMerge, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution"; import { useConnectionStore } from "@/stores/connectionStore"; import { useHistoryStore } from "@/stores/historyStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -1612,3 +1612,35 @@ SELECT @value AS Message;`; expect(executeCurrentSql).toHaveBeenCalledTimes(1); }); }); + +describe("snapshotResultForMerge", () => { + it("stays intact after the store releases the live payload in place", () => { + const live = { + columns: ["id", "amount"], + rows: [ + [1, 10], + [2, 20], + ], + column_types: ["INT", "DECIMAL"], + affected_rows: 2, + execution_time_ms: 5, + }; + const snapshot = snapshotResultForMerge(live as never)!; + + // Mirrors queryStore.releaseResultObjectPayload: the arrays the merged view + // reads are detached, so clearing the live result must not empty them. + live.columns = []; + live.rows = []; + + expect(snapshot.columns).toEqual(["id", "amount"]); + expect(snapshot.rows).toEqual([ + [1, 10], + [2, 20], + ]); + expect(snapshot.column_types).toEqual(["INT", "DECIMAL"]); + }); + + it("passes undefined through", () => { + expect(snapshotResultForMerge(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts index 7023502aea..71966c6e06 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts @@ -15,6 +15,7 @@ import { computed, ref } from "vue"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult"; import { useSqlExecution } from "../useSqlExecution"; import { useHistoryStore } from "@/stores/historyStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -313,4 +314,67 @@ describe("preservedResultIndex staleness theory", () => { expect(options).not.toHaveProperty("preserveActiveResultIndex"); expect(tab.activeResultIndex).toBe(0); }); + + it("reads one multi-database target up to the per-source row cap, not just the editor page", async () => { + const sql = "SELECT 1 AS value"; + const tab = { ...queryTab(), sql }; + const connection = sqlServerConnection(); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const executeTabSql = vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => true); + vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => tab as QueryTab | undefined), + activeConnection: computed(() => connection), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.executeTargetSql({ tab, connection, sql }); + + expect(MULTI_SOURCE_MAX_ROWS_PER_SOURCE).toBe(10_000); + expect(executeTabSql.mock.calls[0]?.[2]).toMatchObject({ pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 } }); + }); + + it("does not count the time an operator spends on the danger confirmation", async () => { + vi.useFakeTimers(); + try { + const sql = "UPDATE t_order SET shard_no = 999"; + const tab = { ...queryTab(), sql }; + const connection = sqlServerConnection(); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => { + tab.result = { columns: [], rows: [], affected_rows: 2, execution_time_ms: 1 }; + return true; + }); + vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab); + const history = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + let answer: ((confirmed: boolean) => void) | undefined; + const execution = useSqlExecution({ + activeTab: computed(() => tab as QueryTab | undefined), + activeConnection: computed(() => connection), + executableSql: computed(() => sql), + activeOutputView, + requestDangerConfirmation: () => new Promise((resolve) => (answer = resolve)), + }); + + const running = execution.executeTargetSql({ tab, connection, sql }); + await vi.advanceTimersByTimeAsync(9_000); + answer?.(true); + const result = await running; + + // The batch is parked on the prompt, so neither the target nor the history + // row may report those nine seconds as execution time. + expect(result.status).toBe("success"); + expect(result.durationMs).toBeLessThan(1_000); + expect(history.mock.calls[0]?.[0]).toMatchObject({ execution_time_ms: expect.any(Number) }); + expect((history.mock.calls[0]?.[0] as { execution_time_ms: number }).execution_time_ms).toBeLessThan(1_000); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/composables/useMultiDbExecution.ts b/apps/desktop/src/composables/useMultiDbExecution.ts index c81a9cea04..04d765fb6d 100644 --- a/apps/desktop/src/composables/useMultiDbExecution.ts +++ b/apps/desktop/src/composables/useMultiDbExecution.ts @@ -1,5 +1,6 @@ import { computed, reactive, ref, type ComputedRef, type Ref } from "vue"; import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution"; +import type { QueryResult } from "@/types/database"; export interface MultiDbExecutionItem { id: string; @@ -11,6 +12,8 @@ export interface MultiDbExecutionItem { durationMs?: number; transaction?: MultiDbManualTransaction; settling?: boolean; + /** Result produced by this target, kept for the merged multi-source view. */ + result?: QueryResult; } export interface MultiDbExecutionBatch { @@ -158,6 +161,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M item.status = current.cancelRequested && result.status === "failed" ? "cancelled" : result.status; item.errorMessage = result.errorMessage; item.durationMs = result.durationMs; + item.result = result.result; item.transaction = result.transaction; } catch (error) { // One target is intentionally isolated from the queue. Adapter errors @@ -170,11 +174,11 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M } } - async function executeBatch(current: MultiDbExecutionBatch): Promise { + async function executeBatch(current: MultiDbExecutionBatch, items = current.items): Promise { if (current.mode === "parallel") { - await Promise.all(current.items.map((item) => executeItem(current, item))); + await Promise.all(items.map((item) => executeItem(current, item))); } else { - for (const item of current.items) { + for (const item of items) { await executeItem(current, item); if (current.cancelRequested) break; } @@ -189,8 +193,17 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M current.durationMs = current.completedAt - current.startedAt; } + async function runBatch(current: MultiDbExecutionBatch, items = current.items): Promise { + activeRun = executeBatch(current, items); + try { + await activeRun; + } finally { + activeRun = undefined; + } + } + async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise { - if (disposed || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined; + if (disposed || activeRun || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined; const sourceId = sourceTabId(); const id = executionId(); const targetSnapshot = targets.map((target) => Object.freeze({ ...target })); @@ -216,12 +229,25 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M startedAt: Date.now(), }); batch.value = current; - activeRun = executeBatch(current); - await activeRun; - activeRun = undefined; + await runBatch(current); return current; } + async function retry(itemId: string): Promise { + const current = batch.value; + const item = current?.items.find((candidate) => candidate.id === itemId); + if (disposed || activeRun || isRunning.value || !current || !item || item.transaction || item.settling || !["failed", "cancelled", "not_executed", "skipped", "rolled_back"].includes(item.status)) return; + current.status = "running"; + current.cancelRequested = false; + current.completedAt = undefined; + current.durationMs = undefined; + item.errorMessage = undefined; + item.completedAt = undefined; + item.durationMs = undefined; + item.result = undefined; + await runBatch(current, [item]); + } + async function cancel(): Promise { const current = batch.value; if (!current || current.status !== "running") return; @@ -250,7 +276,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M } function reset(): void { - if (isRunning.value || hasTransactions.value) return; + if (activeRun || isRunning.value || hasTransactions.value) return; batch.value = undefined; } @@ -275,6 +301,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M dispose, cancelAndRollback, start, + retry, cancel, reset, }; diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index ffe5ff7d58..0c7d6d6e4c 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -28,10 +28,11 @@ import { assessProductionSql } from "@/lib/database/productionSafety"; import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess"; import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore"; -import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database"; import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry"; +import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult"; import { translateBackendError } from "@/i18n/backend-errors"; const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; @@ -66,6 +67,8 @@ interface TargetSqlExecutionInput { sourceOffset?: number; blockDangerousRedisCommands?: boolean; targetLabel?: string; + /** Labels of every target in the batch, shown in the confirmation prompt. */ + batchTargetLabels?: string[]; scopeId?: string; isCancellationRequested?: () => boolean; targetContext?: SqlExecutionTargetContext; @@ -79,6 +82,27 @@ export function stripSqlComments(sql: string): string { .replace(/#.*$/gm, " "); } +/** + * Detached copy of a result for the merged multi-source view. + * + * The store releases a result payload by clearing its columns/rows *in place* + * (see `releaseResultObjectPayload`) as soon as the run loses focus, and a + * multi-db worker's tab is removed right after it finishes. Holding the live + * result would therefore let the merged view lose earlier sources, so the + * arrays it reads are detached here (cells themselves are never mutated). + */ +export function snapshotResultForMerge(result: QueryResult | undefined): QueryResult | undefined { + if (!result) return undefined; + return { + ...result, + columns: [...result.columns], + rows: result.rows.slice(), + column_types: result.column_types ? [...result.column_types] : undefined, + local_column_filters: undefined, + local_hidden_column_keys: undefined, + }; +} + const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i]; const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i]; @@ -466,6 +490,21 @@ export function useSqlExecution(deps: { async function executeTargetSql(input: TargetSqlExecutionInput): Promise { const { tab, connection, sql, sourceOffset, targetLabel } = input; const startedAt = Date.now(); + let confirmationWaitMs = 0; + /** + * A danger or production prompt parks the target until the operator answers. + * Reading and typing that answer is not execution time, so it is excluded + * from the duration this target reports. + */ + const waitForConfirmation = async (prompt: () => Promise | undefined): Promise => { + const waitStartedAt = Date.now(); + try { + return await prompt(); + } finally { + confirmationWaitMs += Date.now() - waitStartedAt; + } + }; + const elapsedMs = () => Math.max(0, Date.now() - startedAt - confirmationWaitMs); const executionTab = input.executionTarget ? { ...tab, @@ -477,7 +516,7 @@ export function useSqlExecution(deps: { : tab; const finish = (result: MultiDbTargetExecutionResult): MultiDbTargetExecutionResult => ({ ...result, - durationMs: Date.now() - startedAt, + durationMs: elapsedMs(), }); const cancelRequested = () => input.isCancellationRequested?.() === true; const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count; @@ -509,15 +548,18 @@ export function useSqlExecution(deps: { return finish({ status: "skipped", errorMessage: t("redis.blockedCommand", { command: "Redis" }) }); } if (highestSafety === "confirm") { - const confirmed = await deps.requestDangerConfirmation?.({ - sql, - kind: "redis", - connectionName: connection.name, - database: executionTab.database, - targetLabel, - databaseType: connection.db_type, - scopeId: input.scopeId, - }); + const confirmed = await waitForConfirmation(() => + deps.requestDangerConfirmation?.({ + sql, + kind: "redis", + connectionName: connection.name, + database: executionTab.database, + targetLabel, + targets: input.batchTargetLabels, + databaseType: connection.db_type, + scopeId: input.scopeId, + }), + ); if (cancelRequested()) return finish({ status: "cancelled" }); if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") }); } @@ -525,28 +567,33 @@ export function useSqlExecution(deps: { const productionAssessment = assessProductionSql(sql, connection, executionTab.database); if (productionAssessment.active && productionAssessment.isMutation) { - const confirmed = await productionSafetyStore.requestConfirmation({ - sql, - connectionName: connection.name, - database: executionTab.database, - productionDatabases: productionAssessment.databases, - source: t("production.sourceMultiDbSql"), - scopeId: input.scopeId, - }); + const confirmed = await waitForConfirmation(() => + productionSafetyStore.requestConfirmation({ + sql, + connectionName: connection.name, + database: executionTab.database, + productionDatabases: productionAssessment.databases, + source: t("production.sourceMultiDbSql"), + scopeId: input.scopeId, + }), + ); if (cancelRequested()) return finish({ status: "cancelled" }); if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") }); } if (isDangerousSql(sql, connection.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) { - const confirmed = await deps.requestDangerConfirmation?.({ - sql, - kind: "sql", - connectionName: connection.name, - database: executionTab.database, - targetLabel, - databaseType: connection.db_type, - scopeId: input.scopeId, - }); + const confirmed = await waitForConfirmation(() => + deps.requestDangerConfirmation?.({ + sql, + kind: "sql", + connectionName: connection.name, + database: executionTab.database, + targetLabel, + targets: input.batchTargetLabels, + databaseType: connection.db_type, + scopeId: input.scopeId, + }), + ); if (cancelRequested()) return finish({ status: "cancelled" }); if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") }); } @@ -567,7 +614,7 @@ export function useSqlExecution(deps: { target: input.resultRun.target, title: input.resultRun.title, status, - durationMs: Date.now() - startedAt, + durationMs: elapsedMs(), errorMessage, }); }; @@ -668,6 +715,10 @@ export function useSqlExecution(deps: { } else await queryStore.executeTabSql(executionTabId, sql, { resultBaseSql: sql, + // A multi-database run is read to be inspected and exported as one + // merged table, so every source fetches a real body of rows instead of + // the editor's first page. The user's own result-row limit still wins. + pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 }, ...(input.targetContext ? { targetContext: input.targetContext } : {}), ...(sourceOffset !== undefined ? { sourceOffset } : {}), ...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}), @@ -680,9 +731,13 @@ export function useSqlExecution(deps: { const failure = firstQueryExecutionError(latest); const errorMessage = failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? t("common.failed"))) : undefined; const success = !failure; + // The produced result travels back with the target status so the dialog can + // union every target's rows into the merged multi-source view. Snapshot it + // before recording the run: later cleanup releases the worker payload. + const mergeResult = snapshotResultForMerge(latest.result); const resultStatus = success ? (input.manualTransaction ? "pending_commit" : "success") : "failed"; recordedRunId = captureWorkerResult(resultStatus, errorMessage); - const executionDuration = Date.now() - startedAt; + const executionDuration = elapsedMs(); const recordOutcome = async () => { await historyStore.add({ connection_id: executionTab.connectionId, @@ -712,7 +767,7 @@ export function useSqlExecution(deps: { transaction.canCommit = true; retainedWorker = true; recordCommittedOutcome = recordOutcome; - return finish({ status: "pending_commit", transaction }); + return finish({ status: "pending_commit", transaction, result: mergeResult }); } await recordOutcome(); if (input.manualTransaction) return await failedManualResult("failed", errorMessage); @@ -723,7 +778,9 @@ export function useSqlExecution(deps: { if (!workerId && deps.activeTab.value?.id === tab.id) { deps.activeOutputView.value = success && latest.result?.server_message === true ? "messages" : success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary"; } - return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage }); + // A target that ran inside a manual transaction keeps its own worker and + // session alive, and reports the transaction the merged view settles. + return finish(success ? { status: "success", errorMessage, result: mergeResult } : { status: "failed", errorMessage, result: mergeResult }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); captureWorkerResult("failed", errorMessage); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7286a952bb..973144ffda 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -478,6 +478,7 @@ export default { switchToSerial: "Switch to serial", elapsed: "Elapsed: {duration}", pending: "Pending", + awaitingConfirmation: "Waiting for confirmation", running: "Running", success: "Success", failed: "Failed", @@ -489,6 +490,45 @@ export default { dangerTitle: "Confirm target execution", dangerConfirm: "Execute on this target", resultTabPrefix: "Multi-db", + mergeResults: "Merged result view", + mergeResultsBack: "Back to progress", + mergedSourceColumn: "Source", + mergedSources: "{count} data sources · {rows} rows", + mergedSourceRowCap: "Up to {count} rows per data source", + mergedWriteSummary: "{targets} targets · {rows} rows affected", + mergedSourceCapReached: "{count} data source(s) hit the per-source limit ({limit} rows); more rows may exist", + mergedSummaryLabel: "SUM", + mergedSummaryCellHint: "Sum of {count} numeric values", + backgroundBatchDone: "{count} data source(s) finished; the merged view is ready", + statusColumn: "Status", + affectedRowsColumn: "Affected rows", + durationColumn: "Elapsed", + errorColumn: "Error", + rerunTarget: "Re-run here", + dangerTargets: "Data sources ({count})", + dangerConfirmCodePrompt: "Type this code to confirm", + dangerConfirmCodeCopy: "Copy code", + dangerConfirmCodeOnce: "Asked once per batch; paste it if you copied it.", + dangerConfirmCodePlaceholder: "Verification code", + txnColumn: "Transaction", + txnOpen: "Uncommitted", + txnCommitted: "Committed", + txnRolledBack: "Rolled back", + txnOpenHint: "{count} target(s) not committed yet", + commitAllTargets: "Commit all ({count})", + rollbackAllTargets: "Roll back all ({count})", + txnActionFailed: "Transaction action failed: {message}", + elapsedColumn: "Elapsed: {duration}", + executedAtColumn: "Executed at", + executedAtChip: "Executed at: {time}", + mergedRowLimit: "Showing the first {count} rows; totals cover all {rows} rows", + mergedTruncated: "Merged result truncated (limit {count} rows)", + mergedEmpty: "This batch produced no rows to merge.", + mergedSheetName: "Merged result", + exportCurrentPage: "Export current page", + exportAllRows: "Export all (Excel)", + exportedRows: "Exported {rows} rows", + exportedRowsCapped: "Exported {rows} rows; {count} data source(s) hit the {limit}-row limit", }, externalSqlFile: { modifiedTitle: "File changed outside DBX", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a3a625b93e..8687357dac 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -470,6 +470,7 @@ export default withEnglishFallback({ switchToSerial: "Cambiar a secuencial", elapsed: "Tiempo transcurrido: {duration}", pending: "Pendiente", + awaitingConfirmation: "Esperando confirmación", running: "En ejecución", success: "Correcto", failed: "Fallido", @@ -481,6 +482,45 @@ export default withEnglishFallback({ dangerTitle: "Confirmar ejecución de destinos", dangerConfirm: "Ejecutar en este destino", resultTabPrefix: "Varias bases de datos", + mergeResults: "Vista de resultados combinados", + mergeResultsBack: "Volver al progreso", + mergedSourceColumn: "Origen", + mergedSources: "{count} orígenes de datos · {rows} filas", + mergedSourceRowCap: "Máximo {count} filas por origen de datos", + mergedWriteSummary: "{targets} destinos · {rows} filas afectadas", + mergedSourceCapReached: "{count} origen(es) alcanzaron el límite por origen ({limit} filas); puede haber más datos", + mergedSummaryLabel: "SUMA", + mergedSummaryCellHint: "Suma de {count} valores numéricos", + backgroundBatchDone: "{count} origen(es) de datos terminaron; el resultado combinado está listo", + statusColumn: "Estado", + affectedRowsColumn: "Filas afectadas", + durationColumn: "Tiempo", + errorColumn: "Error", + rerunTarget: "Reejecutar aquí", + dangerTargets: "Orígenes de datos ({count})", + dangerConfirmCodePrompt: "Escribe este código para confirmar", + dangerConfirmCodeCopy: "Copiar código", + dangerConfirmCodeOnce: "Se pide una vez por lote; puedes copiarlo y pegarlo.", + dangerConfirmCodePlaceholder: "Código de verificación", + txnColumn: "Transacción", + txnOpen: "Sin confirmar", + txnCommitted: "Confirmada", + txnRolledBack: "Revertida", + txnOpenHint: "{count} destino(s) aún sin confirmar", + commitAllTargets: "Confirmar todo ({count})", + rollbackAllTargets: "Revertir todo ({count})", + txnActionFailed: "Falló la operación de transacción: {message}", + elapsedColumn: "Tiempo: {duration}", + executedAtColumn: "Ejecutado el", + executedAtChip: "Ejecutado el: {time}", + mergedRowLimit: "Mostrando las primeras {count} filas; los totales abarcan las {rows} filas", + mergedTruncated: "Resultado combinado truncado (límite {count} filas)", + mergedEmpty: "Esta ejecución no produjo filas que combinar.", + mergedSheetName: "Resultado combinado", + exportCurrentPage: "Exportar página actual", + exportAllRows: "Exportar todo (Excel)", + exportedRows: "Se exportaron {rows} filas", + exportedRowsCapped: "Se exportaron {rows} filas; {count} origen(es) alcanzaron el límite de {limit} filas", }, externalSqlFile: { modifiedTitle: "El archivo cambió fuera de DBX", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2c1649f239..4a2c8e835d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -468,6 +468,7 @@ export default withEnglishFallback({ switchToSerial: "Passa a seriale", elapsed: "Tempo trascorso: {duration}", pending: "In attesa", + awaitingConfirmation: "In attesa di conferma", running: "In esecuzione", success: "Riuscita", failed: "Non riuscita", @@ -479,6 +480,45 @@ export default withEnglishFallback({ dangerTitle: "Conferma esecuzione destinazioni", dangerConfirm: "Esegui su questa destinazione", resultTabPrefix: "Multi-database", + mergeResults: "Vista risultati uniti", + mergeResultsBack: "Torna al progresso", + mergedSourceColumn: "Origine", + mergedSources: "{count} origini dati · {rows} righe", + mergedSourceRowCap: "Massimo {count} righe per origine dati", + mergedWriteSummary: "{targets} destinazioni · {rows} righe interessate", + mergedSourceCapReached: "{count} origine/i ha/hanno raggiunto il limite per origine ({limit} righe); potrebbero esserci altri dati", + mergedSummaryLabel: "SOMMA", + mergedSummaryCellHint: "Somma di {count} valori numerici", + backgroundBatchDone: "{count} origine/i dati ha/hanno terminato; il risultato unito è pronto", + statusColumn: "Stato", + affectedRowsColumn: "Righe interessate", + durationColumn: "Tempo", + errorColumn: "Errore", + rerunTarget: "Riesegui qui", + dangerTargets: "Origini dati ({count})", + dangerConfirmCodePrompt: "Digita questo codice per confermare", + dangerConfirmCodeCopy: "Copia codice", + dangerConfirmCodeOnce: "Richiesto una volta per lotto; puoi copiarlo e incollarlo.", + dangerConfirmCodePlaceholder: "Codice di verifica", + txnColumn: "Transazione", + txnOpen: "Non confermata", + txnCommitted: "Confermata", + txnRolledBack: "Annullata", + txnOpenHint: "{count} destinazione/i non ancora confermate", + commitAllTargets: "Conferma tutto ({count})", + rollbackAllTargets: "Annulla tutto ({count})", + txnActionFailed: "Operazione di transazione non riuscita: {message}", + elapsedColumn: "Tempo: {duration}", + executedAtColumn: "Eseguito il", + executedAtChip: "Eseguito il: {time}", + mergedRowLimit: "Mostrate le prime {count} righe; i totali coprono tutte le {rows} righe", + mergedTruncated: "Risultato unito troncato (limite {count} righe)", + mergedEmpty: "Questa esecuzione non ha prodotto righe da unire.", + mergedSheetName: "Risultato unito", + exportCurrentPage: "Esporta pagina corrente", + exportAllRows: "Esporta tutto (Excel)", + exportedRows: "Esportate {rows} righe", + exportedRowsCapped: "Esportate {rows} righe; {count} origine/i ha/hanno raggiunto il limite di {limit} righe", }, externalSqlFile: { modifiedTitle: "File modificato esternamente a DBX", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f4c3add180..29731a122a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -469,6 +469,7 @@ export default withEnglishFallback({ switchToSerial: "直列に切り替え", elapsed: "経過時間:{duration}", pending: "待機中", + awaitingConfirmation: "確認待ち", running: "実行中", success: "成功", failed: "失敗", @@ -480,6 +481,45 @@ export default withEnglishFallback({ dangerTitle: "ターゲットでの実行を確認", dangerConfirm: "このターゲットで実行", resultTabPrefix: "複数DB", + mergeResults: "マージ結果ビュー", + mergeResultsBack: "実行状況に戻る", + mergedSourceColumn: "取得元", + mergedSources: "{count} 個のデータソース · {rows} 行", + mergedSourceRowCap: "1 つのデータソースにつき最大 {count} 行", + mergedWriteSummary: "{targets} 個のターゲット · {rows} 行に影響", + mergedSourceCapReached: "{count} 個のデータソースが単一ソース上限({limit} 行)に達しました。さらにデータがある可能性があります", + mergedSummaryLabel: "合計", + mergedSummaryCellHint: "{count} 個の数値の合計", + backgroundBatchDone: "{count} 個のデータソースの実行が完了し、マージ結果を表示できます", + statusColumn: "状態", + affectedRowsColumn: "影響行数", + durationColumn: "所要時間", + errorColumn: "エラー", + rerunTarget: "この対象だけ再実行", + dangerTargets: "実行するデータソース({count})", + dangerConfirmCodePrompt: "次の確認コードを入力してください", + dangerConfirmCodeCopy: "コードをコピー", + dangerConfirmCodeOnce: "確認はバッチごとに 1 回だけです。コピーして貼り付けできます。", + dangerConfirmCodePlaceholder: "確認コード", + txnColumn: "トランザクション", + txnOpen: "未コミット", + txnCommitted: "コミット済み", + txnRolledBack: "ロールバック済み", + txnOpenHint: "{count} 件の対象が未コミットです", + commitAllTargets: "まとめてコミット({count})", + rollbackAllTargets: "まとめてロールバック({count})", + txnActionFailed: "トランザクション操作に失敗しました:{message}", + elapsedColumn: "実行時間:{duration}", + executedAtColumn: "実行日時", + executedAtChip: "実行日時:{time}", + mergedRowLimit: "先頭 {count} 行のみ表示中。合計は全 {rows} 行が対象です", + mergedTruncated: "マージ結果は切り詰められました(上限 {count} 行)", + mergedEmpty: "この実行にはマージできる行がありません。", + mergedSheetName: "マージ結果", + exportCurrentPage: "現在のページを書き出す", + exportAllRows: "すべて書き出す(Excel)", + exportedRows: "{rows} 行を書き出しました", + exportedRowsCapped: "{rows} 行を書き出しました。{count} 個のデータソースが {limit} 行の単一ソース上限に達しています", }, externalSqlFile: { modifiedTitle: "ファイルがDBXの外部で変更されました", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 67a0e290ed..56562ad95f 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -476,6 +476,7 @@ export default withEnglishFallback({ switchToSerial: "직렬로 전환", elapsed: "경과 시간: {duration}", pending: "대기 중", + awaitingConfirmation: "확인 대기 중", running: "실행 중", success: "성공", failed: "실패", @@ -487,6 +488,45 @@ export default withEnglishFallback({ dangerTitle: "대상 실행 확인", dangerConfirm: "이 대상에서 실행", resultTabPrefix: "다중 DB", + mergeResults: "병합 결과 보기", + mergeResultsBack: "실행 진행으로 돌아가기", + mergedSourceColumn: "원본", + mergedSources: "데이터 원본 {count}개 · {rows}행", + mergedSourceRowCap: "데이터 원본당 최대 {count}행", + mergedWriteSummary: "대상 {targets}개 · {rows}행 영향", + mergedSourceCapReached: "{count}개 데이터 원본이 단일 원본 한도({limit}행)에 도달했습니다. 더 많은 데이터가 있을 수 있습니다", + mergedSummaryLabel: "합계", + mergedSummaryCellHint: "숫자 {count}개의 합계", + backgroundBatchDone: "데이터 원본 {count}개 실행이 완료되어 병합 결과를 볼 수 있습니다", + statusColumn: "상태", + affectedRowsColumn: "영향 행 수", + durationColumn: "소요 시간", + errorColumn: "오류", + rerunTarget: "이 대상만 다시 실행", + dangerTargets: "실행할 데이터 원본({count})", + dangerConfirmCodePrompt: "아래 확인 코드를 입력하세요", + dangerConfirmCodeCopy: "코드 복사", + dangerConfirmCodeOnce: "배치당 한 번만 확인합니다. 복사해서 붙여넣을 수 있습니다.", + dangerConfirmCodePlaceholder: "확인 코드", + txnColumn: "트랜잭션", + txnOpen: "미커밋", + txnCommitted: "커밋됨", + txnRolledBack: "롤백됨", + txnOpenHint: "{count}개 대상이 아직 커밋되지 않았습니다", + commitAllTargets: "모두 커밋({count})", + rollbackAllTargets: "모두 롤백({count})", + txnActionFailed: "트랜잭션 작업 실패: {message}", + elapsedColumn: "실행 시간: {duration}", + executedAtColumn: "실행 시각", + executedAtChip: "실행 시각: {time}", + mergedRowLimit: "처음 {count}행만 표시하며 합계는 전체 {rows}행 기준입니다", + mergedTruncated: "병합 결과가 잘렸습니다(한도 {count}행)", + mergedEmpty: "이번 실행에는 병합할 결과 행이 없습니다.", + mergedSheetName: "병합 결과", + exportCurrentPage: "현재 페이지 내보내기", + exportAllRows: "전체 내보내기(Excel)", + exportedRows: "{rows}행을 내보냈습니다", + exportedRowsCapped: "{rows}행을 내보냈습니다. {count}개 데이터 원본이 {limit}행 단일 원본 한도에 도달했습니다", }, externalSqlFile: { modifiedTitle: "DBX 외부에서 파일이 변경됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index eb7b4ed79f..432ce9e757 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -469,6 +469,7 @@ export default withEnglishFallback({ switchToSerial: "Mudar para serial", elapsed: "Tempo decorrido: {duration}", pending: "Pendente", + awaitingConfirmation: "Aguardando confirmação", running: "Em execução", success: "Sucesso", failed: "Falha", @@ -480,6 +481,45 @@ export default withEnglishFallback({ dangerTitle: "Confirmar execução dos destinos", dangerConfirm: "Executar neste destino", resultTabPrefix: "Vários bancos", + mergeResults: "Visão de resultados combinados", + mergeResultsBack: "Voltar ao progresso", + mergedSourceColumn: "Origem", + mergedSources: "{count} origens de dados · {rows} linhas", + mergedSourceRowCap: "Máximo de {count} linhas por origem de dados", + mergedWriteSummary: "{targets} destinos · {rows} linhas afetadas", + mergedSourceCapReached: "{count} origem(ns) atingiram o limite por origem ({limit} linhas); pode haver mais dados", + mergedSummaryLabel: "SOMA", + mergedSummaryCellHint: "Soma de {count} valores numéricos", + backgroundBatchDone: "{count} origem(ns) de dados concluíram; o resultado combinado está pronto", + statusColumn: "Status", + affectedRowsColumn: "Linhas afetadas", + durationColumn: "Tempo", + errorColumn: "Erro", + rerunTarget: "Executar novamente aqui", + dangerTargets: "Origens de dados ({count})", + dangerConfirmCodePrompt: "Digite este código para confirmar", + dangerConfirmCodeCopy: "Copiar código", + dangerConfirmCodeOnce: "Pedido uma vez por lote; você pode copiar e colar.", + dangerConfirmCodePlaceholder: "Código de verificação", + txnColumn: "Transação", + txnOpen: "Não confirmada", + txnCommitted: "Confirmada", + txnRolledBack: "Revertida", + txnOpenHint: "{count} destino(s) ainda não confirmados", + commitAllTargets: "Confirmar tudo ({count})", + rollbackAllTargets: "Reverter tudo ({count})", + txnActionFailed: "Falha na operação de transação: {message}", + elapsedColumn: "Tempo: {duration}", + executedAtColumn: "Executado em", + executedAtChip: "Executado em: {time}", + mergedRowLimit: "Mostrando as primeiras {count} linhas; os totais cobrem todas as {rows} linhas", + mergedTruncated: "Resultado combinado truncado (limite {count} linhas)", + mergedEmpty: "Esta execução não produziu linhas para combinar.", + mergedSheetName: "Resultado combinado", + exportCurrentPage: "Exportar página atual", + exportAllRows: "Exportar tudo (Excel)", + exportedRows: "{rows} linhas exportadas", + exportedRowsCapped: "{rows} linhas exportadas; {count} origem(ns) atingiram o limite de {limit} linhas", }, externalSqlFile: { modifiedTitle: "Arquivo alterado fora do DBX", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index db18d1f4a1..3309ed8932 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -394,6 +394,7 @@ export default withEnglishFallback({ switchToSerial: "切换为串行", elapsed: "耗时:{duration}", pending: "等待中", + awaitingConfirmation: "等待确认", running: "执行中", success: "成功", failed: "失败", @@ -405,6 +406,45 @@ export default withEnglishFallback({ dangerTitle: "确认目标执行", dangerConfirm: "在此目标上执行", resultTabPrefix: "多库", + mergeResults: "合并结果视图", + mergeResultsBack: "返回执行进度", + mergedSourceColumn: "来源", + mergedSources: "{count} 个数据源 · {rows} 行", + mergedSourceRowCap: "单个数据源最多读取 {count} 行", + mergedWriteSummary: "{targets} 个目标 · 影响 {rows} 行", + mergedSourceCapReached: "有 {count} 个数据源已达单源上限({limit} 行),可能还有更多数据", + mergedSummaryLabel: "合计", + mergedSummaryCellHint: "{count} 个数字之和", + backgroundBatchDone: "{count} 个数据源执行完成,合并结果已就绪", + statusColumn: "状态", + affectedRowsColumn: "影响行数", + durationColumn: "耗时", + errorColumn: "错误", + rerunTarget: "单独重跑", + dangerTargets: "将执行的数据源({count})", + dangerConfirmCodePrompt: "输入下面的确认码", + dangerConfirmCodeCopy: "复制确认码", + dangerConfirmCodeOnce: "整批只需确认一次;复制后可直接粘贴。", + dangerConfirmCodePlaceholder: "确认码", + txnColumn: "事务", + txnOpen: "未提交", + txnCommitted: "已提交", + txnRolledBack: "已回滚", + txnOpenHint: "有 {count} 个目标尚未提交", + commitAllTargets: "批量提交({count})", + rollbackAllTargets: "批量回滚({count})", + txnActionFailed: "事务操作失败:{message}", + elapsedColumn: "执行耗时:{duration}", + executedAtColumn: "执行时间", + executedAtChip: "执行时间:{time}", + mergedRowLimit: "仅显示前 {count} 行,合计基于全部 {rows} 行", + mergedTruncated: "合并结果已截断(上限 {count} 行)", + mergedEmpty: "本次执行没有可合并的结果行。", + mergedSheetName: "合并结果", + exportCurrentPage: "导出当前页", + exportAllRows: "导出全部(Excel)", + exportedRows: "已导出 {rows} 行", + exportedRowsCapped: "已导出 {rows} 行;{count} 个数据源达到了 {limit} 行的单源上限", }, externalSqlFile: { modifiedTitle: "文件已在外部发生修改", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index cdab049b0d..12a2a57804 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -472,6 +472,7 @@ export default withEnglishFallback({ switchToSerial: "切換為串行", elapsed: "耗時:{duration}", pending: "等待中", + awaitingConfirmation: "等待確認", running: "執行中", success: "成功", failed: "失敗", @@ -483,6 +484,45 @@ export default withEnglishFallback({ dangerTitle: "確認執行目標", dangerConfirm: "在此目標上執行", resultTabPrefix: "多資料庫", + mergeResults: "合併結果檢視", + mergeResultsBack: "返回執行進度", + mergedSourceColumn: "來源", + mergedSources: "{count} 個資料來源 · {rows} 列", + mergedSourceRowCap: "單一資料來源最多讀取 {count} 列", + mergedWriteSummary: "{targets} 個目標 · 影響 {rows} 筆", + mergedSourceCapReached: "有 {count} 個資料來源已達單源上限({limit} 列),可能還有更多資料", + mergedSummaryLabel: "合計", + mergedSummaryCellHint: "{count} 個數字之和", + backgroundBatchDone: "{count} 個資料來源執行完成,合併結果已就緒", + statusColumn: "狀態", + affectedRowsColumn: "影響列數", + durationColumn: "耗時", + errorColumn: "錯誤", + rerunTarget: "單獨重跑", + dangerTargets: "將執行的資料來源({count})", + dangerConfirmCodePrompt: "輸入下面的確認碼", + dangerConfirmCodeCopy: "複製確認碼", + dangerConfirmCodeOnce: "整批只需確認一次;複製後可直接貼上。", + dangerConfirmCodePlaceholder: "確認碼", + txnColumn: "交易", + txnOpen: "未提交", + txnCommitted: "已提交", + txnRolledBack: "已回復", + txnOpenHint: "有 {count} 個目標尚未提交", + commitAllTargets: "批次提交({count})", + rollbackAllTargets: "批次回復({count})", + txnActionFailed: "交易操作失敗:{message}", + elapsedColumn: "執行耗時:{duration}", + executedAtColumn: "執行時間", + executedAtChip: "執行時間:{time}", + mergedRowLimit: "僅顯示前 {count} 列,合計涵蓋全部 {rows} 列", + mergedTruncated: "合併結果已截斷(上限 {count} 列)", + mergedEmpty: "本次執行沒有可合併的結果列。", + mergedSheetName: "合併結果", + exportCurrentPage: "匯出目前頁面", + exportAllRows: "匯出全部(Excel)", + exportedRows: "已匯出 {rows} 列", + exportedRowsCapped: "已匯出 {rows} 列;{count} 個資料來源達到 {limit} 列的單源上限", }, externalSqlFile: { modifiedTitle: "檔案已在 DBX 外部變更", diff --git a/apps/desktop/src/lib/query/__tests__/executionConfirmCode.spec.ts b/apps/desktop/src/lib/query/__tests__/executionConfirmCode.spec.ts new file mode 100644 index 0000000000..21a3c402f5 --- /dev/null +++ b/apps/desktop/src/lib/query/__tests__/executionConfirmCode.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { EXECUTION_CONFIRM_CODE_LENGTH, createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode"; + +describe("executionConfirmCode", () => { + it("generates a code of the documented length from an unambiguous alphabet", () => { + for (let run = 0; run < 50; run += 1) { + const code = createExecutionConfirmCode(); + expect(code).toHaveLength(EXECUTION_CONFIRM_CODE_LENGTH); + // No 0/O/1/I look-alikes: the operator has to retype it from the screen. + expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/); + } + }); + + it("is deterministic for a given random source", () => { + const sequence = () => { + const values = [0, 0.5, 0.999, 0.25, 0.75, 0.1]; + let index = 0; + return () => values[index++ % values.length]!; + }; + expect(createExecutionConfirmCode(sequence())).toBe(createExecutionConfirmCode(sequence())); + expect(createExecutionConfirmCode(() => 0)).toBe("AAAAAA"); + }); + + it("tolerates case and surrounding spaces when matching", () => { + expect(matchesExecutionConfirmCode("ab12cd", "AB12CD")).toBe(true); + expect(matchesExecutionConfirmCode(" AB12CD ", "ab12cd")).toBe(true); + expect(matchesExecutionConfirmCode("", "AB12CD")).toBe(false); + expect(matchesExecutionConfirmCode("AB12CE", "AB12CD")).toBe(false); + }); + + it("never matches an empty code", () => { + expect(matchesExecutionConfirmCode("", "")).toBe(false); + expect(matchesExecutionConfirmCode("anything", "")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/query/__tests__/multiSourceResult.spec.ts b/apps/desktop/src/lib/query/__tests__/multiSourceResult.spec.ts new file mode 100644 index 0000000000..c27d7ecde9 --- /dev/null +++ b/apps/desktop/src/lib/query/__tests__/multiSourceResult.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { MULTI_SOURCE_MERGE_MAX_ROWS, isNumericSummaryCellValue, mergeMultiSourceResults, summarizeNumericColumns } from "@/lib/query/multiSourceResult"; +import type { QueryResult } from "@/types/database"; + +function result(columns: string[], rows: (string | number | boolean | null)[][], overrides: Partial = {}): QueryResult { + return { + columns, + rows, + affected_rows: rows.length, + execution_time_ms: 1, + ...overrides, + }; +} + +describe("mergeMultiSourceResults", () => { + it("unions columns by name and injects the source column first", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "conn-a / sales", result: result(["id", "amount"], [[1, 10]]) }, + { key: "b", label: "conn-b / sales", result: result(["amount", "region"], [[20, "north"]]) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.columns).toEqual(["Source", "id", "amount", "region"]); + expect(merged.rows).toEqual([ + ["conn-a / sales", 1, 10, null], + ["conn-b / sales", null, 20, "north"], + ]); + expect(merged.rowCount).toBe(2); + expect(merged.truncated).toBe(false); + expect(merged.skippedLabels).toEqual([]); + expect(merged.sources).toEqual([ + { key: "a", label: "conn-a / sales", rowCount: 1, columnCount: 2 }, + { key: "b", label: "conn-b / sales", rowCount: 1, columnCount: 2 }, + ]); + }); + + it("keeps duplicate column names of one source as separate value streams", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "a", result: result(["id", "id"], [[1, 2]]) }, + { key: "b", label: "b", result: result(["id"], [[9]]) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.columns).toEqual(["Source", "id", "id"]); + expect(merged.rows).toEqual([ + ["a", 1, 2], + ["b", 9, null], + ]); + }); + + it("excludes hidden middle columns without shifting source values or numeric summaries", () => { + const first = result(["name", "__DBX_PK_0", "amount", "region"], [["Alice", 17, 10, "north"]], { hidden_column_indexes: [1] }); + const second = result(["amount", "name", "__DBX_PK_0"], [[20, "Bob", 23]], { hidden_column_indexes: [2] }); + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "a", result: first }, + { key: "b", label: "b", result: second }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.columns).toEqual(["Source", "name", "amount", "region"]); + expect(merged.rows).toEqual([ + ["a", "Alice", 10, "north"], + ["b", "Bob", 20, null], + ]); + expect(merged.summaries).toEqual([{ columnIndex: 2, column: "amount", sum: 30, count: 2 }]); + expect(merged.sources.map((source) => source.columnCount)).toEqual([3, 2]); + expect(first.columns).toEqual(["name", "__DBX_PK_0", "amount", "region"]); + expect(first.rows).toEqual([["Alice", 17, 10, "north"]]); + }); + + it("matches visible duplicate names and keeps unnamed columns at their source ordinals", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "a", result: result(["id", "id", "id", ""], [[99, 1, 2, 3]], { hidden_column_indexes: [0] }) }, + { key: "b", label: "b", result: result(["id", "id", "__DBX_PK_0", ""], [[4, 5, 88, 6]], { hidden_column_indexes: [2] }) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.columns).toEqual(["Source", "id", "id", "column_4"]); + expect(merged.rows).toEqual([ + ["a", 1, 2, 3], + ["b", 4, 5, 6], + ]); + expect(merged.summaries.map((summary) => summary.sum)).toEqual([5, 7, 9]); + }); + + it("skips sources without a tabular payload instead of failing", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "empty", result: result([], []) }, + { key: "b", label: "failed", result: result(["Error"], [["boom"]], { execution_error: true }) }, + { key: "c", label: "missing", result: undefined }, + { key: "d", label: "ok", result: result(["id"], [[7]]) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.sources.map((source) => source.label)).toEqual(["ok"]); + expect(merged.skippedLabels).toEqual(["empty", "failed", "missing"]); + expect(merged.rows).toEqual([["ok", 7]]); + }); + + it("caps merged rows and reports truncation", () => { + const rows = Array.from({ length: 5 }, (_, index) => [index]); + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "a", result: result(["n"], rows) }, + { key: "b", label: "b", result: result(["n"], rows) }, + ], + { sourceColumnLabel: "Source", maxRows: 4 }, + ); + + expect(merged.rowCount).toBe(4); + expect(merged.truncated).toBe(true); + expect(merged.rows.map((row) => row[0])).toEqual(["a", "a", "a", "a"]); + }); + + it("names unnamed columns positionally and tolerates ragged rows", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "a", result: result(["", "b"], [[1]]) }, + { key: "b", label: "b", result: result(["b"], [[2, 3, 4]]) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.columns).toEqual(["Source", "column_1", "b"]); + expect(merged.rows).toEqual([ + ["a", 1, null], + ["b", null, 2], + ]); + }); + + it("summarizes every all-numeric column of the merged table and never the source column", () => { + const merged = mergeMultiSourceResults( + [ + { key: "a", label: "conn-a", result: result(["id", "amount", "label"], [[1, 10, "x"]]) }, + { key: "b", label: "conn-b", result: result(["id", "amount", "label"], [[2, 32.5, "y"]]) }, + ], + { sourceColumnLabel: "Source" }, + ); + + expect(merged.summaries).toEqual([ + { columnIndex: 1, column: "id", sum: 3, count: 2 }, + { columnIndex: 2, column: "amount", sum: 42.5, count: 2 }, + ]); + }); + + it("uses the documented row cap by default", () => { + expect(MULTI_SOURCE_MERGE_MAX_ROWS).toBe(50_000); + }); + + it("handles no usable input with an empty table", () => { + const merged = mergeMultiSourceResults([{ key: "a", label: "a", result: undefined }], { sourceColumnLabel: "Source" }); + expect(merged.columns).toEqual(["Source"]); + expect(merged.rows).toEqual([]); + expect(merged.sources).toEqual([]); + expect(merged.summaries).toEqual([]); + expect(merged.skippedLabels).toEqual(["a"]); + }); +}); + +describe("summarizeNumericColumns", () => { + it("classifies numeric cells and rejects everything else", () => { + expect(isNumericSummaryCellValue(1)).toBe(true); + expect(isNumericSummaryCellValue(" 3.5 ")).toBe(true); + expect(isNumericSummaryCellValue("")).toBe(false); + expect(isNumericSummaryCellValue("12abc")).toBe(false); + expect(isNumericSummaryCellValue("2026-07-24")).toBe(false); + expect(isNumericSummaryCellValue(true)).toBe(false); + expect(isNumericSummaryCellValue(null)).toBe(false); + expect(isNumericSummaryCellValue(Number.NaN)).toBe(false); + }); + + it("ignores empty cells and drops a column once one value is not numeric", () => { + expect( + summarizeNumericColumns( + ["a", "b"], + [ + [1, null], + [null, "n/a"], + ["", 3], + ], + ), + ).toEqual([{ columnIndex: 0, column: "a", sum: 1, count: 1 }]); + }); + + it("never sums boolean or all-empty columns", () => { + expect(summarizeNumericColumns(["flag"], [[true], [false]])).toEqual([]); + expect(summarizeNumericColumns(["a"], [[null], [null]])).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/query/executionConfirmCode.ts b/apps/desktop/src/lib/query/executionConfirmCode.ts new file mode 100644 index 0000000000..e8d00ada21 --- /dev/null +++ b/apps/desktop/src/lib/query/executionConfirmCode.ts @@ -0,0 +1,26 @@ +/** + * Confirmation code shown in the "confirm target execution" prompt of a + * multi-database batch: the operator has to type it back before the batch is + * allowed to run, so a mis-click cannot fire the same statement at every + * connection. + */ +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +export const EXECUTION_CONFIRM_CODE_LENGTH = 6; + +export function createExecutionConfirmCode(random: () => number = Math.random, length = EXECUTION_CONFIRM_CODE_LENGTH): string { + const size = Math.max(1, Math.trunc(length)); + let code = ""; + for (let index = 0; index < size; index += 1) { + const value = random(); + const normalized = Number.isFinite(value) ? Math.min(Math.max(value, 0), 0.999_999) : 0; + code += CODE_ALPHABET[Math.floor(normalized * CODE_ALPHABET.length)] ?? CODE_ALPHABET[0]; + } + return code; +} + +/** Typed input matches the displayed code, ignoring case and surrounding spaces. */ +export function matchesExecutionConfirmCode(input: string, code: string): boolean { + if (!code) return false; + return input.trim().toUpperCase() === code.trim().toUpperCase(); +} diff --git a/apps/desktop/src/lib/query/multiSourceResult.ts b/apps/desktop/src/lib/query/multiSourceResult.ts new file mode 100644 index 0000000000..1d21fc0b90 --- /dev/null +++ b/apps/desktop/src/lib/query/multiSourceResult.ts @@ -0,0 +1,209 @@ +import type { QueryResult } from "@/types/database"; + +/** + * Rows read from a single target of a multi-database run. The merged view and + * its Excel export need the query's body rather than just the editor's first + * page, so every source is fetched up to this cap; the user's global result-row + * limit still applies on top of it. + */ +export const MULTI_SOURCE_MAX_ROWS_PER_SOURCE = 10_000; + +/** + * Upper bound on merged rows. The merged view is a quick inspection surface, so + * a fan-out over many connections must not be able to build an unbounded table; + * hitting the cap is reported instead of silently dropping rows. + */ +export const MULTI_SOURCE_MERGE_MAX_ROWS = 50_000; + +export type MultiSourceCellValue = string | number | boolean | null; + +export interface MultiSourceResultInput { + /** Stable identity of the contributing result (used as the row's origin key). */ + key: string; + /** Human label written into the injected source column for every row. */ + label: string; + result: QueryResult | undefined; +} + +export interface MultiSourceMergeOptions { + /** Header of the injected first column; localized by the caller. */ + sourceColumnLabel: string; + maxRows?: number; +} + +export interface MultiSourceColumnSummary { + columnIndex: number; + column: string; + sum: number; + /** Number of contributing values; empty cells never count. */ + count: number; +} + +export interface MultiSourceMergeSource { + key: string; + label: string; + rowCount: number; + columnCount: number; +} + +export interface MultiSourceMergeOutput { + columns: string[]; + rows: MultiSourceCellValue[][]; + /** One entry per all-numeric column, aligned by column ordinal. */ + summaries: MultiSourceColumnSummary[]; + sources: MultiSourceMergeSource[]; + rowCount: number; + truncated: boolean; + /** Labels of inputs that carried no tabular payload and were left out. */ + skippedLabels: string[]; +} + +interface MergePlan { + input: MultiSourceResultInput; + result: QueryResult; + /** Merged column ordinal for each source column ordinal. */ + targetIndexes: Map; +} + +function usableInput(input: MultiSourceResultInput): boolean { + if (!input.result) return false; + if (input.result.execution_error === true) return false; + return (input.result.columns?.length ?? 0) > 0; +} + +function mergedColumnName(column: string | undefined, sourceIndex: number): string { + const name = typeof column === "string" ? column.trim() : ""; + return name === "" ? `column_${sourceIndex + 1}` : name; +} + +function isEmptyCell(value: MultiSourceCellValue | undefined): boolean { + if (value === null || value === undefined) return true; + return typeof value === "string" && value.trim() === ""; +} + +/** + * A cell counts as numeric when it is a finite number or a non-empty string that + * parses to one (drivers return DECIMAL/BIGINT as strings). Booleans are not + * numeric, so a flag column never turns into a total. + */ +export function isNumericSummaryCellValue(value: MultiSourceCellValue | undefined): boolean { + if (typeof value === "number") return Number.isFinite(value); + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed !== "" && Number.isFinite(Number(trimmed)); + } + return false; +} + +/** + * Simple result-level aggregates: sums every column whose non-empty cells are + * all numeric. A single non-numeric cell disqualifies the whole column, and a + * column without values produces no total. + */ +export function summarizeNumericColumns(columns: readonly string[], rows: readonly (readonly MultiSourceCellValue[])[], options: { skipColumnIndexes?: readonly number[] } = {}): MultiSourceColumnSummary[] { + const skipped = new Set(options.skipColumnIndexes ?? []); + const sums: number[] = Array.from({ length: columns.length }, () => 0); + const counts: number[] = Array.from({ length: columns.length }, () => 0); + const stillNumeric: boolean[] = Array.from({ length: columns.length }, () => true); + + for (const row of rows) { + for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) { + if (skipped.has(columnIndex) || !stillNumeric[columnIndex]) continue; + const value = row[columnIndex]; + if (isEmptyCell(value)) continue; + if (!isNumericSummaryCellValue(value)) { + stillNumeric[columnIndex] = false; + continue; + } + sums[columnIndex] += typeof value === "number" ? value : Number(value); + counts[columnIndex] += 1; + } + } + + const summaries: MultiSourceColumnSummary[] = []; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) { + if (skipped.has(columnIndex) || !stillNumeric[columnIndex] || counts[columnIndex] === 0) continue; + summaries.push({ columnIndex, column: columns[columnIndex] ?? "", sum: sums[columnIndex]!, count: counts[columnIndex]! }); + } + return summaries; +} + +/** + * Merges the results of one query executed against several connections / + * databases into a single table. + * + * Columns are aligned by name (union, first-seen order) and every row keeps a + * leading provenance column naming the source it came from. A column a source + * does not have is padded with null. Duplicate column names inside one result + * keep their own value streams by matching the Nth occurrence of a name. + */ +export function mergeMultiSourceResults(inputs: readonly MultiSourceResultInput[], options: MultiSourceMergeOptions): MultiSourceMergeOutput { + const maxRows = Math.max(1, Math.trunc(options.maxRows ?? MULTI_SOURCE_MERGE_MAX_ROWS)); + const columns: string[] = [options.sourceColumnLabel]; + // A merged column is identified by (name, occurrence-in-source) so duplicate + // names in one result still keep both of their value streams. + const indexByLayoutKey = new Map(); + const plans: MergePlan[] = []; + const skippedLabels: string[] = []; + + for (const input of inputs) { + if (!usableInput(input)) { + skippedLabels.push(input.label); + continue; + } + const result = input.result!; + const occurrences = new Map(); + const targetIndexes = new Map(); + const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []); + result.columns.forEach((column, sourceIndex) => { + if (hiddenColumnIndexes.has(sourceIndex)) return; + const name = mergedColumnName(column, sourceIndex); + const occurrence = occurrences.get(name) ?? 0; + occurrences.set(name, occurrence + 1); + const layoutKey = `${name}\u0000${occurrence}`; + let targetIndex = indexByLayoutKey.get(layoutKey); + if (targetIndex === undefined) { + targetIndex = columns.length; + indexByLayoutKey.set(layoutKey, targetIndex); + columns.push(name); + } + targetIndexes.set(sourceIndex, targetIndex); + }); + plans.push({ input, result, targetIndexes }); + } + + const rows: MultiSourceCellValue[][] = []; + let truncated = false; + for (const plan of plans) { + for (const sourceRow of plan.result.rows) { + if (rows.length >= maxRows) { + truncated = true; + break; + } + const row: MultiSourceCellValue[] = Array.from({ length: columns.length }, () => null); + row[0] = plan.input.label; + plan.targetIndexes.forEach((targetIndex, sourceIndex) => { + row[targetIndex] = sourceRow[sourceIndex] ?? null; + }); + rows.push(row); + } + if (truncated) break; + } + + return { + columns, + rows, + // The injected provenance column is text by construction, but skip it + // explicitly so a numeric-looking label can never become a total. + summaries: summarizeNumericColumns(columns, rows, { skipColumnIndexes: [0] }), + sources: plans.map((plan) => ({ + key: plan.input.key, + label: plan.input.label, + rowCount: plan.result.rows.length, + columnCount: plan.targetIndexes.size, + })), + rowCount: rows.length, + truncated, + skippedLabels, + }; +} diff --git a/apps/desktop/src/stores/__tests__/sqlExecutionDangerStore.spec.ts b/apps/desktop/src/stores/__tests__/sqlExecutionDangerStore.spec.ts index c089890f1d..4d393e820d 100644 --- a/apps/desktop/src/stores/__tests__/sqlExecutionDangerStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/sqlExecutionDangerStore.spec.ts @@ -20,4 +20,71 @@ describe("sqlExecutionDangerStore", () => { expect(await unrelated).toBe(true); expect(store.pending).toBeUndefined(); }); + + it("asks a batch once and reuses the answer for its remaining targets", async () => { + const store = useSqlExecutionDangerStore(); + const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" }); + expect(store.pending?.scopeId).toBe("batch-1"); + store.confirm(); + expect(await first).toBe(true); + + // The second target of the same batch must not prompt (nor ask for the + // confirmation code) again. + const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" }); + expect(store.pending).toBeUndefined(); + expect(await second).toBe(true); + }); + + it("keeps a declined batch from prompting the rest of its targets", async () => { + const store = useSqlExecutionDangerStore(); + const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" }); + store.cancel(); + expect(await first).toBe(false); + + const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" }); + expect(store.pending).toBeUndefined(); + expect(await second).toBe(false); + }); + + it("resolves already-queued targets of the same batch with the first answer", async () => { + const store = useSqlExecutionDangerStore(); + // Parallel execution can queue several targets before the operator answers. + const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" }); + const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" }); + const other = store.requestConfirmation({ sql: "UPDATE u", kind: "sql", scopeId: "batch-4" }); + + store.confirm(); + + expect(await first).toBe(true); + expect(await second).toBe(true); + expect(store.pending?.scopeId).toBe("batch-4"); + store.cancel(); + expect(await other).toBe(false); + }); + + it("prompts again after the batch scope is cancelled", async () => { + const store = useSqlExecutionDangerStore(); + const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" }); + store.confirm(); + await first; + + store.cancelScope("batch-5"); + + const again = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" }); + expect(store.pending?.scopeId).toBe("batch-5"); + store.cancel(); + expect(await again).toBe(false); + }); + + it("keeps prompting for requests without a scope", async () => { + const store = useSqlExecutionDangerStore(); + const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" }); + store.confirm(); + expect(await first).toBe(true); + + const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" }); + expect(store.pending).toBeDefined(); + store.cancel(); + expect(await second).toBe(false); + }); }); diff --git a/apps/desktop/src/stores/sqlExecutionDangerStore.ts b/apps/desktop/src/stores/sqlExecutionDangerStore.ts index fb01a5cdde..4f17fc8f96 100644 --- a/apps/desktop/src/stores/sqlExecutionDangerStore.ts +++ b/apps/desktop/src/stores/sqlExecutionDangerStore.ts @@ -8,6 +8,12 @@ export interface SqlExecutionDangerRequest { connectionName?: string; database?: string; targetLabel?: string; + /** + * Every data source the pending batch will touch. Shown in the confirmation + * so the operator confirms the whole fan-out, not just the target that + * happened to reach the prompt first. + */ + targets?: string[]; databaseType?: DatabaseType; /** Identifies a cancellable execution batch without coupling this store to its orchestrator. */ scopeId?: string; @@ -18,13 +24,41 @@ interface QueuedDangerRequest { resolve: (confirmed: boolean) => void; } +/** + * A batch asks once per target, but the operator answers once per batch: the + * decision is remembered per `scopeId` so the remaining targets reuse it + * instead of re-prompting (and re-asking for the confirmation code). + */ +const MAX_REMEMBERED_SCOPES = 50; + export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () => { const pending = ref(); const queue: QueuedDangerRequest[] = []; + const scopeOutcome = new Map(); let resolvePending: ((confirmed: boolean) => void) | undefined; + function rememberScopeOutcome(scopeId: string | undefined, confirmed: boolean): void { + if (!scopeId) return; + scopeOutcome.delete(scopeId); + scopeOutcome.set(scopeId, confirmed); + while (scopeOutcome.size > MAX_REMEMBERED_SCOPES) { + const oldest = scopeOutcome.keys().next().value; + if (oldest === undefined) break; + scopeOutcome.delete(oldest); + } + } + + function rememberedOutcome(request: SqlExecutionDangerRequest): boolean | undefined { + return request.scopeId ? scopeOutcome.get(request.scopeId) : undefined; + } + function requestConfirmation(request: SqlExecutionDangerRequest): Promise { return new Promise((resolve) => { + const remembered = rememberedOutcome(request); + if (remembered !== undefined) { + resolve(remembered); + return; + } if (pending.value) { queue.push({ request, resolve }); return; @@ -38,13 +72,28 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () = resolvePending = resolve; } + /** Promotes queued requests, resolving the ones whose batch is already decided. */ + function promoteNext(): void { + while (queue.length > 0) { + const next = queue.shift()!; + const remembered = rememberedOutcome(next.request); + if (remembered !== undefined) { + next.resolve(remembered); + continue; + } + begin(next.request, next.resolve); + return; + } + } + function settle(confirmed: boolean): void { + const request = pending.value; const resolve = resolvePending; resolvePending = undefined; pending.value = undefined; + rememberScopeOutcome(request?.scopeId, confirmed); resolve?.(confirmed); - const next = queue.shift(); - if (next) begin(next.request, next.resolve); + promoteNext(); } function confirm(): void { @@ -72,6 +121,8 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () = queue.splice(0, queue.length, ...retained); cancelled.forEach((entry) => entry.resolve(false)); if (pending.value?.scopeId === scopeId) settle(false); + // A cancelled batch must prompt again if the same scope id is ever reused. + scopeOutcome.delete(scopeId); } return { pending, requestConfirmation, confirm, cancel, cancelAll, cancelScope }; diff --git a/apps/desktop/src/types/sqlExecution.ts b/apps/desktop/src/types/sqlExecution.ts index b231bc3e8c..2f61fc0f69 100644 --- a/apps/desktop/src/types/sqlExecution.ts +++ b/apps/desktop/src/types/sqlExecution.ts @@ -1,4 +1,4 @@ -import type { DatabaseType } from "@/types/database"; +import type { DatabaseType, QueryResult } from "@/types/database"; export interface MultiDbExecutionTarget { connectionId: string; @@ -43,6 +43,8 @@ export interface MultiDbTargetExecutionResult { errorMessage?: string; durationMs?: number; transaction?: MultiDbManualTransaction; + /** Result produced on this target; feeds the merged multi-source view. */ + result?: QueryResult; } export interface SqlExecutionTargetValidation {