mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(grid): guard OceanBase ROWID query editing boundaries
This commit is contained in:
+152
-87
@@ -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<typeof useDataGridEditor>;
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/backend/api")>()),
|
||||
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<typeof import("@/composables/useDataGridEditor")>();
|
||||
return {
|
||||
...actual,
|
||||
useDataGridEditor: (...args: Parameters<typeof actual.useDataGridEditor>) => {
|
||||
editor = actual.useDataGridEditor(...args);
|
||||
return editor;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
|
||||
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<typeof vi.fn>) {
|
||||
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<typeof useDataGridEditor>;
|
||||
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<HTMLButtonElement>('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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<string, unknown>]> = [
|
||||
["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<string, unknown> }).grid;
|
||||
expect(grid.rowIdUpdateConflict).toBeTypeOf("string");
|
||||
expect(grid.rowIdUpdateConflict).not.toHaveLength(0);
|
||||
expect(grid.rowIdUpdateConflict).not.toBe("grid.rowIdUpdateConflict");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2589,6 +2589,7 @@ export default withEnglishFallback({
|
||||
layerPreviewCustomBasemapInvalid: "名前と、{z}、{x}、{y} を含む有効な HTTP(S) XYZ タイル URL を入力してください。",
|
||||
insertRowsNotSupported: "現在の保存先は行の追加に対応していません。",
|
||||
rowIdDeleteConflict: "削除は選択した {expected} 行のうち {actual} 行にのみ反映されました。一部の対象は既に別のセッションで削除されたか、条件に一致しなくなった可能性があります。保留中の削除はクリアされ、結果は更新されました。最新のデータをご確認ください。",
|
||||
rowIdUpdateConflict: "行が元の ROWID と一致しなくなりました。変更は保存されず、下書きは保持されています。結果を更新し、対象を確認してから再試行してください。",
|
||||
zoomIn: "拡大",
|
||||
zoomOut: "縮小",
|
||||
fitImage: "表示領域に合わせる",
|
||||
|
||||
@@ -2628,6 +2628,7 @@ export default withEnglishFallback({
|
||||
layerPreviewCustomBasemapInvalid: "이름과 {z}, {x}, {y}가 포함된 유효한 HTTP(S) XYZ 타일 URL을 입력하세요.",
|
||||
insertRowsNotSupported: "현재 저장 대상은 행 추가를 지원하지 않습니다.",
|
||||
rowIdDeleteConflict: "삭제가 선택한 {expected}행 중 {actual}행에만 적용되었습니다. 일부 대상은 다른 세션에서 이미 삭제되었거나 더 이상 일치하지 않을 수 있습니다. 보류 중인 삭제가 지워지고 결과가 새로 고쳐졌습니다. 현재 데이터를 확인하세요.",
|
||||
rowIdUpdateConflict: "행이 더 이상 원래 ROWID와 일치하지 않습니다. 변경 사항은 저장되지 않았으며 초안은 유지됩니다. 결과를 새로 고치고 대상을 확인한 후 다시 시도하세요.",
|
||||
zoomIn: "확대",
|
||||
zoomOut: "축소",
|
||||
fitImage: "보기에 맞춤",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2764,6 +2764,7 @@ export default withEnglishFallback({
|
||||
layerPreviewCustomBasemapInvalid: "Введите название и допустимый HTTP(S) URL XYZ-тайлов, содержащий {z}, {x} и {y}.",
|
||||
insertRowsNotSupported: "Текущая цель сохранения не поддерживает добавление строк.",
|
||||
rowIdDeleteConflict: "Удаление затронуло {actual} из {expected} выбранных строк. Некоторые цели могли быть уже удалены другим сеансом или больше не соответствуют условиям. Отложенные удаления очищены, результат обновлён; проверьте текущие данные.",
|
||||
rowIdUpdateConflict: "Строка больше не соответствует своему ROWID. Изменения не сохранены; черновик сохранён. Обновите результат и проверьте целевую строку перед повторной попыткой.",
|
||||
zoomIn: "Увеличить",
|
||||
zoomOut: "Уменьшить",
|
||||
fitImage: "По размеру окна",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2805,6 +2805,7 @@ export default withEnglishFallback({
|
||||
layerPreviewCustomBasemapInvalid: "请输入名称和有效的 HTTP(S) XYZ 瓦片地址,并包含 {z}、{x}、{y} 占位符。",
|
||||
insertRowsNotSupported: "当前保存目标不支持新增行。",
|
||||
rowIdDeleteConflict: "本次删除影响 {actual}/{expected} 行。部分目标可能已被其他会话删除或不再匹配。已清理本次待删除标记并刷新,请核对当前数据。",
|
||||
rowIdUpdateConflict: "该行已不再匹配原 ROWID。此次更新未写入,修改草稿已保留。请刷新结果并核对目标后重试。",
|
||||
zoomIn: "放大",
|
||||
zoomOut: "缩小",
|
||||
fitImage: "适合显示区域",
|
||||
|
||||
@@ -2585,6 +2585,7 @@ export default withEnglishFallback({
|
||||
layerPreviewCustomBasemapInvalid: "請輸入名稱和有效的 HTTP(S) XYZ 圖磚位址,並包含 {z}、{x}、{y} 佔位符。",
|
||||
insertRowsNotSupported: "目前的儲存目標不支援新增資料列。",
|
||||
rowIdDeleteConflict: "本次刪除影響 {actual}/{expected} 列。部分目標可能已被其他工作階段刪除或不再符合。已清除本次待刪除標記並重新整理,請核對目前資料。",
|
||||
rowIdUpdateConflict: "該列已不再符合原 ROWID。此次更新未寫入,修改草稿已保留。請重新整理結果並核對目標後重試。",
|
||||
zoomIn: "放大",
|
||||
zoomOut: "縮小",
|
||||
fitImage: "適合顯示區域",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, string> }): 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)}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 行为需要单独实库回归。
|
||||
Reference in New Issue
Block a user