From 9e75f62dbfa102a7e2d71a19853e8565499f1746 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 18 Jun 2026 13:17:44 +0800 Subject: [PATCH] feat(sqlserver): support linked server browsing --- .../src/components/sidebar/TreeItem.vue | 34 ++- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/lib/api.ts | 4 + apps/desktop/src/lib/http.ts | 17 ++ .../desktop/src/lib/sqlServerLinkedServers.ts | 41 ++++ apps/desktop/src/lib/tableSelectSql.ts | 5 + apps/desktop/src/lib/tauri.ts | 17 ++ apps/desktop/src/stores/connectionStore.ts | 199 +++++++++++++++++- apps/desktop/src/types/database.ts | 14 ++ crates/dbx-core/src/db/sqlserver.rs | 37 ++-- crates/dbx-core/src/schema.rs | 106 ++++++++++ crates/dbx-core/src/sql_dialect.rs | 2 +- .../dbx-core/src/sql_dialect/identifiers.rs | 39 ++++ crates/dbx-core/src/sql_dialect/tests.rs | 8 + crates/dbx-core/src/types.rs | 8 + crates/dbx-web/src/main.rs | 4 + crates/dbx-web/src/routes/schema.rs | 57 +++++ src-tauri/src/commands/schema.rs | 51 +++++ src-tauri/src/lib.rs | 4 + 20 files changed, 617 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/lib/sqlServerLinkedServers.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 9fdb18f86..7eccd7119 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -32,6 +32,7 @@ import { Upload, FileCode, Network, + Server, PencilRuler, Search, FolderInput, @@ -111,6 +112,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import { hasEnabledTransportLayers } from "@/lib/connectionTransport"; import { formatShortcut } from "@/lib/shortcutRegistry"; import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSqlHistory"; +import { isSqlServerLinkedNode } from "@/lib/sqlServerLinkedServers"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue"; import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue"; @@ -195,6 +197,14 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" }; case "database": return { icon: Database, colorClass: "text-yellow-500" }; + case "linked-server-root": + return { icon: Network, colorClass: "text-blue-500" }; + case "linked-server": + return { icon: Server, colorClass: "text-blue-400" }; + case "linked-server-catalog": + return { icon: Database, colorClass: "text-yellow-500" }; + case "linked-server-schema": + return { icon: FolderOpen, colorClass: "text-sky-400" }; case "schema": return { icon: FolderOpen, colorClass: "text-sky-400" }; case "table": @@ -273,7 +283,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { } const groupTypes: Set = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions"]); -const pinnableTypes: Set = new Set(["connection-group", "database", "schema", "table", "view", "materialized_view", "redis-db", "mongo-db", "mongo-collection", "elasticsearch-index"]); +const pinnableTypes: Set = new Set(["connection-group", "database", "linked-server", "linked-server-catalog", "linked-server-schema", "schema", "table", "view", "materialized_view", "redis-db", "mongo-db", "mongo-collection", "elasticsearch-index"]); function isGroupLabel(node: TreeNode): boolean { return groupTypes.has(node.type); @@ -283,6 +293,7 @@ function displayLabel(node: TreeNode): string { if (node.type === "load-more") return t(node.label); if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); if (node.type === "user-admin") return t(node.label); + if (node.type === "linked-server-root") return t(node.label); if (node.label === "tree.defaultDatabase") return t(node.label); return isGroupLabel(node) ? t(node.label) : node.label; } @@ -392,6 +403,14 @@ async function toggle() { } } 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) { + await connectionStore.loadSqlServerLinkedServers(node.connectionId); + } else if (node.type === "linked-server" && node.connectionId) { + await connectionStore.loadSqlServerLinkedServerCatalogs(node); + } else if (node.type === "linked-server-catalog" && node.connectionId) { + await connectionStore.loadSqlServerLinkedServerSchemas(node); + } 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); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { @@ -1447,6 +1466,7 @@ function requestDropTableChildObject() { } function canDropTreeNode(node: TreeNode): boolean { + if (isSqlServerLinkedNode(node)) return false; if (node.type === "table") return !!node.connectionId && !!node.database; if (node.type === "view" || node.type === "materialized_view" || node.type === "procedure" || node.type === "function") { return !!node.connectionId && !!node.database && !!dropObjectSqlOptionsForNode(node); @@ -1684,7 +1704,7 @@ async function confirmBatchDrop() { } } -const isTableNotView = computed(() => props.node.type === "table"); +const isTableNotView = computed(() => props.node.type === "table" && !isSqlServerLinkedNode(props.node)); const supportsTruncate = computed(() => { return supportsTableTruncate(currentDatabaseType()); @@ -1692,7 +1712,7 @@ const supportsTruncate = computed(() => { const canCreateTable = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return (props.node.type === "database" || props.node.type === "schema" || props.node.type === "group-tables") && !!props.node.database && supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config)); + return (props.node.type === "database" || props.node.type === "schema" || props.node.type === "group-tables") && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config)); }); const canCreateDatabase = computed(() => { @@ -1712,7 +1732,7 @@ const canSetCreateDatabaseCharset = computed(() => { const canDropDatabase = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "database" && supportsDatabaseCreation(config?.db_type); + return props.node.type === "database" && !isSqlServerLinkedNode(props.node) && supportsDatabaseCreation(config?.db_type); }); const canCreateSchema = computed(() => { @@ -1722,7 +1742,7 @@ const canCreateSchema = computed(() => { const canDropSchema = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "schema" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); + return props.node.type === "schema" && !isSqlServerLinkedNode(props.node) && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); }); function tableAdminSqlOptions(): TableAdminSqlOptions { @@ -2598,10 +2618,10 @@ const canOpenObjectBrowser = computed(() => { return supportsObjectBrowserTreeNode(rawDatabaseType(), props.node.type); }); const canOpenTableImport = computed(() => { - return props.node.type === "table" && !!props.node.database && supportsTableImport(currentDatabaseType()); + return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableImport(currentDatabaseType()); }); const canOpenStructureEditor = computed(() => { - return props.node.type === "table" && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType()); + return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType()); }); const canOpenFieldLineage = computed(() => { return props.node.type === "column" && !!props.node.database && !!props.node.tableName && supportsFieldLineage(currentDatabaseType()); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 295f7f1a8..345c91fa4 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1217,6 +1217,7 @@ export default { tree: { savedSql: "SQL Library", userAdmin: "Users & Privileges", + linkedServers: "Linked Servers", defaultDatabase: "Default DB", columns: "Columns", indexes: "Indexes", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 755a81a42..6e385afc4 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1216,6 +1216,7 @@ export default { tree: { savedSql: "SQL 库", userAdmin: "用户与权限", + linkedServers: "链接服务器", defaultDatabase: "默认库", columns: "字段", indexes: "索引", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index e2d034cbc..3c708ee39 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -102,6 +102,10 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory"); // Schema export const listDatabases = forward("listDatabases"); +export const listSqlServerLinkedServers = forward("listSqlServerLinkedServers"); +export const listSqlServerLinkedServerCatalogs = forward("listSqlServerLinkedServerCatalogs"); +export const listSqlServerLinkedServerSchemas = forward("listSqlServerLinkedServerSchemas"); +export const listSqlServerLinkedServerTables = forward("listSqlServerLinkedServerTables"); export const saveSchemaCache = forward("saveSchemaCache"); export const loadSchemaCache = forward("loadSchemaCache"); export const deleteSchemaCachePrefix = forward("deleteSchemaCachePrefix"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 21bcbaf2c..faef801b2 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1,6 +1,7 @@ import type { ConnectionConfig, DatabaseInfo, + LinkedServerInfo, TableInfo, ObjectInfo, ObjectSource, @@ -392,6 +393,22 @@ export async function listDatabases(connectionId: string): Promise { + return get(`/api/schema/sqlserver/linked-servers?${qs({ connection_id: connectionId })}`); +} + +export async function listSqlServerLinkedServerCatalogs(connectionId: string, server: string): Promise { + return get(`/api/schema/sqlserver/linked-server-catalogs?${qs({ connection_id: connectionId, server })}`); +} + +export async function listSqlServerLinkedServerSchemas(connectionId: string, server: string, catalog: string): Promise { + return get(`/api/schema/sqlserver/linked-server-schemas?${qs({ connection_id: connectionId, server, catalog })}`); +} + +export async function listSqlServerLinkedServerTables(connectionId: string, server: string, catalog: string, schema: string, filter?: string, limit?: number, offset?: number): Promise { + return get(`/api/schema/sqlserver/linked-server-tables?${qs({ connection_id: connectionId, server, catalog, schema, filter, limit, offset })}`); +} + export async function saveSchemaCache(cacheKey: string, payload: unknown): Promise { return post("/api/schema/cache", { cacheKey, payload }); } diff --git a/apps/desktop/src/lib/sqlServerLinkedServers.ts b/apps/desktop/src/lib/sqlServerLinkedServers.ts new file mode 100644 index 000000000..8e97a6185 --- /dev/null +++ b/apps/desktop/src/lib/sqlServerLinkedServers.ts @@ -0,0 +1,41 @@ +import type { TreeNode } from "@/types/database"; + +const SQLSERVER_LINKED_SCHEMA_PREFIX = "__dbx_sqlserver_linked__:"; + +export interface SqlServerLinkedSchemaRef { + server: string; + catalog: string; + schema: string; +} + +export function encodeSqlServerLinkedSchema(ref: SqlServerLinkedSchemaRef): string { + return `${SQLSERVER_LINKED_SCHEMA_PREFIX}${[ref.server, ref.catalog, ref.schema].map(encodeURIComponent).join("|")}`; +} + +export function parseSqlServerLinkedSchema(schema: string | undefined): SqlServerLinkedSchemaRef | null { + if (!schema?.startsWith(SQLSERVER_LINKED_SCHEMA_PREFIX)) return null; + const parts = schema.slice(SQLSERVER_LINKED_SCHEMA_PREFIX.length).split("|").map(decodeURIComponent); + if (parts.length !== 3 || parts.some((part) => !part.trim())) return null; + return { server: parts[0], catalog: parts[1], schema: parts[2] }; +} + +export function isSqlServerLinkedNode(node: Pick): boolean { + return !!node.linkedServer || !!node.linkedCatalog || !!node.linkedSchema || !!parseSqlServerLinkedSchema(node.schema); +} + +export function sqlServerLinkedTableSchema(node: Pick): string | undefined { + if (!node.linkedServer || !node.linkedCatalog || !node.linkedSchema) return undefined; + return encodeSqlServerLinkedSchema({ + server: node.linkedServer, + catalog: node.linkedCatalog, + schema: node.linkedSchema, + }); +} + +export function quoteSqlServerIdentifier(name: string): string { + return `[${name.replace(/\]/g, "]]")}]`; +} + +export function sqlServerLinkedTableName(ref: SqlServerLinkedSchemaRef, tableName: string): string { + return [ref.server, ref.catalog, ref.schema, tableName].map(quoteSqlServerIdentifier).join("."); +} diff --git a/apps/desktop/src/lib/tableSelectSql.ts b/apps/desktop/src/lib/tableSelectSql.ts index 637168ac8..d624db2b8 100644 --- a/apps/desktop/src/lib/tableSelectSql.ts +++ b/apps/desktop/src/lib/tableSelectSql.ts @@ -1,6 +1,7 @@ import type { DatabaseType } from "../types/database.ts"; import { isSchemaAware, usesDatabaseObjectTreeMode } from "./databaseCapabilities.ts"; import * as api from "./api.ts"; +import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "./sqlServerLinkedServers.ts"; export interface BuildTableSelectSqlOptions { databaseType?: DatabaseType; @@ -42,6 +43,10 @@ export function qualifiedTableName(options: Pick { + return invoke("list_sqlserver_linked_servers", { connectionId }); +} + +export async function listSqlServerLinkedServerCatalogs(connectionId: string, server: string): Promise { + return invoke("list_sqlserver_linked_server_catalogs", { connectionId, server }); +} + +export async function listSqlServerLinkedServerSchemas(connectionId: string, server: string, catalog: string): Promise { + return invoke("list_sqlserver_linked_server_schemas", { connectionId, server, catalog }); +} + +export async function listSqlServerLinkedServerTables(connectionId: string, server: string, catalog: string, schema: string, filter?: string, limit?: number, offset?: number): Promise { + return invoke("list_sqlserver_linked_server_tables", { connectionId, server, catalog, schema, filter, limit, offset }); +} + export async function saveSchemaCache(cacheKey: string, payload: unknown): Promise { return invoke("save_schema_cache", { cacheKey, payload }); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b0f682321..6133dd9a7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -48,6 +48,7 @@ import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds"; import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin"; import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities"; import { useSettingsStore } from "@/stores/settingsStore"; +import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/sqlServerLinkedServers"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; @@ -58,6 +59,46 @@ function sidebarObjectGroupPageSize(): number { } type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip"; +function nodeIdPart(value: string): string { + return encodeURIComponent(value); +} + +function sqlServerLinkedRootId(connectionId: string): string { + return `${connectionId}:__linked_servers`; +} + +function sqlServerLinkedServerId(connectionId: string, server: string): string { + return `${sqlServerLinkedRootId(connectionId)}:${nodeIdPart(server)}`; +} + +function sqlServerLinkedCatalogId(connectionId: string, server: string, catalog: string): string { + return `${sqlServerLinkedServerId(connectionId, server)}:${nodeIdPart(catalog)}`; +} + +function sqlServerLinkedRuntimeDatabase(config?: ConnectionConfig): string { + return config?.database?.trim() || "master"; +} + +function sqlServerLinkedRootNode(connectionId: string, database: string): TreeNode { + return { + id: sqlServerLinkedRootId(connectionId), + label: "tree.linkedServers", + type: "linked-server-root", + connectionId, + database, + isExpanded: false, + children: [], + }; +} + +function ensureSqlServerLinkedRootNode(connectionId: string, children: TreeNode[], config?: ConnectionConfig): TreeNode[] { + if (config?.db_type !== "sqlserver") return children; + if (children.some((child) => child.type === "linked-server-root" || child.id === sqlServerLinkedRootId(connectionId))) { + return children; + } + return [...children, sqlServerLinkedRootNode(connectionId, sqlServerLinkedRuntimeDatabase(config))]; +} + // Temporary storage for DataGrip import payload (used to read Keychain passwords after import) let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string } | null = null; @@ -499,7 +540,10 @@ export const useConnectionStore = defineStore("connection", () => { const payload = await api.loadSchemaCache(cacheKey).catch(() => null); const decoded = decodeSchemaTreeCache(payload); if (!decoded) return { hit: false, isStale: false }; - const normalizedChildren = sortSidebarTreeChildrenForParent(node, normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)), node.connectionId ? getConfig(node.connectionId)?.db_type : undefined); + const config = node.connectionId ? getConfig(node.connectionId) : undefined; + const cachedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)); + const childrenWithLinkedServers = node.type === "connection" && node.connectionId ? ensureSqlServerLinkedRootNode(node.connectionId, cachedChildren, config) : cachedChildren; + const normalizedChildren = sortSidebarTreeChildrenForParent(node, childrenWithLinkedServers, config?.db_type); setChildren(node, node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, normalizedChildren, node) : normalizedChildren); node.isExpanded = true; return { hit: true, isStale: decoded.isStale }; @@ -930,13 +974,29 @@ export const useConnectionStore = defineStore("connection", () => { const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); const effectiveDbType = effectiveDatabaseTypeForConnection(config); - const children = withSavedSqlRoot( - connectionId, - buildDatabaseTreeNodes(connectionId, visibleDatabases, { - includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), - }), - node, - ); + const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { + includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), + }); + if (config?.db_type === "sqlserver") { + const linkedServers = await api.listSqlServerLinkedServers(connectionId).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); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); } @@ -1255,6 +1315,116 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadSqlServerLinkedServers(connectionId: string, options?: LoadTreeOptions) { + const node = findNode(treeNodes.value, sqlServerLinkedRootId(connectionId)); + if (!node) return; + node.isLoading = true; + try { + await ensureConnected(connectionId); + if (useCachedChildren(node, options)) return; + const config = getConfig(connectionId); + const database = sqlServerLinkedRuntimeDatabase(config); + const linkedServers = await api.listSqlServerLinkedServers(connectionId); + setChildren( + node, + linkedServers.map((server) => ({ + id: sqlServerLinkedServerId(connectionId, server.name), + label: server.name, + type: "linked-server" as const, + connectionId, + database, + linkedServer: server.name, + comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null, + isExpanded: false, + children: [], + })), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + async function loadSqlServerLinkedServerCatalogs(node: TreeNode, options?: LoadTreeOptions) { + if (!node.connectionId || !node.linkedServer) return; + const connectionId = node.connectionId; + const server = node.linkedServer; + node.isLoading = true; + try { + await ensureConnected(connectionId); + if (useCachedChildren(node, options)) return; + const catalogs = await api.listSqlServerLinkedServerCatalogs(connectionId, server); + const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(connectionId)); + setChildren( + node, + catalogs + .filter((catalog) => catalog.name.trim()) + .map((catalog) => ({ + id: sqlServerLinkedCatalogId(connectionId, server, catalog.name), + label: catalog.name, + type: "linked-server-catalog" as const, + connectionId, + database, + linkedServer: server, + linkedCatalog: catalog.name, + isExpanded: false, + children: [], + })), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + async function loadSqlServerLinkedServerSchemas(node: TreeNode, options?: LoadTreeOptions) { + if (!node.connectionId || !node.linkedServer || !node.linkedCatalog) return; + node.isLoading = true; + try { + await ensureConnected(node.connectionId); + if (useCachedChildren(node, options)) return; + const schemas = await api.listSqlServerLinkedServerSchemas(node.connectionId, node.linkedServer, node.linkedCatalog); + const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(node.connectionId)); + setChildren( + node, + sortSidebarNames(schemas) + .filter((schema) => schema.trim()) + .map((schema) => { + const encodedSchema = encodeSqlServerLinkedSchema({ + server: node.linkedServer!, + catalog: node.linkedCatalog!, + schema, + }); + return { + id: `${node.connectionId}:${database}:${encodedSchema}`, + label: schema, + type: "linked-server-schema" as const, + connectionId: node.connectionId, + database, + schema: encodedSchema, + linkedServer: node.linkedServer, + linkedCatalog: node.linkedCatalog, + linkedSchema: schema, + isExpanded: false, + children: [], + }; + }), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(node.connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); @@ -1435,7 +1605,7 @@ export const useConnectionStore = defineStore("connection", () => { const config = getConfig(connectionId); const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(config)); - if (node.type === "table") { + if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { if (metadataCapabilities.indexes) { children.push({ id: `${parentId}:__indexes`, @@ -1665,6 +1835,14 @@ export const useConnectionStore = defineStore("connection", () => { } } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); + } else if (node.type === "linked-server-root" && node.connectionId) { + await loadSqlServerLinkedServers(node.connectionId, options); + } else if (node.type === "linked-server" && node.connectionId) { + await loadSqlServerLinkedServerCatalogs(node, options); + } else if (node.type === "linked-server-catalog" && node.connectionId) { + await loadSqlServerLinkedServerSchemas(node, options); + } 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); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { @@ -2786,6 +2964,9 @@ export const useConnectionStore = defineStore("connection", () => { loadMongoCollections, loadSchemas, loadSqlServerDatabaseObjects, + loadSqlServerLinkedServers, + loadSqlServerLinkedServerCatalogs, + loadSqlServerLinkedServerSchemas, loadTables, loadObjectGroupChildren, loadMoreObjectGroupChildren, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 8da5c6dd7..207a45d07 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -207,6 +207,13 @@ export interface DatabaseInfo { name: string; } +export interface LinkedServerInfo { + name: string; + product?: string | null; + provider?: string | null; + data_source?: string | null; +} + export interface TableInfo { name: string; table_type: string; @@ -391,6 +398,10 @@ export type TreeNodeType = | "connection" | "connection-group" | "database" + | "linked-server-root" + | "linked-server" + | "linked-server-catalog" + | "linked-server-schema" | "schema" | "table" | "view" @@ -452,6 +463,9 @@ export interface TreeNode { pinned?: boolean; connectionId?: string; database?: string; + linkedServer?: string; + linkedCatalog?: string; + linkedSchema?: string; mqTenant?: string; schema?: string; tableName?: string; diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 58c9a453c..5a581e336 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -723,21 +723,20 @@ async fn linked_server_table_rows( schema: Option<&str>, table_name: Option<&str>, ) -> Result, String> { - let schema_param = schema.unwrap_or(""); - let table_name_param = table_name.unwrap_or(""); - let stream = client - .query( - "EXEC sp_tables_ex \ - @table_server = @P1, \ - @table_name = @P2, \ - @table_schema = @P3, \ - @table_catalog = @P4, \ - @table_type = '''TABLE'',''VIEW''', \ - @fUsePattern = 0", - &[&server, &table_name_param, &schema_param, &catalog], - ) - .await - .map_err(|e| e.to_string())?; + let sql = format!( + "EXEC sp_tables_ex \ + @table_server = {}, \ + @table_name = {}, \ + @table_schema = {}, \ + @table_catalog = {}, \ + @table_type = '''TABLE'',''VIEW''', \ + @fUsePattern = 0", + sqlserver_nstring_literal(server), + sqlserver_optional_nstring_literal(table_name), + sqlserver_optional_nstring_literal(schema), + sqlserver_nstring_literal(catalog), + ); + let stream = client.query(sql.as_str(), &[]).await.map_err(|e| e.to_string())?; let rows = stream.into_first_result().await.map_err(|e| e.to_string())?; Ok(rows .iter() @@ -756,6 +755,14 @@ async fn linked_server_table_rows( .collect()) } +fn sqlserver_optional_nstring_literal(value: Option<&str>) -> String { + value.filter(|value| !value.trim().is_empty()).map(sqlserver_nstring_literal).unwrap_or_else(|| "NULL".to_string()) +} + +fn sqlserver_nstring_literal(value: &str) -> String { + format!("N'{}'", value.replace('\'', "''")) +} + fn normalize_linked_server_table_type(value: Option<&str>) -> String { let upper = value.unwrap_or("TABLE").to_ascii_uppercase(); if upper.contains("VIEW") { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index d3ee2ee2e..e18a9a6bb 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -272,6 +272,68 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await } +pub async fn list_sqlserver_linked_servers_core( + state: &AppState, + connection_id: &str, +) -> Result, String> { + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::list_linked_servers(&mut client).await; + } + Ok(vec![]) +} + +pub async fn list_sqlserver_linked_server_catalogs_core( + state: &AppState, + connection_id: &str, + server: &str, +) -> Result, String> { + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::list_linked_server_catalogs(&mut client, server).await; + } + Ok(vec![]) +} + +pub async fn list_sqlserver_linked_server_schemas_core( + state: &AppState, + connection_id: &str, + server: &str, + catalog: &str, +) -> Result, String> { + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::list_linked_server_schemas(&mut client, server, catalog).await; + } + Ok(vec![]) +} + +pub async fn list_sqlserver_linked_server_tables_core( + state: &AppState, + connection_id: &str, + server: &str, + catalog: &str, + schema: &str, + filter: Option<&str>, + limit: Option, + offset: Option, +) -> Result, String> { + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::list_linked_server_tables(&mut client, server, catalog, schema, filter, limit, offset) + .await; + } + Ok(vec![]) +} + async fn list_databases_once(state: &AppState, connection_id: &str) -> Result, String> { log::info!("[list_databases] connection_id={connection_id}"); { @@ -491,6 +553,23 @@ async fn list_tables_once( .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types)); } + if let Some(linked) = crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema) { + if let Some(client) = extract_pool!(&connections, &pool_key, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::list_linked_server_tables( + &mut client, + &linked.server, + &linked.catalog, + &linked.schema, + filter, + None, + None, + ) + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types)); + } + } if object_types.is_some() { if let Some(client) = extract_pool!(&connections, &pool_key, SqlServer) { drop(connections); @@ -1277,6 +1356,21 @@ pub async fn get_columns_core( drop(connections); return db::influxdb_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); } + if let Some(linked) = crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema) { + if let Some(client) = extract_pool!(&connections, &pool_key, SqlServer) { + drop(connections); + let mut client = client.lock().await; + return db::sqlserver::get_linked_server_columns( + &mut client, + &linked.server, + &linked.catalog, + &linked.schema, + table, + ) + .await + .map(deduplicate_column_infos); + } + } try_sqlserver!(connections, &pool_key, get_columns, schema, table); if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { let fallback_config = db_config.clone(); @@ -1406,6 +1500,9 @@ pub async fn list_indexes_core( schema: &str, table: &str, ) -> Result, String> { + if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { + return Ok(vec![]); + } let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; @@ -1442,6 +1539,9 @@ pub async fn list_foreign_keys_core( schema: &str, table: &str, ) -> Result, String> { + if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { + return Ok(vec![]); + } let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; { @@ -1471,6 +1571,9 @@ pub async fn list_triggers_core( schema: &str, table: &str, ) -> Result, String> { + if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { + return Ok(vec![]); + } let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; { @@ -1566,6 +1669,9 @@ pub async fn get_table_ddl_core( table: &str, object_type: Option, ) -> Result { + if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { + return Err("DDL is not supported for SQL Server linked server tables".to_string()); + } if matches!(object_type, Some(db::ObjectSourceKind::View)) { let source = get_object_source_core(state, connection_id, database, schema, table, db::ObjectSourceKind::View).await?; diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index 4512cac75..cff869326 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -8,6 +8,6 @@ mod tests; pub use capabilities::{is_schema_aware, uses_fetch_first}; pub use identifiers::{normalize_where_input, qualified_table_name, quote_table_identifier}; -pub(crate) use identifiers::{qualified_transfer_table, quote_transfer_identifier}; +pub(crate) use identifiers::{parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_transfer_identifier}; pub use table_select::{build_count_table_sql, build_table_data_select_sql, build_table_select_sql}; pub use types::*; diff --git a/crates/dbx-core/src/sql_dialect/identifiers.rs b/crates/dbx-core/src/sql_dialect/identifiers.rs index e1f071438..a715dc2a7 100644 --- a/crates/dbx-core/src/sql_dialect/identifiers.rs +++ b/crates/dbx-core/src/sql_dialect/identifiers.rs @@ -1,7 +1,41 @@ use crate::models::connection::DatabaseType; +use percent_encoding::percent_decode_str; use super::capabilities::{is_schema_aware, is_simple_informix_identifier}; +pub const SQLSERVER_LINKED_SCHEMA_PREFIX: &str = "__dbx_sqlserver_linked__:"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SqlServerLinkedSchemaRef { + pub server: String, + pub catalog: String, + pub schema: String, +} + +pub fn parse_sqlserver_linked_schema_ref(schema: &str) -> Option { + let payload = schema.strip_prefix(SQLSERVER_LINKED_SCHEMA_PREFIX)?; + let mut parts = payload.split('|'); + let server = decode_linked_schema_part(parts.next()?)?; + let catalog = decode_linked_schema_part(parts.next()?)?; + let schema = decode_linked_schema_part(parts.next()?)?; + if parts.next().is_some() || server.trim().is_empty() || catalog.trim().is_empty() || schema.trim().is_empty() { + return None; + } + Some(SqlServerLinkedSchemaRef { server, catalog, schema }) +} + +pub fn sqlserver_linked_table_name(linked: &SqlServerLinkedSchemaRef, table_name: &str) -> String { + [linked.server.as_str(), linked.catalog.as_str(), linked.schema.as_str(), table_name] + .into_iter() + .map(|part| quote_table_identifier(Some(DatabaseType::SqlServer), part)) + .collect::>() + .join(".") +} + +fn decode_linked_schema_part(value: &str) -> Option { + percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned()) +} + pub fn qualified_table_name(database_type: Option, schema: Option<&str>, table_name: &str) -> String { if database_type == Some(DatabaseType::Iotdb) { let table_name = quote_table_identifier(database_type, table_name); @@ -18,6 +52,11 @@ pub fn qualified_table_name(database_type: Option, schema: Option< && database_type != Some(DatabaseType::Jdbc) && schema.is_some_and(|schema| !schema.trim().is_empty()) { + if database_type == Some(DatabaseType::SqlServer) { + if let Some(linked) = schema.and_then(parse_sqlserver_linked_schema_ref) { + return sqlserver_linked_table_name(&linked, table_name); + } + } return format!( "{}.{}", quote_table_identifier(database_type, schema.unwrap()), diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 2b7f4bc4a..430836dee 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -42,6 +42,14 @@ fn qualifies_schema_only_for_schema_aware_databases() { qualified_table_name(Some(DatabaseType::Iotdb), Some("root.test"), "root.test.device2"), "root.test.device2" ); + assert_eq!( + qualified_table_name( + Some(DatabaseType::SqlServer), + Some("__dbx_sqlserver_linked__:ERP%5D01|Finance%20DB|dbo"), + "Orders]2026" + ), + "[ERP]]01].[Finance DB].[dbo].[Orders]]2026]" + ); } #[test] diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index ed41247ee..9704877e4 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -5,6 +5,14 @@ pub struct DatabaseInfo { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkedServerInfo { + pub name: String, + pub product: Option, + pub provider: Option, + pub data_source: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableInfo { pub name: String, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 85a00777d..8b172590a 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -193,6 +193,10 @@ async fn main() { .route("/agents/progress/{operationId}", get(routes::agents::agent_progress)) // Schema .route("/schema/databases", get(routes::schema::list_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)) + .route("/schema/sqlserver/linked-server-tables", get(routes::schema::list_sqlserver_linked_server_tables)) .route("/schema/schemas", get(routes::schema::list_schemas)) .route("/schema/tables", get(routes::schema::list_tables)) .route("/schema/objects", get(routes::schema::list_objects)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index a9a96d917..0df039f63 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -13,6 +13,8 @@ pub struct SchemaQuery { pub database: Option, pub schema: Option, pub table: Option, + pub server: Option, + pub catalog: Option, pub filter: Option, pub limit: Option, pub offset: Option, @@ -28,6 +30,61 @@ pub async fn list_databases( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +pub async fn list_sqlserver_linked_servers( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let result = + dbx_core::schema::list_sqlserver_linked_servers_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_sqlserver_linked_server_catalogs( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let server = q.server.as_deref().unwrap_or(""); + let result = dbx_core::schema::list_sqlserver_linked_server_catalogs_core(&state.app, &q.connection_id, server) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + +pub async fn list_sqlserver_linked_server_schemas( + State(state): State>, + Query(q): Query, +) -> Result>, AppError> { + let server = q.server.as_deref().unwrap_or(""); + let catalog = q.catalog.as_deref().unwrap_or(""); + let result = + dbx_core::schema::list_sqlserver_linked_server_schemas_core(&state.app, &q.connection_id, server, catalog) + .await + .map_err(AppError)?; + Ok(Json(result)) +} + +pub async fn list_sqlserver_linked_server_tables( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let server = q.server.as_deref().unwrap_or(""); + let catalog = q.catalog.as_deref().unwrap_or(""); + let schema = q.schema.as_deref().unwrap_or(""); + let result = dbx_core::schema::list_sqlserver_linked_server_tables_core( + &state.app, + &q.connection_id, + server, + catalog, + schema, + q.filter.as_deref(), + q.limit, + q.offset, + ) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + pub async fn list_schemas( State(state): State>, Query(q): Query, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 50da1448b..85503217b 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -12,6 +12,57 @@ pub async fn list_databases( dbx_core::schema::list_databases_core(&state, &connection_id).await } +#[tauri::command] +pub async fn list_sqlserver_linked_servers( + state: State<'_, Arc>, + connection_id: String, +) -> Result, String> { + dbx_core::schema::list_sqlserver_linked_servers_core(&state, &connection_id).await +} + +#[tauri::command] +pub async fn list_sqlserver_linked_server_catalogs( + state: State<'_, Arc>, + connection_id: String, + server: String, +) -> Result, String> { + dbx_core::schema::list_sqlserver_linked_server_catalogs_core(&state, &connection_id, &server).await +} + +#[tauri::command] +pub async fn list_sqlserver_linked_server_schemas( + state: State<'_, Arc>, + connection_id: String, + server: String, + catalog: String, +) -> Result, String> { + dbx_core::schema::list_sqlserver_linked_server_schemas_core(&state, &connection_id, &server, &catalog).await +} + +#[tauri::command] +pub async fn list_sqlserver_linked_server_tables( + state: State<'_, Arc>, + connection_id: String, + server: String, + catalog: String, + schema: String, + filter: Option, + limit: Option, + offset: Option, +) -> Result, String> { + dbx_core::schema::list_sqlserver_linked_server_tables_core( + &state, + &connection_id, + &server, + &catalog, + &schema, + filter.as_deref(), + limit, + offset, + ) + .await +} + #[tauri::command] pub async fn list_schemas( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 003cdc55f..5574d3951 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -374,6 +374,10 @@ pub fn run() { commands::plugins::install_jdbc_plugin_local, commands::plugins::uninstall_jdbc_plugin, commands::schema::list_databases, + commands::schema::list_sqlserver_linked_servers, + commands::schema::list_sqlserver_linked_server_catalogs, + commands::schema::list_sqlserver_linked_server_schemas, + commands::schema::list_sqlserver_linked_server_tables, commands::schema::list_tables, commands::schema::list_objects, commands::schema::list_completion_objects,