fix(doris): carry external catalog into unqualified query metadata

Closes #9776
This commit is contained in:
t8y2 committed 2026-09-21 01:44:23 +08:00
1 parent 7e5bcade78
commit 65ee5ac59c
2 files changed
+158 -4

No files matched your search

@@ -11,6 +11,7 @@ const getConnectionConfig = vi.fn();
const lookupLocalCompletionTables = vi.fn();
const buildSortedQuerySql = vi.fn();
const buildDataGridCountSql = vi.fn();
const appendDebugLog = vi.fn();
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
@@ -24,6 +25,10 @@ const editorSettings = {
autoCalculateTotalRows: false,
};
vi.mock("@/lib/backend/debugLog", () => ({
appendDebugLog,
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability,
buildDataGridCountSql,
@@ -136,4 +141,141 @@ describe("queryStore Doris qualified-table metadata target", () => {
// Uncommented columns stay empty — no placeholder in the header.
expect(comments.get("extra_field")).toBeNull();
}, 15000);
it("carries the tab's external catalog for unqualified tables so the column lookup resolves in the federated catalog", async () => {
// The engine's internal default database ("yunye") differs from the
// catalog database ("mydb"): without the catalog the lookup would search
// the internal catalog and find no such database (issue #9776).
getConnectionConfig.mockReturnValue({ id: "doris-1", name: "Doris", db_type: "doris", database: "yunye", query_timeout_secs: 30 });
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
// `SELECT * FROM dbx_comment_test` — nothing qualified in the SQL.
schema: undefined,
schemaQuoted: false,
tableName: "dbx_comment_test",
tableNameQuoted: false,
tableAlias: undefined,
selectStar: true,
columns: [],
multiSource: false,
allowInsertDelete: true,
},
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("doris-1", "mydb", "Query", "query", undefined, undefined, "ice");
await store.executeTabSql(tabId, "SELECT * FROM dbx_comment_test");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined(), { timeout: 8000 });
// The metadata request keeps the execution database and inherits the tab's
// external catalog: column comments and editability resolve there.
expect(getColumns).toHaveBeenCalledWith("doris-1", "mydb", "mydb", "dbx_comment_test", "ice");
const comments = new Map((tab.tableMeta?.columns ?? []).map((c) => [c.name, c.comment]));
expect(comments.get("id")).toBe("主键ID");
expect(comments.get("user_name")).toBe("用户名称");
}, 15000);
it("keeps the SQL-qualified catalog even when the tab selected a different external catalog", async () => {
getConnectionConfig.mockReturnValue({ id: "doris-1", name: "Doris", db_type: "doris", database: "yunye", query_timeout_secs: 30 });
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
// `SELECT * FROM other_catalog.hivedb.orders` — the SQL names a
// different catalog than the tab context; it must win.
catalog: "other_catalog",
catalogQuoted: false,
schema: "hivedb",
schemaQuoted: false,
tableName: "orders",
tableNameQuoted: false,
tableAlias: undefined,
selectStar: true,
columns: [],
multiSource: false,
allowInsertDelete: true,
},
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("doris-1", "mydb", "Query", "query", undefined, undefined, "ice");
await store.executeTabSql(tabId, "SELECT * FROM other_catalog.hivedb.orders");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined(), { timeout: 8000 });
// Qualified behavior is unchanged: the SQL's database lands in `schema`
// (the tab's execution database stays in `database`) and the catalog
// parsed from the SQL wins over the tab context.
expect(getColumns).toHaveBeenCalledWith("doris-1", "mydb", "hivedb", "orders", "other_catalog");
}, 15000);
it("never forwards a tab catalog to metadata on non-Doris-family connections", async () => {
getConnectionConfig.mockReturnValue({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "sakila", query_timeout_secs: 30 });
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
schemaQuoted: false,
tableName: "actor",
tableNameQuoted: false,
tableAlias: undefined,
selectStar: true,
columns: [],
multiSource: false,
allowInsertDelete: true,
},
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
// A stale/foreign catalog on the tab must not leak into metadata requests:
// plain MySQL has no catalog federation, so the lookup stays catalog-less.
const tabId = store.createTab("mysql-1", "sakila", "Query", "query", undefined, undefined, "legacy");
await store.executeTabSql(tabId, "SELECT * FROM actor");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined(), { timeout: 8000 });
expect(getColumns).toHaveBeenCalledWith("mysql-1", "sakila", "sakila", "actor", undefined);
}, 15000);
it("records a warn-level debug log with the execution context when the column lookup fails", async () => {
getConnectionConfig.mockReturnValue({ id: "doris-1", name: "Doris", db_type: "doris", database: "yunye", query_timeout_secs: 30 });
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
schemaQuoted: false,
tableName: "dbx_comment_test",
tableNameQuoted: false,
tableAlias: undefined,
selectStar: true,
columns: [],
multiSource: false,
allowInsertDelete: true,
},
});
getColumns.mockRejectedValueOnce(new Error("errcode: 1049, Unknown database 'mydb'"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("doris-1", "mydb", "Query", "query", undefined, undefined, "ice");
await store.executeTabSql(tabId, "SELECT * FROM dbx_comment_test");
// The failure stays silent for the user (no error result), surfaces as the
// metadata-unavailable read-only reason, and is diagnosable from the log.
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("metadata-unavailable"), { timeout: 8000 });
expect(tab.result?.error).toBeUndefined();
const failure = appendDebugLog.mock.calls.find(([level, message]) => level === "warn" && String(message).includes("metadata:columns:failed"));
expect(failure).toBeTruthy();
const details = failure?.[2] as Record<string, unknown> | undefined;
expect(details?.database).toBe("mydb");
expect(details?.catalog).toBe("ice");
}, 15000);
});
+16 -4
View File
@@ -45,7 +45,7 @@ import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
import { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
@@ -5553,7 +5553,13 @@ export const useQueryStore = defineStore("query", () => {
const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(metadataDbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined);
const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!;
const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.catalog, source.catalogQuoted);
// An unqualified source resolves in the tab's current external catalog on
// Doris-family federation: the statement executed there through the
// catalog execution context, so the column lookup must carry the same
// catalog or it searches the engine's internal catalog and finds no such
// database/table. Qualified sources keep the catalog parsed from the SQL.
const unqualifiedExecutionCatalog = !source.catalog && connectionIsDorisFamilyCatalogCapable(conn) ? tab.catalog : undefined;
const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.catalog ?? unqualifiedExecutionCatalog, source.catalogQuoted);
const metadataSource: EditableQuerySource = {
...source,
catalog: metadataCatalog,
@@ -5904,7 +5910,10 @@ export const useQueryStore = defineStore("query", () => {
queryDisplaySourceColumns: displayInfo.mapping,
};
} catch (err) {
console.error("[DBX] ERROR fetching columns for grouped query metadata:", err);
// Display enrichment stays silent for the user, but a swallowed lookup
// failure must stay diagnosable: record it with the trace id and the
// execution database/catalog so namespace mismatches can be traced.
queryExecutionLog("warn", "metadata:display-columns:failed", { traceId, database: executionDatabase, catalog: tab.catalog, error: err });
return undefined;
}
}
@@ -6095,7 +6104,10 @@ export const useQueryStore = defineStore("query", () => {
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
} catch (err) {
console.error("[DBX] ERROR fetching columns for query metadata:", err);
// Metadata stays optional for the result grid, but the failure was
// previously only a bare console error with no trace context. Record the
// execution database/catalog so a lost catalog shows up in the debug log.
queryExecutionLog("warn", "metadata:columns:failed", { traceId, database: executionDatabase, catalog: tab.catalog, error: err });
return {
queryAnalysis: undefined,
querySourceColumns: undefined,