From fd036915dd0bf1638fa788a5af376277ba592a65 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 20 Jun 2026 20:48:47 +0800 Subject: [PATCH] perf(welcome): index connection tree to rebuild groups in linear time --- CHANGELOG.md | 1 + .../Connection/ConnectionGroupTree.swift | 230 ++++++++++++++++++ TablePro/ViewModels/WelcomeViewModel.swift | 17 +- .../Models/ConnectionGroupTreeTests.swift | 181 ++++++++++++++ 4 files changed, 417 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..afdaee0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. - Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke. +- The welcome sidebar rebuilds its connection tree in linear time, so favoriting, moving, or regrouping connections stays responsive with many connections and nested groups. ### Fixed diff --git a/TablePro/Models/Connection/ConnectionGroupTree.swift b/TablePro/Models/Connection/ConnectionGroupTree.swift index 89fe44e18..e1a00b959 100644 --- a/TablePro/Models/Connection/ConnectionGroupTree.swift +++ b/TablePro/Models/Connection/ConnectionGroupTree.swift @@ -163,3 +163,233 @@ func connectionCount(in groupId: UUID, connections: [DatabaseConnection], groups }.count return directCount + descendantCount } + +// MARK: - Indexed Tree (O(G+C)) + +private struct GroupParentKey: Hashable { + let id: UUID? +} + +struct GroupTreeIndices { + var connectionCountByGroup: [UUID: Int] = [:] + var depthByGroup: [UUID: Int] = [:] + var maxDescendantDepthByGroup: [UUID: Int] = [:] +} + +private struct GroupTreeIndex { + let validGroupIds: Set + let childrenByParentId: [GroupParentKey: [ConnectionGroup]] + let connectionsByGroupId: [UUID: [DatabaseConnection]] +} + +private func sortGroups(_ groups: [ConnectionGroup]) -> [ConnectionGroup] { + groups.sorted { + $0.sortOrder != $1.sortOrder + ? $0.sortOrder < $1.sortOrder + : $0.name.localizedStandardCompare($1.name) == .orderedAscending + } +} + +private func sortConnections(_ connections: [DatabaseConnection]) -> [DatabaseConnection] { + connections.sorted { + $0.sortOrder != $1.sortOrder + ? $0.sortOrder < $1.sortOrder + : $0.name.localizedStandardCompare($1.name) == .orderedAscending + } +} + +private func buildGroupTreeIndex(groups: [ConnectionGroup], connections: [DatabaseConnection]) -> GroupTreeIndex { + let validGroupIds = Set(groups.map(\.id)) + + var childrenByParentId: [GroupParentKey: [ConnectionGroup]] = [:] + for group in groups { + let key = GroupParentKey(id: group.parentId.flatMap { validGroupIds.contains($0) ? $0 : nil }) + childrenByParentId[key, default: []].append(group) + } + for key in childrenByParentId.keys { + if let levelGroups = childrenByParentId[key] { + childrenByParentId[key] = sortGroups(levelGroups) + } + } + + var connectionsByGroupId: [UUID: [DatabaseConnection]] = [:] + for connection in connections { + guard let groupId = connection.groupId, validGroupIds.contains(groupId) else { continue } + connectionsByGroupId[groupId, default: []].append(connection) + } + for groupId in connectionsByGroupId.keys { + if let groupConnections = connectionsByGroupId[groupId] { + connectionsByGroupId[groupId] = sortConnections(groupConnections) + } + } + + return GroupTreeIndex( + validGroupIds: validGroupIds, + childrenByParentId: childrenByParentId, + connectionsByGroupId: connectionsByGroupId + ) +} + +func buildGroupTreeIndexed( + groups: [ConnectionGroup], + connections: [DatabaseConnection], + maxDepth: Int = 3 +) -> [ConnectionGroupTreeNode] { + let index = buildGroupTreeIndex(groups: groups, connections: connections) + return buildGroupTreeIndexedLevel( + parentId: nil, + currentDepth: 0, + maxDepth: maxDepth, + index: index, + connections: connections + ) +} + +private func buildGroupTreeIndexedLevel( + parentId: UUID?, + currentDepth: Int, + maxDepth: Int, + index: GroupTreeIndex, + connections: [DatabaseConnection] +) -> [ConnectionGroupTreeNode] { + var items: [ConnectionGroupTreeNode] = [] + let key = GroupParentKey(id: parentId) + let levelGroups = index.childrenByParentId[key] ?? [] + + for group in levelGroups { + var children: [ConnectionGroupTreeNode] = [] + if currentDepth < maxDepth { + children = buildGroupTreeIndexedLevel( + parentId: group.id, + currentDepth: currentDepth + 1, + maxDepth: maxDepth, + index: index, + connections: connections + ) + } + for conn in index.connectionsByGroupId[group.id] ?? [] { + children.append(.connection(conn)) + } + items.append(.group(group, children: children)) + } + + if parentId == nil { + let ungrouped = sortConnections(connections.filter { conn in + guard let groupId = conn.groupId else { return true } + return !index.validGroupIds.contains(groupId) + }) + for conn in ungrouped { + items.append(.connection(conn)) + } + } + + return items +} + +func computeGroupTreeIndices(groups: [ConnectionGroup], connections: [DatabaseConnection]) -> GroupTreeIndices { + let index = buildGroupTreeIndex(groups: groups, connections: connections) + var result = GroupTreeIndices() + + var depthByGroup: [UUID: Int] = [:] + var visitedDepth: Set = [] + var queue: [(UUID, Int)] = [] + let roots = index.childrenByParentId[GroupParentKey(id: nil)] ?? [] + for root in roots where !visitedDepth.contains(root.id) { + visitedDepth.insert(root.id) + depthByGroup[root.id] = 1 + queue.append((root.id, 1)) + } + var queueIndex = 0 + while queueIndex < queue.count { + let (currentId, currentDepth) = queue[queueIndex] + queueIndex += 1 + let children = index.childrenByParentId[GroupParentKey(id: currentId)] ?? [] + for child in children where !visitedDepth.contains(child.id) { + visitedDepth.insert(child.id) + depthByGroup[child.id] = currentDepth + 1 + queue.append((child.id, currentDepth + 1)) + } + } + + var maxDepthByGroup: [UUID: Int] = [:] + var connectionCountByGroup: [UUID: Int] = [:] + var aggregated: Set = [] + for root in roots { + aggregateSubtree( + groupId: root.id, + visited: [], + index: index, + aggregated: &aggregated, + maxDepthByGroup: &maxDepthByGroup, + connectionCountByGroup: &connectionCountByGroup + ) + } + + for group in groups { + if let depth = depthByGroup[group.id] { + result.depthByGroup[group.id] = depth + } else { + result.depthByGroup[group.id] = depthOf(groupId: group.id, groups: groups) + } + if maxDepthByGroup[group.id] == nil { + result.maxDescendantDepthByGroup[group.id] = maxDescendantDepth(groupId: group.id, groups: groups) + } else { + result.maxDescendantDepthByGroup[group.id] = maxDepthByGroup[group.id] + } + if connectionCountByGroup[group.id] == nil { + result.connectionCountByGroup[group.id] = connectionCount( + in: group.id, + connections: connections, + groups: groups + ) + } else { + result.connectionCountByGroup[group.id] = connectionCountByGroup[group.id] + } + } + + return result +} + +private struct SubtreeAggregate { + var maxDescendantDepth: Int + var connectionCount: Int +} + +private func aggregateSubtree( + groupId: UUID, + visited: Set, + index: GroupTreeIndex, + aggregated: inout Set, + maxDepthByGroup: inout [UUID: Int], + connectionCountByGroup: inout [UUID: Int] +) -> SubtreeAggregate { + aggregated.insert(groupId) + var nextVisited = visited + nextVisited.insert(groupId) + + let children = index.childrenByParentId[GroupParentKey(id: groupId)] ?? [] + var maxChildDescendantDepth = 0 + var subtreeCount = index.connectionsByGroupId[groupId]?.count ?? 0 + + for child in children where !visited.contains(child.id) { + let childAggregate = aggregateSubtree( + groupId: child.id, + visited: nextVisited, + index: index, + aggregated: &aggregated, + maxDepthByGroup: &maxDepthByGroup, + connectionCountByGroup: &connectionCountByGroup + ) + maxChildDescendantDepth = max(maxChildDescendantDepth, childAggregate.maxDescendantDepth) + subtreeCount += childAggregate.connectionCount + } + + let maxDescendantDepthValue = children.isEmpty ? 0 : 1 + maxChildDescendantDepth + let result = SubtreeAggregate( + maxDescendantDepth: maxDescendantDepthValue, + connectionCount: subtreeCount + ) + maxDepthByGroup[groupId] = result.maxDescendantDepth + connectionCountByGroup[groupId] = result.connectionCount + return result +} diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index b95cf9610..af50f8952 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -108,7 +108,7 @@ final class WelcomeViewModel { .filter(\.isFavorite) .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - let tree = buildGroupTree(groups: groups, connections: connections, parentId: nil) + let tree = buildGroupTreeIndexed(groups: groups, connections: connections) let baseItems = searchText.isEmpty ? tree : filterGroupTree(tree, searchText: searchText) if searchText.isEmpty, !favoriteConnections.isEmpty { treeItems = baseItems.filter { node in @@ -119,17 +119,10 @@ final class WelcomeViewModel { treeItems = baseItems } - var counts: [UUID: Int] = [:] - var depths: [UUID: Int] = [:] - var descendantDepths: [UUID: Int] = [:] - for group in groups { - counts[group.id] = connectionCount(in: group.id, connections: connections, groups: groups) - depths[group.id] = depthOf(groupId: group.id, groups: groups) - descendantDepths[group.id] = maxDescendantDepth(groupId: group.id, groups: groups) - } - connectionCountByGroup = counts - depthByGroup = depths - maxDescendantDepthByGroup = descendantDepths + let indices = computeGroupTreeIndices(groups: groups, connections: connections) + connectionCountByGroup = indices.connectionCountByGroup + depthByGroup = indices.depthByGroup + maxDescendantDepthByGroup = indices.maxDescendantDepthByGroup } private func scheduleRebuildTree(oldValue: String) { diff --git a/TableProTests/Models/ConnectionGroupTreeTests.swift b/TableProTests/Models/ConnectionGroupTreeTests.swift index 47633d4fe..c205bae87 100644 --- a/TableProTests/Models/ConnectionGroupTreeTests.swift +++ b/TableProTests/Models/ConnectionGroupTreeTests.swift @@ -499,4 +499,185 @@ struct ConnectionGroupTreeTests { let depth = depthOf(groupId: idA, groups: [a, b]) #expect(depth <= 2) } + + // MARK: - Indexed Tree Equivalence + + @Test("Indexed buildGroupTree matches reference across nested + orphan topologies") + func indexedTree_matchesReference() { + let id1 = UUID() + let id2 = UUID() + let id3 = UUID() + let orphanParent = UUID() + let g1 = makeGroup(id: id1, name: "L1", sortOrder: 0) + let g2 = makeGroup(id: id2, name: "L2", parentId: id1, sortOrder: 0) + let g3 = makeGroup(id: id3, name: "L3", parentId: id2, sortOrder: 0) + let orphan = makeGroup(id: orphanParent, name: "Orphan", parentId: UUID()) + let groups = [g2, orphan, g1, g3] + + let c1 = DatabaseConnection(name: "In L1 a", groupId: id1, sortOrder: 2) + let c2 = DatabaseConnection(name: "In L1 b", groupId: id1, sortOrder: 1) + let c3 = DatabaseConnection(name: "In L3", groupId: id3) + let c4 = DatabaseConnection(name: "Orphan conn", groupId: orphanParent) + let c5 = DatabaseConnection(name: "Ungrouped") + let c6 = DatabaseConnection(name: "Dangling", groupId: UUID()) + let connections = [c6, c5, c4, c3, c2, c1] + + let reference = buildGroupTree(groups: groups, connections: connections, parentId: nil) + let indexed = buildGroupTreeIndexed(groups: groups, connections: connections) + #expect(treeNodeFingerprint(indexed) == treeNodeFingerprint(reference)) + } + + @Test("Indexed tree matches reference for sorting across multiple siblings") + func indexedTree_sortingEquivalence() { + let groups = (0..<8).map { idx in + makeGroup(name: "Group \(idx)", sortOrder: (idx * 7) % 11) + } + let connections = (0.. String { + nodes.map { nodeFingerprint($0) }.joined(separator: "|") + } + + private func nodeFingerprint(_ node: ConnectionGroupTreeNode) -> String { + switch node { + case .connection(let conn): + return "conn(\(conn.id.uuidString.prefix(4)):\(conn.sortOrder))" + case .group(let group, let children): + let childFingerprint = treeNodeFingerprint(children) + return "group(\(group.id.uuidString.prefix(4)):\(group.sortOrder))[\(childFingerprint)]" + } + } + + private func generateRandomTopology(seed: Int) -> ([ConnectionGroup], [DatabaseConnection]) { + var rng = SeededRNG(seed: UInt64(seed)) + let groupCount = Int(rng.next() % 12) + 1 + var groups: [ConnectionGroup] = [] + for index in 0.. UInt64 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + return state + } }