From 0674216d53dbe0195dd2e0cb3d71eaf8020b192f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Jul 2026 13:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20=E5=B0=86=20Post?= =?UTF-8?q?greSQL=20=E5=88=86=E5=8C=BA=E8=A1=A8=E5=BD=92=E5=85=A5=E4=B8=BB?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 读取 PostgreSQL 分区父子关系并隐藏分区主表行数 - 在主表下增加 Partitions 分组并保留分区表操作能力 - 兼容多级分区、跨 schema 显隐及异常元数据 - 补充分区树接入测试与多语言文案 Refs #750 --- .../sidebar/sidebarMetadataLoaders.test.ts | 16 ++ .../sidebar/sidebarMetadataLoaders.ts | 11 +- .../sidebar/sidebarPartitions.test.ts | 175 ++++++++++++++++++ .../components/sidebar/sidebarPartitions.ts | 105 +++++++++++ .../useSidebarTreeLoaders.partitions.test.tsx | 172 +++++++++++++++++ .../sidebar/useSidebarTreeLoaders.tsx | 153 +++++++++++---- shared/i18n/de-DE.json | 1 + shared/i18n/en-US.json | 1 + shared/i18n/ja-JP.json | 1 + shared/i18n/ru-RU.json | 1 + shared/i18n/zh-CN.json | 1 + shared/i18n/zh-TW.json | 1 + 12 files changed, 599 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/sidebar/sidebarPartitions.test.ts create mode 100644 frontend/src/components/sidebar/sidebarPartitions.ts create mode 100644 frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts index 0c3b766d..067fa304 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.test.ts @@ -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", () => { diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts index ca59f781..fb9dd1d7 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -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", diff --git a/frontend/src/components/sidebar/sidebarPartitions.test.ts b/frontend/src/components/sidebar/sidebarPartitions.test.ts new file mode 100644 index 00000000..5a5f9ba7 --- /dev/null +++ b/frontend/src/components/sidebar/sidebarPartitions.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/sidebar/sidebarPartitions.ts b/frontend/src/components/sidebar/sidebarPartitions.ts new file mode 100644 index 00000000..bac3ccbe --- /dev/null +++ b/frontend/src/components/sidebar/sidebarPartitions.ts @@ -0,0 +1,105 @@ +export interface SidebarPartitionTableEntry { + tableName: string; + schemaName?: string; + displayName: string; + partitionParentTableName?: string; + rowCount?: number; +} + +export type GroupedSidebarPartitionTableEntry = T & { + partitionTables?: GroupedSidebarPartitionTableEntry[]; +}; + +interface GroupSidebarPartitionTableEntriesOptions { + 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, +): string[] => { + const tableName = String(entry.tableName || '').trim(); + const schemaName = String(entry.schemaName || '').trim(); + if (!tableName) return []; + + const keys = new Set([normalizePartitionTableKey(tableName)]); + if (schemaName) { + const objectName = extractUnqualifiedPartitionTableName(tableName); + keys.add(normalizePartitionTableKey(`${schemaName}.${objectName}`)); + } + return Array.from(keys).filter(Boolean); +}; + +const buildPartitionParentKeys = ( + entry: Pick, +): string[] => { + const parentTableName = String(entry.partitionParentTableName || '').trim(); + const schemaName = String(entry.schemaName || '').trim(); + if (!parentTableName) return []; + + const keys = new Set(); + if (schemaName && !parentTableName.includes('.')) { + keys.add(normalizePartitionTableKey(`${schemaName}.${parentTableName}`)); + } + keys.add(normalizePartitionTableKey(parentTableName)); + return Array.from(keys).filter(Boolean); +}; + +export const groupSidebarPartitionTableEntries = ( + entries: T[], + options: GroupSidebarPartitionTableEntriesOptions = {}, +): GroupedSidebarPartitionTableEntry[] => { + const groupedEntries = entries + .filter((entry) => options.isEntryVisible?.(entry) ?? true) + .map((entry) => ({ ...entry })) as GroupedSidebarPartitionTableEntry[]; + const entryByKey = new Map>(); + + groupedEntries.forEach((entry) => { + buildPartitionEntryKeys(entry).forEach((key) => { + if (!entryByKey.has(key)) entryByKey.set(key, entry); + }); + }); + + const directParentByEntry = new Map< + GroupedSidebarPartitionTableEntry, + GroupedSidebarPartitionTableEntry + >(); + 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, + parent: GroupedSidebarPartitionTableEntry, + ): boolean => { + const seen = new Set>([child]); + let current: GroupedSidebarPartitionTableEntry | undefined = parent; + while (current) { + if (seen.has(current)) return true; + seen.add(current); + current = directParentByEntry.get(current); + } + return false; + }; + + const nestedEntries = new Set>(); + 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)); +}; diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx new file mode 100644 index 00000000..06686756 --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx @@ -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, + tableSortPreference: {} as Record, + tableAccessCount: {} as Record, + 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('../../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 | undefined; + const Harness = () => { + loaders = useSidebarTreeLoaders({ + savedQueries: [], + tableSortPreference: {}, + tableAccessCount: {}, + pinnedSidebarTables: [], + isV2Ui: true, + loadingNodesRef: { current: new Set() }, + 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(); + }); + 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); + }); +}); diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index fdf67b68..e3bc4dfc 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -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, @@ -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(); + const tableMetadataMap = new Map(); 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: , + type: 'folder-columns', + isLeaf: true, + dataRef: tableDataRef, + }, + { + title: t('sidebar.table_folder.indexes'), + key: `${nodeKey}-indexes`, + icon: , + type: 'folder-indexes', + isLeaf: true, + dataRef: tableDataRef, + }, + { + title: t('sidebar.table_folder.foreign_keys'), + key: `${nodeKey}-fks`, + icon: , + type: 'folder-fks', + isLeaf: true, + dataRef: tableDataRef, + }, + { + title: t('sidebar.table_folder.triggers'), + key: `${nodeKey}-triggers`, + icon: , + type: 'folder-triggers', + isLeaf: true, + dataRef: tableDataRef, + }, + { + title: t('sidebar.table_folder.partitions'), + key: `${nodeKey}-partitions`, + icon: , + 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: , 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)) diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 1097de0a..cfb659df 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -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", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 6d4220bc..e8041dd8 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -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", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 1881c7a9..45754395 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -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": "未分類", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 0c1ace9f..ef645a11 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -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": "Без группы", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 98cb64b6..80fd782b 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -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": "未分组", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index f656d103..9a7ba461 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -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": "未分組",