fix(oracle): preserve explicit DDL owner and trigger completeness

This commit is contained in:
efeire authored and GitHub committed 2026-10-26 11:24:26 +08:00
1 parent 563a0306e8
commit 99ebaecd39
19 files changed
+273 -45

No files matched your search

@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
@@ -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 }),
@@ -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);
@@ -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<T>() {
});
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 });
});
@@ -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"));
});
@@ -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");
@@ -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 });
}
@@ -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);
});
});
+4 -4
View File
@@ -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<string> {
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<string> {
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<string> {
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<string> {
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<SchemaDiffPreparation> {
+4 -2
View File
@@ -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<string> {
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<string> {
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<string> {
export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string, separateIndexes?: boolean, explicitOwner?: string): Promise<string> {
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,
});
}
@@ -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));
@@ -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) => {
+13 -1
View File
@@ -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,
+1
View File
@@ -2811,6 +2811,7 @@ export interface QueryTab {
};
ddlViewer?: {
schema?: string;
explicitOwner?: string;
tableName: string;
objectType?: ObjectSourceKind;
formatDialect?: SqlFormatDialect;
@@ -662,7 +662,11 @@ pub async fn separate_table_ddl_indexes_core(
schema: &str,
table: &str,
source: &str,
explicit_owner: Option<&str>,
) -> Result<String, String> {
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"));
+8
View File
@@ -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(),
});
}
}
}
@@ -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<TableDiff> =
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<TableDiff> =
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::<Vec<TableDiff>>(json!([{"type":"added","name":"T","triggers":[
+11 -2
View File
@@ -831,6 +831,7 @@ pub async fn get_table_ddl(
portable: Option<bool>,
exact_identity: Option<bool>,
separate_indexes: Option<bool>,
explicit_owner: Option<String>,
) -> Result<String, String> {
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)
}
+2
View File
@@ -35,6 +35,7 @@ pub struct SchemaQuery {
pub identity_mode: Option<dbx_core::schema::MetadataIdentityMode>,
pub exact_identity: Option<bool>,
pub separate_indexes: Option<bool>,
pub explicit_owner: Option<String>,
}
async fn run_cancellable<T, F>(state: &Arc<WebState>, execution_id: Option<String>, future: F) -> Result<T, AppError>
@@ -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)?