feat(xugu): expose table child metadata

This commit is contained in:
Elias authored and GitHub committed 2026-07-24 16:47:13 +08:00
1 parent 6ddfa8bd24
commit 0362fef273
33 files changed
+1269 -33

No files matched your search

@@ -0,0 +1,162 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function connection(dbType: ConnectionConfig["db_type"]): ConnectionConfig {
return {
id: `${dbType}-1`,
name: dbType,
db_type: dbType,
host: "127.0.0.1",
port: 5138,
username: "SYSDBA",
password: "",
database: "SHOP_DEMO",
} as ConnectionConfig;
}
function findNode(nodes: TreeNode[], id: string): TreeNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const child = node.children && findNode(node.children, id);
if (child) return child;
}
return undefined;
}
async function setup(dbType: ConnectionConfig["db_type"], overrides: Record<string, unknown> = {}) {
const api = {
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listConstraints: vi.fn().mockResolvedValue([]),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listInstalledAgentsLocal: vi.fn().mockResolvedValue([{ db_type: "xugu", installed: true, installed_version: "0.1.23" }]),
listPartitions: vi.fn().mockResolvedValue([]),
listSubpartitions: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
...overrides,
};
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => api);
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const config = connection(dbType);
const tableId = `${config.id}:SHOP_DEMO:SYSDBA:SHOP_ORDERS`;
store.connections = [config];
store.connectedIds.add(config.id);
store.treeNodes = [
{
id: config.id,
label: config.name,
type: "connection",
connectionId: config.id,
isExpanded: true,
children: [
{
id: tableId,
label: "SHOP_ORDERS",
type: "table",
connectionId: config.id,
database: "SHOP_DEMO",
schema: "SYSDBA",
tableName: "SHOP_ORDERS",
isExpanded: false,
children: [],
},
],
},
];
await store.loadTableGroups(config.id, "SHOP_DEMO", "SHOP_ORDERS", "SYSDBA", tableId);
return { api, config, store, tableId };
}
describe("connectionStore Xugu table child metadata", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("adds Xugu-only constraint and partition groups without changing PostgreSQL groups", async () => {
const xugu = await setup("xugu");
const xuguChildren = findNode(xugu.store.treeNodes, xugu.tableId)?.children?.map((node) => node.type);
expect(xuguChildren).toEqual(["group-columns", "group-constraints", "group-fkeys", "group-triggers", "group-indexes", "group-table-partitions", "group-table-subpartitions"]);
vi.resetModules();
setActivePinia(createPinia());
const postgres = await setup("postgres");
const postgresChildren = findNode(postgres.store.treeNodes, postgres.tableId)?.children?.map((node) => node.type);
expect(postgresChildren).toEqual(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]);
});
it("hides Xugu-only groups when the installed Agent predates their metadata methods", async () => {
const { store, tableId } = await setup("xugu", {
listInstalledAgentsLocal: vi.fn().mockResolvedValue([{ db_type: "xugu", installed: true, installed_version: "0.1.22" }]),
});
const childTypes = findNode(store.treeNodes, tableId)?.children?.map((node) => node.type);
expect(childTypes).toEqual(["group-columns", "group-fkeys", "group-triggers", "group-indexes"]);
});
it("routes Xugu child groups to their dedicated metadata calls, including empty results", async () => {
const listConstraints = vi.fn().mockResolvedValue([
{
name: "PK_SHOP_ORDERS",
constraint_type: "PRIMARY KEY",
definition: '"ORDER_ID"',
columns: ["ORDER_ID"],
ref_columns: [],
deferrable: false,
initially_deferred: false,
enabled: true,
valid: true,
},
]);
const { api, config, store, tableId } = await setup("xugu", { listConstraints });
const constraints = findNode(store.treeNodes, `${tableId}:__constraints`)!;
const partitions = findNode(store.treeNodes, `${tableId}:__table-partitions`)!;
const subpartitions = findNode(store.treeNodes, `${tableId}:__table-subpartitions`)!;
await store.loadTreeNodeChildren(constraints);
await store.loadTreeNodeChildren(partitions);
await store.loadTreeNodeChildren(subpartitions);
expect(listConstraints).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined);
expect(api.listPartitions).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined);
expect(api.listSubpartitions).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined);
expect(constraints.children?.[0]).toMatchObject({ type: "constraint", label: "PK_SHOP_ORDERS (PRIMARY KEY)" });
expect(partitions.children).toEqual([]);
expect(subpartitions.children).toEqual([]);
expect(partitions.isExpanded).toBe(true);
expect(subpartitions.isExpanded).toBe(true);
});
it("keeps a rejected Xugu metadata group collapsed and clears its loading state", async () => {
const listPartitions = vi.fn().mockRejectedValue(new Error("metadata denied"));
const { store, tableId } = await setup("xugu", { listPartitions });
const partitions = findNode(store.treeNodes, `${tableId}:__table-partitions`)!;
await expect(store.loadTreeNodeChildren(partitions)).rejects.toThrow("metadata denied");
expect(partitions.isLoading).toBe(false);
expect(partitions.isExpanded).toBe(false);
expect(partitions.children).toEqual([]);
});
});
+184 -3
View File
@@ -87,7 +87,7 @@ import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
@@ -115,6 +115,7 @@ const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000;
export const COMPLETION_METADATA_CONCURRENCY = 2;
const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy";
const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)";
const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23";
const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt";
function sidebarObjectGroupPageSize(): number {
const settingsStore = useSettingsStore();
@@ -271,6 +272,7 @@ export const useConnectionStore = defineStore("connection", () => {
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
const agentDrivers = ref<AgentDriverInstallState[]>([]);
let agentDriversRefreshPromise: Promise<void> | null = null;
let localAgentDriversRefreshPromise: Promise<void> | null = null;
const loadedTreeNodeChildrenIds = ref<Set<string>>(new Set());
const connectionErrors = ref<Record<string, string>>({});
const connectingIds = ref<Set<string>>(new Set());
@@ -669,6 +671,27 @@ export const useConnectionStore = defineStore("connection", () => {
return agentDriversRefreshPromise;
}
function refreshLocalAgentDrivers(): Promise<void> {
if (localAgentDriversRefreshPromise) return localAgentDriversRefreshPromise;
localAgentDriversRefreshPromise = api
.listInstalledAgentsLocal()
.then((drivers) => {
agentDrivers.value = drivers;
})
.catch(() => undefined)
.finally(() => {
localAgentDriversRefreshPromise = null;
});
return localAgentDriversRefreshPromise;
}
async function supportsXuguTableChildMetadata(): Promise<boolean> {
if (!hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION)) {
await refreshLocalAgentDrivers();
}
return hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION);
}
function maybeAppendAgentDriverUpdateHint(connectionId: string, baseMessage: string) {
const config = getConfig(connectionId);
const message = connectionErrorWithDriverUpdateHint(config, baseMessage);
@@ -3805,9 +3828,26 @@ export const useConnectionStore = defineStore("connection", () => {
];
const config = getConfig(connectionId);
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(config));
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType);
const isXugu = effectiveDbType === "xugu";
const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata());
if (supportsXuguChildMetadata) {
children.push({
id: `${parentId}:__constraints`,
label: "tree.constraints",
type: "group-constraints",
connectionId,
database,
schema,
catalog,
tableName: table,
isExpanded: false,
children: [],
});
}
if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) {
if (metadataCapabilities.indexes) {
if (metadataCapabilities.indexes && !isXugu) {
children.push({
id: `${parentId}:__indexes`,
label: "tree.indexes",
@@ -3851,6 +3891,50 @@ export const useConnectionStore = defineStore("connection", () => {
children: [],
});
}
if (isXugu) {
if (metadataCapabilities.indexes) {
children.push({
id: `${parentId}:__indexes`,
label: "tree.indexes",
type: "group-indexes",
connectionId,
database,
schema,
catalog,
tableName: table,
isExpanded: false,
children: [],
});
}
if (supportsXuguChildMetadata) {
children.push(
{
id: `${parentId}:__table-partitions`,
label: "tree.partitions",
type: "group-table-partitions",
connectionId,
database,
schema,
catalog,
tableName: table,
isExpanded: false,
children: [],
},
{
id: `${parentId}:__table-subpartitions`,
label: "tree.subpartitions",
type: "group-table-subpartitions",
connectionId,
database,
schema,
catalog,
tableName: table,
isExpanded: false,
children: [],
},
);
}
}
}
setChildren(node, children);
@@ -4031,6 +4115,93 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadConstraints(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__constraints`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
setChildren(
node,
constraints.map((constraint) => ({
id: `${parentId}:${constraint.name}`,
label: `${constraint.name} (${constraint.constraint_type})${constraint.valid ? "" : " · INVALID"}`,
type: "constraint" as const,
connectionId,
database,
schema,
tableName: table,
meta: constraint,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadPartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-partitions`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
setChildren(
node,
partitions.map((partition) => ({
id: `${parentId}:${partition.name}`,
label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`,
type: "partition" as const,
connectionId,
database,
schema,
tableName: table,
meta: partition,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadSubpartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-subpartitions`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
setChildren(
node,
partitions.map((partition) => ({
id: `${parentId}:${partition.name}`,
label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`,
type: "subpartition" as const,
connectionId,
database,
schema,
tableName: table,
meta: partition,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
function collectExpandedNodeIds(nodes: TreeNode[], ids = new Set<string>()): Set<string> {
for (const node of nodes) {
if (node.isExpanded) ids.add(node.id);
@@ -4107,6 +4278,12 @@ export const useConnectionStore = defineStore("connection", () => {
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, node.catalog);
} else if (node.type === "group-constraints" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadConstraints(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-table-partitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadPartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-table-subpartitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadSubpartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (objectTypesForGroupNode(node.type)) {
await loadObjectGroupChildren(node, options);
} else if (node.type === "group-partitions") {
@@ -5668,10 +5845,14 @@ export const useConnectionStore = defineStore("connection", () => {
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
loadTreeNodeChildren,
loadColumns,
loadIndexes,
loadForeignKeys,
loadTriggers,
loadConstraints,
loadPartitions,
loadSubpartitions,
listCompletionTables,
listCompletionObjects,
listCompletionColumns,