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 lookupLocalCompletionTables = vi.fn();
|
||||||
const buildSortedQuerySql = vi.fn();
|
const buildSortedQuerySql = vi.fn();
|
||||||
const buildDataGridCountSql = vi.fn();
|
const buildDataGridCountSql = vi.fn();
|
||||||
|
const appendDebugLog = vi.fn();
|
||||||
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
|
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
|
||||||
sqlToExecute: options.sql,
|
sqlToExecute: options.sql,
|
||||||
pageSql: undefined,
|
pageSql: undefined,
|
||||||
@@ -24,6 +25,10 @@ const editorSettings = {
|
|||||||
autoCalculateTotalRows: false,
|
autoCalculateTotalRows: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vi.mock("@/lib/backend/debugLog", () => ({
|
||||||
|
appendDebugLog,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/backend/api", () => ({
|
vi.mock("@/lib/backend/api", () => ({
|
||||||
analyzeEditableQueryEditability,
|
analyzeEditableQueryEditability,
|
||||||
buildDataGridCountSql,
|
buildDataGridCountSql,
|
||||||
@@ -136,4 +141,141 @@ describe("queryStore Doris qualified-table metadata target", () => {
|
|||||||
// Uncommented columns stay empty — no placeholder in the header.
|
// Uncommented columns stay empty — no placeholder in the header.
|
||||||
expect(comments.get("extra_field")).toBeNull();
|
expect(comments.get("extra_field")).toBeNull();
|
||||||
}, 15000);
|
}, 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 { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
|
||||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||||
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
|
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 { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||||
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
|
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
|
||||||
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
|
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 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 metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
|
||||||
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!;
|
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 = {
|
const metadataSource: EditableQuerySource = {
|
||||||
...source,
|
...source,
|
||||||
catalog: metadataCatalog,
|
catalog: metadataCatalog,
|
||||||
@@ -5904,7 +5910,10 @@ export const useQueryStore = defineStore("query", () => {
|
|||||||
queryDisplaySourceColumns: displayInfo.mapping,
|
queryDisplaySourceColumns: displayInfo.mapping,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6095,7 +6104,10 @@ export const useQueryStore = defineStore("query", () => {
|
|||||||
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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 {
|
return {
|
||||||
queryAnalysis: undefined,
|
queryAnalysis: undefined,
|
||||||
querySourceColumns: undefined,
|
querySourceColumns: undefined,
|
||||||
|
|||||||
Reference in new issue
Block a user