From 653bf84829896d7d042eaa3c8b5ddf8ee107c4ea Mon Sep 17 00:00:00 2001 From: efeire Date: Mon, 12 Oct 2026 13:02:52 +0800 Subject: [PATCH] fix(grid): guard OceanBase ROWID query editing boundaries --- .../DataGridRowIdSaveFeedback.spec.ts | 239 +++++++++++------- .../__tests__/useDataGridEditor.spec.ts | 86 +++++++ .../src/composables/useDataGridEditor.ts | 19 ++ .../rowIdUpdateConflictParity.spec.ts | 37 +++ apps/desktop/src/i18n/locales/az.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/id.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/ru.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../sql/editableQueryHiddenKeys.spec.ts | 52 ++++ .../src/lib/sql/editableQueryHiddenKeys.ts | 6 +- apps/desktop/src/lib/sql/sqlAnalysis.ts | 30 ++- .../queryStore.oceanbaseRowId.spec.ts | 117 ++++++++- apps/desktop/src/stores/queryStore.ts | 16 +- docs/testing/oceanbase-rowid-query-editing.md | 34 +++ 22 files changed, 554 insertions(+), 94 deletions(-) create mode 100644 apps/desktop/src/i18n/__tests__/rowIdUpdateConflictParity.spec.ts create mode 100644 docs/testing/oceanbase-rowid-query-editing.md diff --git a/apps/desktop/src/components/grid/__tests__/DataGridRowIdSaveFeedback.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridRowIdSaveFeedback.spec.ts index bafa4bc013..2ab48436c8 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridRowIdSaveFeedback.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridRowIdSaveFeedback.spec.ts @@ -1,120 +1,185 @@ // @vitest-environment happy-dom -import { readFileSync } from "node:fs"; -import { URL as NodeURL } from "node:url"; -import { computed, createApp, defineComponent, h, nextTick, ref } from "vue"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useDataGridEditor } from "@/composables/useDataGridEditor"; +import { createApp, defineComponent, h, markRaw, nextTick, reactive, type App } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { useDataGridEditor } from "@/composables/useDataGridEditor"; +import type { QueryResult } from "@/types/database"; -const mocks = vi.hoisted(() => ({ prepare: vi.fn(), execute: vi.fn(), history: vi.fn() })); -vi.mock("@/lib/backend/api", () => ({ prepareDataGridSave: mocks.prepare, executeBatch: mocks.execute })); -vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => undefined }) })); -vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: mocks.history }) })); -vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}) })); +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), execute: vi.fn(), toast: vi.fn() })); +let editor: ReturnType; +vi.mock("@/lib/backend/api", async (importOriginal) => ({ + ...(await importOriginal()), + prepareDataGridSave: mocks.prepare, + executeBatch: mocks.execute, + saveEditorSettings: vi.fn().mockResolvedValue(undefined), + getTablePartitionStatus: vi.fn().mockResolvedValue(null), + listObjectStatistics: vi.fn().mockResolvedValue([]), + getTableComment: vi.fn().mockResolvedValue(null), + executeQuery: vi.fn().mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 }), +})); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); +vi.mock("@/composables/useDataGridEditor", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDataGridEditor: (...args: Parameters) => { + editor = actual.useDataGridEditor(...args); + return editor; + }, + }; +}); +vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = await import("vue"); + return { + ...actual, + useDataGridColumnResize: () => ({ + initColumnWidths: vi.fn(), + onResizeStart: vi.fn(), + autoFitColumn: vi.fn(), + renderedColumnWidths: ref([120]), + totalWidth: ref(120), + columnVars: ref({ "--total-w": "120px" }), + getIsResizing: () => false, + }), + }; +}); -// Execute the actual grid callback and toolbar handler with the real editor. -// Mount only this save boundary, avoiding unrelated canvas/virtualization setup. -const source = readFileSync(new NodeURL("../DataGrid.vue", import.meta.url), "utf8"); -function saveBoundary(toast: ReturnType) { - const callback = source.match(/onSaveConflict: (.+),\r?\n/); - const onSaveConflict = callback ? new Function("toast", `return (${callback[1]});`)(toast) : undefined; - const toolbar = source.match(/async function onToolbarCommit\(\) \{([\s\S]*?)\n\}/); - expect(toolbar).not.toBeNull(); - let editor!: ReturnType; - const result = ref({ columns: ["LABEL", "__DBX_PK_0"], rows: [["same", "ROW-22"]] }); +import DataGrid from "../DataGrid.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const mounted: Array<{ app: App; host: HTMLElement }> = []; +async function settle() { + for (let i = 0; i < 8; i++) { + await nextTick(); + await Promise.resolve(); + } +} +function mountGrid(editable = true) { + const pinia = createPinia(); + setActivePinia(pinia); + useSettingsStore().updateEditorSettings({ dataGridRenderMode: "canvas", infiniteScroll: false }); + const result = (rows: QueryResult["rows"]): QueryResult => markRaw({ columns: ["LABEL", "__DBX_PK_0"], rows, affected_rows: 0, execution_time_ms: 1 }); + const state = reactive({ result: result([["same", "ROW-22"]]) }); const reload = vi.fn(); const host = document.createElement("div"); document.body.append(host); const app = createApp( defineComponent({ - setup() { - editor = useDataGridEditor({ - result: computed(() => result.value), - editable: computed(() => true), - databaseType: computed(() => "oceanbase-oracle"), - connectionId: computed(() => "connection-1"), - database: computed(() => "app"), - tableMeta: computed(() => ({ tableName: "NOPK", primaryKeys: ["__DBX_ROWID"], columns: [] })), - sourceColumns: computed(() => ["LABEL", "__DBX_ROWID"]), - onExecuteSql: computed(() => undefined), - sql: computed(() => "SELECT LABEL FROM NOPK"), - searchText: ref(""), - whereFilterInput: ref(""), - currentWhereInput: computed(() => undefined), - orderByInput: ref(""), - rowStatusFilter: ref("all"), - confirmDangerousRowDeletion: computed(() => true), - pageSize: ref(100), - currentPage: ref(1), - cacheKey: computed(() => undefined), - getRowItem: () => undefined, - emit: reload, - onSaveConflict, - }); - editor.deletedRows.value.add(0); - const commit = new Function("saveChanges", `return async function() {${toolbar![1]}}`)(editor.saveChanges); - return () => h("button", { onClick: commit }, "commit"); - }, + setup: () => () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + ...state, + editable, + context: "table-data", + databaseType: "oceanbase-oracle", + connectionId: "connection-1", + database: "app", + tableMeta: { tableName: "NOPK", primaryKeys: ["__DBX_ROWID"], columns: [] }, + sourceColumns: ["LABEL", "__DBX_ROWID"], + sql: "SELECT LABEL FROM NOPK", + onReload: reload, + }), + }, + ), }), ); + app.use(pinia); + app.use(i18n); app.mount(host); - const wrapper = { - async commit() { - host.querySelector("button")!.click(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await nextTick(); + mounted.push({ app, host }); + // Stage a deletion in the real editor, then use the rendered grid toolbar. + editor.deletedRows.value.add(0); + return { + reload, + replaceResult: () => { + state.result = result([]); }, - unmount() { - app.unmount(); - host.remove(); + async commit() { + await settle(); + const button = host.querySelector('button[data-toolbar-action="save"]'); + expect(button, "Save toolbar button missing").not.toBeNull(); + expect(button!.disabled).toBe(false); + button!.click(); + await vi.waitFor(() => { + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(editor.isSaving.value).toBe(false); + }); + await settle(); }, }; - return { wrapper, editor, result, reload }; } - +afterEach(async () => { + for (const { app, host } of mounted.splice(0)) { + app.unmount(); + host.remove(); + } + await settle(); + vi.unstubAllGlobals(); +}); describe("DataGrid ROWID save feedback across reload", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Unexpected network request in grid test"))); mocks.prepare.mockResolvedValue({ statements: ["DELETE FROM NOPK WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [] }); }); - - it("toasts the conflict through ToolbarCommit before an asynchronous empty-result replacement clears saveError", async () => { + it("does not prepare or execute staged ROWID changes when query metadata made the grid read-only", async () => { + mountGrid(false); + await settle(); + await editor.saveChanges(); + expect(mocks.prepare).not.toHaveBeenCalled(); + expect(mocks.execute).not.toHaveBeenCalled(); + expect(editor.deletedRows.value).toEqual(new Set([0])); + }); + it("notifies once through the rendered toolbar and preserves feedback after result replacement", async () => { mocks.execute.mockResolvedValue({ affected_rows: 0 }); - const toast = vi.fn(); - const { wrapper, editor, result, reload } = saveBoundary(toast); - await wrapper.commit(); - expect(reload).toHaveBeenCalledTimes(1); + const grid = mountGrid(); + await grid.commit(); + expect(grid.reload).toHaveBeenCalledTimes(1); expect(editor.deletedRows.value.size).toBe(0); - expect(toast).toHaveBeenCalledTimes(1); - const message = toast.mock.calls[0][0]; + expect(mocks.toast).toHaveBeenCalledTimes(1); + const message = mocks.toast.mock.calls[0][0]; expect(message).toMatch(/0\D+1/); expect(message).toMatch(/another session|其他会话|其他工作階段/); - expect(toast).toHaveBeenCalledWith(message, 5000); - // The real result watcher discards indexed pending state after replacement. - result.value = { columns: ["LABEL", "__DBX_PK_0"], rows: [] }; - await nextTick(); + expect(mocks.toast).toHaveBeenCalledWith(message, 5000); + grid.replaceResult(); + await settle(); expect(editor.saveError.value).toBe(""); - expect(toast).toHaveBeenCalledTimes(1); - wrapper.unmount(); + expect(mocks.toast).toHaveBeenCalledTimes(1); }); - - it("does not toast a normal one-row deletion", async () => { + it("does not notify a normal one-row deletion", async () => { mocks.execute.mockResolvedValue({ affected_rows: 1 }); - const toast = vi.fn(); - const { wrapper } = saveBoundary(toast); - await wrapper.commit(); - expect(toast).not.toHaveBeenCalled(); - wrapper.unmount(); + const grid = mountGrid(); + await grid.commit(); + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(mocks.toast).not.toHaveBeenCalled(); }); - - it("does not duplicate a normal execution failure through the conflict notification", async () => { + it("retains the draft and does not emit a conflict notification on execution failure", async () => { mocks.execute.mockRejectedValue(new Error("connection failed")); - const toast = vi.fn(); - const { wrapper, editor, reload } = saveBoundary(toast); - await wrapper.commit(); + const grid = mountGrid(); + await grid.commit(); expect(editor.saveError.value).toContain("connection failed"); expect(editor.deletedRows.value).toEqual(new Set([0])); - expect(reload).not.toHaveBeenCalled(); - expect(toast).not.toHaveBeenCalled(); - wrapper.unmount(); + expect(grid.reload).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it("retains the edited value when a plain ROWID update matches zero rows", async () => { + mocks.prepare.mockResolvedValue({ statements: ["UPDATE NOPK SET LABEL='edited' WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [], keylessGuards: [] }); + mocks.execute.mockResolvedValue({ affected_rows: 0 }); + const grid = mountGrid(); + editor.deletedRows.value.clear(); + editor.dirtyRows.value = new Map([[0, new Map([[0, "edited"]])]]); + await grid.commit(); + expect(editor.saveError.value).toMatch(/no longer matches|不再匹配|不再符合/); + expect(editor.dirtyRows.value.get(0)?.get(0)).toBe("edited"); + expect(grid.reload).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 0a84e6c349..6987f6ecac 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -1471,6 +1471,92 @@ describe("useDataGridEditor saveChanges reload", () => { message: "Cannot safely update or delete this row: more than one row matches.", }; + it.each(["oracle", "oceanbase-oracle"] as const)("keeps a missed single ROWID update pending (%s)", async (databaseType) => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: 0 }); + const { editor, emit } = createSaveTestEditor({ databaseType, primaryKeys: ["__DBX_ROWID"] }); + editor.dirtyRows.value = new Map([[1, new Map([[1, "shipped"]])]]); + await editor.saveChanges(); + expect(editor.saveError.value).toContain("no longer matches"); + expect(editor.dirtyRows.value.get(1)?.get(1)).toBe("shipped"); + expect(editor.manualSaveRequired.value).toBe(true); + expect(mocks.addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, affected_rows: undefined })); + expect(emit).not.toHaveBeenCalled(); + await editor.saveChanges({ autoSave: true }); + expect(mocks.executeBatch).toHaveBeenCalledTimes(1); + }); + + it.each([1, -1, undefined, NaN])("does not report a ROWID update conflict for successful or unknown counts: %s", async (affectedRows) => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: affectedRows }); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"] }); + editor.dirtyRows.value.set(1, new Map([[1, "shipped"]])); + await editor.saveChanges(); + expect(editor.saveError.value).toBe(""); + expect(editor.dirtyRows.value.size).toBe(0); + expect(mocks.addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: true })); + }); + + it.each([ + [{ affected_rows: 0 }, true], + [{ affected_rows: -1 }, false], + [{}, false], + ] as const)("uses the manual transaction's single raw update count %j", async (result, conflict) => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [] }); + mocks.executeInManualTransaction.mockResolvedValue([result]); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"], manualTransactionSessionId: "manual-1" }); + editor.dirtyRows.value.set(1, new Map([[1, "shipped"]])); + await editor.saveChanges(); + expect(Boolean(editor.saveError.value)).toBe(conflict); + expect(editor.dirtyRows.value.size).toBe(conflict ? 1 : 0); + }); + + it.each([ + ["BEGIN UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22'; END;"], + ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';", "DELETE FROM orders_test WHERE ROWIDTOCHAR(ROWID)='ROW-21';"], + ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';", "UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-21';"], + ])("does not infer a single-update conflict from block or batch counts: %j", async (...statements) => { + mocks.prepareDataGridSave.mockResolvedValue({ statements, rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: 0 }); + mocks.executeInTransaction.mockResolvedValue({ affected_rows: 0 }); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"] }); + editor.dirtyRows.value.set(1, new Map([[1, "shipped"]])); + if (statements[1]?.startsWith("DELETE")) editor.deletedRows.value.add(0); + else if (statements.length > 1) editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + await editor.saveChanges(); + expect(editor.saveError.value).toBe(""); + }); + + it("preserves physical-primary-key zero-update behavior", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=2;"], rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: 0 }); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["id"] }); + editor.dirtyRows.value.set(1, new Map([[1, "shipped"]])); + await editor.saveChanges(); + expect(editor.saveError.value).toBe(""); + expect(editor.dirtyRows.value.size).toBe(0); + }); + + it("does not interpret missing manual transaction results as zero matched rows", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';"], rollbackStatements: [] }); + mocks.executeInManualTransaction.mockResolvedValue([]); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"], manualTransactionSessionId: "manual-1" }); + editor.dirtyRows.value = new Map([[1, new Map([[1, "shipped"]])]]); + await editor.saveChanges(); + expect(editor.saveError.value).toBe(""); + }); + + it("does not infer an update conflict from a mixed insert batch", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE ROWIDTOCHAR(ROWID)='ROW-22';", "INSERT INTO orders_test VALUES(3, 'new');"], rollbackStatements: [] }); + mocks.executeInTransaction.mockResolvedValue({ affected_rows: 0 }); + const { editor } = createSaveTestEditor({ databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"] }); + editor.dirtyRows.value = new Map([[1, new Map([[1, "shipped"]])]]); + editor.newRows.value = [[3, "new"]]; + await editor.saveChanges(); + expect(editor.saveError.value).toBe(""); + expect(mocks.executeInTransaction).toHaveBeenCalledTimes(1); + }); + it.each(["oracle", "oceanbase-oracle"] as const)("reports a concurrently deleted ROWID target without keeping an impossible retry pending (%s)", async (databaseType) => { mocks.prepareDataGridSave.mockResolvedValue({ statements: ["DELETE FROM orders_test WHERE ROWID='ROW-22'"], rollbackStatements: ["restore ROW-22"] }); mocks.executeBatch.mockResolvedValue({ affected_rows: 0 }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index cdf5cd4984..cb60bdadb5 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -2226,6 +2226,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const start = Date.now(); let apiResult: { affected_rows?: number } | undefined; + let singleStatementAffectedRows: number | undefined; console.info("[DBX][dataGrid:save-statements]", { databaseType: databaseType.value, table: tableMeta.value ? [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".") : undefined, @@ -2241,6 +2242,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { apiResult = { affected_rows: results.reduce((total, result) => total + (result.affected_rows ?? 0), 0), }; + if (stmts.length === 1 && results.length === 1) singleStatementAffectedRows = results[0]?.affected_rows; } catch (e: any) { saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e); await finishInterruptedSaveChanges(snapshot); @@ -2261,6 +2263,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { apiResult = preparedSave?.boundStatements?.length ? await api.executeBatch(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema, undefined, undefined, preparedSave.boundStatements) : await api.executeBatch(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema); + if (stmts.length === 1) singleStatementAffectedRows = apiResult?.affected_rows; } catch (e: any) { saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e); await finishInterruptedSaveChanges(snapshot); @@ -2277,6 +2280,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { return; } } + const missedSingleRowIdUpdate = + (resolvedDatabaseType.value === "oracle" || resolvedDatabaseType.value === "oceanbase-oracle") && + usesSyntheticRowIdKey(resolvedDatabaseType.value, stmtOptions?.tableMeta.primaryKeys ?? []) && + !joinedWriteTargets.value?.length && + snapshot.dirtyRows.size === 1 && + snapshot.newRows.length === 0 && + snapshot.deletedRows.size === 0 && + stmts.length === 1 && + /^\s*UPDATE\b/i.test(stmts[0]!) && + singleStatementAffectedRows === 0; + if (missedSingleRowIdUpdate) { + manualSaveRequired.value = true; + saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, new Error(i18n.global.t("grid.rowIdUpdateConflict"))); + await finishInterruptedSaveChanges(snapshot); + return; + } // Anonymous PL/SQL reports zero for an unknown JDBC update count; its // SQL%ROWCOUNT guard owns concurrency checks. Only compare plain DELETEs. const rowIdDeleteCountKnown = diff --git a/apps/desktop/src/i18n/__tests__/rowIdUpdateConflictParity.spec.ts b/apps/desktop/src/i18n/__tests__/rowIdUpdateConflictParity.spec.ts new file mode 100644 index 0000000000..d968a66801 --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/rowIdUpdateConflictParity.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import az from "../locales/az"; +import en from "../locales/en"; +import es from "../locales/es"; +import id from "../locales/id"; +import it_ from "../locales/it"; +import ja from "../locales/ja"; +import ko from "../locales/ko"; +import ptBR from "../locales/pt-BR"; +import ru from "../locales/ru"; +import tr from "../locales/tr"; +import zhCN from "../locales/zh-CN"; +import zhTW from "../locales/zh-TW"; + +const locales: Array<[string, Record]> = [ + ["az", az], + ["en", en], + ["es", es], + ["id", id], + ["it", it_], + ["ja", ja], + ["ko", ko], + ["pt-BR", ptBR], + ["ru", ru], + ["tr", tr], + ["zh-CN", zhCN], + ["zh-TW", zhTW], +]; + +describe("ROWID update conflict locale parity", () => { + it.each(locales)("%s exposes update-conflict feedback", (_name, locale) => { + const grid = (locale as { grid: Record }).grid; + expect(grid.rowIdUpdateConflict).toBeTypeOf("string"); + expect(grid.rowIdUpdateConflict).not.toHaveLength(0); + expect(grid.rowIdUpdateConflict).not.toBe("grid.rowIdUpdateConflict"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 3e678b6552..a3e105f336 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2399,6 +2399,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Ad və {z}, {x}, {y} ehtiva edən etibarlı HTTP(S) XYZ xəritə fraqmenti URL-i daxil edin.", insertRowsNotSupported: "Cari saxlama hədəfi sətir əlavə etməyi dəstəkləmir.", rowIdDeleteConflict: "Silinmə {expected} seçilmiş sətirdən {actual} sətirə təsir etdi. Bəzi hədəflər artıq başqa sessiya tərəfindən silinmiş və ya artıq uyğun gəlmir. Gözləyən silinmələr təmizləndi və nəticə yeniləndi; cari məlumatları yoxlayın.", + rowIdUpdateConflict: "Sətir artıq öz ROWID dəyərinə uyğun gəlmir. Dəyişikliklər saxlanmadı; qaralamanız qorundu. Nəticəni yeniləyin və yenidən cəhd etməzdən əvvəl hədəfi yoxlayın.", zoomIn: "Yaxınlaşdır", zoomOut: "Uzaqlaşdır", fitImage: "Görünüşə sığdır", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4620f83bd5..288fd50eea 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2870,6 +2870,7 @@ export default { layerPreviewCustomBasemapInvalid: "Enter a name and a valid HTTP(S) XYZ tile URL containing {z}, {x}, and {y}.", insertRowsNotSupported: "The current save target does not support adding rows.", rowIdDeleteConflict: "Deletion affected {actual} of {expected} selected rows. Some targets may already have been deleted by another session or no longer match. Pending deletes were cleared and the result was refreshed; check the current data.", + rowIdUpdateConflict: "The row no longer matches its ROWID. No changes were saved; your draft was kept. Refresh the result and verify the target before retrying.", zoomIn: "Zoom In", zoomOut: "Zoom Out", fitImage: "Fit to View", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index eea97407e2..56ad4ab110 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2689,6 +2689,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Introduzca un nombre y una URL XYZ HTTP(S) válida con los marcadores {z}, {x} y {y}.", insertRowsNotSupported: "El destino de guardado actual no admite añadir filas.", rowIdDeleteConflict: "La eliminación afectó a {actual} de {expected} filas seleccionadas. Es posible que algunos objetivos ya hayan sido eliminados por otra sesión o que ya no coincidan. Se borraron las eliminaciones pendientes y se actualizó el resultado; compruebe los datos actuales.", + rowIdUpdateConflict: "La fila ya no coincide con su ROWID. No se guardaron cambios; se conservó el borrador. Actualice el resultado y verifique el destino antes de reintentar.", zoomIn: "Acercar", zoomOut: "Alejar", fitImage: "Ajustar a la vista", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 1d4e42ff08..9d5f12d341 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -2704,6 +2704,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Masukkan nama dan URL tile XYZ HTTP(S) yang valid berisi {z}, {x}, dan {y}.", insertRowsNotSupported: "Target penyimpanan saat ini tidak mendukung penambahan baris.", rowIdDeleteConflict: "Penghapusan memengaruhi {actual} dari {expected} baris yang dipilih. Beberapa target mungkin sudah dihapus oleh sesi lain atau tidak lagi cocok. Penghapusan tertunda telah dibersihkan dan hasilnya diperbarui; periksa data saat ini.", + rowIdUpdateConflict: "Baris tidak lagi cocok dengan ROWID-nya. Perubahan tidak disimpan; draf dipertahankan. Segarkan hasil dan periksa target sebelum mencoba lagi.", zoomIn: "Perbesar", zoomOut: "Perkecil", fitImage: "Sesuaikan ke Tampilan", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 33c169edbe..3a958893c8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2582,6 +2582,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapApply: "Applica", layerPreviewCustomBasemapInvalid: "Inserisci un nome e un URL tile XYZ HTTP(S) valido con i segnaposto {z}, {x} e {y}.", insertRowsNotSupported: "La destinazione di salvataggio corrente non supporta l'aggiunta di righe.", + rowIdUpdateConflict: "La riga non corrisponde più al suo ROWID. Nessuna modifica salvata; la bozza è stata mantenuta. Aggiorna il risultato e verifica la destinazione prima di riprovare.", rowIdDeleteConflict: "L'eliminazione ha interessato {actual} delle {expected} righe selezionate. Alcuni target potrebbero essere già stati eliminati da un'altra sessione o non corrispondere più. Le eliminazioni in sospeso sono state cancellate e il risultato è stato aggiornato; controllare i dati attuali.", zoomIn: "Ingrandisci", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8a7342a951..8270bf9d91 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2589,6 +2589,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "名前と、{z}、{x}、{y} を含む有効な HTTP(S) XYZ タイル URL を入力してください。", insertRowsNotSupported: "現在の保存先は行の追加に対応していません。", rowIdDeleteConflict: "削除は選択した {expected} 行のうち {actual} 行にのみ反映されました。一部の対象は既に別のセッションで削除されたか、条件に一致しなくなった可能性があります。保留中の削除はクリアされ、結果は更新されました。最新のデータをご確認ください。", + rowIdUpdateConflict: "行が元の ROWID と一致しなくなりました。変更は保存されず、下書きは保持されています。結果を更新し、対象を確認してから再試行してください。", zoomIn: "拡大", zoomOut: "縮小", fitImage: "表示領域に合わせる", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 60b211e770..88f2b2c62d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2628,6 +2628,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "이름과 {z}, {x}, {y}가 포함된 유효한 HTTP(S) XYZ 타일 URL을 입력하세요.", insertRowsNotSupported: "현재 저장 대상은 행 추가를 지원하지 않습니다.", rowIdDeleteConflict: "삭제가 선택한 {expected}행 중 {actual}행에만 적용되었습니다. 일부 대상은 다른 세션에서 이미 삭제되었거나 더 이상 일치하지 않을 수 있습니다. 보류 중인 삭제가 지워지고 결과가 새로 고쳐졌습니다. 현재 데이터를 확인하세요.", + rowIdUpdateConflict: "행이 더 이상 원래 ROWID와 일치하지 않습니다. 변경 사항은 저장되지 않았으며 초안은 유지됩니다. 결과를 새로 고치고 대상을 확인한 후 다시 시도하세요.", zoomIn: "확대", zoomOut: "축소", fitImage: "보기에 맞춤", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ade7fecc8a..365fbb8b4a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2584,6 +2584,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Informe um nome e uma URL XYZ HTTP(S) válida contendo os marcadores {z}, {x} e {y}.", insertRowsNotSupported: "O destino de salvamento atual não suporta a adição de linhas.", rowIdDeleteConflict: "A exclusão afetou {actual} de {expected} linhas selecionadas. Alguns destinos podem já ter sido excluídos por outra sessão ou não corresponder mais. As exclusões pendentes foram limpas e o resultado foi atualizado; verifique os dados atuais.", + rowIdUpdateConflict: "A linha não corresponde mais ao seu ROWID. Nenhuma alteração foi salva; seu rascunho foi mantido. Atualize o resultado e verifique o destino antes de tentar novamente.", zoomIn: "Aproximar", zoomOut: "Afastar", fitImage: "Ajustar à Visualização", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index ff35d3be31..2b758a1b86 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -2764,6 +2764,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Введите название и допустимый HTTP(S) URL XYZ-тайлов, содержащий {z}, {x} и {y}.", insertRowsNotSupported: "Текущая цель сохранения не поддерживает добавление строк.", rowIdDeleteConflict: "Удаление затронуло {actual} из {expected} выбранных строк. Некоторые цели могли быть уже удалены другим сеансом или больше не соответствуют условиям. Отложенные удаления очищены, результат обновлён; проверьте текущие данные.", + rowIdUpdateConflict: "Строка больше не соответствует своему ROWID. Изменения не сохранены; черновик сохранён. Обновите результат и проверьте целевую строку перед повторной попыткой.", zoomIn: "Увеличить", zoomOut: "Уменьшить", fitImage: "По размеру окна", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index aceff7653e..3609ae5f34 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2392,6 +2392,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "Bir ad ve {z}, {x} ile {y} içeren geçerli bir HTTP(S) XYZ karo URL'si girin.", insertRowsNotSupported: "Geçerli kaydetme hedefi satır eklemeyi desteklemiyor.", rowIdDeleteConflict: "Silme işlemi seçilen {expected} satırdan {actual} satırı etkiledi. Bazı hedefler başka bir oturum tarafından zaten silinmiş veya artık eşleşmiyor olabilir. Bekleyen silmeler temizlendi ve sonuç yenilendi; güncel verileri kontrol edin.", + rowIdUpdateConflict: "Satır artık ROWID değeriyle eşleşmiyor. Değişiklikler kaydedilmedi; taslağınız korundu. Sonucu yenileyin ve yeniden denemeden önce hedefi doğrulayın.", zoomIn: "Yakınlaştır", zoomOut: "Uzaklaştır", fitImage: "Görünüme Sığdır", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a5feef39f8..bc6f58180a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2805,6 +2805,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "请输入名称和有效的 HTTP(S) XYZ 瓦片地址,并包含 {z}、{x}、{y} 占位符。", insertRowsNotSupported: "当前保存目标不支持新增行。", rowIdDeleteConflict: "本次删除影响 {actual}/{expected} 行。部分目标可能已被其他会话删除或不再匹配。已清理本次待删除标记并刷新,请核对当前数据。", + rowIdUpdateConflict: "该行已不再匹配原 ROWID。此次更新未写入,修改草稿已保留。请刷新结果并核对目标后重试。", zoomIn: "放大", zoomOut: "缩小", fitImage: "适合显示区域", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 5f1590c286..5b77c8f718 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2585,6 +2585,7 @@ export default withEnglishFallback({ layerPreviewCustomBasemapInvalid: "請輸入名稱和有效的 HTTP(S) XYZ 圖磚位址,並包含 {z}、{x}、{y} 佔位符。", insertRowsNotSupported: "目前的儲存目標不支援新增資料列。", rowIdDeleteConflict: "本次刪除影響 {actual}/{expected} 列。部分目標可能已被其他工作階段刪除或不再符合。已清除本次待刪除標記並重新整理,請核對目前資料。", + rowIdUpdateConflict: "該列已不再符合原 ROWID。此次更新未寫入,修改草稿已保留。請重新整理結果並核對目標後重試。", zoomIn: "放大", zoomOut: "縮小", fitImage: "適合顯示區域", diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index 7397417648..20f5cb4d45 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -3,6 +3,58 @@ import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes } from "@/li import { analyzeEditableQueryEditability, allPrimaryKeysPresent, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sql/sqlAnalysis"; describe("editable query hidden primary keys", () => { + it.each([ + "APP.ITEMS PIVOT(SUM(AMOUNT) FOR KIND IN (1,2))", + "APP.ITEMS t PIVOT(SUM(AMOUNT) FOR KIND IN (1,2))", + "APP.ITEMS UNPIVOT(AMOUNT FOR KIND IN (FIRST_AMOUNT,SECOND_AMOUNT))", + "APP.ITEMS UNPIVOT INCLUDE NULLS (AMOUNT FOR KIND IN (FIRST_AMOUNT,SECOND_AMOUNT))", + "APP.ITEMS t UNKNOWN_SUFFIX(1)", + ])("rejects an unparsed source suffix: %s", (source) => { + for (const projection of ["*", "t.*", "AMOUNT", "ROWID AS RID, AMOUNT"]) { + const sql = `SELECT ${projection} FROM ${source}`; + expect(analyzeEditableQueryEditability(sql)).toEqual({ editable: false, reason: "complex-source" }); + expect(buildQueryWithHiddenPrimaryKeys({ sql, databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"], existingResultNames: ["AMOUNT"], sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" } })).toBeUndefined(); + } + }); + + it.each(['"PIVOT"', '"UNPIVOT"', '"PARTITION"'])("preserves a quoted keyword alias %s", (alias) => { + const sql = `SELECT * FROM APP.ITEMS ${alias}`; + expect(analyzeEditableQueryEditability(sql).editable).toBe(true); + expect(buildQueryWithHiddenPrimaryKeys({ sql, databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"], existingResultNames: ["AMOUNT"], sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" } })?.sql).toBe(`SELECT ${alias}.*, ROWIDTOCHAR(ROWID) AS "__DBX_PK_0" FROM APP.ITEMS ${alias}`); + }); + + it("preserves SQL Server table hints in the parsed source shape", () => { + for (const source of ["dbo.items WITH (NOLOCK)", "dbo.items t WITH (INDEX (idx_items), NOLOCK)"]) { + const sql = `SELECT amount FROM ${source} WHERE amount > 0`; + expect(analyzeEditableQueryEditability(sql).editable).toBe(true); + expect(buildQueryWithHiddenPrimaryKeys({ sql, databaseType: "sqlserver", primaryKeys: ["id"], existingResultNames: ["amount"] })?.sql).toContain("[id] AS [__DBX_PK_0]"); + } + }); + + it.each(["FROM dbo.items t WITH (NOLOCK) JOIN dbo.owners o WITH (NOLOCK) ON t.owner_id = o.id", "FROM dbo.items t LEFT JOIN dbo.owners o ON t.owner_id = o.id", "FROM dbo.items t CROSS JOIN dbo.owners o", 'FROM "APP"."Items" "t" JOIN "APP"."Owners" "o" ON "t"."OWNER_ID" = "o"."ID"'])( + "retains direct joined-source mappings: %s", + (from) => { + const result = analyzeEditableQueryEditability(`SELECT t.amount, o.id ${from}`); + expect(result.editable).toBe(true); + if (result.editable) expect(result.analysis.sources).toHaveLength(2); + }, + ); + + it.each(["PARTITION(P0)", "SUBPARTITION(P0)", "PARTITION FOR (1)"])("does not treat the Oracle table suffix %s as an alias", (suffix) => { + expect(buildQueryWithHiddenPrimaryKeys({ sql: `SELECT * FROM APP.ITEMS ${suffix}`, databaseType: "oceanbase-oracle", primaryKeys: ["__DBX_ROWID"], existingResultNames: ["LABEL"], sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" } })).toBeUndefined(); + }); + it.each(["oracle", "oceanbase-oracle"] as const)("preserves source spelling and projection boundaries for %s star queries", (databaseType) => { + for (const [sql, expected] of [ + ['SELECT * FROM "Owner"."Items" "i"', 'SELECT "i".*, ROWIDTOCHAR(ROWID) AS "__DBX_PK_1" FROM "Owner"."Items" "i"'], + ['SELECT "i".* FROM "Owner"."Items" "i"', 'SELECT "i".*, ROWIDTOCHAR(ROWID) AS "__DBX_PK_1" FROM "Owner"."Items" "i"'], + ["SELECT /*+ FULL(t) */ * -- columns\nFROM APP.ITEMS t", 'SELECT /*+ FULL(t) */ t.*, ROWIDTOCHAR(ROWID) AS "__DBX_PK_1" -- columns\nFROM APP.ITEMS t'], + ]) { + const result = buildQueryWithHiddenPrimaryKeys({ sql: sql!, databaseType, primaryKeys: ["__DBX_ROWID"], existingResultNames: ["rowid", "__DBX_PK_0"], sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" } }); + expect(result?.sql).toBe(expected); + expect(result?.projections).toEqual([{ sourceName: "__DBX_ROWID", alias: "__DBX_PK_1" }]); + } + }); + it("appends quoted MySQL primary keys without changing the visible projection", () => { expect( buildQueryWithHiddenPrimaryKeys({ diff --git a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts index 07034dc292..527b9aba2e 100644 --- a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts +++ b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts @@ -1,6 +1,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; +import { hasUnparsedQuerySource } from "@/lib/sql/sqlAnalysis"; import type { DatabaseType } from "@/types/database"; const HIDDEN_PRIMARY_KEY_ALIAS_PREFIX = "__DBX_PK_"; @@ -17,6 +18,7 @@ export interface HiddenPrimaryKeyQuery { export function buildQueryWithHiddenPrimaryKeys(options: { sql: string; databaseType: DatabaseType; primaryKeys: string[]; existingResultNames: string[]; sourceExpressions?: Record }): HiddenPrimaryKeyQuery | undefined { if (options.primaryKeys.length === 0) return undefined; + if (hasUnparsedQuerySource(options.sql)) return undefined; const dialect = sqlSemanticDialectFor({ databaseType: options.databaseType }); const usedNames = new Set(options.existingResultNames.map((name) => name.toLowerCase())); @@ -63,7 +65,9 @@ function appendSelectProjections(sql: string, expressions: string[], databaseTyp const projectionTokens = tokens.slice(selectIndex + 1, fromIndex).filter((token) => token.kind !== "comment"); const bareWildcard = projectionTokens.length === 1 && projectionTokens[0]?.text === "*" ? projectionTokens[0] : undefined; - const sourceReference = bareWildcard && databaseType === "oracle" ? singleOracleSourceReference(sql, bareWildcard.span.start) : undefined; + const oracleWildcard = bareWildcard && (databaseType === "oracle" || databaseType === "oceanbase-oracle"); + const sourceReference = oracleWildcard ? singleOracleSourceReference(sql, bareWildcard.span.start) : undefined; + if (oracleWildcard && !sourceReference) return undefined; if (!sourceReference || !bareWildcard) { return `${sql.slice(0, projectionEnd)}, ${expressions.join(", ")}${sql.slice(projectionEnd)}`; } diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 8d690247bf..2e995c8208 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -643,6 +643,21 @@ function isSelectStar(body: string, alias: string | undefined): boolean { return new RegExp(`^${escapeRegExp(alias)}\\s*\\.\\s*\\*$`, "i").test(trimmed); } +export function hasUnparsedQuerySource(sql: string): boolean { + const normalized = stripSqlComments(sql).trim(); + if (/^WITH\b/i.test(normalized) || hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT", "MINUS"])) return false; + const fromIndex = findTopLevelKeyword(normalized, "FROM", 0); + if (fromIndex < 0) return false; + const forIndex = findTopLevelKeyword(normalized, "FOR", fromIndex + 4); + const forLocksRows = forIndex >= 0 && /^FOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(normalized.slice(forIndex)); + const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "FETCH", ...(forLocksRows ? ["FOR"] : [])], fromIndex + 4); + const fromBody = normalized + .slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd) + .replace(/;+\s*$/, "") + .trim(); + return parseFromSources(fromBody).length === 0; +} + function parseFromSources(body: string): EditableQuerySource[] { if (!body) return []; const sources: EditableQuerySource[] = []; @@ -663,7 +678,10 @@ function parseFromSources(body: string): EditableQuerySource[] { continue; } const joinIndex = findTopLevelKeyword(body, "JOIN", pos); - if (joinIndex < 0) break; + const joinPrefix = body.slice(pos, joinIndex < 0 ? body.length : joinIndex).trim(); + const hasJoinCondition = startsWithKeyword(body, pos, "ON") || startsWithKeyword(body, pos, "USING"); + if (joinIndex < 0) return hasJoinCondition ? sources : []; + if (!hasJoinCondition && !/^(?:(?:LEFT|RIGHT|INNER|FULL|CROSS|OUTER|NATURAL)\s*)*$/i.test(joinPrefix)) return []; const next = parseTableSourceAt(body, joinIndex + "JOIN".length, sources.length); if (!next) return []; sources.push(next.source); @@ -695,6 +713,14 @@ function parseTableSourceAt(text: string, start: number, index: number): { sourc alias = aliasIdent.value; end = aliasIdent.end; } + tailPos = skipWhitespace(text, end); + if (startsWithKeyword(text, tailPos, "WITH")) { + const hintTokens = tokenizeSqlSemantic(text.slice(tailPos), "sqlserver"); + if (hintTokens[1]?.text !== "(") return null; + const closingHint = hintTokens.find((token) => token.text === ")" && token.depth === 0); + if (!closingHint) return null; + end = tailPos + closingHint.span.end; + } const tableName = ident.parts[ident.parts.length - 1]!; const tableNameQuoted = ident.quoted[ident.quoted.length - 1]; @@ -724,7 +750,7 @@ function isExternalFromSource(body: string): boolean { function tableSourceTerminatorAt(text: string, pos: number): boolean { if (pos >= text.length) return true; if (text[pos] === ",") return true; - return ["ON", "USING", "JOIN", "LEFT", "RIGHT", "INNER", "FULL", "CROSS", "OUTER", "NATURAL"].some((keyword) => startsWithKeyword(text, pos, keyword)); + return ["ON", "USING", "JOIN", "LEFT", "RIGHT", "INNER", "FULL", "CROSS", "OUTER", "NATURAL", "WITH"].some((keyword) => startsWithKeyword(text, pos, keyword)); } function startsWithKeyword(text: string, pos: number, keyword: string): boolean { diff --git a/apps/desktop/src/stores/__tests__/queryStore.oceanbaseRowId.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseRowId.spec.ts index d7edf4942a..b7ce68d6b6 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.oceanbaseRowId.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseRowId.spec.ts @@ -74,16 +74,95 @@ describe("OceanBase Oracle query ROWID preparation", () => { return (await executedQuery(sql)).sql; } + it.each(["PIVOT(SUM(TASKNAME) FOR KIND IN (1,2))", "UNPIVOT(TASKNAME FOR KIND IN (FIRST_NAME,SECOND_NAME))", "a UNPIVOT INCLUDE NULLS (TASKNAME FOR KIND IN (FIRST_NAME,SECOND_NAME))", "a UNKNOWN_SUFFIX(1)"])("keeps an unparsed source suffix unchanged and read-only: %s", async (suffix) => { + for (const projection of ["*", "a.*", "ROWID AS RID, TASKNAME"]) { + const originalSql = `select ${projection} from sipf.t_sipf_debug_log ${suffix}`; + mocks.analyze.mockResolvedValue({ editable: true, analysis: { schema: "SIPF", tableName: "T_SIPF_DEBUG_LOG", selectStar: true, columns: [] } }); + mocks.executeMulti.mockClear(); + const { store, id, sql } = await executedQuery(originalSql); + expect(sql).toBe(originalSql); + const tab = store.tabs.find((item) => item.id === id)!; + await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("complex-source")); + expect(tab.queryAnalysis).toBeUndefined(); + expect(tab.querySourceColumns).toBeUndefined(); + expect(tab.result?.hidden_column_indexes ?? []).toEqual([]); + } + }); + it("appends a ROWID expression to a single-column projection of a keyless table", async () => { const sql = await emittedSql("select a.taskname from sipf.t_sipf_debug_log a"); expect(sql).toContain('ROWIDTOCHAR(ROWID) AS "__DBX_PK_0"'); expect(sql).not.toContain('"__DBX_ROWID"'); }); - it.each(["select * from sipf.t_sipf_debug_log a", "select a.rowid, a.taskname from sipf.t_sipf_debug_log a", "select q.taskname from (select a.taskname from sipf.t_sipf_debug_log a) q"])("preserves queries not needing an extra ROWID: %s", async (sql) => { + it.each(["select a.rowid, a.taskname from sipf.t_sipf_debug_log a", "select q.taskname from (select a.taskname from sipf.t_sipf_debug_log a) q"])("preserves queries not needing an extra ROWID: %s", async (sql) => { expect(await emittedSql(sql)).toBe(sql); }); + it.each([ + ["select * from sipf.t_sipf_debug_log a", "select a.*"], + ["select a.* from sipf.t_sipf_debug_log a", "select a.*"], + ['select * from sipf.t_sipf_debug_log "Log"', 'select "Log".*'], + ['select "Log".* from sipf.t_sipf_debug_log "Log"', 'select "Log".*'], + ["select * from sipf.t_sipf_debug_log", "select t_sipf_debug_log.*"], + ])("adds a hidden identity to a safe star query: %s", async (originalSql, projection) => { + const { analyzeEditableQueryEditability } = await import("@/lib/sql/sqlAnalysis"); + mocks.analyze.mockImplementation(async (sql: string) => analyzeEditableQueryEditability(sql)); + mocks.executeMulti.mockResolvedValue([ + { + columns: ["TASKNAME", "__DBX_PK_0"], + rows: [ + ["duplicate", "ROW-A"], + ["duplicate", "ROW-B"], + ], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + const { store, id, sql } = await executedQuery(originalSql); + expect(sql).toBe(`${projection}, ROWIDTOCHAR(ROWID) AS "__DBX_PK_0"${originalSql.slice(originalSql.indexOf(" from "))}`); + const tab = store.tabs.find((item) => item.id === id)!; + expect(tab.result?.hidden_column_indexes).toEqual([1]); + expect(tab.result?.rows).toEqual([ + ["duplicate", "ROW-A"], + ["duplicate", "ROW-B"], + ]); + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["TASKNAME", "__DBX_ROWID"])); + expect(tab.tableMeta?.primaryKeys).toEqual(["__DBX_ROWID"]); + }); + + it.each(["*", "a.*"])("keeps physical rowid and hidden-alias columns visible for %s", async (projection) => { + const { analyzeEditableQueryEditability } = await import("@/lib/sql/sqlAnalysis"); + mocks.analyze.mockImplementation(async (sql: string) => analyzeEditableQueryEditability(sql)); + mocks.getColumns.mockResolvedValue([ + { name: "rowid", data_type: "VARCHAR2(100)", is_nullable: true, is_primary_key: false }, + { name: "__DBX_PK_0", data_type: "VARCHAR2(100)", is_nullable: true, is_primary_key: false }, + { name: "TASKNAME", data_type: "VARCHAR2(100)", is_nullable: true, is_primary_key: false }, + ]); + mocks.executeMulti.mockResolvedValue([{ columns: ["rowid", "__DBX_PK_0", "TASKNAME", "__DBX_PK_1"], rows: [["physical", "visible", "sample", "ROW-A"]], affected_rows: 0, execution_time_ms: 1 }]); + const { store, id, sql } = await executedQuery(`select ${projection} from sipf.t_sipf_debug_log a`); + expect(sql).toContain('ROWIDTOCHAR(ROWID) AS "__DBX_PK_1"'); + const tab = store.tabs.find((item) => item.id === id)!; + expect(tab.result?.hidden_column_indexes).toEqual([3]); + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["rowid", "__DBX_PK_0", "TASKNAME", "__DBX_ROWID"])); + }); + + it.each(["*", "a.*"])("does not append ROWID when a star returns the physical primary key: %s", async (projection) => { + mocks.getColumns.mockResolvedValue([ + { name: "ID", data_type: "NUMBER", is_nullable: false, is_primary_key: true }, + { name: "TASKNAME", data_type: "VARCHAR2(100)", is_nullable: true, is_primary_key: false }, + ]); + const sql = `select ${projection} from sipf.t_sipf_debug_log a`; + expect(await emittedSql(sql)).toBe(sql); + }); + + it.each(["view", "unknown"])("keeps star queries unchanged for a %s source", async (type) => { + mocks.lookupLocalCompletionTables.mockReturnValue(type === "unknown" ? [] : [{ name: "T_SIPF_DEBUG_LOG", type, schema: "SIPF" }]); + const sql = "select * from sipf.t_sipf_debug_log a"; + expect(await emittedSql(sql)).toBe(sql); + expect(mocks.listTables).not.toHaveBeenCalled(); + }); + it.each(["view", "unknown"])("does not inject ROWID for a %s source", async (type) => { mocks.lookupLocalCompletionTables.mockReturnValue(type === "unknown" ? [] : [{ name: "T_SIPF_DEBUG_LOG", type, schema: "SIPF" }]); const sql = "select a.taskname from sipf.t_sipf_debug_log a"; @@ -91,6 +170,34 @@ describe("OceanBase Oracle query ROWID preparation", () => { expect(mocks.listTables).not.toHaveBeenCalled(); }); + it.each(["view", "unknown"])("does not make a %s source editable when ROWID is explicitly returned", async (type) => { + const { analyzeEditableQueryEditability } = await import("@/lib/sql/sqlAnalysis"); + mocks.analyze.mockImplementation(async (sql: string) => analyzeEditableQueryEditability(sql)); + mocks.lookupLocalCompletionTables.mockReturnValue(type === "unknown" ? [] : [{ name: "T_SIPF_DEBUG_LOG", type, schema: "SIPF" }]); + mocks.executeMulti.mockResolvedValue([{ columns: ["RID", "TASKNAME"], rows: [["ROW-A", "sample"]], affected_rows: 0, execution_time_ms: 1 }]); + const originalSql = "select a.rowid as rid, a.taskname from sipf.t_sipf_debug_log a"; + const { store, id, sql } = await executedQuery(originalSql); + expect(sql).toBe(originalSql); + const tab = store.tabs.find((item) => item.id === id)!; + await vi.waitFor(() => expect(tab.queryEditabilityReason).toBeDefined()); + expect(tab.queryAnalysis).toBeUndefined(); + expect(tab.querySourceColumns).toBeUndefined(); + expect(tab.tableMeta?.primaryKeys ?? []).not.toContain("__DBX_ROWID"); + expect(mocks.listTables).not.toHaveBeenCalled(); + }); + + it("does not borrow ROWID editability from a same-named table in another schema", async () => { + const { analyzeEditableQueryEditability } = await import("@/lib/sql/sqlAnalysis"); + mocks.analyze.mockImplementation(async (sql: string) => analyzeEditableQueryEditability(sql)); + mocks.lookupLocalCompletionTables.mockReturnValue([{ name: "T_SIPF_DEBUG_LOG", type: "table", schema: "OTHER_OWNER" }]); + mocks.executeMulti.mockResolvedValue([{ columns: ["RID", "TASKNAME"], rows: [["ROW-A", "sample"]], affected_rows: 0, execution_time_ms: 1 }]); + const { store, id } = await executedQuery("select a.rowid as rid, a.taskname from sipf.t_sipf_debug_log a"); + const tab = store.tabs.find((item) => item.id === id)!; + await vi.waitFor(() => expect(tab.queryEditabilityReason).toBeDefined()); + expect(tab.queryAnalysis).toBeUndefined(); + expect(mocks.lookupLocalCompletionTables).toHaveBeenCalledWith("ob-1", "app", "T_SIPF_DEBUG_LOG", 20, "SIPF", undefined); + }); + it("prefers a physical primary key", async () => { mocks.getColumns.mockResolvedValue([ { name: "ID", data_type: "NUMBER", is_nullable: false, is_primary_key: true }, @@ -101,6 +208,11 @@ describe("OceanBase Oracle query ROWID preparation", () => { expect(sql).not.toContain("ROWID"); }); + it.each(["PARTITION(P0)", "SUBPARTITION(P0)", "PARTITION FOR (1)"])("executes table suffix %s without qualifying it as an alias", async (suffix) => { + const originalSql = `select * from sipf.t_sipf_debug_log ${suffix}`; + expect(await emittedSql(originalSql)).toBe(originalSql); + }); + it("preserves a business rowid alias and keeps its locator in a distinct hidden column", async () => { const { analyzeEditableQueryEditability } = await import("@/lib/sql/sqlAnalysis"); mocks.analyze.mockImplementation(async (sql: string) => analyzeEditableQueryEditability(sql)); @@ -185,6 +297,9 @@ describe("OceanBase Oracle query ROWID preparation", () => { }); it.each([ + "select distinct a.* from sipf.t_sipf_debug_log a", + "select a.*, count(*) from sipf.t_sipf_debug_log a group by a.taskname", + "select a.*, b.* from sipf.t_sipf_debug_log a join sipf.t_sipf_debug_log b on a.taskname = b.taskname", "select distinct a.taskname from sipf.t_sipf_debug_log a", "select a.taskname, count(*) from sipf.t_sipf_debug_log a group by a.taskname", "select a.taskname, b.taskname from sipf.t_sipf_debug_log a join sipf.t_sipf_debug_log b on a.taskname = b.taskname", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f2ed1a1bf3..4d12a99d57 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -42,6 +42,7 @@ import { analyzeSelectStructureForDisplay, foldUnquotedPostgresMetadataIdentifier, hasTopLevelKeyword, + hasUnparsedQuerySource, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, @@ -952,7 +953,7 @@ function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, sourc if (column.sourceKey && column.sourceKey !== source.key) return [column]; return tableColumns.map((tableColumn) => ({ sourceName: tableColumn.name, - sourceNameQuoted: false, + sourceNameQuoted: true, ...(column.sourceQualifier ? { sourceQualifier: column.sourceQualifier } : {}), sourceKey: source.key, resultName: tableColumn.name, @@ -6234,6 +6235,7 @@ export const useQueryStore = defineStore("query", () => { function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean { if (loaded.tableMeta.columns.some((column) => column.resolved_table) && loaded.tableMeta.tableType?.trim().toUpperCase() !== "TABLE") return false; if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false; + if (databaseType === "oceanbase-oracle" && loaded.tableMeta.tableType?.trim().toUpperCase() !== "TABLE") return false; // An unknown Oracle object may be a view whose query shape rejects ROWID // and whose rows cannot be mapped safely for writes. Keep the result // read-only until the object tree or tab metadata confirms its type. @@ -6528,10 +6530,10 @@ export const useQueryStore = defineStore("query", () => { const wholeSourceProjected = projectsAllColumnsForSource(analysis, source.key); const hasDirectSourceProjection = analysis.columns.some((column) => Boolean(column.sourceName) && (!column.sourceKey || column.sourceKey === source.key)); if (!wholeSourceProjected && !hasDirectSourceProjection) return unchanged; - // Whole-source projections already include declared primary keys. Oracle + // Whole-source projections already include declared primary keys. Oracle, OceanBase Oracle // and Xugu may need a synthetic key, while DB2 needs column metadata to // decide whether LOB materialization can be deferred safely. - if (databaseType !== "oracle" && databaseType !== "xugu" && databaseType !== "db2" && wholeSourceProjected) return unchanged; + if (databaseType !== "oracle" && databaseType !== "oceanbase-oracle" && databaseType !== "xugu" && databaseType !== "db2" && wholeSourceProjected) return unchanged; const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, databaseType, executionDatabase); const cached = getCachedTableMetadata(target.request); @@ -6735,6 +6737,14 @@ export const useQueryStore = defineStore("query", () => { } queryExecutionLog("info", "metadata:editability:start", { traceId, elapsed: elapsed?.() }); + if (hasUnparsedQuerySource(sql)) { + return { + queryAnalysis: undefined, + querySourceColumns: undefined, + queryEditabilityReason: "complex-source", + tableMeta: undefined, + }; + } const editability = await api.analyzeEditableQueryEditability(sql); queryExecutionLog("info", "metadata:editability:done", { traceId, diff --git a/docs/testing/oceanbase-rowid-query-editing.md b/docs/testing/oceanbase-rowid-query-editing.md new file mode 100644 index 0000000000..3d5eacb78f --- /dev/null +++ b/docs/testing/oceanbase-rowid-query-editing.md @@ -0,0 +1,34 @@ +# OceanBase Oracle 查询结果 ROWID 编辑 + +## 问题与复现 + +在 OceanBase Oracle 模式中创建无主键普通表,插入两行相同业务值。执行 `SELECT * FROM APP.ITEMS t` 或 `SELECT t.* FROM APP.ITEMS t`。修复前不会附加隐藏定位键,结果缺少可用于单行编辑的身份。 + +对视图或尚未确认类型的对象执行 `SELECT t.ROWID AS RID, t.LABEL FROM APP.ITEMS t`。修复前显式返回 ROWID 可以绕过普通表确认,错误开放编辑。 + +## 行为与验收 + +- 已确认的无主键单基表星号查询附加隐藏 `ROWIDTOCHAR(ROWID)`;保留原别名、引号、提示和注释。相同业务值的两行保持独立定位键。 +- 物理主键优先。业务列 `"rowid"`、`"__DBX_PK_0"` 保持可见,隐藏别名避让已有名称。 +- 视图、未知对象、其他 schema 的同名表不能赋予 ROWID 编辑权限;DISTINCT、分组、联表和集合运算保持原 SQL。 +- `PARTITION(P0)`、`SUBPARTITION(P0)`、`PARTITION FOR (...)` 不得被误当表别名。语义解析器尚不能可靠区分这些后缀,隐藏键构造跳过改写,保留原 SQL;不生成 `PARTITION.*`。 +- PIVOT、UNPIVOT 及其他未完整解析的来源后缀保持原 SQL 且只读;引号别名、普通联表和 SQL Server `WITH (...)` 表提示保留现有来源映射。 +- 数据库更新行数保护报告过期 ROWID 时,显示错误并保留修改草稿。删除返回 0 行沿用现有冲突提示及刷新行为,不将已经结束的删除保留为自动重试任务。 +- 单行、单条普通 ROWID UPDATE 返回已知影响 0 行时报告冲突,保留草稿并暂停自动重试。此保护补齐既有零行更新缺口,不从混合批次总行数、缺失或负数计数、PL/SQL 未知计数推断匹配失败。 +- 只读网格不准备或执行保存;正常保存与执行失败反馈分别验证。 + +## 自动验证 + +Store 测试从 `executeTabSql` 进入,只在后端 API 和连接元数据边界提供夹具。新增用例在基线复现 10 项失败,修复后通过。隐藏键测试验证实际 SQL 构造;DataGrid 测试挂载组件并点击保存工具栏,覆盖只读、正常删除、零行删除、执行失败及过期更新草稿。 + +相关文件: + +- `queryStore.oceanbaseRowId.spec.ts` +- `queryStore.hiddenPrimaryKey.spec.ts` +- `editableQueryHiddenKeys.spec.ts` +- `useDataGridEditor.spec.ts` +- `DataGridRowIdSaveFeedback.spec.ts` + +## 待完成验收 + +本批次未重新执行 OceanBase 实库或桌面 GUI 验收。需要在隔离普通表中操作两条相同值,只更新选中行并读回;验证视图只读、业务列可见,以及另一会话删除目标后更新失败保留草稿。组件测试和 SQL 构造测试不替代数据库或 GUI 验收。原生 Oracle 行为需要单独实库回归。