mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:55:51 +00:00
feat(doris): show external catalogs in sidebar
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
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<QueryTab | undefined>)
|
||||
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<QueryTab | undefined>)
|
||||
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,
|
||||
|
||||
@@ -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<ConnectionConfig, "db_type" | "driver_profile"> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
CatalogInfo,
|
||||
TableInfo,
|
||||
ObjectInfo,
|
||||
CompletionAssistantRequest,
|
||||
@@ -487,6 +488,14 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
|
||||
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
|
||||
}
|
||||
|
||||
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
|
||||
return get(`/api/schema/doris/catalogs?${qs({ connection_id: connectionId })}`);
|
||||
}
|
||||
|
||||
export async function listDorisCatalogDatabases(connectionId: string, catalog: string): Promise<DatabaseInfo[]> {
|
||||
return get(`/api/schema/doris/catalog-databases?${qs({ connection_id: connectionId, catalog })}`);
|
||||
}
|
||||
|
||||
export async function listSqlServerLinkedServers(connectionId: string): Promise<LinkedServerInfo[]> {
|
||||
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<TableInfo[]> {
|
||||
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<TableInfo[]> {
|
||||
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<string | null> {
|
||||
export async function getTableComment(_connectionId: string, _database: string, _schema: string, _table: string, _catalog?: string): Promise<string | null> {
|
||||
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<ObjectInfo[]> {
|
||||
export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise<ObjectInfo[]> {
|
||||
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<ColumnInfo[]> {
|
||||
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<ColumnInfo[]> {
|
||||
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
|
||||
}
|
||||
|
||||
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
|
||||
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
|
||||
}
|
||||
|
||||
export async function listIndexes(connectionId: string, database: string, schema: string, table: string): Promise<IndexInfo[]> {
|
||||
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<IndexInfo[]> {
|
||||
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<ForeignKeyInfo[]> {
|
||||
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<ForeignKeyInfo[]> {
|
||||
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<TriggerInfo[]> {
|
||||
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<TriggerInfo[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog })}`);
|
||||
}
|
||||
|
||||
export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise<SchemaDiffPreparation> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
CatalogInfo,
|
||||
TableInfo,
|
||||
ObjectInfo,
|
||||
CompletionAssistantRequest,
|
||||
@@ -593,6 +594,14 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
|
||||
return invoke("list_databases", { connectionId });
|
||||
}
|
||||
|
||||
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
|
||||
return invoke("list_doris_catalogs", { connectionId });
|
||||
}
|
||||
|
||||
export async function listDorisCatalogDatabases(connectionId: string, catalog: string): Promise<DatabaseInfo[]> {
|
||||
return invoke("list_doris_catalog_databases", { connectionId, catalog });
|
||||
}
|
||||
|
||||
export async function listSqlServerLinkedServers(connectionId: string): Promise<LinkedServerInfo[]> {
|
||||
return invoke("list_sqlserver_linked_servers", { connectionId });
|
||||
}
|
||||
@@ -621,16 +630,16 @@ export async function deleteSchemaCachePrefix(prefix: string): Promise<void> {
|
||||
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<TableInfo[]> {
|
||||
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<TableInfo[]> {
|
||||
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<string | null> {
|
||||
return invoke("get_table_comment", { connectionId, database, schema, table });
|
||||
export async function getTableComment(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<string | null> {
|
||||
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<ObjectInfo[]> {
|
||||
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<ObjectInfo[]> {
|
||||
return invoke("list_objects", { connectionId, database, schema, objectTypes, filter, limit, offset, catalog });
|
||||
}
|
||||
|
||||
export async function listObjectStatistics(connectionId: string, database: string, schema: string): Promise<ObjectStatistics[]> {
|
||||
@@ -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<ColumnInfo[]> {
|
||||
return invoke("get_columns", { connectionId, database, schema, table });
|
||||
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ColumnInfo[]> {
|
||||
return invoke("get_columns", { connectionId, database, schema, table, catalog });
|
||||
}
|
||||
|
||||
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
|
||||
@@ -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<IndexInfo[]> {
|
||||
return invoke("list_indexes", { connectionId, database, schema, table });
|
||||
export async function listIndexes(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<IndexInfo[]> {
|
||||
return invoke("list_indexes", { connectionId, database, schema, table, catalog });
|
||||
}
|
||||
|
||||
export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string): Promise<ForeignKeyInfo[]> {
|
||||
return invoke("list_foreign_keys", { connectionId, database, schema, table });
|
||||
export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ForeignKeyInfo[]> {
|
||||
return invoke("list_foreign_keys", { connectionId, database, schema, table, catalog });
|
||||
}
|
||||
|
||||
export async function listTriggers(connectionId: string, database: string, schema: string, table: string): Promise<TriggerInfo[]> {
|
||||
return invoke("list_triggers", { connectionId, database, schema, table });
|
||||
export async function listTriggers(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<TriggerInfo[]> {
|
||||
return invoke("list_triggers", { connectionId, database, schema, table, catalog });
|
||||
}
|
||||
|
||||
export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind): Promise<string> {
|
||||
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<string> {
|
||||
return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog });
|
||||
}
|
||||
|
||||
export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise<SchemaDiffPreparation> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ConnectionConfig, "db_type" | "driver_profile"> | 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);
|
||||
}
|
||||
|
||||
@@ -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<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType">): MetadataScopeInput {
|
||||
export function tableMetadataScope(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): MetadataScopeInput {
|
||||
return {
|
||||
kind: "table-metadata",
|
||||
connectionId: request.connectionId,
|
||||
@@ -54,10 +56,11 @@ export function tableMetadataScope(request: Pick<TableMetadataRequest, "connecti
|
||||
tableName: request.tableName,
|
||||
tableType: request.tableType,
|
||||
driverProfile: request.driverProfile || request.databaseType,
|
||||
extra: request.catalog ? { catalog: request.catalog } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType">): TableMetadataLoadResult | undefined {
|
||||
export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): 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,
|
||||
|
||||
@@ -33,6 +33,7 @@ const pinnableTypes: Set<TreeNodeType> = new Set([
|
||||
"linked-server",
|
||||
"linked-server-catalog",
|
||||
"linked-server-schema",
|
||||
"doris-catalog",
|
||||
"schema",
|
||||
"table",
|
||||
"view",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">): string {
|
||||
const { databaseType, schema, tableName } = options;
|
||||
export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName" | "catalog">): 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}.`)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
@@ -162,6 +164,8 @@ pub struct DataGridColumnDistinctValuesSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
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<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
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()],
|
||||
|
||||
@@ -90,6 +90,19 @@ fn get_opt_str(row: &mysql_async::Row, name: &str) -> Option<String> {
|
||||
.or_else(|| row_get::<Vec<u8>, _>(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<String> {
|
||||
get_opt_str(row, name)
|
||||
.or_else(|| row_get::<NaiveDateTime, _>(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
|
||||
// (`<catalog>.<database>.<table>`), which the engines accept directly without
|
||||
// needing to `SWITCH` the session catalog.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a 2-part qualified identifier `` `<catalog>`.`<database>` ``.
|
||||
fn doris_catalog_database_ref(catalog: &str, database: &str) -> String {
|
||||
format!("{}.{}", quote_identifier(catalog), quote_identifier(database))
|
||||
}
|
||||
|
||||
/// Build a 3-part qualified identifier `` `<catalog>`.`<database>`.`<table>` ``.
|
||||
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<Vec<crate::db::CatalogInfo>, 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<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||||
let catalogs: Vec<crate::db::CatalogInfo> = 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<crate::db::CatalogInfo>) -> Vec<crate::db::CatalogInfo> {
|
||||
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 <catalog>` → databases in the given catalog.
|
||||
pub async fn list_databases_show_from(pool: &MySqlPool, catalog: &str) -> Result<Vec<DatabaseInfo>, 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<mysql_async::Row> = 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 <catalog>.<database>` → 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<Vec<TableInfo>, 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<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||||
let mut tables: Vec<TableInfo> = 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 <catalog>.<database>.<table>` → 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<Vec<ColumnInfo>, 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<mysql_async::Row> = 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 <catalog>.<database>.<table>` → DDL for an external
|
||||
/// catalog table.
|
||||
pub async fn show_create_table_ddl_from(
|
||||
pool: &MySqlPool,
|
||||
catalog: &str,
|
||||
database: &str,
|
||||
table: &str,
|
||||
) -> Result<String, String> {
|
||||
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<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||||
let row = rows.first().ok_or("DDL not found")?;
|
||||
row.get_opt::<String, usize>(1)
|
||||
.and_then(|result| result.ok())
|
||||
.or_else(|| {
|
||||
row.get_opt::<Vec<u8>, 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<Vec<IndexInfo>, 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<IndexInfo>, parsed: Vec<IndexInfo>) {
|
||||
let mut seen_names: HashSet<String> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (`<catalog>.<database>.<table>`), 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<Vec<db::CatalogInfo>, 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 <catalog>` → 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<Vec<db::DatabaseInfo>, 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 <catalog>` 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 <catalog>.<database>` → 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<usize>,
|
||||
offset: Option<usize>,
|
||||
object_types: Option<&[String]>,
|
||||
) -> Result<Vec<db::TableInfo>, 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 <catalog>.<db>.<table>`.
|
||||
pub async fn get_doris_catalog_columns_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
catalog: &str,
|
||||
database: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<db::ColumnInfo>, 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 <catalog>.<db>.<table>`.
|
||||
pub async fn get_doris_catalog_table_ddl_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
catalog: &str,
|
||||
database: &str,
|
||||
table: &str,
|
||||
) -> Result<String, 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 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<Vec<db::IndexInfo>, 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<Option<String>, 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<Vec<db::ForeignKeyInfo>, 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<Vec<db::TriggerInfo>, 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<String> {
|
||||
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<Vec<db::DatabaseInfo>, 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"))
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -71,6 +71,29 @@ pub fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<
|
||||
quote_table_identifier(database_type, table_name)
|
||||
}
|
||||
|
||||
/// Like `qualified_table_name`, but prefixes a Doris/StarRocks external
|
||||
/// catalog (`<catalog>.<schema>.<table>`) 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<DatabaseType>,
|
||||
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<DatabaseType>, name: &str) -> String {
|
||||
match database_type {
|
||||
Some(DatabaseType::Iotdb) => name.to_string(),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;"
|
||||
);
|
||||
|
||||
@@ -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<String>,
|
||||
pub table_name: String,
|
||||
/// Doris / StarRocks multi-catalog: when set to a non-`internal` catalog,
|
||||
/// the FROM clause is prefixed with the catalog
|
||||
/// (`<catalog>.<schema>.<table>`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_type: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -19,6 +19,34 @@ pub struct LinkedServerInfo {
|
||||
pub data_source: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<WebState>, connection_id: &str, catalog: Option<&str>) -> Option<String> {
|
||||
dbx_core::schema::resolve_external_doris_catalog(&state.app, connection_id, catalog).await
|
||||
}
|
||||
|
||||
pub async fn list_doris_catalogs(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
@@ -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::<Vec<_>>()
|
||||
});
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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::<Vec<_>>()
|
||||
} 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 主应用之外,由你决定安装哪些驱动文件。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String> {
|
||||
dbx_core::schema::resolve_external_doris_catalog(state, connection_id, catalog).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_databases(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
@@ -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<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<db::CatalogInfo>, String> {
|
||||
dbx_core::schema::list_doris_catalogs_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_doris_catalog_databases(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
catalog: String,
|
||||
) -> Result<Vec<db::DatabaseInfo>, 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<AppState>>,
|
||||
@@ -107,7 +131,21 @@ pub async fn list_tables(
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
object_types: Option<Vec<String>>,
|
||||
catalog: Option<String>,
|
||||
) -> Result<Vec<db::TableInfo>, 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<String>,
|
||||
) -> Result<Option<String>, 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<usize>,
|
||||
offset: Option<usize>,
|
||||
object_types: Option<Vec<String>>,
|
||||
catalog: Option<String>,
|
||||
) -> Result<Vec<db::ObjectInfo>, 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<String>,
|
||||
) -> Result<Vec<db::ColumnInfo>, 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<String>,
|
||||
) -> Result<Vec<db::IndexInfo>, 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<String>,
|
||||
) -> Result<Vec<db::ForeignKeyInfo>, 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<String>,
|
||||
) -> Result<Vec<db::TriggerInfo>, 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<db::ObjectSourceKind>,
|
||||
catalog: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user