From d9bb92d1e9ae302c7f9c827ec7c579090bdd7dec Mon Sep 17 00:00:00 2001 From: hhzorg Date: Thu, 9 Jul 2026 00:45:29 +0800 Subject: [PATCH] feat(doris): show external catalogs in sidebar --- apps/desktop/src/components/grid/DataGrid.vue | 17 +- .../src/components/sidebar/TreeItem.vue | 42 ++- .../src/composables/useDataGridActions.ts | 9 +- .../database/dorisCatalogCapability.spec.ts | 69 ++++ .../__tests__/table/tableSelectSql.spec.ts | 39 +++ apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 38 ++- apps/desktop/src/lib/backend/tauri.ts | 41 ++- apps/desktop/src/lib/dataGrid/dataGridSql.ts | 3 + .../lib/database/databaseFeatureSupport.ts | 31 +- .../src/lib/metadata/tableMetadataCache.ts | 13 +- .../src/lib/sidebar/sidebarTreeItemLayout.ts | 1 + apps/desktop/src/lib/sidebar/treeNodeIcon.ts | 2 + apps/desktop/src/lib/table/tableSelectSql.ts | 19 +- apps/desktop/src/lib/table/tableTree.ts | 7 +- apps/desktop/src/stores/connectionStore.ts | 318 ++++++++++++++---- apps/desktop/src/stores/queryStore.ts | 1 + apps/desktop/src/types/database.ts | 12 + crates/dbx-core/src/csv_export.rs | 1 + crates/dbx-core/src/data_grid_sql.rs | 195 ++++++++++- crates/dbx-core/src/db/mysql.rs | 294 ++++++++++++++++ crates/dbx-core/src/schema.rs | 229 +++++++++++++ crates/dbx-core/src/sql_dialect.rs | 4 +- .../dbx-core/src/sql_dialect/identifiers.rs | 23 ++ .../dbx-core/src/sql_dialect/table_select.rs | 75 ++++- crates/dbx-core/src/sql_dialect/tests.rs | 26 ++ crates/dbx-core/src/sql_dialect/types.rs | 7 +- crates/dbx-core/src/types.rs | 28 ++ .../dbx-core/tests/live_postgres_tsvector.rs | 1 + .../tests/mysql_cross_database_editing.rs | 2 + crates/dbx-web/src/main.rs | 2 + crates/dbx-web/src/routes/schema.rs | 173 +++++++--- docs/content/docs/databases.cn.mdx | 10 + docs/content/docs/databases.mdx | 10 + src-tauri/src/commands/schema.rs | 108 ++++++ src-tauri/src/lib.rs | 2 + 36 files changed, 1680 insertions(+), 174 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/database/dorisCatalogCapability.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 26fd1cde81..21c9d22b90 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -209,6 +209,7 @@ const props = defineProps<{ sortDirection?: DataGridSortDirection; sortMode?: DataGridSortMode; tableMeta?: { + catalog?: string; schema?: string; tableName: string; tableType?: string; @@ -1323,6 +1324,7 @@ async function loadServerFilterValues(columnIndex: number, searchValue: string) const columnInfo = tableMeta.columns.find((column) => column.name === columnName); const sql = await buildDataGridColumnDistinctValuesSql({ databaseType: resolvedDatabaseType.value, + catalog: tableMeta.catalog, schema: tableMeta.schema, tableName: tableMeta.tableName, columnName, @@ -3683,6 +3685,7 @@ async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string if (props.tableMeta) { const sql = await buildDataGridCountSql({ databaseType: props.databaseType, + catalog: props.tableMeta.catalog, schema: props.tableMeta.schema, tableName: props.tableMeta.tableName, whereInput: currentWhereInput(), @@ -5228,6 +5231,7 @@ async function applyOrderBySearch() { if (!tableMeta) return; const sql = await buildTableSelectSql({ databaseType: resolvedDatabaseType.value, + catalog: tableMeta.catalog, schema: tableMeta.schema, tableName: tableMeta.tableName, tableType: tableMeta.tableType, @@ -5259,6 +5263,7 @@ async function applyWhereFilter() { if (!tableMeta) return; const sql = await buildTableSelectSql({ databaseType: resolvedDatabaseType.value, + catalog: tableMeta.catalog, schema: tableMeta.schema, tableName: tableMeta.tableName, tableType: tableMeta.tableType, @@ -7684,7 +7689,7 @@ async function selectTableInfoTab(tab: TableInfoTab) { } watch( - () => [props.tableInfoTab, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName] as const, + () => [props.tableInfoTab, props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName] as const, ([tab]) => { if (tab) void selectTableInfoTab(tab); }, @@ -7696,7 +7701,7 @@ async function fetchDdl() { showTableInfo.value = true; ddlLoading.value = true; try { - ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName); + ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, undefined, props.tableMeta.catalog); } catch (e: any) { ddlContent.value = `-- Error: ${e}`; } finally { @@ -7709,7 +7714,7 @@ async function fetchIndexes() { indexesLoading.value = true; indexesError.value = ""; try { - indexes.value = await api.listIndexes(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName); + indexes.value = await api.listIndexes(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, props.tableMeta.catalog); indexesLoaded.value = true; } catch (e: any) { indexesError.value = String(e?.message || e); @@ -7728,7 +7733,7 @@ async function fetchForeignKeys() { foreignKeysLoading.value = true; foreignKeysError.value = ""; try { - foreignKeys.value = await api.listForeignKeys(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName); + foreignKeys.value = await api.listForeignKeys(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, props.tableMeta.catalog); foreignKeysLoaded.value = true; } catch (e: any) { foreignKeysError.value = String(e?.message || e); @@ -7742,7 +7747,7 @@ async function fetchTriggers() { triggersLoading.value = true; triggersError.value = ""; try { - triggers.value = await api.listTriggers(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName); + triggers.value = await api.listTriggers(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, props.tableMeta.catalog); triggersLoaded.value = true; } catch (e: any) { triggersError.value = String(e?.message || e); @@ -7752,7 +7757,7 @@ async function fetchTriggers() { } watch( - () => [props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName], + () => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName], () => { ddlContent.value = ""; indexes.value = []; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 79ad3846c5..49b80c7b09 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -650,15 +650,21 @@ async function toggle() { }) .catch(() => {}); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { - const config = connectionStore.getConfig(node.connectionId); - const effectiveDbType = effectiveDatabaseTypeForConnection(config); - if (config?.db_type === "sqlserver") { - await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database); - } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { - await connectionStore.loadSchemas(node.connectionId, node.database); + if (node.catalog && node.catalog !== "internal") { + await connectionStore.loadDorisCatalogTables(node); } else { - await connectionStore.loadTables(node.connectionId, node.database); + const config = connectionStore.getConfig(node.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + if (config?.db_type === "sqlserver") { + await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database); + } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { + await connectionStore.loadSchemas(node.connectionId, node.database); + } else { + await connectionStore.loadTables(node.connectionId, node.database); + } } + } else if (node.type === "doris-catalog" && node.connectionId) { + await connectionStore.loadDorisCatalogDatabases(node); } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await connectionStore.loadTables(node.connectionId, node.database, node.schema); } else if (node.type === "linked-server-root" && node.connectionId) { @@ -670,15 +676,15 @@ async function toggle() { } else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await connectionStore.loadTables(node.connectionId, node.database, node.schema); } else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) { - await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); + await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id); + await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id); + await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id); + await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id); + await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (databaseObjectGroup) { await connectionStore.loadObjectGroupChildren(node); } @@ -1176,7 +1182,8 @@ async function openData() { const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? ""); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const metadataDatabaseType = effectiveDbType || config?.db_type || ""; - const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) => tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database && (tab.schema || "") === (tableSchema || "") && (tab.tableMeta?.tableName || tab.title) === node.label; + const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) => + tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database && (tab.tableMeta?.catalog || "") === (node.catalog || "") && (tab.schema || "") === (tableSchema || "") && (tab.tableMeta?.tableName || tab.title) === node.label; const existingSameTableTab = queryStore.tabs.find(isSameDataTableTab); const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => { tab.title = node.label; @@ -1250,15 +1257,20 @@ async function openData() { tableType, databaseType: metadataDatabaseType, driverProfile: config.driver_profile || config.db_type, + catalog: node.catalog, }) : undefined; - const tabCachedTableMeta = existingTableMeta?.tableName === node.label && existingTableMeta.schema === tableSchema && existingTableMeta.tableType === tableType && existingTableMeta.columns.length > 0 && existingTableMetaAgeMs < DATA_TAB_METADATA_TTL_MS ? existingTableMeta : undefined; + const tabCachedTableMeta = + existingTableMeta?.tableName === node.label && (existingTableMeta.catalog || "") === (node.catalog || "") && existingTableMeta.schema === tableSchema && existingTableMeta.tableType === tableType && existingTableMeta.columns.length > 0 && existingTableMetaAgeMs < DATA_TAB_METADATA_TTL_MS + ? existingTableMeta + : undefined; const cachedTableMeta = sharedCachedTableMeta ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, tableSchema) : tabCachedTableMeta; const cachedTableMetaAgeMs = sharedCachedTableMeta?.ageMs ?? existingTableMetaAgeMs; const cachedTableMetaSource = sharedCachedTableMeta ? "shared" : tabCachedTableMeta ? "tab" : undefined; queryStore.setTableMeta( tabId, cachedTableMeta ?? { + catalog: node.catalog, schema: tableSchema, tableName: node.label, tableType, @@ -1306,6 +1318,7 @@ async function openData() { tableType, databaseType: metadataDatabaseType, driverProfile: config.driver_profile || config.db_type, + catalog: node.catalog, traceLogger: (event) => console.debug("[DBX][openData:metadata:trace]", { sourceTraceId: traceId, ...event }), }); if (!isCurrentDataTab()) { @@ -1362,6 +1375,7 @@ async function openData() { schema: tableSchema, tableName: node.label, tableType, + catalog: node.catalog, columns: columns.map((column) => column.name), primaryKeys, limit, diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 874e40f3d4..466d8126e6 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -39,6 +39,7 @@ export function useDataGridActions(activeTab: ComputedRef) schema: tableMeta?.schema, tableName: tableMeta?.tableName ?? "", tableType: tableMeta?.tableType, + catalog: tableMeta?.catalog, columns: tableMeta?.columns.map((column) => column.name), primaryKeys, includeRowId: useRowId, @@ -55,6 +56,7 @@ export function useDataGridActions(activeTab: ComputedRef) tabId: tab.id, connectionId: tab.connectionId, database: tab.database, + catalog: tableMeta.catalog, schema: tableMeta.schema, tableName: tableMeta.tableName, tableType: tableMeta.tableType, @@ -66,17 +68,18 @@ export function useDataGridActions(activeTab: ComputedRef) const config = connectionStore.getConfig(target.connectionId); const querySchema = metadataSchemaForConnection(config, target.database, target.schema); console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: target.tableName }); - const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName); - const indexes = await api.listIndexes(target.connectionId, target.database, querySchema, target.tableName).catch(() => []); + const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName, target.catalog); + const indexes = await api.listIndexes(target.connectionId, target.database, querySchema, target.tableName, target.catalog).catch(() => []); console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length }); const current = queryStore.tabs.find((item) => item.id === target.tabId); const currentMeta = current ? tableMetaForDataTab(current) : undefined; - if (!current || current.mode !== "data" || current.connectionId !== target.connectionId || current.database !== target.database || currentMeta?.tableName !== target.tableName || (currentMeta.schema ?? "") !== (target.schema ?? "")) { + if (!current || current.mode !== "data" || current.connectionId !== target.connectionId || current.database !== target.database || currentMeta?.tableName !== target.tableName || (currentMeta.schema ?? "") !== (target.schema ?? "") || (currentMeta.catalog ?? "") !== (target.catalog ?? "")) { console.info("[DBX][reloadData:metadata:stale-tab]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName }); return; } const primaryKeys = editableRowIdentifierColumns(effectiveDatabaseTypeForConnection(config), columns, indexes, target.tableType); queryStore.setTableMeta(target.tabId, { + catalog: target.catalog, schema: target.schema, tableName: target.tableName, tableType: target.tableType, diff --git a/apps/desktop/src/lib/__tests__/database/dorisCatalogCapability.spec.ts b/apps/desktop/src/lib/__tests__/database/dorisCatalogCapability.spec.ts new file mode 100644 index 0000000000..542083980c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/dorisCatalogCapability.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { connectionIsDorisFamilyCatalogCapable, isDorisFamilyCatalogCapable, isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport"; +import type { ConnectionConfig } from "@/types/database"; + +function conn(db_type: ConnectionConfig["db_type"], driver_profile?: string | null): Pick { + return { db_type, driver_profile: driver_profile ?? null }; +} + +describe("isDorisFamilyCatalogCapable", () => { + it("matches Doris and StarRocks by db_type", () => { + expect(isDorisFamilyCatalogCapable("doris")).toBe(true); + expect(isDorisFamilyCatalogCapable("starrocks")).toBe(true); + }); + + it("matches Doris / SelectDB / StarRocks driver profiles", () => { + expect(isDorisFamilyCatalogCapable("mysql", "doris")).toBe(true); + expect(isDorisFamilyCatalogCapable("mysql", "selectdb")).toBe(true); + expect(isDorisFamilyCatalogCapable("mysql", "starrocks")).toBe(true); + }); + + it("excludes ManticoreSearch (no catalog concept)", () => { + expect(isDorisFamilyCatalogCapable("manticoresearch")).toBe(false); + expect(isDorisFamilyCatalogCapable("mysql", "manticoresearch")).toBe(false); + }); + + it("excludes plain MySQL / Postgres", () => { + expect(isDorisFamilyCatalogCapable("mysql")).toBe(false); + expect(isDorisFamilyCatalogCapable("postgres")).toBe(false); + }); +}); + +describe("connectionIsDorisFamilyCatalogCapable", () => { + it("returns false for undefined connection", () => { + expect(connectionIsDorisFamilyCatalogCapable(undefined)).toBe(false); + }); + + it("returns true for a Doris connection", () => { + expect(connectionIsDorisFamilyCatalogCapable(conn("doris"))).toBe(true); + }); + + it("returns false for a plain MySQL connection", () => { + expect(connectionIsDorisFamilyCatalogCapable(conn("mysql"))).toBe(false); + }); +}); + +describe("isInternalDorisCatalog", () => { + it("detects Doris internal catalog by type", () => { + expect(isInternalDorisCatalog("internal", "internal")).toBe(true); + }); + + it("detects StarRocks internal catalog by type (case-insensitive)", () => { + expect(isInternalDorisCatalog("Internal", "default_catalog")).toBe(true); + expect(isInternalDorisCatalog("INTERNAL", "default_catalog")).toBe(true); + }); + + it("treats external catalogs as non-internal", () => { + expect(isInternalDorisCatalog("iceberg", "iceberg_catalog")).toBe(false); + expect(isInternalDorisCatalog("hive", "hive_catalog")).toBe(false); + // A catalog literally named `internal` but with an external type is not built-in. + expect(isInternalDorisCatalog("iceberg", "internal")).toBe(false); + }); + + it("falls back to the Doris name when the type is absent", () => { + expect(isInternalDorisCatalog("", "internal")).toBe(true); + expect(isInternalDorisCatalog(null, "internal")).toBe(true); + expect(isInternalDorisCatalog("", "default_catalog")).toBe(false); + expect(isInternalDorisCatalog(undefined, undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts new file mode 100644 index 0000000000..e3b82f1640 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; + +describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => { + it("prefixes external catalog for Doris (no schema)", () => { + expect(qualifiedTableName({ databaseType: "doris", catalog: "iceberg_catalog", tableName: "orders" })).toBe("`iceberg_catalog`.`orders`"); + }); + + it("prefixes external catalog for Doris (with schema)", () => { + expect(qualifiedTableName({ databaseType: "doris", catalog: "iceberg_catalog", schema: "sales", tableName: "orders" })).toBe("`iceberg_catalog`.`sales`.`orders`"); + }); + + it("prefixes external catalog for StarRocks", () => { + expect(qualifiedTableName({ databaseType: "starrocks", catalog: "hive_catalog", tableName: "orders" })).toBe("`hive_catalog`.`orders`"); + }); + + it("treats the internal catalog as no catalog", () => { + expect(qualifiedTableName({ databaseType: "doris", catalog: "internal", tableName: "orders" })).toBe("`orders`"); + }); + + it("omits the catalog for non-Doris engines", () => { + // MySQL has no 3-part catalog naming; the catalog must be ignored. + expect(qualifiedTableName({ databaseType: "mysql", catalog: "iceberg_catalog", tableName: "orders" })).toBe("`orders`"); + }); + + it("escapes embedded backticks in catalog and table identifiers", () => { + expect(qualifiedTableName({ databaseType: "doris", catalog: "a`b", schema: "c`d", tableName: "e`f" })).toBe("`a``b`.`c``d`.`e``f`"); + }); +}); + +describe("quoteTableIdentifier", () => { + it("backtick-quotes mysql identifiers", () => { + expect(quoteTableIdentifier("mysql", "orders")).toBe("`orders`"); + }); + + it("bracket-quotes sqlserver identifiers", () => { + expect(quoteTableIdentifier("sqlserver", "orders")).toBe("[orders]"); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index afc7f306cf..5aa6d12c58 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -107,6 +107,8 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory"); // Schema export const listDatabases = forward("listDatabases"); +export const listDorisCatalogs = forward("listDorisCatalogs"); +export const listDorisCatalogDatabases = forward("listDorisCatalogDatabases"); export const listSqlServerLinkedServers = forward("listSqlServerLinkedServers"); export const listSqlServerLinkedServerCatalogs = forward("listSqlServerLinkedServerCatalogs"); export const listSqlServerLinkedServerSchemas = forward("listSqlServerLinkedServerSchemas"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index d9e3ddfbcc..4dab88e1ce 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -3,6 +3,7 @@ import type { DatabaseInfo, SchemaInfo, LinkedServerInfo, + CatalogInfo, TableInfo, ObjectInfo, CompletionAssistantRequest, @@ -487,6 +488,14 @@ export async function listDatabases(connectionId: string): Promise { + return get(`/api/schema/doris/catalogs?${qs({ connection_id: connectionId })}`); +} + +export async function listDorisCatalogDatabases(connectionId: string, catalog: string): Promise { + return get(`/api/schema/doris/catalog-databases?${qs({ connection_id: connectionId, catalog })}`); +} + export async function listSqlServerLinkedServers(connectionId: string): Promise { return get(`/api/schema/sqlserver/linked-servers?${qs({ connection_id: connectionId })}`); } @@ -524,15 +533,15 @@ export async function listSchemaInfos(connectionId: string, database: string): P return schemas.map((name) => ({ name, comment: null })); } -export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[]): Promise { - return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema, filter, limit, offset, object_types: objectTypes?.join(",") })}`); +export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[], catalog?: string): Promise { + return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema, filter, limit, offset, object_types: objectTypes?.join(","), catalog })}`); } -export async function getTableComment(_connectionId: string, _database: string, _schema: string, _table: string): Promise { +export async function getTableComment(_connectionId: string, _database: string, _schema: string, _table: string, _catalog?: string): Promise { throw new Error("Table comment lookup is not available in the web backend"); } -export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number): Promise { +export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise { return get( `/api/schema/objects?${qs({ connection_id: connectionId, @@ -542,6 +551,7 @@ export async function listObjects(connectionId: string, database: string, schema filter, limit, offset, + catalog, })}`, ); } @@ -562,28 +572,28 @@ export async function getObjectSource(connectionId: string, database: string, sc return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType })}`); } -export async function getColumns(connectionId: string, database: string, schema: string, table: string): Promise { - return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table })}`); +export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } export async function listDataTypes(connectionId: string, database: string): Promise { return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`); } -export async function listIndexes(connectionId: string, database: string, schema: string, table: string): Promise { - return get(`/api/schema/indexes?${qs({ connection_id: connectionId, database, schema, table })}`); +export async function listIndexes(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/indexes?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } -export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string): Promise { - return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table })}`); +export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } -export async function listTriggers(connectionId: string, database: string, schema: string, table: string): Promise { - return get(`/api/schema/triggers?${qs({ connection_id: connectionId, database, schema, table })}`); +export async function listTriggers(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/triggers?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } -export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind): Promise { - return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType })}`); +export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { + return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog })}`); } 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 fbcadeb45c..5ab12ed2c4 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -5,6 +5,7 @@ import type { DatabaseInfo, SchemaInfo, LinkedServerInfo, + CatalogInfo, TableInfo, ObjectInfo, CompletionAssistantRequest, @@ -593,6 +594,14 @@ export async function listDatabases(connectionId: string): Promise { + return invoke("list_doris_catalogs", { connectionId }); +} + +export async function listDorisCatalogDatabases(connectionId: string, catalog: string): Promise { + return invoke("list_doris_catalog_databases", { connectionId, catalog }); +} + export async function listSqlServerLinkedServers(connectionId: string): Promise { return invoke("list_sqlserver_linked_servers", { connectionId }); } @@ -621,16 +630,16 @@ export async function deleteSchemaCachePrefix(prefix: string): Promise { return invoke("delete_schema_cache_prefix", { prefix }); } -export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[]): Promise { - return invoke("list_tables", { connectionId, database, schema, filter, limit, offset, objectTypes }); +export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[], catalog?: string): Promise { + return invoke("list_tables", { connectionId, database, schema, filter, limit, offset, objectTypes, catalog }); } -export async function getTableComment(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("get_table_comment", { connectionId, database, schema, table }); +export async function getTableComment(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("get_table_comment", { connectionId, database, schema, table, catalog }); } -export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number): Promise { - return invoke("list_objects", { connectionId, database, schema, objectTypes, filter, limit, offset }); +export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise { + return invoke("list_objects", { connectionId, database, schema, objectTypes, filter, limit, offset, catalog }); } export async function listObjectStatistics(connectionId: string, database: string, schema: string): Promise { @@ -657,8 +666,8 @@ export async function listSchemaInfos(connectionId: string, database: string): P return invoke("list_schema_infos", { connectionId, database }); } -export async function getColumns(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("get_columns", { connectionId, database, schema, table }); +export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("get_columns", { connectionId, database, schema, table, catalog }); } export async function listDataTypes(connectionId: string, database: string): Promise { @@ -964,20 +973,20 @@ export async function buildDataCompareSyncPlan(options: DataCompareSyncPlanOptio return invoke("build_data_compare_sync_plan", { options }); } -export async function listIndexes(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("list_indexes", { connectionId, database, schema, table }); +export async function listIndexes(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_indexes", { connectionId, database, schema, table, catalog }); } -export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("list_foreign_keys", { connectionId, database, schema, table }); +export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_foreign_keys", { connectionId, database, schema, table, catalog }); } -export async function listTriggers(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("list_triggers", { connectionId, database, schema, table }); +export async function listTriggers(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_triggers", { connectionId, database, schema, table, catalog }); } -export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind): Promise { - return invoke("get_table_ddl", { connectionId, database, schema, table, objectType }); +export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { + return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog }); } export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise { diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts index 62a41145ef..a4938cea44 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts @@ -4,6 +4,7 @@ import * as api from "@/lib/backend/api"; export type GridCellValue = string | number | boolean | null | unknown[] | { [key: string]: unknown }; export interface DataGridTableMeta { + catalog?: string; schema?: string; tableName: string; primaryKeys: string[]; @@ -76,6 +77,7 @@ export interface DataGridColumnValuesFilterConditionOptions { export interface DataGridColumnDistinctValuesSqlOptions { databaseType?: DatabaseType; + catalog?: string; schema?: string; tableName: string; columnName: string; @@ -88,6 +90,7 @@ export interface DataGridColumnDistinctValuesSqlOptions { export interface DataGridCountSqlOptions { databaseType?: DatabaseType; + catalog?: string; schema?: string; tableName: string; whereInput?: string; diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index 7bff7fa096..a2aa8801d4 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -1,4 +1,4 @@ -import type { DatabaseType, TreeNodeType } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database"; import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest"; import { canEditTableStructure } from "@/lib/table/tableStructureCapabilities"; import { DATABASE_OBJECT_TREE_TYPES, FETCH_FIRST_TYPES, PG_LIKE_STRUCTURE_TYPES, SCHEMA_AWARE_TYPES, SINGLE_DATABASE_TYPES, TREE_SCHEMA_TYPES } from "@/lib/database/databaseCapabilitySets"; @@ -7,6 +7,35 @@ export function isSchemaAware(dbType?: DatabaseType): boolean { return !!dbType && SCHEMA_AWARE_TYPES.has(dbType); } +/** + * Doris-family engines that support multi-catalog federation (`SHOW CATALOGS`): + * Doris (incl. SelectDB) and StarRocks. Manticore Search shares the MySQL code + * path but has no catalog concept, so it is excluded. + */ +export function isDorisFamilyCatalogCapable(dbType?: DatabaseType, driverProfile?: string | null): boolean { + if (dbType === "doris" || dbType === "starrocks") return true; + return driverProfile === "doris" || driverProfile === "selectdb" || driverProfile === "starrocks"; +} + +export function connectionIsDorisFamilyCatalogCapable(connection: Pick | undefined): boolean { + if (!connection) return false; + return isDorisFamilyCatalogCapable(connection.db_type, connection.driver_profile); +} + +/** + * Whether a Doris/StarRocks catalog is the engine's built-in (non-federated) + * catalog. Doris names it `internal` (Type=`internal`); StarRocks names it + * `default_catalog` (Type=`Internal`). The `catalogType` column is the + * cross-engine signal, so it is matched case-insensitively, falling back to the + * canonical Doris name `internal` when the type is absent (very old / proxied + * deployments). Mirrors `CatalogInfo::is_internal` on the backend. + */ +export function isInternalDorisCatalog(catalogType?: string | null, catalogName?: string | null): boolean { + const type = (catalogType ?? "").trim().toLowerCase(); + if (type) return type === "internal"; + return (catalogName ?? "").trim() === "internal"; +} + export function usesTreeSchemaMode(dbType?: DatabaseType): boolean { return !!dbType && TREE_SCHEMA_TYPES.has(dbType); } diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index 121a60abfb..f7c7654a5c 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -12,6 +12,7 @@ export interface TableMetadata { schema?: string; tableName: string; tableType?: string; + catalog?: string; columns: ColumnInfo[]; indexes: IndexInfo[]; primaryKeys: string[]; @@ -26,6 +27,7 @@ export interface TableMetadataRequest { tableType?: string; databaseType: DatabaseType | string; driverProfile?: string; + catalog?: string; force?: boolean; traceLogger?: MetadataLoadTraceLogger; } @@ -45,7 +47,7 @@ const tableMetadataCoordinator = new MetadataLoadCoordinator((event) => { console.debug("[DBX][metadata-load:table-coordinator]", event); }); -export function tableMetadataScope(request: Pick): MetadataScopeInput { +export function tableMetadataScope(request: Pick): MetadataScopeInput { return { kind: "table-metadata", connectionId: request.connectionId, @@ -54,10 +56,11 @@ export function tableMetadataScope(request: Pick): TableMetadataLoadResult | undefined { +export function getCachedTableMetadata(request: Pick): TableMetadataLoadResult | undefined { const hit = tableMetadataCache.get(tableMetadataScope(request)); if (!hit) return undefined; return { metadata: hit.value, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs }; @@ -68,6 +71,7 @@ export function tableMetadataToDataTabMeta(metadata: TableMetadata, schema = met schema, tableName: metadata.tableName, tableType: metadata.tableType, + catalog: metadata.catalog, columns: metadata.columns, primaryKeys: metadata.primaryKeys, }; @@ -92,13 +96,14 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< const metadata = await tableMetadataCoordinator.run( scope, async () => { - const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName); - const indexes = await api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName).catch((): IndexInfo[] => []); + const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); + const indexes = await api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog).catch((): IndexInfo[] => []); const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType); return { schema: request.schema || undefined, tableName: request.tableName, tableType: request.tableType, + catalog: request.catalog, columns, indexes, primaryKeys, diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index 3680f9ce45..cbdbe56ab7 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -33,6 +33,7 @@ const pinnableTypes: Set = new Set([ "linked-server", "linked-server-catalog", "linked-server-schema", + "doris-catalog", "schema", "table", "view", diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index b83a317d6c..4fcb37590d 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -25,6 +25,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { case "linked-server-schema": case "mq-tenant": return { icon: FolderOpen, colorClass: "text-sky-400" }; + case "doris-catalog": + return { icon: FolderOpen, colorClass: "text-emerald-500" }; case "nacos-namespace": case "etcd-root": return { icon: FolderOpen, colorClass: "text-sky-500" }; diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 54bfa78275..e56238bcb5 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -16,6 +16,7 @@ export interface BuildTableSelectSqlOptions { offset?: number; whereInput?: string; includeRowId?: boolean; + catalog?: string; } export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string { @@ -23,7 +24,8 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam // JDBC connections use the driver-reported identifier quote string // (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted. if (databaseType === "jdbc") return name; - if (databaseType === "mysql" || databaseType === "clickhouse" || databaseType === "hive" || databaseType === "spark" || databaseType === "databend" || databaseType === "tdengine" || databaseType === "access") return `\`${name.replace(/`/g, "``")}\``; + if (databaseType === "mysql" || databaseType === "clickhouse" || databaseType === "hive" || databaseType === "spark" || databaseType === "databend" || databaseType === "tdengine" || databaseType === "access" || databaseType === "doris" || databaseType === "starrocks") + return `\`${name.replace(/`/g, "``")}\``; if (databaseType === "informix" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) return name; if (databaseType === "neo4j") return quoteCypherIdentifier(name); if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`; @@ -34,8 +36,19 @@ function quoteCypherIdentifier(name: string): string { return `\`${name.replace(/`/g, "``")}\``; } -export function qualifiedTableName(options: Pick): string { - const { databaseType, schema, tableName } = options; +export function qualifiedTableName(options: Pick): string { + const { databaseType, schema, tableName, catalog } = options; + // Doris / StarRocks multi-catalog: address external-catalog tables with the + // 3-part `catalog.database.table` form, which the engines accept directly. + if (catalog && catalog !== "internal" && (databaseType === "doris" || databaseType === "starrocks")) { + const quotedCatalog = quoteTableIdentifier(databaseType, catalog); + const quotedTable = quoteTableIdentifier(databaseType, tableName); + const trimmedSchema = schema?.trim(); + if (trimmedSchema) { + return `${quotedCatalog}.${quoteTableIdentifier(databaseType, trimmedSchema)}.${quotedTable}`; + } + return `${quotedCatalog}.${quotedTable}`; + } if (databaseType === "iotdb") { const trimmedSchema = schema?.trim(); if (trimmedSchema && tableName !== trimmedSchema && !tableName.startsWith(`${trimmedSchema}.`)) { diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 8c725f9365..d86a39c405 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -7,7 +7,7 @@ export function normalizeDatabaseObjectName(name: string): string { return name.trim(); } -export function buildTableTreeNodes({ nodeId, connectionId, database, schema, tables }: { nodeId: string; connectionId: string; database: string; schema?: string; tables: TableInfo[] }): TreeNode[] { +export function buildTableTreeNodes({ nodeId, connectionId, database, schema, tables, catalog }: { nodeId: string; connectionId: string; database: string; schema?: string; tables: TableInfo[]; catalog?: string }): TreeNode[] { const entries = tables.flatMap((table) => { const name = normalizeDatabaseObjectName(table.name); if (!name) return []; @@ -26,6 +26,7 @@ export function buildTableTreeNodes({ nodeId, connectionId, database, schema, ta comment: table.comment, parentSchema: table.parent_schema, parentName: table.parent_name, + catalog, }), ]; }); @@ -54,6 +55,7 @@ function makeTableTreeEntry({ comment, parentSchema, parentName, + catalog, }: { nodeId: string; connectionId: string; @@ -66,6 +68,7 @@ function makeTableTreeEntry({ comment?: string | null; parentSchema?: string | null; parentName?: string | null; + catalog?: string; }): TableTreeEntry { const normalizedParentSchema = parentSchema ? normalizeDatabaseObjectName(parentSchema) : undefined; const normalizedParentName = parentName ? normalizeDatabaseObjectName(parentName) : undefined; @@ -79,6 +82,7 @@ function makeTableTreeEntry({ connectionId, database, schema, + catalog, isExpanded: false, children: [], }; @@ -293,6 +297,7 @@ function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, dat connectionId, database, schema: entry.schema, + catalog: entry.node.catalog, tableName: entry.node.label, objectCount: partitionChildren.length, isExpanded: false, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 56cd58aae5..323ae4cf05 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import { ref, computed, watch } from "vue"; -import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, VectorCollectionMeta } from "@/types/database"; +import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, VectorCollectionMeta } from "@/types/database"; import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems"; import { reconcileLayout, @@ -23,9 +23,9 @@ import { import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; -import { isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; +import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; +import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget"; @@ -121,6 +121,14 @@ function sqlServerLinkedCatalogId(connectionId: string, server: string, catalog: return `${sqlServerLinkedServerId(connectionId, server)}:${nodeIdPart(catalog)}`; } +function dorisCatalogId(connectionId: string, catalog: string): string { + return `${connectionId}:doris-catalog:${nodeIdPart(catalog)}`; +} + +function dorisCatalogDatabaseId(connectionId: string, catalog: string, database: string): string { + return `${dorisCatalogId(connectionId, catalog)}:${nodeIdPart(database)}`; +} + function sqlServerLinkedRuntimeDatabase(config?: ConnectionConfig): string { return config?.database?.trim() || "master"; } @@ -2027,49 +2035,87 @@ export const useConnectionStore = defineStore("connection", () => { setChildren(node, withSavedSqlRoot(connectionId, schemaNodes, node)); await savePersistedConnectionTreeChildren(cacheKey, node.children || schemaNodes); } else { - const cacheKey = schemaCacheKey(connectionId, "databases"); - if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); - if (cached.hit) { - if (cached.isStale) refreshStaleTreeNode(node); - return; - } - } - const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"); - const visibleNames = filterDatabaseNamesForConnection( - databases.map((database) => database.name), - config, - ); - const visibleNameSet = new Set(visibleNames); - const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); - const effectiveDbType = effectiveDatabaseTypeForConnection(config); - const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { - includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), - }); - if (config?.db_type === "sqlserver") { - const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []); - const linkedDatabase = sqlServerLinkedRuntimeDatabase(config); - databaseNodes.push({ - ...sqlServerLinkedRootNode(connectionId, linkedDatabase), - children: linkedServers.map((server) => ({ - id: sqlServerLinkedServerId(connectionId, server.name), - label: server.name, - type: "linked-server", - connectionId, - database: linkedDatabase, - linkedServer: server.name, - comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null, - isExpanded: false, - children: [], - })), + // Doris / StarRocks multi-catalog: when external catalogs exist, + // render a catalog grouping layer (catalog → database → tables). + // When only `internal` is present, fall through to the flat database + // list (no regression for single-catalog deployments). + let dorisCatalogs: CatalogInfo[] | null = null; + if (connectionIsDorisFamilyCatalogCapable(config)) { + dorisCatalogs = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogs(connectionId), "catalogs").catch((error: unknown) => { + recordMetadataLoadError(connectionId, error); + return null; }); - if (linkedServers.length > 0) loadedTreeNodeChildrenIds.value.add(sqlServerLinkedRootId(connectionId)); } - const children = withSavedSqlRoot(connectionId, databaseNodes, node); - if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, children); - await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + if (dorisCatalogs && dorisCatalogs.length > 1) { + const cacheKey = schemaCacheKey(connectionId, "doris-catalogs"); + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } + const catalogNodes: TreeNode[] = dorisCatalogs.map((catalog) => ({ + id: dorisCatalogId(connectionId, catalog.name), + label: catalog.name, + type: "doris-catalog" as const, + connectionId, + catalog: catalog.name, + catalogType: catalog.catalog_type, + comment: catalog.comment ?? null, + isExpanded: false, + children: [], + })); + const children = withSavedSqlRoot(connectionId, catalogNodes, node); + if (isSidebarSearchQueryChanged(options)) return; + if (!canApplyTreeMetadataResult(node)) return; + setChildren(node, children); + await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + } else { + const cacheKey = schemaCacheKey(connectionId, "databases"); + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } + const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"); + const visibleNames = filterDatabaseNamesForConnection( + databases.map((database) => database.name), + config, + ); + const visibleNameSet = new Set(visibleNames); + const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { + includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), + }); + if (config?.db_type === "sqlserver") { + const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []); + const linkedDatabase = sqlServerLinkedRuntimeDatabase(config); + databaseNodes.push({ + ...sqlServerLinkedRootNode(connectionId, linkedDatabase), + children: linkedServers.map((server) => ({ + id: sqlServerLinkedServerId(connectionId, server.name), + label: server.name, + type: "linked-server", + connectionId, + database: linkedDatabase, + linkedServer: server.name, + comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null, + isExpanded: false, + children: [], + })), + }); + if (linkedServers.length > 0) loadedTreeNodeChildrenIds.value.add(sqlServerLinkedRootId(connectionId)); + } + const children = withSavedSqlRoot(connectionId, databaseNodes, node); + if (isSidebarSearchQueryChanged(options)) return; + if (!canApplyTreeMetadataResult(node)) return; + setChildren(node, children); + await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + } } node.isExpanded = true; } catch (e) { @@ -2695,6 +2741,136 @@ export const useConnectionStore = defineStore("connection", () => { } } + // Doris / StarRocks multi-catalog: load the databases under a catalog node. + async function loadDorisCatalogDatabases(node: TreeNode, options?: LoadTreeOptions) { + if (!node.connectionId || !node.catalog) return; + const connectionId = node.connectionId; + const catalog = node.catalog; + node.isLoading = true; + try { + await ensureConnected(connectionId); + if (useCachedChildren(node, options)) return; + const config = getConfig(connectionId); + const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases"); + let databaseNodes: TreeNode[]; + if (isInternalDorisCatalog(node.catalogType, catalog)) { + // The internal catalog's databases are rendered as standard database + // nodes so they reuse the existing table-loading / table-open paths. + // Detection is type-based (catalogType=`internal`), so StarRocks + // `default_catalog` routes here too — its tables carry no catalog. + const visibleNames = filterDatabaseNamesForConnection( + databases.map((database) => database.name), + config, + ); + const visibleNameSet = new Set(visibleNames); + const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); + databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: false }); + } else { + databaseNodes = sortSidebarDatabases(databases).flatMap((database) => { + const name = database.name.trim(); + if (!name) return []; + return [ + { + id: dorisCatalogDatabaseId(connectionId, catalog, name), + label: name, + type: "database" as const, + connectionId, + database: name, + catalog, + isExpanded: false, + children: [], + }, + ]; + }); + } + if (isSidebarSearchQueryChanged(options)) return; + if (!canApplyTreeMetadataResult(node)) return; + setChildren(node, databaseNodes); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + // Doris / StarRocks multi-catalog: load tables under an external-catalog + // database node. External catalogs only expose tables/views, so a flat list + // (no routines/sequences/triggers) is rendered. + async function loadDorisCatalogTables(node: TreeNode, options?: LoadTreeOptions) { + if (!node.connectionId || !node.database || !node.catalog) return; + const connectionId = node.connectionId; + const { database, catalog } = node; + const configForScope = getConfig(connectionId); + const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; + const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; + return runTreeMetadataLoad( + { + kind: "schema-tables", + connectionId, + database, + schema: undefined, + nodeKind: "database", + objectTypes: objectTypesForScope, + searchFilter: activeTreeLoadSearchFilter(options), + limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined, + offset: 0, + sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped", + driverProfile: metadataDriverProfile(configForScope), + extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined, + }, + async () => { + node.isLoading = true; + try { + await ensureConnected(connectionId); + if (useCachedChildren(node, options)) return; + const searchFilter = activeTreeLoadSearchFilter(options); + const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v3"); + if (!options?.force && !searchFilter) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } + const pageSize = sidebarObjectGroupPageSize(); + const fetchLimit = searchFilter ? pageSize : pageSize + 1; + const fetchOffset = searchFilter ? undefined : 0; + const tables = await withMetadataLoadTimeout(connectionId, api.listTables(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog), "tables"); + const hasMore = searchFilter ? false : tables.length > pageSize; + const pageTables = hasMore ? tables.slice(0, pageSize) : tables; + indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined)); + let children = buildTableTreeNodes({ + nodeId: node.id, + connectionId, + database, + schema: undefined, + tables: pageTables, + catalog, + }); + if (hasMore && !searchFilter) { + children = [...children, buildLoadMoreNode(node, pageSize, pageSize)]; + } + if (isTreeLoadSearchChanged(searchFilter, options)) return; + if (!canApplyTreeMetadataResult(node)) return; + node.objectCount = children.filter((child) => child.type !== "load-more").length; + setChildren(node, children); + if (!searchFilter && !options?.sidebarTableSearchParentId) { + await savePersistedTreeChildren(cacheKey, children); + } + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + }, + options, + ); + } + async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; @@ -3181,7 +3357,7 @@ export const useConnectionStore = defineStore("connection", () => { return normalizeSidebarObjectKind(type); } - async function loadTableGroups(connectionId: string, database: string, table: string, schema?: string, nodeId?: string) { + async function loadTableGroups(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}` : `${connectionId}:${database}:${table}`); const node = findNode(treeNodes.value, parentId); if (!node) return; @@ -3195,6 +3371,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, database, schema, + catalog, tableName: table, isExpanded: false, children: [], @@ -3212,6 +3389,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, database, schema, + catalog, tableName: table, isExpanded: false, children: [], @@ -3227,6 +3405,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, database, schema, + catalog, tableName: table, isExpanded: false, children: [], @@ -3240,6 +3419,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, database, schema, + catalog, tableName: table, isExpanded: false, children: [], @@ -3251,7 +3431,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isExpanded = true; } - async function loadColumns(connectionId: string, database: string, table: string, schema?: string, nodeId?: string) { + async function loadColumns(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__columns` : `${connectionId}:${database}:${table}:__columns`); const node = findNode(treeNodes.value, parentId); if (!node) return; @@ -3286,7 +3466,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } const querySchema = metadataQuerySchema(connectionId, database, schema); - const columns = await api.getColumns(connectionId, database, querySchema, table); + const columns = await api.getColumns(connectionId, database, querySchema, table, catalog); setChildren( node, columns.map((col) => ({ @@ -3309,7 +3489,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadIndexes(connectionId: string, database: string, table: string, schema?: string, nodeId?: string) { + async function loadIndexes(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__indexes` : `${connectionId}:${database}:${table}:__indexes`); const node = findNode(treeNodes.value, parentId); if (!node) return; @@ -3323,7 +3503,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } const querySchema = metadataQuerySchema(connectionId, database, schema); - const indexes = await api.listIndexes(connectionId, database, querySchema, table); + const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog); setChildren( node, indexes.map((idx) => ({ @@ -3346,7 +3526,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string, nodeId?: string) { + async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__fkeys` : `${connectionId}:${database}:${table}:__fkeys`); const node = findNode(treeNodes.value, parentId); if (!node) return; @@ -3360,7 +3540,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } const querySchema = metadataQuerySchema(connectionId, database, schema); - const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table); + const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table, catalog); const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; completionForeignKeysCache.value[cacheKey] = fkeys; evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); @@ -3387,7 +3567,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string) { + async function loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__triggers` : `${connectionId}:${database}:${table}:__triggers`); const node = findNode(treeNodes.value, parentId); if (!node) return; @@ -3401,7 +3581,7 @@ export const useConnectionStore = defineStore("connection", () => { return; } const querySchema = metadataQuerySchema(connectionId, database, schema); - const triggers = await api.listTriggers(connectionId, database, querySchema, table); + const triggers = await api.listTriggers(connectionId, database, querySchema, table, catalog); setChildren( node, triggers.map((tr) => ({ @@ -3464,15 +3644,21 @@ export const useConnectionStore = defineStore("connection", () => { await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); } else if (node.type === "mongo-gridfs") { node.isExpanded = true; + } else if (node.type === "doris-catalog" && node.connectionId) { + await loadDorisCatalogDatabases(node, options); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { - const config = getConfig(node.connectionId); - const effectiveDbType = effectiveDatabaseTypeForConnection(config); - if (config?.db_type === "sqlserver") { - await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); - } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { - await loadSchemas(node.connectionId, node.database, options); + if (node.catalog && node.catalog !== "internal") { + await loadDorisCatalogTables(node, options); } else { - await loadTables(node.connectionId, node.database, undefined, options); + const config = getConfig(node.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + if (config?.db_type === "sqlserver") { + await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); + } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { + await loadSchemas(node.connectionId, node.database, options); + } else { + await loadTables(node.connectionId, node.database, undefined, options); + } } } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); @@ -3485,15 +3671,15 @@ export const useConnectionStore = defineStore("connection", () => { } else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); } else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) { - await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); + await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id); + await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id); + await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id); + await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id); + await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views" || node.type === "group-procedures" || node.type === "group-functions" || node.type === "group-sequences" || node.type === "group-packages") { await loadObjectGroupChildren(node, options); } else if (node.type === "group-partitions") { @@ -4837,6 +5023,8 @@ export const useConnectionStore = defineStore("connection", () => { loadSqlServerLinkedServers, loadSqlServerLinkedServerCatalogs, loadSqlServerLinkedServerSchemas, + loadDorisCatalogDatabases, + loadDorisCatalogTables, loadTables, loadTableForLocate, loadObjectGroupChildren, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index fcd7f430db..1ca539ed26 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2880,6 +2880,7 @@ export const useQueryStore = defineStore("query", () => { schema: tableMeta.schema, tableName: tableMeta.tableName, tableType: tableMeta.tableType, + catalog: tableMeta.catalog, columns: tableMeta.columns.map((column) => column.name), primaryKeys, whereInput: tab.whereInput, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index e6147a6c3e..069ee3b84d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -299,6 +299,14 @@ export interface LinkedServerInfo { data_source?: string | null; } +/** A catalog exposed by a multi-catalog engine (Doris / StarRocks). */ +export interface CatalogInfo { + name: string; + catalog_type: string; + is_current: boolean; + comment?: string | null; +} + export interface TableInfo { name: string; table_type: string; @@ -513,6 +521,7 @@ export type TreeNodeType = | "connection" | "connection-group" | "database" + | "doris-catalog" | "linked-server-root" | "linked-server" | "linked-server-catalog" @@ -589,6 +598,8 @@ export interface TreeNode { pinned?: boolean; connectionId?: string; database?: string; + catalog?: string; + catalogType?: string; linkedServer?: string; linkedCatalog?: string; linkedSchema?: string; @@ -714,6 +725,7 @@ export interface QueryTab { schema?: string; tableName: string; tableType?: string; + catalog?: string; columns: ColumnInfo[]; primaryKeys: string[]; }; diff --git a/crates/dbx-core/src/csv_export.rs b/crates/dbx-core/src/csv_export.rs index 697a239a44..21b6f7bfef 100644 --- a/crates/dbx-core/src/csv_export.rs +++ b/crates/dbx-core/src/csv_export.rs @@ -129,6 +129,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor offset: Some(offset), where_input: None, include_row_id: false, + ..Default::default() }); let result = execute_sql_statement_with_options( state, diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 4373d8e452..291fc87b82 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -26,6 +26,8 @@ const DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT: usize = 1000; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DataGridTableMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String, @@ -162,6 +164,8 @@ pub struct DataGridColumnDistinctValuesSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String, pub column_name: String, @@ -183,6 +187,8 @@ pub struct DataGridCountSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -259,8 +265,9 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme return Vec::new(); } - let table = qualified_table_name( + let table = crate::sql_dialect::qualified_table_name_with_catalog( options.database_type, + options.table_meta.catalog.as_deref(), options.table_meta.schema.as_deref(), &options.table_meta.table_name, ); @@ -342,7 +349,14 @@ pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatemen let table = options.table_meta.as_ref().map_or_else( || "table_name".to_string(), - |meta| qualified_table_name(options.database_type, meta.schema.as_deref(), &meta.table_name), + |meta| { + crate::sql_dialect::qualified_table_name_with_catalog( + options.database_type, + meta.catalog.as_deref(), + meta.schema.as_deref(), + &meta.table_name, + ) + }, ); let columns = insert_columns .iter() @@ -495,7 +509,12 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc } let limit = data_grid_column_distinct_values_limit(options.limit); - let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name); + let table = crate::sql_dialect::qualified_table_name_with_catalog( + options.database_type, + options.catalog.as_deref(), + options.schema.as_deref(), + &options.table_name, + ); let column = column_filter_ref(options.database_type, &options.column_name); let mut predicates = Vec::new(); let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref()); @@ -541,7 +560,12 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc } pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String { - let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name); + let table = crate::sql_dialect::qualified_table_name_with_catalog( + options.database_type, + options.catalog.as_deref(), + options.schema.as_deref(), + &options.table_name, + ); let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref()); let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") }; format!("SELECT COUNT(*) AS cnt FROM {table}{where_clause}") @@ -747,8 +771,9 @@ fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Ve let save_columns = effective_columns(options); let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]); - let table = qualified_table_name( + let table = crate::sql_dialect::qualified_table_name_with_catalog( options.database_type, + options.table_meta.catalog.as_deref(), options.table_meta.schema.as_deref(), &options.table_meta.table_name, ); @@ -2050,6 +2075,7 @@ mod tests { let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::Postgres), table_meta: DataGridTableMeta { + catalog: None, schema: Some("public".to_string()), table_name: "users".to_string(), primary_keys: vec!["id".to_string()], @@ -2070,6 +2096,7 @@ mod tests { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: Some(DataGridTableMeta { + catalog: None, schema: None, table_name: "users".to_string(), primary_keys: vec!["id".to_string()], @@ -2092,6 +2119,7 @@ mod tests { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: Some(DataGridTableMeta { + catalog: None, schema: None, table_name: "users".to_string(), primary_keys: vec!["id".to_string()], @@ -2116,6 +2144,7 @@ mod tests { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Oracle), table_meta: Some(DataGridTableMeta { + catalog: None, schema: Some("APP".to_string()), table_name: "USERS".to_string(), primary_keys: vec!["ID".to_string()], @@ -2137,6 +2166,7 @@ mod tests { #[test] fn mysql_copy_statements_preserve_blob_hex_literals() { let table_meta = DataGridTableMeta { + catalog: None, schema: None, table_name: "reports".to_string(), primary_keys: vec!["id".to_string()], @@ -2200,6 +2230,7 @@ mod tests { let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { database_type: Some(DatabaseType::Postgres), table_meta: Some(DataGridTableMeta { + catalog: None, schema: Some("public".to_string()), table_name: "articles".to_string(), primary_keys: vec!["id".to_string()], @@ -2308,6 +2339,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Postgres), + catalog: None, schema: Some("public".to_string()), table_name: "users".to_string(), column_name: "status".to_string(), @@ -2322,6 +2354,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::SqlServer), + catalog: None, schema: None, table_name: "users".to_string(), column_name: "status".to_string(), @@ -2336,6 +2369,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::SqlServer), + catalog: None, schema: None, table_name: "users".to_string(), column_name: "id".to_string(), @@ -2350,6 +2384,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Oracle), + catalog: None, schema: Some("APP".to_string()), table_name: "EVENTS".to_string(), column_name: "KIND".to_string(), @@ -2364,6 +2399,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Firebird), + catalog: None, schema: None, table_name: "USERS".to_string(), column_name: "STATUS".to_string(), @@ -2375,6 +2411,53 @@ mod tests { }), "SELECT \"STATUS\" AS dbx_value FROM \"USERS\" WHERE (DELETED_AT IS NULL) GROUP BY \"STATUS\" ORDER BY dbx_value ROWS 25" ); + // Doris / StarRocks external-catalog tables are addressed with a 3-part name. + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::Doris), + catalog: Some("iceberg_catalog".to_string()), + schema: Some("sales".to_string()), + table_name: "orders".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "varchar", true, None)), + where_input: None, + search_value: None, + limit: Some(10), + include_counts: false, + }), + "SELECT `status` AS dbx_value FROM `iceberg_catalog`.`sales`.`orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10" + ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::StarRocks), + catalog: Some("hive_catalog".to_string()), + schema: None, + table_name: "orders".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "varchar", true, None)), + where_input: None, + search_value: None, + limit: Some(10), + include_counts: true, + }), + "SELECT `status` AS dbx_value, COUNT(*) AS dbx_count FROM `hive_catalog`.`orders` GROUP BY `status` ORDER BY dbx_count DESC, dbx_value LIMIT 10" + ); + // The built-in `internal` catalog is never prefixed. + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::Doris), + catalog: Some("internal".to_string()), + schema: None, + table_name: "orders".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "varchar", true, None)), + where_input: None, + search_value: None, + limit: Some(10), + include_counts: false, + }), + "SELECT `status` AS dbx_value FROM `orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10" + ); } #[test] @@ -2382,12 +2465,33 @@ mod tests { assert_eq!( build_data_grid_count_sql(DataGridCountSqlOptions { database_type: Some(DatabaseType::Postgres), + catalog: None, schema: Some("public".to_string()), table_name: "users".to_string(), where_input: Some("WHERE active = true;".to_string()), }), "SELECT COUNT(*) AS cnt FROM \"public\".\"users\" WHERE (active = true)" ); + assert_eq!( + build_data_grid_count_sql(DataGridCountSqlOptions { + database_type: Some(DatabaseType::Doris), + catalog: Some("iceberg_catalog".to_string()), + schema: Some("sales".to_string()), + table_name: "orders".to_string(), + where_input: Some("WHERE active = true;".to_string()), + }), + "SELECT COUNT(*) AS cnt FROM `iceberg_catalog`.`sales`.`orders` WHERE (active = true)" + ); + assert_eq!( + build_data_grid_count_sql(DataGridCountSqlOptions { + database_type: Some(DatabaseType::StarRocks), + catalog: Some("hive_catalog".to_string()), + schema: None, + table_name: "orders".to_string(), + where_input: None, + }), + "SELECT COUNT(*) AS cnt FROM `hive_catalog`.`orders`" + ); } #[test] @@ -2523,6 +2627,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), table_meta: DataGridTableMeta { + catalog: None, schema: Some("dbo".to_string()), table_name: "users".to_string(), primary_keys: vec!["Id".to_string()], @@ -2545,6 +2650,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oracle), table_meta: DataGridTableMeta { + catalog: None, schema: Some("APP".to_string()), table_name: "EVENTS".to_string(), primary_keys: vec!["ID".to_string()], @@ -2590,6 +2696,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), table_meta: DataGridTableMeta { + catalog: None, schema: Some("dbo".to_string()), table_name: "flags".to_string(), primary_keys: vec![], @@ -2620,6 +2727,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "employees".to_string(), primary_keys: vec!["id".to_string()], @@ -2646,6 +2754,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "employees".to_string(), primary_keys: vec!["id".to_string()], @@ -2672,6 +2781,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "employees".to_string(), primary_keys: vec!["id".to_string()], @@ -2698,6 +2808,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::SqlServer), table_meta: DataGridTableMeta { + catalog: None, schema: Some("game".to_string()), table_name: "player states".to_string(), primary_keys: vec!["role id".to_string()], @@ -2726,6 +2837,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Databend), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "people".to_string(), primary_keys: vec!["id".to_string()], @@ -2756,6 +2868,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "people".to_string(), primary_keys: vec!["id".to_string()], @@ -2788,6 +2901,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "people".to_string(), primary_keys: vec!["id".to_string()], @@ -2818,6 +2932,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "events".to_string(), primary_keys: vec!["id".to_string()], @@ -2844,6 +2959,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ClickHouse), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "events".to_string(), primary_keys: vec!["id".to_string()], @@ -2876,6 +2992,7 @@ mod tests { let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { database_type: Some(DatabaseType::ClickHouse), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "people".to_string(), primary_keys: vec!["id".to_string()], @@ -2889,11 +3006,69 @@ mod tests { assert_eq!(statements, vec!["ALTER TABLE `people` UPDATE `name` = 'Ada' WHERE `id` = 1;"]); } + #[test] + fn doris_external_catalog_save_and_copy_statements_use_catalog_scope() { + let table_meta = DataGridTableMeta { + catalog: Some("iceberg_catalog".to_string()), + schema: Some("sales".to_string()), + table_name: "orders".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![column("id", "bigint", false, None), column("status", "varchar", true, None)]), + }; + + let copy_updates = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions { + database_type: Some(DatabaseType::Doris), + table_meta: table_meta.clone(), + columns: vec!["id".to_string(), "status".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("paid")]], + }); + assert_eq!( + copy_updates, + vec!["UPDATE `iceberg_catalog`.`sales`.`orders` SET `status` = 'paid' WHERE `id` = 1;"] + ); + + let copy_insert = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions { + database_type: Some(DatabaseType::Doris), + table_meta: Some(table_meta.clone()), + columns: vec!["id".to_string(), "status".to_string()], + source_columns: None, + rows: vec![vec![json!(2), json!("new")]], + exclude_primary_keys: false, + insert_mode: DataGridCopyInsertMode::Merged, + }); + assert_eq!( + copy_insert.as_deref(), + Some("INSERT INTO `iceberg_catalog`.`sales`.`orders` (`id`, `status`) VALUES (2, 'new');") + ); + + let save = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Doris), + table_meta, + columns: vec!["id".to_string(), "status".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("pending")], vec![json!(3), json!("cancelled")]], + dirty_rows: vec![(0, vec![(1, json!("paid"))])], + deleted_rows: vec![1], + new_rows: vec![vec![json!(4), json!("new")]], + }); + assert_eq!( + save.statements, + vec![ + "UPDATE `iceberg_catalog`.`sales`.`orders` SET `status` = 'paid' WHERE `id` = 1;", + "DELETE FROM `iceberg_catalog`.`sales`.`orders` WHERE `id` = 3;", + "INSERT INTO `iceberg_catalog`.`sales`.`orders` (`id`, `status`) VALUES (4, 'new');", + ] + ); + assert!(save.validation_error.is_none()); + } + #[test] fn prepares_databend_keyless_save_statements_with_row_predicate() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Databend), table_meta: DataGridTableMeta { + catalog: None, schema: Some("default".to_string()), table_name: "people".to_string(), primary_keys: vec![], @@ -2921,6 +3096,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Oscar), table_meta: DataGridTableMeta { + catalog: None, schema: Some("SYSDBA".to_string()), table_name: "PEOPLE".to_string(), primary_keys: vec![], @@ -2948,6 +3124,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), table_meta: DataGridTableMeta { + catalog: None, schema: Some("public".to_string()), table_name: "ihli_data".to_string(), primary_keys: vec!["iso3".to_string(), "year".to_string()], @@ -2980,6 +3157,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "policies".to_string(), primary_keys: vec!["id".to_string()], @@ -3030,6 +3208,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "school".to_string(), primary_keys: vec!["id".to_string()], @@ -3054,6 +3233,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "parts".to_string(), primary_keys: vec![], @@ -3084,6 +3264,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::ManticoreSearch), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "rt_products".to_string(), primary_keys: vec![], @@ -3113,6 +3294,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Postgres), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "education_data".to_string(), primary_keys: vec!["country_code".to_string(), "year".to_string()], @@ -3149,6 +3331,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "OnlineLogs".to_string(), primary_keys: vec!["OnlineLogId".to_string()], @@ -3174,6 +3357,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "OnlineLogs".to_string(), primary_keys: vec!["OnlineLogId".to_string()], @@ -3202,6 +3386,7 @@ mod tests { let result = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Sqlite), table_meta: DataGridTableMeta { + catalog: None, schema: None, table_name: "OnlineLogs".to_string(), primary_keys: vec!["OnlineLogId".to_string()], diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 38d039890f..6d35662b0a 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -90,6 +90,19 @@ fn get_opt_str(row: &mysql_async::Row, name: &str) -> Option { .or_else(|| row_get::, _>(row, name).map(|b| String::from_utf8_lossy(&b).to_string())) } +/// First non-empty string value among the named columns (e.g. Doris `CatalogName` +/// vs StarRocks `Catalog`). Returns an empty string when none of the columns +/// are present or all are empty. +fn first_nonempty_str_by_name(row: &mysql_async::Row, names: &[&str]) -> String { + for name in names { + let value = get_str_by_name(row, name); + if !value.is_empty() { + return value; + } + } + String::new() +} + fn get_opt_metadata_string(row: &mysql_async::Row, name: &str) -> Option { get_opt_str(row, name) .or_else(|| row_get::(row, name).map(|value| value.to_string())) @@ -3081,6 +3094,202 @@ pub async fn show_create_table_ddl(pool: &MySqlPool, database: &str, table: &str .ok_or_else(|| "Failed to read DDL".to_string()) } +// --------------------------------------------------------------------------- +// Doris / StarRocks multi-catalog support. +// +// These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside +// the native `internal` catalog via `SHOW CATALOGS`. The functions below address +// objects in a specific catalog using 3-part qualified names +// (`..`), which the engines accept directly without +// needing to `SWITCH` the session catalog. +// --------------------------------------------------------------------------- + +/// Build a 2-part qualified identifier `` ``.`` ``. +fn doris_catalog_database_ref(catalog: &str, database: &str) -> String { + format!("{}.{}", quote_identifier(catalog), quote_identifier(database)) +} + +/// Build a 3-part qualified identifier `` ``.``.`
` ``. +fn doris_catalog_table_ref(catalog: &str, database: &str, table: &str) -> String { + format!("{}.{}.{}", quote_identifier(catalog), quote_identifier(database), quote_identifier(table)) +} + +/// `SHOW CATALOGS` → list of catalogs visible to the current user. +/// +/// Column layouts differ between engines: Doris exposes `CatalogName` (with +/// `CatalogId`/`IsCurrent`/`CreateTime`/`LastUpdateTime`), while StarRocks +/// exposes `Catalog` (only `Type`/`Comment`, no `IsCurrent`). The name is read +/// from either column; missing trailing columns degrade gracefully to +/// empty/None. The built-in catalog is named `internal` in Doris and +/// `default_catalog` in StarRocks (both with `Type=internal`); detection is +/// type-based (see `CatalogInfo::is_internal`), not name-based. +pub async fn list_doris_catalogs(pool: &MySqlPool) -> Result, String> { + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let result = conn.query_iter("SHOW CATALOGS").await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let catalogs: Vec = rows + .iter() + .filter_map(|row| { + // Doris column is `CatalogName`; StarRocks column is `Catalog`. + let name = first_nonempty_str_by_name(row, &["CatalogName", "Catalog"]).trim().to_string(); + if name.is_empty() { + return None; + } + let catalog_type = get_str_by_name(row, "Type").trim().to_string(); + let is_current = { + let value = get_str_by_name(row, "IsCurrent").trim().to_ascii_lowercase(); + !value.is_empty() && value != "no" && value != "false" && value != "0" + }; + let comment = get_opt_str(row, "Comment").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + Some(crate::db::CatalogInfo { name, catalog_type, is_current, comment }) + }) + .collect(); + Ok(normalize_doris_catalogs(catalogs)) +} + +/// Sort with the built-in catalog first, then the rest alphabetically by name. +/// The built-in catalog is identified by `CatalogInfo::is_internal` (type-based) +/// rather than by name, so StarRocks `default_catalog` sorts first just like +/// Doris `internal`. No synthetic catalog is injected: `SHOW CATALOGS` always +/// lists the built-in catalog on both engines, and a single-catalog result is +/// handled by the flat-sidebar fallback in the caller. +fn normalize_doris_catalogs(mut catalogs: Vec) -> Vec { + catalogs.sort_by(|a, b| match (a.is_internal(), b.is_internal()) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + catalogs +} + +/// `SHOW DATABASES FROM ` → databases in the given catalog. +pub async fn list_databases_show_from(pool: &MySqlPool, catalog: &str) -> Result, String> { + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let sql = format!("SHOW DATABASES FROM {}", quote_identifier(catalog)); + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + Ok(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false)) +} + +/// `SHOW TABLES FROM .` → tables in an external catalog. +/// +/// External catalogs do not support `SHOW TABLE STATUS`, so comments/status are +/// not fetched (the caller only needs names + types for browsing). +pub async fn list_tables_show_from(pool: &MySqlPool, catalog: &str, database: &str) -> Result, String> { + let sql = format!("SHOW TABLES FROM {}", doris_catalog_database_ref(catalog, database)); + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let mut tables: Vec = rows + .iter() + .filter_map(|row| { + let name = get_str(row, 0).trim().to_string(); + if name.is_empty() { + return None; + } + // SHOW FULL TABLES exposes a type column; plain SHOW TABLES does not. + let table_type = get_str(row, 1); + Some(TableInfo { + name, + table_type: if table_type.trim().is_empty() { "TABLE".to_string() } else { table_type }, + comment: None, + parent_schema: None, + parent_name: None, + }) + }) + .collect(); + tables.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(tables) +} + +/// `SHOW COLUMNS FROM ..
` → columns of an external +/// catalog table. Falls back to `DESCRIBE` if `SHOW COLUMNS` is rejected. +pub async fn get_columns_show_from( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let qualified = doris_catalog_table_ref(catalog, database, table); + let full_sql = format!("SHOW FULL COLUMNS FROM {qualified}"); + let plain_sql = format!("SHOW COLUMNS FROM {qualified}"); + let describe_sql = format!("DESCRIBE {qualified}"); + let mut conn = get_conn_with_health_check(pool).await?; + let rows: Vec = match conn.query_iter(&full_sql).await { + Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, + Err(_) => match conn.query_iter(&plain_sql).await { + Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, + Err(_) => { + let result = conn.query_iter(&describe_sql).await.map_err(|e| e.to_string())?; + result.collect_and_drop().await.map_err(|e| e.to_string())? + } + }, + }; + Ok(rows + .iter() + .filter_map(|row| { + let name = get_str_by_name(row, "Field").trim().to_string(); + if name.is_empty() { + return None; + } + let key = get_str_by_name(row, "Key"); + Some(ColumnInfo { + name, + data_type: get_str_by_name(row, "Type"), + is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), + column_default: get_opt_str(row, "Default"), + is_primary_key: key.eq_ignore_ascii_case("PRI"), + extra: get_opt_str(row, "Extra"), + comment: get_opt_str(row, "Comment") + .map(|s| fix_potential_double_encoding(&s)) + .filter(|s| !s.is_empty()), + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + enum_values: None, + }) + }) + .collect()) +} + +/// `SHOW CREATE TABLE ..
` → DDL for an external +/// catalog table. +pub async fn show_create_table_ddl_from( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result { + let sql = format!("SHOW CREATE TABLE {}", doris_catalog_table_ref(catalog, database, table)); + let mut conn = get_conn_with_health_check(pool).await?; + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let row = rows.first().ok_or("DDL not found")?; + row.get_opt::(1) + .and_then(|result| result.ok()) + .or_else(|| { + row.get_opt::, usize>(1) + .and_then(|result| result.ok()) + .map(|b| String::from_utf8_lossy(&b).to_string()) + }) + .ok_or_else(|| "Failed to read DDL".to_string()) +} + +/// Best-effort index listing for an external catalog table. External catalogs +/// generally do not expose MySQL-style index metadata via `information_schema` +/// (that view is scoped to the internal catalog), so indexes are derived from +/// `SHOW CREATE TABLE` parsing. Returns empty on failure (graceful degradation +/// — indexes are informational for external tables). +pub async fn list_doris_catalog_indexes( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let ddl = show_create_table_ddl_from(pool, catalog, database, table).await?; + Ok(doris_indexes_from_create_table_ddl(&ddl)) +} + fn merge_index_infos(target: &mut Vec, parsed: Vec) { let mut seen_names: HashSet = target.iter().map(|index| index.name.to_ascii_lowercase()).collect(); for index in parsed { @@ -4401,4 +4610,89 @@ UNIQUE KEY(`tenant_id`, `name``part`) vec!["USE `db`", "SET NAMES utf8mb4", "SET @@group_concat_max_len = 1048576"] ); } + + fn doris_catalog_info(name: &str, catalog_type: &str, is_current: bool) -> crate::db::CatalogInfo { + crate::db::CatalogInfo { + name: name.to_string(), + catalog_type: catalog_type.to_string(), + is_current, + comment: None, + } + } + + #[test] + fn doris_catalog_database_ref_backtick_qualifies_two_parts() { + assert_eq!(doris_catalog_database_ref("iceberg_catalog", "sales"), "`iceberg_catalog`.`sales`"); + } + + #[test] + fn doris_catalog_table_ref_backtick_qualifies_three_parts() { + assert_eq!(doris_catalog_table_ref("iceberg_catalog", "sales", "orders"), "`iceberg_catalog`.`sales`.`orders`"); + } + + #[test] + fn doris_catalog_refs_escape_embedded_backticks() { + assert_eq!(doris_catalog_database_ref("a`b", "c`d"), "`a``b`.`c``d`"); + assert_eq!(doris_catalog_table_ref("a`b", "c`d", "e`f"), "`a``b`.`c``d`.`e``f`"); + } + + #[test] + fn normalize_doris_catalogs_does_not_inject_missing_internal() { + // SHOW CATALOGS always lists the built-in catalog on both engines, so a + // missing internal catalog is not synthesized — the caller's flat-sidebar + // fallback handles a single/empty result instead. + let catalogs = vec![ + doris_catalog_info("iceberg_catalog", "iceberg", true), + doris_catalog_info("hive_catalog", "hive", false), + ]; + let normalized = normalize_doris_catalogs(catalogs); + let names: Vec<&str> = normalized.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["hive_catalog", "iceberg_catalog"]); + assert!(!normalized.iter().any(|c| c.is_internal())); + } + + #[test] + fn normalize_doris_catalogs_keeps_existing_internal_first() { + let catalogs = vec![ + doris_catalog_info("iceberg_catalog", "iceberg", false), + doris_catalog_info("internal", "internal", true), + doris_catalog_info("hive_catalog", "hive", false), + ]; + let normalized = normalize_doris_catalogs(catalogs); + let names: Vec<&str> = normalized.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["internal", "hive_catalog", "iceberg_catalog"]); + } + + #[test] + fn normalize_doris_catalogs_sorts_starrocks_default_catalog_first() { + // StarRocks names its built-in catalog `default_catalog` (Type=Internal); + // detection is type-based, so it sorts first just like Doris `internal`. + let catalogs = vec![ + doris_catalog_info("hive_catalog", "hive", false), + doris_catalog_info("default_catalog", "Internal", true), + ]; + let normalized = normalize_doris_catalogs(catalogs); + let names: Vec<&str> = normalized.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["default_catalog", "hive_catalog"]); + assert!(normalized[0].is_internal()); + assert!(!normalized[1].is_internal()); + } + + #[test] + fn normalize_doris_catalogs_detects_internal_by_type_not_name() { + // A catalog literally named `internal` but with an external type is NOT + // the built-in catalog and must not sort first. + let catalogs = + vec![doris_catalog_info("internal", "iceberg", false), doris_catalog_info("hive_catalog", "hive", false)]; + let normalized = normalize_doris_catalogs(catalogs); + let names: Vec<&str> = normalized.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["hive_catalog", "internal"]); + assert!(!normalized.iter().any(|c| c.is_internal())); + } + + #[test] + fn normalize_doris_catalogs_handles_empty_input() { + let normalized = normalize_doris_catalogs(Vec::new()); + assert!(normalized.is_empty()); + } } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index d2fef2b8c1..883c9d8737 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -522,6 +522,196 @@ pub async fn list_sqlserver_linked_server_tables_core( Ok(vec![]) } +// --------------------------------------------------------------------------- +// Doris / StarRocks multi-catalog federation. +// +// These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside +// the native `internal` catalog via `SHOW CATALOGS`. The functions below browse +// a specific catalog's databases/tables and read table metadata using 3-part +// qualified names (`..
`), which the engines accept +// directly. The native `internal` catalog continues to use the existing +// `list_databases_core` / `list_tables_core` paths. +// --------------------------------------------------------------------------- + +/// `SHOW CATALOGS` → catalogs visible to the current user. Returns an empty +/// list when the connection pool is not a MySQL pool (Doris/StarRocks always +/// use the MySQL protocol, so this is a defensive no-op); the caller's +/// flat-sidebar fallback then renders the standard database list. +pub async fn list_doris_catalogs_core(state: &AppState, connection_id: &str) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let connections = state.connections.read().await; + if let Some(PoolKind::Mysql(p, _)) = connections.get(&pool_key) { + return db::mysql::list_doris_catalogs(p).await; + } + Ok(vec![]) +} + +/// `SHOW DATABASES FROM ` → databases in the given catalog. +/// +/// For `internal`, system databases are filtered (mirroring `list_databases_core`). +/// For external catalogs, permission errors degrade to an empty list (the user +/// asked that inaccessible catalogs simply not be shown). +pub async fn list_doris_catalog_databases_core( + state: &AppState, + connection_id: &str, + catalog: &str, +) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let db_config = connection_config(state, connection_id).await; + let connections = state.connections.read().await; + let pool = connections.get(&pool_key).ok_or("Pool not found")?; + let PoolKind::Mysql(p, _) = pool else { + return Ok(vec![]); + }; + let databases = db::mysql::list_databases_show_from(p, catalog).await; + // External catalogs may reject `SHOW DATABASES FROM ` when the user + // lacks permission — surface as an empty list rather than an error. + let databases = match databases { + Ok(databases) => databases, + Err(error) => { + log::warn!( + "[schema][doris:list_catalog_databases] connection_id={} catalog={} error={}", + connection_id, + catalog, + error + ); + return Ok(vec![]); + } + }; + if catalog == "internal" { + return Ok(filter_mysql_system_databases_for_config(databases, db_config.as_ref())); + } + Ok(databases) +} + +/// `SHOW TABLES FROM .` → tables in an external catalog. +pub async fn list_doris_catalog_tables_core( + state: &AppState, + connection_id: &str, + catalog: &str, + database: &str, + filter: Option<&str>, + limit: Option, + offset: Option, + object_types: Option<&[String]>, +) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let connections = state.connections.read().await; + let pool = connections.get(&pool_key).ok_or("Pool not found")?; + let PoolKind::Mysql(p, _) = pool else { + return Ok(vec![]); + }; + db::mysql::list_tables_show_from(p, catalog, database) + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types)) +} + +/// Columns of an external catalog table via `SHOW COLUMNS FROM ..
`. +pub async fn get_doris_catalog_columns_core( + state: &AppState, + connection_id: &str, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let connections = state.connections.read().await; + let pool = connections.get(&pool_key).ok_or("Pool not found")?; + let PoolKind::Mysql(p, _) = pool else { + return Ok(vec![]); + }; + db::mysql::get_columns_show_from(p, catalog, database, table).await.map(deduplicate_column_infos) +} + +/// DDL for an external catalog table via `SHOW CREATE TABLE ..
`. +pub async fn get_doris_catalog_table_ddl_core( + state: &AppState, + connection_id: &str, + catalog: &str, + database: &str, + table: &str, +) -> Result { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let connections = state.connections.read().await; + let pool = connections.get(&pool_key).ok_or("Pool not found")?; + let PoolKind::Mysql(p, _) = pool else { + return Err("DDL not supported for this connection".to_string()); + }; + db::mysql::show_create_table_ddl_from(p, catalog, database, table).await +} + +/// Best-effort index listing for an external catalog table (derived from DDL). +pub async fn list_doris_catalog_indexes_core( + state: &AppState, + connection_id: &str, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, None).await?; + let connections = state.connections.read().await; + let pool = connections.get(&pool_key).ok_or("Pool not found")?; + let PoolKind::Mysql(p, _) = pool else { + return Ok(vec![]); + }; + db::mysql::list_doris_catalog_indexes(p, catalog, database, table).await +} + +/// Table comment for an external catalog table. Doris does not reliably expose +/// comments for external catalog tables, so this returns `None`. +pub async fn get_doris_catalog_table_comment_core( + _state: &AppState, + _connection_id: &str, + _catalog: &str, + _database: &str, + _table: &str, +) -> Result, String> { + Ok(None) +} + +/// Foreign keys are not applicable to external catalog tables. +pub async fn list_doris_catalog_foreign_keys_core( + _state: &AppState, + _connection_id: &str, + _catalog: &str, + _database: &str, + _table: &str, +) -> Result, String> { + Ok(vec![]) +} + +/// Triggers are not applicable to external catalog tables. +pub async fn list_doris_catalog_triggers_core( + _state: &AppState, + _connection_id: &str, + _catalog: &str, + _database: &str, + _table: &str, +) -> Result, String> { + Ok(vec![]) +} + +/// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path. +/// Returns `Some(catalog)` only when `catalog` is a non-empty, non-`internal` +/// name and the connection is a Doris-family engine that supports +/// `SHOW CATALOGS`. Otherwise `None` (caller uses the default metadata path). +pub async fn resolve_external_doris_catalog( + state: &AppState, + connection_id: &str, + catalog: Option<&str>, +) -> Option { + let catalog = catalog?.trim(); + if catalog.is_empty() || catalog == "internal" { + return None; + } + let config = connection_config(state, connection_id).await?; + if is_doris_family_catalog_capable_config(&config) { + Some(catalog.to_string()) + } else { + None + } +} + async fn list_databases_once(state: &AppState, connection_id: &str) -> Result, String> { log::info!("[list_databases] connection_id={connection_id}"); let db_config = connection_config(state, connection_id).await; @@ -2969,6 +3159,37 @@ mod tests { vec!["ORDERS".to_string(), "ORDERS_VIEW".to_string()] ); } + + #[test] + fn doris_family_catalog_capable_matches_doris_and_starrocks_only() { + // Doris and StarRocks expose multi-catalog federation. + assert!(super::is_doris_family_catalog_capable_config(&test_connection_config(DatabaseType::Doris))); + assert!(super::is_doris_family_catalog_capable_config(&test_connection_config(DatabaseType::StarRocks))); + + // Driver profiles for Doris/SelectDB/StarRocks also qualify. + let mut doris = test_connection_config(DatabaseType::Mysql); + doris.driver_profile = Some("doris".to_string()); + assert!(super::is_doris_family_catalog_capable_config(&doris)); + + let mut selectdb = test_connection_config(DatabaseType::Mysql); + selectdb.driver_profile = Some("selectdb".to_string()); + assert!(super::is_doris_family_catalog_capable_config(&selectdb)); + + let mut starrocks = test_connection_config(DatabaseType::Mysql); + starrocks.driver_profile = Some("starrocks".to_string()); + assert!(super::is_doris_family_catalog_capable_config(&starrocks)); + + // ManticoreSearch shares the MySQL code path but has no catalog concept. + assert!(!super::is_doris_family_catalog_capable_config(&test_connection_config(DatabaseType::ManticoreSearch))); + + let mut manticore = test_connection_config(DatabaseType::Mysql); + manticore.driver_profile = Some("manticoresearch".to_string()); + assert!(!super::is_doris_family_catalog_capable_config(&manticore)); + + // Plain MySQL / Postgres are not catalog-capable. + assert!(!super::is_doris_family_catalog_capable_config(&test_connection_config(DatabaseType::Mysql))); + assert!(!super::is_doris_family_catalog_capable_config(&test_connection_config(DatabaseType::Postgres))); + } } pub async fn list_objects_core( @@ -4384,6 +4605,14 @@ fn is_doris_family_config(config: &ConnectionConfig) -> bool { || matches!(config.driver_profile.as_deref(), Some("doris" | "selectdb" | "starrocks" | "manticoresearch")) } +/// Doris-family engines that support multi-catalog federation (`SHOW CATALOGS`). +/// Manticore Search is excluded — it shares the MySQL code path but has no +/// catalog concept. +pub fn is_doris_family_catalog_capable_config(config: &ConnectionConfig) -> bool { + matches!(config.db_type, DatabaseType::Doris | DatabaseType::StarRocks) + || matches!(config.driver_profile.as_deref(), Some("doris" | "selectdb" | "starrocks")) +} + fn is_manticoresearch_config(config: &ConnectionConfig) -> bool { matches!(config.db_type, DatabaseType::ManticoreSearch) || matches!(config.driver_profile.as_deref(), Some("manticoresearch")) diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index 54f3f92969..b013050b3b 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -10,7 +10,9 @@ pub use capabilities::{ firebird_rows_clause, is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first, uses_single_row_insert_statements, PaginationContext, TablePaginationStrategy, }; -pub use identifiers::{normalize_where_input, qualified_table_name, quote_table_identifier}; +pub use identifiers::{ + normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_table_identifier, +}; pub(crate) use identifiers::{parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_transfer_identifier}; pub use table_select::{build_count_table_sql, build_table_data_select_sql, build_table_select_sql}; pub use types::*; diff --git a/crates/dbx-core/src/sql_dialect/identifiers.rs b/crates/dbx-core/src/sql_dialect/identifiers.rs index a00944aad7..e4fef2e49b 100644 --- a/crates/dbx-core/src/sql_dialect/identifiers.rs +++ b/crates/dbx-core/src/sql_dialect/identifiers.rs @@ -71,6 +71,29 @@ pub fn qualified_table_name(database_type: Option, schema: Option< quote_table_identifier(database_type, table_name) } +/// Like `qualified_table_name`, but prefixes a Doris/StarRocks external +/// catalog (`..
`) when `catalog` is present, non-empty, +/// and not the engine's `internal` catalog. The `internal` guard is defensive — +/// built-in-catalog tables never carry a catalog in the first place (the +/// sidebar routes them through the standard path), so this only ever prefixes +/// genuine external catalogs. Other engines ignore `catalog` (they have no +/// 3-part catalog naming). +pub fn qualified_table_name_with_catalog( + database_type: Option, + catalog: Option<&str>, + schema: Option<&str>, + table_name: &str, +) -> String { + let table = qualified_table_name(database_type, schema, table_name); + let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty() && *catalog != "internal"); + match (catalog, database_type) { + (Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) => { + format!("{}.{}", quote_table_identifier(database_type, catalog), table) + } + _ => table, + } +} + pub fn quote_table_identifier(database_type: Option, name: &str) -> String { match database_type { Some(DatabaseType::Iotdb) => name.to_string(), diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 5353895a82..09c3e362c5 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -1,7 +1,9 @@ use crate::models::connection::DatabaseType; use super::capabilities::{firebird_rows_clause, table_pagination_strategy, uses_fetch_first, TablePaginationStrategy}; -use super::identifiers::{normalize_where_input, qualified_table_name, quote_table_identifier}; +use super::identifiers::{ + normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_table_identifier, +}; use super::types::{ TableDataSelectSqlOptions, TableSelectSqlOptions, DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN, @@ -18,7 +20,13 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String return build_neo4j_table_select_sql(&options, limit); } - let table = qualified_table_name(database_type, options.schema.as_deref(), &options.table_name); + // Doris / StarRocks multi-catalog: prefix the catalog for external-catalog tables. + let table = qualified_table_name_with_catalog( + database_type, + options.catalog.as_deref(), + options.schema.as_deref(), + &options.table_name, + ); let predicate = normalize_where_input(options.where_input.as_deref()); let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") }; let default_order_by = if database_type == Some(DatabaseType::InfluxDb) { @@ -399,3 +407,66 @@ pub(super) fn build_questdb_table_select_sql( let upper_bound = offset + limit; format!("SELECT {columns_sql} FROM {table}{where_clause}{order_by} LIMIT {offset}, {upper_bound}") } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(database_type: DatabaseType, catalog: Option<&str>, table: &str) -> TableDataSelectSqlOptions { + TableDataSelectSqlOptions { + database_type: Some(database_type), + schema: None, + table_name: table.to_string(), + catalog: catalog.map(|c| c.to_string()), + table_type: None, + primary_keys: Vec::new(), + columns: Vec::new(), + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(10), + offset: None, + where_input: None, + include_row_id: false, + } + } + + #[test] + fn doris_external_catalog_prefixes_from_clause() { + let sql = build_table_data_select_sql(opts(DatabaseType::Doris, Some("iceberg_catalog"), "orders")); + assert!(sql.contains("FROM `iceberg_catalog`.`orders`"), "sql was: {sql}"); + } + + #[test] + fn starrocks_external_catalog_prefixes_from_clause() { + let sql = build_table_data_select_sql(opts(DatabaseType::StarRocks, Some("hive_catalog"), "orders")); + assert!(sql.contains("FROM `hive_catalog`.`orders`"), "sql was: {sql}"); + } + + #[test] + fn doris_internal_catalog_is_not_prefixed() { + let sql = build_table_data_select_sql(opts(DatabaseType::Doris, Some("internal"), "orders")); + assert!(!sql.contains("internal"), "sql was: {sql}"); + assert!(sql.contains("FROM `orders`"), "sql was: {sql}"); + } + + #[test] + fn doris_empty_catalog_is_not_prefixed() { + let sql = build_table_data_select_sql(opts(DatabaseType::Doris, Some(" "), "orders")); + assert!(sql.contains("FROM `orders`"), "sql was: {sql}"); + } + + #[test] + fn doris_no_catalog_is_not_prefixed() { + let sql = build_table_data_select_sql(opts(DatabaseType::Doris, None, "orders")); + assert!(sql.contains("FROM `orders`"), "sql was: {sql}"); + } + + #[test] + fn external_catalog_is_ignored_for_non_doris_engines() { + // Postgres does not support the 3-part catalog naming; the catalog + // must be ignored to avoid emitting an invalid qualified name. + let sql = build_table_data_select_sql(opts(DatabaseType::Postgres, Some("iceberg_catalog"), "orders")); + assert!(!sql.contains("iceberg_catalog"), "sql was: {sql}"); + assert!(sql.contains("orders"), "sql was: {sql}"); + } +} diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 343259733c..a8a04b656a 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -258,6 +258,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: Some("where status = 'active'".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM `users` WHERE (status = 'active') LIMIT 100;" ); @@ -275,6 +276,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT * FROM `sys_dic` LIMIT 100;" ); @@ -292,6 +294,7 @@ fn builds_table_data_where_and_schema_queries() { offset: Some(100), where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM \"public\".\"orders\" WHERE (amount > 10) LIMIT 50 OFFSET 100;" ); @@ -309,6 +312,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT * FROM \"DBX_TEST\".\"PRODUCTS\" LIMIT 100;" ); @@ -326,6 +330,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: Some("`customer_name` = 'Acme'".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM `sales_report` WHERE (`customer_name` = 'Acme') LIMIT 100;" ); @@ -343,6 +348,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM \"DB2INST1\".\"ORDERS\" WHERE (amount > 10) FETCH FIRST 50 ROWS ONLY" ); @@ -360,6 +366,7 @@ fn builds_table_data_where_and_schema_queries() { offset: Some(100), where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM \"ORDERS\" WHERE (amount > 10) ORDER BY \"ID\" ASC ROWS 101 TO 150" ); @@ -377,6 +384,7 @@ fn builds_table_data_where_and_schema_queries() { offset: Some(100), where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT \"ID\", \"AMOUNT\" FROM (SELECT dbx_inner.*, ROWNUM AS \"__dbx_row_num\" FROM (SELECT \"ID\", \"AMOUNT\" FROM \"DBXTEST\".\"ORDERS\" WHERE (amount > 10) ORDER BY \"ID\" ASC) dbx_inner WHERE ROWNUM <= 150) WHERE \"__dbx_row_num\" > 100" ); @@ -394,6 +402,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT * FROM (SELECT * FROM \"DBXTEST\".\"ORDERS\") WHERE ROWNUM <= 100" ); @@ -411,6 +420,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT * FROM (SELECT * FROM \"DBXTEST\".\"ORDERS\" WHERE (amount > 10)) WHERE ROWNUM <= 50" ); @@ -428,6 +438,7 @@ fn builds_table_data_where_and_schema_queries() { offset: Some(100), where_input: Some("WHERE amount > 10".to_string()), include_row_id: false, + ..Default::default() }), "SELECT \"ID\", \"AMOUNT\" FROM (SELECT dbx_t.\"ID\", dbx_t.\"AMOUNT\", ROW_NUMBER() OVER () AS \"__dbx_row_num\" FROM \"DB2INST1\".\"ORDERS\" dbx_t WHERE (amount > 10)) dbx_page WHERE \"__dbx_row_num\" > 100 AND \"__dbx_row_num\" <= 150 ORDER BY \"__dbx_row_num\"" ); @@ -445,6 +456,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT TOP 100 * FROM \"Ens\".\"AlarmResponse\"" ); @@ -462,6 +474,7 @@ fn builds_table_data_where_and_schema_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT * FROM root.test.device2 LIMIT 100;" ); @@ -483,6 +496,7 @@ fn builds_informix_table_data_with_skip_first_pagination() { offset: Some(100), where_input: Some("WHERE active = 1".to_string()), include_row_id: false, + ..Default::default() }), "SELECT SKIP 100 FIRST 50 * FROM users WHERE (active = 1)" ); @@ -516,6 +530,7 @@ fn explicit_table_data_order_is_preserved() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT * FROM \"public\".\"country_gdp\" ORDER BY \"iso3\" ASC LIMIT 100;" ); @@ -536,6 +551,7 @@ fn builds_iris_table_data_sql_with_literal_top_and_quoted_object() { offset: None, where_input: Some("WHERE \"Status\" = 'Open'".to_string()), include_row_id: false, + ..Default::default() }); assert_eq!( @@ -569,6 +585,7 @@ fn builds_table_data_special_column_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` LIMIT 100;" ); @@ -586,6 +603,7 @@ fn builds_table_data_special_column_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT `ts` AS `ts`, `current` AS `current` FROM `test_db`.`d1001` LIMIT 100;" ); @@ -603,6 +621,7 @@ fn builds_table_data_special_column_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT `ts` AS `ts`, `current` AS `current` FROM `test_db`.`d1001` LIMIT 100;" ); @@ -620,6 +639,7 @@ fn builds_table_data_special_column_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "SELECT `id` AS `id`, `name` AS `name` FROM `departments` LIMIT 100;" ); @@ -641,6 +661,7 @@ fn builds_sqlserver_table_data_pages() { offset: None, where_input: Some("where id = 1".to_string()), include_row_id: false, + ..Default::default() }), "SELECT TOP (25) * FROM [dbo].[accounts] WHERE (id = 1)" ); @@ -658,6 +679,7 @@ fn builds_sqlserver_table_data_pages() { offset: Some(100), where_input: None, include_row_id: false, + ..Default::default() }), "WITH [dbx_page] AS (SELECT [order_id], [customer], ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS [__dbx_row_num] FROM [sales].[orders]) SELECT [order_id], [customer] FROM [dbx_page] WHERE [__dbx_row_num] > 100 AND [__dbx_row_num] <= 150 ORDER BY [__dbx_row_num]" ); @@ -679,6 +701,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { offset: None, where_input: None, include_row_id: true, + ..Default::default() }), "SELECT * FROM (SELECT ROWIDTOCHAR(t.ROWID) AS \"__DBX_ROWID\", t.* FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" t) WHERE ROWNUM <= 100" ); @@ -696,6 +719,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { offset: None, where_input: None, include_row_id: true, + ..Default::default() }), "SELECT \"__DBX_ROWID\", \"ID\", \"NAME\" FROM (SELECT ROWIDTOCHAR(t.ROWID) AS \"__DBX_ROWID\", t.* FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" t) WHERE ROWNUM <= 100" ); @@ -713,6 +737,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { offset: None, where_input: None, include_row_id: true, + ..Default::default() }), "SELECT \"ID\", \"NAME\" FROM (SELECT \"ID\", \"NAME\" FROM \"DBXTEST\".\"DBX_JOIN_VIEW\") WHERE ROWNUM <= 100" ); @@ -730,6 +755,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { offset: None, where_input: None, include_row_id: false, + ..Default::default() }), "MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`first name` AS `first name`, n.`role` AS `role` LIMIT 100;" ); diff --git a/crates/dbx-core/src/sql_dialect/types.rs b/crates/dbx-core/src/sql_dialect/types.rs index cfd5f061d7..2f3b523111 100644 --- a/crates/dbx-core/src/sql_dialect/types.rs +++ b/crates/dbx-core/src/sql_dialect/types.rs @@ -16,7 +16,7 @@ pub struct TableSelectSqlOptions<'a> { pub limit: usize, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableDataSelectSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -24,6 +24,11 @@ pub struct TableDataSelectSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String, + /// Doris / StarRocks multi-catalog: when set to a non-`internal` catalog, + /// the FROM clause is prefixed with the catalog + /// (`..
`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub table_type: Option, #[serde(default)] diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 519589bb1a..9da18aad4b 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -19,6 +19,34 @@ pub struct LinkedServerInfo { pub data_source: Option, } +/// A catalog exposed by a multi-catalog engine (e.g. Doris / StarRocks). +/// `internal` is the engine's native catalog; other entries are external +/// catalogs (iceberg, hive, jdbc, ...) federated through the same connection. +/// +/// Note: the built-in catalog is named `internal` in Doris (Type=`internal`) +/// but `default_catalog` in StarRocks (Type=`Internal`). The `catalog_type` +/// column is the cross-engine signal, so `is_internal()` matches it +/// case-insensitively and falls back to the canonical Doris name when the +/// column is absent (very old / proxied deployments). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogInfo { + pub name: String, + pub catalog_type: String, + pub is_current: bool, + pub comment: Option, +} + +impl CatalogInfo { + /// Whether this is the engine's built-in (non-federated) catalog. + pub fn is_internal(&self) -> bool { + if !self.catalog_type.trim().is_empty() { + self.catalog_type.eq_ignore_ascii_case("internal") + } else { + self.name == "internal" + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableInfo { pub name: String, diff --git a/crates/dbx-core/tests/live_postgres_tsvector.rs b/crates/dbx-core/tests/live_postgres_tsvector.rs index a5e75a5995..57132406cf 100644 --- a/crates/dbx-core/tests/live_postgres_tsvector.rs +++ b/crates/dbx-core/tests/live_postgres_tsvector.rs @@ -61,6 +61,7 @@ async fn postgres_tsvector_generated_columns_are_readable_and_omitted_from_inser assert!(search_vector.extra.as_deref().unwrap_or_default().contains("generated always as")); let table_meta = DataGridTableMeta { + catalog: None, schema: Some(schema.clone()), table_name: "articles".to_string(), primary_keys: vec!["id".to_string()], diff --git a/crates/dbx-core/tests/mysql_cross_database_editing.rs b/crates/dbx-core/tests/mysql_cross_database_editing.rs index aea71ad430..611f04db5e 100644 --- a/crates/dbx-core/tests/mysql_cross_database_editing.rs +++ b/crates/dbx-core/tests/mysql_cross_database_editing.rs @@ -32,6 +32,7 @@ fn mysql_cross_database_query_flow_preserves_target_database() { offset: Some(0), where_input: Some("active = 1".to_string()), include_row_id: false, + ..Default::default() }); assert!(preview_sql.contains("FROM `db_9`.`users`"), "preview SQL should stay on the referenced database"); @@ -54,6 +55,7 @@ fn mysql_cross_database_query_flow_preserves_target_database() { let save = prepare_data_grid_save(DataGridSaveStatementOptions { database_type: Some(DatabaseType::Mysql), table_meta: DataGridTableMeta { + catalog: None, schema: Some("db_9".to_string()), table_name: "users".to_string(), primary_keys: vec!["id".to_string()], diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index e5bad506a5..1b5de758f7 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -270,6 +270,8 @@ async fn main() { .route("/agents/progress/{operationId}", get(routes::agents::agent_progress)) // Schema .route("/schema/databases", get(routes::schema::list_databases)) + .route("/schema/doris/catalogs", get(routes::schema::list_doris_catalogs)) + .route("/schema/doris/catalog-databases", get(routes::schema::list_doris_catalog_databases)) .route("/schema/sqlserver/linked-servers", get(routes::schema::list_sqlserver_linked_servers)) .route("/schema/sqlserver/linked-server-catalogs", get(routes::schema::list_sqlserver_linked_server_catalogs)) .route("/schema/sqlserver/linked-server-schemas", get(routes::schema::list_sqlserver_linked_server_schemas)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index aa00a71316..284d5cf682 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -31,6 +31,30 @@ pub async fn list_databases( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +/// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path. +async fn external_doris_catalog(state: &Arc, connection_id: &str, catalog: Option<&str>) -> Option { + dbx_core::schema::resolve_external_doris_catalog(&state.app, connection_id, catalog).await +} + +pub async fn list_doris_catalogs( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let result = dbx_core::schema::list_doris_catalogs_core(&state.app, &q.connection_id).await.map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + +pub async fn list_doris_catalog_databases( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let catalog = q.catalog.as_deref().unwrap_or("internal"); + let result = dbx_core::schema::list_doris_catalog_databases_core(&state.app, &q.connection_id, catalog) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + pub async fn list_sqlserver_linked_servers( State(state): State>, Query(q): Query, @@ -111,18 +135,33 @@ pub async fn list_tables( let object_types = q.object_types.as_ref().map(|value| { value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).collect::>() }); - let result = dbx_core::schema::list_tables_core( - &state.app, - &q.connection_id, - database, - schema, - q.filter.as_deref(), - q.limit, - q.offset, - object_types.as_deref(), - ) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::list_doris_catalog_tables_core( + &state.app, + &q.connection_id, + &catalog, + database, + q.filter.as_deref(), + q.limit, + q.offset, + object_types.as_deref(), + ) + .await + .map_err(AppError)? + } else { + dbx_core::schema::list_tables_core( + &state.app, + &q.connection_id, + database, + schema, + q.filter.as_deref(), + q.limit, + q.offset, + object_types.as_deref(), + ) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -135,18 +174,47 @@ pub async fn list_objects( let object_types = q.object_types.as_ref().map(|value| { value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).collect::>() }); - let result = dbx_core::schema::list_objects_core( - &state.app, - &q.connection_id, - database, - schema, - q.filter.as_deref(), - q.limit, - q.offset, - object_types.as_deref(), - ) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + let tables = dbx_core::schema::list_doris_catalog_tables_core( + &state.app, + &q.connection_id, + &catalog, + database, + q.filter.as_deref(), + q.limit, + q.offset, + object_types.as_deref(), + ) + .await + .map_err(AppError)?; + tables + .into_iter() + .map(|table| dbx_core::db::ObjectInfo { + name: table.name, + object_type: table.table_type, + schema: Some(database.to_string()), + signature: None, + comment: table.comment, + created_at: None, + updated_at: None, + parent_schema: table.parent_schema, + parent_name: table.parent_name, + }) + .collect::>() + } else { + dbx_core::schema::list_objects_core( + &state.app, + &q.connection_id, + database, + schema, + q.filter.as_deref(), + q.limit, + q.offset, + object_types.as_deref(), + ) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -204,9 +272,15 @@ pub async fn list_columns( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); - let result = dbx_core::schema::get_columns_core(&state.app, &q.connection_id, database, schema, table) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::get_doris_catalog_columns_core(&state.app, &q.connection_id, &catalog, database, table) + .await + .map_err(AppError)? + } else { + dbx_core::schema::get_columns_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -227,9 +301,15 @@ pub async fn list_indexes( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); - let result = dbx_core::schema::list_indexes_core(&state.app, &q.connection_id, database, schema, table) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::list_doris_catalog_indexes_core(&state.app, &q.connection_id, &catalog, database, table) + .await + .map_err(AppError)? + } else { + dbx_core::schema::list_indexes_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -240,9 +320,15 @@ pub async fn list_foreign_keys( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); - let result = dbx_core::schema::list_foreign_keys_core(&state.app, &q.connection_id, database, schema, table) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::list_doris_catalog_foreign_keys_core(&state.app, &q.connection_id, &catalog, database, table) + .await + .map_err(AppError)? + } else { + dbx_core::schema::list_foreign_keys_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -253,9 +339,15 @@ pub async fn list_triggers( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); - let result = dbx_core::schema::list_triggers_core(&state.app, &q.connection_id, database, schema, table) - .await - .map_err(AppError)?; + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::list_doris_catalog_triggers_core(&state.app, &q.connection_id, &catalog, database, table) + .await + .map_err(AppError)? + } else { + dbx_core::schema::list_triggers_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError)? + }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } @@ -266,10 +358,15 @@ pub async fn get_ddl( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); - let result = + let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { + dbx_core::schema::get_doris_catalog_table_ddl_core(&state.app, &q.connection_id, &catalog, database, table) + .await + .map_err(AppError)? + } else { dbx_core::schema::get_table_ddl_core(&state.app, &q.connection_id, database, schema, table, q.object_type) .await - .map_err(AppError)?; + .map_err(AppError)? + }; Ok(Json(result)) } diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx index 71c0aa67a4..eb503e4a5f 100644 --- a/docs/content/docs/databases.cn.mdx +++ b/docs/content/docs/databases.cn.mdx @@ -124,6 +124,16 @@ DuckDB 连接支持两种模式: SQLite 连接可以使用 `:memory:` 作为文件路径,创建临时的内存数据库。适用于快速实验或临时数据转换。 +### Doris / StarRocks 多 Catalog + +Doris 与 StarRocks 作为 MPP 引擎,通过 Catalog 联邦访问外部数据源(`SHOW CATALOGS`)。DBX 会列出当前用户有权限读取的所有 Catalog,并以 `Catalog → 数据库 → 表` 的树形结构展示: + +- 内置的 `internal` Catalog 展示引擎自身的数据库与表。 +- 外部 Catalog(Hive、Iceberg、Hudi、Paimon、JDBC 等)同样列出其数据库与表。无权限访问的 Catalog 会被静默跳过。 +- 当只存在 `internal` Catalog 时,侧边栏保持扁平结构(不出现 Catalog 分组层),单 Catalog 部署不受影响。 + +从外部 Catalog 打开表时,数据预览使用三段式 `catalog.database.table` 名称,无需在会话中执行 `SWITCH`。外部 Catalog 仅支持表与视图——存储过程、序列、触发器、外键均不可用。 + ## 扩展支持 当目标数据库不在内置类型中,或团队必须使用厂商 JDBC 驱动时,可以使用 [JDBC 插件](/cn/docs/plugins)。JDBC 会把厂商驱动 JAR 放在 DBX 主应用之外,由你决定安装哪些驱动文件。 diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx index 7424a3d573..cf838858be 100644 --- a/docs/content/docs/databases.mdx +++ b/docs/content/docs/databases.mdx @@ -124,6 +124,16 @@ Create a new DuckDB database file directly from the connection form by entering SQLite connections can use `:memory:` as the file path for ephemeral, in-memory databases. This is useful for quick experiments or temporary data transformations. +### Doris / StarRocks Multi-Catalog + +Doris and StarRocks are MPP engines that federate external data through catalogs (`SHOW CATALOGS`). DBX lists every catalog the current user can read and renders them as a `catalog → database → table` tree: + +- The built-in `internal` catalog shows the engine's own databases and tables. +- External catalogs (Hive, Iceberg, Hudi, Paimon, JDBC, etc.) list their databases and tables the same way. Catalogs you lack permission to read are skipped silently. +- When only the `internal` catalog is present, the sidebar stays flat (no catalog grouping layer) so single-catalog deployments are unaffected. + +Opening a table from an external catalog previews its data using the 3-part `catalog.database.table` name, so no session `SWITCH` is needed. External catalogs expose tables and views only — routines, sequences, triggers, and foreign keys are not available. + ## Extending Support Use the [JDBC Plugin](/en/docs/plugins) when a database is not covered by the built-in profiles or when your team must use a vendor JDBC driver. JDBC keeps vendor driver JARs outside the main DBX app, so you control which driver files are installed. diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index f8cb62f261..b9daa5c174 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -4,6 +4,13 @@ use tauri::State; use crate::commands::connection::AppState; use dbx_core::db; +/// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path. +/// Thin wrapper around the shared dbx-core resolver so the Tauri and HTTP +/// backends stay in sync. +async fn external_doris_catalog(state: &AppState, connection_id: &str, catalog: Option<&str>) -> Option { + dbx_core::schema::resolve_external_doris_catalog(state, connection_id, catalog).await +} + #[tauri::command] pub async fn list_databases( state: State<'_, Arc>, @@ -12,6 +19,23 @@ pub async fn list_databases( dbx_core::schema::list_databases_core(&state, &connection_id).await } +#[tauri::command] +pub async fn list_doris_catalogs( + state: State<'_, Arc>, + connection_id: String, +) -> Result, String> { + dbx_core::schema::list_doris_catalogs_core(&state, &connection_id).await +} + +#[tauri::command] +pub async fn list_doris_catalog_databases( + state: State<'_, Arc>, + connection_id: String, + catalog: String, +) -> Result, String> { + dbx_core::schema::list_doris_catalog_databases_core(&state, &connection_id, &catalog).await +} + #[tauri::command] pub async fn list_sqlserver_linked_servers( state: State<'_, Arc>, @@ -107,7 +131,21 @@ pub async fn list_tables( limit: Option, offset: Option, object_types: Option>, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::list_doris_catalog_tables_core( + &state, + &connection_id, + &catalog, + &database, + filter.as_deref(), + limit, + offset, + object_types.as_deref(), + ) + .await; + } dbx_core::schema::list_tables_core( &state, &connection_id, @@ -128,7 +166,18 @@ pub async fn get_table_comment( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::get_doris_catalog_table_comment_core( + &state, + &connection_id, + &catalog, + &database, + &table, + ) + .await; + } dbx_core::schema::get_table_comment_core(&state, &connection_id, &database, &schema, &table).await } @@ -142,7 +191,35 @@ pub async fn list_objects( limit: Option, offset: Option, object_types: Option>, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + let tables = dbx_core::schema::list_doris_catalog_tables_core( + &state, + &connection_id, + &catalog, + &database, + filter.as_deref(), + limit, + offset, + object_types.as_deref(), + ) + .await?; + return Ok(tables + .into_iter() + .map(|table| db::ObjectInfo { + name: table.name, + object_type: table.table_type, + schema: Some(database.clone()), + signature: None, + comment: table.comment, + created_at: None, + updated_at: None, + parent_schema: table.parent_schema, + parent_name: table.parent_name, + }) + .collect()); + } dbx_core::schema::list_objects_core( &state, &connection_id, @@ -203,7 +280,12 @@ pub async fn get_columns( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::get_doris_catalog_columns_core(&state, &connection_id, &catalog, &database, &table) + .await; + } dbx_core::schema::get_columns_core(&state, &connection_id, &database, &schema, &table).await } @@ -214,7 +296,12 @@ pub async fn list_indexes( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::list_doris_catalog_indexes_core(&state, &connection_id, &catalog, &database, &table) + .await; + } dbx_core::schema::list_indexes_core(&state, &connection_id, &database, &schema, &table).await } @@ -225,7 +312,18 @@ pub async fn list_foreign_keys( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::list_doris_catalog_foreign_keys_core( + &state, + &connection_id, + &catalog, + &database, + &table, + ) + .await; + } dbx_core::schema::list_foreign_keys_core(&state, &connection_id, &database, &schema, &table).await } @@ -236,7 +334,12 @@ pub async fn list_triggers( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::list_doris_catalog_triggers_core(&state, &connection_id, &catalog, &database, &table) + .await; + } dbx_core::schema::list_triggers_core(&state, &connection_id, &database, &schema, &table).await } @@ -248,7 +351,12 @@ pub async fn get_table_ddl( schema: String, table: String, object_type: Option, + catalog: Option, ) -> Result { + if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { + return dbx_core::schema::get_doris_catalog_table_ddl_core(&state, &connection_id, &catalog, &database, &table) + .await; + } dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6b06c3644..f90dff0b67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -807,6 +807,8 @@ pub fn run() { commands::plugins::install_jdbc_plugin_local, commands::plugins::uninstall_jdbc_plugin, commands::schema::list_databases, + commands::schema::list_doris_catalogs, + commands::schema::list_doris_catalog_databases, commands::schema::list_sqlserver_linked_servers, commands::schema::list_sqlserver_linked_server_catalogs, commands::schema::list_sqlserver_linked_server_schemas,