From e3a00f1586b9b373dbe11ea8af0eb09c11df8b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Wed, 26 Aug 2026 16:23:37 +0900 Subject: [PATCH 1/2] fix(editor): rerank incremental completions for the current prefix (#2444) --- CHANGELOG.md | 1 + .../Views/Editor/QueryCompletionAdapter.swift | 103 +++++------ ...QueryCompletionAdapterLifecycleTests.swift | 167 ++++++++++++++++++ 3 files changed, 214 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86420206f..cc74f3dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Autocomplete keeping an earlier prefix's ordering after the typed word becomes an exact match. (#2444) - Save reporting the number of statements it ran as the number of rows it changed. - An edit or a delete on a table with no primary key changing every identical row. (#2107) - The same statements committed twice when Cmd+S is pressed again during a slow save. diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 9401286ca..d0c2b206f 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -9,15 +9,22 @@ import AppKit import CodeEditSourceEditor import CodeEditTextView -import os import SwiftUI import TableProPluginKit @MainActor final class QueryCompletionAdapter: CodeSuggestionDelegate { + private enum SessionKind { + case seed + case resolved + } + private struct Session { var items: [SQLCompletionItem] var replacementRange: NSRange + var kind: SessionKind + var lastPrefix: String? + var lastItems: [SQLCompletionItem]? } private struct Configuration: Equatable { @@ -32,15 +39,8 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { private var session: Session? private let debounceNanoseconds: UInt64 = 50_000_000 - private let refilterDebounceNanoseconds: UInt64 = 30_000_000 private let maximumPrefixLength = 500 - private var cursorRefilterTask: Task? - private var lastRefilterPrefix: String? - private var lastRefilterItems: [SQLCompletionItem]? - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QueryCompletionAdapter") - init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil) { self.service = QueryCompletionServiceFactory.make(schemaProvider: schemaProvider, databaseType: databaseType) } @@ -69,13 +69,20 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { ) service.updateFavoriteKeywords(favoriteKeywords) session = nil - clearRefilterState() } #if DEBUG /// Identifies the built service so a test can tell a rebuild from a no-op without reaching /// for the private session state a rebuild discards. var serviceIdentityForTesting: ObjectIdentifier { ObjectIdentifier(service) } + + init(serviceForTesting service: QueryCompletionService) { + self.service = service + } + + func seedSessionForTesting(textView: TextViewController, cursorPosition: CursorPosition) { + seedSessionIfNeeded(textView: textView, cursorPosition: cursorPosition) + } #endif func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { @@ -121,8 +128,13 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { return nil } - clearRefilterState() - session = Session(items: result.items, replacementRange: result.replacementRange) + session = Session( + items: result.items, + replacementRange: result.replacementRange, + kind: .resolved, + lastPrefix: nil, + lastItems: nil + ) return (windowPosition: liveCursorPosition, items: result.items.map { SQLSuggestionEntry(item: $0) }) } @@ -138,7 +150,13 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { offset >= 0, offset <= text.length else { return } let start = service.tokenStart(in: text, endingAt: offset) - session = Session(items: items, replacementRange: NSRange(location: start, length: offset - start)) + session = Session( + items: items, + replacementRange: NSRange(location: start, length: offset - start), + kind: .seed, + lastPrefix: nil, + lastItems: nil + ) } func completionOnCursorMove( @@ -158,58 +176,29 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let prefix = text.substring(with: NSRange(location: start, length: length)).lowercased() guard !prefix.isEmpty else { return nil } - let items = synchronousRefilter(fullItems: session.items, prefix: prefix) - scheduleRefilter(fullItems: session.items, prefix: prefix) - - return items?.map { SQLSuggestionEntry(item: $0) } - } - - private func synchronousRefilter(fullItems: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem]? { - if prefix == lastRefilterPrefix, let cached = lastRefilterItems { - return cached - } - - if let lastPrefix = lastRefilterPrefix, prefix.hasPrefix(lastPrefix), let lastItems = lastRefilterItems { - let narrowed = service.filter(lastItems, prefix: prefix) - return narrowed.isEmpty ? nil : narrowed + let sourceItems: [SQLCompletionItem] + if let lastPrefix = session.lastPrefix, + prefix.hasPrefix(lastPrefix), + let lastItems = session.lastItems { + sourceItems = lastItems + } else { + sourceItems = session.items } - let filtered = service.filter(fullItems, prefix: prefix) - return filtered.isEmpty ? nil : filtered - } - - private func scheduleRefilter(fullItems: [SQLCompletionItem], prefix: String) { - cursorRefilterTask?.cancel() - - cursorRefilterTask = Task { [weak self] in - guard let self else { return } - - do { - try await Task.sleep(nanoseconds: self.refilterDebounceNanoseconds) - } catch { - return - } - guard !Task.isCancelled else { return } - - let ranked = self.service.rank(fullItems, prefix: prefix) - guard !Task.isCancelled else { return } - - self.lastRefilterPrefix = prefix - self.lastRefilterItems = ranked - Self.logger.debug("refilter cached prefix='\(prefix)' count=\(ranked.count)") + let items: [SQLCompletionItem] + switch session.kind { + case .seed: + items = service.filter(sourceItems, prefix: prefix) + case .resolved: + items = service.rank(sourceItems, prefix: prefix) } + self.session?.lastPrefix = prefix + self.session?.lastItems = items + return items.isEmpty ? nil : items.map { SQLSuggestionEntry(item: $0) } } func completionWindowDidClose() { session = nil - clearRefilterState() - } - - private func clearRefilterState() { - cursorRefilterTask?.cancel() - cursorRefilterTask = nil - lastRefilterPrefix = nil - lastRefilterItems = nil } func completionWindowApplyCompletion( diff --git a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 45b068f8d..1a00ab6f0 100644 --- a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -10,6 +10,8 @@ // deterministically unit-testable; these tests cover the logic the fix relies on. // +import CodeEditSourceEditor +import CodeEditTextView import Foundation @testable import TablePro import TableProPluginKit @@ -97,4 +99,169 @@ struct QueryCompletionAdapterLifecycleTests { #expect(adapter.serviceIdentityForTesting != first) } + + @MainActor + @Test("incremental completion reranks exact keywords") + func incrementalCompletionReranksExactKeywords() async { + let cases = [ + (initial: "t", completed: "true", exact: "TRUE", longer: "TRUNCATE"), + (initial: "n", completed: "null", exact: "NULL", longer: "NULLIF"), + (initial: "i", completed: "in", exact: "IN", longer: "INSTR") + ] + + for testCase in cases { + let labels = await incrementalLabels(initial: testCase.initial, completed: testCase.completed) + let exactIndex = labels.firstIndex(of: testCase.exact) + let longerIndex = labels.firstIndex(of: testCase.longer) + + #expect(exactIndex != nil, "Missing exact candidate \(testCase.exact)") + #expect(longerIndex != nil, "Missing longer candidate \(testCase.longer)") + if let exactIndex, let longerIndex { + #expect( + exactIndex < longerIndex, + "Expected \(testCase.exact) before \(testCase.longer) for \(testCase.completed)" + ) + } + } + } + + @MainActor + @Test("seed completion filters without ranking all favorites") + func seedCompletionFiltersWithoutRankingAllFavorites() async { + let initialQuery = "SELECT * WHERE t" + let controller = EditorControllerFixture.make(string: initialQuery) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + let initialCursor = CursorPosition(range: NSRange(location: initialQuery.utf16.count, length: 0)) + + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: initialCursor, + isManualTrigger: false + ) + adapter.completionWindowDidClose() + + let favorites = Dictionary(uniqueKeysWithValues: (0..<1_000).map { index in + let keyword = String(format: "s%04d", index) + return (keyword, (name: keyword, query: "SELECT 1")) + }) + adapter.updateFavoriteKeywords(favorites) + + let seedQuery = "s" + controller.textView.setText(seedQuery) + let seedCursor = CursorPosition(range: NSRange(location: seedQuery.utf16.count, length: 0)) + adapter.seedSessionForTesting(textView: controller, cursorPosition: seedCursor) + + let labels = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: seedCursor + )?.map(\.label) ?? [] + + #expect(labels.first == "SELECT") + #expect(labels.contains("s0000")) + } + + @MainActor + @Test("resolved completion narrows the next ranking input") + func resolvedCompletionNarrowsTheNextRankingInput() async { + let items = (0..<500).flatMap { index in + [ + SQLCompletionItem.keyword(String(format: "ab%03d", index)), + SQLCompletionItem.keyword(String(format: "ac%03d", index)) + ] + } + let service = RankingInputRecordingCompletionService(items: items) + let adapter = QueryCompletionAdapter(serviceForTesting: service) + let controller = EditorControllerFixture.make(string: "a") + let initialCursor = CursorPosition(range: NSRange(location: 1, length: 0)) + + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: initialCursor, + isManualTrigger: false + ) + + controller.textView.setText("ab") + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: 2, length: 0)) + ) + + controller.textView.setText("ab0") + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: 3, length: 0)) + ) + + #expect(service.rankingInputCounts == [1_000, 500]) + } + + @MainActor + private func incrementalLabels(initial: String, completed: String) async -> [String] { + let queryPrefix = "SELECT * WHERE " + let initialQuery = queryPrefix + initial + let controller = EditorControllerFixture.make(string: initialQuery) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + let initialCursor = CursorPosition(range: NSRange(location: initialQuery.utf16.count, length: 0)) + + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: initialCursor, + isManualTrigger: false + ) + + let completedQuery = queryPrefix + completed + controller.textView.setText(completedQuery) + let completedCursor = CursorPosition(range: NSRange(location: completedQuery.utf16.count, length: 0)) + + return adapter.completionOnCursorMove( + textView: controller, + cursorPosition: completedCursor + )?.map(\.label) ?? [] + } +} + +@MainActor +private final class RankingInputRecordingCompletionService: QueryCompletionService { + private let items: [SQLCompletionItem] + private(set) var rankingInputCounts: [Int] = [] + + init(items: [SQLCompletionItem]) { + self.items = items + } + + var triggerCharacters: Set { [] } + + func seedItems() -> [SQLCompletionItem] { [] } + + func prepare() async {} + + func completions( + in text: NSString, + at offset: Int, + isManualTrigger: Bool + ) async -> QueryCompletionSession? { + _ = text + _ = offset + _ = isManualTrigger + return QueryCompletionSession(items: items, replacementRange: NSRange(location: 0, length: 1)) + } + + func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + let lowerPrefix = prefix.lowercased() + return items.filter { $0.filterText.hasPrefix(lowerPrefix) } + } + + func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + rankingInputCounts.append(items.count) + return filter(items, prefix: prefix) + } + + func tokenStart(in text: NSString, endingAt offset: Int) -> Int { + _ = text + return 0 + } + + func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + _ = keywords + } } From 86c29a7f49e4888c6e5467d4145d969d7825fc36 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 26 Aug 2026 15:06:23 +0700 Subject: [PATCH 2/2] fix(editor): rank every incremental completion and keep a pool it can reach --- CHANGELOG.md | 11 +- .../Core/Autocomplete/CompletionEngine.swift | 7 +- .../Mongo/MongoCompletionService.swift | 23 +- .../Autocomplete/QueryCompletionService.swift | 5 +- .../Autocomplete/SQLCompletionProvider.swift | 67 ++++- .../Autocomplete/SQLCompletionService.swift | 17 +- .../Autocomplete/SQLContextAnalyzer.swift | 14 + .../Views/Editor/QueryCompletionAdapter.swift | 65 ++--- ...QueryCompletionAdapterLifecycleTests.swift | 254 +++++++++++++----- .../Editor/QueryCompletionRankingTests.swift | 135 ++++++++++ ...QLCompletionProviderConcurrencyTests.swift | 14 +- .../EditorAutocompleteFocusUITests.swift | 46 ++++ 12 files changed, 501 insertions(+), 157 deletions(-) create mode 100644 TableProTests/Views/Editor/QueryCompletionRankingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cc74f3dc6..568360b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data Rewind settings in Settings > Data & Results, with an off switch and Clear Saved Changes. - Restore Previous Values in the toolbar's Table Actions group. +### Changed + +- Editor tabs drawn as a segmented tab picker rather than in Liquid Glass, on every macOS version. (#2439) + ### Fixed - Autocomplete keeping an earlier prefix's ordering after the typed word becomes an exact match. (#2444) @@ -29,13 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A save reported as failed on an engine without transactions after some statements had been written. - Keep Open for a preview tab, by double-clicking it in the tab strip or from its contextual menu. (#2436) - Kafka driver plugin: topics in the grid, consumer group lag, and KafkaQL for seeking and producing. (#2419) - -### Changed - -- Editor tabs drawn as a segmented tab picker rather than in Liquid Glass, on every macOS version. (#2439) - -### Fixed - - Active editor tab indistinguishable from the inactive ones in light appearance. (#2439) - Active editor tab drawn darker than its track on macOS 27, and inverting when the window lost focus. - Editor tab selection changing with the desktop picture behind the window. diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 23cc497e8..47ed698ce 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -11,6 +11,9 @@ import TableProPluginKit /// Completion context returned by the engine struct CompletionContext { let items: [SQLCompletionItem] + /// The wider ranked pool an open popup re-ranks against as the prefix grows. `items` is the + /// slice of it the popup shows. + let candidates: [SQLCompletionItem] let replacementRange: NSRange let sqlContext: SQLContext } @@ -95,6 +98,7 @@ final class CompletionEngine { return CompletionContext( items: context.items, + candidates: context.candidates, replacementRange: mappedRange, sqlContext: context.sqlContext ) @@ -128,7 +132,7 @@ final class CompletionEngine { let adjustedCursor = cursorPosition - windowOffset - let (items, context) = await provider.getCompletions( + let (items, candidates, context) = await provider.completionSession( text: analysisText, cursorPosition: adjustedCursor, forcedTableReferences: forcedTableReferences @@ -164,6 +168,7 @@ final class CompletionEngine { return CompletionContext( items: items, + candidates: candidates, replacementRange: replacementRange, sqlContext: adjustedContext ) diff --git a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift index cd5071329..faa4a7b12 100644 --- a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift +++ b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift @@ -8,6 +8,12 @@ final class MongoCompletionService: QueryCompletionService { private static let windowRadius = 5_000 + /// How many candidates a session may keep for re-ranking as the prefix grows. Every position's + /// vocabulary is well under this except a collection's sampled field paths, which a wide + /// document schema can run into the thousands; those sessions prefer what the popup showed, + /// and the cap holds either way because filtering an empty opening prefix returns everything. + private static let sessionPoolLimit = 400 + init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?) { self.schemaProvider = schemaProvider _ = databaseType @@ -29,14 +35,17 @@ final class MongoCompletionService: QueryCompletionService { MongoContextAnalyzer.prefixRange(in: text, endingAt: offset).location } - func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + private func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { guard !prefix.isEmpty else { return items } let needle = prefix.lowercased() return items.filter { $0.filterText.hasPrefix(needle) || $0.filterText.contains(needle) } .sorted { lhs, rhs in - let lhsExact = lhs.filterText.hasPrefix(needle) - let rhsExact = rhs.filterText.hasPrefix(needle) - if lhsExact != rhsExact { return lhsExact } + let lhsComplete = lhs.filterText == needle + let rhsComplete = rhs.filterText == needle + if lhsComplete != rhsComplete { return lhsComplete } + let lhsAnchored = lhs.filterText.hasPrefix(needle) + let rhsAnchored = rhs.filterText.hasPrefix(needle) + if lhsAnchored != rhsAnchored { return lhsAnchored } if lhs.sortPriority != rhs.sortPriority { return lhs.sortPriority < rhs.sortPriority } return lhs.label < rhs.label } @@ -58,8 +67,12 @@ final class MongoCompletionService: QueryCompletionService { let items = await items(for: context.position) guard !items.isEmpty else { return nil } + let shown = filter(items, prefix: context.prefix) + let pool = items.count <= Self.sessionPoolLimit ? items : shown + return QueryCompletionSession( - items: filter(items, prefix: context.prefix), + items: shown, + candidates: Array(pool.prefix(Self.sessionPoolLimit)), replacementRange: NSRange( location: context.prefixRange.location + windowStart, length: context.prefixRange.length diff --git a/TablePro/Core/Autocomplete/QueryCompletionService.swift b/TablePro/Core/Autocomplete/QueryCompletionService.swift index 1730dbf79..b7a47eb04 100644 --- a/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -2,7 +2,11 @@ import Foundation import TableProPluginKit struct QueryCompletionSession { + /// What the popup shows when it opens. let items: [SQLCompletionItem] + /// What it re-ranks against while it stays open, which is wider than `items` so a longer + /// prefix can promote a candidate the opening prefix ranked out of view. + let candidates: [SQLCompletionItem] let replacementRange: NSRange } @@ -13,7 +17,6 @@ protocol QueryCompletionService: AnyObject { func seedItems() -> [SQLCompletionItem] func prepare() async func completions(in text: NSString, at offset: Int, isManualTrigger: Bool) async -> QueryCompletionSession? - func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] func tokenStart(in text: NSString, endingAt offset: Int) -> Int func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 64c45dcde..77dd7d37f 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -76,13 +76,28 @@ final class SQLCompletionProvider { cursorPosition: Int, forcedTableReferences: [TableReference]? = nil ) async -> (items: [SQLCompletionItem], context: SQLContext) { + let session = await completionSession( + text: text, + cursorPosition: cursorPosition, + forcedTableReferences: forcedTableReferences + ) + return (session.items, session.context) + } + + /// The completions for the cursor position, as both what the popup shows and the wider pool + /// it re-ranks against while it stays open. + func completionSession( + text: String, + cursorPosition: Int, + forcedTableReferences: [TableReference]? = nil + ) async -> (items: [SQLCompletionItem], candidates: [SQLCompletionItem], context: SQLContext) { var context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition) if let forcedTableReferences { context = context.replacingTableReferences(forcedTableReferences) } if context.isInsideString || context.isInsideComment { - return ([], context) + return ([], [], context) } var candidates = await getCandidates(for: context) @@ -93,11 +108,36 @@ final class SQLCompletionProvider { candidates = rankResults(candidates, prefix: context.prefix, context: context) - let limited = Array(candidates.prefix(maxSuggestions(for: context.clauseType))) + let limit = maxSuggestions(for: context.clauseType) - return (limited, context) + return (Array(candidates.prefix(limit)), Array(candidates.prefix(sessionPool(for: limit))), context) } + /// Filter, rank and cut a session's candidates down to what the popup shows. + /// + /// The popup shows `maxSuggestions`, but the session holds `sessionPool` of them, because an + /// open popup re-ranks against what it kept rather than asking again. A candidate cut at the + /// opening prefix can never lead for a longer one however well the survivors are ordered, and + /// PostgreSQL declares more `T`-prefixed functions than the popup shows rows, every one of + /// them outranking the `TRUE` keyword for the single letter `t`. + func filterRankAndLimit( + _ items: [SQLCompletionItem], + prefix: String, + context: SQLContext + ) -> [SQLCompletionItem] { + Array(filterAndRank(items, prefix: prefix, context: context).prefix(maxSuggestions(for: context.clauseType))) + } + + /// Ten times what the popup shows. Ranking is linear in the pool and the filter beside it + /// already walks every candidate: measured at 36us for 40 candidates and 340us for 400, against + /// 4ms at 5,000, which is why the pool is bounded rather than kept whole. + private func sessionPool(for limit: Int) -> Int { limit * 10 } + + /// The ceiling for a session built without going through `completionSession`. The seeded + /// window is statement keywords plus every saved favorite, which has no natural bound, and it + /// is replaced by an analyzed session as soon as the request lands. + var seedPoolLimit: Int { sessionPool(for: maxSuggestions(for: .unknown)) } + /// Generic SQL functions plus the active dialect's own functions (deduplicated). /// Cached per dialect; invalidated in `setDatabaseType`. private func functionItems() -> [SQLCompletionItem] { @@ -716,15 +756,24 @@ final class SQLCompletionProvider { // MARK: - Ranking - /// Rank results by relevance + /// Rank results by relevance, lowest score first. + /// + /// Scores are resolved once per candidate rather than inside the comparator, which called + /// `calculateScore` twice per comparison. Ranking runs on every keystroke of an open popup, + /// so the candidate set is walked once and the sort then compares integers. + /// + /// Equal scores fall back to the candidate's own position, which is the order the generator + /// emitted it in and carries meaning `sorted(by:)` would otherwise be free to discard: the + /// standard library documents the sort as not stable. func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { let lowerPrefix = prefix.lowercased() - - return items.sorted { a, b in - let aScore = calculateScore(for: a, prefix: lowerPrefix, context: context) - let bScore = calculateScore(for: b, prefix: lowerPrefix, context: context) - return aScore < bScore // Lower score = higher priority + let scored = items.enumerated().map { position, item in + (position: position, item: item, score: calculateScore(for: item, prefix: lowerPrefix, context: context)) } + + return scored.sorted { lhs, rhs in + lhs.score == rhs.score ? lhs.position < rhs.position : lhs.score < rhs.score + }.map(\.item) } /// Calculate ranking score for an item (lower = better). diff --git a/TablePro/Core/Autocomplete/SQLCompletionService.swift b/TablePro/Core/Autocomplete/SQLCompletionService.swift index cf68f571d..85b121077 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -4,7 +4,7 @@ import TableProPluginKit @MainActor final class SQLCompletionService: QueryCompletionService { private let engine: CompletionEngine - private var lastContext: SQLContext? + private var lastContext = SQLContext.unanalyzed private static let windowRadius = 5_000 @@ -28,8 +28,13 @@ final class SQLCompletionService: QueryCompletionService { var triggerCharacters: Set { [".", " ", ":", "(", ","] } + /// Seeding starts a session the analyzer has not seen, so the context a previous session + /// left behind stops describing anything. Ranking a seeded session against it would score + /// the new prefix under the old clause. func seedItems() -> [SQLCompletionItem] { - engine.keywordCompletions() + engine.allFavoriteItems() + lastContext = .unanalyzed + let items = engine.keywordCompletions() + engine.allFavoriteItems() + return Array(items.prefix(engine.provider.seedPoolLimit)) } func prepare() async { @@ -44,13 +49,8 @@ final class SQLCompletionService: QueryCompletionService { SQLTokenBoundary.segmentStart(in: text, endingAt: offset) } - func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - engine.provider.filterByPrefix(items, prefix: prefix) - } - func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - guard let context = lastContext else { return filter(items, prefix: prefix) } - return engine.provider.filterAndRank(items, prefix: prefix, context: context) + engine.provider.filterRankAndLimit(items, prefix: prefix, context: lastContext) } func completions(in text: NSString, at offset: Int, isManualTrigger: Bool) async -> QueryCompletionSession? { @@ -68,6 +68,7 @@ final class SQLCompletionService: QueryCompletionService { lastContext = context.sqlContext return QueryCompletionSession( items: context.items, + candidates: context.candidates, replacementRange: NSRange( location: context.replacementRange.location + windowStart, length: context.replacementRange.length diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 9cb99791e..ca334f8d2 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -142,6 +142,20 @@ struct SQLContext { comparisonColumn: comparisonColumn ) } + + /// The context to rank against before the analyzer has produced one, which is the window + /// between the popup seeding itself with statement-start keywords and the first analyzed + /// request completing. It carries no clause and no tables, so ranking falls back to the + /// prefix and exact-match bonuses alone rather than not ranking at all. + static let unanalyzed = SQLContext( + clauseType: .unknown, + prefix: "", + prefixRange: 0..<0, + dotPrefix: nil, + tableReferences: [], + isInsideString: false, + isInsideComment: false + ) } /// Analyzes SQL query to determine completion context diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index d0c2b206f..915c16e2c 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -14,17 +14,9 @@ import TableProPluginKit @MainActor final class QueryCompletionAdapter: CodeSuggestionDelegate { - private enum SessionKind { - case seed - case resolved - } - private struct Session { - var items: [SQLCompletionItem] + var candidates: [SQLCompletionItem] var replacementRange: NSRange - var kind: SessionKind - var lastPrefix: String? - var lastItems: [SQLCompletionItem]? } private struct Configuration: Equatable { @@ -79,10 +71,6 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { init(serviceForTesting service: QueryCompletionService) { self.service = service } - - func seedSessionForTesting(textView: TextViewController, cursorPosition: CursorPosition) { - seedSessionIfNeeded(textView: textView, cursorPosition: cursorPosition) - } #endif func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { @@ -128,13 +116,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { return nil } - session = Session( - items: result.items, - replacementRange: result.replacementRange, - kind: .resolved, - lastPrefix: nil, - lastItems: nil - ) + session = Session(candidates: result.candidates, replacementRange: result.replacementRange) return (windowPosition: liveCursorPosition, items: result.items.map { SQLSuggestionEntry(item: $0) }) } @@ -150,15 +132,19 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { offset >= 0, offset <= text.length else { return } let start = service.tokenStart(in: text, endingAt: offset) - session = Session( - items: items, - replacementRange: NSRange(location: start, length: offset - start), - kind: .seed, - lastPrefix: nil, - lastItems: nil - ) - } - + session = Session(candidates: items, replacementRange: NSRange(location: start, length: offset - start)) + } + + /// Filters and ranks the open session's candidates for the token the cursor sits at the end of. + /// + /// Ranking happens here, on the keystroke, rather than on a debounced task writing to a cache: + /// the list handed back is the list the suggestion window shows and preselects its first row + /// from, so a list ordered for an earlier prefix commits the wrong item on Return. It costs no + /// debounce, because filtering already matches every candidate in the session and ordering the + /// survivors is the cheaper half. + /// + /// The survivors come from the session's own candidates rather than the previous keystroke's, + /// so deleting a character widens the list back out. func completionOnCursorMove( textView: TextViewController, cursorPosition: CursorPosition @@ -176,25 +162,8 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let prefix = text.substring(with: NSRange(location: start, length: length)).lowercased() guard !prefix.isEmpty else { return nil } - let sourceItems: [SQLCompletionItem] - if let lastPrefix = session.lastPrefix, - prefix.hasPrefix(lastPrefix), - let lastItems = session.lastItems { - sourceItems = lastItems - } else { - sourceItems = session.items - } - - let items: [SQLCompletionItem] - switch session.kind { - case .seed: - items = service.filter(sourceItems, prefix: prefix) - case .resolved: - items = service.rank(sourceItems, prefix: prefix) - } - self.session?.lastPrefix = prefix - self.session?.lastItems = items - return items.isEmpty ? nil : items.map { SQLSuggestionEntry(item: $0) } + let ranked = service.rank(session.candidates, prefix: prefix) + return ranked.isEmpty ? nil : ranked.map { SQLSuggestionEntry(item: $0) } } func completionWindowDidClose() { diff --git a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 1a00ab6f0..8b0b3451c 100644 --- a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -100,83 +100,187 @@ struct QueryCompletionAdapterLifecycleTests { #expect(adapter.serviceIdentityForTesting != first) } + // MARK: - Incremental ranking (#2444) + + /// The popup preselects its first row on every incremental update, so a list still ordered for + /// the prefix it opened with is what Return commits. Every pair here opened with the function + /// correctly ahead of the keyword, and has to swap once the typed token completes the keyword. @MainActor - @Test("incremental completion reranks exact keywords") - func incrementalCompletionReranksExactKeywords() async { - let cases = [ - (initial: "t", completed: "true", exact: "TRUE", longer: "TRUNCATE"), - (initial: "n", completed: "null", exact: "NULL", longer: "NULLIF"), - (initial: "i", completed: "in", exact: "IN", longer: "INSTR") + @Test( + "typing to an exact keyword puts it first", + arguments: [ + (opening: "t", typed: "true", exact: "TRUE", longer: "TRUNCATE"), + (opening: "n", typed: "null", exact: "NULL", longer: "NULLIF"), + (opening: "i", typed: "in", exact: "IN", longer: "INSTR"), + (opening: "i", typed: "is", exact: "IS", longer: "ISNULL"), + (opening: "r", typed: "regexp", exact: "REGEXP", longer: "REGEXP_REPLACE") ] - - for testCase in cases { - let labels = await incrementalLabels(initial: testCase.initial, completed: testCase.completed) - let exactIndex = labels.firstIndex(of: testCase.exact) - let longerIndex = labels.firstIndex(of: testCase.longer) - - #expect(exactIndex != nil, "Missing exact candidate \(testCase.exact)") - #expect(longerIndex != nil, "Missing longer candidate \(testCase.longer)") - if let exactIndex, let longerIndex { - #expect( - exactIndex < longerIndex, - "Expected \(testCase.exact) before \(testCase.longer) for \(testCase.completed)" - ) - } + ) + func incrementalUpdatePutsTheExactKeywordFirst( + opening: String, + typed: String, + exact: String, + longer: String + ) async { + let labels = await incrementalLabels(opening: opening, typed: typed) + + #expect(labels.first == exact) + if let exactIndex = labels.firstIndex(of: exact), let longerIndex = labels.firstIndex(of: longer) { + #expect(exactIndex < longerIndex) } } + /// The reporter's own A/B: dismissing the popup and reopening it on the complete token gave the + /// right order, so an incremental update has to arrive at the same answer. @MainActor - @Test("seed completion filters without ranking all favorites") - func seedCompletionFiltersWithoutRankingAllFavorites() async { - let initialQuery = "SELECT * WHERE t" - let controller = EditorControllerFixture.make(string: initialQuery) + @Test("typing into an open popup lands where reopening it would") + func incrementalUpdateMatchesAFreshRequest() async { + let opened = "SELECT * FROM gt_user WHERE t" + let completed = "SELECT * FROM gt_user WHERE true" + + let controller = EditorControllerFixture.make(string: opened) let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - let initialCursor = CursorPosition(range: NSRange(location: initialQuery.utf16.count, length: 0)) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: opened), + isManualTrigger: false + ) + controller.textView.setText(completed) + for length in (opened.utf16.count + 1)...completed.utf16.count { + _ = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: CursorPosition(range: NSRange(location: length, length: 0)) + ) + } + let incremental = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: completed) + )?.map(\.label) + + let freshController = EditorControllerFixture.make(string: completed) + let freshAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + let fresh = await freshAdapter.completionSuggestionsRequested( + textView: freshController, + cursorPosition: cursor(atEndOf: completed), + isManualTrigger: false + )?.items.map(\.label) + + #expect(incremental?.first == "TRUE") + #expect(incremental?.first == fresh?.first) + } + + /// Deleting a character has to widen the list again, which it only does when each update is + /// resolved from the session's own candidates rather than from the previous keystroke's. + @MainActor + @Test("deleting a character widens the list again") + func deletingACharacterWidensTheList() async { + let opened = "SELECT * FROM gt_user WHERE t" + let controller = EditorControllerFixture.make(string: opened) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) _ = await adapter.completionSuggestionsRequested( textView: controller, - cursorPosition: initialCursor, + cursorPosition: cursor(atEndOf: opened), isManualTrigger: false ) - adapter.completionWindowDidClose() - let favorites = Dictionary(uniqueKeysWithValues: (0..<1_000).map { index in + let narrow = "SELECT * FROM gt_user WHERE trun" + controller.textView.setText(narrow) + let narrowed = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: narrow) + )?.map(\.label) ?? [] + + let wide = "SELECT * FROM gt_user WHERE tr" + controller.textView.setText(wide) + let widened = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: wide) + )?.map(\.label) ?? [] + + #expect(!narrowed.isEmpty) + #expect(widened.count > narrowed.count) + #expect(widened.contains("TRUE")) + } + + // MARK: - The seeded session + + /// The popup seeds itself with statement keywords and shows them while the analyzed request is + /// in flight, and keeps them when that request comes back suppressed. Ranking used to be + /// skipped for a session with no analyzed context, so the seeded list came back in declaration + /// order: DESCRIBE sits ahead of DESC in the keyword table. + @MainActor + @Test("a seeded session ranks its exact match first") + func seededSessionRanksItsExactMatchFirst() async { + let suppressed = "SELECT * FROM users WHERE " + let controller = EditorControllerFixture.make(string: suppressed) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + + let request = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: suppressed), + isManualTrigger: false + ) + #expect(request == nil, "An empty prefix in a WHERE clause is suppressed, leaving the seeded session") + + let typed = suppressed + "desc" + controller.textView.setText(typed) + let labels = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: typed) + )?.map(\.label) ?? [] + + #expect(labels.first == "DESC") + #expect(labels.contains("DESCRIBE")) + } + + /// Saved favorites have no natural bound, so the seeded window caps what it keeps. Ranking is + /// linear in the candidate count and runs on every keystroke, and the seeded session is + /// replaced by an analyzed one as soon as the request lands. + @MainActor + @Test("a seeded session bounds what it keeps") + func seededSessionBoundsWhatItKeeps() async { + let suppressed = "SELECT * FROM users WHERE " + let controller = EditorControllerFixture.make(string: suppressed) + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) + adapter.updateFavoriteKeywords(Dictionary(uniqueKeysWithValues: (0..<5_000).map { index in let keyword = String(format: "s%04d", index) return (keyword, (name: keyword, query: "SELECT 1")) - }) - adapter.updateFavoriteKeywords(favorites) + })) - let seedQuery = "s" - controller.textView.setText(seedQuery) - let seedCursor = CursorPosition(range: NSRange(location: seedQuery.utf16.count, length: 0)) - adapter.seedSessionForTesting(textView: controller, cursorPosition: seedCursor) + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: suppressed), + isManualTrigger: false + ) + let typed = suppressed + "s" + controller.textView.setText(typed) let labels = adapter.completionOnCursorMove( textView: controller, - cursorPosition: seedCursor + cursorPosition: cursor(atEndOf: typed) )?.map(\.label) ?? [] - #expect(labels.first == "SELECT") - #expect(labels.contains("s0000")) + #expect(!labels.isEmpty) + #expect(labels.count <= 200) } + // MARK: - The session pool + + /// An open popup re-ranks against what the session kept rather than asking again, so the + /// session keeps more candidates than the popup shows. It still keeps a bounded number: ranking + /// walks every one of them on every keystroke. @MainActor - @Test("resolved completion narrows the next ranking input") - func resolvedCompletionNarrowsTheNextRankingInput() async { - let items = (0..<500).flatMap { index in - [ - SQLCompletionItem.keyword(String(format: "ab%03d", index)), - SQLCompletionItem.keyword(String(format: "ac%03d", index)) - ] - } - let service = RankingInputRecordingCompletionService(items: items) + @Test("an open session re-ranks a bounded pool wider than the visible list") + func openSessionReranksABoundedPool() async { + let items = (0..<5_000).map { SQLCompletionItem.keyword(String(format: "ab%04d", $0)) } + let service = RankingInputRecordingCompletionService(items: items, poolLimit: 400) let adapter = QueryCompletionAdapter(serviceForTesting: service) let controller = EditorControllerFixture.make(string: "a") - let initialCursor = CursorPosition(range: NSRange(location: 1, length: 0)) _ = await adapter.completionSuggestionsRequested( textView: controller, - cursorPosition: initialCursor, + cursorPosition: CursorPosition(range: NSRange(location: 1, length: 0)), isManualTrigger: false ) @@ -185,48 +289,56 @@ struct QueryCompletionAdapterLifecycleTests { textView: controller, cursorPosition: CursorPosition(range: NSRange(location: 2, length: 0)) ) - controller.textView.setText("ab0") _ = adapter.completionOnCursorMove( textView: controller, cursorPosition: CursorPosition(range: NSRange(location: 3, length: 0)) ) - #expect(service.rankingInputCounts == [1_000, 500]) + #expect(service.rankingInputCounts == [400, 400]) + } + + // MARK: - Helpers + + @MainActor + private func cursor(atEndOf text: String) -> CursorPosition { + CursorPosition(range: NSRange(location: text.utf16.count, length: 0)) } @MainActor - private func incrementalLabels(initial: String, completed: String) async -> [String] { - let queryPrefix = "SELECT * WHERE " - let initialQuery = queryPrefix + initial - let controller = EditorControllerFixture.make(string: initialQuery) + private func incrementalLabels(opening: String, typed: String) async -> [String] { + let queryPrefix = "SELECT * FROM gt_user WHERE " + let openingQuery = queryPrefix + opening + let controller = EditorControllerFixture.make(string: openingQuery) let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .mysql) - let initialCursor = CursorPosition(range: NSRange(location: initialQuery.utf16.count, length: 0)) _ = await adapter.completionSuggestionsRequested( textView: controller, - cursorPosition: initialCursor, + cursorPosition: cursor(atEndOf: openingQuery), isManualTrigger: false ) - let completedQuery = queryPrefix + completed - controller.textView.setText(completedQuery) - let completedCursor = CursorPosition(range: NSRange(location: completedQuery.utf16.count, length: 0)) + let typedQuery = queryPrefix + typed + controller.textView.setText(typedQuery) return adapter.completionOnCursorMove( textView: controller, - cursorPosition: completedCursor + cursorPosition: cursor(atEndOf: typedQuery) )?.map(\.label) ?? [] } } +/// Records what each incremental update was asked to rank, so a test can pin the pool's bound +/// without reaching into the adapter's private session. @MainActor private final class RankingInputRecordingCompletionService: QueryCompletionService { private let items: [SQLCompletionItem] + private let poolLimit: Int private(set) var rankingInputCounts: [Int] = [] - init(items: [SQLCompletionItem]) { + init(items: [SQLCompletionItem], poolLimit: Int) { self.items = items + self.poolLimit = poolLimit } var triggerCharacters: Set { [] } @@ -240,24 +352,22 @@ private final class RankingInputRecordingCompletionService: QueryCompletionServi at offset: Int, isManualTrigger: Bool ) async -> QueryCompletionSession? { - _ = text - _ = offset - _ = isManualTrigger - return QueryCompletionSession(items: items, replacementRange: NSRange(location: 0, length: 1)) - } - - func filter(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - let lowerPrefix = prefix.lowercased() - return items.filter { $0.filterText.hasPrefix(lowerPrefix) } + _ = (text, offset, isManualTrigger) + return QueryCompletionSession( + items: Array(items.prefix(40)), + candidates: Array(items.prefix(poolLimit)), + replacementRange: NSRange(location: 0, length: 1) + ) } func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { rankingInputCounts.append(items.count) - return filter(items, prefix: prefix) + let lowerPrefix = prefix.lowercased() + return items.filter { $0.filterText.hasPrefix(lowerPrefix) } } func tokenStart(in text: NSString, endingAt offset: Int) -> Int { - _ = text + _ = (text, offset) return 0 } diff --git a/TableProTests/Views/Editor/QueryCompletionRankingTests.swift b/TableProTests/Views/Editor/QueryCompletionRankingTests.swift new file mode 100644 index 000000000..093055ef4 --- /dev/null +++ b/TableProTests/Views/Editor/QueryCompletionRankingTests.swift @@ -0,0 +1,135 @@ +// +// QueryCompletionRankingTests.swift +// TableProTests +// +// The ranking behind #2444, at the provider and service level: a completed token leads, the +// session keeps enough candidates for a longer prefix to reach one, and equal scores keep the +// order the generator emitted them in. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Query Completion Ranking") +struct QueryCompletionRankingTests { + // MARK: - The session pool + + /// The popup shows `maxSuggestions` candidates, and a session that kept only those could not + /// answer a longer prefix. PostgreSQL declares more `T`-prefixed functions than the popup shows + /// rows, every one of them outranking the `TRUE` keyword for the single letter `t`, so `TRUE` + /// was cut before the session was stored and typing `rue` re-ranked a set it was never in. + @Test("a candidate ranked out of the visible list still leads once the prefix reaches it") + func candidateBelowTheDisplayCutStillLeads() { + let provider = SQLCompletionProvider(schemaProvider: nil) + let context = SQLContext.unanalyzed + let crowd = (0..<60).map { SQLCompletionItem.function("to_something_\($0)", signature: "()") } + let candidates = crowd + [SQLCompletionItem.keyword("TRUE")] + + let shown = provider.filterRankAndLimit(candidates, prefix: "t", context: context) + #expect(!shown.contains { $0.label == "TRUE" }, "The crowd fills the visible list at 't'") + + let pool = provider.filterAndRank(candidates, prefix: "t", context: context) + let completed = provider.filterRankAndLimit(pool, prefix: "true", context: context) + + #expect(completed.first?.label == "TRUE") + } + + @Test("a request keeps a wider pool than it shows") + func requestKeepsAWiderPoolThanItShows() async { + let provider = SQLCompletionProvider(schemaProvider: nil, databaseType: .postgresql) + let text = "SELECT * FROM gt_user WHERE t" + + let session = await provider.completionSession(text: text, cursorPosition: text.utf16.count) + + #expect(!session.items.isEmpty) + #expect(session.candidates.count >= session.items.count) + #expect(session.candidates.starts(with: session.items)) + } + + // MARK: - Ordering + + @Test("a completed token outranks the longer candidates it prefixes") + func completedTokenOutranksLongerCandidates() { + let provider = SQLCompletionProvider(schemaProvider: nil) + let items = [ + SQLCompletionItem.function("TRUNCATE", signature: "TRUNCATE(n, decimals)"), + SQLCompletionItem.keyword("TRUE") + ] + + let opening = provider.filterAndRank(items, prefix: "t", context: .unanalyzed) + let completed = provider.filterAndRank(items, prefix: "true", context: .unanalyzed) + + #expect(opening.first?.label == "TRUNCATE", "A function outranks a keyword at a bare 't'") + #expect(completed.first?.label == "TRUE") + } + + /// Ties keep the order the candidate generator emitted them in, which carries meaning: + /// statement keywords are declared in the order they are offered. `sorted(by:)` is documented + /// as not stable, so the position is carried into the comparison rather than assumed. + @Test("candidates that score alike keep the order they were generated in") + func tiedScoresKeepGeneratedOrder() { + let provider = SQLCompletionProvider(schemaProvider: nil) + let items = [ + SQLCompletionItem.keyword("AXLE"), + SQLCompletionItem.keyword("ABLE"), + SQLCompletionItem.keyword("ACME") + ] + + let ranked = provider.rankResults(items, prefix: "a", context: .unanalyzed) + let reversed = provider.rankResults(items.reversed(), prefix: "a", context: .unanalyzed) + + #expect(ranked.map(\.label) == ["AXLE", "ABLE", "ACME"]) + #expect(reversed.map(\.label) == ["ACME", "ABLE", "AXLE"]) + } + + // MARK: - MongoDB + + /// MongoDB ranks by anchored match then by kind priority, and a shell method outranks a + /// keyword. Without a tier for the completed token, a longer method led the keyword the user + /// had finished typing. + @MainActor + @Test("a completed MongoDB token leads the longer candidate it prefixes") + func mongoCompletedTokenLeads() { + let service = MongoCompletionService(schemaProvider: nil, databaseType: .mongodb) + let items = [ + SQLCompletionItem.function("statsDetail", signature: "()"), + SQLCompletionItem.keyword("stats") + ] + + let ranked = service.rank(items, prefix: "stats") + + #expect(ranked.first?.filterText == "stats") + #expect(ranked.count == 2) + } + + /// A wide document schema can sample thousands of field paths, and an empty opening prefix + /// filters none of them away, so the pool has to be cut rather than merely preferred. + @MainActor + @Test("a MongoDB session bounds what it keeps even with no opening prefix") + func mongoSessionBoundsAnEmptyPrefixPool() async { + let service = MongoCompletionService(schemaProvider: nil, databaseType: .mongodb) + let text = "db.orders.aggregate([{ $" as NSString + + let session = await service.completions(in: text, at: text.length, isManualTrigger: true) + + #expect(session != nil) + #expect((session?.candidates.count ?? 0) <= 400) + } + + @MainActor + @Test("MongoDB collection methods still rank the exact method first") + func mongoCollectionMethodsRank() { + let service = MongoCompletionService(schemaProvider: nil, databaseType: .mongodb) + let items = [ + SQLCompletionItem.function("findOne", signature: "()"), + SQLCompletionItem.function("findOneAndUpdate", signature: "()"), + SQLCompletionItem.function("find", signature: "()") + ] + + let ranked = service.rank(items, prefix: "find") + + #expect(ranked.map(\.label) == ["find", "findOne", "findOneAndUpdate"]) + } +} diff --git a/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift b/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift index 469a1fa94..9f134499c 100644 --- a/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift @@ -2,10 +2,11 @@ // SQLCompletionProviderConcurrencyTests.swift // TableProTests // -// Guards the invariant that filterByPrefix and filterAndRank are pure and -// safe to call off the main actor. SQLCompletionAdapter runs filterAndRank -// on a detached task while typing, so concurrent invocations from the main -// actor's synchronous fast path must not diverge. +// Guards the invariant that filterByPrefix and filterAndRank are pure: they read +// no mutable provider state, so repeated and concurrent invocations on the same +// input agree. QueryCompletionAdapter now ranks synchronously on the keystroke +// (#2444), and purity is what lets it, because the popup asks on every keystroke +// and must get the same answer for the same prefix. // @testable import TablePro @@ -63,12 +64,13 @@ struct SQLCompletionProviderConcurrencyTests { } /// The point of the test is that `filterAndRank` reads no mutable state, which is what the - /// compiler cannot see from the provider's type alone. + /// compiler cannot see from the provider's type alone. Nothing calls it off the main actor + /// today; the guard is that nothing in it would break if something did. private struct ConcurrentProvider: @unchecked Sendable { let provider: SQLCompletionProvider } - @Test("filterAndRank is safe under concurrent invocations from a detached task") + @Test("filterAndRank is safe under concurrent invocations") func filterAndRankConcurrent() async { let provider = makeProvider() let items = makeItems() diff --git a/TableProUITests/EditorAutocompleteFocusUITests.swift b/TableProUITests/EditorAutocompleteFocusUITests.swift index 887d7981b..d69697ef0 100644 --- a/TableProUITests/EditorAutocompleteFocusUITests.swift +++ b/TableProUITests/EditorAutocompleteFocusUITests.swift @@ -18,6 +18,52 @@ final class EditorAutocompleteFocusUITests: UITestCase { ) } + /// #2444: with the popup already open for `t`, typing the rest of `true` has to re-rank so the + /// preselected first row is the exact keyword. The popup is a borderless panel whose rows are + /// not reliably queryable, so this asserts the text Return actually inserts. + func testTypingToAnExactKeywordCommitsThatKeyword() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitToExist(timeout: 10)) + XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") + + app.typeText("select * from t where t") + XCTAssertTrue( + waitForValue(in: editor, timeout: 5) { $0.lowercased() == "select * from t where t" }, + "Editor should hold the opening prefix; got '\(editor.value as? String ?? "nil")'" + ) + + app.typeText("rue") + RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) + app.typeKey(.return, modifierFlags: []) + + let committed = waitForValue(in: editor, timeout: 5) { + $0.lowercased().hasSuffix("true") + } + + XCTAssertTrue( + committed, + "Return should commit the keyword the typed token completes; got " + + "'\(editor.value as? String ?? "nil")'" + ) + } + + private func waitForValue( + in element: XCUIElement, + timeout: TimeInterval, + matching predicate: (String) -> Bool + ) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + if predicate(element.value as? String ?? "") { return true } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + return predicate(element.value as? String ?? "") + } + private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { let deadline = Date(timeIntervalSinceNow: timeout) while Date() < deadline {