From 99ebaecd395cd08b4f79dfabc091dccbeefd6ad5 Mon Sep 17 00:00:00 2001 From: efeire Date: Mon, 26 Oct 2026 11:24:26 +0800 Subject: [PATCH] fix(oracle): preserve explicit DDL owner and trigger completeness --- .../ContentArea.ddlPreferences.spec.ts | 13 ++- .../src/components/objects/DdlViewDialog.vue | 2 + .../src/components/objects/ObjectBrowser.vue | 14 +-- .../DdlViewDialog.preferences.spec.ts | 30 ++++-- ...ectBrowser.schemaIndexes.component.spec.ts | 4 +- .../useSidebarTreeExportRuntime.spec.ts | 2 +- .../useSidebarTreeExportRuntime.ts | 3 +- .../__tests__/exactMetadataTransport.spec.ts | 14 +++ apps/desktop/src/lib/backend/http.ts | 8 +- apps/desktop/src/lib/backend/tauri.ts | 6 +- .../metadata/__tests__/objectDdlCache.spec.ts | 17 +++- .../src/lib/metadata/objectDdlCache.ts | 7 +- apps/desktop/src/stores/queryStore.ts | 14 ++- apps/desktop/src/types/database.ts | 1 + .../dbx-core/src/schema/oracle_ddl_indexes.rs | 66 +++++++++++-- crates/dbx-sql-schema/src/schema_diff.rs | 8 ++ .../src/schema_diff/oracle_indexes.rs | 94 +++++++++++++++++++ crates/dbx-tauri-schema/src/commands.rs | 13 ++- crates/dbx-web/src/routes/schema.rs | 2 + 19 files changed, 273 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.ddlPreferences.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.ddlPreferences.spec.ts index 7f63fb5a8e..b5b58d99a7 100644 --- a/apps/desktop/src/components/layout/__tests__/ContentArea.ddlPreferences.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.ddlPreferences.spec.ts @@ -30,7 +30,7 @@ afterEach(() => { document.body.innerHTML = ""; window.localStorage.clear(); }); -async function mountViewer(type: "oracle" | "oceanbase-oracle" = "oracle", separateIndexes = false) { +async function mountViewer(type: "oracle" | "oceanbase-oracle" = "oracle", separateIndexes = false, schema = "Owner", explicitOwner?: string) { const pinia = createPinia(); setActivePinia(pinia); const connection = { id: "oracle-viewer", name: "Oracle", db_type: type, host: "localhost", port: 1, username: "test", password: "" } as ConnectionConfig; @@ -40,7 +40,7 @@ async function mountViewer(type: "oracle" | "oceanbase-oracle" = "oracle", separ settings.editorSettings.separateDdlIndexes = separateIndexes; const id = store.createTab(connection.id, "service", "DDL", "query", "Owner", "", undefined, { forceNew: true, sourceView: true }); const tab = store.tabs.find((tab) => tab.id === id)!; - tab.ddlViewer = { schema: "Owner", tableName: "First", formatDialect: "plsql" }; + tab.ddlViewer = { schema, explicitOwner, tableName: "First", formatDialect: "plsql" }; const activeTab = shallowRef(tab); const container = document.createElement("div"); document.body.append(container); @@ -58,6 +58,15 @@ function toggle(container: HTMLElement, option: string) { expect(button!.disabled).toBe(false); button!.click(); } + +it.each(["oracle", "oceanbase-oracle"] as const)("preserves %s explicit service-name owner on initial load and refresh", async (type) => { + const { tab, store } = await mountViewer(type, true, "service", "service"); + await vi.waitFor(() => expect(tab.ddlLoad).toBeUndefined()); + expect(mocks.load.mock.calls[0][0]).toMatchObject({ schema: "service", explicitOwner: "service", separateIndexes: true }); + tab.ddlLoad = { startedAt: Date.now() }; + await store.loadDdlViewerTab(tab.id, true); + expect(mocks.load.mock.lastCall?.[0]).toMatchObject({ schema: "service", explicitOwner: "service", separateIndexes: true }); +}); function deferred() { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index b3f3e89a34..1748affe74 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -297,6 +297,7 @@ async function loadDdl(force = false) { queryTimeoutSecs: connectionStore.getConfig(props.connectionId)?.query_timeout_secs, database: props.database, schema: props.schema || props.database, + explicitOwner: props.schema || undefined, tableName: props.tableName, objectType: props.objectType, catalog: props.catalog, @@ -485,6 +486,7 @@ function createDdlViewerTab(ddl: string, pending: boolean) { if (tab) { tab.ddlViewer = { schema: props.schema || props.database, + explicitOwner: props.schema || undefined, tableName: props.tableName, objectType: props.objectType, formatDialect: ddlFormatDialectFor({ formatDialect: props.formatDialect, databaseType: props.databaseType, highlightDialect: props.dialect }), diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 7143d1105d..1ee24329f3 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1672,6 +1672,7 @@ function tableMetadataRequest(row: ObjectBrowserRow): ObjectDdlRequest { queryTimeoutSecs: props.connection.query_timeout_secs, database: props.database || "", schema: index ? index.parentSchema : row.schema || selectedSchema.value || props.database, + explicitOwner: index ? index.parentSchema : row.schema || selectedSchema.value || undefined, tableName: index ? index.parentName : row.name, objectType: index ? undefined : tableDdlObjectType(row.type), catalog: props.catalog, @@ -3754,16 +3755,9 @@ async function confirmBatchEmptyTables() { async function exportStructure(row: ObjectBrowserRow) { try { const schema = row.schema || selectedSchema.value || props.database; - const ddl = await api.getTableDdl( - props.connection.id, - props.database, - schema, - row.name, - tableDdlObjectType(row.type), - props.catalog, - true, - ...((effectiveDatabaseType.value === "oracle" || effectiveDatabaseType.value === "oceanbase-oracle") && row.type === "TABLE" && settingsStore.editorSettings.separateDdlIndexes ? ([undefined, true] as const) : []), - ); + const indexOptions: [boolean?, boolean?, string?] = + (effectiveDatabaseType.value === "oracle" || effectiveDatabaseType.value === "oceanbase-oracle") && row.type === "TABLE" && settingsStore.editorSettings.separateDdlIndexes ? [undefined, true, row.schema || selectedSchema.value || undefined] : []; + const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name, tableDdlObjectType(row.type), props.catalog, true, ...indexOptions); await saveFileContent(buildSingleDdlExportFileContent(applyDdlTextPreferences(ddl, effectiveDatabaseType.value, settingsStore.editorSettings)), `${row.name}.sql`, "SQL", "sql"); } catch (e: any) { console.error("Export structure failed:", e); diff --git a/apps/desktop/src/components/objects/__tests__/DdlViewDialog.preferences.spec.ts b/apps/desktop/src/components/objects/__tests__/DdlViewDialog.preferences.spec.ts index d65c8920d2..7340bd9cfd 100644 --- a/apps/desktop/src/components/objects/__tests__/DdlViewDialog.preferences.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/DdlViewDialog.preferences.spec.ts @@ -4,11 +4,17 @@ import { createApp, h, nextTick, reactive, type App } from "vue"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import DdlViewDialog from "../DdlViewDialog.vue"; -const calls = vi.hoisted(() => ({ load: vi.fn(), copy: vi.fn(), format: vi.fn(), settings: { ddlOpenMode: "dialog", refreshDdlOnOpen: false, includeDdlStorage: true, compactDdlSql: false, separateDdlIndexes: false } })); +const calls = vi.hoisted(() => ({ + load: vi.fn(), + copy: vi.fn(), + format: vi.fn(), + queryStore: { tabs: [] as any[], createTab: vi.fn(), loadDdlViewerTab: vi.fn() }, + settings: { ddlOpenMode: "dialog", refreshDdlOnOpen: false, includeDdlStorage: true, compactDdlSql: false, separateDdlIndexes: false }, +})); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: reactive(calls.settings), updateEditorSettings: (patch: object) => Object.assign(reactive(calls.settings), patch) }) })); vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => ({ query_timeout_secs: 20 }) }) })); -vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => calls.queryStore })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: undefined } }) })); vi.mock("@/lib/metadata/objectDdlCache", () => ({ loadObjectDdl: calls.load })); @@ -74,8 +80,8 @@ function deferred() { }); return { promise, resolve, reject }; } -function mountDialog() { - const props = reactive({ open: true, connectionId: "oracle-connection", database: "service", schema: "Owner", tableName: "First", databaseType: "oracle" as const, dialect: "mysql" as const }); +function mountDialog(schema = "Owner") { + const props = reactive({ open: true, connectionId: "oracle-connection", database: "service", schema, tableName: "First", databaseType: "oracle" as const, dialect: "mysql" as const }); const container = document.createElement("div"); document.body.append(container); app = createApp({ render: () => h(DdlViewDialog, props) }); @@ -90,7 +96,8 @@ async function copyDdl() { } beforeEach(() => { vi.clearAllMocks(); - Object.assign(reactive(calls.settings), { separateDdlIndexes: false, includeDdlStorage: true, compactDdlSql: false }); + Object.assign(reactive(calls.settings), { ddlOpenMode: "dialog", separateDdlIndexes: false, includeDdlStorage: true, compactDdlSql: false }); + calls.queryStore.tabs.length = 0; calls.format.mockImplementation(async (sql: string) => sql); }); afterEach(() => { @@ -103,7 +110,7 @@ it("loads independent indexes only when selected, and copies the selected result calls.load.mockResolvedValueOnce({ ddl: "CREATE TABLE First (id NUMBER);" }).mockResolvedValueOnce({ ddl: "CREATE TABLE First (id NUMBER);\nCREATE INDEX ix ON First(id);" }); mountDialog(); await vi.waitFor(() => expect(document.querySelector("textarea")?.value).toContain("CREATE TABLE")); - expect(calls.load.mock.calls[0][0]).toMatchObject({ schema: "Owner", tableName: "First", separateIndexes: false }); + expect(calls.load.mock.calls[0][0]).toMatchObject({ schema: "Owner", explicitOwner: "Owner", tableName: "First", separateIndexes: false }); reactive(calls.settings).separateDdlIndexes = true; await vi.waitFor(() => expect(document.querySelector("textarea")?.value).toContain("CREATE INDEX")); expect(calls.load.mock.calls[1][0]).toMatchObject({ separateIndexes: true }); @@ -176,3 +183,14 @@ it("blocks stale copying on failure and lets the user turn independent indexes o await copyDdl(); expect(calls.copy).toHaveBeenLastCalledWith("RECOVERED"); }); + +it.each(["service", ""])("keeps explicit owner provenance when opening schema '%s' in a new tab", async (schema) => { + reactive(calls.settings).ddlOpenMode = "tab"; + calls.queryStore.createTab.mockImplementation(() => { + calls.queryStore.tabs.push({ id: "ddl" }); + return "ddl"; + }); + mountDialog(schema); + await vi.waitFor(() => expect(calls.queryStore.loadDdlViewerTab).toHaveBeenCalledWith("ddl")); + expect(calls.queryStore.tabs[0].ddlViewer).toMatchObject({ schema: "service", explicitOwner: schema || undefined }); +}); diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaIndexes.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaIndexes.component.spec.ts index b0c4fcb847..ebc0f71fb5 100644 --- a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaIndexes.component.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaIndexes.component.spec.ts @@ -150,7 +150,7 @@ describe("schema index browsing and draft navigation", () => { }); action.click(); await vi.waitFor(() => expect(mocks.getTableDdl).toHaveBeenCalledOnce()); - expect(mocks.getTableDdl.mock.calls[0]).toEqual([connection.id, "service", "Mixed", "EXPORT_TARGET", objectType === "VIEW" ? "VIEW" : undefined, undefined, true, ...(objectType === "TABLE" ? [undefined, true] : [])]); + expect(mocks.getTableDdl.mock.calls[0]).toEqual([connection.id, "service", "Mixed", "EXPORT_TARGET", objectType === "VIEW" ? "VIEW" : undefined, undefined, true, ...(objectType === "TABLE" ? [undefined, true, "Mixed"] : [])]); } finally { createUrl.mockRestore(); revokeUrl.mockRestore(); @@ -177,7 +177,7 @@ describe("schema index browsing and draft navigation", () => { option.click(); await vi.waitFor(() => expect(mocks.getTableDdl).toHaveBeenCalledTimes(2)); expect(mocks.getTableDdl.mock.calls[1].slice(0, 6)).toEqual(mocks.getTableDdl.mock.calls[0].slice(0, 6)); - expect(mocks.getTableDdl.mock.calls[1].slice(7)).toEqual([true, true]); + expect(mocks.getTableDdl.mock.calls[1].slice(7)).toEqual([true, true, " Other Owner "]); await vi.waitFor(() => expect(host.textContent).toContain("CREATE INDEX detached")); }); diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts index 5812c3ffc6..d06c3c7f15 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts @@ -135,7 +135,7 @@ describe("useSidebarTreeExportRuntime", () => { activeNode.value = secondNode; settingsStore.editorSettings.separateDdlIndexes = true; await vi.waitFor(() => expect(apiMock.getTableDdl).toHaveBeenCalledTimes(2)); - expect(apiMock.getTableDdl.mock.calls[1]).toEqual(["conn-1", "db", "app", "A", undefined, undefined, true, undefined, true]); + expect(apiMock.getTableDdl.mock.calls[1]).toEqual(["conn-1", "db", "app", "A", undefined, undefined, true, undefined, true, "app"]); await vi.waitFor(() => expect(isLoadingStructurePreview.value).toBe(false)); expect(structurePreviewSql.value).toContain("CREATE INDEX ix"); expect(structurePreviewDefaultFileName.value).toBe("A.sql"); diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts index aa6a72ff3a..bb3500a3b1 100644 --- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts @@ -225,7 +225,8 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt await connectionStore.ensureConnected(target.connectionId); if (requestId !== structureRequestId) return; const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(target.connectionId)); - const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, tableDdlObjectTypeForNode(target.type), target.catalog, true, ...(supportsDdlStoragePreference(databaseType) && separateIndexes ? ([undefined, true] as const) : [])); + const indexOptions: [boolean?, boolean?, string?] = supportsDdlStoragePreference(databaseType) && separateIndexes ? [undefined, true, target.schema || undefined] : []; + const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, tableDdlObjectTypeForNode(target.type), target.catalog, true, ...indexOptions); if (requestId !== structureRequestId) return; requestSource.push({ ddl, databaseType }); } diff --git a/apps/desktop/src/lib/backend/__tests__/exactMetadataTransport.spec.ts b/apps/desktop/src/lib/backend/__tests__/exactMetadataTransport.spec.ts index fa47229949..72f94a76f6 100644 --- a/apps/desktop/src/lib/backend/__tests__/exactMetadataTransport.spec.ts +++ b/apps/desktop/src/lib/backend/__tests__/exactMetadataTransport.spec.ts @@ -58,4 +58,18 @@ describe("exact metadata transports", () => { expect(url.searchParams.get("table")).toBe(table); expect(url.searchParams.get("exact_identity")).toBe("true"); }); + + test.each(["native", "display"] as const)("both %s DDL transports carry explicit owner without requiring exact capability", async (mode) => { + for (const api of [tauri, http]) { + if (mode === "native") await api.getTableDdl("conn", owner, owner, table, undefined, undefined, false, undefined, true, owner); + else await api.getTableDisplayDdl("conn", owner, owner, table, undefined, undefined, true, owner); + } + const payload = invoke.mock.calls[0][1]; + expect(payload).toMatchObject({ schema: owner, explicitOwner: owner, separateIndexes: true }); + expect(payload).not.toHaveProperty("exactIdentity"); + const url = new URL(String(vi.mocked(fetch).mock.calls[0][0]), "http://localhost"); + expect(url.searchParams.get("explicit_owner")).toBe(owner); + expect(url.searchParams.get("schema")).toBe(owner); + expect(url.searchParams.has("exact_identity")).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index fdd70173cd..641e65dbf2 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1429,12 +1429,12 @@ export async function listSubpartitions(connectionId: string, database: string, return get(`/api/schema/subpartitions?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } -export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, portable = false, exactIdentity?: boolean, separateIndexes?: boolean): Promise { - return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, portable, exact_identity: exactIdentity || undefined, separate_indexes: separateIndexes || undefined })}`); +export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, portable = false, exactIdentity?: boolean, separateIndexes?: boolean, explicitOwner?: string): Promise { + return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, portable, exact_identity: exactIdentity || undefined, separate_indexes: separateIndexes || undefined, explicit_owner: explicitOwner })}`); } -export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, separateIndexes?: boolean): Promise { - return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, include_postgres_access: true, separate_indexes: separateIndexes || undefined })}`); +export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, separateIndexes?: boolean, explicitOwner?: string): Promise { + return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, include_postgres_access: true, separate_indexes: separateIndexes || undefined, explicit_owner: explicitOwner })}`); } export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index e1eae1be8f..515596c095 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2873,7 +2873,7 @@ export async function listSubpartitions(connectionId: string, database: string, }); } -export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, portable = false, exactIdentity?: boolean, separateIndexes?: boolean): Promise { +export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, portable = false, exactIdentity?: boolean, separateIndexes?: boolean, explicitOwner?: string): Promise { return invoke("get_table_ddl", { connectionId, database, @@ -2884,10 +2884,11 @@ export async function getTableDdl(connectionId: string, database: string, schema portable, ...(exactIdentity ? { exactIdentity: true } : {}), ...(separateIndexes ? { separateIndexes: true } : {}), + ...(explicitOwner !== undefined ? { explicitOwner } : {}), }); } -export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, separateIndexes?: boolean): Promise { +export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, separateIndexes?: boolean, explicitOwner?: string): Promise { return invoke("get_table_ddl", { connectionId, database, @@ -2897,6 +2898,7 @@ export async function getTableDisplayDdl(connectionId: string, database: string, catalog, includePostgresAccess: true, ...(separateIndexes ? { separateIndexes: true } : {}), + ...(explicitOwner !== undefined ? { explicitOwner } : {}), portable: false, }); } diff --git a/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts index 4709270c03..a03abde1dc 100644 --- a/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts +++ b/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts @@ -24,7 +24,7 @@ describe("objectDdlCache", () => { expect(objectDdlCacheKey(separate)).not.toBe(objectDdlCacheKey(request)); await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "original", cacheStatus: "remote" }); await expect(loadObjectDdl(separate)).resolves.toMatchObject({ ddl: "independent", cacheStatus: "remote" }); - expect(mocks.getTableDisplayDdl.mock.calls[1]).toEqual(["c1", "app", "public", "users", undefined, "analytics", true]); + expect(mocks.getTableDisplayDdl.mock.calls[1]).toEqual(["c1", "app", "public", "users", undefined, "analytics", true, undefined]); await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "original", cacheStatus: "memory" }); await invalidateObjectDdlCache({ connectionId: "c1", database: "app", schema: "public", tableName: "users" }); mocks.getTableDisplayDdl.mockRejectedValueOnce(new Error("supporting index unavailable")); @@ -33,6 +33,21 @@ describe("objectDdlCache", () => { await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "fresh original", cacheStatus: "remote" }); }); + it("isolates an explicit owner from the same service fallback and invalidates both", async () => { + const fallback = { ...request, schema: request.database, separateIndexes: true }; + const explicit = { ...fallback, explicitOwner: request.database }; + expect(objectDdlCacheKey(explicit)).not.toBe(objectDdlCacheKey(fallback)); + mocks.getTableDisplayDdl.mockResolvedValueOnce("explicit").mockRejectedValueOnce(new Error("ambiguous service")); + await expect(loadObjectDdl(explicit)).resolves.toMatchObject({ ddl: "explicit", cacheStatus: "remote" }); + expect(mocks.getTableDisplayDdl).toHaveBeenLastCalledWith("c1", "app", "app", "users", undefined, "analytics", true, "app"); + await expect(loadObjectDdl(fallback)).rejects.toThrow("ambiguous service"); + await expect(loadObjectDdl(explicit)).resolves.toMatchObject({ cacheStatus: "memory" }); + await invalidateObjectDdlCache({ connectionId: "c1", database: "app", schema: "app", tableName: "users" }); + mocks.getTableDisplayDdl.mockResolvedValueOnce("fresh"); + await expect(loadObjectDdl(explicit)).resolves.toMatchObject({ ddl: "fresh", cacheStatus: "remote" }); + expect(mocks.getTableDdl).not.toHaveBeenCalled(); + }); + it.each(["remote", "disk"])("settles local waiting when cancellation retires a never-returning %s read", async (phase) => { if (phase === "disk") mocks.loadSchemaCache.mockReturnValueOnce(new Promise(() => undefined)); mocks.getTableDisplayDdl.mockReturnValueOnce(new Promise(() => undefined)); diff --git a/apps/desktop/src/lib/metadata/objectDdlCache.ts b/apps/desktop/src/lib/metadata/objectDdlCache.ts index 2ba8ed65b3..a4123ee7c8 100644 --- a/apps/desktop/src/lib/metadata/objectDdlCache.ts +++ b/apps/desktop/src/lib/metadata/objectDdlCache.ts @@ -33,6 +33,7 @@ export interface ObjectDdlRequest { catalog?: string; exactIdentity?: boolean; separateIndexes?: boolean; + explicitOwner?: string; } export interface ObjectDdlLoadResult { @@ -67,7 +68,7 @@ function cacheSegment(value: string | undefined): string { } export function objectDdlCacheKey(request: ObjectDdlRequest): string { - return `${[OBJECT_DDL_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), cacheSegment(request.objectType ?? "TABLE"), ...(request.exactIdentity ? ["exact-v1"] : []), ...(request.separateIndexes ? ["separate-indexes-v1"] : [])].join(":")}:`; + return `${[OBJECT_DDL_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), cacheSegment(request.objectType ?? "TABLE"), ...(request.exactIdentity ? ["exact-v1"] : []), ...(request.separateIndexes ? ["separate-indexes-v1"] : []), ...(request.separateIndexes && request.explicitOwner !== undefined ? ["owner-v1", cacheSegment(request.explicitOwner)] : [])].join(":")}:`; } function invalidationPrefix(match: MetadataCacheInvalidation): string { @@ -158,10 +159,10 @@ async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: recordMetadataCacheRemoteMiss(cacheKey); const remote = request.exactIdentity ? request.separateIndexes - ? api.getTableDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog, false, true, true) + ? api.getTableDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog, false, true, true, request.explicitOwner) : api.getTableDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog, false, true) : request.separateIndexes - ? api.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog, true) + ? api.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog, true, request.explicitOwner) : api.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog); entry.promise = waitForMetadata( remote.then((rawDdl) => { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 50a730c46e..e2685aeb4c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3626,13 +3626,24 @@ export const useQueryStore = defineStore("query", () => { const { database } = tab; const catalog = tab.catalog; const schema = ddlViewer.schema; + const explicitOwner = ddlViewer.explicitOwner; const tableName = ddlViewer.tableName; const objectType = ddlViewer.objectType; const settingsStore = useSettingsStore(); const connectionStore = useConnectionStore(); const isCurrent = () => { const current = tabs.value.find((candidate) => candidate.id === id); - return current?.ddlLoad === request && current.ddlViewer === ddlViewer && current.connectionId === connectionId && current.database === database && current.catalog === catalog && ddlViewer.schema === schema && ddlViewer.tableName === tableName && ddlViewer.objectType === objectType; + return ( + current?.ddlLoad === request && + current.ddlViewer === ddlViewer && + current.connectionId === connectionId && + current.database === database && + current.catalog === catalog && + ddlViewer.schema === schema && + ddlViewer.explicitOwner === explicitOwner && + ddlViewer.tableName === tableName && + ddlViewer.objectType === objectType + ); }; const currentSeparateIndexes = () => { const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId)); @@ -3657,6 +3668,7 @@ export const useQueryStore = defineStore("query", () => { queryTimeoutSecs: connectionStore.getConfig(connectionId)?.query_timeout_secs, database, schema: ddlViewer.schema || database, + explicitOwner, tableName: ddlViewer.tableName, objectType: ddlViewer.objectType, catalog: tab.catalog, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 97abe0fbab..219ab7396c 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -2811,6 +2811,7 @@ export interface QueryTab { }; ddlViewer?: { schema?: string; + explicitOwner?: string; tableName: string; objectType?: ObjectSourceKind; formatDialect?: SqlFormatDialect; diff --git a/crates/dbx-core/src/schema/oracle_ddl_indexes.rs b/crates/dbx-core/src/schema/oracle_ddl_indexes.rs index 769bb446cd..11ccbc5547 100644 --- a/crates/dbx-core/src/schema/oracle_ddl_indexes.rs +++ b/crates/dbx-core/src/schema/oracle_ddl_indexes.rs @@ -662,7 +662,11 @@ pub async fn separate_table_ddl_indexes_core( schema: &str, table: &str, source: &str, + explicit_owner: Option<&str>, ) -> Result { + if explicit_owner.is_some_and(|owner| owner.is_empty() || owner != schema) { + return Err("Explicit table owner must match the selected schema".into()); + } let config = connection_config(state, connection_id).await.ok_or("connection not found")?; let separate_supporting_indexes = config.db_type != DatabaseType::OceanbaseOracle && crate::models::connection::jdbc_dialect_hint(&config) @@ -674,7 +678,7 @@ pub async fn separate_table_ddl_indexes_core( schema, table, source, - false, + explicit_owner.is_some(), separate_supporting_indexes, ) .await?; @@ -717,7 +721,7 @@ async fn capture_table_ddl_indexes( return Err("Table DDL does not match the selected table name".into()); } let owner = match source_metadata.owner.as_deref() { - Some(owner) if owner == schema || schema.is_empty() || schema == database => owner, + Some(owner) if owner == schema || (!explicit_schema && (schema.is_empty() || schema == database)) => owner, Some(_) => return Err("Table DDL does not match the selected table owner".into()), None if !schema.is_empty() && (explicit_schema || schema != database) => schema, None => return Err("Unqualified table DDL requires an explicit Oracle schema, not a service name".into()), @@ -1041,7 +1045,8 @@ for line in sys.stdin: #[tokio::test] async fn native_post_table_primary_key_reaches_view_and_removed_rollback_entry_points() { let (_directory, state) = fixture("native-post").await; - let output = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", POST_SOURCE).await.unwrap(); + let output = + separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", POST_SOURCE, None).await.unwrap(); assert_eq!(output.matches("CREATE INDEX").count(), 1); assert_eq!(output.matches("PRIMARY KEY").count(), 1); assert!(!output.contains("CREATE UNIQUE INDEX")); @@ -1056,7 +1061,7 @@ for line in sys.stdin: assert!(captured.rollback[0].issues.is_empty(), "{:?}", captured.rollback[0].issues); assert_eq!(captured.rollback[0].supporting_indexes.len(), 1); let conflict = POST_SOURCE.replace("USING INDEX \" O'w \".\" I'x \"", "USING INDEX \" O'w \".OTHER"); - assert!(separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", &conflict) + assert!(separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", &conflict, None) .await .unwrap_err() .contains("binding disagree")); @@ -1126,7 +1131,7 @@ for line in sys.stdin: async fn oceanbase_view_export_keeps_inline_support_and_supplements_only_the_ordinary_index() { let (directory, state) = fixture("ob-ordinary").await; state.configs.write().await.get_mut("conn").unwrap().db_type = DatabaseType::OceanbaseOracle; - let result = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE).await.unwrap(); + let result = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE, None).await.unwrap(); assert!(result.contains("PRIMARY KEY (\"ID\") USING INDEX ENABLE")); assert_eq!(result.matches("CREATE INDEX").count(), 1); assert!(result.contains("\"ORD\"")); @@ -1270,6 +1275,44 @@ for line in sys.stdin: assert!(!requests.contains("execute_query")); } + #[tokio::test] + async fn explicit_display_owner_can_equal_service_and_rejects_conflicting_ddl() { + for db_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { + let (directory, state) = fixture("ordinary").await; + state.configs.write().await.get_mut("conn").unwrap().db_type = db_type; + let ddl = "CREATE TABLE T(ID NUMBER);"; + assert!(separate_table_ddl_indexes_core(&state, "conn", OWNER, OWNER, "T", ddl, None) + .await + .unwrap_err() + .contains("explicit Oracle schema")); + let result = + separate_table_ddl_indexes_core(&state, "conn", OWNER, OWNER, "T", ddl, Some(OWNER)).await.unwrap(); + assert!(result.contains("DESC) INVISIBLE"), "{result}"); + let calls = std::fs::read_to_string(directory.path().join("calls")).unwrap(); + assert!(calls.contains("TABLE_OWNER = ' O''w '")); + for owner in ["", "OTHER"] { + assert!(separate_table_ddl_indexes_core(&state, "conn", OWNER, OWNER, "T", ddl, Some(owner)) + .await + .unwrap_err() + .contains("must match")); + } + assert!(separate_table_ddl_indexes_core( + &state, + "conn", + OWNER, + OWNER, + "T", + "CREATE TABLE OTHER.T(ID NUMBER);", + Some(OWNER), + ) + .await + .unwrap_err() + .contains("selected table owner")); + assert_eq!(calls, std::fs::read_to_string(directory.path().join("calls")).unwrap()); + state.shutdown(Duration::from_secs(1)).await; + } + } + #[tokio::test] async fn comparison_explicit_schema_can_equal_service_without_using_the_legacy_fallback() { let (_directory, state) = fixture("ordinary").await; @@ -1281,7 +1324,7 @@ for line in sys.stdin: assert!(captured.issues.is_empty(), "{:?}", captured.issues); assert_eq!(captured.owner, OWNER); assert!(captured.table_ddl.unwrap().contains("CREATE TABLE T")); - assert!(separate_table_ddl_indexes_core(&state, "conn", OWNER, OWNER, "T", ddl) + assert!(separate_table_ddl_indexes_core(&state, "conn", OWNER, OWNER, "T", ddl, None) .await .unwrap_err() .contains("explicit Oracle schema")); @@ -1332,7 +1375,7 @@ for line in sys.stdin: #[tokio::test] async fn independent_indexes_core_preserves_full_definitions_and_exact_dictionary_identity() { let (directory, state) = fixture("valid").await; - let result = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE).await.unwrap(); + let result = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE, None).await.unwrap(); assert!(result.contains(&"完整定义 ".repeat(4000))); assert!(result.contains("(\"ID\" DESC) INVISIBLE")); assert!(result.contains("USING INDEX \" O'w \".\" I'x \"")); @@ -1368,7 +1411,8 @@ for line in sys.stdin: "inventory-deferred", ] { let (_directory, state) = fixture(mode).await; - let error = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE).await.unwrap_err(); + let error = + separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", SOURCE, None).await.unwrap_err(); let expected = match mode { "dictionary-deferred" | "ddl-deferred" @@ -1400,7 +1444,8 @@ for line in sys.stdin: ("iot", SOURCE.trim_end_matches(';').to_string() + " ORGANIZATION INDEX;"), ] { let (directory, state) = fixture(mode).await; - let result = separate_table_ddl_indexes_core(&state, "conn", "demo", "demo", "T", &source).await.unwrap(); + let result = + separate_table_ddl_indexes_core(&state, "conn", "demo", "demo", "T", &source, None).await.unwrap(); assert_eq!(result, source); let requests = std::fs::read_to_string(directory.path().join("calls")).unwrap(); assert!(!requests.contains("GET_DDL"), "{mode}: {requests}"); @@ -1416,7 +1461,8 @@ for line in sys.stdin: if existing { source.push_str("\nCREATE INDEX \" O'w \".\" I'x \" ON \" O'w \".\"T\" (\"ID\" DESC) INVISIBLE;"); } - let result = separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", &source).await.unwrap(); + let result = + separate_table_ddl_indexes_core(&state, "conn", "demo", OWNER, "T", &source, None).await.unwrap(); assert_eq!(result.matches(" INDEX ").count(), 1); assert!(result.contains("DESC) INVISIBLE")); assert!(!result.contains("ALTER TABLE")); diff --git a/crates/dbx-sql-schema/src/schema_diff.rs b/crates/dbx-sql-schema/src/schema_diff.rs index 03cc270358..8ca23e47fe 100644 --- a/crates/dbx-sql-schema/src/schema_diff.rs +++ b/crates/dbx-sql-schema/src/schema_diff.rs @@ -7119,6 +7119,14 @@ fn generate_schema_sync_sql_inner_with_oracle_ddl( "-- Trigger {}: {} on {}; review trigger definition manually.", trigger.diff_type, trigger.name, diff.name )); + if matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) { + missing_objects.push(MissingRollbackObject { + kind: "trigger".into(), + name: trigger.name.clone(), + table: Some(target_name.into()), + reason: "Trigger synchronization requires a complete reviewed definition; no trigger DDL was generated".into(), + }); + } } } diff --git a/crates/dbx-sql-schema/src/schema_diff/oracle_indexes.rs b/crates/dbx-sql-schema/src/schema_diff/oracle_indexes.rs index 1364921efa..8d25d2b868 100644 --- a/crates/dbx-sql-schema/src/schema_diff/oracle_indexes.rs +++ b/crates/dbx-sql-schema/src/schema_diff/oracle_indexes.rs @@ -461,6 +461,100 @@ mod tests { } } + #[test] + fn modified_trigger_manual_preview_is_not_reported_complete() { + let source: TriggerInfo = + serde_json::from_value(json!({"name":"TR","event":"DELETE","timing":"BEFORE EACH ROW","enabled":false})) + .unwrap(); + let target: TriggerInfo = + serde_json::from_value(json!({"name":"TR","event":"INSERT","timing":"BEFORE EACH ROW","enabled":true})) + .unwrap(); + for (source, target) in + [(vec![source.clone()], vec![target.clone()]), (vec![source], vec![]), (vec![], vec![target])] + { + let triggers = diff_triggers(&source, &target); + assert_eq!(triggers.len(), 1); + let diffs: Vec = + serde_json::from_value(json!([{"type":"modified","name":"T","triggers":triggers}])).unwrap(); + for db_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { + let result = generate_schema_sync_sql_plan_with_oracle_ddl( + &diffs, + &[], + &[], + &[], + &[], + db_type, + Some("APP"), + false, + Some(DialectKind::Oracle), + &[], + true, + false, + Some(&OracleSchemaSyncDdlSources::default()), + ); + assert_eq!(result.forward_completeness, RollbackCompleteness::Incomplete); + assert_eq!(result.rollback_completeness, RollbackCompleteness::Incomplete); + assert!(result.generation_issues.iter().any(|issue| issue.kind == "trigger" && issue.name == "TR")); + } + } + } + + #[test] + fn disabled_raw_triggers_without_enriched_source_never_silently_enable() { + for db_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] { + for rollback in [false, true] { + for captured_state in ["", " ALTER TRIGGER APP.TR DISABLE;"] { + let kind = if rollback { "removed" } else { "added" }; + let side = if rollback { "target" } else { "source" }; + let mut trigger = json!({"type":kind,"name":"TR"}); + trigger[side] = json!({"name":"TR","event":"INSERT","timing":"BEFORE EACH ROW","enabled":false}); + let diffs: Vec = + serde_json::from_value(json!([{"type":kind,"name":"T","triggers":[trigger]}])).unwrap(); + let original = captured("T", &format!("CREATE TABLE APP.T(ID NUMBER); CREATE TRIGGER APP.TR BEFORE INSERT ON APP.T FOR EACH ROW BEGIN NULL; END;{captured_state}")); + let sources = if rollback { + OracleSchemaSyncDdlSources { rollback: vec![original], ..Default::default() } + } else { + OracleSchemaSyncDdlSources { forward: vec![original], ..Default::default() } + }; + let result = generate_schema_sync_sql_plan_with_oracle_ddl( + &diffs, + &[], + &[], + &[], + &[], + db_type, + Some("APP"), + false, + Some(DialectKind::Oracle), + &[], + rollback, + false, + Some(&sources), + ); + let (sql, completeness) = if rollback { + (result.rollback_sync_sql.as_deref().unwrap(), &result.rollback_completeness) + } else { + (result.sync_sql.as_str(), &result.forward_completeness) + }; + if captured_state.is_empty() { + assert_eq!( + *completeness, + RollbackCompleteness::Incomplete, + "{db_type:?} rollback={rollback}: {sql}" + ); + assert!(result + .generation_issues + .iter() + .any(|issue| issue.reason.contains("firing state changed"))); + } else { + assert_eq!(*completeness, RollbackCompleteness::Complete, "{:?}", result.generation_issues); + assert!(sql.contains("ALTER TRIGGER APP.TR DISABLE;"), "{sql}"); + } + } + } + } + } + #[test] fn raw_trigger_default_does_not_supply_an_unknown_snapshot_firing_state() { let diffs = serde_json::from_value::>(json!([{"type":"added","name":"T","triggers":[ diff --git a/crates/dbx-tauri-schema/src/commands.rs b/crates/dbx-tauri-schema/src/commands.rs index e27264e397..f7673fc5f6 100644 --- a/crates/dbx-tauri-schema/src/commands.rs +++ b/crates/dbx-tauri-schema/src/commands.rs @@ -831,6 +831,7 @@ pub async fn get_table_ddl( portable: Option, exact_identity: Option, separate_indexes: Option, + explicit_owner: Option, ) -> Result { let separate_indexes = separate_indexes.unwrap_or(false) && object_type.is_none(); let result = if exact_identity.unwrap_or(false) { @@ -856,8 +857,16 @@ pub async fn get_table_ddl( dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await? }; if separate_indexes { - dbx_core::schema::separate_table_ddl_indexes_core(&state, &connection_id, &database, &schema, &table, &result) - .await + dbx_core::schema::separate_table_ddl_indexes_core( + &state, + &connection_id, + &database, + &schema, + &table, + &result, + explicit_owner.as_deref().or_else(|| exact_identity.unwrap_or(false).then_some(schema.as_str())), + ) + .await } else { Ok(result) } diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 86615faac6..b415b1aca8 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -35,6 +35,7 @@ pub struct SchemaQuery { pub identity_mode: Option, pub exact_identity: Option, pub separate_indexes: Option, + pub explicit_owner: Option, } async fn run_cancellable(state: &Arc, execution_id: Option, future: F) -> Result @@ -998,6 +999,7 @@ pub async fn get_ddl( schema, table, &result, + q.explicit_owner.as_deref().or_else(|| q.exact_identity.unwrap_or(false).then_some(schema)), ) .await .map_err(AppError::from)?