From b67ffeaa54c43ffa7f22ee3a7047d63f32ccf6e3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 25 Oct 2026 04:44:10 +0700 Subject: [PATCH 1/2] perf(sidebar): time and fix the sidebar, Open Quickly and autocomplete on 50,000 tables --- apple/CONTRIBUTING.md | 2 + .../SQLCompletionCatalog.swift | 7 + .../SQLCompletionProvider.swift | 106 ++-- .../SQLCompletionSession.swift | 10 + .../SQLContextAnalyzer.swift | 16 +- .../SQLCompletionProviderCatalogTests.swift | 89 ++- ...LContextAnalyzerQuotedReferenceTests.swift | 7 + .../Model/CodeSuggestionDelegate.swift | 9 + .../Model/SuggestionViewModel.swift | 104 +++- .../Window/SuggestionController.swift | 1 + .../SuggestionCursorsUpdatedTests.swift | 260 ++++++++ .../SuggestionLiveCursorTests.swift | 122 +++- .../SuggestionShowCompletionsGuardTests.swift | 7 + .../UI/SidebarNameFilter.swift | 75 ++- .../TableProUtilities/UI/SidebarSearch.swift | 37 +- .../SidebarNameFilterTests.swift | 19 + .../SidebarSearchTests.swift | 45 ++ .../OracleCoreConnection.swift | 4 +- .../TableProOracleCore/OracleRowLimits.swift | 12 + .../OracleRowLimitsTests.swift | 14 + apple/TablePro/Core/AI/AISchemaContext.swift | 29 + .../Core/Autocomplete/CompletionEngine.swift | 19 +- .../Autocomplete/QueryCompletionService.swift | 2 + .../Autocomplete/SQLCompletionService.swift | 3 +- .../Core/Autocomplete/SQLSchemaProvider.swift | 154 +++-- .../Core/Database/CatalogTableListing.swift | 26 +- .../Database/DatabaseManager+Sessions.swift | 1 + .../DatabaseObjectToolEligibility.swift | 22 + .../Core/Diagnostics/InteractionTrace.swift | 117 ++++ .../Core/Diagnostics/InteractionTracer.swift | 84 +++ .../Diagnostics/MainThreadStallMonitor.swift | 56 ++ .../Core/Diagnostics/TabSwitchTracer.swift | 2 +- .../Core/MCP/MCPConnectionBridge+Schema.swift | 60 +- .../Core/Plugins/PluginDriverAdapter.swift | 25 +- .../SidebarContainerViewController.swift | 1 + .../Query/AllSchemaTablesDemand.swift | 9 +- .../Query/DatabaseTreeMetadataService.swift | 5 + .../TablePro/Resources/Localizable.xcstrings | 3 + .../AIChatViewModel+SchemaContext.swift | 17 +- .../AIChatViewModel+TableMentions.swift | 114 ++++ .../TablePro/ViewModels/AIChatViewModel.swift | 9 + ...QuickSwitcherViewModel+ListingDemand.swift | 4 +- .../QuickSwitcherViewModel+TableRows.swift | 162 +++++ .../ViewModels/QuickSwitcherViewModel.swift | 399 ++++++------ .../ViewModels/SidebarViewModel.swift | 27 +- .../Views/AIChat/AIChatPanelView.swift | 24 +- .../Views/Editor/QueryCompletionAdapter.swift | 40 +- ...MainContentCoordinator+QuickSwitcher.swift | 4 + .../QuickSwitcher/QuickSwitcherPanel.swift | 2 + .../Views/Sidebar/DatabaseTreeFilter.swift | 170 +++-- .../Views/Sidebar/DatabaseTreeNode.swift | 41 +- ...baseTreeOutlineCoordinator+Expansion.swift | 57 ++ ...DatabaseTreeOutlineCoordinator+Nodes.swift | 234 +++++-- ...tabaseTreeOutlineCoordinator+Tracing.swift | 84 +++ .../DatabaseTreeOutlineCoordinator.swift | 114 ++-- .../Sidebar/DatabaseTreeRenderInputs.swift | 141 +++++ .../Views/Sidebar/DatabaseTreeSelection.swift | 7 +- .../Views/Sidebar/SidebarTreeView.swift | 11 +- .../TablePro/Views/Sidebar/SidebarView.swift | 5 +- apple/TablePro/main.swift | 1 + ...LSchemaProviderUnqualifiedScopeTests.swift | 120 ++++ .../DatabaseObjectToolSupportTests.swift | 54 ++ .../InteractionTraceRecorderTests.swift | 90 +++ .../Core/MCP/MCPListIndexesTests.swift | 96 +++ .../Query/AllSchemaTablesDemandTests.swift | 52 +- .../ViewModels/AIChatTableMentionsTests.swift | 148 +++++ .../AIChatViewModelSchemaRankingTests.swift | 51 ++ .../QuickSwitcherListingDemandTests.swift | 109 +++- .../QuickSwitcherViewModelTests.swift | 39 ++ ...QueryCompletionAdapterLifecycleTests.swift | 71 +++ .../Sidebar/DatabaseTreeFilterMemoTests.swift | 239 ++++++++ ...tabaseTreeFilterQualifiedSearchTests.swift | 28 +- .../Sidebar/DatabaseTreeFilterTests.swift | 4 +- .../DatabaseTreeHierarchicalSearchTests.swift | 152 +++++ .../Views/Sidebar/DatabaseTreeNodeTests.swift | 8 + .../Sidebar/DatabaseTreeReconcileTests.swift | 177 ++++++ apple/scripts/benchmark-large-catalog.sh | 56 ++ .../benchmark/large-catalog/baseline.json | 201 ++++++ .../benchmark/large-catalog/benchmark.py | 579 ++++++++++++++++++ .../probes/build-large-catalog-fixtures.sh | 25 +- 80 files changed, 4887 insertions(+), 643 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionSession.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRowLimitsTests.swift create mode 100644 apple/TablePro/Core/Diagnostics/InteractionTrace.swift create mode 100644 apple/TablePro/Core/Diagnostics/InteractionTracer.swift create mode 100644 apple/TablePro/Core/Diagnostics/MainThreadStallMonitor.swift create mode 100644 apple/TablePro/ViewModels/AIChatViewModel+TableMentions.swift create mode 100644 apple/TablePro/ViewModels/QuickSwitcherViewModel+TableRows.swift create mode 100644 apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift create mode 100644 apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift create mode 100644 apple/TableProTests/Core/Database/DatabaseObjectToolSupportTests.swift create mode 100644 apple/TableProTests/Core/Diagnostics/InteractionTraceRecorderTests.swift create mode 100644 apple/TableProTests/Core/MCP/MCPListIndexesTests.swift create mode 100644 apple/TableProTests/ViewModels/AIChatTableMentionsTests.swift create mode 100644 apple/TableProTests/ViewModels/AIChatViewModelSchemaRankingTests.swift create mode 100644 apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift create mode 100644 apple/TableProTests/Views/Sidebar/DatabaseTreeHierarchicalSearchTests.swift create mode 100644 apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift create mode 100755 apple/scripts/benchmark-large-catalog.sh create mode 100644 apple/scripts/benchmark/large-catalog/baseline.json create mode 100644 apple/scripts/benchmark/large-catalog/benchmark.py diff --git a/apple/CONTRIBUTING.md b/apple/CONTRIBUTING.md index 11469ca70e..61edc5ae2d 100644 --- a/apple/CONTRIBUTING.md +++ b/apple/CONTRIBUTING.md @@ -98,6 +98,8 @@ xcodebuild -project TableProMobile/TableProMobile.xcodeproj -scheme TableProMobi `scripts/benchmark-app-paths.sh` times cold launch, opening a 1,000,000-row table and switching tabs, 10 runs each, and checks the median and 95th percentile against the budgets in `scripts/benchmark/app-paths/baseline.json`. Usage, permissions and the fixtures are in its header. In Instruments the same three paths are the `AppLaunch`, `TableLoad` and `TabSwitch` intervals under Points of Interest. +`scripts/benchmark-large-catalog.sh` times the sidebar, Open Quickly and autocomplete on 50,000 tables (`scripts/probes/build-large-catalog-fixtures.sh` builds the fixtures) against `scripts/benchmark/large-catalog/baseline.json`, and fails any step during which the main thread stalls for more than 100 ms. `TABLEPRO_INTERACTION_TRACE=1` prints each traced step and each stall of 50 ms or more on standard error; in Instruments the steps are the `Interaction` intervals. + ## Code style `.swiftlint.yml` is the source of truth. Run `swiftlint lint --strict `; CI lints all of `apple/` the same way on every pull request, and nothing lints during an Xcode build. Formatting the linter leaves to you: diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift index cbce84c447..0cdf7d7599 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift @@ -6,6 +6,7 @@ public protocol SQLCompletionCatalog: Sendable { func columnCompletionItems(for tableName: String, schema: String?) async -> [SQLCompletionItem] func tableCompletionItems() async -> [SQLCompletionItem] func tableCompletionItems(inSchema schema: String) async -> [SQLCompletionItem] + func tableCompletionItems(inSchema schema: String, isQuoted: Bool) async -> [SQLCompletionItem] func schemaCompletionItems() async -> [SQLCompletionItem] func namespaceCompletionItems() async -> [SQLCompletionItem] func isKnownSchema(_ name: String) async -> Bool @@ -14,3 +15,9 @@ public protocol SQLCompletionCatalog: Sendable { func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] func allColumnsFromCachedTables() async -> [SQLCompletionItem] } + +public extension SQLCompletionCatalog { + func tableCompletionItems(inSchema schema: String, isQuoted: Bool) async -> [SQLCompletionItem] { + await tableCompletionItems(inSchema: schema) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift index 385935eae5..a190774b8c 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift @@ -56,24 +56,40 @@ public final class SQLCompletionProvider { return (session.items, session.context) } - // `items` is what the popup shows; `candidates` is the wider pool it re-ranks while it stays open. public func completionSession( text: String, cursorPosition: Int, forcedTableReferences: [TableReference]? = nil - ) async -> (items: [SQLCompletionItem], candidates: [SQLCompletionItem], context: SQLContext) { + ) async -> SQLCompletionSession { var context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition, grammar: grammar) if let forcedTableReferences { context = context.replacingTableReferences(forcedTableReferences) } if context.isInsideString || context.isInsideComment { - return ([], [], context) + return SQLCompletionSession(items: [], candidates: [], context: context, candidatesTruncated: false) } - var candidates = await getCandidates(for: context) + let limit = maxSuggestions(for: context.clauseType) + return await Self.rankedSession( + await getCandidates(for: context), + context: context, + limit: limit, + pool: sessionPool(for: limit) + ) + } + + // A catalog can list tens of thousands of tables, so joining, filtering and sorting them leaves the caller's actor. + @concurrent + private static func rankedSession( + _ segments: [[SQLCompletionItem]], + context: SQLContext, + limit: Int, + pool: Int + ) async -> SQLCompletionSession { + var candidates = Array(segments.joined()) if context.quotedName != nil { - candidates = candidates.filter { Self.nameKinds.contains($0.kind) } + candidates = candidates.filter { nameKinds.contains($0.kind) } } if !context.prefix.isEmpty { @@ -82,9 +98,12 @@ public final class SQLCompletionProvider { candidates = rankResults(candidates, prefix: context.prefix, context: context) - let limit = maxSuggestions(for: context.clauseType) - - return (Array(candidates.prefix(limit)), Array(candidates.prefix(sessionPool(for: limit))), context) + return SQLCompletionSession( + items: Array(candidates.prefix(limit)), + candidates: Array(candidates.prefix(pool)), + context: context, + candidatesTruncated: candidates.count > pool + ) } // A candidate cut at the opening prefix can never lead for a longer one, and PostgreSQL declares more `T` @@ -122,13 +141,16 @@ public final class SQLCompletionProvider { // MARK: - Candidate Generation + // In emission order. The catalog's table list stays a segment of its own, so the caller never copies it. private func getCandidates( // swiftlint:disable:this function_body_length for context: SQLContext - ) async -> [SQLCompletionItem] { + ) async -> [[SQLCompletionItem]] { + var segments: [[SQLCompletionItem]] = [] var items: [SQLCompletionItem] = [] - // A dot prefix is a table or alias first, then a schema (its tables) or a database (its schemas). The - // namespace fallback also covers an alias that resolves to a schema name parsed out of the FROM clause. + // A dot prefix is a table or alias first, then a schema (its tables) or a database (its schemas, or its + // tables on an engine with none). The namespace fallback also covers an alias that resolves to a schema + // name parsed out of the FROM clause. if let dotPrefix = context.dotPrefix { guard let catalog else { return [] } if let derived = context.tableReferences.first(where: { @@ -136,7 +158,7 @@ public final class SQLCompletionProvider { }) { let columns = await derivedColumns(of: derived) if !columns.isEmpty { - return columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) } + return [columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }] } } if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { @@ -147,18 +169,21 @@ public final class SQLCompletionProvider { } if items.isEmpty { if await catalog.isKnownSchema(dotPrefix) { - items = await catalog.tableCompletionItems(inSchema: dotPrefix) + items = await catalog.tableCompletionItems(inSchema: dotPrefix, isQuoted: context.isDotPrefixQuoted) } else if await catalog.isKnownDatabase(dotPrefix) { items = await catalog.schemaCompletionItems() + if items.isEmpty { + items = await catalog.tableCompletionItems(inSchema: dotPrefix, isQuoted: context.isDotPrefixQuoted) + } } } - return items + return [items] } switch context.clauseType { case .from, .join: - items = await catalog?.tableCompletionItems() ?? [] - items += await catalog?.namespaceCompletionItems() ?? [] + segments = [await catalog?.tableCompletionItems() ?? []] + items = await catalog?.namespaceCompletionItems() ?? [] items += filterKeywords([ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", @@ -168,8 +193,8 @@ public final class SQLCompletionProvider { ]) case .into: - items = await catalog?.tableCompletionItems() ?? [] - items += filterKeywords([ + segments = [await catalog?.tableCompletionItems() ?? []] + items = filterKeywords([ "VALUES", "SELECT", "SET", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", @@ -424,33 +449,31 @@ public final class SQLCompletionProvider { ]) case .dropObject: - items = await catalog?.tableCompletionItems() ?? [] - items += filterKeywords(["IF EXISTS", "CASCADE", "RESTRICT"]) + segments = [await catalog?.tableCompletionItems() ?? []] + items = filterKeywords(["IF EXISTS", "CASCADE", "RESTRICT"]) case .createIndex: if context.tableReferences.isEmpty { - items = await catalog?.tableCompletionItems() ?? [] - items += filterKeywords(["ON"]) + segments = [await catalog?.tableCompletionItems() ?? []] + items = filterKeywords(["ON"]) } else { items = await columnItems(for: context.tableReferences) items += filterKeywords(["USING", "BTREE", "HASH", "GIN", "GIST"]) } case .createView: - items = filterKeywords(["SELECT", "AS"]) - items += await catalog?.tableCompletionItems() ?? [] + segments = [filterKeywords(["SELECT", "AS"]), await catalog?.tableCompletionItems() ?? []] case .castTarget: items = castTargetCompletionItems() case .unknown: - items = statementStartCompletionItems() - items += await catalog?.tableCompletionItems() ?? [] + segments = [statementStartCompletionItems(), await catalog?.tableCompletionItems() ?? []] } items += favoriteCompletions(matching: context.prefix) - return items + return segments + [items] } private func favoriteCompletions(matching prefix: String) -> [SQLCompletionItem] { @@ -638,13 +661,16 @@ public final class SQLCompletionProvider { // MARK: - Filtering public func filterAndRank(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { - let filtered = filterByPrefix(items, prefix: prefix) - return rankResults(filtered, prefix: prefix, context: context) + Self.rankResults(Self.filterByPrefix(items, prefix: prefix), prefix: prefix, context: context) + } + + public func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + Self.filterByPrefix(items, prefix: prefix) } // Resolves `matchedRanges` and the fuzzy-only `fuzzyPenalty` once per candidate, so ranking never matches again. // Both are assigned rather than accumulated, so filtering a filtered list is idempotent. - public func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + private static func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { guard !prefix.isEmpty else { var reset = items for i in reset.indices { @@ -682,13 +708,13 @@ public final class SQLCompletionProvider { return kept } - private func optionalRange(of substring: String, in target: NSString) -> Range? { + private static func optionalRange(of substring: String, in target: NSString) -> Range? { let range = target.range(of: substring) guard range.location != NSNotFound else { return nil } return range.location..<(range.location + range.length) } - private func resolveFuzzyMatch(pattern: String, target: String) -> (penalty: Int, indices: [Int])? { + private static func resolveFuzzyMatch(pattern: String, target: String) -> (penalty: Int, indices: [Int])? { let nsPattern = pattern as NSString let nsTarget = target as NSString let patternLen = nsPattern.length @@ -737,10 +763,10 @@ public final class SQLCompletionProvider { // Higher is worse; nil is no match. public func fuzzyMatchScore(pattern: String, target: String) -> Int? { - resolveFuzzyMatch(pattern: pattern, target: target)?.penalty + Self.resolveFuzzyMatch(pattern: pattern, target: target)?.penalty } - private func indicesToRanges(_ indices: [Int]) -> [Range] { + private static func indicesToRanges(_ indices: [Int]) -> [Range] { guard !indices.isEmpty else { return [] } var ranges: [Range] = [] var start = indices[0] @@ -760,9 +786,13 @@ public final class SQLCompletionProvider { // MARK: - Ranking + public func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { + Self.rankResults(items, prefix: prefix, context: context) + } + // Scores once per candidate rather than in the comparator. Equal scores keep the order the generator emitted, // which carries meaning: the standard library's sort is not stable. - public func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { + private static func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { let lowerPrefix = prefix.lowercased() let scored = items.enumerated().map { position, item in (position: position, item: item, score: calculateScore(for: item, prefix: lowerPrefix, context: context)) @@ -773,8 +803,12 @@ public final class SQLCompletionProvider { }.map(\.item) } - // Lower is better. public func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { + Self.calculateScore(for: item, prefix: prefix, context: context) + } + + // Lower is better. + private static func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { var score = item.sortPriority + item.fuzzyPenalty if item.filterText.hasPrefix(prefix) { diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionSession.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionSession.swift new file mode 100644 index 0000000000..fac41ef23a --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionSession.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct SQLCompletionSession: Sendable { + public let items: [SQLCompletionItem] + // The wider ranked pool an open popup re-ranks while it stays open; `items` is the slice it shows. + public let candidates: [SQLCompletionItem] + public let context: SQLContext + // More candidates matched than the pool holds, so a longer prefix can promote one the pool dropped. + public let candidatesTruncated: Bool +} diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift index d2a6349ab4..aadeb59b74 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -116,6 +116,7 @@ public struct SQLContext: Sendable { public let prefix: String public private(set) var prefixRange: Range // UTF-16 offsets into the analyzed text public let dotPrefix: String? // "u" in "u.name" + public let isDotPrefixQuoted: Bool // `"hr".` names the schema as written, case and all public private(set) var tableReferences: [TableReference] public let isInsideString: Bool public let isInsideComment: Bool @@ -135,6 +136,7 @@ public struct SQLContext: Sendable { prefix: String, prefixRange: Range, dotPrefix: String?, + isDotPrefixQuoted: Bool = false, tableReferences: [TableReference], isInsideString: Bool, isInsideComment: Bool, @@ -152,6 +154,7 @@ public struct SQLContext: Sendable { self.prefix = prefix self.prefixRange = prefixRange self.dotPrefix = dotPrefix + self.isDotPrefixQuoted = isDotPrefixQuoted self.tableReferences = tableReferences self.isInsideString = isInsideString self.isInsideComment = isInsideComment @@ -332,7 +335,7 @@ public final class SQLContextAnalyzer { break } - let (prefix, prefixStart, dotPrefix) = extractPrefix(in: text, cursor: cursor, grammar: grammar) + let (prefix, prefixStart, dotPrefix, isDotPrefixQuoted) = extractPrefix(in: text, cursor: cursor, grammar: grammar) var tableReferences = extractTableReferences(from: text.forReferences) @@ -391,6 +394,7 @@ public final class SQLContextAnalyzer { prefix: prefix, prefixRange: (statementOffset + prefixStart).. (prefix: String, start: Int, dotPrefix: String?) { + ) -> (prefix: String, start: Int, dotPrefix: String?, isDotPrefixQuoted: Bool) { let ns = text.original let start: Int let tokenStart: Int @@ -759,23 +763,23 @@ public final class SQLContextAnalyzer { let prefix = SQLTokenBoundary.matchText(of: ns.substring(with: NSRange(location: start, length: cursor - start))) guard tokenStart > 0, ns.character(at: tokenStart - 1) == Self.dot else { - return (prefix, start, nil) + return (prefix, start, nil, false) } let qualifierEnd = tokenStart - 1 if let quotedStart = text.quotedIdentifierStarts[qualifierEnd] { let raw = ns.substring(with: NSRange(location: quotedStart, length: qualifierEnd - quotedStart)) - return (prefix, start, Self.unquoted(raw)) + return (prefix, start, Self.unquoted(raw), true) } let qualifierStart = SQLTokenBoundary.segmentStart(in: ns, endingAt: qualifierEnd, grammar: grammar) guard qualifierStart < qualifierEnd else { - return (prefix, start, nil) + return (prefix, start, nil, false) } let qualifierRange = NSRange(location: qualifierStart, length: qualifierEnd - qualifierStart) let dotPrefix = SQLTokenBoundary.matchText(of: ns.substring(with: qualifierRange)) - return (prefix, start, dotPrefix) + return (prefix, start, dotPrefix, false) } // MARK: - Table References diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift index 9063d7f818..cb4c923171 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift @@ -7,6 +7,9 @@ private struct StubCatalog: SQLCompletionCatalog { let tables: [String] let columns: [String: [String]] var values: [String] = [] + var databases: [String] = [] + var schemas: [String] = [] + var schemaTables: [String: [String]] = [:] func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { let name = aliasOrName.lowercased() @@ -27,11 +30,14 @@ private struct StubCatalog: SQLCompletionCatalog { tables.map { SQLCompletionItem.table($0) } } - func tableCompletionItems(inSchema schema: String) -> [SQLCompletionItem] { [] } - func schemaCompletionItems() -> [SQLCompletionItem] { [] } + func tableCompletionItems(inSchema schema: String) -> [SQLCompletionItem] { + (schemaTables[schema] ?? []).map { SQLCompletionItem.table($0) } + } + + func schemaCompletionItems() -> [SQLCompletionItem] { schemas.map { SQLCompletionItem.schemaName($0) } } func namespaceCompletionItems() -> [SQLCompletionItem] { [] } - func isKnownSchema(_ name: String) -> Bool { false } - func isKnownDatabase(_ name: String) -> Bool { false } + func isKnownSchema(_ name: String) -> Bool { schemas.contains(name) } + func isKnownDatabase(_ name: String) -> Bool { databases.contains(name) } func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] { values } func allColumnsInScope(for references: [TableReference]) -> [SQLCompletionItem] { @@ -75,6 +81,27 @@ struct SQLCompletionProviderCatalogTests { #expect(Set(session.items.map(\.label)) == ["id", "total", "status"]) } + @Test("A database and a dot offer its schemas") + func databaseDotOffersSchemas() async { + let catalog = StubCatalog( + tables: [], columns: [:], databases: ["shop"], schemas: ["sales", "audit"], + schemaTables: ["shop": ["orders"]] + ) + let provider = SQLCompletionProvider(catalog: catalog, grammar: .engine("SQLite"), vocabulary: .generic) + let session = await provider.completionSession(text: "SELECT * FROM shop.", cursorPosition: 19) + #expect(Set(session.items.map(\.label)) == ["sales", "audit"]) + } + + @Test("A database and a dot offer its tables on an engine with no schemas") + func databaseDotOffersTablesWithoutSchemas() async { + let catalog = StubCatalog( + tables: [], columns: [:], databases: ["shop"], schemaTables: ["shop": ["orders", "customers"]] + ) + let provider = SQLCompletionProvider(catalog: catalog, grammar: .engine("SQLite"), vocabulary: .generic) + let session = await provider.completionSession(text: "SELECT * FROM shop.", cursorPosition: 19) + #expect(Set(session.items.map(\.label)) == ["orders", "customers"]) + } + @Test("UPDATE offers tables") func updateOffersTables() async { let session = await provider.completionSession(text: "UPDATE or", cursorPosition: 9) @@ -362,4 +389,58 @@ struct SQLCompletionProviderCatalogTests { let globals = await sqlServer.completionSession(text: global, cursorPosition: (global as NSString).length) #expect(globals.items.first { $0.label == "@@ROWCOUNT" }?.insertText == "@@ROWCOUNT") } + + // MARK: - Large catalogs + + @Test("A pool cut short of the matches says so, and one holding every match does not") + func cutPoolIsMarked() async { + let large = SQLCompletionProvider( + catalog: StubCatalog(tables: (0..<500).map { String(format: "t%03d", $0) }, columns: [:]), + grammar: .engine("MySQL"), + vocabulary: .generic + ) + + let cut = await large.completionSession(text: "SELECT * FROM ", cursorPosition: 14) + let whole = await provider.completionSession(text: "SELECT * FROM ", cursorPosition: 14) + + #expect(cut.candidates.count == 400) + #expect(cut.candidatesTruncated) + #expect(!whole.candidatesTruncated) + } +} + +@MainActor +struct SQLCompletionProviderIsolationTests { + @available(macOS 15, iOS 18, *) + @Test("Filtering and ranking run off the caller's actor") + func rankingLeavesTheCallersActor() async { + let provider = SQLCompletionProvider( + catalog: StubCatalog(tables: ["customers", "orders"], columns: [:]), + grammar: .engine("MySQL"), + vocabulary: .generic + ) + let executor = CountingTaskExecutor() + + let session = await withTaskExecutorPreference(executor) { + await provider.completionSession(text: "SELECT * FROM or", cursorPosition: 16) + } + + #expect(session.items.first?.label == "orders") + #expect(executor.enqueuedJobs > 0) + } +} + +// The caller's own actor never reaches it, so a job here is work that left the caller. +@available(macOS 15, iOS 18, *) +private final class CountingTaskExecutor: TaskExecutor, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var enqueuedJobs: Int { lock.withLock { count } } + + func enqueue(_ job: consuming ExecutorJob) { + lock.withLock { count += 1 } + let job = UnownedJob(job) + DispatchQueue.global().async { job.runSynchronously(on: self.asUnownedTaskExecutor()) } + } } diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift index 3403d0c085..170e2609a4 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift @@ -106,6 +106,13 @@ struct SQLContextAnalyzerQuotedReferenceTests { #expect(quoted.prefix == "ord") } + @Test("A quoted qualifier says it was quoted, and a bare one does not") + func qualifierQuotingIsReported() { + #expect(context("SELECT * FROM \"hr\".", on: "Oracle").isDotPrefixQuoted) + #expect(!context("SELECT * FROM hr.", on: "Oracle").isDotPrefixQuoted) + #expect(context("SELECT * FROM [dbo].", on: "SQL Server").isDotPrefixQuoted) + } + @Test("A dot inside a quoted name is no qualifier") func dotInsideAQuotedName() { let query = "SELECT * FROM \"a.b\"" diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/CodeSuggestionDelegate.swift index d8ca520360..a51715b859 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/CodeSuggestionDelegate.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/CodeSuggestionDelegate.swift @@ -21,6 +21,13 @@ public protocol CodeSuggestionDelegate: AnyObject { cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? + // Optional. True when the items `completionOnCursorMove` gave for this position are a partial pool: the window + // keeps them and asks for the full answer behind them. + func completionNeedsFreshItems(textView: TextViewController, cursorPosition: CursorPosition) -> Bool + + // Optional + func completionWindowDidShow(textView: TextViewController, itemCount: Int) + // Optional func completionWindowDidClose() @@ -35,6 +42,8 @@ public protocol CodeSuggestionDelegate: AnyObject { public extension CodeSuggestionDelegate { func completionTriggerCharacters() -> Set { [] } + func completionNeedsFreshItems(textView: TextViewController, cursorPosition: CursorPosition) -> Bool { false } + func completionWindowDidShow(textView: TextViewController, itemCount: Int) { } func completionWindowDidClose() { } func completionWindowDidSelect(item: CodeSuggestionEntry) { } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift index f699ec590e..6298dc728f 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift @@ -13,7 +13,12 @@ final class SuggestionViewModel: ObservableObject { private static let logger = Logger(subsystem: "com.TableProEditorKit", category: "SuggestionVM") /// The items to be displayed in the window @Published var items: [CodeSuggestionEntry] = [] - @Published var selectedIndex: Int = 0 + // A row the user picked, by arrow key or click, rather than the first row a new list starts on. + @Published var selectedIndex: Int = 0 { + didSet { selectionIsChosen = !isResettingSelection } + } + private var selectionIsChosen = false + private var isResettingSelection = false var itemsRequestTask: Task? weak var activeTextView: TextViewController? @@ -35,6 +40,9 @@ final class SuggestionViewModel: ObservableObject { private var lastDroppedMovePresents = false + private var refresh: (generation: Int, position: CursorPosition)? + private var isRefreshingItems: Bool { itemsRequestTask != nil && refresh?.generation == requestGeneration } + weak var delegate: CodeSuggestionDelegate? /// Invoked after a successful apply so the owning controller can dismiss the @@ -48,6 +56,9 @@ final class SuggestionViewModel: ObservableObject { /// The owning controller dismisses the window so the click is not swallowed. var onBackgroundTap: (() -> Void)? + // Invoked when a refresh of an open window answers nothing, so the controller closes through its own close(). + var onRequestClose: (() -> Void)? + private var cursorPosition: CursorPosition? private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] @@ -161,7 +172,7 @@ final class SuggestionViewModel: ObservableObject { ) self.items = items - self.selectedIndex = 0 + self.resetSelection() self.syntaxHighlightedCache = [:] self.notifySelection() @@ -171,6 +182,8 @@ final class SuggestionViewModel: ObservableObject { showWindowOnParent(window, screenCursorRect) self.isPresented = true + delegate.completionWindowDidShow(textView: textView, itemCount: items.count) + self.refreshIfOvertaken(response, textView: textView, delegate: delegate) } } catch { self.endSession(generation: generation) @@ -226,7 +239,7 @@ final class SuggestionViewModel: ObservableObject { isPresented = false lastDroppedMovePresents = false items.removeAll() - selectedIndex = 0 + resetSelection() syntaxHighlightedCache = [:] activeTextView = nil if let delegate { @@ -256,13 +269,19 @@ final class SuggestionViewModel: ObservableObject { cursorPosition: position ), !newItems.isEmpty { items = newItems - selectedIndex = 0 + resetSelection() syntaxHighlightedCache = [:] notifySelection() + // A deletion arrives here as a plain move, so the delegate, not the trigger, decides. + let isRefreshingHere = isRefreshingItems && refresh?.position == position + if !isRefreshingHere, delegate.completionNeedsFreshItems(textView: textView, cursorPosition: position) { + refreshItems(textView: textView, delegate: delegate, cursorPosition: position) + } return } - guard itemsRequestTask == nil else { + // A refresh only replaces what the window shows, so a move it cannot answer closes as if none were running. + guard itemsRequestTask == nil || isRefreshingItems else { lastDroppedMovePresents = presentIfNot return } @@ -270,6 +289,81 @@ final class SuggestionViewModel: ObservableObject { close() } + // Closing first and asking again would blank the window on every keystroke while a partial pool narrows. + private func refreshItems( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + cursorPosition: CursorPosition + ) { + itemsRequestTask?.cancel() + requestGeneration &+= 1 + let generation = requestGeneration + refresh = (generation, cursorPosition) + itemsRequestTask = Task { + defer { + if self.requestGeneration == generation { + self.itemsRequestTask = nil + } + } + + let response = await delegate.completionSuggestionsRequested( + textView: textView, + cursorPosition: cursorPosition, + isManualTrigger: false + ) + guard self.requestGeneration == generation, !Task.isCancelled, + self.isPresented, self.activeTextView === textView else { return } + + guard let response else { + self.onRequestClose?() + return + } + guard let items = self.itemsForLiveEditor(response, textView: textView, delegate: delegate), + !items.isEmpty else { + // A key typed while this ran can leave a prefix the window still answers but this pool does not. + if let liveCursor = textView.cursorPositions.first, + !response.prefix.isCurrent(at: liveCursor, in: textView) { + self.refreshItems(textView: textView, delegate: delegate, cursorPosition: liveCursor) + } else { + self.onRequestClose?() + } + return + } + + let chosen = self.selectionIsChosen ? self.selectedItem : nil + self.items = items + // Entries carry no identity, so a chosen row is found again by what it shows. + if let chosen, let index = items.firstIndex(where: { $0.label == chosen.label && $0.detail == chosen.detail }) { + self.selectedIndex = index + } else { + self.resetSelection() + } + self.syntaxHighlightedCache = [:] + self.notifySelection() + delegate.completionWindowDidShow(textView: textView, itemCount: items.count) + self.refreshIfOvertaken(response, textView: textView, delegate: delegate) + } + } + + // Text changed while a request ran, and the pool it left cannot answer what is there now. + private func refreshIfOvertaken( + _ response: CodeSuggestionResponse, + textView: TextViewController, + delegate: CodeSuggestionDelegate + ) { + guard let liveCursor = textView.cursorPositions.first, + !response.prefix.isCurrent(at: liveCursor, in: textView), + delegate.completionNeedsFreshItems(textView: textView, cursorPosition: liveCursor) + else { return } + refreshItems(textView: textView, delegate: delegate, cursorPosition: liveCursor) + } + + private func resetSelection() { + isResettingSelection = true + selectedIndex = 0 + isResettingSelection = false + } + func didSelect(item: CodeSuggestionEntry) { delegate?.completionWindowDidSelect(item: item) } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift index ba50ea5e73..713e36cd2d 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift @@ -62,6 +62,7 @@ public final class SuggestionController: NSWindowController { model.onApply = { [weak self] in self?.close() } model.onBackgroundTap = { [weak self] in self?.close() } + model.onRequestClose = { [weak self] in self?.close() } NotificationCenter.default.addObserver( self, diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift index 15d8b7f50b..4a19a27350 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift @@ -1,5 +1,6 @@ import AppKit @testable import TableProEditorKit +import TableProTestSupport import Testing @MainActor @@ -95,6 +96,213 @@ struct SuggestionCursorsUpdatedTests { #expect(closeCount == 1) #expect(model.itemsRequestTask == nil) } + + // MARK: - A partial pool + + @MainActor + @Test func test_cursorsUpdated_typingIntoAPartialPoolKeepsTheWindowAndShowsTheFreshItems() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["t045"], isPartial: true, fresh: ["t450", "t451"], fetch: fetch) + var closeCount = 0 + present(model, in: textViewController, delegate: delegate) { closeCount += 1 } + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 3, length: 0)), + presentIfNot: true + ) { closeCount += 1 } + let pooled = model.items.map(\.label) + await fetch.open() + await model.itemsRequestTask?.value + + #expect(pooled == ["t045"]) + #expect(closeCount == 0) + #expect(model.isPresented) + #expect(model.items.map(\.label) == ["t450", "t451"]) + #expect(delegate.shownItemCounts == [2]) + } + + @MainActor + @Test(arguments: [true, false]) + func test_cursorsUpdated_aPoolThatCanAnswerAsksNothing(typed: Bool) async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let delegate = PartialPoolStubDelegate(pool: ["select"], isPartial: false, fresh: ["set"], fetch: AsyncGate()) + var closeCount = 0 + present(model, in: textViewController, delegate: delegate) { closeCount += 1 } + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 2, length: 0)), + presentIfNot: typed + ) { closeCount += 1 } + + #expect(closeCount == 0) + #expect(model.items.map(\.label) == ["select"]) + #expect(model.itemsRequestTask == nil) + #expect(delegate.requestCount == 0) + } + + // A deletion is not typing: the trigger model leaves it to the selection change, which reports a plain move. + @MainActor + @Test func test_cursorsUpdated_aDeletionThePoolCannotAnswerShowsTheFreshItemsInPlace() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["ab1"], isPartial: true, fresh: ["ab1", "ac1"], fetch: fetch) + var closeCount = 0 + present(model, in: textViewController, delegate: delegate) { closeCount += 1 } + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 1, length: 0)) + ) { closeCount += 1 } + await fetch.open() + await model.itemsRequestTask?.value + + #expect(closeCount == 0) + #expect(model.items.map(\.label) == ["ab1", "ac1"]) + } + + @MainActor + @Test func test_cursorsUpdated_freshItemsKeepTheRowTheUserChose() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["a1", "b2"], isPartial: true, fresh: ["c3", "b2"], fetch: fetch) + present(model, in: textViewController, delegate: delegate) {} + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 2, length: 0)), + presentIfNot: true + ) {} + model.moveDown() + await fetch.open() + await model.itemsRequestTask?.value + + #expect(model.items.map(\.label) == ["c3", "b2"]) + #expect(model.selectedItem?.label == "b2") + } + + @MainActor + @Test func test_cursorsUpdated_freshItemsStartAtTheFirstRowWhenTheChosenRowIsGone() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["a1", "b2"], isPartial: true, fresh: ["c3", "d4"], fetch: fetch) + present(model, in: textViewController, delegate: delegate) {} + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 2, length: 0)), + presentIfNot: true + ) {} + model.moveDown() + await fetch.open() + await model.itemsRequestTask?.value + + #expect(model.selectedIndex == 0) + } + + @MainActor + @Test func test_cursorsUpdated_anEditAfterAChoiceStartsTheFreshItemsAtTheFirstRow() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["a1", "b2"], isPartial: true, fresh: ["c3", "b2"], fetch: fetch) + present(model, in: textViewController, delegate: delegate) {} + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 2, length: 0)), + presentIfNot: true + ) {} + model.moveDown() + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 3, length: 0)), + presentIfNot: true + ) {} + await fetch.open() + await model.itemsRequestTask?.value + + #expect(model.items.map(\.label) == ["c3", "b2"]) + #expect(model.selectedIndex == 0) + } + + @MainActor + @Test func test_cursorsUpdated_aFreshRequestThatAnswersNothingClosesTheWindow() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let fetch = AsyncGate() + let delegate = PartialPoolStubDelegate(pool: ["t045"], isPartial: true, fresh: nil, fetch: fetch) + var moveCloses = 0 + var requestedCloses = 0 + present(model, in: textViewController, delegate: delegate) { requestedCloses += 1 } + + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 3, length: 0)), + presentIfNot: true + ) { moveCloses += 1 } + await fetch.open() + await model.itemsRequestTask?.value + + #expect(moveCloses == 0) + #expect(requestedCloses == 1) + #expect(delegate.shownItemCounts.isEmpty) + } + + @MainActor + @Test func test_cursorsUpdated_aMoveThePoolCannotAnswerClosesWhileARefreshRuns() async throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let delegate = PartialPoolStubDelegate(pool: ["t045"], isPartial: true, fresh: ["t450"], fetch: AsyncGate()) + present(model, in: textViewController, delegate: delegate) {} + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 3, length: 0)), + presentIfNot: true + ) {} + defer { model.itemsRequestTask?.cancel() } + + delegate.pool = nil + var closeCount = 0 + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 4, length: 0)), + presentIfNot: true + ) { closeCount += 1 } + + #expect(closeCount == 1) + } + + @MainActor + private func present( + _ model: SuggestionViewModel, + in textViewController: TextViewController, + delegate: CodeSuggestionDelegate, + onRequestClose: @escaping () -> Void + ) { + model.activeTextView = textViewController + model.delegate = delegate + model.isPresented = true + model.items = [StubSuggestionEntry(label: "t000")] + model.onRequestClose = onRequestClose + } } private final class FilteringStubDelegate: CodeSuggestionDelegate { @@ -125,3 +333,55 @@ private final class FilteringStubDelegate: CodeSuggestionDelegate { cursorPosition: CursorPosition? ) {} } + +@MainActor +private final class PartialPoolStubDelegate: CodeSuggestionDelegate { + var pool: [String]? + private let isPartial: Bool + private let fresh: [String]? + private let fetch: AsyncGate + private(set) var requestCount = 0 + private(set) var shownItemCounts: [Int] = [] + + init(pool: [String]?, isPartial: Bool, fresh: [String]?, fetch: AsyncGate) { + self.pool = pool + self.isPartial = isPartial + self.fresh = fresh + self.fetch = fetch + } + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition, + isManualTrigger: Bool + ) async -> CodeSuggestionResponse? { + requestCount += 1 + await fetch.wait() + guard let fresh else { return nil } + return .answeringAnEmptyPrefix( + fresh.map { StubSuggestionEntry(label: $0) }, + at: textView.cursorPositions.first ?? cursorPosition + ) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + pool?.map { StubSuggestionEntry(label: $0) } + } + + func completionNeedsFreshItems(textView: TextViewController, cursorPosition: CursorPosition) -> Bool { + isPartial + } + + func completionWindowDidShow(textView: TextViewController, itemCount: Int) { + shownItemCounts.append(itemCount) + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) {} +} diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift index fa7db9d6f4..52ce87564d 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift @@ -113,6 +113,113 @@ final class SuggestionLiveCursorTests: XCTestCase { XCTAssertEqual(model.items.map(\.label), ["id", "name"]) } + @MainActor + func test_showCompletions_refreshesWhenADeletionDuringTheRequestLeftAPrefixItsPoolCannotAnswer() async throws { + // Text after the token keeps the request's window position on the text once the deletion lands. + let editor = FocusedEditor(text: "ab x") + defer { editor.close() } + editor.controller.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + let delegate = ParkingDelegate( + answers: ["ab": ["ab1"], "a": ["ab1", "ac1"]], + reranked: ["a": ["ab1"]], + needsFresh: ["a"] + ) + let model = SuggestionViewModel() + + model.showCompletions(textView: editor.controller, delegate: delegate, cursorPosition: editor.cursor) { _, _ in } + let request = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + editor.deleteBackward(reportingTo: model, delegate: delegate) + delegate.answer() + await request.value + + XCTAssertEqual(model.items.map(\.label), ["ab1"]) + let refresh = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + delegate.answer() + await refresh.value + + XCTAssertTrue(model.isPresented) + XCTAssertEqual(model.items.map(\.label), ["ab1", "ac1"]) + XCTAssertEqual(delegate.askedAt, [2, 1]) + } + + @MainActor + func test_refresh_asksAgainWhenAKeyTypedDuringItLeftItsPoolShort() async throws { + let editor = FocusedEditor(text: "ab x") + defer { editor.close() } + editor.controller.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + let delegate = ParkingDelegate( + answers: ["ab": ["ab_target"], "a": ["a_b"]], + reranked: ["a": ["a_b"], "ab": ["ab_target"]], + needsFresh: ["a"] + ) + let model = SuggestionViewModel() + + model.showCompletions(textView: editor.controller, delegate: delegate, cursorPosition: editor.cursor) { _, _ in } + let first = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + delegate.answer() + await first.value + + editor.deleteBackward(reportingTo: model, delegate: delegate) + let refresh = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + editor.type("b", reportingTo: model, delegate: delegate) {} + // The refresh's answer leaves a pool ranked for `a`, which cannot answer `ab`. + delegate.reranked["ab"] = ["a_b"] + delegate.needsFresh = ["ab"] + delegate.answer() + await refresh.value + + let again = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + delegate.answer() + await again.value + + XCTAssertTrue(model.isPresented) + XCTAssertEqual(model.items.map(\.label), ["ab_target"]) + } + + @MainActor + func test_refresh_asksAgainInsteadOfClosingWhenItsAnswerCannotRankARetypedPrefix() async throws { + let editor = FocusedEditor(text: "ab x") + defer { editor.close() } + editor.controller.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + let delegate = ParkingDelegate( + answers: ["ab": ["ab1", "ab2"], "a": ["ac1"]], + reranked: ["a": ["ab1", "ab2"], "ab": ["ab1", "ab2"]], + needsFresh: ["a"] + ) + let model = SuggestionViewModel() + + model.showCompletions(textView: editor.controller, delegate: delegate, cursorPosition: editor.cursor) { _, _ in } + let first = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + delegate.answer() + await first.value + + editor.deleteBackward(reportingTo: model, delegate: delegate) + let refresh = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + // The first pool still ranks `ab`, so retyping starts no request of its own. + editor.type("b", reportingTo: model, delegate: delegate) {} + // The refresh leaves a pool cut for `a` that holds none of the `ab` names. + delegate.reranked["ab"] = [] + delegate.answer() + await refresh.value + + XCTAssertTrue(model.isPresented) + let again = try XCTUnwrap(model.itemsRequestTask) + await delegate.readTheEditor() + delegate.answer() + await again.value + + XCTAssertTrue(model.isPresented) + XCTAssertEqual(model.items.map(\.label), ["ab1", "ab2"]) + XCTAssertEqual(delegate.askedAt, [2, 1, 2]) + } + @MainActor func test_showCompletions_asksAgainWhenAnAnswerOfNothingMissedAKeyTypedDuringIt() async throws { let editor = FocusedEditor(text: "a ") @@ -280,18 +387,25 @@ private struct FocusedEditor { @MainActor private final class ParkingDelegate: CodeSuggestionDelegate { private let answers: [String: [String]] - private let reranked: [String: [String]] + var reranked: [String: [String]] private let triggerCharacters: Set + var needsFresh: Set private var parked: CheckedContinuation? private var parkingWaiter: CheckedContinuation? private(set) var askedAt: [Int] = [] private(set) var rerankedPrefixes: [String] = [] private(set) var didCloseCount = 0 - init(answers: [String: [String]], reranked: [String: [String]], triggerCharacters: Set = []) { + init( + answers: [String: [String]], + reranked: [String: [String]], + triggerCharacters: Set = [], + needsFresh: Set = [] + ) { self.answers = answers self.reranked = reranked self.triggerCharacters = triggerCharacters + self.needsFresh = needsFresh } func untilParked() async { @@ -342,6 +456,10 @@ private final class ParkingDelegate: CodeSuggestionDelegate { return reranked[prefix.text]?.map { StubSuggestionEntry(label: $0) } } + func completionNeedsFreshItems(textView: TextViewController, cursorPosition: CursorPosition) -> Bool { + needsFresh.contains(Self.prefix(in: textView, endingAt: cursorPosition.range.location).text) + } + func completionWindowDidClose() { didCloseCount += 1 } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift index 1d4a90533c..f252fc9987 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionShowCompletionsGuardTests.swift @@ -31,6 +31,7 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { await model.itemsRequestTask?.value XCTAssertEqual(presentationCount, 0) + XCTAssertEqual(delegate.shownItemCounts, []) } @MainActor @@ -63,6 +64,7 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { XCTAssertEqual(presentedWindows.count, 1) XCTAssertIdentical(presentedWindows.first, window) + XCTAssertEqual(delegate.shownItemCounts, [1]) } } @@ -70,6 +72,7 @@ final class SuggestionShowCompletionsGuardTests: XCTestCase { private final class DelayedStubDelegate: CodeSuggestionDelegate { let items: [CodeSuggestionEntry] private let fetch: AsyncGate + private(set) var shownItemCounts: [Int] = [] init(items: [CodeSuggestionEntry], fetch: AsyncGate) { self.items = items @@ -92,6 +95,10 @@ private final class DelayedStubDelegate: CodeSuggestionDelegate { nil } + func completionWindowDidShow(textView: TextViewController, itemCount: Int) { + shownItemCounts.append(itemCount) + } + func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarNameFilter.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarNameFilter.swift index 528e4be395..5bebb58b20 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarNameFilter.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarNameFilter.swift @@ -10,32 +10,73 @@ public enum SidebarNameMatchTier: Int, Comparable { } } -public enum SidebarNameFilter { - public static func matches(query: String, candidate: String) -> Bool { - tier(query: query, candidate: candidate) != nil +// Trimmed once for a whole pass over the names, not once per name. +public struct SidebarNameQuery: Equatable, Sendable { + public let text: String + + public init(_ query: String) { + self.text = query.trimmingCharacters(in: .whitespaces) } - public static func tier(query: String, candidate: String) -> SidebarNameMatchTier? { - let trimmed = query.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return .exact } + public var isEmpty: Bool { + text.isEmpty + } + + public func tier(of candidate: String) -> SidebarNameMatchTier? { + guard !text.isEmpty else { return .exact } let candidateString = candidate as NSString - let range = candidateString.localizedStandardRange(of: trimmed) + let range = candidateString.localizedStandardRange(of: text) guard range.location != NSNotFound else { return nil } guard range.location == 0 else { return .substring } return range.length == candidateString.length ? .exact : .prefix } +} + +// Read back exact, prefix, substring: the stable sort by tier, without sorting. +public struct SidebarRankedMatches { + private var exact: [Element] = [] + private var prefix: [Element] = [] + private var substring: [Element] = [] + + public init() {} + + public mutating func append(_ element: Element, tier: SidebarNameMatchTier) { + switch tier { + case .exact: exact.append(element) + case .prefix: prefix.append(element) + case .substring: substring.append(element) + } + } + + public var elements: [Element] { + exact + prefix + substring + } +} + +public enum SidebarNameFilter { + public static func matches(query: String, candidate: String) -> Bool { + tier(query: query, candidate: candidate) != nil + } + + public static func tier(query: String, candidate: String) -> SidebarNameMatchTier? { + SidebarNameQuery(query).tier(of: candidate) + } public static func ranked(_ items: [Element], query: String, name: (Element) -> String) -> [Element] { - let trimmed = query.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return items } - let scored = items.enumerated().compactMap { index, element -> (element: Element, tier: SidebarNameMatchTier, index: Int)? in - guard let tier = tier(query: trimmed, candidate: name(element)) else { return nil } - return (element, tier, index) + ranked(items, query: SidebarNameQuery(query), name: name) + } + + public static func ranked( + _ items: [Element], + query: SidebarNameQuery, + name: (Element) -> String + ) -> [Element] { + guard !query.isEmpty else { return items } + var matches = SidebarRankedMatches() + for item in items { + guard let tier = query.tier(of: name(item)) else { continue } + matches.append(item, tier: tier) } - return scored - .sorted { lhs, rhs in - lhs.tier != rhs.tier ? lhs.tier < rhs.tier : lhs.index < rhs.index - } - .map(\.element) + return matches.elements } } diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarSearch.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarSearch.swift index 68724e6b54..5a52dfff1f 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarSearch.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/UI/SidebarSearch.swift @@ -11,11 +11,16 @@ import Foundation public struct SidebarSearch: Equatable, Sendable { public let text: String public let qualified: QualifiedSearchQuery? + private let textQuery: SidebarNameQuery + private let nameMatcher: SidebarNameQuery public init(_ text: String) { let trimmed = text.trimmingCharacters(in: .whitespaces) + let qualified = QualifiedSearchQuery(trimmed) self.text = trimmed - self.qualified = QualifiedSearchQuery(trimmed) + self.qualified = qualified + self.textQuery = SidebarNameQuery(trimmed) + self.nameMatcher = SidebarNameQuery(qualified?.name ?? trimmed) } public var isEmpty: Bool { @@ -29,16 +34,40 @@ public struct SidebarSearch: Equatable, Sendable { } public func matchesName(_ name: String) -> Bool { - SidebarNameFilter.matches(query: nameQuery, candidate: name) + nameMatcher.tier(of: name) != nil } - /// An object matches as a path, or by holding the whole text in its own name. + // An object matches as a path, or by holding the whole text in its own name. public func matchesObject(named name: String, database: String?, schema: String?) -> Bool { - if SidebarNameFilter.matches(query: text, candidate: name) { return true } + if textQuery.tier(of: name) != nil { return true } guard qualified != nil else { return false } return admits(database: database, schema: schema) && matchesName(name) } + // What `matchesObject` then `SidebarNameFilter.ranked` by `nameQuery` decide, in one look at the name. + public func rankTier(named name: String, database: String?, schema: String?) -> SidebarNameMatchTier? { + guard qualified != nil else { return textQuery.tier(of: name) } + guard let tier = nameMatcher.tier(of: name) else { return nil } + if admits(database: database, schema: schema) || textQuery.tier(of: name) != nil { return tier } + return nil + } + + // The matching objects, exact names first, then prefixes, then the rest, each in arrival order. + public func ranked( + _ items: [Element], + name: (Element) -> String, + database: String?, + schema: (Element) -> String? + ) -> [Element] { + guard !isEmpty else { return items } + var matches = SidebarRankedMatches() + for item in items { + guard let tier = rankTier(named: name(item), database: database, schema: schema(item)) else { continue } + matches.append(item, tier: tier) + } + return matches.elements + } + /// Whether a container at this location may hold a match. Every container may for a plain /// query, which matches on object names alone. public func admits(database: String?, schema: String?) -> Bool { diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarNameFilterTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarNameFilterTests.swift index 3167bf6807..aea7c4d250 100644 --- a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarNameFilterTests.swift +++ b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarNameFilterTests.swift @@ -90,4 +90,23 @@ struct SidebarNameFilterTests { let result = SidebarNameFilter.ranked(names, query: "ab", name: { $0 }) #expect(result == ["ab_x", "ab_a", "ab_m"]) } + + @Test("A prepared query trims once and ranks like the text it was made from") + func preparedQueryRanksLikeText() { + let query = SidebarNameQuery(" user ") + let names = ["audit_user", "users", "user", "orders", "user_log"] + #expect(query.text == "user") + #expect(query.tier(of: "USERS") == .prefix) + #expect(SidebarNameFilter.ranked(names, query: query, name: { $0 }) + == SidebarNameFilter.ranked(names, query: " user ", name: { $0 })) + #expect(SidebarNameFilter.ranked(names, query: query, name: { $0 }) == ["user", "users", "user_log", "audit_user"]) + } + + @Test("An empty prepared query matches every name as exact") + func emptyPreparedQuery() { + let query = SidebarNameQuery(" ") + #expect(query.isEmpty) + #expect(query.tier(of: "anything") == .exact) + #expect(SidebarNameFilter.ranked(["b", "a"], query: query, name: { $0 }) == ["b", "a"]) + } } diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarSearchTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarSearchTests.swift index b4c257b6e4..54eefd0d76 100644 --- a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarSearchTests.swift +++ b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/SidebarSearchTests.swift @@ -75,4 +75,49 @@ struct SidebarSearchTests { #expect(search.admits(database: "shop", schema: nil)) #expect(!search.admits(database: "blog", schema: nil)) } + + private struct Row { + let name: String + let schema: String? + + var path: String { "\(schema ?? "").\(name)" } + } + + private static let rows = [ + Row(name: "audit_user", schema: "public"), + Row(name: "users", schema: "public"), + Row(name: "user", schema: "attendance"), + Row(name: "user_log", schema: "attendance"), + Row(name: "timesheet", schema: "attendance"), + Row(name: "timesheet", schema: "payroll"), + Row(name: "audit.events", schema: "public"), + Row(name: "events", schema: "audit"), + Row(name: "café_orders", schema: nil), + Row(name: "orders", schema: nil) + ] + + @Test( + "One pass keeps the rows and order that filtering then ranking by name gave", + arguments: ["", " ", "user", " USER ", "log", "sheet", "cafe", "attendance.", "attendance.user", + "tend.sheet", "shop.attendance.time", "audit.events", "shop.", "zzz"] + ) + func rankedMatchesFilterThenRank(query: String) { + let search = SidebarSearch(query) + let admitted = Self.rows.filter { search.matchesObject(named: $0.name, database: "shop", schema: $0.schema) } + let twoPasses = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) + let onePass = search.ranked(Self.rows, name: { $0.name }, database: "shop", schema: { $0.schema }) + #expect(onePass.map(\.path) == twoPasses.map(\.path)) + } + + @Test("Each name is read once per pass, matches included") + func rankedReadsEachNameOnce() { + var reads = 0 + let readName = { (row: Row) -> String in + reads += 1 + return row.name + } + let matched = SidebarSearch("user").ranked(Self.rows, name: readName, database: "shop", schema: { $0.schema }) + #expect(matched.map(\.name) == ["user", "users", "user_log", "audit_user"]) + #expect(reads == Self.rows.count) + } } diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 70db6e3f69..8c60c6df99 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -776,7 +776,9 @@ public final class OracleCoreConnection: @unchecked Sendable { let admitted = try admit(role) let result = try await withQueryDeadline { [self] in let zones = await sessionZones(on: connection) - return try await collectRows(statement, options: admitted.options, zones: zones, on: connection) + return try await collectRows( + statement, options: OracleRowLimits.collecting(admitted.options), zones: zones, on: connection + ) } await recordSuccess(of: role, on: admitted.session) forgetSessionZonesIfChanged(by: query) diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleRowLimits.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleRowLimits.swift index 09749f8ac6..a12b06cbd9 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleRowLimits.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleRowLimits.swift @@ -1,5 +1,17 @@ import Foundation +import OracleNIO public enum OracleRowLimits { public static let emergencyMax = 5_000_000 + + // A read that keeps every row asks for them in large batches: at the driver's default of 100 a row, + // the 50,000 tables of a catalog listing took a thousand round trips. + static let collectedBatchRows = 1_000 + + static func collecting(_ options: StatementOptions) -> StatementOptions { + var options = options + options.prefetchRows = collectedBatchRows + options.arraySize = collectedBatchRows + return options + } } diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRowLimitsTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRowLimitsTests.swift new file mode 100644 index 0000000000..1d5761f549 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleRowLimitsTests.swift @@ -0,0 +1,14 @@ +import OracleNIO +@testable import TableProOracleCore +import Testing + +struct OracleRowLimitsTests { + @Test("A read that keeps every row fetches a thousand rows per round trip") + func collectedReadFetchesInLargeBatches() { + let options = OracleRowLimits.collecting(StatementOptions(autoCommit: true)) + + #expect(options.prefetchRows == 1_000) + #expect(options.arraySize == 1_000) + #expect(options.autoCommit) + } +} diff --git a/apple/TablePro/Core/AI/AISchemaContext.swift b/apple/TablePro/Core/AI/AISchemaContext.swift index 8d09184397..3ba5786214 100644 --- a/apple/TablePro/Core/AI/AISchemaContext.swift +++ b/apple/TablePro/Core/AI/AISchemaContext.swift @@ -24,6 +24,15 @@ struct AISchemaTable: Sendable { } } +struct AISchemaRankingInput: Sendable { + let tables: [TableInfo] + let columnsByTable: [String: [ColumnInfo]] + let question: String + let queryReferences: [TableReference] + let defaultSchema: String? + let maxTables: Int +} + struct AISchemaContext { static let maxQueryLength = 2_000 @@ -191,6 +200,26 @@ struct AISchemaContext { return names } + static func relevantTableNames(for input: AISchemaRankingInput) -> [String] { + relevantTableNames( + in: input.tables, + columnsByTable: input.columnsByTable, + question: input.question, + queryReferences: input.queryReferences, + defaultSchema: input.defaultSchema, + maxTables: input.maxTables + ) + } + + // Ranking a large catalog takes hundreds of milliseconds, too long for the main actor. + @concurrent + static func rankOffMainActor( + _ input: AISchemaRankingInput, + using rank: @Sendable (AISchemaRankingInput) -> [String] + ) async -> [String] { + rank(input) + } + static func carriedQuery(_ query: String) -> String { let nsQuery = query as NSString return nsQuery.length > maxQueryLength ? nsQuery.substring(to: maxQueryLength) : query diff --git a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift index f061d8a598..6949899846 100644 --- a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -6,6 +6,7 @@ struct CompletionContext { let items: [SQLCompletionItem] // The wider ranked pool an open popup re-ranks against as the prefix grows; `items` is the slice it shows. let candidates: [SQLCompletionItem] + let candidatesTruncated: Bool let replacementRange: NSRange let sqlContext: SQLContext } @@ -92,6 +93,7 @@ final class CompletionEngine { return CompletionContext( items: context.items, candidates: context.candidates, + candidatesTruncated: context.candidatesTruncated, replacementRange: NSRange(sqlContext.prefixRange), sqlContext: sqlContext ) @@ -105,24 +107,25 @@ final class CompletionEngine { identifierCase: SQLIdentifierCase = .default, forcedTableReferences: [TableReference]? = nil ) async -> CompletionContext? { - let (items, candidates, context) = await provider.completionSession( + let session = await provider.completionSession( text: text, cursorPosition: cursorPosition, forcedTableReferences: forcedTableReferences ) - guard !items.isEmpty else { return nil } + guard !session.items.isEmpty else { return nil } return CompletionContext( items: spelled( - items, - typedPrefix: context.prefix, - context: context, + session.items, + typedPrefix: session.context.prefix, + context: session.context, keywordCase: keywordCase, identifierCase: identifierCase ), - candidates: candidates, - replacementRange: NSRange(context.prefixRange), - sqlContext: context + candidates: session.candidates, + candidatesTruncated: session.candidatesTruncated, + replacementRange: NSRange(session.context.prefixRange), + sqlContext: session.context ) } } diff --git a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift index 29433b95cd..3419a54dfb 100644 --- a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -9,6 +9,8 @@ struct QueryCompletionSession { let replacementRange: NSRange // In document offsets. The re-rank and the accept read it, since a local walk back stops at a space in the name. var quotedName: SQLQuotedName? + // More matched than `candidates` holds, so only the prefix they were ranked for can be re-ranked from them. + var candidatesTruncated = false } @MainActor diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index c00e40c4b3..d6811a2b98 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -82,7 +82,8 @@ final class SQLCompletionService: QueryCompletionService { items: result.items, candidates: result.candidates, replacementRange: NSRange(context.prefixRange), - quotedName: context.quotedName + quotedName: context.quotedName, + candidatesTruncated: result.candidatesTruncated ) } } diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 6fb7ea2c7a..ccf4fa6590 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -87,6 +87,9 @@ actor SQLSchemaProvider { private var knownSchemas: [String] = [] private var knownDatabases: [String] = [] + // Every table-slot request reads all of them, so they are built once per table list, not per keystroke. + private var unqualifiedTableItems: (defaultSchema: String?, items: [SQLCompletionItem])? + private weak var cachedDriver: (any DatabaseDriver)? private let metadataSource: ColumnMetadataSource? private var connectionInfo: DatabaseConnection? @@ -350,6 +353,7 @@ actor SQLSchemaProvider { /// waits for the next request. private func noteTableListWritten() { tableListGeneration &+= 1 + unqualifiedTableItems = nil switch columnPreload { case .awaitingTables: startColumnPreloadIfStale(retryingFailure: true) @@ -490,12 +494,12 @@ actor SQLSchemaProvider { // MARK: - Completion Items - /// Tables a statement can name without their schema. func tableCompletionItems() async -> [SQLCompletionItem] { - let tableData = tablesResolvableUnqualified.map { (name: $0.name, isView: $0.type == .view) } - return await MainActor.run { - tableData.map { SQLCompletionItem.table($0.name, isView: $0.isView) } - } + let defaultSchema = getDefaultSchema() + if let cached = unqualifiedTableItems, cached.defaultSchema == defaultSchema { return cached.items } + let items = tablesResolvableUnqualified.map { SQLCompletionItem.table($0.name, isView: $0.type == .view) } + unqualifiedTableItems = (defaultSchema, items) + return items } func setNamespaces(schemas: [String], databases: [String]) { @@ -511,12 +515,8 @@ actor SQLSchemaProvider { knownDatabases.contains { $0.caseInsensitiveCompare(name) == .orderedSame } } - /// Schema names only — suggested after a database-qualified dot (e.g. "ANALYTICS_PROD."). func schemaCompletionItems() async -> [SQLCompletionItem] { - let schemas = qualifiableSchemas - return await MainActor.run { - schemas.map { SQLCompletionItem.schemaName($0) } - } + qualifiableSchemas.map { SQLCompletionItem.schemaName($0) } } private var qualifiableSchemas: [String] { @@ -526,39 +526,50 @@ actor SQLSchemaProvider { } } - /// Databases + schemas — suggested alongside tables in FROM/JOIN contexts. func namespaceCompletionItems() async -> [SQLCompletionItem] { - let schemas = qualifiableSchemas - let databases = knownDatabases - return await MainActor.run { - databases.map { SQLCompletionItem.databaseName($0) } - + schemas.map { SQLCompletionItem.schemaName($0) } - } + knownDatabases.map { SQLCompletionItem.databaseName($0) } + + qualifiableSchemas.map { SQLCompletionItem.schemaName($0) } } - /// Tables of one schema — suggested after a schema-qualified dot (e.g. "DBT_MARTS."). - /// Falls back to fetching from the database when that schema's tables aren't loaded yet. + // Falls back to fetching from the database when that schema's tables aren't loaded yet. func tableCompletionItems(inSchema schema: String) async -> [SQLCompletionItem] { - let listed = await knownTables(inSchema: schema) - let tableData = listed.map { (name: $0.name, isView: $0.type == .view) } - return await MainActor.run { - tableData.map { SQLCompletionItem.table($0.name, isView: $0.isView) } + await tableCompletionItems(inSchema: schema, isQuoted: false) + } + + func tableCompletionItems(inSchema schema: String, isQuoted: Bool) async -> [SQLCompletionItem] { + await knownTables(inSchema: schema, isQuoted: isQuoted).map { + SQLCompletionItem.table($0.name, isView: $0.type == .view) } } - private func knownTables(inSchema schema: String) async -> [TableInfo] { - let loaded = tables.filter { $0.schema?.caseInsensitiveCompare(schema) == .orderedSame } + // Oracle compares the owner as written, so `lc_s050.` fetches `LC_S050`. A quoted name is the spelling itself, + // and an exact match wins either way: quoted names can differ only in case. + private func catalogSpelling(ofSchema typed: String, isQuoted: Bool) -> String? { + if knownSchemas.contains(typed) { return typed } + guard !isQuoted else { return nil } + return knownSchemas.first { $0.caseInsensitiveCompare(typed) == .orderedSame } + } + + private func knownTables(inSchema typed: String, isQuoted: Bool) async -> [TableInfo] { + let resolved = catalogSpelling(ofSchema: typed, isQuoted: isQuoted) + let schema = resolved ?? typed + let names = { (name: String?) in isQuoted ? name == schema : name?.caseInsensitiveCompare(schema) == .orderedSame } + let loaded = tables.filter { names($0.schema) } guard loaded.isEmpty else { return loaded } - return await onDemandTables(inSchema: schema) + // An engine without schemas qualifies a table by its database, and this scope's own tables name none. + if knownSchemas.isEmpty, names(scopeDatabase) { + return tables + } + return await onDemandTables(inSchema: schema, isCatalogSpelling: resolved != nil) } - /// Concurrent callers await the fetch already in flight, and a fetch that a reset overtook - /// answers its own caller without writing into the scope that replaced it. An empty schema is - /// an answer and is kept; a failed fetch is not, so the next keystroke asks again. - private func onDemandTables(inSchema schema: String) async -> [TableInfo] { - let key = schema.lowercased() - if let cached = onDemandSchemaTables[key] { return cached } - if let inFlight = onDemandSchemaTableTasks[key] { return await inFlight.value ?? [] } + // Concurrent callers await the fetch already in flight, and a fetch that a reset overtook answers its own caller + // without writing into the scope that replaced it. A failed fetch is never kept, and an empty one only for a + // schema the catalog lists: a spelling it does not know may be the wrong case, and would hide the schema until + // the next refresh. + private func onDemandTables(inSchema schema: String, isCatalogSpelling: Bool) async -> [TableInfo] { + if let cached = onDemandSchemaTables[schema] { return cached } + if let inFlight = onDemandSchemaTableTasks[schema] { return await inFlight.value ?? [] } guard let fetchSchemaTables = metadataSource?.fetchSchemaTables else { return [] } let task = Task<[TableInfo]?, Never> { @@ -571,11 +582,11 @@ actor SQLSchemaProvider { return nil } } - onDemandSchemaTableTasks[key] = task + onDemandSchemaTableTasks[schema] = task let fetched = await task.value - guard onDemandSchemaTableTasks[key] == task else { return fetched ?? [] } - onDemandSchemaTableTasks[key] = nil - if let fetched { onDemandSchemaTables[key] = fetched } + guard onDemandSchemaTableTasks[schema] == task else { return fetched ?? [] } + onDemandSchemaTableTasks[schema] = nil + if let fetched, isCatalogSpelling || !fetched.isEmpty { onDemandSchemaTables[schema] = fetched } return fetched ?? [] } @@ -584,22 +595,14 @@ actor SQLSchemaProvider { return tableSchema.caseInsensitiveCompare(schema) == .orderedSame } - /// Get completion items for columns of a specific table func columnCompletionItems(for tableName: String, schema: String? = nil) async -> [SQLCompletionItem] { - let columns = await getColumns(for: tableName, schema: schema) - let columnData = columns.map { col in - (name: col.name, type: col.dataType, isPK: col.isPrimaryKey, - isNullable: col.isNullable, defaultValue: col.defaultValue, comment: col.comment) - } - return await MainActor.run { - columnData.map { - SQLCompletionItem.column( - $0.name, dataType: $0.type, tableName: tableName, - isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment, - identifier: .catalog($0.name) - ) - } + await getColumns(for: tableName, schema: schema).map { column in + SQLCompletionItem.column( + column.name, dataType: column.dataType, tableName: tableName, + isPrimaryKey: column.isPrimaryKey, isNullable: column.isNullable, + defaultValue: column.defaultValue, comment: column.comment, + identifier: .catalog(column.name) + ) } } @@ -695,20 +698,15 @@ actor SQLSchemaProvider { } } - // Capture as immutable for Sendable compliance - let itemData = itemDataBuilder - - return await MainActor.run { - itemData.map { - var item = SQLCompletionItem.column( - $0.label, dataType: $0.type, tableName: $0.table, - isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment, - identifier: $0.identifier - ) - item.isQualifiedReference = $0.isQualified - return item - } + return itemDataBuilder.map { + var item = SQLCompletionItem.column( + $0.label, dataType: $0.type, tableName: $0.table, + isPrimaryKey: $0.isPK, isNullable: $0.isNullable, + defaultValue: $0.defaultValue, comment: $0.comment, + identifier: $0.identifier + ) + item.isQualifiedReference = $0.isQualified + return item } } @@ -770,20 +768,16 @@ actor SQLSchemaProvider { )) } - let itemData = itemDataBuilder - - return await MainActor.run { - itemData.map { - var item = SQLCompletionItem.column( - $0.label, dataType: $0.type, tableName: $0.table, - isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment, - identifier: $0.identifier - ) - item.sortPriority = 150 - item.isQualifiedReference = $0.isQualified - return item - } + return itemDataBuilder.map { + var item = SQLCompletionItem.column( + $0.label, dataType: $0.type, tableName: $0.table, + isPrimaryKey: $0.isPK, isNullable: $0.isNullable, + defaultValue: $0.defaultValue, comment: $0.comment, + identifier: $0.identifier + ) + item.sortPriority = 150 + item.isQualifiedReference = $0.isQualified + return item } } } diff --git a/apple/TablePro/Core/Database/CatalogTableListing.swift b/apple/TablePro/Core/Database/CatalogTableListing.swift index 5cea70047e..af00ac6578 100644 --- a/apple/TablePro/Core/Database/CatalogTableListing.swift +++ b/apple/TablePro/Core/Database/CatalogTableListing.swift @@ -91,11 +91,7 @@ internal enum CatalogTableListing { try await driver.fetchTablesInAllSchemas() } if let listed { - let tables = listed.filter { table in - guard let schema = table.schema else { return true } - return !excluded.contains(schema) - } - return Result(tables: tables, unlistedSchemas: []) + return await listing(listed, excludingSchemas: excluded) } let schemas = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in try await driver.fetchSchemas() @@ -129,13 +125,27 @@ internal enum CatalogTableListing { listed = nil } if let listed { - let schemas = Set(names) - let tables = listed.filter { table in table.schema.map { schemas.contains($0) } ?? true } - return Result(tables: tables, unlistedSchemas: []) + return await listing(listed, keepingSchemas: Set(names)) } return try await tables(inSchemas: names, scope: scope, metadata: metadata) } + // A set read can hold every table of a large catalog; filtering it here would hold the main actor. + @concurrent + nonisolated private static func listing(_ listed: [TableInfo], excludingSchemas excluded: Set) async -> Result { + let tables = listed.filter { table in + guard let schema = table.schema else { return true } + return !excluded.contains(schema) + } + return Result(tables: tables, unlistedSchemas: []) + } + + @concurrent + nonisolated private static func listing(_ listed: [TableInfo], keepingSchemas schemas: Set) async -> Result { + let tables = listed.filter { table in table.schema.map { schemas.contains($0) } ?? true } + return Result(tables: tables, unlistedSchemas: []) + } + /// One lease per schema, so a sidebar expansion queued behind this waits for one schema, not all. /// A lost connection fails every schema alike, so it fails the whole read rather than listing nothing. internal static func tables( diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index b59f1c6417..86bafad920 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -32,6 +32,7 @@ extension DatabaseManager { } MacAnalyticsProvider.shared.markConnectionAttempted() + InteractionTracer.shared.begin(.sidebarListing, key: connection.id.uuidString, event: nil) let attempt = connectionAttempts.begin(for: connection.id) disconnectReasons[connection.id] = nil diff --git a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift index 63b068c207..1469b7dd77 100644 --- a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift +++ b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift @@ -26,9 +26,23 @@ enum DatabaseObjectToolEligibility { static let none = Support() + // Menu validation builds a context for every item it checks, so each driver is probed once. + // Its statement hooks answer the same for the life of the driver. + @MainActor private static let probed = NSMapTable.weakToStrongObjects() + @MainActor static func of(_ driver: DatabaseDriver?) -> Support { guard let driver else { return .none } + if let known = probed.object(forKey: driver) { + return known.support + } + let support = probe(driver) + probed.setObject(ProbedSupport(support), forKey: driver) + return support + } + + @MainActor + private static func probe(_ driver: DatabaseDriver) -> Support { let kinds = UserDefinedTypeInfo.Kind.allCases let probe = { (kind: UserDefinedTypeInfo.Kind) in UserDefinedTypeInfo(name: "t", kind: kind, schema: "s") } let sequenceProbe = PluginSequenceDetails( @@ -56,6 +70,14 @@ enum DatabaseObjectToolEligibility { ) } + private final class ProbedSupport { + let support: Support + + init(_ support: Support) { + self.support = support + } + } + static func commentableTypes(of driver: DatabaseDriver) -> Set { Set(TableInfo.TableType.allCases.filter { type in driver.objectCommentStatement(name: "t", objectType: type.rawValue, schema: "s", comment: nil) != nil diff --git a/apple/TablePro/Core/Diagnostics/InteractionTrace.swift b/apple/TablePro/Core/Diagnostics/InteractionTrace.swift new file mode 100644 index 0000000000..325b5b9af0 --- /dev/null +++ b/apple/TablePro/Core/Diagnostics/InteractionTrace.swift @@ -0,0 +1,117 @@ +// +// InteractionTrace.swift +// TablePro +// + +import Foundation + +internal enum InteractionPath: String, Sendable, CaseIterable { + case sidebarListing + case sidebarFilter + case sidebarExpand + case quickOpenPanel + case quickOpenResult + case completion +} + +internal enum InteractionOutcome: String, Sendable { + case presented + case superseded +} + +internal struct InteractionTiming: Equatable, Sendable { + let sequence: Int + let path: InteractionPath + let outcome: InteractionOutcome + let total: Duration + let detail: String + // systemUptime at the end, so stalls printed against the same clock can be placed inside the trace. + let endUptime: TimeInterval + + var reportLine: String { + var line = """ + interaction #\(sequence) path=\(path.rawValue) outcome=\(outcome.rawValue) \ + totalMs=\(String(format: "%.1f", TableLoadDuration.milliseconds(total))) \ + end=\(String(format: "%.3f", endUptime)) + """ + if !detail.isEmpty { line += " \(detail)" } + return line + } +} + +// One trace per path: a new one means the user moved on, so the open one ends as superseded. +internal struct InteractionTraceRecorder { + private struct Trace { + let sequence: Int + let key: String + let subject: String + let startedAt: ContinuousClock.Instant + var awaitingFrame = false + } + + private var open: [InteractionPath: Trace] = [:] + private var nextSequence = 1 + + internal init() {} + + internal mutating func begin( + _ path: InteractionPath, + key: String, + subject: String, + startedAt: ContinuousClock.Instant, + at now: ContinuousClock.Instant, + uptime: TimeInterval + ) -> (sequence: Int, superseded: InteractionTiming?) { + let superseded = open[path].map { + timing($0, path: path, outcome: .superseded, detail: "", at: now, uptime: uptime) + } + let sequence = nextSequence + nextSequence += 1 + open[path] = Trace(sequence: sequence, key: key, subject: subject, startedAt: startedAt) + return (sequence, superseded) + } + + // The subject is what the trace waits to see: the text a search was typed for, the row being expanded. + internal func subject(of path: InteractionPath, key: String) -> String? { + guard let trace = open[path], trace.key == key, !trace.awaitingFrame else { return nil } + return trace.subject + } + + // A reload while the frame is pending must not end the same trace twice. + internal mutating func awaitFrame(_ path: InteractionPath, key: String) -> Int? { + guard var trace = open[path], trace.key == key, !trace.awaitingFrame else { return nil } + trace.awaitingFrame = true + open[path] = trace + return trace.sequence + } + + internal mutating func framePresented( + _ path: InteractionPath, + sequence: Int, + detail: String, + at now: ContinuousClock.Instant, + uptime: TimeInterval + ) -> InteractionTiming? { + guard let trace = open[path], trace.sequence == sequence else { return nil } + open[path] = nil + return timing(trace, path: path, outcome: .presented, detail: detail, at: now, uptime: uptime) + } + + private func timing( + _ trace: Trace, + path: InteractionPath, + outcome: InteractionOutcome, + detail: String, + at now: ContinuousClock.Instant, + uptime: TimeInterval + ) -> InteractionTiming { + InteractionTiming( + sequence: trace.sequence, + path: path, + outcome: outcome, + total: trace.startedAt.duration(to: now), + detail: detail, + endUptime: uptime + ) + } +} diff --git a/apple/TablePro/Core/Diagnostics/InteractionTracer.swift b/apple/TablePro/Core/Diagnostics/InteractionTracer.swift new file mode 100644 index 0000000000..1d70266825 --- /dev/null +++ b/apple/TablePro/Core/Diagnostics/InteractionTracer.swift @@ -0,0 +1,84 @@ +// +// InteractionTracer.swift +// TablePro +// + +import AppKit +import os + +// Sidebar, Open Quickly and autocomplete on a large catalog (#3661): the key press, click or connect +// to the first frame that shows the answer. +@MainActor +internal final class InteractionTracer { + internal static let shared = InteractionTracer() + + nonisolated internal static let writesToStandardError = + ProcessInfo.processInfo.environment["TABLEPRO_INTERACTION_TRACE"] == "1" + + private let signposter = OSSignposter(subsystem: "com.TablePro", category: .pointsOfInterest) + private let clock = ContinuousClock() + private var recorder = InteractionTraceRecorder() + private var intervals: [Int: OSSignpostIntervalState] = [:] + + // Off unless something reads the traces, so the checks behind an end cost nothing otherwise. + internal var isRecording: Bool { + Self.writesToStandardError || signposter.isEnabled + } + + internal func begin( + _ path: InteractionPath, + key: String = "", + subject: String = "", + event: NSEvent? = NSApp.currentEvent + ) { + guard isRecording else { return } + let now = clock.now + let uptime = ProcessInfo.processInfo.systemUptime + let timestamp = event.flatMap { TabSwitchTracer.inputEventTypes.contains($0.type) ? $0.timestamp : nil } + let startedAt = TabSwitchTraceRecorder.startInstant(eventTimestamp: timestamp, uptime: uptime, now: now) + let (sequence, superseded) = recorder.begin( + path, key: key, subject: subject, startedAt: startedAt, at: now, uptime: uptime + ) + if let superseded { + report(superseded) + } + intervals[sequence] = signposter.beginInterval( + "Interaction", + id: signposter.makeSignpostID(), + "#\(sequence, privacy: .public) \(path.rawValue, privacy: .public)" + ) + } + + internal func subject(of path: InteractionPath, key: String = "") -> String? { + recorder.subject(of: path, key: key) + } + + internal func end(_ path: InteractionPath, key: String = "", detail: String = "", after window: NSWindow?) { + guard let sequence = recorder.awaitFrame(path, key: key) else { return } + let finish: @MainActor () -> Void = { [weak self] in + guard let self, + let timing = self.recorder.framePresented( + path, + sequence: sequence, + detail: detail, + at: self.clock.now, + uptime: ProcessInfo.processInfo.systemUptime + ) + else { return } + self.report(timing) + } + guard let window else { + finish() + return + } + window.afterNextFrame(finish) + } + + private func report(_ timing: InteractionTiming) { + if let interval = intervals.removeValue(forKey: timing.sequence) { + signposter.endInterval("Interaction", interval, "\(timing.outcome.rawValue, privacy: .public)") + } + guard Self.writesToStandardError else { return } + FileHandle.standardError.write(Data("\(timing.reportLine)\n".utf8)) + } +} diff --git a/apple/TablePro/Core/Diagnostics/MainThreadStallMonitor.swift b/apple/TablePro/Core/Diagnostics/MainThreadStallMonitor.swift new file mode 100644 index 0000000000..3174a92446 --- /dev/null +++ b/apple/TablePro/Core/Diagnostics/MainThreadStallMonitor.swift @@ -0,0 +1,56 @@ +// +// MainThreadStallMonitor.swift +// TablePro +// + +import AppKit + +// Each main run loop turn that kept the thread busy for 50 ms or more, on standard error next to the +// interaction traces, so a benchmark can hold every traced path to #3661's 100 ms rule. +@MainActor +internal final class MainThreadStallMonitor { + private static var shared: MainThreadStallMonitor? + private static let reportThreshold: TimeInterval = 0.05 + + private var observers: [CFRunLoopObserver] = [] + private var busySince: TimeInterval? + + internal static func startIfRequested() { + guard InteractionTracer.writesToStandardError, shared == nil else { return } + let monitor = MainThreadStallMonitor() + monitor.start() + shared = monitor + } + + // The turn starts before any other observer and ends after the last one, Core Animation's commit included. + private func start() { + let handler: (CFRunLoopObserver?, CFRunLoopActivity) -> Void = { [weak self] _, activity in + MainActor.assumeIsolated { self?.observe(activity) } + } + let starts: CFRunLoopActivity = [.entry, .afterWaiting] + let ends: CFRunLoopActivity = [.beforeWaiting, .exit] + for (activities, order) in [(starts, CFIndex.min), (ends, CFIndex.max)] { + let observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities.rawValue, true, order, handler) + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + if let observer { observers.append(observer) } + } + } + + private func observe(_ activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + switch activity { + case .afterWaiting: + busySince = now + case .entry: + // Work between a nested loop's exit and the next entry belongs to the turn already running. + if busySince == nil { busySince = now } + default: + if let busySince, now - busySince >= Self.reportThreshold { + let line = String(format: "stall ms=%.1f end=%.3f\n", (now - busySince) * 1_000, now) + FileHandle.standardError.write(Data(line.utf8)) + } + // A nested loop that exits hands the thread back to the outer turn, which keeps working. + busySince = activity == .exit ? now : nil + } + } +} diff --git a/apple/TablePro/Core/Diagnostics/TabSwitchTracer.swift b/apple/TablePro/Core/Diagnostics/TabSwitchTracer.swift index 8b8c4e99cb..dec6edbe08 100644 --- a/apple/TablePro/Core/Diagnostics/TabSwitchTracer.swift +++ b/apple/TablePro/Core/Diagnostics/TabSwitchTracer.swift @@ -14,7 +14,7 @@ internal final class TabSwitchTracer { nonisolated private static let writesToStandardError = ProcessInfo.processInfo.environment["TABLEPRO_TAB_SWITCH_TRACE"] == "1" - private static let inputEventTypes: Set = [ + internal static let inputEventTypes: Set = [ .keyDown, .keyUp, .flagsChanged, .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp ] diff --git a/apple/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift b/apple/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift index 9e279b1817..418f5756e7 100644 --- a/apple/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift +++ b/apple/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift @@ -155,22 +155,7 @@ extension MCPConnectionBridge { scope: scope, workload: table == nil ? .bulk : .interactive ) { driver in - let targets: [String] - if let table { - targets = [table] - } else { - targets = try await driver.fetchTables(schema: schema).map(\.name).sorted() - } - var entries: [JsonValue] = [] - for target in targets { - let indexes = (try? await driver.fetchIndexes(table: target)) ?? [] - guard !indexes.isEmpty else { continue } - entries.append(.object([ - "table": .string(target), - "indexes": .array(indexes.map(MCPConnectionBridge.encode(index:))) - ])) - } - return entries + try await MCPConnectionBridge.indexEntries(using: driver, schema: schema, table: table) } return .object([ "database": .string(scope.database), @@ -179,6 +164,49 @@ extension MCPConnectionBridge { ]) } + static func indexEntries(using driver: DatabaseDriver, schema: String?, table: String?) async throws -> [JsonValue] { + let targets: [String] + let bulkIndexes: [String: [IndexInfo]]? + if let table { + targets = [table] + bulkIndexes = nil + } else { + targets = try await driver.fetchTables(schema: schema).map(\.name).sorted() + bulkIndexes = try await schemaIndexes(using: driver, schema: schema) + } + var entries: [JsonValue] = [] + for target in targets { + let indexes: [IndexInfo] + if let bulkIndexes { + indexes = bulkIndexes[target] ?? [] + } else { + indexes = (try? await driver.fetchIndexes(table: target)) ?? [] + } + guard !indexes.isEmpty else { continue } + entries.append(.object([ + "table": .string(target), + "indexes": .array(indexes.map(MCPConnectionBridge.encode(index:))) + ])) + } + return entries + } + + // Nil sends the caller to the per-table reads: the driver has no single-query form, or it failed. + private static func schemaIndexes(using driver: DatabaseDriver, schema: String?) async throws -> [String: [IndexInfo]]? { + guard let plugin = CompareMetadataService.pluginDriver(from: driver), plugin.providesBulkIndexFetch else { + return nil + } + do { + let indexes = try await plugin.fetchAllIndexes(schema: schema ?? plugin.currentSchema) + return indexes.mapValues { $0.map(IndexInfo.init) } + } catch let cancellation as CancellationError { + throw cancellation + } catch { + logger.warning("Bulk index fetch failed: \(error.publicLogShape, privacy: .public)") + return nil + } + } + func listForeignKeys(scope: DatabaseScope, table: String?) async throws -> JsonValue { try await ensureConnected(scope.connectionId) let schema = scope.schema diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index cf4ff827b6..687fa88b57 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -502,18 +502,18 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor func fetchTables(schema: String?) async throws -> [TableInfo] { let resolvedSchema = schema ?? pluginDriver.currentSchema let pluginTables = try await pluginDriver.fetchTables(schema: resolvedSchema) - return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } + return await Self.mapPluginTables(pluginTables, schemaFallback: resolvedSchema) } func fetchTablesInAllSchemas() async throws -> [TableInfo]? { guard let pluginTables = try await pluginDriver.fetchTablesInAllSchemas() else { return nil } - return pluginTables.map { mapPluginTable($0, schemaFallback: nil) } + return await Self.mapPluginTables(pluginTables, schemaFallback: nil) } func fetchTables(inSchemas schemas: [String]) async throws -> [TableInfo]? { guard let pluginTables = try await pluginDriver.fetchTables(inSchemas: schemas) else { return nil } let schemaFallback = schemas.count == 1 ? schemas.first : nil - return pluginTables.map { mapPluginTable($0, schemaFallback: schemaFallback) } + return await Self.mapPluginTables(pluginTables, schemaFallback: schemaFallback) } var listsTemporaryTables: Bool { @@ -521,7 +521,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor } func fetchTemporaryTables() async throws -> [TableInfo] { - try await pluginDriver.fetchTemporaryTables().map { mapPluginTable($0, schemaFallback: nil, isTemporary: true) } + let pluginTables = try await pluginDriver.fetchTemporaryTables() + return await Self.mapPluginTables(pluginTables, schemaFallback: nil, isTemporary: true) } func fetchPartitionDetails(table: String, schema: String?) async throws -> [PartitionInfo] { @@ -548,7 +549,21 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor PluginTableKindDecoder.decode(declaredType).kind } - private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?, isTemporary: Bool = false) -> TableInfo { + // A listing can name every table of a large catalog, and a caller on the main actor would map it there. + @concurrent + nonisolated private static func mapPluginTables( + _ tables: [PluginTableInfo], + schemaFallback: String?, + isTemporary: Bool = false + ) async -> [TableInfo] { + tables.map { mapPluginTable($0, schemaFallback: schemaFallback, isTemporary: isTemporary) } + } + + nonisolated private static func mapPluginTable( + _ table: PluginTableInfo, + schemaFallback: String?, + isTemporary: Bool + ) -> TableInfo { let decoded = PluginTableKindDecoder.decode(table.type) let tableType: TableInfo.TableType if let mapped = decoded.kind { diff --git a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index fee721c7c4..e043dd0e8c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -344,6 +344,7 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { guard let sidebarState else { return } switch sidebarState.selectedSidebarTab { case .tables: + InteractionTracer.shared.begin(.sidebarFilter, key: sidebarState.connectionId.uuidString, subject: text) sidebarState.searchText = text case .favorites: sidebarState.favoritesSearchText = text diff --git a/apple/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift b/apple/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift index 519af51dbd..12623fc1ac 100644 --- a/apple/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift +++ b/apple/TablePro/Core/Services/Query/AllSchemaTablesDemand.swift @@ -58,7 +58,14 @@ internal struct AllSchemaTablesDemand { return false case .idle: return true - case .loaded, .failed: + case .loaded(let listing): + // A reader that never asked, like the next Open Quickly, would otherwise ask for rows already held. + if listing.unlistedSchemas.isEmpty, + service.isAllSchemaTablesCurrent(connectionId: connectionId, database: database) { + return false + } + return requested[database] != service.allSchemaTablesRevision(connectionId: connectionId, database: database) + case .failed: return requested[database] != service.allSchemaTablesRevision(connectionId: connectionId, database: database) } } diff --git a/apple/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/apple/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index d52f3465ed..f7e894e09b 100644 --- a/apple/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/apple/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -411,6 +411,11 @@ final class DatabaseTreeMetadataService: ObservableObject, CatalogChangeTarget { allSchemaTablesFreshness.revision(for: DatabaseKey(connectionId: connectionId, database: database)) } + func isAllSchemaTablesCurrent(connectionId: UUID, database: String) -> Bool { + isConnected(connectionId) + && allSchemaTablesFreshness.isCurrent(DatabaseKey(connectionId: connectionId, database: database)) + } + /// System schemas stay out, as they stay out of the tree until Show System is on. private func fetchAllSchemaTables( _ key: DatabaseKey, diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 9d9ff5acee..eda57dfcda 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -202917,6 +202917,9 @@ } } } + }, + "Type @ in the message to find any other table" : { + }, "Type %@ in the query to set where the cursor lands after keyword expansion." : { "localizations" : { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 4c30f410e6..9c3f184a7e 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -81,7 +81,8 @@ extension AIChatViewModel { if let question { await self.loadEveryColumnForRanking() guard !Task.isCancelled else { return } - self.prioritizeSchema(for: question) + await self.prioritizeSchema(for: question) + guard !Task.isCancelled else { return } } await self.runSchemaLoad() } @@ -222,18 +223,20 @@ extension AIChatViewModel { } } - private func prioritizeSchema(for question: String) { - let ranked = AISchemaContext.relevantTableNames( - in: tables, + private func prioritizeSchema(for question: String) async { + let input = AISchemaRankingInput( + tables: tables, columnsByTable: columnsByTable, question: question, queryReferences: editorQueryReferences(), defaultSchema: connection.flatMap { defaultSchema(for: $0) }, maxTables: services.appSettings.ai.maxSchemaTables ) - if !ranked.isEmpty { - relevantTableNames = ranked - } + guard input.tables.count > input.maxTables else { return } + let scope = schemaCacheScope + let ranked = await AISchemaContext.rankOffMainActor(input, using: schemaRanker) + guard !Task.isCancelled, schemaCacheScope == scope, !ranked.isEmpty else { return } + relevantTableNames = ranked } // The editor steers the order only while the model is shown the editor's query. diff --git a/apple/TablePro/ViewModels/AIChatViewModel+TableMentions.swift b/apple/TablePro/ViewModels/AIChatViewModel+TableMentions.swift new file mode 100644 index 0000000000..e197e82197 --- /dev/null +++ b/apple/TablePro/ViewModels/AIChatViewModel+TableMentions.swift @@ -0,0 +1,114 @@ +// +// AIChatViewModel+TableMentions.swift +// TablePro +// + +import Combine +import Foundation + +struct TableMentionIndex: Sendable { + static let empty = TableMentionIndex(sortedNames: []) + + let names: [String] + private let foldedNames: [[UInt8]?] + + private init(sortedNames: [String]) { + names = sortedNames + foldedNames = sortedNames.map(Self.asciiFolded) + } + + init(tables: [TableInfo]) { + var seen = Set() + let unique = tables.map(\.name).filter { seen.insert($0).inserted } + self.init(sortedNames: unique.sorted { $0.localizedStandardCompare($1) == .orderedAscending }) + } + + // Sorting and folding a large catalog takes up to a second. + @concurrent + static func build(from tables: [TableInfo]) async -> TableMentionIndex { + TableMentionIndex(tables: tables) + } + + // The names are already sorted, so the first `limit` matches are the ones a sort of every match + // would keep. + func names(matching query: String, limit: Int) -> [String] { + guard limit > 0 else { return [] } + guard !query.isEmpty else { return Array(names.prefix(limit)) } + let needle = Self.asciiFolded(query) + var matches: [String] = [] + for offset in names.indices { + let isMatch = if let needle, let haystack = foldedNames[offset] { + Self.contains(haystack, needle) + } else { + names[offset].localizedCaseInsensitiveContains(query) + } + guard isMatch else { continue } + matches.append(names[offset]) + if matches.count == limit { break } + } + return matches + } + + // Only ASCII folds to ASCII one character for one, which is where a byte search finds what + // `localizedCaseInsensitiveContains` finds; `ß` folds to `ss` and would match `weis`. + private static func asciiFolded(_ text: String) -> [UInt8]? { + guard text.utf8.allSatisfy({ $0 < 0x80 }) else { return nil } + let folded = Array(text.folding(options: .caseInsensitive, locale: .current).utf8) + return folded.allSatisfy { $0 < 0x80 } ? folded : nil + } + + private static func contains(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool { + guard let first = needle.first else { return true } + guard needle.count <= haystack.count else { return false } + for start in 0...(haystack.count - needle.count) where haystack[start] == first { + var matched = 1 + while matched < needle.count, haystack[start + matched] == needle[matched] { + matched += 1 + } + if matched == needle.count { return true } + } + return false + } +} + +extension AIChatViewModel { + static let mentionMenuTableLimit = 100 + + var mentionMenuTableNames: [String] { + Array(tableMentions.names.prefix(Self.mentionMenuTableLimit)) + } + + var hasUnlistedMentionTables: Bool { + tableMentions.names.count > Self.mentionMenuTableLimit + } + + // Started by the panel, so a session whose assistant is never shown never indexes its catalog. + func trackTableMentions() { + if tableMentionObservation == nil { + tableMentionObservation = services.schemaService.$generations + .map { _ in () } + .merge(with: $connection.map { _ in () }) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.refreshTableMentions() } + } + refreshTableMentions() + } + + func refreshTableMentions() { + let current = tables + guard !Self.sameNames(current, tableMentionSource) else { return } + tableMentionSource = current + tableMentionBuild?.cancel() + tableMentionBuild = Task { [weak self] in + let index = await TableMentionIndex.build(from: current) + guard !Task.isCancelled, let self else { return } + self.tableMentions = index + self.tableMentionBuild = nil + } + } + + // A reload hands over a new array of the same tables, and only the names show in a mention. + private static func sameNames(_ lhs: [TableInfo], _ rhs: [TableInfo]) -> Bool { + lhs.count == rhs.count && lhs.indices.allSatisfy { lhs[$0].name == rhs[$0].name } + } +} diff --git a/apple/TablePro/ViewModels/AIChatViewModel.swift b/apple/TablePro/ViewModels/AIChatViewModel.swift index 3df1451e56..21678b4e3f 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel.swift @@ -108,6 +108,11 @@ final class AIChatViewModel: ObservableObject { var inFlightColumnFetches: [String: Task] = [:] var inFlightSchemaLoad: Task? var relevantTableNames: [String] = [] + var schemaRanker: @Sendable (AISchemaRankingInput) -> [String] = AISchemaContext.relevantTableNames(for:) + @Published var tableMentions = TableMentionIndex.empty + var tableMentionSource: [TableInfo] = [] + var tableMentionBuild: Task? + var tableMentionObservation: AnyCancellable? var bulkColumnReadTableCount: Int? var schemaCacheScope: String? // A custom command's message carries the schema it rendered, so its tables are ranked by the @@ -373,6 +378,10 @@ final class AIChatViewModel: ObservableObject { inFlightSchemaLoad?.cancel() inFlightSchemaLoad = nil relevantTableNames = [] + tableMentionBuild?.cancel() + tableMentionBuild = nil + tableMentionSource = [] + tableMentions = .empty bulkColumnReadTableCount = nil schemaCacheScope = nil schemaQuestionOverride = nil diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel+ListingDemand.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel+ListingDemand.swift index dc2838d2db..5a116f980e 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel+ListingDemand.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel+ListingDemand.swift @@ -24,14 +24,14 @@ internal extension QuickSwitcherViewModel { /// the section is full; a table key that has not resolved before that may be in the listing. nonisolated static func recentNeedsListing( recentKeys: [String], - resolvedKeys: Set, + isResolved: (String) -> Bool, isTableKey: (String) -> Bool, limit: Int = QuickSwitcherRanking.recentLimit ) -> Bool { var resolved = 0 for key in recentKeys { guard resolved < limit else { return false } - if resolvedKeys.contains(key) { + if isResolved(key) { resolved += 1 } else if isTableKey(key) { return true diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel+TableRows.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel+TableRows.swift new file mode 100644 index 0000000000..08d60fa5ef --- /dev/null +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel+TableRows.swift @@ -0,0 +1,162 @@ +// +// QuickSwitcherViewModel+TableRows.swift +// TablePro +// + +import Foundation + +extension QuickSwitcherViewModel { + /// The schema service answers for every schema it holds a list of, because it refreshes that + /// list the moment the catalog changes. The all-schema listing fills in the schemas it does not + /// hold, and may be a read behind for those until it is next asked. + /// + /// The schema service keeps its lists until a reload replaces them, so while a database switch + /// settles it still holds the old database's tables, which would open against the new one. + /// They count only once the database they were loaded from is the one being browsed. + /// + /// `coveredSchemas` names the schemas the schema service answers for even when it found them + /// empty. Judged from its rows alone, a schema whose last table was dropped would have no rows, + /// so no say, and the listing's stale copy of that table would come back. + /// + /// The listing already honors the schema filter, but the schema service still holds every schema + /// the sidebar opened before the filter hid it. + nonisolated static func tables(_ tables: [TableInfo], keeping kept: Set?) -> [TableInfo] { + guard let kept else { return tables } + return tables.filter { table in + guard let schema = table.schema else { return true } + return kept.contains(schema) + } + } + + /// `staleSchemas` are the per-schema lists a catalog change has overtaken and nothing has read + /// again, because only an expanded tree row reads one again. The listing, which this panel asks + /// for again after every change, answers for them once it has arrived. + nonisolated static func mergedTables( + local loaded: [TableInfo], + loadedFrom loadedDatabase: String?, + coveredSchemas: Set, + staleSchemas: Set, + listing: [TableInfo]?, + browsing database: String? + ) -> [TableInfo] { + let isCurrent = loadedDatabase == database + let overtaken = listing == nil ? [] : staleSchemas + let local = isCurrent ? loaded.filter { !overtaken.contains($0.schema ?? "") } : [] + let authoritative = (isCurrent ? coveredSchemas.subtracting(overtaken) : []) + .union(local.map { $0.schema ?? "" }) + var seen: Set = [] + return (local + (listing ?? []).filter { !authoritative.contains($0.schema ?? "") }) + .filter { seen.insert(TableIdentity(schema: $0.schema ?? "", name: $0.name)).inserted } + } + + private struct TableIdentity: Hashable { + let schema: String + let name: String + } + + /// A table outside the browsed schema names its schema, and one inside it does not, the way + /// SQL written against the browsed schema would spell them. + nonisolated static func makeTableItems( + _ tables: [TableInfo], + database: String?, + connectionSwitchesDatabases: Bool, + browseSchema: String?, + openTables: Set + ) -> [QuickSwitcherItem] { + let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( + database: database, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) + var listedKeys: Set = [] + return tables.compactMap { table in + let frecencyKey = QuickSwitcherFrecencyKey.table( + name: table.name, schema: table.schema ?? browseSchema, in: qualifier + ) + guard listedKeys.insert(frecencyKey).inserted else { return nil } + let presentation = tablePresentation(for: table.type) + let otherSchema = SchemaQualifiedName.explicitSchema(table.schema, implicitSchemaName: browseSchema) + let subtitle = [otherSchema, presentation.subtitle] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: ", ") + return QuickSwitcherItem( + frecencyKey: frecencyKey, + name: table.name, + kind: presentation.kind, + subtitle: subtitle, + isOpenInTab: openTables.contains( + QuickSwitcherOpenTable( + database: database, + schema: table.schema, + name: table.name, + browsingDatabase: database, + browsingSchema: browseSchema + ) + ), + isReadOnly: !table.type.allowsRowEditing, + schemaName: table.schema, + databaseName: database, + tableType: table.type, + isOutsideBrowsedSchema: otherSchema != nil + ) + } + } + + nonisolated static func makeCrossConnectionItems( + tables: [TableInfo], + target: QuickSwitcherTarget, + connectionSwitchesDatabases: Bool + ) -> [QuickSwitcherItem] { + let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( + database: target.databaseName, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) + var listedKeys: Set = [] + return tables.compactMap { table in + let resolvedTarget = QuickSwitcherTarget( + connectionId: target.connectionId, + connectionName: target.connectionName, + databaseName: target.databaseName, + schemaName: table.schema ?? target.schemaName, + databaseDisplayName: target.databaseDisplayName, + pathFieldRole: target.pathFieldRole + ) + let frecencyKey = QuickSwitcherFrecencyKey.table( + name: table.name, schema: resolvedTarget.schemaName, in: qualifier + ) + guard listedKeys.insert(frecencyKey).inserted else { return nil } + let presentation = tablePresentation(for: table.type) + return QuickSwitcherItem( + frecencyKey: frecencyKey, + name: table.name, + kind: presentation.kind, + subtitle: connectionPath(for: resolvedTarget), + isReadOnly: !table.type.allowsRowEditing, + target: resolvedTarget, + tableType: table.type + ) + } + } + + nonisolated private static func tablePresentation( + for type: TableInfo.TableType + ) -> (kind: QuickSwitcherItemKind, subtitle: String) { + switch type { + case .table: + return (.table, "") + case .view: + return (.view, String(localized: "View")) + case .materializedView: + return (.view, String(localized: "Materialized View")) + case .foreignTable: + return (.table, String(localized: "Foreign Table")) + case .systemTable: + return (.systemTable, String(localized: "System")) + case .partitionedTable: + return (.table, String(localized: "Partitioned Table")) + case .externalTable: + return (.table, String(localized: "External Table")) + case .sequence: + return (.table, String(localized: "Sequence")) + } + } +} diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift index 816776578f..a946110541 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -3,6 +3,7 @@ // TablePro // +import AppKit import Combine import Foundation import os @@ -75,6 +76,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") private static let filterDebounceNanoseconds: UInt64 = 40_000_000 + nonisolated private static let cancellationCheckStride = 1_024 private let services: AppServices private let connectionId: UUID @@ -99,8 +101,15 @@ internal final class QuickSwitcherViewModel: ObservableObject { } private var baseItems: [QuickSwitcherItem]? private var tableItems: [QuickSwitcherItem] = [] + private var tableItemKeys: Set = [] + private var tableInputs: TableInputs? + private var pendingTableBuild: (inputs: TableInputs, task: Task)? + private(set) var tableItemsBuildCount = 0 private var tableSource: TableSource? private var tableSourceObservations: [AnyCancellable] = [] + private var isTableSourceChangeScheduled = false + private var pendingListingRefresh = false + private var pendingTableMerge = false private var listingDemand = AllSchemaTablesDemand() private var hasLoadedBrowseCatalog = false private var listingRequestId: UUID? @@ -140,6 +149,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { didSet { guard oldValue != searchText else { return } pendingSelectionMoves = [] + InteractionTracer.shared.begin( + .quickOpenResult, key: connectionId.uuidString, subject: trimmedSearchText + ) scheduleFilter(debounced: true) refreshListingDemand() } @@ -252,6 +264,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { grouping: services.pluginManager.databaseGroupingStrategy(for: databaseType) ) self.tableSource = tableSource + tableInputs = nil observeTableSources() async let tablesLoaded: Void = loadTables(from: tableSource, loadId: loadId) @@ -276,7 +289,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { } baseItems = catalog.items } - tableItems = currentTableItems() + await rebuildTableItemsIfNeeded() + // A build that finished before the catalog arrived published nothing, so this load publishes either way. publishItems() await tablesLoaded } @@ -427,7 +441,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { _ = await services.schemaRefreshService.loadBrowseCatalogs(connectionIds: [connectionId]) guard activeLoadId == loadId else { return } hasLoadedBrowseCatalog = true - mergeTableItems() + await rebuildTableItemsIfNeeded() refreshListingDemand() } @@ -436,12 +450,15 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// while it is open asks for the listing again. private func observeTableSources() { tableSourceObservations = [ - services.schemaService.onMainActorChange { [weak self] in self?.mergeTableItems() }, + services.schemaService.onMainActorChange { [weak self] in + self?.noteTableSourceChange(refreshesListing: false, mergesTables: true) + }, DatabaseTreeMetadataService.shared.onMainActorChange { [weak self] in - self?.refreshListingDemand() - self?.mergeTableItems() + self?.noteTableSourceChange(refreshesListing: true, mergesTables: true) }, - services.databaseManager.onMainActorChange { [weak self] in self?.refreshListingDemand() } + services.databaseManager.onMainActorChange { [weak self] in + self?.noteTableSourceChange(refreshesListing: true, mergesTables: false) + } ] } @@ -467,8 +484,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { Task { [weak self] in await service.loadAllSchemaTables(connectionId: connectionId, database: database, selection: selection) guard let self, self.listingRequestId == requestId else { return } - self.isListingInFlight = false - self.mergeTableItems() + await self.rebuildTableItemsIfNeeded() + if self.listingRequestId == requestId { self.isListingInFlight = false } } } @@ -478,50 +495,15 @@ internal final class QuickSwitcherViewModel: ObservableObject { database: tableSource.database, connectionSwitchesDatabases: tableSource.connectionSwitchesDatabases ) + let baseKeys = Set((baseItems ?? []).map(\.frecencyKey)) + let tableKeys = tableItemKeys return Self.recentNeedsListing( recentKeys: frecencyStore.recentItemIds(), - resolvedKeys: Set((tableItems + (baseItems ?? [])).map(\.frecencyKey)), + isResolved: { tableKeys.contains($0) || baseKeys.contains($0) }, isTableKey: { QuickSwitcherFrecencyKey.isTable($0, in: qualifier) } ) } - private func mergeTableItems() { - let items = currentTableItems() - guard items != tableItems else { return } - tableItems = items - publishItems() - } - - private func currentTableItems() -> [QuickSwitcherItem] { - guard let tableSource else { return [] } - let listing = tableSource.database.flatMap { database -> [TableInfo]? in - guard tableSource.listsTablesPerSchema else { return nil } - return DatabaseTreeMetadataService.shared - .allSchemaTablesLoadState(connectionId: connectionId, database: database).value?.tables - } - let schemaService = services.schemaService - let loadedScope = schemaService.loadedScope(for: connectionId) - let tables = Self.mergedTables( - local: schemaService.allLoadedTables(for: connectionId), - loadedFrom: loadedScope?.database, - coveredSchemas: coveredSchemas(loadedScope: loadedScope, grouping: tableSource.grouping), - staleSchemas: schemaService.schemasWithLoadedTables(for: connectionId) - .subtracting(schemaService.schemasWithCurrentTables(for: connectionId)), - listing: listing, - browsing: tableSource.database - ) - let kept = tableSource.database.flatMap { - SidebarSchemaFilter(databaseManager: services.databaseManager).keptSchemas(connectionId: connectionId, database: $0) - } - return Self.makeTableItems( - Self.tables(tables, keeping: kept), - database: tableSource.database, - connectionSwitchesDatabases: tableSource.connectionSwitchesDatabases, - browseSchema: tableSource.browseSchema, - openTables: tableSource.openTables - ) - } - /// Nothing is shown before the rest of the catalog has arrived, as before tables were merged /// separately: assigning `allItems` is what ends the load. private func publishItems() { @@ -718,49 +700,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { ) } - /// The schema service answers for every schema it holds a list of, because it refreshes that - /// list the moment the catalog changes. The all-schema listing fills in the schemas it does not - /// hold, and may be a read behind for those until it is next asked. - /// - /// The schema service keeps its lists until a reload replaces them, so while a database switch - /// settles it still holds the old database's tables, which would open against the new one. - /// They count only once the database they were loaded from is the one being browsed. - /// - /// `coveredSchemas` names the schemas the schema service answers for even when it found them - /// empty. Judged from its rows alone, a schema whose last table was dropped would have no rows, - /// so no say, and the listing's stale copy of that table would come back. - /// - /// The listing already honors the schema filter, but the schema service still holds every schema - /// the sidebar opened before the filter hid it. - nonisolated static func tables(_ tables: [TableInfo], keeping kept: Set?) -> [TableInfo] { - guard let kept else { return tables } - return tables.filter { table in - guard let schema = table.schema else { return true } - return kept.contains(schema) - } - } - - /// `staleSchemas` are the per-schema lists a catalog change has overtaken and nothing has read - /// again, because only an expanded tree row reads one again. The listing, which this panel asks - /// for again after every change, answers for them once it has arrived. - nonisolated static func mergedTables( - local loaded: [TableInfo], - loadedFrom loadedDatabase: String?, - coveredSchemas: Set, - staleSchemas: Set, - listing: [TableInfo]?, - browsing database: String? - ) -> [TableInfo] { - let isCurrent = loadedDatabase == database - let overtaken = listing == nil ? [] : staleSchemas - let local = isCurrent ? loaded.filter { !overtaken.contains($0.schema ?? "") } : [] - let authoritative = (isCurrent ? coveredSchemas.subtracting(overtaken) : []) - .union(local.map { $0.schema ?? "" }) - var seen: Set = [] - return (local + (listing ?? []).filter { !authoritative.contains($0.schema ?? "") }) - .filter { seen.insert(TableIdentity(schema: $0.schema ?? "", name: $0.name)).inserted } - } - /// The schemas the schema service holds an answer for. On a schema-grouped engine its flat list /// is the browsed schema's; on a hierarchical one each schema keeps a list of its own, and the /// flat list is empty whatever the browsed schema holds. @@ -774,94 +713,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { return covered } - private struct TableIdentity: Hashable { - let schema: String - let name: String - } - - /// A table outside the browsed schema names its schema, and one inside it does not, the way - /// SQL written against the browsed schema would spell them. - nonisolated static func makeTableItems( - _ tables: [TableInfo], - database: String?, - connectionSwitchesDatabases: Bool, - browseSchema: String?, - openTables: Set - ) -> [QuickSwitcherItem] { - let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( - database: database, - connectionSwitchesDatabases: connectionSwitchesDatabases - ) - var listedKeys: Set = [] - return tables.compactMap { table in - let frecencyKey = QuickSwitcherFrecencyKey.table( - name: table.name, schema: table.schema ?? browseSchema, in: qualifier - ) - guard listedKeys.insert(frecencyKey).inserted else { return nil } - let presentation = tablePresentation(for: table.type) - let otherSchema = SchemaQualifiedName.explicitSchema(table.schema, implicitSchemaName: browseSchema) - let subtitle = [otherSchema, presentation.subtitle] - .compactMap { $0?.isEmpty == false ? $0 : nil } - .joined(separator: ", ") - return QuickSwitcherItem( - frecencyKey: frecencyKey, - name: table.name, - kind: presentation.kind, - subtitle: subtitle, - isOpenInTab: openTables.contains( - QuickSwitcherOpenTable( - database: database, - schema: table.schema, - name: table.name, - browsingDatabase: database, - browsingSchema: browseSchema - ) - ), - isReadOnly: !table.type.allowsRowEditing, - schemaName: table.schema, - databaseName: database, - tableType: table.type, - isOutsideBrowsedSchema: otherSchema != nil - ) - } - } - - nonisolated static func makeCrossConnectionItems( - tables: [TableInfo], - target: QuickSwitcherTarget, - connectionSwitchesDatabases: Bool - ) -> [QuickSwitcherItem] { - let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( - database: target.databaseName, - connectionSwitchesDatabases: connectionSwitchesDatabases - ) - var listedKeys: Set = [] - return tables.compactMap { table in - let resolvedTarget = QuickSwitcherTarget( - connectionId: target.connectionId, - connectionName: target.connectionName, - databaseName: target.databaseName, - schemaName: table.schema ?? target.schemaName, - databaseDisplayName: target.databaseDisplayName, - pathFieldRole: target.pathFieldRole - ) - let frecencyKey = QuickSwitcherFrecencyKey.table( - name: table.name, schema: resolvedTarget.schemaName, in: qualifier - ) - guard listedKeys.insert(frecencyKey).inserted else { return nil } - let presentation = tablePresentation(for: table.type) - return QuickSwitcherItem( - frecencyKey: frecencyKey, - name: table.name, - kind: presentation.kind, - subtitle: connectionPath(for: resolvedTarget), - isReadOnly: !table.type.allowsRowEditing, - target: resolvedTarget, - tableType: table.type - ) - } - } - func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { guard let target = item.target else { return true } return target.connectionId == connectionId @@ -932,6 +783,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { self.isFiltering = false self.reconcileSelection(query: query, scope: scope) self.applyPendingSelectionMoves() + self.endResultTrace(query: query, groups: groups) } } @@ -943,6 +795,19 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } + private func endResultTrace(query: String, groups: [Group]) { + let tracer = InteractionTracer.shared + let key = connectionId.uuidString + let results = groups.reduce(0) { $0 + $1.items.count } + guard results > 0, tracer.subject(of: .quickOpenResult, key: key) == query else { return } + tracer.end( + .quickOpenResult, + key: key, + detail: "chars=\(query.count) results=\(results) tableBuilds=\(tableItemsBuildCount)", + after: NSApp.keyWindow + ) + } + /// Commits the pending refilter now instead of waiting out its debounce, so a Return typed /// straight after the last keystroke commits the result for what was typed rather than /// finding no selection and doing nothing. @@ -983,14 +848,16 @@ internal final class QuickSwitcherViewModel: ObservableObject { return source.filter { includedKinds.contains($0.kind) } } + // A superseded pass returns empty at the next check; the caller drops it either way. @concurrent - nonisolated private static func emptyQueryGroups( + nonisolated static func emptyQueryGroups( items: [QuickSwitcherItem], scope: QuickSwitcherScope, recentKeys: [String], connectionId: UUID ) async -> [Group] { let recent = recentItems(in: items, keys: recentKeys, ownedBy: connectionId) + guard !Task.isCancelled else { return [] } let recentKeySet = Set(recent.map(\.frecencyKey)) let isRecent: (QuickSwitcherItem) -> Bool = { item in item.belongs(to: connectionId) && recentKeySet.contains(item.frecencyKey) @@ -1009,6 +876,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { guard scope != .all else { return result } for kind in QuickSwitcherItemKind.displayOrder { + guard !Task.isCancelled else { return [] } let kindItems = items .filter { $0.kind == kind && !isRecent($0) } .sorted { lhs, rhs in @@ -1032,8 +900,11 @@ internal final class QuickSwitcherViewModel: ObservableObject { ) -> [QuickSwitcherItem] { let rank = Dictionary(recentKeys.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first }) var rankedItems: [Int: QuickSwitcherItem] = [:] - for item in items where item.belongs(to: connectionId) { - guard let position = rank[item.frecencyKey], rankedItems[position] == nil else { continue } + for (index, item) in items.enumerated() { + if index.isMultiple(of: cancellationCheckStride), Task.isCancelled { return [] } + guard item.belongs(to: connectionId), + let position = rank[item.frecencyKey], + rankedItems[position] == nil else { continue } rankedItems[position] = item } return rankedItems @@ -1050,8 +921,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { if isExcluded(item) { count += 1 } } let availableCount = max(0, QuickSwitcherRanking.maxResults - excludedCount) - let sortedItems = items - .filter { !isExcluded($0) } + let candidates = items.filter { !isExcluded($0) } + guard !Task.isCancelled else { return [] } + let sortedItems = candidates .sorted { lhs, rhs in let lhsConnection = lhs.target?.connectionName ?? "" let rhsConnection = rhs.target?.connectionName ?? "" @@ -1079,8 +951,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } + // A superseded keystroke stops at the next check instead of competing with the current pass. @concurrent - nonisolated private static func filteredGroups( + nonisolated static func filteredGroups( items: [QuickSwitcherItem], query: String, frecencyScores: [String: Double], @@ -1088,9 +961,11 @@ internal final class QuickSwitcherViewModel: ObservableObject { ) async -> [Group] { let qualified = QualifiedSearchQuery(query) let prefersShorterNames = qualified.map { !$0.name.isEmpty } ?? true - var ranked = items.compactMap { item -> (item: QuickSwitcherItem, rank: Double)? in + var ranked: [(item: QuickSwitcherItem, rank: Double)] = [] + for (index, item) in items.enumerated() { + if index.isMultiple(of: cancellationCheckStride), Task.isCancelled { return [] } guard let (matchScore, matchedIndices) = bestMatch(for: item, query: query, qualified: qualified) else { - return nil + continue } var matched = item matched.matchedIndices = matchedIndices @@ -1098,8 +973,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { let frecency = 1 + recalled * QuickSwitcherRanking.frecencyBoost let openBoost = item.isOpenInTab ? QuickSwitcherRanking.openTabBoost : 1 let location = item.isOutsideBrowsedSchema ? QuickSwitcherRanking.otherSchemaWeight : 1 - return (matched, matchScore * item.kind.rankWeight * frecency * openBoost * location) + ranked.append((item: matched, rank: matchScore * item.kind.rankWeight * frecency * openBoost * location)) } + guard !Task.isCancelled else { return [] } ranked.sort { lhs, rhs in if lhs.rank != rhs.rank { return lhs.rank > rhs.rank } let lhsOrder = QuickSwitcherItemKind.displayOrder.firstIndex(of: lhs.item.kind) ?? Int.max @@ -1175,29 +1051,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - nonisolated private static func tablePresentation( - for type: TableInfo.TableType - ) -> (kind: QuickSwitcherItemKind, subtitle: String) { - switch type { - case .table: - return (.table, "") - case .view: - return (.view, String(localized: "View")) - case .materializedView: - return (.view, String(localized: "Materialized View")) - case .foreignTable: - return (.table, String(localized: "Foreign Table")) - case .systemTable: - return (.systemTable, String(localized: "System")) - case .partitionedTable: - return (.table, String(localized: "Partitioned Table")) - case .externalTable: - return (.table, String(localized: "External Table")) - case .sequence: - return (.table, String(localized: "Sequence")) - } - } - nonisolated static func connectionPath(for target: QuickSwitcherTarget) -> String { var components = [target.connectionName] if let databaseDisplayName = target.databaseDisplayName ?? target.databaseName, @@ -1288,6 +1141,134 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } +// The services publish for every connection, so only a change to what these rows read rebuilds them. +extension QuickSwitcherViewModel { + // An unchanged listing is the same array, which `==` answers without a scan. + fileprivate struct TableInputs: Equatable { + let schemaGeneration: Int + let loadedScope: DatabaseScope? + let listing: CatalogTableListing.Result? + let keptSchemas: Set? + } + + // Each service publishes once per property it writes, so one write burst lands as several wakes. + private func noteTableSourceChange(refreshesListing: Bool, mergesTables: Bool) { + pendingListingRefresh = pendingListingRefresh || refreshesListing + pendingTableMerge = pendingTableMerge || mergesTables + guard !isTableSourceChangeScheduled else { return } + isTableSourceChangeScheduled = true + Task { @MainActor [weak self] in + await self?.applyTableSourceChanges() + } + } + + private func applyTableSourceChanges() async { + isTableSourceChangeScheduled = false + let refreshesListing = pendingListingRefresh + let mergesTables = pendingTableMerge + pendingListingRefresh = false + pendingTableMerge = false + if refreshesListing { refreshListingDemand() } + if mergesTables { await rebuildTableItemsIfNeeded() } + } + + // A call that finds a build of the same inputs running waits for that one instead of starting another. + private func rebuildTableItemsIfNeeded() async { + guard let tableSource else { return } + let inputs = currentTableInputs(for: tableSource) + if let pendingTableBuild, pendingTableBuild.inputs == inputs { + await pendingTableBuild.task.value + return + } + guard inputs != tableInputs else { return } + tableInputs = inputs + tableItemsBuildCount += 1 + let request = tableBuildRequest(from: inputs, source: tableSource) + let previous = tableItems + let task = Task { @MainActor [weak self] in + let built = await Self.buildTableItems(request, replacing: previous) + guard let self, self.tableInputs == inputs, let built else { return } + self.tableItems = built.items + self.tableItemKeys = built.keys + self.publishItems() + } + pendingTableBuild = (inputs, task) + await task.value + if pendingTableBuild?.inputs == inputs { pendingTableBuild = nil } + } + + private func currentTableInputs(for source: TableSource) -> TableInputs { + let listing = source.database.flatMap { database -> CatalogTableListing.Result? in + guard source.listsTablesPerSchema else { return nil } + return DatabaseTreeMetadataService.shared + .allSchemaTablesLoadState(connectionId: connectionId, database: database).value + } + return TableInputs( + schemaGeneration: services.schemaService.generationToken(for: connectionId), + loadedScope: services.schemaService.loadedScope(for: connectionId), + listing: listing, + keptSchemas: source.database.flatMap { + SidebarSchemaFilter(databaseManager: services.databaseManager).keptSchemas(connectionId: connectionId, database: $0) + } + ) + } + + fileprivate struct TableBuildRequest: Sendable { + let local: [TableInfo] + let loadedFrom: String? + let coveredSchemas: Set + let staleSchemas: Set + let listing: [TableInfo]? + let keptSchemas: Set? + let database: String? + let connectionSwitchesDatabases: Bool + let browseSchema: String? + let openTables: Set + } + + private func tableBuildRequest(from inputs: TableInputs, source: TableSource) -> TableBuildRequest { + let schemaService = services.schemaService + return TableBuildRequest( + local: schemaService.allLoadedTables(for: connectionId), + loadedFrom: inputs.loadedScope?.database, + coveredSchemas: coveredSchemas(loadedScope: inputs.loadedScope, grouping: source.grouping), + staleSchemas: schemaService.schemasWithLoadedTables(for: connectionId) + .subtracting(schemaService.schemasWithCurrentTables(for: connectionId)), + listing: inputs.listing?.tables, + keptSchemas: inputs.keptSchemas, + database: source.database, + connectionSwitchesDatabases: source.connectionSwitchesDatabases, + browseSchema: source.browseSchema, + openTables: source.openTables + ) + } + + // Tens of thousands of rows, so built off the main actor. Nil when they come out as they were. + @concurrent + nonisolated private static func buildTableItems( + _ request: TableBuildRequest, + replacing previous: [QuickSwitcherItem] + ) async -> (items: [QuickSwitcherItem], keys: Set)? { + let tables = mergedTables( + local: request.local, + loadedFrom: request.loadedFrom, + coveredSchemas: request.coveredSchemas, + staleSchemas: request.staleSchemas, + listing: request.listing, + browsing: request.database + ) + let items = makeTableItems( + Self.tables(tables, keeping: request.keptSchemas), + database: request.database, + connectionSwitchesDatabases: request.connectionSwitchesDatabases, + browseSchema: request.browseSchema, + openTables: request.openTables + ) + guard items != previous else { return nil } + return (items, Set(items.map(\.frecencyKey))) + } +} + private extension QuickSwitcherItemKind { static let displayOrder: [QuickSwitcherItemKind] = [ .table, .view, .systemTable, .database, .schema, diff --git a/apple/TablePro/ViewModels/SidebarViewModel.swift b/apple/TablePro/ViewModels/SidebarViewModel.swift index 08d82276cf..fbb94820db 100644 --- a/apple/TablePro/ViewModels/SidebarViewModel.swift +++ b/apple/TablePro/ViewModels/SidebarViewModel.swift @@ -109,11 +109,14 @@ final class SidebarViewModel: ObservableObject { @Published private(set) var filterQuery = "" { didSet { invalidateFilterCaches() + if filterQuery.isEmpty { listingMatchesMemo.removeAll() } listingDemand.reset() loadAllSchemaTablesForSearch() } } + private var listingMatchesMemo = DatabaseTreeFilter.SchemaListingMatchesMemo() + private var listingDemand = AllSchemaTablesDemand() private var listingDemandObservations: [AnyCancellable] = [] @@ -528,13 +531,25 @@ final class SidebarViewModel: ObservableObject { return expanded[kind] } - /// A qualified search reaches this list only when it names the schema being browsed: the flat - /// list holds that schema alone, and the other schemas it names are listed below it. + // A qualified search reaches this list only when it names the browsed schema; other schemas are listed below it. private func applyQuery(_ query: String, to tables: [TableInfo]) -> [TableInfo] { - let search = SidebarSearch(query) - let database = browsedDatabase - let admitted = tables.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } - return SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) + SidebarSearch(query).ranked(tables, name: { $0.name }, database: browsedDatabase, schema: { $0.schema }) + } + + // The outline, the sidebar's body and the hierarchical tree all judge schemas against this one scan. + func schemaListingMatches(database: String, searchText: String) -> DatabaseTreeFilter.SchemaListingMatches? { + let service = DatabaseTreeMetadataService.shared + guard let listing = service.allSchemaTablesLoadState(connectionId: connectionId, database: database).value else { + return nil + } + return listingMatchesMemo.matches( + listing: listing, + database: database, + searchText: searchText, + covered: DatabaseTreeFilter.coveredSchemas( + service.allSchemaTablesSelection(connectionId: connectionId, database: database) + ) + ) } /// Goes through DatabaseTreeFilter so the flat root and the tree share one dedup owner. The diff --git a/apple/TablePro/Views/AIChat/AIChatPanelView.swift b/apple/TablePro/Views/AIChat/AIChatPanelView.swift index 8dddee0d30..8f2558e756 100644 --- a/apple/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/apple/TablePro/Views/AIChat/AIChatPanelView.swift @@ -67,6 +67,7 @@ struct AIChatPanelView: View { .environment(\.chatApprovalSessionId, viewModel.sessionId) .onAppear { viewModel.connection = connection + viewModel.trackTableMentions() } .onChange(of: connection.id) { _ in viewModel.connection = connection @@ -393,18 +394,19 @@ struct AIChatPanelView: View { .disabled(viewModel.tables.isEmpty) Menu(String(localized: "Tables")) { - let sortedTables = viewModel.tables.sorted { - $0.name.localizedStandardCompare($1.name) == .orderedAscending - } - ForEach(sortedTables, id: \.name) { table in + ForEach(viewModel.mentionMenuTableNames, id: \.self) { name in Button { - viewModel.attach(.table(connectionId: connectionId, name: table.name)) + viewModel.attach(.table(connectionId: connectionId, name: name)) } label: { - Text(table.name) + Text(name) } } + if viewModel.hasUnlistedMentionTables { + Divider() + Text("Type @ in the message to find any other table") + } } - .disabled(viewModel.tables.isEmpty) + .disabled(viewModel.tableMentions.names.isEmpty) Button { if let query = currentQuery, !query.isEmpty { @@ -582,13 +584,9 @@ struct AIChatPanelView: View { } let tableBudget = max(0, (Self.maxMentionCandidates / 2) - items.count) - let matchingTables = viewModel.tables - .filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - .prefix(tableBudget) - for table in matchingTables { + for name in viewModel.tableMentions.names(matching: query, limit: tableBudget) { items.append(MentionCandidate( - item: .table(connectionId: connectionId, name: table.name) + item: .table(connectionId: connectionId, name: name) )) } diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index 79995d4cf5..cc1e7add05 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -12,6 +12,16 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { var replacementRange: NSRange var tokenStart: Int var quotedName: QuotedName? + var rankedPrefix: String + var candidatesTruncated: Bool + + // The pool holds only matches for the prefix it was ranked for, so a shorter one misses `ac...` after `ab`. + // A cut pool holds the best of those only: `lc_t25` re-ranks to `lc_t00250` and never reaches `lc_t25000`. + func holdsEveryMatch(for prefix: String) -> Bool { + let typed = prefix.lowercased() + let ranked = rankedPrefix.lowercased() + return candidatesTruncated ? typed == ranked : typed.hasPrefix(ranked) + } } // Typing lands before a closed name's close, so the close keeps its distance from the end of the text. @@ -95,6 +105,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { isManualTrigger: Bool ) async -> CodeSuggestionResponse? { guard !textView.textView.hasMarkedText() else { return nil } + InteractionTracer.shared.begin(.completion) do { try await Task.sleep(nanoseconds: debounceNanoseconds) @@ -123,19 +134,22 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { // The analyzer reads a quoted name whole; the local walk back stops at its last space. let tokenStart = result.quotedName == nil ? localStart : result.replacementRange.location let prefixRange = NSRange(location: tokenStart, length: offset - tokenStart) + let prefix = text.substring(with: prefixRange) session = Session( candidates: result.candidates, replacementRange: result.replacementRange, tokenStart: tokenStart, quotedName: result.quotedName.map { name in QuotedName(name: name, closeFromEnd: name.close.map { text.length - $0 }) - } + }, + rankedPrefix: prefix, + candidatesTruncated: result.candidatesTruncated ) return CodeSuggestionResponse( items: result.items.map { SQLSuggestionEntry(item: $0) }, windowPosition: liveCursorPosition, - prefix: CodeSuggestionPrefix(range: prefixRange, text: text.substring(with: prefixRange)) + prefix: CodeSuggestionPrefix(range: prefixRange, text: prefix) ) } @@ -144,18 +158,32 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { textView: TextViewController, cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { - guard let session, !textView.textView.hasMarkedText() else { return nil } + guard let session, !textView.textView.hasMarkedText(), + let prefix = typedPrefix(in: textView, at: cursorPosition, for: session) else { return nil } + + let ranked = service.rank(session.candidates, prefix: prefix) + return ranked.isEmpty ? nil : ranked.map { SQLSuggestionEntry(item: $0) } + } + // Every row of a cut pool's re-rank matches what was typed, so the window keeps them while it asks for the rest. + func completionNeedsFreshItems(textView: TextViewController, cursorPosition: CursorPosition) -> Bool { + guard let session, !textView.textView.hasMarkedText(), + let prefix = typedPrefix(in: textView, at: cursorPosition, for: session) else { return false } + return !session.holdsEveryMatch(for: prefix) + } + + private func typedPrefix(in textView: TextViewController, at cursorPosition: CursorPosition, for session: Session) -> String? { let offset = cursorPosition.range.location guard let text = textView.textView.textStorage?.string as NSString?, offset >= 0, offset <= text.length, tokenStart(in: text, endingAt: offset, for: session) == session.tokenStart else { return nil } let length = offset - session.tokenStart guard length > 0, length <= maximumPrefixLength else { return nil } + return text.substring(with: NSRange(location: session.tokenStart, length: length)) + } - let prefix = text.substring(with: NSRange(location: session.tokenStart, length: length)) - let ranked = service.rank(session.candidates, prefix: prefix) - return ranked.isEmpty ? nil : ranked.map { SQLSuggestionEntry(item: $0) } + func completionWindowDidShow(textView: TextViewController, itemCount: Int) { + InteractionTracer.shared.end(.completion, detail: "items=\(itemCount)", after: textView.view.window) } func completionWindowDidClose() { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 67ede9f42b..44ec500a21 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -19,6 +19,7 @@ extension MainContentCoordinator { quickSwitcherPanel.dismiss() return } + InteractionTracer.shared.begin(.quickOpenPanel, key: connectionId.uuidString) if #available(macOS 14.0, *) { FeatureTipSignals.quickSwitcherOpened() } @@ -47,6 +48,9 @@ extension MainContentCoordinator { onDismiss: { [weak self] in self?.quickSwitcherPanel?.dismiss() } ) quickSwitcherPanel.present(panelView, over: contentWindow, identity: Self.openQuicklyPanelIdentity) + InteractionTracer.shared.end( + .quickOpenPanel, key: connectionId.uuidString, after: quickSwitcherPanel.presentedWindow + ) } func handleQuickSwitcherSelection(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent = .open) { diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 5cca43ff45..a851917b92 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -105,6 +105,8 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { var isPresented: Bool { panel != nil } + var presentedWindow: NSWindow? { panel } + /// Whether the panel on screen is the one a caller named. One controller serves Open Quickly /// and Jump to Column, so a command that toggles its own panel has to ask which one is up /// rather than whether any is, or Command Shift J would close an Open Quickly it never opened. diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index 19e895056d..b3b5bb2dcb 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -13,6 +13,80 @@ struct DatabaseTreeContainerKey: Hashable { let searchText: String } +struct DatabaseTreeContainerSlot: Hashable { + let database: String + let schema: String? +} + +// Equal only while both hold the same storage: `TableInfo ==` leaves out the comment and row count a row draws. +struct SameRows: Equatable { + let rows: [Element] + + static func == (lhs: SameRows, rhs: SameRows) -> Bool { + guard lhs.rows.count == rhs.rows.count else { return false } + guard !lhs.rows.isEmpty else { return true } + return lhs.rows.withUnsafeBufferPointer { left in + rhs.rows.withUnsafeBufferPointer { right in left.baseAddress == right.baseAddress } + } + } +} + +// Everything one container's filter pass reads: a pass whose inputs have not moved is not run again. +struct DatabaseTreeBucketSource: Equatable { + let tables: SameRows + let routines: SameRows + let triggers: SameRows + let userTypes: SameRows + let searchText: String + let database: String? + + init( + tables: [TableInfo], + routines: [RoutineInfo], + triggers: [TriggerInfo], + userTypes: [UserDefinedTypeInfo], + searchText: String, + database: String? + ) { + self.tables = SameRows(rows: tables) + self.routines = SameRows(rows: routines) + self.triggers = SameRows(rows: triggers) + self.userTypes = SameRows(rows: userTypes) + self.searchText = searchText + self.database = database + } + + func filtered() -> DatabaseTreeObjectBuckets { + DatabaseTreeFilter.objectBuckets( + tables: tables.rows, + routines: routines.rows, + triggers: triggers.rows, + userTypes: userTypes.rows, + searchText: searchText, + database: database + ) + } +} + +// Kept across reloads; `dropUnused()` lets go of containers nobody asked for since the last call. +struct DatabaseTreeBucketMemo { + private var entries: [Key: (source: DatabaseTreeBucketSource, buckets: DatabaseTreeObjectBuckets)] = [:] + private var used: Set = [] + + mutating func buckets(for key: Key, source: DatabaseTreeBucketSource) -> DatabaseTreeObjectBuckets { + used.insert(key) + if let entry = entries[key], entry.source == source { return entry.buckets } + let buckets = source.filtered() + entries[key] = (source, buckets) + return buckets + } + + mutating func dropUnused() { + entries = entries.filter { used.contains($0.key) } + used.removeAll() + } +} + /// One filtered pass over a container's objects, split by kind. The container row needs the counts /// and each group row under it needs one bucket, so both read this rather than filtering the whole /// list again per group. @@ -100,9 +174,7 @@ enum DatabaseTreeFilter { /// `database` is where the objects live when the caller knows it. A qualified search matches /// each object's own schema, and the database only when the search names one. static func filteredTables(_ tables: [TableInfo], searchText: String, database: String? = nil) -> [TableInfo] { - let search = SidebarSearch(searchText) - let admitted = tables.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } - let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) + let matched = SidebarSearch(searchText).ranked(tables, name: { $0.name }, database: database, schema: { $0.schema }) return deduplicated(matched, by: \.id) } @@ -111,9 +183,7 @@ enum DatabaseTreeFilter { searchText: String, database: String? = nil ) -> [RoutineInfo] { - let search = SidebarSearch(searchText) - let admitted = routines.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } - let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) + let matched = SidebarSearch(searchText).ranked(routines, name: { $0.name }, database: database, schema: { $0.schema }) return deduplicated(matched, by: \.id) } @@ -125,8 +195,7 @@ enum DatabaseTreeFilter { database: String? = nil ) -> [TriggerInfo] { let search = SidebarSearch(searchText) - let named = triggers.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } - let matched = SidebarNameFilter.ranked(named, query: search.nameQuery, name: { $0.name }) + let matched = search.ranked(triggers, name: { $0.name }, database: database, schema: { $0.schema }) let byTable = search.nameQuery.isEmpty ? [] : triggers.filter { trigger in @@ -141,9 +210,7 @@ enum DatabaseTreeFilter { searchText: String, database: String? = nil ) -> [UserDefinedTypeInfo] { - let search = SidebarSearch(searchText) - let admitted = types.filter { search.matchesObject(named: $0.name, database: database, schema: $0.schema) } - let matched = SidebarNameFilter.ranked(admitted, query: search.nameQuery, name: { $0.name }) + let matched = SidebarSearch(searchText).ranked(types, name: { $0.name }, database: database, schema: { $0.schema }) return deduplicated(matched, by: \.id) } @@ -217,45 +284,24 @@ enum DatabaseTreeFilter { connectionId: UUID, schema: String, searchText: String, - database: String? + database: String?, + filter: (DatabaseTreeBucketSource) -> DatabaseTreeObjectBuckets = { $0.filtered() } ) -> LoadedSchemaContent? { guard service.hasLoadedContent(for: connectionId, schema: schema) else { return nil } return LoadedSchemaContent( - buckets: objectBuckets( + buckets: filter(DatabaseTreeBucketSource( tables: service.tables(for: connectionId, schema: schema), routines: service.routines(for: connectionId, schema: schema), triggers: service.triggers(for: connectionId, schema: schema), userTypes: service.userDefinedTypes(for: connectionId, schema: schema), searchText: searchText, database: database - ), + )), isSettled: service.isSchemaSettled(for: connectionId, schema: schema), isCurrent: service.isSchemaCurrent(for: connectionId, schema: schema) ) } - /// The listing of the database the hierarchical tree's schemas were read from, which during a - /// database switch is still the one being left. - @MainActor - static func hierarchicalListingMatches( - in treeMetadata: DatabaseTreeMetadataService, - schemaService: SchemaService, - connectionId: UUID, - searchText: String - ) -> SchemaListingMatches? { - guard let database = schemaService.loadedScope(for: connectionId)?.database, - let listing = treeMetadata.allSchemaTablesLoadState(connectionId: connectionId, database: database).value - else { return nil } - return SchemaListingMatches( - listing: listing, - database: database, - searchText: searchText, - covered: coveredSchemas( - treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: database) - ) - ) - } - /// The schemas a held listing answers for, nil when it answers for every schema. static func coveredSchemas(_ selection: CatalogTableListing.SchemaSelection?) -> Set? { guard case .only(let kept) = selection else { return nil } @@ -264,7 +310,7 @@ enum DatabaseTreeFilter { /// A schema the search matched by name shows everything inside it. Filtering its objects by the /// same query leaves the matched schema reporting no items. - static func hierarchicalObjectBuckets( + static func hierarchicalBucketSource( schema: String, tables: [TableInfo], routines: [RoutineInfo], @@ -272,9 +318,9 @@ enum DatabaseTreeFilter { userTypes: [UserDefinedTypeInfo], searchText: String, database: String? = nil - ) -> DatabaseTreeObjectBuckets { + ) -> DatabaseTreeBucketSource { let matchesSchema = SidebarSearch(searchText).matchesContainer(database: database, schema: schema) - return objectBuckets( + return DatabaseTreeBucketSource( tables: tables, routines: routines, triggers: triggers, @@ -368,6 +414,44 @@ enum DatabaseTreeFilter { } } + // Redraws and body evaluations share one scan per database until the listing, search or coverage moves. + struct SchemaListingMatchesMemo { + private struct Entry { + let tables: SameRows + let unlisted: Set + let searchText: String + let covered: Set? + let matches: SchemaListingMatches + } + + private var entries: [String: Entry] = [:] + + mutating func matches( + listing: CatalogTableListing.Result, + database: String, + searchText: String, + covered: Set? + ) -> SchemaListingMatches { + if let entry = entries[database], entry.searchText == searchText, entry.covered == covered, + entry.unlisted == listing.unlistedSchemas, entry.tables == SameRows(rows: listing.tables) { + return entry.matches + } + let matches = SchemaListingMatches(listing: listing, database: database, searchText: searchText, covered: covered) + entries[database] = Entry( + tables: SameRows(rows: listing.tables), + unlisted: listing.unlistedSchemas, + searchText: searchText, + covered: covered, + matches: matches + ) + return matches + } + + mutating func removeAll() { + entries.removeAll() + } + } + /// The schema's own loaded lists answer first. A schema the tree has not loaded is judged from /// the database's all-schema listing, and is unknown while that listing is missing, failed, or /// says it could not read the schema, unless a qualified search names another schema: holding @@ -435,15 +519,11 @@ enum DatabaseTreeFilter { browsedSchema: String?, searchText: String, hiddenSchemas: Set, - allSchemaTables: MetadataLoadState, + listingMatches: SchemaListingMatches?, keptSchemas: Set? = nil, - covered: Set? = nil, loadedContent: (String, SchemaListingMatches) -> DatabaseTreeObjectBuckets? ) -> [String] { - guard !SidebarSearch(searchText).isEmpty, let listing = allSchemaTables.value else { return [] } - let listingMatches = SchemaListingMatches( - listing: listing, database: database, searchText: searchText, covered: covered - ) + guard !SidebarSearch(searchText).isEmpty, let listingMatches else { return [] } return listingMatches.listed .filter { schema in guard schema != browsedSchema, !hiddenSchemas.contains(schema) else { return false } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift index 98d132b943..a179675a9d 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -206,7 +206,46 @@ final class DatabaseTreeNode: SidebarOutlineNode { static func tableFolderId(_ folderId: UUID) -> String { "tablefolder\u{1}\(folderId.uuidString)" } static func databaseId(_ database: String) -> String { "db\u{1}\(database)" } static func schemaId(database: String, schema: String) -> String { "schema\u{1}\(database)\u{1}\(schema)" } - static func tableId(_ ref: DatabaseTreeTableRef) -> String { "table\u{1}\(ref.id)" } + static func tableId(_ ref: DatabaseTreeTableRef) -> String { + plainTableId(ref) ?? "table\u{1}\(ref.id)" + } + + // The id `ref.id` spells, built in one buffer when no part needs `IdentityPath` escaping. + private static func plainTableId(_ ref: DatabaseTreeTableRef) -> String? { + let table = ref.table + let database = ref.database ?? "" + let schema = ref.schema ?? "" + let tableSchema = table.schema ?? "" + let type = table.type.rawValue + guard isPlain(database, dots: false), isPlain(schema, dots: false), isPlain(type, dots: false), + isPlain(tableSchema, dots: true), isPlain(table.name, dots: true) + else { return nil } + var id = "table\u{1}" + id.reserveCapacity( + id.utf8.count + database.utf8.count + schema.utf8.count + tableSchema.utf8.count + + table.name.utf8.count + type.utf8.count + 14 + ) + id += database + id += "|" + id += schema + id += "|" + if !tableSchema.isEmpty { + id += tableSchema + id += "." + } + id += table.name + id += "_" + id += type + if table.isTemporary { id += "_temporary" } + return id + } + + private static func isPlain(_ part: String, dots: Bool) -> Bool { + !part.utf8.contains { byte in + byte == UInt8(ascii: "|") || byte == UInt8(ascii: "\\") || (dots && byte == UInt8(ascii: ".")) + } + } + static func partitionId(_ ref: DatabaseTreePartitionRef) -> String { "partition\u{1}\(ref.id)" } static func recentTableId(_ ref: DatabaseTreeTableRef) -> String { "recent\u{1}table\u{1}\(ref.id)" } static let temporarySectionId = "temporary-section" diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift index d518d591bc..94c47a42b3 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -5,6 +5,7 @@ import AppKit import TableProPluginKit +import TableProUtilities /// Which rows are disclosed, where that is remembered, and the loads a disclosure triggers. extension DatabaseTreeOutlineCoordinator { @@ -282,6 +283,9 @@ extension DatabaseTreeOutlineCoordinator { case .partition(let ref): loadPartitions(ref.tableRef ?? ref.parent) case .hierarchicalSchemaSection(let schema): + guard DatabaseTreeFilter.readsOnDisclosure(origin: disclosureOrigin) + || !hierarchicalSearchSkipsRead(schema) + else { return } loadHierarchicalSchemaObjects(schema) case .recentSection, .recentTable, .temporarySection, .temporaryTable, .foldersSection, .tableFolder, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, @@ -297,6 +301,59 @@ extension DatabaseTreeOutlineCoordinator { return windowState?.expandedTreeDatabaseSchemas.contains(key) == true || schemasOpenedDuringSearch.contains(key) } + /// The listing holds tables alone, so a search skips reading only a schema never read before, which + /// it found a matching table in and the user did not open or name. Until the listing lands it reads + /// none: both share one metadata connection, and the reads queued ahead of it held back the search's + /// first answer by seconds. + internal func hierarchicalSearchSkipsRead(_ schema: String) -> Bool { + guard !searchText.isEmpty, + !schemaService.hasLoadedContent(for: connectionId, schema: schema), + !isHierarchicalSchemaOpenedByUser(schema) + else { return false } + if isSearchListingPending { return true } + return !searchNamesSchema(schema) && hierarchicalListingMatchesTable(in: schema) + } + + /// The databases a search reads the all-schema listing of, the ones `SidebarViewModel` asks for. + internal var searchListingDatabases: Set { + SidebarViewModel.databasesListedForSearch( + grouping: PluginManager.shared.databaseGroupingStrategy(for: databaseType), + browsedDatabase: DatabaseManager.shared.browseScope(for: connectionId)?.database, + databasesWithSchemaLists: Set( + service.schemaList.compactMap { key, state in + key.connectionId == connectionId && state.value != nil ? key.database : nil + } + ), + layout: sidebarState?.sidebarLayout ?? .flat + ) + } + + private var isSearchListingPending: Bool { + guard DatabaseManager.shared.session(for: connectionId)?.status == .connected else { return false } + return searchListingDatabases.contains { database in + switch service.allSchemaTablesLoadState(connectionId: connectionId, database: database) { + case .idle, .loading: return true + case .loaded, .failed: return false + } + } + } + + private func searchNamesSchema(_ schema: String) -> Bool { + let search = SidebarSearch(searchText) + if search.matchesContainer(database: browsingDatabase, schema: schema) { return true } + return search.qualified != nil && search.admits(database: browsingDatabase, schema: schema) + } + + internal func isHierarchicalSchemaOpenedByUser(_ schema: String) -> Bool { + windowState?.expandedTreeSchemas.contains(schema) == true + || schemasOpenedDuringSearch.contains(Self.hierarchicalSearchKey(schema)) + } + + /// A tree with one row per schema names no database, so its schemas share one key space here. + internal static func hierarchicalSearchKey(_ schema: String) -> DatabaseSchemaKey { + DatabaseSchemaKey(database: "", schema: schema) + } + /// An expanded schema is the reader a catalog change waits for: it reads a schema never read, /// and one a change has overtaken, once per change. private func loadHierarchicalSchemaObjects(_ schema: String) { diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index 2a263fc930..665e162416 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -7,6 +7,19 @@ import AppKit import TableProPluginKit import TableProUtilities +// The table rows one parent was last built with, so a reload over the same rows skips their ids. +struct DatabaseTreeTableRows { + enum Schema: Equatable { + case own + case fixed(String?) + } + + let tables: SameRows + let database: String? + let schema: Schema + let nodes: [DatabaseTreeNode] +} + /// Turning the connection's metadata into the rows the outline draws. The three sidebar shapes /// differ only in what this builds at the root; everything below the root is shared. extension DatabaseTreeOutlineCoordinator { @@ -261,11 +274,12 @@ extension DatabaseTreeOutlineCoordinator { let database = browsingDatabase switch kind.category { case .table: - return flatPlan().looseObjects(of: kind) - .map { table in - let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } + return tableNodes( + parentId: DatabaseTreeNode.objectKindSectionId(kind), + tables: flatPlan().looseObjects(of: kind), + database: database, + schema: .own + ) case .routine: let routines = viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) return routineNodes(routines, database: database, schema: { $0.schema }) @@ -313,7 +327,8 @@ extension DatabaseTreeOutlineCoordinator { connectionId: connectionId, schema: schema, searchText: searchText, - database: browsingDatabase + database: browsingDatabase, + filter: { self.loadedContentMemo.buckets(for: schema, source: $0) } ), listingMatches: schemaService.loadedScope(for: connectionId).flatMap { listingMatches(database: $0.database) }, listingCoversSchema: !systemSchemas.contains(schema) @@ -324,13 +339,13 @@ extension DatabaseTreeOutlineCoordinator { /// holds only procedures shows a Procedures group instead of reading as empty. private func hierarchicalSchemaNodes(schema: String) -> [DatabaseTreeNode] { let parentId = DatabaseTreeNode.hierarchicalSchemaSectionId(schema) - switch schemaService.schemaState(for: connectionId, schema: schema) { - case .idle, .loading: + let database = hierarchicalGroupDatabase + switch hierarchicalContentSource(schema: schema) { + case .pending: return [statusNode(parentId: parentId, status: .loading)] case .failed(let message): return [statusNode(parentId: parentId, status: .error(message))] case .loaded: - let database = hierarchicalGroupDatabase return groupedObjectNodes( buckets: objectBuckets(database: database, schema: schema), sidePhases: hierarchicalSideLoadStates(schema: schema), @@ -338,9 +353,52 @@ extension DatabaseTreeOutlineCoordinator { schema: schema, parentId: parentId ) + case .listing: + return groupedObjectNodes( + buckets: objectBuckets(database: database, schema: schema), + sidePhases: DatabaseTreeSidePhases(routines: .loaded, triggers: nil, types: nil), + database: database, + schema: schema, + parentId: parentId + ) } } + /// The tree's rule for a schema the search opened: the all-schema listing answers for it, so a + /// search over a hundred schemas reads only those it matched by something other than a table. + private func hierarchicalContentSource(schema: String) -> DatabaseTreeFilter.SchemaContentSource { + let tables: MetadataLoadPhase + switch schemaService.schemaState(for: connectionId, schema: schema) { + case .idle: tables = .idle + case .loading: tables = .loading + case .loaded: tables = .loaded + case .failed(let message): tables = .failed(message) + } + return DatabaseTreeFilter.schemaContentSource( + searching: !searchText.isEmpty, + tables: tables, + isTablesCurrent: schemaService.isSchemaCurrent(for: connectionId, schema: schema), + listingRows: hierarchicalListingRows(schema: schema) + ) + } + + private func hierarchicalListingRows(schema: String) -> [TableInfo]? { + guard let matches = hierarchicalListing(answering: schema) else { return nil } + return matches.rowsBySchema[schema] ?? [] + } + + internal func hierarchicalListingMatchesTable(in schema: String) -> Bool { + hierarchicalListing(answering: schema)?.matched.contains(schema) ?? false + } + + private func hierarchicalListing(answering schema: String) -> DatabaseTreeFilter.SchemaListingMatches? { + guard !searchText.isEmpty, !systemSchemas.contains(schema), + let database = schemaService.loadedScope(for: connectionId)?.database, + let matches = listingMatches(database: database), matches.answers(for: schema) + else { return nil } + return matches + } + /// An engine grouped by hierarchical schema browses no database, so its groups are keyed by /// the browsed one when there is one and by nothing otherwise. private var hierarchicalGroupDatabase: String { @@ -360,13 +418,23 @@ extension DatabaseTreeOutlineCoordinator { ) } - private func hierarchicalObjectBuckets(schema: String) -> DatabaseTreeObjectBuckets { - DatabaseTreeFilter.hierarchicalObjectBuckets( + private func hierarchicalBucketSource(schema: String) -> DatabaseTreeBucketSource { + var tables = schemaService.tables(for: connectionId, schema: schema) + if case .listing(let rows) = hierarchicalContentSource(schema: schema) { + tables = rows + } + // A schema the search opened without reading keeps side lists a change overtook out of it. + let showsOvertaken = !hierarchicalSearchSkipsRead(schema) + || schemaService.isSchemaCurrent(for: connectionId, schema: schema) + func shown(_ items: [Item]) -> [Item] { + showsOvertaken ? items : [] + } + return DatabaseTreeFilter.hierarchicalBucketSource( schema: schema, - tables: schemaService.tables(for: connectionId, schema: schema), - routines: schemaService.routines(for: connectionId, schema: schema), - triggers: schemaService.triggers(for: connectionId, schema: schema), - userTypes: schemaService.userDefinedTypes(for: connectionId, schema: schema), + tables: tables, + routines: shown(schemaService.routines(for: connectionId, schema: schema)), + triggers: shown(schemaService.triggers(for: connectionId, schema: schema)), + userTypes: shown(schemaService.userDefinedTypes(for: connectionId, schema: schema)), searchText: searchText, database: browsingDatabase ) @@ -439,11 +507,8 @@ extension DatabaseTreeOutlineCoordinator { browsedSchema: activeSchema, searchText: searchText, hiddenSchemas: showSystemContainers ? [] : systemSchemas, - allSchemaTables: service.allSchemaTablesLoadState(connectionId: connectionId, database: database), + listingMatches: listingMatches(database: database), keptSchemas: keptSchemas(database: database), - covered: DatabaseTreeFilter.coveredSchemas( - service.allSchemaTablesSelection(connectionId: connectionId, database: database) - ), loadedContent: { schema, _ in self.loadedObjectBuckets(database: database, schema: schema) } ) } @@ -542,45 +607,50 @@ extension DatabaseTreeOutlineCoordinator { internal func objectBuckets(database: String, schema: String?) -> DatabaseTreeObjectBuckets { let key = DatabaseTreeContainerKey(database: database, schema: schema, searchText: searchText) if let cached = objectBucketsCache[key] { return cached } - let buckets: DatabaseTreeObjectBuckets + let source: DatabaseTreeBucketSource if rootShape == .hierarchicalSchema, let schema { - buckets = hierarchicalObjectBuckets(schema: schema) + source = hierarchicalBucketSource(schema: schema) } else { - let tables: [TableInfo] - // A schema drawn from the listing keeps whatever side lists it has read. - if case .listing(let rows) = contentSource(database: database, schema: schema) { - tables = rows - } else { - tables = service.tables(connectionId: connectionId, database: database, schema: schema) - } - // A schema the search opened is never read, so a side list a change overtook would stay - // on screen with objects that no longer exist; it shows what is current only. - let showsOvertaken = searchText.isEmpty || schema.map { isOpenedByUser(database: database, schema: $0) } ?? true - func shown(_ items: [Item], isCurrent: Bool) -> [Item] { - showsOvertaken || isCurrent ? items : [] - } - buckets = DatabaseTreeFilter.objectBuckets( - tables: tables, - routines: shown( - service.routines(connectionId: connectionId, database: database, schema: schema), - isCurrent: service.isRoutinesCurrent(connectionId: connectionId, database: database, schema: schema) - ), - triggers: shown( - service.triggers(connectionId: connectionId, database: database, schema: schema), - isCurrent: service.isTriggersCurrent(connectionId: connectionId, database: database, schema: schema) - ), - userTypes: shown( - service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema), - isCurrent: service.isTypesCurrent(connectionId: connectionId, database: database, schema: schema) - ), - searchText: searchText, - database: database - ) + source = bucketSource(database: database, schema: schema) } + let buckets = bucketMemo.buckets(for: DatabaseTreeContainerSlot(database: database, schema: schema), source: source) objectBucketsCache[key] = buckets return buckets } + private func bucketSource(database: String, schema: String?) -> DatabaseTreeBucketSource { + let tables: [TableInfo] + // A schema drawn from the listing keeps whatever side lists it has read. + if case .listing(let rows) = contentSource(database: database, schema: schema) { + tables = rows + } else { + tables = service.tables(connectionId: connectionId, database: database, schema: schema) + } + // A schema the search opened is never read, so a side list a change overtook would stay + // on screen with objects that no longer exist; it shows what is current only. + let showsOvertaken = searchText.isEmpty || schema.map { isOpenedByUser(database: database, schema: $0) } ?? true + func shown(_ items: [Item], isCurrent: Bool) -> [Item] { + showsOvertaken || isCurrent ? items : [] + } + return DatabaseTreeBucketSource( + tables: tables, + routines: shown( + service.routines(connectionId: connectionId, database: database, schema: schema), + isCurrent: service.isRoutinesCurrent(connectionId: connectionId, database: database, schema: schema) + ), + triggers: shown( + service.triggers(connectionId: connectionId, database: database, schema: schema), + isCurrent: service.isTriggersCurrent(connectionId: connectionId, database: database, schema: schema) + ), + userTypes: shown( + service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema), + isCurrent: service.isTypesCurrent(connectionId: connectionId, database: database, schema: schema) + ), + searchText: searchText, + database: database + ) + } + internal func matchCount(in group: DatabaseTreeObjectGroup) -> Int { let buckets = objectBuckets(database: group.database, schema: group.schema) guard group.kind.category == .table else { return buckets.itemCounts[group.kind] ?? 0 } @@ -669,10 +739,7 @@ extension DatabaseTreeOutlineCoordinator { let plan = containerPlan(database: group.database, schema: group.schema, buckets: buckets) let tables = plan.looseObjects(of: group.kind) guard !tables.isEmpty else { return placeholder } - return tables.map { table in - let ref = DatabaseTreeTableRef(database: database, schema: group.schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } + return tableNodes(parentId: emptyId, tables: tables, database: database, schema: .fixed(group.schema)) case .routine: let routines = buckets.routines[group.kind] ?? [] guard !routines.isEmpty else { return placeholder } @@ -692,6 +759,34 @@ extension DatabaseTreeOutlineCoordinator { } } + // Kinds are written again on a reused row, since another parent may have drawn the same id since. + private func tableNodes( + parentId: String, + tables: [TableInfo], + database: String?, + schema: DatabaseTreeTableRows.Schema + ) -> [DatabaseTreeNode] { + func ref(_ table: TableInfo) -> DatabaseTreeTableRef { + switch schema { + case .own: DatabaseTreeTableRef(database: database, schema: table.schema, table: table) + case .fixed(let fixed): DatabaseTreeTableRef(database: database, schema: fixed, table: table) + } + } + let rows = SameRows(rows: tables) + if let built = tableRowMemo[parentId], built.tables == rows, built.database == database, built.schema == schema { + for (node, table) in zip(built.nodes, tables) { + node.kind = .table(ref(table)) + } + return built.nodes + } + let nodes = tables.map { table in + let tableRef = ref(table) + return node(id: DatabaseTreeNode.tableId(tableRef), kind: .table(tableRef)) + } + tableRowMemo[parentId] = DatabaseTreeTableRows(tables: rows, database: database, schema: schema, nodes: nodes) + return nodes + } + /// The labels are decided over the whole section at once, because "is this name ambiguous" /// is a question about the section and not about the routine. private func routineNodes( @@ -746,22 +841,27 @@ extension DatabaseTreeOutlineCoordinator { ) } - /// Worked out once per database per redraw, since every schema of the database is judged - /// against the same listing. + // Every schema of a database is judged against one scan, shared with the sidebar's body through the view model. private func listingMatches(database: String) -> DatabaseTreeFilter.SchemaListingMatches? { let key = DatabaseTreeContainerKey(database: database, schema: nil, searchText: searchText) if let cached = listingMatchesCache[key] { return cached } - guard let listing = service.allSchemaTablesLoadState(connectionId: connectionId, database: database).value else { - return nil - } - let matches = DatabaseTreeFilter.SchemaListingMatches( - listing: listing, - database: database, - searchText: searchText, - covered: DatabaseTreeFilter.coveredSchemas( - service.allSchemaTablesSelection(connectionId: connectionId, database: database) + let matches: DatabaseTreeFilter.SchemaListingMatches + if let viewModel { + guard let shared = viewModel.schemaListingMatches(database: database, searchText: searchText) else { return nil } + matches = shared + } else { + guard let listing = service.allSchemaTablesLoadState(connectionId: connectionId, database: database).value else { + return nil + } + matches = DatabaseTreeFilter.SchemaListingMatches( + listing: listing, + database: database, + searchText: searchText, + covered: DatabaseTreeFilter.coveredSchemas( + service.allSchemaTablesSelection(connectionId: connectionId, database: database) + ) ) - ) + } listingMatchesCache[key] = matches return matches } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift new file mode 100644 index 0000000000..d5c004a7e7 --- /dev/null +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift @@ -0,0 +1,84 @@ +// +// DatabaseTreeOutlineCoordinator+Tracing.swift +// TablePro +// + +import AppKit + +// The sidebar's interaction traces end on the first reload that shows their answer with nothing left loading. +extension DatabaseTreeOutlineCoordinator { + private var traceKey: String { + connectionId.uuidString + } + + internal func beginExpansionTrace(for node: DatabaseTreeNode) { + guard !isApplyingExpansion else { return } + switch node.kind { + case .database, .schema, .hierarchicalSchemaSection: + InteractionTracer.shared.begin(.sidebarExpand, key: traceKey, subject: node.id) + default: + return + } + } + + internal func endSettledInteractionTraces() { + let tracer = InteractionTracer.shared + guard tracer.isRecording, let outlineView else { return } + let key = traceKey + let awaitsListing = tracer.subject(of: .sidebarListing, key: key) != nil + let filter = tracer.subject(of: .sidebarFilter, key: key) + let expanding = tracer.subject(of: .sidebarExpand, key: key) + guard awaitsListing || filter != nil || expanding != nil, + let rows = settledRowCount(in: outlineView) + else { return } + let window = outlineView.window + if awaitsListing, rows > 0, isCatalogSettled { + tracer.end(.sidebarListing, key: key, detail: "rows=\(rows)", after: window) + } + if let filter, filter == searchText, let listing = searchListingOutcome(for: filter) { + tracer.end( + .sidebarFilter, key: key, detail: "chars=\(filter.count) rows=\(rows) listing=\(listing)", after: window + ) + } + if let expanding, let node = nodeCache[expanding], outlineView.isItemExpanded(node) { + tracer.end(.sidebarExpand, key: key, detail: "rows=\(rows)", after: window) + } + } + + private var isCatalogSettled: Bool { + switch schemaService.state(for: connectionId) { + case .loaded, .failed: return true + case .idle, .loading: return false + } + } + + // Other schemas' matches join a search once the all-schema listings land, and the requests for them + // start a turn after the search does, so an idle listing is still on its way. Nil until they settle. + private func searchListingOutcome(for filter: String) -> String? { + guard !filter.isEmpty else { return "none" } + let databases = searchListingDatabases + guard !databases.isEmpty else { return "none" } + var failed = false + for database in databases { + switch service.allSchemaTablesLoadState(connectionId: connectionId, database: database) { + case .loaded: continue + case .failed: failed = true + case .idle, .loading: return nil + } + } + return failed ? "failed" : "loaded" + } + + // Nil while any open row still reads Loading. A Loading row is always its parent's only child, so the + // children built so far answer this without walking tens of thousands of rows. + private func settledRowCount(in outlineView: NSOutlineView) -> Int? { + for (parentId, children) in childrenCache { + guard children.count == 1, case .status(.loading) = children[0].kind else { continue } + guard !parentId.isEmpty else { return nil } + if let parent = nodeCache[parentId], outlineView.row(forItem: parent) >= 0, outlineView.isItemExpanded(parent) { + return nil + } + } + return outlineView.numberOfRows + } +} diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index bcfbab2433..79d50b3a91 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -44,6 +44,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { internal var childrenCache: [String: [DatabaseTreeNode]] = [:] internal var objectBucketsCache: [DatabaseTreeContainerKey: DatabaseTreeObjectBuckets] = [:] internal var listingMatchesCache: [DatabaseTreeContainerKey: DatabaseTreeFilter.SchemaListingMatches] = [:] + internal var bucketMemo = DatabaseTreeBucketMemo() + internal var loadedContentMemo = DatabaseTreeBucketMemo() + internal var tableRowMemo: [String: DatabaseTreeTableRows] = [:] /// Whether a routine row shows its signature depends on the other rows in its own section, so /// the label is decided where the section is built and looked up here when the row draws. internal var routineDisplayLabels: [String: String] = [:] @@ -80,6 +83,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { private var isReloading = false private var hasRenderedOnce = false private var reconcileScheduled = false + private var isReconcileForced = false + private var renderedInputs: DatabaseTreeRenderInputs? private var observationGeneration = 0 private var appearanceObservation: AnyCancellable? private var treeObservations: [AnyCancellable] = [] @@ -148,7 +153,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { let changed = notification.userInfo?[TableFolderStorage.connectionIdUserInfoKey] as? UUID MainActor.assumeIsolated { guard let self, changed == self.connectionId else { return } - self.scheduleReconcile() + self.scheduleReconcile(forcing: true) } } favoritesObservers.withLockUnchecked { $0.append(folderObserver) } @@ -256,79 +261,48 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { private func beginObserving() { observationGeneration += 1 let generation = observationGeneration - /// `snapshotDependencies` read across four objects, and `objectWillChange` is per - /// object, so each one gets its own sink. `scheduleReconcile` already coalesces, which - /// is what absorbs the wider wake set. - let reconcile: () -> Void = { [weak self] in + let wake: (Bool) -> Void = { [weak self] forcing in guard let self, generation == self.observationGeneration else { return } - self.scheduleReconcile() + self.scheduleReconcile(forcing: forcing) } treeObservations = [ - service.onMainActorChange(reconcile), - schemaService.onMainActorChange(reconcile), - temporaryTables.onMainActorChange(reconcile), - sidebarState?.onMainActorChange(reconcile), - sidebarState?.redisKeyTreeViewModel?.onMainActorChange(reconcile), + service.onMainActorChange { wake(false) }, + schemaService.onMainActorChange { wake(false) }, + temporaryTables.onMainActorChange { wake(false) }, + sidebarState?.onMainActorChange { wake(false) }, + // Its one published value is the key tree this outline draws. + sidebarState?.redisKeyTreeViewModel?.onMainActorChange { wake(true) }, ].compactMap { $0 } } - private func scheduleReconcile() { + private func scheduleReconcile(forcing: Bool = false) { + isReconcileForced = isReconcileForced || forcing guard !reconcileScheduled else { return } reconcileScheduled = true Task { @MainActor in self.reconcileScheduled = false - self.refresh() - } - } - - private func snapshotDependencies() { - _ = service.databaseListState(for: connectionId) - _ = sidebarState?.recentTables - /// Both feed `rootNodes()`: the layout picks the root's shape and the filter picks which - /// databases survive into it. Left unobserved, hiding a database in the filter popover - /// changed nothing until some other edit happened to rebuild the tree. - _ = sidebarState?.sidebarLayout - _ = sidebarState?.databaseFilterSelected - _ = sidebarState?.schemaFilterSelected - /// One token covers every table, routine and per-schema load for this connection, which is - /// the whole reactive surface the flat and hierarchical shapes read. - _ = schemaService.generationToken(for: connectionId) - _ = sidebarState?.redisKeyTreeViewModel?.state - for node in nodeCache.values { - switch node.kind { - case .database(let metadata): - _ = service.schemaListState(connectionId: connectionId, database: metadata.name) - _ = service.tablesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) - _ = service.routinesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) - _ = service.triggersLoadState(connectionId: connectionId, database: metadata.name, schema: nil) - _ = service.typesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) - case .schema(let database, let schema): - _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) - _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) - _ = service.triggersLoadState(connectionId: connectionId, database: database, schema: schema) - _ = service.typesLoadState(connectionId: connectionId, database: database, schema: schema) - case .hierarchicalSchemaSection(let schema): - _ = schemaService.schemaState(for: connectionId, schema: schema) - case .table(let ref) where ref.table.type == .partitionedTable: - _ = service.partitionsLoadState( - connectionId: connectionId, database: ref.database ?? "", schema: ref.schema, table: ref.table.name - ) - case .partition(let ref): - let source = ref.tableRef ?? ref.parent - _ = service.partitionsLoadState( - connectionId: connectionId, - database: source.database ?? "", - schema: source.schema, - table: source.table.name - ) - case .recentSection, .recentTable, .temporarySection, .temporaryTable, .foldersSection, .tableFolder, - .table, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, - .redisKeysSection, .redisNode: - break - } + self.reconcile() } } + // A wake from a shared service rebuilds the outline only when something this connection draws moved. + internal func reconcile() { + let forced = isReconcileForced + isReconcileForced = false + guard forced || currentRenderInputs() != renderedInputs else { return } + refresh() + } + + internal func currentRenderInputs() -> DatabaseTreeRenderInputs { + DatabaseTreeRenderInputs( + connectionId: connectionId, + treeMetadata: service, + schemaService: schemaService, + temporaryTables: temporaryTables, + sidebarState: sidebarState + ) + } + internal func refresh() { guard let outlineView else { return } guard !isDragging, renameSession == nil else { @@ -336,6 +310,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { return } isReloading = true + renderedInputs = currentRenderInputs() + dropUnusedMemos() childrenCache.removeAll() objectBucketsCache.removeAll() listingMatchesCache.removeAll() @@ -348,6 +324,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { syncSelectionToModel() isReloading = false beginObserving() + endSettledInteractionTraces() + } + + // The memos outlive a reload, but only for the parents and containers built since the last one. + private func dropUnusedMemos() { + bucketMemo.dropUnused() + loadedContentMemo.dropUnused() + tableRowMemo = tableRowMemo.filter { childrenCache[$0.key] != nil } } /// Coalesced with any reload already scheduled, because the edit that ended the hold usually @@ -355,7 +339,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { internal func applyDeferredReloadIfNeeded() { guard isReloadDeferred, !isDragging, renameSession == nil else { return } isReloadDeferred = false - scheduleReconcile() + scheduleReconcile(forcing: true) } /// A star toggling on or off changes no row and no ordering, so the rows are reconfigured in @@ -882,6 +866,7 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { /// there is describing the state the row is leaving. func outlineViewItemWillExpand(_ notification: Notification) { guard let node = notification.userInfo?["NSObject"] as? DatabaseTreeNode else { return } + beginExpansionTrace(for: node) triggerLoad(for: node) } @@ -892,8 +877,11 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { restoreDescendantExpansion(afterExpanding: node) } else if !isApplyingExpansion, case .schema(let database, let schema) = node.kind { schemasOpenedDuringSearch.insert(DatabaseSchemaKey(database: database, schema: schema)) + } else if !isApplyingExpansion, case .hierarchicalSchemaSection(let schema) = node.kind { + schemasOpenedDuringSearch.insert(Self.hierarchicalSearchKey(schema)) } scheduleSelectionSync() + endSettledInteractionTraces() } private func scheduleSelectionSync(force: Bool = false) { @@ -926,6 +914,8 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { recordExpansion(node, expanded: false) } else if case .schema(let database, let schema) = node.kind { schemasOpenedDuringSearch.remove(DatabaseSchemaKey(database: database, schema: schema)) + } else if case .hierarchicalSchemaSection(let schema) = node.kind { + schemasOpenedDuringSearch.remove(Self.hierarchicalSearchKey(schema)) } } scheduleSelectionSync(force: true) diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift new file mode 100644 index 0000000000..746b6580d7 --- /dev/null +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift @@ -0,0 +1,141 @@ +// +// DatabaseTreeRenderInputs.swift +// TablePro +// + +import Foundation + +// What one connection's outline draws from the services it observes. Those services publish for every +// connection and every property, so a wake rebuilds the outline only when this value moved. +struct DatabaseTreeRenderInputs: Equatable { + typealias DatabaseKey = DatabaseTreeMetadataService.DatabaseKey + typealias ObjectsKey = DatabaseTreeMetadataService.ObjectsKey + + struct Rows: Equatable { + let phase: MetadataLoadPhase + let rows: SameRows + + init(_ state: MetadataLoadState<[Element]>) { + self.phase = state.erased + self.rows = SameRows(rows: state.value ?? []) + } + } + + struct Currency: Equatable { + let tables: Bool + let routines: Bool + let triggers: Bool + let types: Bool + } + + struct Listing: Equatable { + let phase: MetadataLoadPhase + let tables: SameRows + let unlisted: Set + let covered: CatalogTableListing.SchemaSelection? + } + + struct TemporaryTables: Equatable { + let tables: SameRows + let session: ObjectIdentifier + } + + struct Sidebar: Equatable { + let recentTables: [RecentTableEntry] + let layout: SidebarLayout + let databaseFilter: Set + let schemaFilter: [String: Set] + let redisKeyTree: ObjectIdentifier? + } + + let databases: Rows + let schemaLists: [DatabaseKey: Rows] + let tables: [ObjectsKey: Rows] + let routines: [ObjectsKey: Rows] + let triggers: [ObjectsKey: Rows] + let types: [ObjectsKey: Rows] + let currency: [ObjectsKey: Currency] + let partitions: [DatabaseTreeMetadataService.PartitionsKey: Rows] + let listings: [DatabaseKey: Listing] + let listsRevision: Int + let catalogGeneration: Int + let loadedScope: DatabaseScope? + // `SchemaService.runLoad` settles these a suspension before it moves the generation. + let catalogRoutines: Rows + let catalogTriggers: Rows + let catalogTypes: Rows + let temporaryTables: [TemporaryTableStore.Key: TemporaryTables] + let sidebar: Sidebar? + + @MainActor + init( + connectionId: UUID, + treeMetadata: DatabaseTreeMetadataService, + schemaService: SchemaService, + temporaryTables: TemporaryTableStore, + sidebarState: SharedSidebarState? + ) { + let ownsObjects = { (key: ObjectsKey) in key.connectionId == connectionId } + let tables = Self.slice(treeMetadata.tablesState, where: ownsObjects) { Rows($0) } + let routines = Self.slice(treeMetadata.routinesState, where: ownsObjects) { Rows($0) } + let triggers = Self.slice(treeMetadata.triggersState, where: ownsObjects) { Rows($0) } + let types = Self.slice(treeMetadata.typesState, where: ownsObjects) { Rows($0) } + var currency: [ObjectsKey: Currency] = [:] + for key in Set(tables.keys).union(routines.keys).union(triggers.keys).union(types.keys) { + currency[key] = Currency( + tables: treeMetadata.isTablesCurrent(connectionId: connectionId, database: key.database, schema: key.schema), + routines: treeMetadata.isRoutinesCurrent(connectionId: connectionId, database: key.database, schema: key.schema), + triggers: treeMetadata.isTriggersCurrent(connectionId: connectionId, database: key.database, schema: key.schema), + types: treeMetadata.isTypesCurrent(connectionId: connectionId, database: key.database, schema: key.schema) + ) + } + var listings: [DatabaseKey: Listing] = [:] + for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId { + listings[key] = Listing( + phase: state.erased, + tables: SameRows(rows: state.value?.tables ?? []), + unlisted: state.value?.unlistedSchemas ?? [], + covered: treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: key.database) + ) + } + self.databases = Rows(treeMetadata.databaseListState(for: connectionId)) + self.schemaLists = Self.slice(treeMetadata.schemaList, where: { $0.connectionId == connectionId }) { Rows($0) } + self.tables = tables + self.routines = routines + self.triggers = triggers + self.types = types + self.currency = currency + self.partitions = Self.slice(treeMetadata.partitionsState, where: { $0.connectionId == connectionId }) { Rows($0) } + self.listings = listings + self.listsRevision = treeMetadata.listsRevision(for: connectionId) + self.catalogGeneration = schemaService.generationToken(for: connectionId) + self.loadedScope = schemaService.loadedScope(for: connectionId) + self.catalogRoutines = Rows(schemaService.routinesLoadState(for: connectionId)) + self.catalogTriggers = Rows(schemaService.triggersLoadState(for: connectionId)) + self.catalogTypes = Rows(schemaService.userDefinedTypesLoadState(for: connectionId)) + self.temporaryTables = Self.slice(temporaryTables.listings, where: { $0.connectionId == connectionId }) { + TemporaryTables(tables: SameRows(rows: $0.tables), session: $0.session) + } + self.sidebar = sidebarState.map { state in + Sidebar( + recentTables: state.recentTables, + layout: state.sidebarLayout, + databaseFilter: state.databaseFilterSelected, + schemaFilter: state.schemaFilterSelected, + redisKeyTree: state.redisKeyTreeViewModel.map { ObjectIdentifier($0) } + ) + } + } + + private static func slice( + _ states: [Key: Value], + where belongs: (Key) -> Bool, + _ snapshot: (Value) -> Snapshot + ) -> [Key: Snapshot] { + var slice: [Key: Snapshot] = [:] + for (key, value) in states where belongs(key) { + slice[key] = snapshot(value) + } + return slice + } +} diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeSelection.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeSelection.swift index 40c382c883..85fa896b1f 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeSelection.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeSelection.swift @@ -15,11 +15,10 @@ import Foundation internal enum DatabaseTreeSelection { internal static func isSelectable(_ kind: DatabaseTreeNode.Kind) -> Bool { switch kind { - case .status, .recentSection, .temporarySection, .foldersSection, .objectKindSection, - .hierarchicalSchemaSection, .redisKeysSection: + case .status, .recentSection, .temporarySection, .foldersSection, .objectKindSection, .redisKeysSection: return false - case .database, .schema, .table, .partition, .routine, .trigger, .userType, .recentTable, .temporaryTable, - .containerObjectKindSection, .tableFolder, .redisNode: + case .database, .schema, .hierarchicalSchemaSection, .table, .partition, .routine, .trigger, .userType, + .recentTable, .temporaryTable, .containerObjectKindSection, .tableFolder, .redisNode: return true } } diff --git a/apple/TablePro/Views/Sidebar/SidebarTreeView.swift b/apple/TablePro/Views/Sidebar/SidebarTreeView.swift index a536dc3f59..82868ac200 100644 --- a/apple/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -4,6 +4,7 @@ import TableProPluginKit struct SidebarTreeView: View { @ObservedObject private var databaseManager = DatabaseManager.shared @ObservedObject private var schemaService = SchemaService.shared + // Read through the view model's listing scan; observed so a listing that lands redraws the verdicts. @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared let connectionId: UUID @@ -48,12 +49,10 @@ struct SidebarTreeView: View { /// whether a schema survived the filter. private var visibleSchemas: [String] { guard !searchText.isEmpty else { return schemas } - let listingMatches = DatabaseTreeFilter.hierarchicalListingMatches( - in: treeMetadata, - schemaService: schemaService, - connectionId: connectionId, - searchText: searchText - ) + // The listing of the database the schemas were read from, which during a switch is the one being left. + let listingMatches = schemaService.loadedScope(for: connectionId).flatMap { + viewModel.schemaListingMatches(database: $0.database, searchText: searchText) + } return schemas.filter { schema in DatabaseTreeFilter.hierarchicalSchemaSearchVerdict( schema: schema, diff --git a/apple/TablePro/Views/Sidebar/SidebarView.swift b/apple/TablePro/Views/Sidebar/SidebarView.swift index 58c7a455e3..3cf187c3d2 100644 --- a/apple/TablePro/Views/Sidebar/SidebarView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarView.swift @@ -94,12 +94,9 @@ struct SidebarView: View { browsedSchema: coordinator?.toolbarState.currentSchema, searchText: searchText, hiddenSchemas: settingsManager.general.showSystemContainers ? [] : systemSchemas, - allSchemaTables: treeMetadata.allSchemaTablesLoadState(connectionId: connectionId, database: database), + listingMatches: viewModel.schemaListingMatches(database: database, searchText: searchText), keptSchemas: SidebarSchemaFilter().keptSchemas(connectionId: connectionId, database: database)? .union(pending), - covered: DatabaseTreeFilter.coveredSchemas( - treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: database) - ), loadedContent: { schema, listingMatches in DatabaseTreeFilter.loadedObjectBuckets( in: treeMetadata, diff --git a/apple/TablePro/main.swift b/apple/TablePro/main.swift index 3b060df69a..7dcfc45f98 100644 --- a/apple/TablePro/main.swift +++ b/apple/TablePro/main.swift @@ -27,6 +27,7 @@ MainActor.assumeIsolated { AppActivationPolicyController.shared.applyLaunchRole() LaunchTracer.shared.mark(.activationRoleApplied) } +MainActor.assumeIsolated { MainThreadStallMonitor.startIfRequested() } let delegate = MainActor.assumeIsolated { AppDelegate() } application.delegate = delegate _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderUnqualifiedScopeTests.swift b/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderUnqualifiedScopeTests.swift index c160b471f0..9015fb3ac0 100644 --- a/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderUnqualifiedScopeTests.swift +++ b/apple/TableProTests/Core/Autocomplete/SQLSchemaProviderUnqualifiedScopeTests.swift @@ -80,6 +80,42 @@ struct SQLSchemaProviderUnqualifiedScopeTests { #expect(!bare.contains { $0.insertText.localizedCaseInsensitiveContains("timesheet") }) } + @Test("On an engine without schemas, the scope's own database and a dot read its loaded tables") + func ownDatabaseQualifierReadsTheLoadedTables() async { + let script = ScriptedFetch<[TableInfo]>([.success([TestFixtures.makeTableInfo(name: "old_orders")])]) + let driver = MockDatabaseDriver(connection: TestFixtures.makeConnection(type: .mysql)) + let provider = SQLSchemaProvider(metadataSource: Self.source(script)) + await provider.resetForDatabase( + "shop", tables: [TestFixtures.makeTableInfo(name: "orders")], driver: driver, connection: driver.connection + ) + await provider.setNamespaces(schemas: [], databases: ["shop", "archive"]) + + let own = await provider.tableCompletionItems(inSchema: "SHOP").map(\.label) + let fetchesBeforeOther = script.calls + let other = await provider.tableCompletionItems(inSchema: "archive").map(\.label) + + #expect(own == ["orders"]) + #expect(fetchesBeforeOther == 0) + #expect(other == ["old_orders"]) + } + + @Test("Another database and a dot complete that database's tables on an engine without schemas") + func databaseQualifierCompletesItsTables() async { + let script = ScriptedFetch<[TableInfo]>([.success([TestFixtures.makeTableInfo(name: "old_orders")])]) + let driver = MockDatabaseDriver(connection: TestFixtures.makeConnection(type: .mysql)) + let schemaProvider = SQLSchemaProvider(metadataSource: Self.source(script)) + await schemaProvider.resetForDatabase( + "shop", tables: [TestFixtures.makeTableInfo(name: "orders")], driver: driver, connection: driver.connection + ) + await schemaProvider.setNamespaces(schemas: [], databases: ["shop", "archive"]) + let completion = SQLCompletionProvider(schemaProvider: schemaProvider, databaseType: .mysql) + + let text = "SELECT * FROM archive." + let items = await completion.getCompletions(text: text, cursorPosition: (text as NSString).length).items + + #expect(items.map(\.label) == ["old_orders"]) + } + @Test("A bare name before a dot resolves only to a table the tab can name without its schema") func bareNameResolvesWithinTheUnqualifiedScope() async { let driver = Self.postgresDriver() @@ -138,6 +174,7 @@ struct SQLSchemaProviderUnqualifiedScopeTests { func emptySchemaIsFetchedOnce() async { let script = ScriptedFetch<[TableInfo]>([.success([])]) let provider = SQLSchemaProvider(metadataSource: Self.source(script)) + await provider.setNamespaces(schemas: ["archive"], databases: []) let first = await provider.tableCompletionItems(inSchema: "archive") let second = await provider.tableCompletionItems(inSchema: "archive") @@ -147,6 +184,81 @@ struct SQLSchemaProviderUnqualifiedScopeTests { #expect(script.calls == 1) } + @Test("An empty answer for a schema the catalog does not list is asked again") + func emptyAnswerForAnUnlistedSchemaIsAskedAgain() async { + let script = ScriptedFetch<[TableInfo]>([.success([]), .success([Self.timesheet])]) + let provider = SQLSchemaProvider(metadataSource: Self.source(script)) + + let first = await provider.tableCompletionItems(inSchema: "attendance").map(\.label) + let second = await provider.tableCompletionItems(inSchema: "attendance").map(\.label) + + #expect(first.isEmpty) + #expect(second == ["timesheet"]) + #expect(script.calls == 2) + } + + @Test("A schema typed in another case fetches the catalog's spelling, which either case then reads") + func schemaTypedInAnotherCaseFetchesTheCatalogSpelling() async { + let table = TestFixtures.makeTableInfo(name: "LC_T001", schema: "LC_S050") + let asked = SchemaSpellingLog() + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _, _ in [] }, + fetchAllColumns: { [:] }, + fetchSchemaTables: { schema in + await asked.append(schema) + return schema == "LC_S050" ? [table] : [] + } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.setNamespaces(schemas: ["LC_S050"], databases: []) + + let lower = await provider.tableCompletionItems(inSchema: "lc_s050").map(\.label) + let upper = await provider.tableCompletionItems(inSchema: "LC_S050").map(\.label) + + #expect(lower == ["LC_T001"]) + #expect(upper == ["LC_T001"]) + #expect(await asked.spellings == ["LC_S050"]) + } + + @Test("A quoted schema in another case fetches the name as written, which the catalog does not hold") + func quotedSchemaKeepsItsSpelling() async { + let table = TestFixtures.makeTableInfo(name: "EMPLOYEES", schema: "HR") + let asked = SchemaSpellingLog() + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _, _ in [] }, + fetchAllColumns: { [:] }, + fetchSchemaTables: { schema in + await asked.append(schema) + return schema == "HR" ? [table] : [] + } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.setNamespaces(schemas: ["HR"], databases: []) + + let quoted = await provider.tableCompletionItems(inSchema: "hr", isQuoted: true).map(\.label) + let bare = await provider.tableCompletionItems(inSchema: "hr", isQuoted: false).map(\.label) + + #expect(quoted.isEmpty) + #expect(bare == ["EMPLOYEES"]) + #expect(await asked.spellings == ["hr", "HR"]) + } + + @Test("Table items are built once per table list") + func tableItemsAreBuiltOncePerTableList() async { + let provider = SQLSchemaProvider() + await provider.updateTables([TestFixtures.makeTableInfo(name: "orders")]) + + let firstIDs = await provider.tableCompletionItems().map(\.id) + let secondIDs = await provider.tableCompletionItems().map(\.id) + await provider.updateTables([TestFixtures.makeTableInfo(name: "orders"), TestFixtures.makeTableInfo(name: "users")]) + let rewritten = await provider.tableCompletionItems() + let rewrittenLabels = rewritten.map(\.label) + + #expect(secondIDs == firstIDs) + #expect(rewrittenLabels == ["orders", "users"]) + #expect(rewritten.first?.id != firstIDs.first) + } + @Test("A failed fetch of another schema's tables is asked again") func failedSchemaFetchIsRetried() async { let script = ScriptedFetch<[TableInfo]>([ @@ -363,3 +475,11 @@ private final class ScriptedFetch: @unchecked Sendable { lock.unlock() } } + +private actor SchemaSpellingLog { + private(set) var spellings: [String] = [] + + func append(_ spelling: String) { + spellings.append(spelling) + } +} diff --git a/apple/TableProTests/Core/Database/DatabaseObjectToolSupportTests.swift b/apple/TableProTests/Core/Database/DatabaseObjectToolSupportTests.swift new file mode 100644 index 0000000000..51be434ee1 --- /dev/null +++ b/apple/TableProTests/Core/Database/DatabaseObjectToolSupportTests.swift @@ -0,0 +1,54 @@ +// +// DatabaseObjectToolSupportTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class CountingTypeDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var probes = 0 + + var probeCount: Int { + lock.withLock { probes } + } + + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { + lock.withLock { probes += 1 } + return "DROP TYPE \(type.name)" + } +} + +@MainActor +struct DatabaseObjectToolSupportTests { + @Test("A driver is probed once, however often its support is asked for") + func probesEachDriverOnce() { + let connection = TestFixtures.makeConnection(database: "shop", type: .cassandra) + let plugin = CountingTypeDriver() + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: plugin) + + let first = DatabaseObjectToolEligibility.Support.of(adapter) + let probesForOneAnswer = plugin.probeCount + let second = DatabaseObjectToolEligibility.Support.of(adapter) + + #expect(probesForOneAnswer > 0) + #expect(plugin.probeCount == probesForOneAnswer) + #expect(second == first) + #expect(!first.droppableTypeKinds.isEmpty) + } + + @Test("A new driver for the same connection is probed again") + func newDriverIsProbed() { + let connection = TestFixtures.makeConnection(database: "shop", type: .cassandra) + let plugin = CountingTypeDriver() + + _ = DatabaseObjectToolEligibility.Support.of(PluginDriverAdapter(connection: connection, pluginDriver: plugin)) + let afterFirstDriver = plugin.probeCount + _ = DatabaseObjectToolEligibility.Support.of(PluginDriverAdapter(connection: connection, pluginDriver: plugin)) + + #expect(plugin.probeCount == afterFirstDriver * 2) + } +} diff --git a/apple/TableProTests/Core/Diagnostics/InteractionTraceRecorderTests.swift b/apple/TableProTests/Core/Diagnostics/InteractionTraceRecorderTests.swift new file mode 100644 index 0000000000..dde24bfe07 --- /dev/null +++ b/apple/TableProTests/Core/Diagnostics/InteractionTraceRecorderTests.swift @@ -0,0 +1,90 @@ +// +// InteractionTraceRecorderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct InteractionTraceRecorderTests { + private let base = ContinuousClock.now + + private func instant(_ milliseconds: Int) -> ContinuousClock.Instant { + base.advanced(by: .milliseconds(milliseconds)) + } + + @Test("A trace runs from its start to the frame that shows the answer") + func endsAtTheFrame() throws { + var recorder = InteractionTraceRecorder() + let (sequence, superseded) = recorder.begin( + .sidebarFilter, key: "c1", subject: "lc_t250", startedAt: instant(0), at: instant(3), uptime: 10 + ) + #expect(superseded == nil) + #expect(recorder.subject(of: .sidebarFilter, key: "c1") == "lc_t250") + + let awaiting = recorder.awaitFrame(.sidebarFilter, key: "c1") + #expect(awaiting == sequence) + let presented = recorder.framePresented( + .sidebarFilter, sequence: sequence, detail: "rows=100", at: instant(120), uptime: 10.12 + ) + let timing = try #require(presented) + + #expect(timing.outcome == .presented) + #expect(timing.total == .milliseconds(120)) + #expect(timing.reportLine == "interaction #1 path=sidebarFilter outcome=presented totalMs=120.0 end=10.120 rows=100") + #expect(recorder.subject(of: .sidebarFilter, key: "c1") == nil) + } + + @Test("A second start of the same path supersedes the first, and its pending frame ends nothing") + func secondStartSupersedes() throws { + var recorder = InteractionTraceRecorder() + let first = recorder.begin(.quickOpenResult, key: "c1", subject: "lc", startedAt: instant(0), at: instant(0), uptime: 1) + let awaited = recorder.awaitFrame(.quickOpenResult, key: "c1") + let pending = try #require(awaited) + + let second = recorder.begin( + .quickOpenResult, key: "c1", subject: "lc_t", startedAt: instant(30), at: instant(30), uptime: 1.03 + ) + + let superseded = try #require(second.superseded) + #expect(superseded.sequence == first.sequence) + #expect(superseded.outcome == .superseded) + let late = recorder.framePresented(.quickOpenResult, sequence: pending, detail: "", at: instant(40), uptime: 1.04) + #expect(late == nil) + #expect(recorder.subject(of: .quickOpenResult, key: "c1") == "lc_t") + } + + @Test("A reload while the frame is pending does not end the trace twice") + func awaitsOneFrame() { + var recorder = InteractionTraceRecorder() + _ = recorder.begin(.sidebarListing, key: "c1", subject: "", startedAt: instant(0), at: instant(0), uptime: 1) + + let first = recorder.awaitFrame(.sidebarListing, key: "c1") + let second = recorder.awaitFrame(.sidebarListing, key: "c1") + #expect(first != nil) + #expect(second == nil) + #expect(recorder.subject(of: .sidebarListing, key: "c1") == nil) + } + + @Test("A trace answers only for the connection it started on") + func keyedByConnection() { + var recorder = InteractionTraceRecorder() + _ = recorder.begin(.sidebarExpand, key: "c1", subject: "schema:a", startedAt: instant(0), at: instant(0), uptime: 1) + + #expect(recorder.subject(of: .sidebarExpand, key: "c2") == nil) + let otherConnection = recorder.awaitFrame(.sidebarExpand, key: "c2") + #expect(otherConnection == nil) + #expect(recorder.subject(of: .sidebarExpand, key: "c1") == "schema:a") + } + + @Test("Paths are traced independently") + func pathsAreIndependent() { + var recorder = InteractionTraceRecorder() + _ = recorder.begin(.completion, key: "", subject: "", startedAt: instant(0), at: instant(0), uptime: 1) + let other = recorder.begin(.quickOpenPanel, key: "c1", subject: "", startedAt: instant(5), at: instant(5), uptime: 1) + + #expect(other.superseded == nil) + #expect(recorder.subject(of: .completion, key: "") == "") + } +} diff --git a/apple/TableProTests/Core/MCP/MCPListIndexesTests.swift b/apple/TableProTests/Core/MCP/MCPListIndexesTests.swift new file mode 100644 index 0000000000..d7413c764c --- /dev/null +++ b/apple/TableProTests/Core/MCP/MCPListIndexesTests.swift @@ -0,0 +1,96 @@ +// +// MCPListIndexesTests.swift +// TableProTests +// + +import Foundation +import os +@testable import TablePro +import TableProMCP +import TableProPluginKit +import Testing + +@MainActor +struct MCPListIndexesTests { + private static let expected = ["customers: customers_email, customers_pkey", "orders: orders_pkey"] + + private static func entries( + _ driver: IndexListingDriver, + table: String? = nil + ) async throws -> [String] { + let adapter = PluginDriverAdapter(connection: TestFixtures.makeConnection(type: .postgresql), pluginDriver: driver) + let entries = try await MCPConnectionBridge.indexEntries(using: adapter, schema: "public", table: table) + return entries.map { entry in + let names = entry["indexes"]?.arrayValue?.compactMap { $0["name"]?.stringValue } ?? [] + return "\(entry["table"]?.stringValue ?? "?"): \(names.joined(separator: ", "))" + } + } + + @Test("Without a table, a driver with a bulk index read answers in one query instead of one per table") + func bulkReadReplacesPerTableReads() async throws { + let driver = IndexListingDriver(providesBulkIndexFetch: true) + #expect(try await Self.entries(driver) == Self.expected) + #expect(driver.calls == IndexListingDriver.Calls(perTable: 0, bulk: 1)) + } + + @Test("A driver without a bulk read lists the same indexes one table at a time") + func perTableReadsWithoutBulkRead() async throws { + let driver = IndexListingDriver(providesBulkIndexFetch: false) + #expect(try await Self.entries(driver) == Self.expected) + #expect(driver.calls == IndexListingDriver.Calls(perTable: 3, bulk: 0)) + } + + @Test("A bulk read that fails falls back to the per-table reads") + func failedBulkReadFallsBack() async throws { + let driver = IndexListingDriver(providesBulkIndexFetch: true, bulkError: URLError(.timedOut)) + #expect(try await Self.entries(driver) == Self.expected) + #expect(driver.calls == IndexListingDriver.Calls(perTable: 3, bulk: 1)) + } + + @Test("A named table reads that table's indexes only") + func namedTableSkipsTheBulkRead() async throws { + let driver = IndexListingDriver(providesBulkIndexFetch: true) + #expect(try await Self.entries(driver, table: "orders") == ["orders: orders_pkey"]) + #expect(driver.calls == IndexListingDriver.Calls(perTable: 1, bulk: 0)) + } +} + +private final class IndexListingDriver: PluginDriverStub, @unchecked Sendable { + struct Calls: Equatable { + var perTable = 0 + var bulk = 0 + } + + let providesBulkIndexFetch: Bool + private let bulkError: Error? + private let recorded = OSAllocatedUnfairLock(initialState: Calls()) + private let indexes: [String: [PluginIndexInfo]] = [ + "orders": [PluginIndexInfo(name: "orders_pkey", columns: ["id"], isUnique: true, isPrimary: true)], + "customers": [ + PluginIndexInfo(name: "customers_email", columns: ["email"], isUnique: true), + PluginIndexInfo(name: "customers_pkey", columns: ["id"], isUnique: true, isPrimary: true) + ] + ] + + init(providesBulkIndexFetch: Bool, bulkError: Error? = nil) { + self.providesBulkIndexFetch = providesBulkIndexFetch + self.bulkError = bulkError + } + + var calls: Calls { recorded.withLock { $0 } } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + ["orders", "customers", "audit_log"].map { PluginTableInfo(name: $0, comment: nil, partitionCount: nil) } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + recorded.withLock { $0.perTable += 1 } + return indexes[table] ?? [] + } + + func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] { + recorded.withLock { $0.bulk += 1 } + if let bulkError { throw bulkError } + return indexes + } +} diff --git a/apple/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift b/apple/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift index fead268760..335b311b8b 100644 --- a/apple/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift +++ b/apple/TableProTests/Core/Services/Query/AllSchemaTablesDemandTests.swift @@ -114,15 +114,65 @@ struct AllSchemaTablesDemandTests { await disconnect(connection) } - @Test("Starting a new search asks again whatever was asked before") + @Test("Starting a new search asks again for a listing that is not current, whatever was asked before") func resetForgetsEarlierRequests() async { let connection = await connectedListing() + service.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: connection.id, database: connection.database) + ]) var demand = AllSchemaTablesDemand() demand.noteRequested(connectionId: connection.id, database: connection.database, service: service) + #expect(!demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) demand.reset() #expect(demand.needsRequest(connectionId: connection.id, database: connection.database, service: service)) await disconnect(connection) } + + // Open Quickly builds a fresh demand every time it opens, so it never asked for the listing it finds. + @Test("A current listing is not asked for by a reader that never asked") + func currentListingIsNotAskedForAgain() async { + let connection = await connectedListing() + + #expect(!AllSchemaTablesDemand().needsRequest( + connectionId: connection.id, database: connection.database, service: service + )) + await disconnect(connection) + } + + @Test("A listing a catalog change overtook is asked for by a reader that never asked") + func overtakenListingIsAskedFor() async { + let connection = await connectedListing() + service.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: connection.id, database: connection.database) + ]) + + #expect(AllSchemaTablesDemand().needsRequest( + connectionId: connection.id, database: connection.database, service: service + )) + await disconnect(connection) + } + + @Test("A current listing that could not list a schema is still asked for, so that schema is read again") + func unlistedSchemaIsAskedForAgain() async throws { + let connection = TestFixtures.makeConnection(type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.schemasToReturn = ["public", "attendance"] + driver.schemaTablesToReturn = ["public": [TestFixtures.makeTableInfo(name: "users", schema: "public")]] + driver.schemaTablesErrors = ["attendance": NSError(domain: "test", code: 1)] + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + await service.loadAllSchemaTables(connectionId: connection.id, database: connection.database) + let listing = try #require( + service.allSchemaTablesLoadState(connectionId: connection.id, database: connection.database).value + ) + #expect(listing.unlistedSchemas == ["attendance"]) + + #expect(AllSchemaTablesDemand().needsRequest( + connectionId: connection.id, database: connection.database, service: service + )) + await disconnect(connection) + } } diff --git a/apple/TableProTests/ViewModels/AIChatTableMentionsTests.swift b/apple/TableProTests/ViewModels/AIChatTableMentionsTests.swift new file mode 100644 index 0000000000..747552b122 --- /dev/null +++ b/apple/TableProTests/ViewModels/AIChatTableMentionsTests.swift @@ -0,0 +1,148 @@ +// +// AIChatTableMentionsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +struct AIChatTableMentionsTests { + private static let names = [ + "orders", "Order_Items", "reorder_log", "customers", "ORDER_LOG", + "Café", "Cafe\u{301}_menu", "Đ_bảng", "audit_log", "products", "table10", "table2", + "weiß_log", "İstanbul", "kiss_list" + ] + + private static func index(_ names: [String]) -> TableMentionIndex { + TableMentionIndex(tables: names.map { TestFixtures.makeTableInfo(name: $0) }) + } + + // What the composer ran over every table on each keystroke before the index. + private static func filterSortAndCut(_ names: [String], query: String, limit: Int) -> [String] { + Array( + names + .filter { query.isEmpty || $0.localizedCaseInsensitiveContains(query) } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + .prefix(limit) + ) + } + + @Test("Names are kept in Finder order") + func namesAreSorted() { + #expect(Self.index(["table10", "Table2", "accounts"]).names == ["accounts", "Table2", "table10"]) + } + + @Test( + "A query finds the names the per-keystroke filter found, in the same order", + arguments: ["", "o", "ORD", "_log", "café", "CAFÉ", "đ", "table", "zzz", "weis", "ss", "i", "İ"] + ) + func matchesThePerKeystrokeFilter(query: String) { + #expect( + Self.index(Self.names).names(matching: query, limit: 5) + == Self.filterSortAndCut(Self.names, query: query, limit: 5) + ) + } + + @Test("A name two schemas share is offered once") + func sharedNameIsOfferedOnce() { + let index = TableMentionIndex(tables: [ + TestFixtures.makeTableInfo(name: "orders", schema: "sales"), + TestFixtures.makeTableInfo(name: "orders", schema: "archive") + ]) + #expect(index.names == ["orders"]) + } + + @Test("The Tables menu lists the first names in order and leaves the rest to @") + func tablesMenuIsCapped() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let count = AIChatViewModel.mentionMenuTableLimit + 20 + let fixture = await AIChatSchemaFixture.loaded(connection: connection, names: AIChatSchemaFixture.names(count: count)) + let vm = AIChatViewModel(services: fixture.services) + vm.connection = connection + + vm.trackTableMentions() + await vm.tableMentionBuild?.value + + let expected = Array(AIChatSchemaFixture.names(count: count).prefix(AIChatViewModel.mentionMenuTableLimit)) + #expect(vm.mentionMenuTableNames == expected) + #expect(vm.hasUnlistedMentionTables) + #expect(vm.tableMentions.names(matching: "_119", limit: 5) == ["table_119"]) + } + + @Test("The index is rebuilt when a reload changes the catalog") + func indexFollowsCatalogReload() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let fixture = await AIChatSchemaFixture.loaded(connection: connection, names: ["orders"]) + let vm = AIChatViewModel(services: fixture.services) + vm.connection = connection + vm.trackTableMentions() + await vm.tableMentionBuild?.value + #expect(vm.tableMentions.names == ["orders"]) + + fixture.driver.tablesToReturn = ["invoices", "accounts"].map { TestFixtures.makeTableInfo(name: $0) } + await fixture.schemaService.reload(connectionId: connection.id, driver: fixture.driver, connection: connection) + + try await waitUntil { vm.tableMentions.names == ["accounts", "invoices"] } + #expect(!vm.hasUnlistedMentionTables) + } +} + +@MainActor +struct AIChatSchemaFixture { + let schemaService: SchemaService + let driver: MockDatabaseDriver + let services: AppServices + + static func names(count: Int) -> [String] { + (0.. AIChatSchemaFixture { + let schemaService = SchemaService() + let driver = MockDatabaseDriver(connection: connection) + driver.tablesToReturn = names.reversed().map { TestFixtures.makeTableInfo(name: $0) } + await schemaService.load(connectionId: connection.id, driver: driver, connection: connection) + return AIChatSchemaFixture(schemaService: schemaService, driver: driver, services: services(schemaService: schemaService)) + } + + private static func services(schemaService: SchemaService) -> AppServices { + let live = AppServices.live + return AppServices( + appEvents: live.appEvents, + appSettings: live.appSettings, + appSettingsStorage: live.appSettingsStorage, + connectionStorage: live.connectionStorage, + databaseManager: live.databaseManager, + pluginManager: live.pluginManager, + schemaService: schemaService, + schemaRefreshService: live.schemaRefreshService, + schemaProviderRegistry: live.schemaProviderRegistry, + catalogChangeService: live.catalogChangeService, + sqlFavoriteManager: live.sqlFavoriteManager, + favoriteTablesStorage: live.favoriteTablesStorage, + favoriteDatabasesStorage: live.favoriteDatabasesStorage, + tableFolderStorage: live.tableFolderStorage, + aiChatStorage: live.aiChatStorage, + aiKeyStorage: live.aiKeyStorage, + aiAccessApprovals: live.aiAccessApprovals, + groupStorage: live.groupStorage, + tagStorage: live.tagStorage, + sshProfileStorage: live.sshProfileStorage, + credentialProfileStorage: live.credentialProfileStorage, + licenseManager: live.licenseManager, + syncMetadataStorage: live.syncMetadataStorage, + favoritesExpansionState: live.favoritesExpansionState, + linkedFolderWatcher: live.linkedFolderWatcher, + queryHistoryManager: live.queryHistoryManager, + dateFormattingService: live.dateFormattingService, + copilotService: live.copilotService, + mcpServerManager: live.mcpServerManager, + syncTracker: live.syncTracker, + welcomeRouter: live.welcomeRouter + ) + } +} diff --git a/apple/TableProTests/ViewModels/AIChatViewModelSchemaRankingTests.swift b/apple/TableProTests/ViewModels/AIChatViewModelSchemaRankingTests.swift new file mode 100644 index 0000000000..9e7c980af9 --- /dev/null +++ b/apple/TableProTests/ViewModels/AIChatViewModelSchemaRankingTests.swift @@ -0,0 +1,51 @@ +// +// AIChatViewModelSchemaRankingTests.swift +// TableProTests +// + +import Foundation +import os +@testable import TablePro +import Testing + +@MainActor +struct AIChatViewModelSchemaRankingTests { + @Test("Ranking the schema for a question runs off the main actor, and its order is kept") + func rankingRunsOffTheMainActor() async { + let connection = TestFixtures.makeConnection(type: .postgresql) + let count = AppServices.live.appSettings.ai.maxSchemaTables + 5 + let fixture = await AIChatSchemaFixture.loaded(connection: connection, names: AIChatSchemaFixture.names(count: count)) + let vm = AIChatViewModel(services: fixture.services) + vm.connection = connection + let rankedOnMainThread = OSAllocatedUnfairLock(initialState: nil) + vm.schemaRanker = { _ in + rankedOnMainThread.withLock { $0 = Thread.isMainThread } + return ["table_007", "table_003"] + } + + await vm.ensureSchemaLoaded(relevantTo: "recent orders") + + let onMainThread = rankedOnMainThread.withLock { $0 } + #expect(onMainThread == false) + #expect(vm.relevantTableNames == ["table_007", "table_003"]) + } + + @Test("A catalog that fits the prompt is not ranked") + func smallCatalogIsNotRanked() async { + let connection = TestFixtures.makeConnection(type: .postgresql) + let fixture = await AIChatSchemaFixture.loaded(connection: connection, names: ["orders", "customers"]) + let vm = AIChatViewModel(services: fixture.services) + vm.connection = connection + let ranked = OSAllocatedUnfairLock(initialState: false) + vm.schemaRanker = { _ in + ranked.withLock { $0 = true } + return ["customers"] + } + + await vm.ensureSchemaLoaded(relevantTo: "orders") + + let didRank = ranked.withLock { $0 } + #expect(!didRank) + #expect(vm.relevantTableNames.isEmpty) + } +} diff --git a/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift index 7075f1f15e..13d82cec8e 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift @@ -38,7 +38,7 @@ struct QuickSwitcherListingDemandTests { private func needsListing(recent: [String], resolved: Set, limit: Int = 10) -> Bool { QuickSwitcherViewModel.recentNeedsListing( recentKeys: recent, - resolvedKeys: resolved, + isResolved: resolved.contains, isTableKey: { QuickSwitcherFrecencyKey.isTable($0, in: qualifier) }, limit: limit ) @@ -142,4 +142,111 @@ struct QuickSwitcherListingDemandTests { #expect(driver.fetchTablesInAllSchemasCallCount == 1) await disconnect(connection) } + + @Test("The next Open Quickly finds a current listing and neither reads it again nor rebuilds its rows") + func currentListingIsNotAskedForOnReopen() async throws { + let (connection, driver) = connect() + let first = makeSwitcher(connection) + await first.loadItems(databaseType: .pglite, browseSchema: "public") + first.searchText = "time" + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !first.isLoadingTables } + + let second = makeSwitcher(connection) + await second.loadItems(databaseType: .pglite, browseSchema: "public") + await afterQueuedMainWork() + let opened = second.tableItemsBuildCount + second.searchText = "time" + + #expect(!second.isLoadingTables) + await second.flushPendingFilter() + await afterQueuedMainWork() + #expect(second.tableItemsBuildCount == opened) + #expect(second.flatItems.contains { $0.name == "timesheet" }) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + await disconnect(connection) + } + + // MARK: - Table rows + + // Sinks deliver through `RunLoop.main.perform` and answer on the main actor; both have run once this returns. + private func afterQueuedMainWork() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } + } + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + + @Test("Reopening over a loaded catalog builds the table rows once") + func reopenBuildsRowsOnce() async { + let (connection, _) = connect() + await makeSwitcher(connection).loadItems(databaseType: .pglite, browseSchema: "public") + let switcher = makeSwitcher(connection) + + await switcher.loadItems(databaseType: .pglite, browseSchema: "public") + await afterQueuedMainWork() + + #expect(switcher.tableItemsBuildCount == 1) + #expect(switcher.allItems.contains { $0.name == "users" }) + await disconnect(connection) + } + + @Test("The listing landing rebuilds the table rows once") + func listingLandingRebuildsOnce() async throws { + let (connection, driver) = connect() + let switcher = makeSwitcher(connection) + await switcher.loadItems(databaseType: .pglite, browseSchema: "public") + await afterQueuedMainWork() + let opened = switcher.tableItemsBuildCount + + switcher.searchText = "time" + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 && !switcher.isLoadingTables } + await afterQueuedMainWork() + + #expect(switcher.tableItemsBuildCount == opened + 1) + #expect(switcher.allItems.contains { $0.name == "timesheet" }) + await disconnect(connection) + } + + @Test("A change on another connection rebuilds none of this connection's rows") + func otherConnectionChangeRebuildsNothing() async { + let (connection, _) = connect() + let switcher = makeSwitcher(connection) + await switcher.loadItems(databaseType: .pglite, browseSchema: "public") + await afterQueuedMainWork() + let opened = switcher.tableItemsBuildCount + let elsewhere = UUID() + + DatabaseTreeMetadataService.shared.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: elsewhere, database: "shop") + ]) + SchemaService.shared.forget(database: "shop", connectionId: elsewhere) + await afterQueuedMainWork() + + #expect(switcher.tableItemsBuildCount == opened) + await SchemaService.shared.invalidate(connectionId: elsewhere) + await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: elsewhere) + await disconnect(connection) + } + + @Test("A burst of changes to this connection in one turn rebuilds the rows once") + func burstRebuildsOnce() async { + let (connection, _) = connect() + let switcher = makeSwitcher(connection) + await switcher.loadItems(databaseType: .pglite, browseSchema: "public") + await afterQueuedMainWork() + let opened = switcher.tableItemsBuildCount + + SchemaService.shared.forget(database: "archive", connectionId: connection.id) + DatabaseTreeMetadataService.shared.markAllSchemaTablesChanged([ + DatabaseTreeMetadataService.DatabaseKey(connectionId: connection.id, database: "shop") + ]) + SchemaService.shared.forget(database: "archive", connectionId: connection.id) + await afterQueuedMainWork() + + #expect(switcher.tableItemsBuildCount == opened + 1) + #expect(switcher.allItems.contains { $0.name == "users" }) + await disconnect(connection) + } } diff --git a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 0757fb8bc1..e08caec4f6 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -1319,4 +1319,43 @@ struct QuickSwitcherViewModelTests { #expect(vm.selectedItem()?.name == "orders") #expect(vm.searchGeneration == generation + 1) } + + // The task is main-actor isolated and cancelled before this test suspends, so it starts cancelled. + @Test("A search the next keystroke superseded stops ranking and returns nothing") + func cancelledSearchStopsRanking() async { + let items = tableItems((0..<5_000).map { "orders_\($0)" }) + let full = await QuickSwitcherViewModel.filteredGroups( + items: items, query: "ord", frecencyScores: [:], connectionId: UUID() + ) + #expect(full.first?.items.count == QuickSwitcherRanking.maxResults) + + let superseded = Task { @MainActor in + await QuickSwitcherViewModel.filteredGroups( + items: items, query: "ord", frecencyScores: [:], connectionId: UUID() + ) + } + superseded.cancel() + let groups = await superseded.value + + #expect(groups.isEmpty) + } + + @Test("A scope change the next one superseded stops listing and returns nothing") + func cancelledBrowseStopsListing() async { + let items = tableItems((0..<5_000).map { "orders_\($0)" }) + let full = await QuickSwitcherViewModel.emptyQueryGroups( + items: items, scope: .tables, recentKeys: [], connectionId: UUID() + ) + #expect(!full.isEmpty) + + let superseded = Task { @MainActor in + await QuickSwitcherViewModel.emptyQueryGroups( + items: items, scope: .tables, recentKeys: [], connectionId: UUID() + ) + } + superseded.cancel() + let groups = await superseded.value + + #expect(groups.isEmpty) + } } diff --git a/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 7898dec386..370ebc103f 100644 --- a/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -233,6 +233,77 @@ extension GlobalStateSuites { #expect(service.rankingInputCounts == [400, 400]) } + // The pool after `FROM ` is t000 to t399, so a re-rank for `t45` offers t045 and never reaches t450. + @MainActor + @Test("a narrowing keystroke on a cut pool keeps the re-rank and asks the whole catalog behind it") + func cutPoolAsksTheWholeCatalogBehindTheRerank() async { + let provider = await schema((0..<500).map { String(format: "t%03d", $0) }) + let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .mysql) + let opened = "SELECT * FROM " + let controller = EditorControllerFixture.make(string: opened) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: opened), + isManualTrigger: false + ) + + let narrowed = "SELECT * FROM t45" + controller.textView.setText(narrowed) + let reranked = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: narrowed) + )?.map(\.label) ?? [] + let narrowedNeedsFreshItems = adapter.completionNeedsFreshItems( + textView: controller, + cursorPosition: cursor(atEndOf: narrowed) + ) + let fresh = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: narrowed), + isManualTrigger: false + )?.items.map(\.label) ?? [] + controller.textView.setText(narrowed + "0") + let freshNeedsFreshItems = adapter.completionNeedsFreshItems( + textView: controller, + cursorPosition: cursor(atEndOf: narrowed + "0") + ) + + #expect(!reranked.isEmpty) + #expect(narrowedNeedsFreshItems) + #expect(fresh.first == "t450") + #expect(!freshNeedsFreshItems) + } + + // A pool ranked for `ab` holds no `ac1`, so deleting back to `a` has to ask again. + @MainActor + @Test("a pool answers a prefix that extends the one it was ranked for, and asks again for a shorter one") + func poolAsksAgainForAShorterPrefix() async { + let provider = await schema(["ab1", "ab2", "ac1"]) + let adapter = QueryCompletionAdapter(schemaProvider: provider, databaseType: .mysql) + let opened = "SELECT * FROM ab" + let controller = EditorControllerFixture.make(string: opened) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: opened), + isManualTrigger: false + ) + + controller.textView.setText(opened + "1") + let longerNeedsFreshItems = adapter.completionNeedsFreshItems( + textView: controller, + cursorPosition: cursor(atEndOf: opened + "1") + ) + let shorter = "SELECT * FROM a" + controller.textView.setText(shorter) + let shorterNeedsFreshItems = adapter.completionNeedsFreshItems( + textView: controller, + cursorPosition: cursor(atEndOf: shorter) + ) + + #expect(!longerNeedsFreshItems) + #expect(shorterNeedsFreshItems) + } + // MARK: - The session's token @MainActor diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift new file mode 100644 index 0000000000..5d3a75d61e --- /dev/null +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift @@ -0,0 +1,239 @@ +// +// DatabaseTreeFilterMemoTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +// A filter pass, a listing scan and a row's id are worked out once per input, not once per reload. +@MainActor +struct DatabaseTreeFilterMemoTests { + private func table(_ name: String, _ schema: String? = nil, comment: String? = nil) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema, comment: comment) + } + + private func source(_ tables: [TableInfo], searchText: String = "") -> DatabaseTreeBucketSource { + DatabaseTreeBucketSource( + tables: tables, routines: [], triggers: [], userTypes: [], searchText: searchText, database: "shop" + ) + } + + private func tableBucket(_ buckets: DatabaseTreeObjectBuckets) -> SameRows { + SameRows(rows: buckets.tables[.table] ?? []) + } + + // MARK: - Rows + + @Test("Rows compare by storage, since TableInfo equality leaves out the comment a row draws") + func sameRowsComparesStorage() { + let rows = [table("orders", comment: "old")] + let edited = [table("orders", comment: "new")] + #expect(rows == edited) + #expect(SameRows(rows: rows) != SameRows(rows: edited)) + let held = SameRows(rows: rows) + #expect(held == SameRows(rows: rows)) + #expect(SameRows(rows: []) == SameRows(rows: [])) + } + + // MARK: - Container filter + + @Test("A container's filter pass is reused until its rows or its search move") + func bucketMemoReusesUnchangedPass() { + var memo = DatabaseTreeBucketMemo() + let tables = [table("orders"), table("order_lines"), table("users")] + + let first = memo.buckets(for: "shop", source: source(tables)) + let again = memo.buckets(for: "shop", source: source(tables)) + let reread = memo.buckets(for: "shop", source: source(tables.map { $0 })) + let searched = memo.buckets(for: "shop", source: source(tables, searchText: "order")) + + #expect(tableBucket(first) == tableBucket(again)) + #expect(tableBucket(reread) != tableBucket(first)) + #expect(searched.tables[.table]?.map { $0.name } == ["orders", "order_lines"]) + } + + @Test("A container nobody asked for since the last drop lets go of its pass") + func bucketMemoDropsUnaskedContainers() { + var memo = DatabaseTreeBucketMemo() + let attendance = source([table("timesheet")]) + let payroll = source([table("salary")]) + + _ = memo.buckets(for: "attendance", source: attendance) + let payrollFirst = memo.buckets(for: "payroll", source: payroll) + memo.dropUnused() + let attendanceKept = memo.buckets(for: "attendance", source: attendance) + let payrollKept = memo.buckets(for: "payroll", source: payroll) + memo.dropUnused() + _ = memo.buckets(for: "attendance", source: attendance) + memo.dropUnused() + let payrollAfterDrop = memo.buckets(for: "payroll", source: payroll) + + #expect(tableBucket(payrollKept) == tableBucket(payrollFirst)) + #expect(tableBucket(payrollAfterDrop) != tableBucket(payrollFirst)) + #expect(attendanceKept.tables[.table]?.map { $0.name } == ["timesheet"]) + } + + // MARK: - Listing scan + + @Test("A listing scan is shared until the listing, the search or the covered schemas move") + func listingMemoSharedUntilInputsMove() { + var memo = DatabaseTreeFilter.SchemaListingMatchesMemo() + let listing = CatalogTableListing.Result( + tables: [table("timesheet", "attendance"), table("timesheet", "payroll"), table("orders", "sales")], + unlistedSchemas: [] + ) + + let first = memo.matches(listing: listing, database: "shop", searchText: "timesheet", covered: nil) + let again = memo.matches(listing: listing, database: "shop", searchText: "timesheet", covered: nil) + let narrowed = memo.matches(listing: listing, database: "shop", searchText: "timesheet", covered: ["payroll"]) + let reread = memo.matches( + listing: CatalogTableListing.Result(tables: listing.tables.map { $0 }, unlistedSchemas: []), + database: "shop", searchText: "timesheet", covered: ["payroll"] + ) + let other = memo.matches(listing: listing, database: "shop", searchText: "orders", covered: nil) + + #expect(first.matched == ["attendance", "payroll"]) + #expect(SameRows(rows: first.rowsBySchema["attendance"] ?? []) == SameRows(rows: again.rowsBySchema["attendance"] ?? [])) + #expect(narrowed.covered == ["payroll"]) + #expect(SameRows(rows: reread.rowsBySchema["payroll"] ?? []) != SameRows(rows: narrowed.rowsBySchema["payroll"] ?? [])) + #expect(other.matched == ["sales"]) + } + + // MARK: - Outline + + private struct Fixture { + let connection: DatabaseConnection + let coordinator: DatabaseTreeOutlineCoordinator + let viewModel: SidebarViewModel + let scrollView: NSScrollView + } + + // A PGlite session, so every read stays on the injected driver. + private func makeFixture(searchText: String) async throws -> Fixture { + let connection = TestFixtures.makeConnection(database: "shop", type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + let rows: [String: [TableInfo]] = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance"), table("shift", "attendance")], + "payroll": [table("timesheet", "payroll"), table("salary", "payroll")] + ] + driver.databasesToReturn = ["shop"] + driver.schemasToReturn = ["public", "attendance", "payroll"] + driver.schemaTablesToReturn = rows + driver.allSchemaTablesToReturn = ["public", "attendance", "payroll"].flatMap { rows[$0] ?? [] } + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseSchema = "public" + DatabaseManager.shared.injectSession(session, for: connection.id) + let service = DatabaseTreeMetadataService.shared + await service.loadSchemas(connectionId: connection.id, database: "shop") + await service.loadAllSchemaTables(connectionId: connection.id, database: "shop") + await service.loadObjects(connectionId: connection.id, database: "shop", schema: "attendance") + + let scrollView = SidebarOutlineScaffold.makeScrollView( + outlineView: NSOutlineView(), + configuration: SidebarOutlineScaffold.Configuration( + columnIdentifier: "FilterMemoColumn", + allowsMultipleSelection: true, + rowSizePreference: .medium + ) + ) + scrollView.frame = NSRect(x: 0, y: 0, width: 280, height: 800) + let outlineView = try #require(scrollView.documentView as? NSOutlineView) + let sidebarState = SharedSidebarState.forConnection(connection.id) + sidebarState.sidebarLayout = .flat + let viewModel = SidebarViewModel( + selectedTables: .constant([]), + pendingTruncates: .constant([]), + pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), + databaseType: .pglite, + connectionId: connection.id + ) + let coordinator = DatabaseTreeOutlineCoordinator() + coordinator.connectionId = connection.id + coordinator.databaseType = .pglite + coordinator.sidebarState = sidebarState + coordinator.windowState = WindowSidebarState() + coordinator.viewModel = viewModel + coordinator.activeDatabase = "shop" + coordinator.activeSchema = "public" + coordinator.showRecentTables = false + coordinator.searchText = searchText + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.attach(outlineView: outlineView) + coordinator.refresh() + return Fixture(connection: connection, coordinator: coordinator, viewModel: viewModel, scrollView: scrollView) + } + + private func tearDown(_ fixture: Fixture) async { + await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: fixture.connection.id) + ExternalSchemaTracker.shared.reset(connectionId: fixture.connection.id) + DatabaseManager.shared.removeSession(for: fixture.connection.id) + SharedSidebarState.removeConnection(fixture.connection.id) + _ = fixture.scrollView + _ = fixture.viewModel + } + + @Test("A container's filtered rows survive a reload that changed nothing it reads") + func containerPassSurvivesReload() async throws { + let fixture = try await makeFixture(searchText: "") + let before = fixture.coordinator.objectBuckets(database: "shop", schema: "attendance") + + fixture.coordinator.refresh() + let after = fixture.coordinator.objectBuckets(database: "shop", schema: "attendance") + + #expect(before.tables[.table]?.map { $0.name } == ["timesheet", "shift"]) + #expect(tableBucket(before) == tableBucket(after)) + await tearDown(fixture) + } + + @Test("The outline and the view model judge schemas against one listing scan, kept across reloads") + func listingScanIsShared() async throws { + let fixture = try await makeFixture(searchText: "timesheet") + let key = DatabaseTreeContainerKey(database: "shop", schema: nil, searchText: "timesheet") + let drawn = try #require(fixture.coordinator.listingMatchesCache[key]) + let shared = try #require(fixture.viewModel.schemaListingMatches(database: "shop", searchText: "timesheet")) + + fixture.coordinator.refresh() + let redrawn = try #require(fixture.coordinator.listingMatchesCache[key]) + + #expect(drawn.matched == ["attendance", "payroll"]) + #expect(SameRows(rows: drawn.rowsBySchema["payroll"] ?? []) == SameRows(rows: shared.rowsBySchema["payroll"] ?? [])) + #expect(SameRows(rows: redrawn.rowsBySchema["payroll"] ?? []) == SameRows(rows: drawn.rowsBySchema["payroll"] ?? [])) + await tearDown(fixture) + } + + // MARK: - Row ids + + @Test("A table row's id is the escaped spelling whether or not a part needs escaping") + func tableIdMatchesEscapedSpelling() { + let names = ["orders", "a.b", "pipe|name", "back\\slash", "café", ""] + let schemas: [String?] = [nil, "", "public", "s.t", "x|y", "z\\w"] + let databases: [String?] = [nil, "", "shop", "a|b", "c\\d", "e.f"] + for name in names { + for tableSchema in schemas { + for type in TableInfo.TableType.allCases { + for isTemporary in [false, true] { + let table = TableInfo( + name: name, type: type, rowCount: nil, schema: tableSchema, isTemporary: isTemporary + ) + for database in databases { + for rowSchema in schemas { + let ref = DatabaseTreeTableRef(database: database, schema: rowSchema, table: table) + #expect(DatabaseTreeNode.tableId(ref) == "table\u{1}\(ref.id)") + } + } + } + } + } + } + } +} diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift index 2d99b2bdd4..3072e63a34 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterQualifiedSearchTests.swift @@ -17,6 +17,13 @@ struct DatabaseTreeFilterQualifiedSearchTests { .loaded(CatalogTableListing.Result(tables: tables, unlistedSchemas: unlisted)) } + private func listingMatches( + _ state: MetadataLoadState, + searchText: String + ) -> DatabaseTreeFilter.SchemaListingMatches? { + state.value.map { DatabaseTreeFilter.SchemaListingMatches(listing: $0, database: "shop", searchText: searchText) } + } + private func buckets(_ tables: [TableInfo], searchText: String) -> DatabaseTreeObjectBuckets { DatabaseTreeFilter.objectBuckets(tables: tables, routines: [], triggers: [], searchText: searchText, database: "shop") } @@ -198,7 +205,8 @@ struct DatabaseTreeFilterQualifiedSearchTests { ]) let schemas = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "timesheet", - hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _, _ in nil } + hiddenSchemas: [], listingMatches: listingMatches(allSchemas, searchText: "timesheet"), + loadedContent: { _, _ in nil } ) #expect(schemas == ["attendance", "payroll"]) } @@ -211,7 +219,8 @@ struct DatabaseTreeFilterQualifiedSearchTests { ]) let schemas = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "timesheet", - hiddenSchemas: [], allSchemaTables: allSchemas, keptSchemas: ["attendance", "public"], + hiddenSchemas: [], listingMatches: listingMatches(allSchemas, searchText: "timesheet"), + keptSchemas: ["attendance", "public"], loadedContent: { _, _ in nil } ) #expect(schemas == ["attendance"]) @@ -222,7 +231,8 @@ struct DatabaseTreeFilterQualifiedSearchTests { let allSchemas = listing([table("orders", schema: "timesheets")]) let schemas = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "timesheet", - hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _, _ in nil } + hiddenSchemas: [], listingMatches: listingMatches(allSchemas, searchText: "timesheet"), + loadedContent: { _, _ in nil } ) #expect(schemas.isEmpty) } @@ -232,7 +242,8 @@ struct DatabaseTreeFilterQualifiedSearchTests { let allSchemas = listing([table("orders", schema: "attendance"), table("orders", schema: "sales")]) let schemas = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "attendance.", - hiddenSchemas: [], allSchemaTables: allSchemas, loadedContent: { _, _ in nil } + hiddenSchemas: [], listingMatches: listingMatches(allSchemas, searchText: "attendance."), + loadedContent: { _, _ in nil } ) #expect(schemas == ["attendance"]) } @@ -241,12 +252,13 @@ struct DatabaseTreeFilterQualifiedSearchTests { func otherSchemaMatchesWithoutListing() { let pending = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "timesheet", - hiddenSchemas: [], allSchemaTables: .loading, loadedContent: { _, _ in nil } + hiddenSchemas: [], listingMatches: listingMatches(.loading, searchText: "timesheet"), + loadedContent: { _, _ in nil } ) let hidden = DatabaseTreeFilter.otherSchemaMatches( database: "shop", browsedSchema: "public", searchText: "timesheet", hiddenSchemas: ["attendance"], - allSchemaTables: listing([table("timesheet", schema: "attendance")]), + listingMatches: listingMatches(listing([table("timesheet", schema: "attendance")]), searchText: "timesheet"), loadedContent: { _, _ in nil } ) #expect(pending.isEmpty) @@ -295,11 +307,11 @@ struct DatabaseTreeFilterQualifiedSearchTests { @Test("A trailing dot shows everything in the hierarchical schema it names") func hierarchicalTrailingDot() { - let buckets = DatabaseTreeFilter.hierarchicalObjectBuckets( + let buckets = DatabaseTreeFilter.hierarchicalBucketSource( schema: "SALES", tables: [table("ORDERS", schema: "SALES"), table("LINES", schema: "SALES")], routines: [], triggers: [], userTypes: [], searchText: "SALES." - ) + ).filtered() #expect(buckets.itemCounts[.table] == 2) } diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index 37fe393c48..e1f5323fca 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -205,14 +205,14 @@ struct DatabaseTreeFilterTests { routines: [RoutineInfo] = [], searchText: String ) -> DatabaseTreeObjectBuckets { - DatabaseTreeFilter.hierarchicalObjectBuckets( + DatabaseTreeFilter.hierarchicalBucketSource( schema: schema, tables: tables, routines: routines, triggers: [], userTypes: [], searchText: searchText - ) + ).filtered() } /// Nothing has read it and no listing has answered for it, so hiding it would hide the match. diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeHierarchicalSearchTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeHierarchicalSearchTests.swift new file mode 100644 index 0000000000..73b4619ea5 --- /dev/null +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeHierarchicalSearchTests.swift @@ -0,0 +1,152 @@ +// +// DatabaseTreeHierarchicalSearchTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +/// An Oracle-shaped tree, one row per schema, over a session with no database name so every read stays +/// on the injected driver. The search finds `timesheet` in two schemas the tree never opened. +@MainActor +struct DatabaseTreeHierarchicalSearchTests { + private struct Fixture { + let scrollView: NSScrollView + let outlineView: NSOutlineView + let coordinator: DatabaseTreeOutlineCoordinator + let connection: DatabaseConnection + let driver: MockDatabaseDriver + } + + private func table(_ name: String, _ schema: String) -> TableInfo { + TestFixtures.makeTableInfo(name: name, schema: schema) + } + + private func makeFixture( + searchText: String = "timesheet", + showsSystem: Bool = false, + loadsListing: Bool = true + ) async throws -> Fixture { + let connection = TestFixtures.makeConnection(database: "", type: .oracle) + let driver = MockDatabaseDriver(connection: connection) + let rows: [String: [TableInfo]] = [ + "app": [table("users", "app")], + "attendance": [table("timesheet", "attendance"), table("shift", "attendance")], + "payroll": [table("timesheet", "payroll"), table("salary", "payroll")], + "CTXSYS": [table("ctx_parameters", "CTXSYS")] + ] + driver.schemasToReturn = ["app", "attendance", "payroll", "CTXSYS"] + driver.schemaTablesToReturn = rows + driver.allSchemaTablesToReturn = ["app", "attendance", "payroll"].flatMap { rows[$0] ?? [] } + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseSchema = "app" + DatabaseManager.shared.injectSession(session, for: connection.id) + + let scope = try #require(DatabaseManager.shared.browseScope(for: connection.id)) + await SchemaService.shared.load(connectionId: connection.id, driver: driver, connection: connection, scope: scope) + if loadsListing { + await DatabaseTreeMetadataService.shared.loadAllSchemaTables(connectionId: connection.id, database: scope.database) + } + + let scrollView = SidebarOutlineScaffold.makeScrollView( + outlineView: NSOutlineView(), + configuration: SidebarOutlineScaffold.Configuration( + columnIdentifier: "HierarchicalSearchColumn", + allowsMultipleSelection: true, + rowSizePreference: .medium + ) + ) + scrollView.frame = NSRect(x: 0, y: 0, width: 280, height: 800) + let outlineView = try #require(scrollView.documentView as? NSOutlineView) + let coordinator = DatabaseTreeOutlineCoordinator() + coordinator.connectionId = connection.id + coordinator.databaseType = .oracle + coordinator.sidebarState = SharedSidebarState.forConnection(connection.id) + coordinator.windowState = WindowSidebarState() + coordinator.activeSchema = "app" + coordinator.showRecentTables = false + coordinator.showSystemContainers = showsSystem + coordinator.searchText = searchText + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.attach(outlineView: outlineView) + coordinator.refresh() + return Fixture( + scrollView: scrollView, outlineView: outlineView, coordinator: coordinator, connection: connection, driver: driver + ) + } + + private func tearDown(_ fixture: Fixture) async { + await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: fixture.connection.id) + await SchemaService.shared.invalidate(connectionId: fixture.connection.id) + DatabaseManager.shared.removeSession(for: fixture.connection.id) + SharedSidebarState.removeConnection(fixture.connection.id) + _ = fixture.scrollView + } + + private func nodes(_ fixture: Fixture) -> [DatabaseTreeNode] { + (0.. String? in + guard case .table(let ref) = node.kind else { return nil } + return "\(ref.schema ?? "").\(ref.table.name)" + }) + let loadingRows = nodes(fixture).filter { node in + guard case .status(.loading) = node.kind else { return false } + return true + } + #expect(tables == ["attendance.timesheet", "payroll.timesheet"]) + #expect(loadingRows.isEmpty) + await tearDown(fixture) + } + + @Test("A schema the listing cannot answer for still reads when the search opens it") + func uncoveredSchemaReads() async throws { + let fixture = try await makeFixture(searchText: "ctxsys", showsSystem: true) + + try await waitUntil { fixture.driver.fetchSchemaTablesCalls.contains("CTXSYS") } + await tearDown(fixture) + } + + @Test("A search naming a schema reads it, since the listing holds its tables alone") + func namedSchemaReads() async throws { + let fixture = try await makeFixture(searchText: "payroll.") + + try await waitUntil { fixture.driver.fetchSchemaTablesCalls.contains("payroll") } + #expect(!fixture.driver.fetchSchemaTablesCalls.contains("attendance")) + await tearDown(fixture) + } + + @Test("A search reads no schema before the listing lands, then reads the one it names") + func namedSchemaWaitsForTheListing() async throws { + let fixture = try await makeFixture(searchText: "payroll", loadsListing: false) + let skippedBeforeListing = fixture.coordinator.hierarchicalSearchSkipsRead("payroll") + + let database = try #require(DatabaseManager.shared.browseScope(for: fixture.connection.id)).database + await DatabaseTreeMetadataService.shared.loadAllSchemaTables(connectionId: fixture.connection.id, database: database) + fixture.coordinator.refresh() + + #expect(skippedBeforeListing) + #expect(!fixture.coordinator.hierarchicalSearchSkipsRead("payroll")) + try await waitUntil { fixture.driver.fetchSchemaTablesCalls.contains("payroll") } + await tearDown(fixture) + } + + @Test("A plain search that matches a schema's name reads it") + func nameMatchReads() async throws { + let fixture = try await makeFixture(searchText: "payroll") + + try await waitUntil { fixture.driver.fetchSchemaTablesCalls.contains("payroll") } + await tearDown(fixture) + } +} diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift index 6e943ba8c5..bd0cb34067 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift @@ -145,6 +145,14 @@ struct DatabaseTreeNodeTests { #expect(node.containerRef(systemSchemas: []) == nil) } + @Test("A schema row of a one-row-per-schema tree is selectable, so the keyboard can reach and open it") + func hierarchicalSchemaRowIsSelectable() { + let node = DatabaseTreeNode(id: "a", kind: .hierarchicalSchemaSection(schema: "HR")) + + #expect(DatabaseTreeSelection.isSelectable(node.kind)) + #expect(DatabaseTreeTypeSelect.matchString(for: node.kind) == "HR") + } + @Test("Type-select can land on a partition by its own name") func partitionMatchesTypeSelect() { let node = DatabaseTreeNode(id: "a", kind: .partition(partitionRef("orders_2024"))) diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift new file mode 100644 index 0000000000..651b0cf089 --- /dev/null +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift @@ -0,0 +1,177 @@ +// +// DatabaseTreeReconcileTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +// The outline's services publish for every connection and every property; only a change to what this +// connection's outline draws may rebuild it. +@MainActor +struct DatabaseTreeReconcileTests { + private final class CountingOutlineView: NSOutlineView { + var reloads = 0 + + override func reloadData() { + reloads += 1 + super.reloadData() + } + } + + private struct Fixture { + let connection: DatabaseConnection + let driver: MockDatabaseDriver + let coordinator: DatabaseTreeOutlineCoordinator + let outlineView: CountingOutlineView + let sidebarState: SharedSidebarState + let viewModel: SidebarViewModel + let scrollView: NSScrollView + } + + private func makeFixture() async throws -> Fixture { + let connection = TestFixtures.makeConnection(database: "shop", type: .mysql) + let driver = MockDatabaseDriver(connection: connection) + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "orders"), TestFixtures.makeTableInfo(name: "customers")] + await SchemaService.shared.load(connectionId: connection.id, driver: driver, connection: connection) + + let outlineView = CountingOutlineView() + let scrollView = SidebarOutlineScaffold.makeScrollView( + outlineView: outlineView, + configuration: SidebarOutlineScaffold.Configuration( + columnIdentifier: "ReconcileColumn", + allowsMultipleSelection: true, + rowSizePreference: .medium + ) + ) + scrollView.frame = NSRect(x: 0, y: 0, width: 280, height: 800) + let sidebarState = SharedSidebarState.forConnection(connection.id) + sidebarState.sidebarLayout = .flat + let viewModel = SidebarViewModel( + selectedTables: .constant([]), + pendingTruncates: .constant([]), + pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), + databaseType: .mysql, + connectionId: connection.id + ) + let coordinator = DatabaseTreeOutlineCoordinator() + coordinator.connectionId = connection.id + coordinator.databaseType = .mysql + coordinator.sidebarState = sidebarState + coordinator.windowState = WindowSidebarState() + coordinator.viewModel = viewModel + coordinator.activeDatabase = "shop" + coordinator.showRecentTables = true + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.attach(outlineView: outlineView) + coordinator.refresh() + return Fixture( + connection: connection, + driver: driver, + coordinator: coordinator, + outlineView: outlineView, + sidebarState: sidebarState, + viewModel: viewModel, + scrollView: scrollView + ) + } + + private func tearDown(_ fixture: Fixture) async { + await SchemaService.shared.invalidate(connectionId: fixture.connection.id) + SharedSidebarState.removeConnection(fixture.connection.id) + _ = fixture.scrollView + _ = fixture.viewModel + } + + private func tableRows(_ fixture: Fixture) throws -> [DatabaseTreeNode] { + let section = try #require(fixture.coordinator.resolvedChildren(of: nil).first { node in + guard case .objectKindSection(.table) = node.kind else { return false } + return true + }) + return fixture.coordinator.resolvedChildren(of: section) + } + + private func tableNames(_ fixture: Fixture) throws -> [String] { + try tableRows(fixture).compactMap { node -> String? in + guard case .table(let ref) = node.kind else { return nil } + return ref.table.name + } + } + + @Test("A catalog load on another connection does not reload the outline") + func otherConnectionDoesNotReload() async throws { + let fixture = try await makeFixture() + let reloads = fixture.outlineView.reloads + let other = TestFixtures.makeConnection(database: "blog", type: .mysql) + let otherDriver = MockDatabaseDriver(connection: other) + otherDriver.tablesToReturn = [TestFixtures.makeTableInfo(name: "posts")] + + await SchemaService.shared.load(connectionId: other.id, driver: otherDriver, connection: other) + fixture.coordinator.reconcile() + + #expect(fixture.outlineView.reloads == reloads) + await SchemaService.shared.invalidate(connectionId: other.id) + await tearDown(fixture) + } + + @Test("A change to this connection's tables reloads the outline with them") + func ownTablesReload() async throws { + let fixture = try await makeFixture() + let reloads = fixture.outlineView.reloads + fixture.driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "orders"), TestFixtures.makeTableInfo(name: "invoices")] + + await SchemaService.shared.reload( + connectionId: fixture.connection.id, driver: fixture.driver, connection: fixture.connection + ) + + try await waitUntil { fixture.outlineView.reloads > reloads } + #expect(try tableNames(fixture).contains("invoices")) + await tearDown(fixture) + } + + @Test("Typing in the filter field alone does not reload the outline") + func searchTextWriteDoesNotReload() async throws { + let fixture = try await makeFixture() + let reloads = fixture.outlineView.reloads + + fixture.sidebarState.searchText = "ord" + fixture.coordinator.reconcile() + + #expect(fixture.outlineView.reloads == reloads) + await tearDown(fixture) + } + + @Test("A recent table recorded for this connection reloads the outline") + func recentTableReloads() async throws { + let fixture = try await makeFixture() + let reloads = fixture.outlineView.reloads + + fixture.sidebarState.recentTables = [RecentTableEntry( + database: "shop", schema: nil, name: "orders", isView: false, objectType: nil, openedAt: Date() + )] + fixture.coordinator.reconcile() + + #expect(fixture.outlineView.reloads == reloads + 1) + await tearDown(fixture) + } + + @Test("A reload over the same rows hands back the same row nodes") + func unchangedRowsKeepTheirNodes() async throws { + let fixture = try await makeFixture() + let before = try tableRows(fixture) + + fixture.coordinator.refresh() + let after = try tableRows(fixture) + + #expect(before.map { $0.id } == after.map { $0.id }) + #expect(SameRows(rows: before) == SameRows(rows: after)) + await tearDown(fixture) + } +} diff --git a/apple/scripts/benchmark-large-catalog.sh b/apple/scripts/benchmark-large-catalog.sh new file mode 100755 index 0000000000..e8a438ebd4 --- /dev/null +++ b/apple/scripts/benchmark-large-catalog.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Time the sidebar, Open Quickly and autocomplete on a catalog of 50,000 tables (#3661), 10 runs each, +# and compare the median and 95th percentile with scripts/benchmark/large-catalog/baseline.json. A step +# also fails when the main thread stalls for more than 100 ms during it. The app times each step itself +# (TABLEPRO_INTERACTION_TRACE=1): sidebar-listing (connect to the full listing), sidebar-filter (one +# keystroke), sidebar-expand (a schema never read; MySQL: the database), quick-open-panel, +# quick-open-result (a table in every schema), complete-from and complete-schema (MySQL: the database), +# plus resident memory. +# +# Fixtures come from scripts/probes/build-large-catalog-fixtures.sh. The release build has no Oracle +# driver, so Oracle needs --oracle-plugin with an OracleDriver.tableplugin signed by the TablePro team. +# Builds Release unless --app names a build, and drives a signed copy through System Events in a +# TABLEPRO_UI_TEST_SANDBOX, so the terminal needs Accessibility and Automation permission. +# +# Usage: +# scripts/benchmark-large-catalog.sh [--app ] [--fixtures postgres,oracle,mysql] +# [--parts listing,session,expand] [--runs 10] [--mysql user@host:port] [--postgres user@host:port] +# [--oracle host:port] [--oracle-container ] [--oracle-plugin ] [--record] [--keep] +# +# Exit: 0 within budget, 1 over budget on the baseline's Mac and configuration, 2 the run stopped +# or a step has no baseline. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="" +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --app) + [ $# -ge 2 ] || { echo "--app needs a path" >&2; exit 2; } + APP="$2" + shift 2 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +if [ -z "$APP" ]; then + (cd "$ROOT" && scripts/release/build.sh app --unsigned) + APP="$ROOT/build/release/TablePro.app" +fi +[ -d "$APP/Contents/MacOS" ] || { echo "not an app bundle: $APP" >&2; exit 2; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/tablepro-benchmark-catalog.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +# An unsigned or development-signed build is refused by launchd once copied; an ad hoc copy runs. +ditto "$APP" "$WORK/TablePro.app" +codesign --force --deep --sign - "$WORK/TablePro.app" 2> /dev/null + +python3 "$ROOT/scripts/benchmark/large-catalog/benchmark.py" "$WORK/TablePro.app" ${ARGS[@]+"${ARGS[@]}"} diff --git a/apple/scripts/benchmark/large-catalog/baseline.json b/apple/scripts/benchmark/large-catalog/baseline.json new file mode 100644 index 0000000000..38d7454681 --- /dev/null +++ b/apple/scripts/benchmark/large-catalog/baseline.json @@ -0,0 +1,201 @@ +{ + "recorded": "2026-10-22", + "machine": { + "model": "Mac16,11", + "chip": "Apple M4 Pro", + "memoryGB": 24, + "macOS": "27.0.1" + }, + "app": { + "version": "0.0.0-dev", + "build": "1", + "configuration": "Release" + }, + "runs": 10, + "budgetRule": "1.25x the baseline median and p95, rounded up to 5 ms; no main-thread stall over 100 ms", + "fixtures": { + "postgres": { + "steps": { + "sidebar-listing": { + "median": 445.1, + "p95": 506.0, + "runs": 10, + "maxStallMs": 270.9, + "budgetMedian": 560, + "budgetP95": 635 + }, + "sidebar-filter": { + "median": 123.8, + "p95": 380.2, + "runs": 10, + "maxStallMs": 192.4, + "budgetMedian": 155, + "budgetP95": 480 + }, + "sidebar-expand": { + "median": 133.0, + "p95": 242.7, + "runs": 10, + "maxStallMs": 58.5, + "budgetMedian": 170, + "budgetP95": 305 + }, + "quick-open-panel": { + "median": 169.9, + "p95": 260.9, + "runs": 10, + "maxStallMs": 51.1, + "budgetMedian": 215, + "budgetP95": 330 + }, + "quick-open-result": { + "median": 236.0, + "p95": 369.1, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 295, + "budgetP95": 465 + }, + "complete-from": { + "median": 85.3, + "p95": 92.1, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 110, + "budgetP95": 120 + }, + "complete-schema": { + "median": 122.2, + "p95": 139.6, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 155, + "budgetP95": 175 + } + }, + "memoryMB": 119.1 + }, + "oracle": { + "steps": { + "sidebar-listing": { + "median": 1444.9, + "p95": 2185.2, + "runs": 10, + "maxStallMs": 964.3, + "budgetMedian": 1810, + "budgetP95": 2735 + }, + "sidebar-filter": { + "median": 138.3, + "p95": 668.1, + "runs": 10, + "maxStallMs": 195.3, + "budgetMedian": 175, + "budgetP95": 840 + }, + "sidebar-expand": { + "median": 281.9, + "p95": 338.4, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 355, + "budgetP95": 425 + }, + "quick-open-panel": { + "median": 173.2, + "p95": 202.9, + "runs": 10, + "maxStallMs": 59.5, + "budgetMedian": 220, + "budgetP95": 255 + }, + "quick-open-result": { + "median": 171.9, + "p95": 207.5, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 215, + "budgetP95": 260 + }, + "complete-from": { + "median": 83.9, + "p95": 91.0, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 105, + "budgetP95": 115 + }, + "complete-schema": { + "median": 499.0, + "p95": 539.5, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 625, + "budgetP95": 675 + } + }, + "memoryMB": 193.9 + }, + "mysql": { + "steps": { + "sidebar-listing": { + "median": 573.7, + "p95": 657.7, + "runs": 10, + "maxStallMs": 222.0, + "budgetMedian": 720, + "budgetP95": 825 + }, + "sidebar-filter": { + "median": 134.4, + "p95": 195.5, + "runs": 10, + "maxStallMs": 140.2, + "budgetMedian": 170, + "budgetP95": 245 + }, + "sidebar-expand": { + "median": 286.1, + "p95": 317.1, + "runs": 10, + "maxStallMs": 111.9, + "budgetMedian": 360, + "budgetP95": 400 + }, + "quick-open-panel": { + "median": 155.6, + "p95": 185.8, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 195, + "budgetP95": 235 + }, + "quick-open-result": { + "median": 142.4, + "p95": 150.1, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 180, + "budgetP95": 190 + }, + "complete-from": { + "median": 103.3, + "p95": 109.9, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 130, + "budgetP95": 140 + }, + "complete-schema": { + "median": 95.4, + "p95": 116.0, + "runs": 10, + "maxStallMs": 0.0, + "budgetMedian": 120, + "budgetP95": 145 + } + }, + "memoryMB": 772.2 + } + } +} diff --git a/apple/scripts/benchmark/large-catalog/benchmark.py b/apple/scripts/benchmark/large-catalog/benchmark.py new file mode 100644 index 0000000000..e404db73dd --- /dev/null +++ b/apple/scripts/benchmark/large-catalog/benchmark.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +# Driver for scripts/benchmark-large-catalog.sh, which builds and signs the app first. + +import argparse +import datetime +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +# The sandbox, launch and System Events helpers are the app-paths benchmark's; both files are benchmark.py. +_spec = importlib.util.spec_from_file_location("app_paths_benchmark", HERE.parent / "app-paths" / "benchmark.py") +app_paths = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(app_paths) + +BASELINE = HERE / "baseline.json" +DATABASE = "tablepro_large_catalog" +# build-large-catalog-fixtures.sh creates the Oracle tenant LC_S001 with this password, whatever SYSTEM's is. +ORACLE_TENANT_PASSWORD = "probe_pw" +STALL_LIMIT_MS = 100 +STALL_REPORT_SECONDS = 1.0 +STALL_SETTLE_SECONDS = 0.2 +SETTLE_SECONDS = 0.6 +# The schema each run expands or qualifies, one per run so every read is a first read. +FIRST_RUN_SCHEMA = 50 + +FIXTURES = ["postgres", "oracle", "mysql"] +# listing: sidebar-listing. session: the filter, Open Quickly, autocomplete and memory. expand: sidebar-expand. +PARTS = ["listing", "session", "expand"] +STEPS = [ + "sidebar-listing", + "sidebar-filter", + "sidebar-expand", + "quick-open-panel", + "quick-open-result", + "complete-from", + "complete-schema", +] + +INTERACTION = re.compile( + r"^interaction #\d+ path=(\w+) outcome=(\w+) totalMs=([0-9.]+) end=([0-9.]+)(.*)$", re.M +) +STALL = re.compile(r"^stall ms=([0-9.]+) end=([0-9.]+)$", re.M) + +FOCUS_FILTER = ("f", ("control", "option", "command")) +FOCUS_LIST = ("l", ("control", "option", "command")) +FOCUS_EDITOR = ("e", ("control", "option", "command")) +KEY_DELETE, KEY_ESCAPE, KEY_LEFT, KEY_RIGHT = 51, 53, 123, 124 + + +def log(message): + app_paths.log(message) + + +class Fixture: + def __init__(self, name, record, schema_format, table, password=None, expand_database=None): + self.name = name + self.record = record + self.schema_format = schema_format + self.table = table + self.password = password + # MySQL has no schemas: its expansion is the database in the tree, with nothing browsed. + self.expand_database = expand_database + + def schema(self, index): + return self.schema_format % index + + +def postgres_fixture(server): + record = app_paths.connection_record("Catalog PostgreSQL", "PostgreSQL", 0, DATABASE, server) + return Fixture("postgres", record, "lc_s%03d", "lc_t250") + + +def oracle_fixture(server): + record = app_paths.connection_record("Catalog Oracle", "Oracle", 0, "FREEPDB1", server) + record["oracleServiceName"] = "FREEPDB1" + record["additionalFields"] = {"oracleServiceName": "FREEPDB1"} + return Fixture("oracle", record, "LC_S%03d", "LC_T250", password=server["password"]) + + +# The mysql client reads MYSQL_PWD for the fixture check; the app needs it in the sandbox's keychain. +def mysql_fixture(server): + record = app_paths.connection_record("Catalog MySQL", "MySQL", 0, DATABASE, server) + return Fixture("mysql", record, "", "lc_t00250", password=os.environ.get("MYSQL_PWD") or None, + expand_database=DATABASE) + + +# Fixture checks + + +def postgres_ready(server): + if shutil.which("psql") is None: + return "no psql client on PATH" + try: + count = app_paths.psql_command( + server, "SELECT count(*) FROM pg_tables WHERE schemaname LIKE 'lc!_s%' ESCAPE '!'", DATABASE) + except subprocess.CalledProcessError as error: + return error.stderr.strip() or str(error) + return None if int(count or 0) >= 50000 else "%s tables" % count + + +def mysql_ready(server): + if shutil.which("mysql") is None: + return "no mysql client on PATH" + try: + count = app_paths.mysql_command( + server, "SELECT count(*) FROM information_schema.tables WHERE table_schema = '%s'" % DATABASE) + except subprocess.CalledProcessError as error: + return error.stderr.strip() or str(error) + return None if int(count or 0) >= 50000 else "%s tables" % count + + +def oracle_ready(container, password): + if shutil.which("docker") is None: + return "no docker on PATH" + sql = "SET HEADING OFF\nSELECT COUNT(*) FROM dba_tables WHERE REGEXP_LIKE(owner, '^LC_S[0-9]+$');\nEXIT\n" + result = subprocess.run(["docker", "exec", "-i", container, "sqlplus", "-s", "-L", "system/%s@FREEPDB1" % password], + input=sql, capture_output=True, text=True) + numbers = re.findall(r"\d+", result.stdout) + if result.returncode != 0 or not numbers: + return (result.stderr or result.stdout).strip() or "no answer" + return None if int(numbers[-1]) >= 50000 else "%s tables" % numbers[-1] + + +# Sandboxes + + +def query_tab(): + return {"id": "00000000-0000-4000-8000-000000003661", "title": "Query 1", "query": "", + "tabType": {"query": {}}, "databaseName": ""} + + +def catalog_sandbox(work, name, fixture, layout, plugin=None, database=None, welcome=False): + sandbox = app_paths.Sandbox(work, name) + record = dict(fixture.record) + if database is not None: + record["database"] = database + sandbox.add_connection(record, [query_tab()], is_open=not welcome) + sandbox.finish("showWelcome" if welcome else "reopenLast") + suite = app_paths.defaults_suite(sandbox.path) + connection = record["id"] + app_paths.run(["defaults", "write", suite, "sidebar.layout.%s" % connection, layout]) + if fixture.password: + app_paths.write_json(sandbox.support / "keychain.json", + {"com.TablePro.password.%s" % connection: fixture.password}) + if plugin: + target = sandbox.support / "Plugins" / plugin.name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(plugin, target, symlinks=True) + return sandbox + + +# Reading the app's traces + + +def stalls_in(text): + return [(float(ms), float(end)) for ms, end in STALL.findall(text)] + + +# Any stall whose turn overlapped the trace counts, wherever it began or ended. +def worst_stall(trace, stalls): + start = trace["end"] - trace["ms"] / 1000.0 + overlapping = [ms for ms, end in stalls if end >= start and end - ms / 1000.0 <= trace["end"]] + return max(overlapping, default=0.0) + + +# A turn reports its stall only when it ends, so the stalls are scored again once every app has quit. +def rescore(results): + for traces in results.values(): + for trace in traces: + trace["stallMs"] = worst_stall(trace, stalls_in(Path(trace["log"]).read_text(errors="replace"))) + + +class Session(app_paths.RunningApp): + def interactions(self): + return [ + {"path": path, "outcome": outcome, "ms": float(total), "end": float(end), "detail": detail.strip()} + for path, outcome, total, end, detail in INTERACTION.findall(self.output()) + ] + + def stalls(self): + return stalls_in(self.output()) + + def count(self, path): + return len([item for item in self.interactions() if item["path"] == path]) + + # The next presented trace of a path after `before` of them, optionally the one whose detail matches. + def await_trace(self, path, before, describe, detail=None, timeout=120): + def check(): + found = [item for item in self.interactions() if item["path"] == path][before:] + found = [item for item in found if item["outcome"] == "presented" + and (detail is None or detail in item["detail"].split())] + return found[0] if found else None + + trace = self.wait_for(describe, check, timeout=timeout) + if "listing=failed" in trace["detail"].split(): + raise app_paths.BenchmarkError("the all-schema listing failed during %s; log: %s" % (describe, self.log_path)) + self.await_main_thread() + trace["log"] = self.log_path + trace["stallMs"] = worst_stall(trace, self.stalls()) + return trace + + # The app answers an accessibility request on its main thread, so the turn the trace ended in, and the + # stall line it prints when it ends, are done once the answer arrives. + def await_main_thread(self): + script = 'tell application "System Events" to count windows of (first process whose unix id is %d)' + try: + app_paths.osascript(script % self.pid) + except app_paths.BenchmarkError: + time.sleep(STALL_REPORT_SECONDS) + time.sleep(STALL_SETTLE_SECONDS) + + def key(self, key, modifiers=()): + self.keystroke(key, modifiers) + + def clear_field(self): + self.keystroke("a", ("command",)) + self.keystroke(KEY_DELETE) + + def resident_mb(self): + rss = app_paths.run(["ps", "-o", "rss=", "-p", str(self.pid)]).stdout.strip() + return round(int(rss) / 1024.0, 1) + + +def launch(app, sandbox, work, label): + return Session(app, sandbox, work / ("%s.log" % label)) + + +# Steps + + +# Opened by its tablepro:// link once launch has settled, so the listing is not timed against the launch. +def listing_runs(app, sandbox, work, fixture, runs): + samples = [] + for index in range(runs): + with launch(app, sandbox, work, "%s-listing-%d" % (fixture.name, index)) as session: + session.launch_ms() + time.sleep(1) + app_paths.run(["open", "-a", str(app), "tablepro://connect/%s" % fixture.record["id"]]) + trace = session.await_trace("sidebarListing", 0, "the sidebar listing") + samples.append(trace) + log(" sidebar-listing run %d: %.1f ms, %s, worst stall %.0f ms" + % (index + 1, trace["ms"], trace["detail"], trace["stallMs"])) + return samples + + +def interactive_runs(app, sandbox, work, fixture, runs, results): + with launch(app, sandbox, work, "%s-session" % fixture.name) as session: + session.await_trace("sidebarListing", 0, "the sidebar listing") + session.bring_forward() + time.sleep(1) + + samples = [] + for index in range(runs): + session.key(*FOCUS_FILTER) + before = session.count("sidebarFilter") + session.key("7") + trace = session.await_trace("sidebarFilter", before, "the filtered sidebar", detail="chars=1") + samples.append(trace) + log(" sidebar-filter run %d: %.1f ms, %s, worst stall %.0f ms" + % (index + 1, trace["ms"], trace["detail"], trace["stallMs"])) + before = session.count("sidebarFilter") + session.clear_field() + session.await_trace("sidebarFilter", before, "the cleared sidebar", detail="chars=0") + time.sleep(SETTLE_SECONDS) + results["sidebar-filter"] = samples + + panel, result = [], [] + for index in range(runs): + before = session.count("quickOpenPanel") + session.key("o", ("command", "shift")) + panel.append(session.await_trace("quickOpenPanel", before, "the Open Quickly panel")) + time.sleep(SETTLE_SECONDS) + before = session.count("quickOpenResult") + session.key(fixture.table) + result.append(session.await_trace("quickOpenResult", before, "the first result", + detail="chars=%d" % len(fixture.table))) + log(" quick-open run %d: panel %.1f ms, result %.1f ms (%s), worst stall %.0f / %.0f ms" + % (index + 1, panel[-1]["ms"], result[-1]["ms"], result[-1]["detail"], + panel[-1]["stallMs"], result[-1]["stallMs"])) + # The first Escape clears the field, the second closes the panel. + session.key(KEY_ESCAPE) + session.key(KEY_ESCAPE) + time.sleep(SETTLE_SECONDS) + results["quick-open-panel"] = panel + results["quick-open-result"] = result + + complete_from, complete_schema = [], [] + session.key(*FOCUS_EDITOR) + time.sleep(SETTLE_SECONDS) + for index in range(runs): + # MySQL has no schemas, so its database is what qualifies a table. + qualifier = fixture.schema(FIRST_RUN_SCHEMA + 10 + index) if fixture.schema_format else DATABASE + prompts = [("SELECT * FROM", " ", complete_from), ("SELECT * FROM " + qualifier, ".", complete_schema)] + for prefix, trigger, samples in prompts: + session.clear_field() + session.key(prefix) + time.sleep(1) + session.key(KEY_ESCAPE) + time.sleep(SETTLE_SECONDS) + before = session.count("completion") + session.key(trigger) + trace = session.await_trace("completion", before, "the completion popup after %r" % trigger) + samples.append(trace) + log(" complete %r run %d: %.1f ms, %s, worst stall %.0f ms" + % (trigger, index + 1, trace["ms"], trace["detail"], trace["stallMs"])) + session.key(KEY_ESCAPE) + time.sleep(SETTLE_SECONDS) + session.clear_field() + results["complete-from"] = complete_from + results["complete-schema"] = complete_schema + return session.resident_mb() + + +# Each run opens a schema no run has read, picked by typing its name in the tree. +def expand_runs(session, fixture, runs): + samples = [] + for index in range(runs): + session.key(*FOCUS_LIST) + time.sleep(SETTLE_SECONDS) + session.key(fixture.schema(FIRST_RUN_SCHEMA + index)) + time.sleep(1.2) + before = session.count("sidebarExpand") + session.key(KEY_RIGHT) + trace = session.await_trace("sidebarExpand", before, "the expanded schema") + samples.append(trace) + log(" sidebar-expand run %d: %.1f ms, %s, worst stall %.0f ms" + % (index + 1, trace["ms"], trace["detail"], trace["stallMs"])) + session.key(KEY_LEFT) + time.sleep(SETTLE_SECONDS) + return samples + + +def tree_expand_runs(app, sandbox, work, fixture, runs): + with launch(app, sandbox, work, "%s-tree" % fixture.name) as session: + session.await_trace("sidebarListing", 0, "the sidebar listing") + session.bring_forward() + time.sleep(1) + return expand_runs(session, fixture, runs) + + +# A database is read once per launch, so each run of MySQL's expansion is its own launch, in a sandbox +# of its own: the tree keeps what was open in the sandbox's defaults. +def database_expand_runs(app, work, fixture, runs, plugin, sandboxes): + samples = [] + for index in range(runs): + sandbox = catalog_sandbox(work, "%s-tree-%d" % (fixture.name, index), fixture, "tree", plugin, database="") + sandboxes.append(sandbox) + with launch(app, sandbox, work, "%s-tree-%d" % (fixture.name, index)) as session: + session.await_trace("sidebarListing", 0, "the sidebar listing") + session.bring_forward() + time.sleep(1) + session.key(*FOCUS_LIST) + time.sleep(SETTLE_SECONDS) + session.key(fixture.expand_database) + time.sleep(1.2) + before = session.count("sidebarExpand") + session.key(KEY_RIGHT) + trace = session.await_trace("sidebarExpand", before, "the expanded database") + samples.append(trace) + log(" sidebar-expand run %d: %.1f ms, %s, worst stall %.0f ms" + % (index + 1, trace["ms"], trace["detail"], trace["stallMs"])) + return samples + + +def run_fixture(app, work, fixture, runs, plugin, parts): + results = {} + memory = None + welcome = catalog_sandbox(work, "%s-welcome" % fixture.name, fixture, "flat", plugin, welcome=True) + flat = catalog_sandbox(work, "%s-flat" % fixture.name, fixture, "flat", plugin) + sandboxes = [welcome, flat] + try: + if "listing" in parts: + log("%s: sidebar-listing" % fixture.name) + results["sidebar-listing"] = listing_runs(app, welcome, work, fixture, runs) + if "session" in parts: + log("%s: filter, Open Quickly, autocomplete" % fixture.name) + memory = interactive_runs(app, flat, work, fixture, runs, results) + if "expand" not in parts: + pass + elif fixture.name == "oracle": + # Oracle lists one row per schema in either layout, so its session's sandbox serves. + log("%s: sidebar-expand" % fixture.name) + results["sidebar-expand"] = tree_expand_runs(app, flat, work, fixture, runs) + elif fixture.name == "postgres": + tree = catalog_sandbox(work, "%s-tree" % fixture.name, fixture, "tree", plugin) + sandboxes.append(tree) + log("%s: sidebar-expand" % fixture.name) + results["sidebar-expand"] = tree_expand_runs(app, tree, work, fixture, runs) + elif fixture.expand_database: + log("%s: sidebar-expand" % fixture.name) + results["sidebar-expand"] = database_expand_runs(app, work, fixture, runs, plugin, sandboxes) + finally: + for sandbox in sandboxes: + sandbox.delete_defaults() + rescore(results) + return results, memory + + +# Report + + +def summarize(traces): + summary = app_paths.summarize([trace["ms"] for trace in traces]) + summary["runs"] = len(traces) + summary["maxStallMs"] = round(max(trace["stallMs"] for trace in traces), 1) + return summary + + +def report(measured, baseline, comparable): + header = "%-9s %-18s %5s %9s %9s %10s %17s %s" % ( + "fixture", "step", "runs", "median", "p95", "max stall", "budget med/p95", "status") + print(header) + print("-" * len(header)) + over, missing = [], [] + for fixture in FIXTURES: + if fixture not in measured: + continue + steps = measured[fixture]["steps"] + expected_steps = ((baseline or {}).get("fixtures", {}).get(fixture) or {}).get("steps", {}) + for step in STEPS: + if step not in steps: + continue + summary = steps[step] + expected = expected_steps.get(step) + budget = "-" + status = "ok" if summary["maxStallMs"] <= STALL_LIMIT_MS else "STALL" + if summary["maxStallMs"] > STALL_LIMIT_MS and comparable: + over.append("%s %s" % (fixture, step)) + if not expected: + missing.append("%s %s" % (fixture, step)) + status += ", no baseline" + else: + budget = "%d / %d" % (expected["budgetMedian"], expected["budgetP95"]) + if summary["median"] > expected["budgetMedian"] or summary["p95"] > expected["budgetP95"]: + status = "OVER BUDGET" + if comparable: + over.append("%s %s" % (fixture, step)) + print("%-9s %-18s %5d %9.1f %9.1f %10.1f %17s %s" % ( + fixture, step, summary["runs"], summary["median"], summary["p95"], summary["maxStallMs"], + budget, status)) + if measured[fixture]["memoryMB"] is not None: + print("%-9s %-18s %5s %9.1f MB resident after autocomplete" + % (fixture, "memory", "", measured[fixture]["memoryMB"])) + return over, missing + + +# A step or a memory reading this run did not take keeps its entry from a baseline of the same Mac and build. +def record_baseline(measured, current_machine, current_app, runs): + previous = json.loads(BASELINE.read_text()) if BASELINE.exists() else None + fixtures = dict(previous.get("fixtures", {})) if app_paths.same_setup(previous, current_machine, current_app) else {} + for fixture, values in measured.items(): + entry = dict(fixtures.get(fixture, {})) + steps = dict(entry.get("steps", {})) + for step, summary in values["steps"].items(): + steps[step] = dict(summary, budgetMedian=app_paths.budget_for(summary["median"]), + budgetP95=app_paths.budget_for(summary["p95"])) + entry["steps"] = {step: steps[step] for step in STEPS if step in steps} + if values["memoryMB"] is not None: + entry["memoryMB"] = values["memoryMB"] + fixtures[fixture] = entry + BASELINE.write_text(json.dumps({ + "recorded": datetime.date.today().isoformat(), + "machine": current_machine, + "app": current_app, + "runs": runs, + "budgetRule": "%.2fx the baseline median and p95, rounded up to 5 ms; no main-thread stall over %d ms" + % (app_paths.BUDGET_FACTOR, STALL_LIMIT_MS), + "fixtures": {name: fixtures[name] for name in FIXTURES if name in fixtures}, + }, indent=2) + "\n") + log("wrote %s" % BASELINE) + + +def main(): + parser = argparse.ArgumentParser(description="Time the sidebar, Open Quickly and autocomplete on 50,000 tables.") + parser.add_argument("app", type=Path, help="a signed TablePro.app") + parser.add_argument("--runs", type=int, default=10) + parser.add_argument("--fixtures", default=",".join(FIXTURES), help="comma-separated: " + ", ".join(FIXTURES)) + parser.add_argument("--parts", default=",".join(PARTS), help="comma-separated: " + ", ".join(PARTS)) + parser.add_argument("--postgres", default="127.0.0.1:5432", help="user@host:port") + parser.add_argument("--mysql", help="user@host:port or user@/path/to.sock; default: the mysql client's own socket") + parser.add_argument("--oracle", default="127.0.0.1:1521", help="host:port of the fixture container's listener") + parser.add_argument("--oracle-container", default="oracle-large-catalog") + parser.add_argument("--oracle-password", default="probe_pw", help="SYSTEM's, for the fixture check") + parser.add_argument("--oracle-plugin", type=Path, help="OracleDriver.tableplugin signed with the TablePro team") + parser.add_argument("--record", action="store_true", help="write this run as the baseline") + parser.add_argument("--keep", action="store_true", help="keep the sandboxes and logs") + options = parser.parse_args() + + wanted = [name.strip() for name in options.fixtures.split(",") if name.strip()] + parts = [name.strip() for name in options.parts.split(",") if name.strip()] + unknown = [name for name in wanted if name not in FIXTURES] + [name for name in parts if name not in PARTS] + if unknown: + parser.error("unknown fixture or part: %s" % ", ".join(unknown)) + try: + postgres = app_paths.parse_server(options.postgres, 5432) + oracle = app_paths.parse_server(options.oracle, 1521) + mysql = app_paths.parse_server(options.mysql or app_paths.default_mysql_socket(), 3306, mysql_socket=True) + except argparse.ArgumentTypeError as error: + parser.error(str(error)) + + busy = os.getloadavg()[0] > os.cpu_count() / 2 + if busy and options.record: + log("load average %.1f on %d cores: record the baseline on an idle Mac" % (os.getloadavg()[0], os.cpu_count())) + return 2 + app_paths.require_accessibility() + os.environ["TABLEPRO_INTERACTION_TRACE"] = "1" + + fixtures, skipped = [], [] + for name in wanted: + if name == "postgres": + problem, fixture = postgres_ready(postgres), postgres_fixture(postgres) + elif name == "mysql": + problem, fixture = mysql_ready(mysql), mysql_fixture(mysql) + else: + problem = oracle_ready(options.oracle_container, options.oracle_password) + if problem is None and options.oracle_plugin is None: + problem = "no --oracle-plugin: the release build has no Oracle driver" + fixture = oracle_fixture({"host": oracle["host"], "port": oracle["port"], "user": "LC_S001", + "password": ORACLE_TENANT_PASSWORD}) + if problem: + log("skipping %s: %s (scripts/probes/build-large-catalog-fixtures.sh %s builds it)" % (name, problem, name)) + skipped.append(name) + continue + fixtures.append(fixture) + + work = Path(tempfile.mkdtemp(prefix="tablepro-catalog-")) + log("sandboxes and logs: %s" % work) + measured = {} + try: + for fixture in fixtures: + plugin = options.oracle_plugin if fixture.name == "oracle" else None + results, memory = run_fixture(options.app, work, fixture, options.runs, plugin, parts) + measured[fixture.name] = { + "memoryMB": memory, + "steps": {step: summarize(traces) for step, traces in results.items()}, + } + except app_paths.BenchmarkError as error: + log("benchmark stopped: %s" % error) + return 2 + finally: + if not options.keep: + shutil.rmtree(work, ignore_errors=True) + + current_machine = app_paths.machine() + current_app = app_paths.app_identity(options.app) + print("%s, %s, %d GB, macOS %s; TablePro %s (%s) %s; load average %.1f" % ( + current_machine["model"], current_machine["chip"], current_machine["memoryGB"], current_machine["macOS"], + current_app["version"], current_app["build"], current_app["configuration"], os.getloadavg()[0])) + if busy: + log("the Mac was busy when the run started: these numbers say more about the load than the app") + baseline = json.loads(BASELINE.read_text()) if BASELINE.exists() else None + comparable = app_paths.same_setup(baseline, current_machine, current_app) + over, missing = report(measured, baseline, comparable) + if skipped: + log("not measured: %s" % ", ".join(skipped)) + if options.record: + if skipped: + log("baseline not written: every requested fixture has to be measured") + return 2 + record_baseline(measured, current_machine, current_app, options.runs) + return 0 + if over: + log("over budget: %s" % ", ".join(over)) + return 1 + if missing: + log("no baseline for %s: record one with --record" % ", ".join(missing)) + return 2 if skipped or missing else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apple/scripts/probes/build-large-catalog-fixtures.sh b/apple/scripts/probes/build-large-catalog-fixtures.sh index 391a299ee0..003c48ecc9 100755 --- a/apple/scripts/probes/build-large-catalog-fixtures.sh +++ b/apple/scripts/probes/build-large-catalog-fixtures.sh @@ -8,11 +8,12 @@ # mysql database tablepro_large_catalog: 50,000 tables # # Every schema holds the same table names (lc_t001..lc_t500), as a tenant-per-schema database does, -# so a search for lc_t250 matches once per schema. +# so a search for lc_t250 matches once per schema. A session starts in the first schema: the database's +# search_path on PostgreSQL, logging in as LC_S001 (password probe_pw, reads every schema) on Oracle. # # Usage: # scripts/probes/build-large-catalog-fixtures.sh postgres [host] [port] [user] -# scripts/probes/build-large-catalog-fixtures.sh mysql [host] [port] [user] +# scripts/probes/build-large-catalog-fixtures.sh mysql [host|/path/to.sock] [port] [user] # scripts/probes/build-large-catalog-fixtures.sh oracle [container] [system-password] # scripts/probes/build-large-catalog-fixtures.sh drop-postgres|drop-mysql|drop-oracle [same arguments] # @@ -75,6 +76,8 @@ postgres_fixture() { \$\$;" > /dev/null || exit 1 [ $((i % 10)) -eq 0 ] && echo "postgres: $i of $SCHEMAS schemas, $(elapsed_since "$started")" done + psql_at "$DATABASE" -c "ALTER DATABASE $DATABASE SET search_path TO $(printf 'lc_s%0*d' "$SCHEMA_WIDTH" 1), public" \ + > /dev/null || exit 1 echo "postgres: $(psql_at "$DATABASE" -Atc "SELECT count(*) FROM pg_tables WHERE schemaname LIKE 'lc!_s%' ESCAPE '!'") tables in $DATABASE" } @@ -84,11 +87,22 @@ postgres_drop() { psql -X -q -h "$host" -p "$port" -U "$user" -d postgres -c "DROP DATABASE IF EXISTS $DATABASE" } +# A host starting with / is a socket, for a server that only lets the local user in over one. +mysql_client() { + local host="$1" port="$2" user="$3" + shift 3 + if [ "${host#/}" != "$host" ]; then + mysql --protocol=SOCKET --socket="$host" -u "$user" "$@" + else + mysql --protocol=TCP -h "$host" -P "$port" -u "$user" "$@" + fi +} + mysql_fixture() { local host="${1:-127.0.0.1}" port="${2:-3306}" user="${3:-root}" started batch i need mysql mysql_do() { - mysql --protocol=TCP -h "$host" -P "$port" -u "$user" "$@" + mysql_client "$host" "$port" "$user" "$@" } mysql_do -e "SELECT 1" > /dev/null 2>&1 || { echo "no MySQL at $host:$port as $user (MYSQL_PWD supplies a password)" >&2 @@ -111,7 +125,7 @@ mysql_fixture() { mysql_drop() { local host="${1:-127.0.0.1}" port="${2:-3306}" user="${3:-root}" need mysql - mysql --protocol=TCP -h "$host" -P "$port" -u "$user" -e "DROP DATABASE IF EXISTS $DATABASE" + mysql_client "$host" "$port" "$user" -e "DROP DATABASE IF EXISTS $DATABASE" } oracle_sqlplus() { @@ -144,6 +158,9 @@ BEGIN EXECUTE IMMEDIATE 'CREATE USER ' || owner_name || ' IDENTIFIED BY probe_pw QUOTA UNLIMITED ON USERS'; END IF; SELECT COUNT(*) INTO present FROM dba_tables WHERE owner = owner_name; + IF s = 1 THEN + EXECUTE IMMEDIATE 'GRANT CREATE SESSION, SELECT ANY TABLE TO ' || owner_name; + END IF; CONTINUE WHEN present >= $TABLES_PER_SCHEMA; FOR t IN 1..$TABLES_PER_SCHEMA LOOP BEGIN From 104bd724dbfad3d24aa54144bfff0f0488b9da1c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 25 Oct 2026 04:45:39 +0700 Subject: [PATCH 2/2] docs(changelog): add the #3830 fragments --- apple/changelog.d/3830.changed.1.md | 1 + apple/changelog.d/3830.changed.md | 1 + apple/changelog.d/3830.fixed.1.md | 1 + apple/changelog.d/3830.fixed.2.md | 1 + apple/changelog.d/3830.fixed.3.md | 1 + apple/changelog.d/3830.fixed.4.md | 1 + apple/changelog.d/3830.fixed.5.md | 1 + apple/changelog.d/3830.fixed.6.md | 1 + apple/changelog.d/3830.fixed.md | 1 + 9 files changed, 9 insertions(+) create mode 100644 apple/changelog.d/3830.changed.1.md create mode 100644 apple/changelog.d/3830.changed.md create mode 100644 apple/changelog.d/3830.fixed.1.md create mode 100644 apple/changelog.d/3830.fixed.2.md create mode 100644 apple/changelog.d/3830.fixed.3.md create mode 100644 apple/changelog.d/3830.fixed.4.md create mode 100644 apple/changelog.d/3830.fixed.5.md create mode 100644 apple/changelog.d/3830.fixed.6.md create mode 100644 apple/changelog.d/3830.fixed.md diff --git a/apple/changelog.d/3830.changed.1.md b/apple/changelog.d/3830.changed.1.md new file mode 100644 index 0000000000..d23280cd27 --- /dev/null +++ b/apple/changelog.d/3830.changed.1.md @@ -0,0 +1 @@ +MCP list_indexes reads a whole schema's indexes in one query where the database supports it. diff --git a/apple/changelog.d/3830.changed.md b/apple/changelog.d/3830.changed.md new file mode 100644 index 0000000000..f667b1c1c9 --- /dev/null +++ b/apple/changelog.d/3830.changed.md @@ -0,0 +1 @@ +The AI chat Tables menu lists the first 100 tables; type @ in the message to find any other. diff --git a/apple/changelog.d/3830.fixed.1.md b/apple/changelog.d/3830.fixed.1.md new file mode 100644 index 0000000000..14f2c2b9c6 --- /dev/null +++ b/apple/changelog.d/3830.fixed.1.md @@ -0,0 +1 @@ +Sending an AI chat message froze the app on a database with thousands of tables. diff --git a/apple/changelog.d/3830.fixed.2.md b/apple/changelog.d/3830.fixed.2.md new file mode 100644 index 0000000000..2f8e32b883 --- /dev/null +++ b/apple/changelog.d/3830.fixed.2.md @@ -0,0 +1 @@ +Autocomplete could leave the best matching table name out on databases with more than 400 tables. diff --git a/apple/changelog.d/3830.fixed.3.md b/apple/changelog.d/3830.fixed.3.md new file mode 100644 index 0000000000..827ec3887a --- /dev/null +++ b/apple/changelog.d/3830.fixed.3.md @@ -0,0 +1 @@ +Autocomplete offered nothing after a schema name and a dot typed in another case. diff --git a/apple/changelog.d/3830.fixed.4.md b/apple/changelog.d/3830.fixed.4.md new file mode 100644 index 0000000000..ca45cf1304 --- /dev/null +++ b/apple/changelog.d/3830.fixed.4.md @@ -0,0 +1 @@ +Autocomplete offered nothing after a database name and a dot on MySQL, MariaDB and other engines without schemas. diff --git a/apple/changelog.d/3830.fixed.5.md b/apple/changelog.d/3830.fixed.5.md new file mode 100644 index 0000000000..4c705b5b4e --- /dev/null +++ b/apple/changelog.d/3830.fixed.5.md @@ -0,0 +1 @@ +Oracle and other one-row-per-schema sidebars could not select a schema row, so the keyboard could not open one. diff --git a/apple/changelog.d/3830.fixed.6.md b/apple/changelog.d/3830.fixed.6.md new file mode 100644 index 0000000000..4e0401d5a6 --- /dev/null +++ b/apple/changelog.d/3830.fixed.6.md @@ -0,0 +1 @@ +Searching the sidebar on Oracle waited seconds for other schemas' tables, longer over a slow network. diff --git a/apple/changelog.d/3830.fixed.md b/apple/changelog.d/3830.fixed.md new file mode 100644 index 0000000000..33430123ae --- /dev/null +++ b/apple/changelog.d/3830.fixed.md @@ -0,0 +1 @@ +The sidebar, Open Quickly and autocomplete stalled on databases with tens of thousands of tables.