feat(sqlserver): support linked server browsing

This commit is contained in:
t8y2 2026-06-18 13:17:44 +08:00
parent fc8ae4b69f
commit 9e75f62dbf
20 changed files with 617 additions and 32 deletions

View File

@ -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<TreeNodeType> = 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<TreeNodeType> = new Set(["connection-group", "database", "schema", "table", "view", "materialized_view", "redis-db", "mongo-db", "mongo-collection", "elasticsearch-index"]);
const pinnableTypes: Set<TreeNodeType> = 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());

View File

@ -1217,6 +1217,7 @@ export default {
tree: {
savedSql: "SQL Library",
userAdmin: "Users & Privileges",
linkedServers: "Linked Servers",
defaultDatabase: "Default DB",
columns: "Columns",
indexes: "Indexes",

View File

@ -1216,6 +1216,7 @@ export default {
tree: {
savedSql: "SQL 库",
userAdmin: "用户与权限",
linkedServers: "链接服务器",
defaultDatabase: "默认库",
columns: "字段",
indexes: "索引",

View File

@ -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");

View File

@ -1,6 +1,7 @@
import type {
ConnectionConfig,
DatabaseInfo,
LinkedServerInfo,
TableInfo,
ObjectInfo,
ObjectSource,
@ -392,6 +393,22 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
}
export async function listSqlServerLinkedServers(connectionId: string): Promise<LinkedServerInfo[]> {
return get(`/api/schema/sqlserver/linked-servers?${qs({ connection_id: connectionId })}`);
}
export async function listSqlServerLinkedServerCatalogs(connectionId: string, server: string): Promise<DatabaseInfo[]> {
return get(`/api/schema/sqlserver/linked-server-catalogs?${qs({ connection_id: connectionId, server })}`);
}
export async function listSqlServerLinkedServerSchemas(connectionId: string, server: string, catalog: string): Promise<string[]> {
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<TableInfo[]> {
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<void> {
return post("/api/schema/cache", { cacheKey, payload });
}

View File

@ -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<TreeNode, "linkedServer" | "linkedCatalog" | "linkedSchema" | "schema">): boolean {
return !!node.linkedServer || !!node.linkedCatalog || !!node.linkedSchema || !!parseSqlServerLinkedSchema(node.schema);
}
export function sqlServerLinkedTableSchema(node: Pick<TreeNode, "linkedServer" | "linkedCatalog" | "linkedSchema">): 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(".");
}

View File

@ -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<BuildTableSelectSqlOptions, "da
return quoteTableIdentifier(databaseType, tableName);
}
if (isSchemaAware(databaseType) && !usesDatabaseObjectTreeMode(databaseType) && schema) {
if (databaseType === "sqlserver") {
const linked = parseSqlServerLinkedSchema(schema);
if (linked) return sqlServerLinkedTableName(linked, tableName);
}
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, tableName)}`;
}
return quoteTableIdentifier(databaseType, tableName);

View File

@ -3,6 +3,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
DatabaseInfo,
LinkedServerInfo,
TableInfo,
ObjectInfo,
ObjectSource,
@ -474,6 +475,22 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return invoke("list_databases", { connectionId });
}
export async function listSqlServerLinkedServers(connectionId: string): Promise<LinkedServerInfo[]> {
return invoke("list_sqlserver_linked_servers", { connectionId });
}
export async function listSqlServerLinkedServerCatalogs(connectionId: string, server: string): Promise<DatabaseInfo[]> {
return invoke("list_sqlserver_linked_server_catalogs", { connectionId, server });
}
export async function listSqlServerLinkedServerSchemas(connectionId: string, server: string, catalog: string): Promise<string[]> {
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<TableInfo[]> {
return invoke("list_sqlserver_linked_server_tables", { connectionId, server, catalog, schema, filter, limit, offset });
}
export async function saveSchemaCache(cacheKey: string, payload: unknown): Promise<void> {
return invoke("save_schema_cache", { cacheKey, payload });
}

View File

@ -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<unknown>(cacheKey).catch(() => null);
const decoded = decodeSchemaTreeCache<TreeNode[]>(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,

View File

@ -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;

View File

@ -723,21 +723,20 @@ async fn linked_server_table_rows(
schema: Option<&str>,
table_name: Option<&str>,
) -> Result<Vec<LinkedServerTableRow>, 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") {

View File

@ -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<Vec<db::LinkedServerInfo>, 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<Vec<db::DatabaseInfo>, 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<Vec<String>, 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<usize>,
offset: Option<usize>,
) -> Result<Vec<db::TableInfo>, 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<Vec<db::DatabaseInfo>, 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<Vec<db::IndexInfo>, 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<Vec<db::ForeignKeyInfo>, 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<Vec<db::TriggerInfo>, 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<db::ObjectSourceKind>,
) -> Result<String, String> {
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?;

View File

@ -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::*;

View File

@ -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<SqlServerLinkedSchemaRef> {
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::<Vec<_>>()
.join(".")
}
fn decode_linked_schema_part(value: &str) -> Option<String> {
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
}
pub fn qualified_table_name(database_type: Option<DatabaseType>, 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<DatabaseType>, 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()),

View File

@ -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]

View File

@ -5,6 +5,14 @@ pub struct DatabaseInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkedServerInfo {
pub name: String,
pub product: Option<String>,
pub provider: Option<String>,
pub data_source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableInfo {
pub name: String,

View File

@ -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))

View File

@ -13,6 +13,8 @@ pub struct SchemaQuery {
pub database: Option<String>,
pub schema: Option<String>,
pub table: Option<String>,
pub server: Option<String>,
pub catalog: Option<String>,
pub filter: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
@ -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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<Vec<String>>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,

View File

@ -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<AppState>>,
connection_id: String,
) -> Result<Vec<db::LinkedServerInfo>, 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<AppState>>,
connection_id: String,
server: String,
) -> Result<Vec<db::DatabaseInfo>, 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<AppState>>,
connection_id: String,
server: String,
catalog: String,
) -> Result<Vec<String>, 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<AppState>>,
connection_id: String,
server: String,
catalog: String,
schema: String,
filter: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<db::TableInfo>, 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<AppState>>,

View File

@ -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,