feat(xugu): expose table child metadata
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user