fix(doris): carry external catalog into unqualified query metadata
Closes #9776
This commit is contained in:
2 files changed
+158
-4
No files matched your search
+142
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user