From e4617097f097721d53b5d056663118b1b9ebe920 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 20:03:06 +0700 Subject: [PATCH 1/2] fix(datagrid): suggest columns at every clause position in raw SQL filter (#1346) --- CHANGELOG.md | 1 + .../Core/Autocomplete/CompletionEngine.swift | 39 +++- .../RawSQLFilterCompletionProvider.swift | 51 +++++ .../Autocomplete/SQLCompletionProvider.swift | 13 +- .../Autocomplete/SQLContextAnalyzer.swift | 16 ++ TablePro/Views/Filter/FilterPanelView.swift | 27 ++- TablePro/Views/Filter/FilterRowView.swift | 12 +- .../Views/Filter/FilterValueTextField.swift | 130 ++++++++++--- .../MainContentCoordinator+FilterState.swift | 5 + .../CompletionEngineFilterTests.swift | 184 ++++++++++++++++++ docs/features/filtering.mdx | 2 + 11 files changed, 446 insertions(+), 34 deletions(-) create mode 100644 TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift create mode 100644 TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 004004beb..c132178f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346) - Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322) - A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322) diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index dd02595ba..46f0552ba 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -55,11 +55,45 @@ final class CompletionEngine { await provider.retrySchemaIfNeeded() } + /// Completions for a single-table filter expression (a bare WHERE-clause + /// fragment such as `id = 1 AND na`). The fragment is completed as the WHERE + /// clause it denotes and columns are scoped to `tableName`, so suggestions + /// fire at every clause position. Returned ranges are relative to `fragment`. + func filterCompletions( + fragment: String, + cursorPosition: Int, + tableName: String + ) async -> CompletionContext? { + let clausePrefix = "WHERE " + let prefixLength = (clausePrefix as NSString).length + let analysisText = clausePrefix + fragment + let references = [TableReference(tableName: tableName, alias: nil)] + + guard let context = await getCompletions( + text: analysisText, + cursorPosition: cursorPosition + prefixLength, + forcedTableReferences: references + ) else { + return nil + } + + let mappedLocation = context.replacementRange.location - prefixLength + guard mappedLocation >= 0 else { return nil } + let mappedRange = NSRange(location: mappedLocation, length: context.replacementRange.length) + + return CompletionContext( + items: context.items, + replacementRange: mappedRange, + sqlContext: context.sqlContext + ) + } + /// Get completions for the given text and cursor position /// This is a pure function - no side effects func getCompletions( text: String, - cursorPosition: Int + cursorPosition: Int, + forcedTableReferences: [TableReference]? = nil ) async -> CompletionContext? { let nsText = text as NSString let textLength = nsText.length @@ -85,7 +119,8 @@ final class CompletionEngine { // Get completions from provider (uses the potentially windowed text) let (items, context) = await provider.getCompletions( text: analysisText, - cursorPosition: adjustedCursor + cursorPosition: adjustedCursor, + forcedTableReferences: forcedTableReferences ) // Don't return empty results diff --git a/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift b/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift new file mode 100644 index 000000000..56b599ee8 --- /dev/null +++ b/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift @@ -0,0 +1,51 @@ +// +// RawSQLFilterCompletionProvider.swift +// TablePro +// + +import Foundation + +struct RawSQLFilterCompletionItem: Equatable { + let label: String + let insertText: String +} + +struct RawSQLFilterCompletions { + let items: [RawSQLFilterCompletionItem] + let replacementRange: NSRange +} + +@MainActor +final class RawSQLFilterCompletionProvider { + private let engine: CompletionEngine + private let tableName: String + + init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType, tableName: String) { + let dialect = PluginManager.shared.sqlDialect(for: databaseType) + let statementCompletions = PluginManager.shared.statementCompletions(for: databaseType) + self.engine = CompletionEngine( + schemaProvider: schemaProvider, + databaseType: databaseType, + dialect: dialect, + statementCompletions: statementCompletions + ) + self.tableName = tableName + } + + func completions(fieldText: String, cursor: Int) async -> RawSQLFilterCompletions? { + guard let context = await engine.filterCompletions( + fragment: fieldText, + cursorPosition: cursor, + tableName: tableName + ) else { + return nil + } + + let items = context.items.map { + RawSQLFilterCompletionItem(label: $0.label, insertText: $0.insertText) + } + guard !items.isEmpty else { return nil } + + return RawSQLFilterCompletions(items: items, replacementRange: context.replacementRange) + } +} diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index f80dea1e5..737d24b09 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -65,13 +65,20 @@ final class SQLCompletionProvider { // MARK: - Public API - /// Get completion suggestions for the current cursor position + /// Get completion suggestions for the current cursor position. + /// `forcedTableReferences` overrides the tables in scope, used when the caller + /// already knows the table (e.g. a single-table filter expression) rather than + /// relying on a FROM clause in the analyzed text. func getCompletions( text: String, - cursorPosition: Int + cursorPosition: Int, + forcedTableReferences: [TableReference]? = nil ) async -> (items: [SQLCompletionItem], context: SQLContext) { // Analyze context - let context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition) + var context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition) + if let forcedTableReferences { + context = context.replacingTableReferences(forcedTableReferences) + } // Don't complete inside strings or comments if context.isInsideString || context.isInsideComment { diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 8b1cdf3d3..5abeaade8 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -103,6 +103,22 @@ struct SQLContext { self.currentFunction = currentFunction self.isAfterComma = isAfterComma } + + func replacingTableReferences(_ references: [TableReference]) -> SQLContext { + SQLContext( + clauseType: clauseType, + prefix: prefix, + prefixRange: prefixRange, + dotPrefix: dotPrefix, + tableReferences: references, + isInsideString: isInsideString, + isInsideComment: isInsideComment, + cteNames: cteNames, + nestingLevel: nestingLevel, + currentFunction: currentFunction, + isAfterComma: isAfterComma + ) + } } /// Analyzes SQL query to determine completion context diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index c4cfa4467..9558b13e4 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -20,6 +20,7 @@ struct FilterPanelView: View { @State private var showSavePresetAlert = false @State private var newPresetName = "" @State private var focusedFilterId: UUID? + @State private var rawSQLCompletionProvider: RawSQLFilterCompletionProvider? private let estimatedFilterRowHeight: CGFloat = 32 private let maxFilterListHeight: CGFloat = 200 @@ -44,12 +45,17 @@ struct FilterPanelView: View { coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) } focusedFilterId = filterState.filters.last?.id + refreshRawSQLCompletionProvider() } .onChange(of: columns) { _, newColumns in if filterState.filters.isEmpty && !newColumns.isEmpty && filterState.isVisible { coordinator.addFilter(columns: newColumns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id } + refreshRawSQLCompletionProvider() + } + .onChange(of: coordinator.currentTableName) { _, _ in + refreshRawSQLCompletionProvider() } .sheet(isPresented: $showSQLSheet) { SQLPreviewSheet(sql: generatedSQL) @@ -183,6 +189,7 @@ struct FilterPanelView: View { columns: columns, completions: completionItems(), enumValuesByColumn: enumValuesByColumn, + rawSQLCompletionProvider: rawSQLCompletionProvider, onAdd: { coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id @@ -238,9 +245,12 @@ struct FilterPanelView: View { onApply(coordinator.selectedTabFilterState.appliedFilters) } - private func completionItems() -> [String] { + private var isSQLDialect: Bool { let langName = PluginManager.shared.queryLanguageName(for: databaseType) - let isSQLDialect = langName == "SQL" || langName == "CQL" || langName == "PartiQL" + return langName == "SQL" || langName == "CQL" || langName == "PartiQL" + } + + private func completionItems() -> [String] { let sqlKeywords = [ "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "IS NULL", "IS NOT NULL", "EXISTS", @@ -248,4 +258,17 @@ struct FilterPanelView: View { ] return isSQLDialect ? columns + sqlKeywords : columns } + + private func refreshRawSQLCompletionProvider() { + guard isSQLDialect, let tableName = coordinator.currentTableName else { + rawSQLCompletionProvider = nil + return + } + let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id) + rawSQLCompletionProvider = RawSQLFilterCompletionProvider( + schemaProvider: schemaProvider, + databaseType: databaseType, + tableName: tableName + ) + } } diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index e6a294f6f..cc3fba8f7 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -10,6 +10,7 @@ struct FilterRowView: View { let columns: [String] let completions: [String] var enumValuesByColumn: [String: [String]] = [:] + var rawSQLCompletionProvider: RawSQLFilterCompletionProvider? let onAdd: () -> Void let onDuplicate: () -> Void let onRemove: () -> Void @@ -20,6 +21,13 @@ struct FilterRowView: View { [.equal, .notEqual] } + private var rawSQLCompletionSource: FilterCompletionSource { + if let rawSQLCompletionProvider { + return .sqlTokens(rawSQLCompletionProvider) + } + return .staticValues(completions) + } + private var allowedValuesForCurrentColumn: [String]? { guard !filter.isRawSQL, let values = enumValuesByColumn[filter.columnName], @@ -85,7 +93,7 @@ struct FilterRowView: View { focusedId: $focusedFilterId, identity: filter.id, placeholder: "e.g. id = 1", - completions: completions, + completionSource: rawSQLCompletionSource, allowsMultiLine: true, onSubmit: onSubmit ) @@ -100,7 +108,7 @@ struct FilterRowView: View { focusedId: $focusedFilterId, identity: filter.id, placeholder: String(localized: "Value"), - completions: completions, + completionSource: .staticValues(completions), onSubmit: onSubmit ) .frame(minWidth: 80) diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index 4e5ef4b7d..fd9df3ef0 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -7,12 +7,17 @@ import AppKit import Combine import SwiftUI +enum FilterCompletionSource { + case staticValues([String]) + case sqlTokens(RawSQLFilterCompletionProvider) +} + struct FilterValueTextField: NSViewRepresentable { @Binding var text: String @Binding var focusedId: UUID? let identity: UUID var placeholder: String = "" - var completions: [String] = [] + var completionSource: FilterCompletionSource = .staticValues([]) var allowsMultiLine: Bool = false var onSubmit: () -> Void = {} @@ -49,7 +54,7 @@ struct FilterValueTextField: NSViewRepresentable { context.coordinator.text = $text context.coordinator.focusedId = $focusedId context.coordinator.identity = identity - context.coordinator.completions = completions + context.coordinator.completionSource = completionSource context.coordinator.onSubmit = onSubmit return textField @@ -59,7 +64,7 @@ struct FilterValueTextField: NSViewRepresentable { context.coordinator.text = $text context.coordinator.focusedId = $focusedId context.coordinator.identity = identity - context.coordinator.completions = completions + context.coordinator.completionSource = completionSource context.coordinator.onSubmit = onSubmit context.coordinator.textField = textField @@ -90,7 +95,7 @@ struct FilterValueTextField: NSViewRepresentable { text: $text, focusedId: $focusedId, identity: identity, - completions: completions, + completionSource: completionSource, onSubmit: onSubmit ) } @@ -100,25 +105,32 @@ struct FilterValueTextField: NSViewRepresentable { var text: Binding var focusedId: Binding var identity: UUID - var completions: [String] + var completionSource: FilterCompletionSource var onSubmit: () -> Void weak var textField: NSTextField? private let suggestionState = SuggestionState() private var suggestionPopover: NSPopover? private var keyMonitor: Any? + private var latestReplacementRange: NSRange? + private var completionGeneration = 0 + + private var submitsOnAccept: Bool { + if case .staticValues = completionSource { return true } + return false + } init( text: Binding, focusedId: Binding, identity: UUID, - completions: [String], + completionSource: FilterCompletionSource, onSubmit: @escaping () -> Void ) { self.text = text self.focusedId = focusedId self.identity = identity - self.completions = completions + self.completionSource = completionSource self.onSubmit = onSubmit } @@ -145,7 +157,7 @@ struct FilterValueTextField: NSViewRepresentable { ) -> Bool { if commandSelector == #selector(NSResponder.insertNewline(_:)) { if suggestionPopover != nil { - acceptCurrentSelection(submitting: true) + acceptCurrentSelection(submitting: submitsOnAccept) return true } onSubmit() @@ -160,12 +172,21 @@ struct FilterValueTextField: NSViewRepresentable { } private func updateSuggestions(for textField: NSTextField) { - guard !completions.isEmpty else { + if let fieldEditor = textField.currentEditor() as? NSTextView, + fieldEditor.hasMarkedText() { dismissSuggestions() return } - if let fieldEditor = textField.currentEditor() as? NSTextView, - fieldEditor.hasMarkedText() { + switch completionSource { + case .staticValues(let values): + updateStaticSuggestions(for: textField, values: values) + case .sqlTokens(let provider): + updateTokenSuggestions(for: textField, provider: provider) + } + } + + private func updateStaticSuggestions(for textField: NSTextField, values: [String]) { + guard !values.isEmpty else { dismissSuggestions() return } @@ -174,22 +195,56 @@ struct FilterValueTextField: NSViewRepresentable { dismissSuggestions() return } - let filtered = FilterValueTextField.suggestions(for: input, in: completions) + let filtered = FilterValueTextField.suggestions(for: input, in: values) guard !filtered.isEmpty else { dismissSuggestions() return } + let items = filtered.map { SuggestionItem(label: $0, insertText: $0) } + presentSuggestions(items, for: textField, replacementRange: nil) + } + + private func updateTokenSuggestions(for textField: NSTextField, provider: RawSQLFilterCompletionProvider) { + let fieldText = textField.stringValue + guard !fieldText.isEmpty else { + dismissSuggestions() + return + } + let editor = textField.currentEditor() as? NSTextView + let cursor = editor?.selectedRange().location ?? (fieldText as NSString).length + + completionGeneration &+= 1 + let generation = completionGeneration + Task { [weak self] in + guard let self else { return } + let result = await provider.completions(fieldText: fieldText, cursor: cursor) + guard self.completionGeneration == generation else { return } + guard let result else { + self.dismissSuggestions() + return + } + let items = result.items.map { + SuggestionItem(label: $0.label, insertText: $0.insertText) + } + self.presentSuggestions(items, for: textField, replacementRange: result.replacementRange) + } + } + private func presentSuggestions( + _ items: [SuggestionItem], + for textField: NSTextField, + replacementRange: NSRange? + ) { + latestReplacementRange = replacementRange if suggestionPopover != nil { - suggestionState.items = filtered + suggestionState.items = items suggestionState.selectedIndex = 0 return } - - showPopover(for: textField, items: filtered) + showPopover(for: textField, items: items) } - private func showPopover(for textField: NSTextField, items: [String]) { + private func showPopover(for textField: NSTextField, items: [SuggestionItem]) { suggestionState.items = items suggestionState.selectedIndex = 0 @@ -207,7 +262,7 @@ struct FilterValueTextField: NSViewRepresentable { contentSize: NSSize(width: dropdownWidth, height: dropdownHeight) ) { [weak self] dismiss in SuggestionDropdownView(state: state) { selection in - self?.commit(selection: selection, submitting: false) + self?.commit(item: selection, submitting: false) dismiss() } } @@ -235,7 +290,7 @@ struct FilterValueTextField: NSViewRepresentable { self.moveSelection(by: -1) return nil case .return: - self.acceptCurrentSelection(submitting: true) + self.acceptCurrentSelection(submitting: self.submitsOnAccept) return nil case .tab: self.acceptCurrentSelection(submitting: false) @@ -272,19 +327,39 @@ struct FilterValueTextField: NSViewRepresentable { if submitting { onSubmit() } return } - commit(selection: items[index], submitting: submitting) + commit(item: items[index], submitting: submitting) } - private func commit(selection: String, submitting: Bool) { - text.wrappedValue = selection - textField?.stringValue = selection + private func commit(item: SuggestionItem, submitting: Bool) { + switch completionSource { + case .staticValues: + text.wrappedValue = item.insertText + textField?.stringValue = item.insertText + case .sqlTokens: + spliceTokenCompletion(item.insertText) + } dismissSuggestions() if submitting { onSubmit() } } + private func spliceTokenCompletion(_ insertText: String) { + guard let textField, let range = latestReplacementRange else { return } + let current = textField.stringValue as NSString + guard range.location >= 0, + range.location + range.length <= current.length else { return } + + let newText = current.replacingCharacters(in: range, with: insertText) + text.wrappedValue = newText + textField.stringValue = newText + + let caret = range.location + (insertText as NSString).length + (textField.currentEditor() as? NSTextView)?.selectedRange = NSRange(location: caret, length: 0) + } + func dismissSuggestions() { + completionGeneration &+= 1 removeKeyMonitor() suggestionPopover?.close() suggestionPopover = nil @@ -304,22 +379,27 @@ struct FilterValueTextField: NSViewRepresentable { } } + private struct SuggestionItem: Equatable { + let label: String + let insertText: String + } + @MainActor private final class SuggestionState: ObservableObject { - @Published var items: [String] = [] + @Published var items: [SuggestionItem] = [] @Published var selectedIndex: Int = 0 } private struct SuggestionDropdownView: View { @ObservedObject var state: SuggestionState - let onSelect: (String) -> Void + let onSelect: (SuggestionItem) -> Void var body: some View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(Array(state.items.enumerated()), id: \.offset) { index, item in - Text(item) + Text(item.label) .font(.callout) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift index 1ef2194bc..77c5660ee 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift @@ -11,6 +11,11 @@ extension MainContentCoordinator { filterCoordinator.selectedTabFilterState } + var currentTableName: String? { + guard let tab = tabManager.selectedTab, tab.tabType == .table else { return nil } + return tab.tableContext.tableName + } + func addFilter(columns: [String] = [], primaryKeyColumn: String? = nil) { filterCoordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) } diff --git a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift new file mode 100644 index 000000000..e984bd660 --- /dev/null +++ b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift @@ -0,0 +1,184 @@ +// +// CompletionEngineFilterTests.swift +// TableProTests +// +// Tests for CompletionEngine.filterCompletions: completion of a raw SQL +// filter fragment (a bare WHERE-clause expression) at every clause position. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class MockFilterDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var tablesToReturn: [TableInfo] = [] + var columnsPerTable: [String: [ColumnInfo]] = [:] + + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchTables() async throws -> [TableInfo] { tablesToReturn } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { + columnsPerTable[table.lowercased()] ?? [] + } + + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { columnsPerTable } + + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func createDatabase(name: String, charset: String, collation: String?) async throws {} + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} + +@Suite("Completion Engine Filter Completions", .serialized) +@MainActor +struct CompletionEngineFilterTests { + private func makeEngine() async -> CompletionEngine { + let driver = MockFilterDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsPerTable = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "email"), + TestFixtures.makeColumnInfo(name: "created_at") + ], + "orders": [TestFixtures.makeColumnInfo(name: "total")] + ] + + let provider = SQLSchemaProvider() + await provider.resetForDatabase("testdb", tables: driver.tablesToReturn, driver: driver) + _ = await provider.getColumns(for: "users") + _ = await provider.getColumns(for: "orders") + + return CompletionEngine(schemaProvider: provider, databaseType: .mysql) + } + + @Test("Suggests columns after AND") + func columnsAfterAnd() async { + let engine = await makeEngine() + let fragment = "id = 1 AND cre" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: (fragment as NSString).length, + tableName: "users" + ) + let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("created_at")) + } + + @Test("Replacement range covers the current token, not the whole field") + func replacementRangeCoversToken() async { + let engine = await makeEngine() + let fragment = "id = 1 AND cre" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: (fragment as NSString).length, + tableName: "users" + ) + #expect(result?.replacementRange == NSRange(location: 11, length: 3)) + } + + @Test("Suggests columns at the first token") + func columnsAtFirstToken() async { + let engine = await makeEngine() + let fragment = "cre" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: 3, + tableName: "users" + ) + let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("created_at")) + #expect(result?.replacementRange == NSRange(location: 0, length: 3)) + } + + @Test("Scopes columns to the given table only") + func scopesToTableOnly() async { + let engine = await makeEngine() + let fragment = "id = 1 AND to" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: (fragment as NSString).length, + tableName: "users" + ) + let labels = result?.items.map(\.label) ?? [] + #expect(!labels.contains("total")) + } + + @Test("Suggests logical keywords after a complete condition") + func keywordsAfterAnd() async { + let engine = await makeEngine() + let fragment = "id = 1 AND li" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: (fragment as NSString).length, + tableName: "users" + ) + let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("LIKE")) + } + + @Test("No completion inside a string literal") + func noCompletionInsideString() async { + let engine = await makeEngine() + let fragment = "email = 'jo" + let result = await engine.filterCompletions( + fragment: fragment, + cursorPosition: (fragment as NSString).length, + tableName: "users" + ) + #expect(result == nil) + } +} diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index 5d4c1456b..46b513587 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -29,6 +29,8 @@ created_at > NOW() - INTERVAL 7 DAY price * quantity > 1000 ``` +As you type, autocomplete suggests the table's columns and SQL keywords at every position in the expression, including after AND and OR. Use the arrow keys to pick a suggestion and Tab or Return to insert it. + To switch a row to column mode, select a column from the picker. From f66c57da00490957d3dbef4d15903c50ffe9ba4a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 20:10:47 +0700 Subject: [PATCH 2/2] refactor(datagrid): debounce raw SQL filter completion and extract testable token splice (#1346) --- .../Views/Filter/FilterValueTextField.swift | 31 +++++++------ .../CompletionEngineFilterTests.swift | 6 ++- .../Filter/FilterValueTextFieldTests.swift | 43 ++++++++++++++++++- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index fd9df3ef0..7c2533e9b 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -31,6 +31,13 @@ struct FilterValueTextField: NSViewRepresentable { return matches } + static func splice(into current: String, range: NSRange, insertText: String) -> (text: String, caret: Int)? { + let ns = current as NSString + guard range.location >= 0, range.location + range.length <= ns.length else { return nil } + let caret = range.location + (insertText as NSString).length + return (ns.replacingCharacters(in: range, with: insertText), caret) + } + func makeNSView(context: Context) -> NSTextField { let textField = SubstitutionDisabledTextField() textField.bezelStyle = .roundedBezel @@ -114,6 +121,7 @@ struct FilterValueTextField: NSViewRepresentable { private var keyMonitor: Any? private var latestReplacementRange: NSRange? private var completionGeneration = 0 + private static let completionDebounce: UInt64 = 50_000_000 private var submitsOnAccept: Bool { if case .staticValues = completionSource { return true } @@ -216,7 +224,8 @@ struct FilterValueTextField: NSViewRepresentable { completionGeneration &+= 1 let generation = completionGeneration Task { [weak self] in - guard let self else { return } + try? await Task.sleep(nanoseconds: Self.completionDebounce) + guard let self, self.completionGeneration == generation else { return } let result = await provider.completions(fieldText: fieldText, cursor: cursor) guard self.completionGeneration == generation else { return } guard let result else { @@ -345,17 +354,15 @@ struct FilterValueTextField: NSViewRepresentable { } private func spliceTokenCompletion(_ insertText: String) { - guard let textField, let range = latestReplacementRange else { return } - let current = textField.stringValue as NSString - guard range.location >= 0, - range.location + range.length <= current.length else { return } - - let newText = current.replacingCharacters(in: range, with: insertText) - text.wrappedValue = newText - textField.stringValue = newText - - let caret = range.location + (insertText as NSString).length - (textField.currentEditor() as? NSTextView)?.selectedRange = NSRange(location: caret, length: 0) + guard let textField, let range = latestReplacementRange, + let spliced = FilterValueTextField.splice( + into: textField.stringValue, range: range, insertText: insertText + ) + else { return } + + text.wrappedValue = spliced.text + textField.stringValue = spliced.text + (textField.currentEditor() as? NSTextView)?.selectedRange = NSRange(location: spliced.caret, length: 0) } func dismissSuggestions() { diff --git a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift index e984bd660..e2a8026ff 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift @@ -92,7 +92,8 @@ struct CompletionEngineFilterTests { "users": [ TestFixtures.makeColumnInfo(name: "id"), TestFixtures.makeColumnInfo(name: "email"), - TestFixtures.makeColumnInfo(name: "created_at") + TestFixtures.makeColumnInfo(name: "created_at"), + TestFixtures.makeColumnInfo(name: "title") ], "orders": [TestFixtures.makeColumnInfo(name: "total")] ] @@ -147,13 +148,14 @@ struct CompletionEngineFilterTests { @Test("Scopes columns to the given table only") func scopesToTableOnly() async { let engine = await makeEngine() - let fragment = "id = 1 AND to" + let fragment = "id = 1 AND t" let result = await engine.filterCompletions( fragment: fragment, cursorPosition: (fragment as NSString).length, tableName: "users" ) let labels = result?.items.map(\.label) ?? [] + #expect(labels.contains("title")) #expect(!labels.contains("total")) } diff --git a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift index 436a527c6..33b1b6f39 100644 --- a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift +++ b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("Filter Value Text Field Suggestions") @@ -72,4 +72,45 @@ struct FilterValueTextFieldTests { ) #expect(result == ["name"]) } + + @Test("Splice replaces only the token range and preserves surrounding text") + func testSplice_replacesOnlyTokenRange() { + let result = FilterValueTextField.splice( + into: "id = 1 AND cre", + range: NSRange(location: 11, length: 3), + insertText: "created_at" + ) + #expect(result?.text == "id = 1 AND created_at") + } + + @Test("Splice places the caret after the inserted text") + func testSplice_caretAfterInsertedText() { + let result = FilterValueTextField.splice( + into: "id = 1 AND cre", + range: NSRange(location: 11, length: 3), + insertText: "created_at" + ) + #expect(result?.caret == 21) + } + + @Test("Splice into the middle of an expression keeps the trailing text") + func testSplice_keepsTrailingText() { + let result = FilterValueTextField.splice( + into: "sta AND id = 1", + range: NSRange(location: 0, length: 3), + insertText: "status" + ) + #expect(result?.text == "status AND id = 1") + #expect(result?.caret == 6) + } + + @Test("Splice rejects an out-of-bounds range") + func testSplice_outOfBoundsReturnsNil() { + let result = FilterValueTextField.splice( + into: "abc", + range: NSRange(location: 5, length: 2), + insertText: "x" + ) + #expect(result == nil) + } }