From e2eb481b8a12ba6590e92ef64569c9b70c6cae1f Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 3 Sep 2026 10:27:41 +0800 Subject: [PATCH] fix(editor): route grid edits through manual txn session --- apps/desktop/src/components/grid/DataGrid.vue | 4 ++ .../src/components/layout/ContentArea.vue | 2 + .../__tests__/useDataGridEditor.spec.ts | 50 ++++++++++++++++++- .../src/composables/useDataGridEditor.ts | 20 ++++++-- .../queryStore.oracleTxnStickyState.spec.ts | 15 ++++++ apps/desktop/src/stores/queryStore.ts | 8 +++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d5279f811a..9688a9d899 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -515,6 +515,8 @@ interface DataGridProps { }>; exportFileBaseName?: string; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; + manualTransactionSessionId?: string; + onManualTransactionMutation?: () => void; mongoUpdateTarget?: MongoCopyUpdateTarget; queryEditabilityReason?: QueryEditabilityReason; allowInsertRows?: boolean; @@ -4268,6 +4270,8 @@ const editor = useDataGridEditor({ canEditExistingRows, onExecuteSql: computed(() => props.onExecuteSql), customSaveHandler: computed(() => props.customSaveHandler), + manualTransactionSessionId: computed(() => props.manualTransactionSessionId), + onManualTransactionMutation: () => props.onManualTransactionMutation?.(), sql: computed(() => props.sql), searchText, whereFilterInput, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 6fc56482be..35d17dac39 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1794,6 +1794,8 @@ defineExpose({ :custom-save-handler="mongoQueryResultSaveHandler" :mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined" :query-editability-reason="activeTab.queryEditabilityReason" + :manual-transaction-session-id="activeTab.txnSessionId" + :on-manual-transaction-mutation="() => queryStore.markManualTransactionDirty(activeTab.id)" :allow-insert-rows="activeTab.queryAnalysis?.allowInsert ?? activeTab.queryAnalysis?.allowInsertDelete !== false" :allow-delete-rows="activeTab.queryAnalysis?.allowDelete ?? activeTab.queryAnalysis?.allowInsertDelete !== false" context="results" diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 740068109f..c25f2dbd40 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ executeConditionalUpdate: vi.fn(), cancelConditionalUpdate: vi.fn(), executeInTransaction: vi.fn(), + executeInManualTransaction: vi.fn(), addHistory: vi.fn(), })); @@ -19,6 +20,7 @@ vi.mock("@/lib/backend/api", () => ({ executeConditionalUpdate: mocks.executeConditionalUpdate, cancelConditionalUpdate: mocks.cancelConditionalUpdate, executeInTransaction: mocks.executeInTransaction, + executeInManualTransaction: mocks.executeInManualTransaction, unlockConnectionWrites: vi.fn(), lockConnectionWrites: vi.fn(), connectionWriteUnlockState: vi.fn().mockResolvedValue(0), @@ -586,11 +588,21 @@ describe("useDataGridEditor saveChanges reload", () => { mocks.executeConditionalUpdate.mockReset(); mocks.cancelConditionalUpdate.mockReset(); mocks.executeInTransaction.mockReset(); + mocks.executeInManualTransaction.mockReset(); mocks.addHistory.mockReset(); mocks.getConfig.mockReset(); }); - function createSaveTestEditor(options: { currentPage?: Ref; prepareFullReload?: () => void; customSaveHandler?: { save: ReturnType } } = {}) { + function createSaveTestEditor( + options: { + currentPage?: Ref; + prepareFullReload?: () => void; + customSaveHandler?: { save: ReturnType }; + manualTransactionSessionId?: string; + refreshSavedRows?: ReturnType; + onManualTransactionMutation?: ReturnType; + } = {}, + ) { const emit = vi.fn(); const currentPage = options.currentPage ?? ref(1); const result = ref<{ columns: string[]; rows: CellValue[][] }>({ @@ -617,6 +629,8 @@ describe("useDataGridEditor saveChanges reload", () => { sourceColumns: computed(() => undefined), onExecuteSql: computed(() => undefined), customSaveHandler: computed(() => options.customSaveHandler), + manualTransactionSessionId: computed(() => options.manualTransactionSessionId), + onManualTransactionMutation: options.onManualTransactionMutation, sql: computed(() => undefined), searchText: ref(""), whereFilterInput: ref(""), @@ -629,6 +643,7 @@ describe("useDataGridEditor saveChanges reload", () => { cacheKey: computed(() => undefined), getRowItem: () => undefined, prepareFullReload: options.prepareFullReload, + refreshSavedRows: options.refreshSavedRows, emit, }); return { editor, emit, currentPage }; @@ -647,6 +662,39 @@ describe("useDataGridEditor saveChanges reload", () => { expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0); }); + it("saves query-result edits through the active manual transaction session", async () => { + const statement = "UPDATE orders_test SET status='shipped' WHERE id=1"; + const refreshSavedRows = vi.fn().mockResolvedValue(true); + const onManualTransactionMutation = vi.fn(); + mocks.prepareDataGridSave.mockResolvedValue({ statements: [statement], rollbackStatements: [] }); + mocks.executeInManualTransaction.mockResolvedValue([{ affected_rows: 1 }]); + + const { editor, emit } = createSaveTestEditor({ manualTransactionSessionId: "txn-session-1", refreshSavedRows, onManualTransactionMutation }); + editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + + await editor.saveChanges(); + + expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("txn-session-1", statement, "app", undefined); + expect(onManualTransactionMutation).toHaveBeenCalledTimes(1); + expect(mocks.executeBatch).not.toHaveBeenCalled(); + expect(refreshSavedRows).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0); + }); + + it("marks the manual transaction dirty before a result-grid mutation can fail", async () => { + const onManualTransactionMutation = vi.fn(); + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] }); + mocks.executeInManualTransaction.mockRejectedValue(new Error("Query timed out")); + + const { editor } = createSaveTestEditor({ manualTransactionSessionId: "txn-session-1", onManualTransactionMutation }); + editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + + await editor.saveChanges(); + + expect(onManualTransactionMutation).toHaveBeenCalledTimes(1); + expect(editor.saveError.value).toContain("Query timed out"); + }); + it("executes a conditional update immediately, records affected rows, and reloads", async () => { mocks.getConfig.mockReturnValue({ id: "connection-1", name: "Local MySQL", db_type: "mysql" }); mocks.executeConditionalUpdate.mockResolvedValue({ affected_rows: 7 }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index a291e52337..c83f647026 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -103,6 +103,8 @@ export interface UseDataGridEditorOptions { canEditExistingRows?: ComputedRef; onExecuteSql: ComputedRef<((sql: string) => Promise) | undefined>; customSaveHandler?: ComputedRef; + manualTransactionSessionId?: ComputedRef; + onManualTransactionMutation?: () => void; sql: ComputedRef; searchText: Ref; whereFilterInput: Ref; @@ -219,6 +221,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { canEditExistingRows = computed(() => true), onExecuteSql, customSaveHandler, + manualTransactionSessionId = computed(() => undefined), sql, searchText, orderByInput, @@ -1818,8 +1821,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { statements: stmts, rollbackStatements: rollbackStmts, }); - - if (useTransaction.value && stmts.length > 1 && hasBackendSaveTarget.value) { + if (manualTransactionSessionId.value && hasBackendSaveTarget.value) { + options.onManualTransactionMutation?.(); + try { + const results = await api.executeInManualTransaction(manualTransactionSessionId.value, stmts.join(";\n"), database.value ?? "", preparedSave?.executionSchema); + apiResult = { + affected_rows: results.reduce((total, result) => total + (result.affected_rows ?? 0), 0), + }; + } catch (e: any) { + saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e); + await finishInterruptedSaveChanges(snapshot); + return; + } + } else if (useTransaction.value && stmts.length > 1 && hasBackendSaveTarget.value) { try { apiResult = await api.executeInTransaction(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema); } catch (e: any) { @@ -1854,7 +1868,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { applyDirtyRowsToResult(snapshot); options.onResultPayloadMutated?.(); let savedRowsRefreshed = false; - if (!shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) { + if (!manualTransactionSessionId.value && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) { try { savedRowsRefreshed = await options.refreshSavedRows({ dirtyRows: snapshot.dirtyRows, diff --git a/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts index cc19473b0f..ba29ab07ef 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts @@ -161,6 +161,20 @@ describe("queryStore Oracle manual-transaction sticky state", () => { expect(tab.oracleTxnPossiblyDirty).toBe(true); }); + it("marks an Oracle manual session dirty when the result grid dispatches a mutation", async () => { + mocks.executeInManualTransaction.mockResolvedValue(cleanSelect()); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = await setupManualTab(store); + await store.executeTabSql(tabId, "SELECT * FROM EMP"); + + store.markManualTransactionDirty(tabId); + + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(tab.oracleTxnPossiblyDirty).toBe(true); + }); + it("never clears the sticky state after a later read (UPDATE -> SELECT)", async () => { mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockResolvedValueOnce(cleanSelect()); @@ -332,6 +346,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => { store.setAutoCommit(tabId, false); await store.executeTabSql(tabId, "SELECT 1"); + store.markManualTransactionDirty(tabId); const tab = store.tabs.find((item) => item.id === tabId)!; // No classificationSql was sent and no Oracle sticky state exists. diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 536de1d6ad..97faf7ca30 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3429,6 +3429,13 @@ export const useQueryStore = defineStore("query", () => { } } + function markManualTransactionDirty(id: string) { + const tab = tabs.value.find((item) => item.id === id); + if (!tab?.txnSessionId) return; + const dbType = effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)); + if (dbType === "oracle") tab.oracleTxnPossiblyDirty = true; + } + /** Reset only the Oracle sticky-dirty bit. Used when a session continues but * the old dirty state must be discarded (e.g. idle-expiry recovery where the * replacement session starts fresh). Full session cleanup goes through @@ -7101,6 +7108,7 @@ export const useQueryStore = defineStore("query", () => { updateObjectBrowserViewport, updateNacosConfigEditorViewport, setAutoCommit, + markManualTransactionDirty, commitTransaction, rollbackTransaction, renameTab,