mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
🐛 fix(sidebar): 将 PostgreSQL 分区表归入主表
- 读取 PostgreSQL 分区父子关系并隐藏分区主表行数 - 在主表下增加 Partitions 分组并保留分区表操作能力 - 兼容多级分区、跨 schema 显隐及异常元数据 - 补充分区树接入测试与多语言文案 Refs #750
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
buildPackagesMetadataQuerySpecs,
|
||||
buildSchemasMetadataQuerySpecs,
|
||||
buildSequencesMetadataQuerySpecs,
|
||||
buildSidebarTableStatusSQL,
|
||||
buildViewsMetadataQuerySpecs,
|
||||
getSidebarTableName,
|
||||
loadFunctions,
|
||||
@@ -29,6 +30,21 @@ describe("sidebar table metadata", () => {
|
||||
it("keeps the table name when SQLite table rows include an exact row count", () => {
|
||||
expect(getSidebarTableName({ Rows: "2", Table: "orders" })).toBe("orders");
|
||||
});
|
||||
|
||||
it("loads PostgreSQL partition parents without running an exact row count", () => {
|
||||
const sql = buildSidebarTableStatusSQL(
|
||||
{ config: { type: "postgres" } } as any,
|
||||
"analytics",
|
||||
);
|
||||
|
||||
expect(sql).toContain("pg_inherits");
|
||||
expect(sql).toContain("AS partition_parent_table");
|
||||
expect(sql).toContain("c.relkind IN ('r', 'p')");
|
||||
expect(sql).toContain(
|
||||
"CASE WHEN c.relkind = 'p' THEN NULL ELSE c.reltuples::bigint END AS table_rows",
|
||||
);
|
||||
expect(sql).not.toMatch(/COUNT\s*\(/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSchemasMetadataQuerySpecs", () => {
|
||||
|
||||
@@ -287,11 +287,18 @@ const buildSidebarTableStatusSQL = (
|
||||
case "opengauss":
|
||||
case "gaussdb":
|
||||
return [
|
||||
"SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS table_rows,",
|
||||
"SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment,",
|
||||
"CASE WHEN c.relkind = 'p' THEN NULL ELSE c.reltuples::bigint END AS table_rows,",
|
||||
"(SELECT parent_n.nspname || '.' || parent_c.relname",
|
||||
" FROM pg_inherits inheritance",
|
||||
" JOIN pg_class parent_c ON parent_c.oid = inheritance.inhparent AND parent_c.relkind = 'p'",
|
||||
" JOIN pg_namespace parent_n ON parent_n.oid = parent_c.relnamespace",
|
||||
" WHERE inheritance.inhrelid = c.oid",
|
||||
" ORDER BY inheritance.inhseqno LIMIT 1) AS partition_parent_table,",
|
||||
"pg_total_relation_size(c.oid) AS table_size, NULL::text AS create_time, NULL::text AS update_time",
|
||||
"FROM pg_class c",
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace",
|
||||
"WHERE c.relkind = 'r'",
|
||||
"WHERE c.relkind IN ('r', 'p')",
|
||||
"AND n.nspname NOT IN ('information_schema', 'pg_catalog')",
|
||||
"AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'",
|
||||
"ORDER BY n.nspname, c.relname",
|
||||
|
||||
175
frontend/src/components/sidebar/sidebarPartitions.test.ts
Normal file
175
frontend/src/components/sidebar/sidebarPartitions.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { groupSidebarPartitionTableEntries } from './sidebarPartitions';
|
||||
|
||||
describe('groupSidebarPartitionTableEntries', () => {
|
||||
it('nests PostgreSQL partitions under their parent and hides the parent row estimate', () => {
|
||||
const grouped = groupSidebarPartitionTableEntries([
|
||||
{
|
||||
tableName: 'public.orders',
|
||||
schemaName: 'public',
|
||||
displayName: 'orders',
|
||||
rowCount: 502,
|
||||
},
|
||||
{
|
||||
tableName: 'public.orders_2026_01',
|
||||
schemaName: 'public',
|
||||
displayName: 'orders_2026_01',
|
||||
partitionParentTableName: 'public.orders',
|
||||
rowCount: 240,
|
||||
},
|
||||
{
|
||||
tableName: 'public.orders_2026_02',
|
||||
schemaName: 'public',
|
||||
displayName: 'orders_2026_02',
|
||||
partitionParentTableName: 'public.orders',
|
||||
rowCount: 262,
|
||||
},
|
||||
{
|
||||
tableName: 'public.customers',
|
||||
schemaName: 'public',
|
||||
displayName: 'customers',
|
||||
rowCount: 18,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(grouped.map((entry) => entry.tableName)).toEqual([
|
||||
'public.orders',
|
||||
'public.customers',
|
||||
]);
|
||||
expect(grouped[0]).not.toHaveProperty('rowCount');
|
||||
expect(grouped[0].partitionTables?.map((entry) => [entry.tableName, entry.rowCount])).toEqual([
|
||||
['public.orders_2026_01', 240],
|
||||
['public.orders_2026_02', 262],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps orphaned partition metadata visible instead of dropping a table', () => {
|
||||
const grouped = groupSidebarPartitionTableEntries([
|
||||
{
|
||||
tableName: 'archive.orders_2025',
|
||||
schemaName: 'archive',
|
||||
displayName: 'orders_2025',
|
||||
partitionParentTableName: 'archive.orders',
|
||||
rowCount: 91,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(grouped).toEqual([
|
||||
{
|
||||
tableName: 'archive.orders_2025',
|
||||
schemaName: 'archive',
|
||||
displayName: 'orders_2025',
|
||||
partitionParentTableName: 'archive.orders',
|
||||
rowCount: 91,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports sub-partition trees without leaking descendants into the root list', () => {
|
||||
const grouped = groupSidebarPartitionTableEntries([
|
||||
{
|
||||
tableName: 'public.events',
|
||||
schemaName: 'public',
|
||||
displayName: 'events',
|
||||
rowCount: 12,
|
||||
},
|
||||
{
|
||||
tableName: 'public.events_2026',
|
||||
schemaName: 'public',
|
||||
displayName: 'events_2026',
|
||||
partitionParentTableName: 'public.events',
|
||||
rowCount: 6,
|
||||
},
|
||||
{
|
||||
tableName: 'public.events_2026_07',
|
||||
schemaName: 'public',
|
||||
displayName: 'events_2026_07',
|
||||
partitionParentTableName: 'public.events_2026',
|
||||
rowCount: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0]).not.toHaveProperty('rowCount');
|
||||
expect(grouped[0].partitionTables?.[0]).not.toHaveProperty('rowCount');
|
||||
expect(grouped[0].partitionTables?.[0].partitionTables?.[0].tableName)
|
||||
.toBe('public.events_2026_07');
|
||||
});
|
||||
|
||||
it('prefers the child schema when an unqualified parent name is ambiguous', () => {
|
||||
const grouped = groupSidebarPartitionTableEntries([
|
||||
{
|
||||
tableName: 'public.orders',
|
||||
schemaName: 'public',
|
||||
displayName: 'orders',
|
||||
},
|
||||
{
|
||||
tableName: 'archive.orders',
|
||||
schemaName: 'archive',
|
||||
displayName: 'orders',
|
||||
},
|
||||
{
|
||||
tableName: 'archive.orders_2025',
|
||||
schemaName: 'archive',
|
||||
displayName: 'orders_2025',
|
||||
partitionParentTableName: 'orders',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(grouped[0].partitionTables).toBeUndefined();
|
||||
expect(grouped[1].partitionTables?.map((entry) => entry.tableName)).toEqual([
|
||||
'archive.orders_2025',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters schema visibility before nesting cross-schema partitions', () => {
|
||||
const entries = [
|
||||
{
|
||||
tableName: 'public.orders',
|
||||
schemaName: 'public',
|
||||
displayName: 'orders',
|
||||
},
|
||||
{
|
||||
tableName: 'archive.orders_2025',
|
||||
schemaName: 'archive',
|
||||
displayName: 'orders_2025',
|
||||
partitionParentTableName: 'public.orders',
|
||||
},
|
||||
];
|
||||
|
||||
const publicOnly = groupSidebarPartitionTableEntries(entries, {
|
||||
isEntryVisible: (entry) => entry.schemaName === 'public',
|
||||
});
|
||||
expect(publicOnly.map((entry) => entry.tableName)).toEqual(['public.orders']);
|
||||
expect(publicOnly[0].partitionTables).toBeUndefined();
|
||||
|
||||
const archiveOnly = groupSidebarPartitionTableEntries(entries, {
|
||||
isEntryVisible: (entry) => entry.schemaName === 'archive',
|
||||
});
|
||||
expect(archiveOnly.map((entry) => entry.tableName)).toEqual(['archive.orders_2025']);
|
||||
});
|
||||
|
||||
it('keeps cyclic partition metadata at the root instead of recursing forever', () => {
|
||||
const grouped = groupSidebarPartitionTableEntries([
|
||||
{
|
||||
tableName: 'public.partition_a',
|
||||
schemaName: 'public',
|
||||
displayName: 'partition_a',
|
||||
partitionParentTableName: 'public.partition_b',
|
||||
},
|
||||
{
|
||||
tableName: 'public.partition_b',
|
||||
schemaName: 'public',
|
||||
displayName: 'partition_b',
|
||||
partitionParentTableName: 'public.partition_a',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(grouped.map((entry) => entry.tableName)).toEqual([
|
||||
'public.partition_a',
|
||||
'public.partition_b',
|
||||
]);
|
||||
expect(grouped.every((entry) => entry.partitionTables === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
105
frontend/src/components/sidebar/sidebarPartitions.ts
Normal file
105
frontend/src/components/sidebar/sidebarPartitions.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface SidebarPartitionTableEntry {
|
||||
tableName: string;
|
||||
schemaName?: string;
|
||||
displayName: string;
|
||||
partitionParentTableName?: string;
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export type GroupedSidebarPartitionTableEntry<T extends SidebarPartitionTableEntry> = T & {
|
||||
partitionTables?: GroupedSidebarPartitionTableEntry<T>[];
|
||||
};
|
||||
|
||||
interface GroupSidebarPartitionTableEntriesOptions<T extends SidebarPartitionTableEntry> {
|
||||
isEntryVisible?: (entry: T) => boolean;
|
||||
}
|
||||
|
||||
const normalizePartitionTableKey = (value: unknown): string =>
|
||||
String(value || '').trim().toLowerCase();
|
||||
|
||||
const extractUnqualifiedPartitionTableName = (value: string): string => {
|
||||
const separatorIndex = value.indexOf('.');
|
||||
return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
|
||||
};
|
||||
|
||||
const buildPartitionEntryKeys = (
|
||||
entry: Pick<SidebarPartitionTableEntry, 'tableName' | 'schemaName'>,
|
||||
): string[] => {
|
||||
const tableName = String(entry.tableName || '').trim();
|
||||
const schemaName = String(entry.schemaName || '').trim();
|
||||
if (!tableName) return [];
|
||||
|
||||
const keys = new Set<string>([normalizePartitionTableKey(tableName)]);
|
||||
if (schemaName) {
|
||||
const objectName = extractUnqualifiedPartitionTableName(tableName);
|
||||
keys.add(normalizePartitionTableKey(`${schemaName}.${objectName}`));
|
||||
}
|
||||
return Array.from(keys).filter(Boolean);
|
||||
};
|
||||
|
||||
const buildPartitionParentKeys = (
|
||||
entry: Pick<SidebarPartitionTableEntry, 'partitionParentTableName' | 'schemaName'>,
|
||||
): string[] => {
|
||||
const parentTableName = String(entry.partitionParentTableName || '').trim();
|
||||
const schemaName = String(entry.schemaName || '').trim();
|
||||
if (!parentTableName) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
if (schemaName && !parentTableName.includes('.')) {
|
||||
keys.add(normalizePartitionTableKey(`${schemaName}.${parentTableName}`));
|
||||
}
|
||||
keys.add(normalizePartitionTableKey(parentTableName));
|
||||
return Array.from(keys).filter(Boolean);
|
||||
};
|
||||
|
||||
export const groupSidebarPartitionTableEntries = <T extends SidebarPartitionTableEntry>(
|
||||
entries: T[],
|
||||
options: GroupSidebarPartitionTableEntriesOptions<T> = {},
|
||||
): GroupedSidebarPartitionTableEntry<T>[] => {
|
||||
const groupedEntries = entries
|
||||
.filter((entry) => options.isEntryVisible?.(entry) ?? true)
|
||||
.map((entry) => ({ ...entry })) as GroupedSidebarPartitionTableEntry<T>[];
|
||||
const entryByKey = new Map<string, GroupedSidebarPartitionTableEntry<T>>();
|
||||
|
||||
groupedEntries.forEach((entry) => {
|
||||
buildPartitionEntryKeys(entry).forEach((key) => {
|
||||
if (!entryByKey.has(key)) entryByKey.set(key, entry);
|
||||
});
|
||||
});
|
||||
|
||||
const directParentByEntry = new Map<
|
||||
GroupedSidebarPartitionTableEntry<T>,
|
||||
GroupedSidebarPartitionTableEntry<T>
|
||||
>();
|
||||
groupedEntries.forEach((entry) => {
|
||||
const parent = buildPartitionParentKeys(entry)
|
||||
.map((key) => entryByKey.get(key))
|
||||
.find((candidate) => candidate && candidate !== entry);
|
||||
if (parent) directParentByEntry.set(entry, parent);
|
||||
});
|
||||
|
||||
const createsCycle = (
|
||||
child: GroupedSidebarPartitionTableEntry<T>,
|
||||
parent: GroupedSidebarPartitionTableEntry<T>,
|
||||
): boolean => {
|
||||
const seen = new Set<GroupedSidebarPartitionTableEntry<T>>([child]);
|
||||
let current: GroupedSidebarPartitionTableEntry<T> | undefined = parent;
|
||||
while (current) {
|
||||
if (seen.has(current)) return true;
|
||||
seen.add(current);
|
||||
current = directParentByEntry.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const nestedEntries = new Set<GroupedSidebarPartitionTableEntry<T>>();
|
||||
groupedEntries.forEach((entry) => {
|
||||
const parent = directParentByEntry.get(entry);
|
||||
if (!parent || createsCycle(entry, parent)) return;
|
||||
parent.partitionTables = [...(parent.partitionTables || []), entry];
|
||||
delete parent.rowCount;
|
||||
nestedEntries.add(entry);
|
||||
});
|
||||
|
||||
return groupedEntries.filter((entry) => !nestedEntries.has(entry));
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SavedConnection } from '../../types';
|
||||
import { useSidebarTreeLoaders } from './useSidebarTreeLoaders';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dbGetDatabases: vi.fn(),
|
||||
dbGetTables: vi.fn(),
|
||||
dbQuery: vi.fn(),
|
||||
getDriverStatusList: vi.fn(),
|
||||
jvmProbeCapabilities: vi.fn(),
|
||||
replaceTreeNodeChildren: vi.fn(),
|
||||
storeState: {
|
||||
connections: [] as Array<SavedConnection & { dbName?: string }>,
|
||||
tableSortPreference: {} as Record<string, string>,
|
||||
tableAccessCount: {} as Record<string, number>,
|
||||
pinnedSidebarTables: [] as string[],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../store')>('../../store');
|
||||
const useStore = Object.assign(vi.fn(), {
|
||||
getState: () => mocks.storeState,
|
||||
});
|
||||
return { ...actual, useStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetDatabases: mocks.dbGetDatabases,
|
||||
DBGetTables: mocks.dbGetTables,
|
||||
DBQuery: mocks.dbQuery,
|
||||
GetDriverStatusList: mocks.getDriverStatusList,
|
||||
JVMProbeCapabilities: mocks.jvmProbeCapabilities,
|
||||
}));
|
||||
|
||||
describe('useSidebarTreeLoaders PostgreSQL partitions', () => {
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.storeState.tableSortPreference = {};
|
||||
mocks.storeState.tableAccessCount = {};
|
||||
mocks.storeState.pinnedSidebarTables = [];
|
||||
mocks.replaceTreeNodeChildren.mockImplementation((_key, children) => children || []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount());
|
||||
renderer = null;
|
||||
});
|
||||
|
||||
it('builds a Partitions group with clickable table nodes and hides the parent row count', async () => {
|
||||
const connection = {
|
||||
id: 'conn-pg',
|
||||
name: 'PostgreSQL',
|
||||
dbName: 'analytics',
|
||||
config: {
|
||||
type: 'postgres',
|
||||
host: '127.0.0.1',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
database: 'analytics',
|
||||
},
|
||||
} as SavedConnection & { dbName: string };
|
||||
mocks.storeState.connections = [connection];
|
||||
mocks.dbGetTables.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ Table: 'public.orders', Rows: '502' },
|
||||
{ Table: 'public.orders_2026_01', Rows: '240' },
|
||||
{ Table: 'public.orders_2026_02', Rows: '262' },
|
||||
{ Table: 'public.customers', Rows: '18' },
|
||||
],
|
||||
});
|
||||
mocks.dbQuery.mockImplementation(async (_config, _dbName, sql: string) => {
|
||||
if (sql.includes('partition_parent_table')) {
|
||||
return {
|
||||
success: true,
|
||||
data: [
|
||||
{ table_name: 'public.orders', table_rows: null },
|
||||
{
|
||||
table_name: 'public.orders_2026_01',
|
||||
table_rows: 240,
|
||||
partition_parent_table: 'public.orders',
|
||||
},
|
||||
{
|
||||
table_name: 'public.orders_2026_02',
|
||||
table_rows: 262,
|
||||
partition_parent_table: 'public.orders',
|
||||
},
|
||||
{ table_name: 'public.customers', table_rows: 18 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { success: true, data: [] };
|
||||
});
|
||||
|
||||
let loaders: ReturnType<typeof useSidebarTreeLoaders> | undefined;
|
||||
const Harness = () => {
|
||||
loaders = useSidebarTreeLoaders({
|
||||
savedQueries: [],
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef: { current: new Set<string>() },
|
||||
setConnectionStates: vi.fn(),
|
||||
setLoadedKeys: vi.fn(),
|
||||
replaceTreeNodeChildren: mocks.replaceTreeNodeChildren,
|
||||
buildRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMDiagnosticTreeNodes: () => [],
|
||||
resolveSavedQueryDisplayName: (name) => String(name || ''),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderer = create(<Harness />);
|
||||
});
|
||||
await act(async () => {
|
||||
await loaders?.loadTables({
|
||||
key: 'conn-pg-analytics',
|
||||
dataRef: connection,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
|
||||
const [, databaseChildren] = mocks.replaceTreeNodeChildren.mock.calls[0];
|
||||
const publicSchema = databaseChildren.find(
|
||||
(node: any) => node.dataRef?.groupKey === 'schema' && node.dataRef?.schemaName === 'public',
|
||||
);
|
||||
const tablesGroup = publicSchema.children.find(
|
||||
(node: any) => node.dataRef?.groupKey === 'tables',
|
||||
);
|
||||
const rootTableNames = tablesGroup.children
|
||||
.filter((node: any) => node.type === 'table')
|
||||
.map((node: any) => node.dataRef.tableName);
|
||||
expect(rootTableNames).toEqual(['public.customers', 'public.orders']);
|
||||
|
||||
const ordersNode = tablesGroup.children.find(
|
||||
(node: any) => node.dataRef?.tableName === 'public.orders',
|
||||
);
|
||||
expect(ordersNode.dataRef).not.toHaveProperty('rowCount');
|
||||
const partitionsGroup = ordersNode.children.find(
|
||||
(node: any) => node.dataRef?.groupKey === 'partitions',
|
||||
);
|
||||
expect(partitionsGroup.dataRef.partitionCount).toBe(2);
|
||||
expect(partitionsGroup.children.map((node: any) => ({
|
||||
tableName: node.dataRef.tableName,
|
||||
type: node.type,
|
||||
rowCount: node.dataRef.rowCount,
|
||||
}))).toEqual([
|
||||
{ tableName: 'public.orders_2026_01', type: 'table', rowCount: 240 },
|
||||
{ tableName: 'public.orders_2026_02', type: 'table', rowCount: 262 },
|
||||
]);
|
||||
|
||||
const executedSql = mocks.dbQuery.mock.calls.map((call) => String(call[2] || '')).join('\n');
|
||||
expect(executedSql).not.toMatch(/COUNT\s*\(/i);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
FunctionOutlined,
|
||||
HddOutlined,
|
||||
KeyOutlined,
|
||||
LinkOutlined,
|
||||
TableOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { SavedConnection, SavedQuery, JVMCapability, JVMResourceSummary } from '../../types';
|
||||
import { useStore } from '../../store';
|
||||
@@ -53,6 +55,9 @@ import {
|
||||
type SidebarConnectionState,
|
||||
type SidebarTreeNode as TreeNode,
|
||||
} from '../sidebarV2Utils';
|
||||
import {
|
||||
groupSidebarPartitionTableEntries,
|
||||
} from './sidebarPartitions';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App';
|
||||
import type { SidebarTableMetadataSnapshot } from '../../utils/sidebarTableMetadata';
|
||||
|
||||
@@ -66,6 +71,24 @@ type DriverStatusSnapshot = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type SidebarLoadedTableMetadata = SidebarTableMetadataSnapshot & {
|
||||
schemaName?: string;
|
||||
partitionParentTableName?: string;
|
||||
};
|
||||
|
||||
type SidebarLoadedTableEntry = {
|
||||
tableName: string;
|
||||
schemaName: string;
|
||||
displayName: string;
|
||||
rowCount?: number;
|
||||
tableSize?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
tableComment?: string;
|
||||
partitionParentTableName?: string;
|
||||
partitionTables?: SidebarLoadedTableEntry[];
|
||||
};
|
||||
|
||||
export const formatSidebarDriverAgentUpdateWarning = (
|
||||
driverName: string,
|
||||
status: Pick<DriverStatusSnapshot, 'message' | 'updateReason'>,
|
||||
@@ -500,7 +523,7 @@ export const useSidebarTreeLoaders = ({
|
||||
const tableStatsResult = tableStatusSql
|
||||
? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] }))
|
||||
: { success: false, data: [] as any[] };
|
||||
const tableMetadataMap = new Map<string, SidebarTableMetadataSnapshot & { schemaName?: string }>();
|
||||
const tableMetadataMap = new Map<string, SidebarLoadedTableMetadata>();
|
||||
const buildTableMetadataKeys = (rawTableName: string, rawSchemaName = ''): string[] => {
|
||||
const tableName = String(rawTableName || '').trim();
|
||||
if (!tableName) return [];
|
||||
@@ -526,7 +549,7 @@ export const useSidebarTreeLoaders = ({
|
||||
};
|
||||
const mergeTableMetadata = (
|
||||
rawTableName: string,
|
||||
patch: SidebarTableMetadataSnapshot & { schemaName?: string },
|
||||
patch: SidebarLoadedTableMetadata,
|
||||
rawSchemaName = '',
|
||||
) => {
|
||||
buildTableMetadataKeys(rawTableName, rawSchemaName).forEach((metadataKey) => {
|
||||
@@ -534,6 +557,7 @@ export const useSidebarTreeLoaders = ({
|
||||
tableMetadataMap.set(metadataKey, {
|
||||
...current,
|
||||
...(patch.schemaName ? { schemaName: patch.schemaName } : {}),
|
||||
...(patch.partitionParentTableName ? { partitionParentTableName: patch.partitionParentTableName } : {}),
|
||||
...(patch.tableComment ? { tableComment: patch.tableComment } : {}),
|
||||
...(patch.rowCount !== undefined ? { rowCount: patch.rowCount } : {}),
|
||||
...(patch.tableSize !== undefined ? { tableSize: patch.tableSize } : {}),
|
||||
@@ -558,6 +582,10 @@ export const useSidebarTreeLoaders = ({
|
||||
).trim();
|
||||
if (!rawTableName) return;
|
||||
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'SCHEMA_NAME', 'owner', 'OWNER']);
|
||||
const partitionParentTableName = String(getCaseInsensitiveValue(row, [
|
||||
'partition_parent_table',
|
||||
'PARTITION_PARENT_TABLE',
|
||||
]) || '').trim();
|
||||
const tableComment = String(getCaseInsensitiveValue(row, [
|
||||
'table_comment',
|
||||
'TABLE_COMMENT',
|
||||
@@ -596,6 +624,7 @@ export const useSidebarTreeLoaders = ({
|
||||
]));
|
||||
mergeTableMetadata(rawTableName, {
|
||||
schemaName: rawSchemaName ? String(rawSchemaName).trim() : undefined,
|
||||
...(partitionParentTableName ? { partitionParentTableName } : {}),
|
||||
...(tableComment ? { tableComment } : {}),
|
||||
...(rowCount !== undefined ? { rowCount } : {}),
|
||||
...(tableSize !== undefined ? { tableSize } : {}),
|
||||
@@ -610,7 +639,7 @@ export const useSidebarTreeLoaders = ({
|
||||
const metadataKeys = buildTableMetadataKeys(tableName);
|
||||
const resolvedMetadata = metadataKeys
|
||||
.map((metadataKey) => tableMetadataMap.get(metadataKey))
|
||||
.find((value): value is SidebarTableMetadataSnapshot & { schemaName?: string } => !!value);
|
||||
.find((value): value is SidebarLoadedTableMetadata => !!value);
|
||||
const rowSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'SCHEMA_NAME', 'owner', 'OWNER']);
|
||||
const mappedSchemaName = rowSchemaName
|
||||
|| resolvedMetadata?.schemaName
|
||||
@@ -625,7 +654,7 @@ export const useSidebarTreeLoaders = ({
|
||||
]);
|
||||
return {
|
||||
tableName,
|
||||
schemaName: mappedSchemaName,
|
||||
schemaName: String(mappedSchemaName || '').trim(),
|
||||
displayName: getSidebarTableDisplayName(conn, tableName),
|
||||
rowCount: parseMetadataRowCount(row) ?? resolvedMetadata?.rowCount,
|
||||
tableSize: resolvedMetadata?.tableSize,
|
||||
@@ -634,8 +663,9 @@ export const useSidebarTreeLoaders = ({
|
||||
tableComment: rowComment
|
||||
|| resolvedMetadata?.tableComment
|
||||
|| '',
|
||||
partitionParentTableName: resolvedMetadata?.partitionParentTableName,
|
||||
};
|
||||
});
|
||||
}) as SidebarLoadedTableEntry[];
|
||||
|
||||
const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult, sequencesResult, packagesResult, eventsResult] = await Promise.all([
|
||||
loadSchemas(conn, conn.dbName),
|
||||
@@ -785,18 +815,30 @@ export const useSidebarTreeLoaders = ({
|
||||
const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference;
|
||||
const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount;
|
||||
const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables;
|
||||
// Metadata loading can overlap with a schema visibility save. Build partition
|
||||
// relationships from the newest visible table set so hidden schemas cannot leak
|
||||
// through a visible parent, and visible children do not disappear with a hidden parent.
|
||||
const latestConnection = useStore.getState().connections.find(
|
||||
(candidate) => candidate.id === conn.id,
|
||||
) || conn;
|
||||
const latestDatabaseConnection = { ...latestConnection, dbName };
|
||||
const shouldGroupBySchema = shouldHideSchemaPrefix(latestDatabaseConnection as SavedConnection);
|
||||
const schemaVisibilityRule = getSchemaVisibilityRule(latestDatabaseConnection, dbName);
|
||||
|
||||
// 获取当前数据库的排序偏好
|
||||
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
|
||||
const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name';
|
||||
|
||||
const sortedTableEntries = sortSidebarTableEntries(normalizedTableEntries, {
|
||||
const sortedTableEntries = groupSidebarPartitionTableEntries(sortSidebarTableEntries(normalizedTableEntries, {
|
||||
connectionId: conn.id,
|
||||
dbName: conn.dbName,
|
||||
sortBy,
|
||||
tableAccessCount: currentTableAccessCount,
|
||||
pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [],
|
||||
});
|
||||
}), {
|
||||
isEntryVisible: (entry) => !shouldGroupBySchema
|
||||
|| isSchemaVisible(schemaVisibilityRule, entry.schemaName),
|
||||
}) as SidebarLoadedTableEntry[];
|
||||
|
||||
// Sort views by name (case-insensitive)
|
||||
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
@@ -815,16 +857,7 @@ export const useSidebarTreeLoaders = ({
|
||||
|
||||
eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
|
||||
const buildTableNode = (entry: {
|
||||
tableName: string;
|
||||
schemaName: string;
|
||||
displayName: string;
|
||||
rowCount?: number;
|
||||
tableSize?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
tableComment?: string;
|
||||
}): TreeNode => {
|
||||
const buildTableNode = (entry: SidebarLoadedTableEntry): TreeNode => {
|
||||
const isPinned = isV2Ui && isSidebarTablePinned(
|
||||
currentPinnedSidebarTables,
|
||||
conn.id,
|
||||
@@ -832,22 +865,76 @@ export const useSidebarTreeLoaders = ({
|
||||
entry.tableName,
|
||||
entry.schemaName,
|
||||
);
|
||||
const nodeKey = `${conn.id}-${conn.dbName}-${entry.tableName}`;
|
||||
const tableDataRef = {
|
||||
...conn,
|
||||
tableName: entry.tableName,
|
||||
schemaName: entry.schemaName,
|
||||
...(entry.rowCount !== undefined ? { rowCount: entry.rowCount } : {}),
|
||||
tableSize: entry.tableSize,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
tableComment: entry.tableComment,
|
||||
...(isPinned ? { pinnedSidebarTable: true } : {}),
|
||||
};
|
||||
const partitionNodes = (entry.partitionTables || []).map(buildTableNode);
|
||||
const children: TreeNode[] | undefined = partitionNodes.length > 0
|
||||
? [
|
||||
{
|
||||
title: t('sidebar.table_folder.columns'),
|
||||
key: `${nodeKey}-columns`,
|
||||
icon: <UnorderedListOutlined />,
|
||||
type: 'folder-columns',
|
||||
isLeaf: true,
|
||||
dataRef: tableDataRef,
|
||||
},
|
||||
{
|
||||
title: t('sidebar.table_folder.indexes'),
|
||||
key: `${nodeKey}-indexes`,
|
||||
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
|
||||
type: 'folder-indexes',
|
||||
isLeaf: true,
|
||||
dataRef: tableDataRef,
|
||||
},
|
||||
{
|
||||
title: t('sidebar.table_folder.foreign_keys'),
|
||||
key: `${nodeKey}-fks`,
|
||||
icon: <LinkOutlined />,
|
||||
type: 'folder-fks',
|
||||
isLeaf: true,
|
||||
dataRef: tableDataRef,
|
||||
},
|
||||
{
|
||||
title: t('sidebar.table_folder.triggers'),
|
||||
key: `${nodeKey}-triggers`,
|
||||
icon: <ThunderboltOutlined />,
|
||||
type: 'folder-triggers',
|
||||
isLeaf: true,
|
||||
dataRef: tableDataRef,
|
||||
},
|
||||
{
|
||||
title: t('sidebar.table_folder.partitions'),
|
||||
key: `${nodeKey}-partitions`,
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'object-group',
|
||||
isLeaf: false,
|
||||
selectable: false,
|
||||
children: partitionNodes,
|
||||
dataRef: {
|
||||
...tableDataRef,
|
||||
groupKey: 'partitions',
|
||||
partitionCount: partitionNodes.length,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
return {
|
||||
title: entry.displayName,
|
||||
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
|
||||
key: nodeKey,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table',
|
||||
dataRef: {
|
||||
...conn,
|
||||
tableName: entry.tableName,
|
||||
schemaName: entry.schemaName,
|
||||
rowCount: entry.rowCount,
|
||||
tableSize: entry.tableSize,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
tableComment: entry.tableComment,
|
||||
...(isPinned ? { pinnedSidebarTable: true } : {}),
|
||||
},
|
||||
dataRef: tableDataRef,
|
||||
...(children ? { children } : {}),
|
||||
isLeaf: false,
|
||||
};
|
||||
};
|
||||
@@ -955,13 +1042,6 @@ export const useSidebarTreeLoaders = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Metadata loading can overlap with a schema visibility save. Render with the
|
||||
// newest saved connection so an in-flight request cannot restore stale groups.
|
||||
const latestConnection = useStore.getState().connections.find(
|
||||
(candidate) => candidate.id === conn.id,
|
||||
) || conn;
|
||||
const latestDatabaseConnection = { ...latestConnection, dbName };
|
||||
const shouldGroupBySchema = shouldHideSchemaPrefix(latestDatabaseConnection as SavedConnection);
|
||||
if (shouldGroupBySchema) {
|
||||
type SchemaBucket = {
|
||||
schemaName: string;
|
||||
@@ -1014,7 +1094,6 @@ export const useSidebarTreeLoaders = ({
|
||||
const includeSequences = supportsDatabaseSequences(conn as SavedConnection);
|
||||
const includeEvents = supportsDatabaseEvents(conn as SavedConnection);
|
||||
|
||||
const schemaVisibilityRule = getSchemaVisibilityRule(latestDatabaseConnection, dbName);
|
||||
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
|
||||
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
|
||||
.filter((bucket) => isSchemaVisible(schemaVisibilityRule, bucket.schemaName))
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "Spalten",
|
||||
"sidebar.table_folder.foreign_keys": "Fremdschlüssel",
|
||||
"sidebar.table_folder.indexes": "Indizes",
|
||||
"sidebar.table_folder.partitions": "Partitionen",
|
||||
"sidebar.table_folder.triggers": "Trigger",
|
||||
"sidebar.tree.all_saved_queries": "Alle gespeicherten Abfragen",
|
||||
"sidebar.tree.ungrouped_saved_queries": "Nicht gruppiert",
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "Columns",
|
||||
"sidebar.table_folder.foreign_keys": "Foreign keys",
|
||||
"sidebar.table_folder.indexes": "Indexes",
|
||||
"sidebar.table_folder.partitions": "Partitions",
|
||||
"sidebar.table_folder.triggers": "Triggers",
|
||||
"sidebar.tree.all_saved_queries": "All saved queries",
|
||||
"sidebar.tree.ungrouped_saved_queries": "Ungrouped",
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "列",
|
||||
"sidebar.table_folder.foreign_keys": "外部キー",
|
||||
"sidebar.table_folder.indexes": "インデックス",
|
||||
"sidebar.table_folder.partitions": "パーティション",
|
||||
"sidebar.table_folder.triggers": "トリガー",
|
||||
"sidebar.tree.all_saved_queries": "すべての保存済みクエリ",
|
||||
"sidebar.tree.ungrouped_saved_queries": "未分類",
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "Столбцы",
|
||||
"sidebar.table_folder.foreign_keys": "Внешние ключи",
|
||||
"sidebar.table_folder.indexes": "Индексы",
|
||||
"sidebar.table_folder.partitions": "Секции",
|
||||
"sidebar.table_folder.triggers": "Триггеры",
|
||||
"sidebar.tree.all_saved_queries": "Все сохраненные запросы",
|
||||
"sidebar.tree.ungrouped_saved_queries": "Без группы",
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "列",
|
||||
"sidebar.table_folder.foreign_keys": "外键",
|
||||
"sidebar.table_folder.indexes": "索引",
|
||||
"sidebar.table_folder.partitions": "分区",
|
||||
"sidebar.table_folder.triggers": "触发器",
|
||||
"sidebar.tree.all_saved_queries": "全部已存查询",
|
||||
"sidebar.tree.ungrouped_saved_queries": "未分组",
|
||||
|
||||
@@ -7670,6 +7670,7 @@
|
||||
"sidebar.table_folder.columns": "欄位",
|
||||
"sidebar.table_folder.foreign_keys": "外鍵",
|
||||
"sidebar.table_folder.indexes": "索引",
|
||||
"sidebar.table_folder.partitions": "分割區",
|
||||
"sidebar.table_folder.triggers": "觸發器",
|
||||
"sidebar.tree.all_saved_queries": "全部已儲存查詢",
|
||||
"sidebar.tree.ungrouped_saved_queries": "未分組",
|
||||
|
||||
Reference in New Issue
Block a user