🐛 fix(sidebar): 将 PostgreSQL 分区表归入主表

- 读取 PostgreSQL 分区父子关系并隐藏分区主表行数
- 在主表下增加 Partitions 分组并保留分区表操作能力
- 兼容多级分区、跨 schema 显隐及异常元数据
- 补充分区树接入测试与多语言文案

Refs #750
This commit is contained in:
Syngnat
2026-07-28 13:58:56 +08:00
parent 19bb9d633b
commit 0674216d53
12 changed files with 599 additions and 39 deletions

View File

@@ -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", () => {

View File

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

View 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);
});
});

View 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));
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "未分類",

View File

@@ -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": "Без группы",

View File

@@ -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": "未分组",

View File

@@ -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": "未分組",