diff --git a/CHANGELOG.md b/CHANGELOG.md index 6636fd116..7bc5f8e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Favorite keywords now work again: favorites scoped to a deleted connection were silently kept in storage but hidden everywhere, so typing their keyword did nothing. Deleting a connection now also deletes its saved queries, their folders, and per-table filters, the delete confirmation says so, and favorites already orphaned by an earlier delete are cleaned up at launch. +- Keyword autocomplete and SQL keyword suggestions now work in editors without a database connection, and favorites appear in the completion popup immediately instead of after a short delay. +- Typing a favorite's keyword in the Quick Switcher now finds the saved query instead of ranking it below name matches. - PostgreSQL databases without a "public" schema now load tables from the first available schema, the schema selector also appears when only one schema exists, and the database list counts tables in every user schema instead of only "public". (#1662) - Creating a table now turns the Create Table tab into the new table's tab instead of leaving the creation tab open next to a duplicate, and the sidebar shows the new table without a manual refresh. (#1664) - Cmd+S in the Create Table tab now creates the table, matching the Save shortcut everywhere else. (#1664) diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index adb8f104a..883622f23 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -31,7 +31,7 @@ final class CompletionEngine { // MARK: - Initialization init( - schemaProvider: SQLSchemaProvider, + schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = [] @@ -61,6 +61,12 @@ final class CompletionEngine { provider.statementStartCompletionItems() } + /// All favorite keyword items, used to seed the pre-debounce completion + /// session so favorites are filterable before the async fetch completes. + func allFavoriteItems() -> [SQLCompletionItem] { + provider.allFavoriteItems() + } + /// 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 diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 43aab2b20..79c425c6c 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -13,7 +13,7 @@ final class SQLCompletionProvider { // MARK: - Properties private let contextAnalyzer = SQLContextAnalyzer() - private let schemaProvider: SQLSchemaProvider + private let schemaProvider: SQLSchemaProvider? private var databaseType: DatabaseType? private var cachedDialect: SQLDialectDescriptor? private var cachedFunctionItems: [SQLCompletionItem]? @@ -40,7 +40,7 @@ final class SQLCompletionProvider { // MARK: - Init - init(schemaProvider: SQLSchemaProvider, databaseType: DatabaseType? = nil, + init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = []) { self.schemaProvider = schemaProvider self.databaseType = databaseType @@ -62,7 +62,7 @@ final class SQLCompletionProvider { } func retrySchemaIfNeeded() async { - await schemaProvider.retryLoadSchemaIfNeeded() + await schemaProvider?.retryLoadSchemaIfNeeded() } // MARK: - Public API @@ -133,6 +133,7 @@ final class SQLCompletionProvider { // namespace fallback also covers aliases that spuriously resolve to a // schema name parsed out of the FROM clause itself. if let dotPrefix = context.dotPrefix { + guard let schemaProvider else { return [] } if let tableName = await schemaProvider.resolveAlias(dotPrefix, in: context.tableReferences) { let schema = context.tableReferences.first { $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame @@ -153,8 +154,8 @@ final class SQLCompletionProvider { switch context.clauseType { case .from, .join: // Tables + schema/database names + JOIN/clause transition keywords - items = await schemaProvider.tableCompletionItems() - items += await schemaProvider.namespaceCompletionItems() + items = await schemaProvider?.tableCompletionItems() ?? [] + items += await schemaProvider?.namespaceCompletionItems() ?? [] items += filterKeywords([ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", @@ -165,7 +166,7 @@ final class SQLCompletionProvider { case .into: // Tables + INSERT continuation keywords - items = await schemaProvider.tableCompletionItems() + items = await schemaProvider?.tableCompletionItems() ?? [] items += filterKeywords([ "VALUES", "SELECT", "SET", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", @@ -235,7 +236,7 @@ final class SQLCompletionProvider { // Add qualified column suggestions (table.column) for join conditions for ref in context.tableReferences { let qualifier = ref.alias ?? ref.tableName - let cols = await schemaProvider.columnCompletionItems(for: ref.tableName, schema: ref.schema) + let cols = await schemaProvider?.columnCompletionItems(for: ref.tableName, schema: ref.schema) ?? [] for col in cols { items.append(SQLCompletionItem( label: "\(qualifier).\(col.label)", @@ -299,14 +300,14 @@ final class SQLCompletionProvider { case .set: // Columns for UPDATE SET clause + transition keywords if let firstTable = context.tableReferences.first { - items = await schemaProvider.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) + items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } items += filterKeywords(["WHERE", "RETURNING"]) case .insertColumns: // Columns for INSERT column list if let firstTable = context.tableReferences.first { - items = await schemaProvider.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) + items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } case .values: @@ -375,7 +376,7 @@ final class SQLCompletionProvider { case .alterTableColumn: // After ALTER TABLE tablename DROP/MODIFY/CHANGE/RENAME or AFTER/BEFORE - suggest column names if let firstTable = context.tableReferences.first { - items = await schemaProvider.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) + items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } case .createTable: @@ -439,13 +440,13 @@ final class SQLCompletionProvider { case .dropObject: // After DROP TABLE/INDEX/VIEW - suggest tables - items = await schemaProvider.tableCompletionItems() + items = await schemaProvider?.tableCompletionItems() ?? [] items += filterKeywords(["IF EXISTS", "CASCADE", "RESTRICT"]) case .createIndex: if context.tableReferences.isEmpty { // Before ON tablename — suggest tables and ON keyword - items = await schemaProvider.tableCompletionItems() + items = await schemaProvider?.tableCompletionItems() ?? [] items += filterKeywords(["ON"]) } else { // After ON tablename (inside parens) — suggest columns @@ -456,11 +457,11 @@ final class SQLCompletionProvider { case .createView: // After CREATE VIEW - suggest SELECT items = filterKeywords(["SELECT", "AS"]) - items += await schemaProvider.tableCompletionItems() + items += await schemaProvider?.tableCompletionItems() ?? [] case .unknown: items = statementStartCompletionItems() - items += await schemaProvider.tableCompletionItems() + items += await schemaProvider?.tableCompletionItems() ?? [] } items += favoriteCompletions(matching: context.prefix) @@ -468,6 +469,12 @@ final class SQLCompletionProvider { return items } + func allFavoriteItems() -> [SQLCompletionItem] { + favoriteKeywords + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + .map { SQLCompletionItem.favorite(keyword: $0.key, name: $0.value.name, query: $0.value.query) } + } + private func favoriteCompletions(matching prefix: String) -> [SQLCompletionItem] { guard !prefix.isEmpty, !favoriteKeywords.isEmpty else { return [] } let lowerPrefix = prefix.lowercased() @@ -507,9 +514,9 @@ final class SQLCompletionProvider { /// Columns from explicit table references, or all cached schema columns as fallback private func columnItems(for references: [TableReference]) async -> [SQLCompletionItem] { if references.isEmpty { - return await schemaProvider.allColumnsFromCachedTables() + return await schemaProvider?.allColumnsFromCachedTables() ?? [] } - return await schemaProvider.allColumnsInScope(for: references) + return await schemaProvider?.allColumnsInScope(for: references) ?? [] } /// Filter to specific keywords diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 51e08377d..686965536 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -255,6 +255,10 @@ final class ConnectionStorage { appSettings.saveLastSchema(nil, for: connection.id) FavoriteTablesStorage.shared.removeFavorites(for: connection.id) + FilterSettingsStorage.shared.removeFilters(for: connection.id) + Task { + await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id) + } } /// Batch-delete multiple connections and clean up their Keychain entries @@ -284,6 +288,12 @@ final class ConnectionStorage { appSettings.saveLastSchema(nil, for: conn.id) FavoriteTablesStorage.shared.removeFavorites(for: conn.id) } + FilterSettingsStorage.shared.removeFilters(for: idsToDelete) + Task { + for conn in connectionsToDelete { + await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id) + } + } } /// Duplicate a connection with a new UUID and "(Copy)" suffix diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 424e3e1f7..032b949da 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -304,6 +304,34 @@ final class FilterSettingsStorage { ) + ".browse" } + func removeFilters(for connectionId: UUID) { + removeFilters(for: [connectionId]) + } + + func removeFilters(for connectionIds: Set) { + guard !connectionIds.isEmpty else { return } + + let encodedPrefixes = connectionIds.map { id in + let idString = id.uuidString + return (idString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? idString) + "." + } + let matchesConnection: (String) -> Bool = { name in + encodedPrefixes.contains { name.hasPrefix($0) } + } + + let fm = FileManager.default + do { + let files = try fm.contentsOfDirectory(at: filterStateDirectory, includingPropertiesForKeys: nil) + for file in files where matchesConnection(file.lastPathComponent) { + try? fm.removeItem(at: file) + } + } catch { + Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") + } + lastFiltersCache = lastFiltersCache.filter { !matchesConnection($0.key) } + browseSearchCache = browseSearchCache.filter { !matchesConnection($0.key) } + } + func clearAllLastFilters() { let fm = FileManager.default do { diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index 49fa1f858..93664cedc 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -51,6 +51,21 @@ internal final class SQLFavoriteManager: @unchecked Sendable { } } + func removeFavoritesAndFolders(for connectionId: UUID) async { + let removed = await storage.deleteFavoritesAndFolders(connectionId: connectionId) + if removed { + postUpdateNotification(connectionId: nil) + } + } + + func pruneOrphaned(activeConnectionIds: Set) async { + await storage.pruneOrphaned(retaining: activeConnectionIds) + } + + func hasFavorites(for connectionIds: [UUID]) async -> Bool { + await storage.hasFavorites(connectionIds: connectionIds) + } + func fetchFavorite(id: UUID) async -> SQLFavorite? { await storage.fetchFavorite(id: id) } diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index 7c642e904..47fa08379 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -375,6 +375,103 @@ internal actor SQLFavoriteStorage { return result == SQLITE_DONE } + private static let detachDanglingFolderReferencesSQL = """ + UPDATE favorites SET folder_id = NULL + WHERE folder_id IS NOT NULL AND folder_id NOT IN (SELECT id FROM folders); + """ + + @discardableResult + func deleteFavoritesAndFolders(connectionId: UUID) -> Bool { + guard sqlite3_exec(db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK else { return false } + + let id = connectionId.uuidString + guard run("DELETE FROM favorites WHERE connection_id = ?;", bindings: [id]), + run("DELETE FROM folders WHERE connection_id = ?;", bindings: [id]), + run(Self.detachDanglingFolderReferencesSQL) else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return false + } + + return sqlite3_exec(db, "COMMIT;", nil, nil, nil) == SQLITE_OK + } + + @discardableResult + func pruneOrphaned(retaining activeConnectionIds: Set) -> Int { + guard !activeConnectionIds.isEmpty else { return 0 } + + let ids = activeConnectionIds.map(\.uuidString) + let placeholders = ids.map { _ in "?" }.joined(separator: ",") + let orphanCondition = "connection_id IS NOT NULL AND connection_id NOT IN (\(placeholders))" + + guard sqlite3_exec(db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK else { return 0 } + + guard run("DELETE FROM favorites WHERE \(orphanCondition);", bindings: ids) else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return 0 + } + let prunedFavorites = Int(sqlite3_changes(db)) + + guard run("DELETE FROM folders WHERE \(orphanCondition);", bindings: ids) else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return 0 + } + let prunedFolders = Int(sqlite3_changes(db)) + + guard run(Self.detachDanglingFolderReferencesSQL) else { + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return 0 + } + + guard sqlite3_exec(db, "COMMIT;", nil, nil, nil) == SQLITE_OK else { return 0 } + + if prunedFavorites > 0 || prunedFolders > 0 { + Self.logger.info("Pruned \(prunedFavorites) favorites and \(prunedFolders) folders scoped to deleted connections") + } + return prunedFavorites + } + + private func run(_ sql: String, bindings: [String] = []) -> Bool { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + Self.logger.error("Failed to prepare statement: \(String(cString: sqlite3_errmsg(self.db)))") + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for (index, value) in bindings.enumerated() { + sqlite3_bind_text(statement, Int32(index + 1), value, -1, SQLITE_TRANSIENT) + } + + guard sqlite3_step(statement) == SQLITE_DONE else { + Self.logger.error("Statement failed: \(String(cString: sqlite3_errmsg(self.db)))") + return false + } + return true + } + + func hasFavorites(connectionIds: [UUID]) -> Bool { + guard !connectionIds.isEmpty else { return false } + + let placeholders = connectionIds.map { _ in "?" }.joined(separator: ",") + let sql = "SELECT 1 FROM favorites WHERE connection_id IN (\(placeholders)) LIMIT 1;" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for (index, id) in connectionIds.enumerated() { + sqlite3_bind_text(statement, Int32(index + 1), id.uuidString, -1, SQLITE_TRANSIENT) + } + + return sqlite3_step(statement) == SQLITE_ROW + } + func fetchFavorite(id: UUID) -> SQLFavorite? { let sql = "SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1;" var statement: OpaquePointer? @@ -695,6 +792,8 @@ internal actor SQLFavoriteStorage { if connectionIdString != nil { sql += " AND (connection_id IS NULL OR connection_id = ?)" + } else { + sql += " AND connection_id IS NULL" } sql += ";" diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 20c538b9a..c1c840cc3 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -488,6 +488,14 @@ final class SyncCoordinator { connections.removeAll { connectionIdsToDelete.contains($0.id) } if !services.connectionStorage.saveConnections(connections) { Self.logger.error("Failed to apply remote connection deletions: persistence error") + } else { + FilterSettingsStorage.shared.removeFilters(for: connectionIdsToDelete) + let favoriteManager = services.sqlFavoriteManager + Task { + for id in connectionIdsToDelete { + await favoriteManager.removeFavoritesAndFolders(for: id) + } + } } } if !groupIdsToDelete.isEmpty { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a785be35a..d09b2d897 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7957,6 +7957,12 @@ } } } + }, + "Are you sure you want to delete \"%@\"? Saved queries linked to this connection will also be deleted." : { + + }, + "Are you sure you want to delete %lld connections? Saved queries linked to these connections will also be deleted. This cannot be undone." : { + }, "Are you sure you want to delete %lld connections? This cannot be undone." : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 907e0e4e0..91a4e404c 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -773,6 +773,11 @@ struct TableProApp: App { Task { await QueryHistoryManager.shared.performStartupCleanup() } + + Task { @MainActor in + let activeIds = Set(ConnectionStorage.shared.loadConnections().map(\.id)) + await SQLFavoriteManager.shared.pruneOrphaned(activeConnectionIds: activeIds) + } } var body: some Scene { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 90b0b8d87..ef6c0c35e 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -10,6 +10,7 @@ import os private enum QuickSwitcherRanking { static let maxResults = 200 static let subtitleMatchPenalty = 0.6 + static let keywordMatchWeight = 1.0 static let frecencyBoost = 0.5 static let openTabBoost = 1.2 } @@ -318,13 +319,25 @@ internal final class QuickSwitcherViewModel { for item: QuickSwitcherItem, query: String ) -> (score: Double, matchedIndices: [Int])? { - if let nameMatch = FuzzyMatcher.match(query: query, candidate: item.name) { - return (Double(nameMatch.score), nameMatch.matchedIndices) + let nameMatch = FuzzyMatcher.match(query: query, candidate: item.name) + let subtitleWeight = item.kind == .savedQuery + ? QuickSwitcherRanking.keywordMatchWeight + : QuickSwitcherRanking.subtitleMatchPenalty + var subtitleScore: Double? + if !item.subtitle.isEmpty, let subtitleMatch = FuzzyMatcher.match(query: query, candidate: item.subtitle) { + subtitleScore = Double(subtitleMatch.score) * subtitleWeight + } + + switch (nameMatch, subtitleScore) { + case let (match?, score?) where score > Double(match.score): + return (score, []) + case let (match?, _): + return (Double(match.score), match.matchedIndices) + case let (nil, score?): + return (score, []) + case (nil, nil): + return nil } - guard !item.subtitle.isEmpty, - let subtitleMatch = FuzzyMatcher.match(query: query, candidate: item.subtitle) - else { return nil } - return (Double(subtitleMatch.score) * QuickSwitcherRanking.subtitleMatchPenalty, []) } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index e4ef44afc..f01d0f720 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -47,6 +47,7 @@ final class WelcomeViewModel { var showOnboarding: Bool var connectionsToDelete: [DatabaseConnection] = [] var showDeleteConfirmation = false + var pendingDeleteHasFavorites = false var showDeleteGroupConfirmation = false var groupToDelete: ConnectionGroup? var pendingMoveToNewGroup: [DatabaseConnection] = [] @@ -359,6 +360,16 @@ final class WelcomeViewModel { // MARK: - Delete + func requestDeleteConnections(_ targets: [DatabaseConnection]) { + guard !targets.isEmpty else { return } + connectionsToDelete = targets + pendingDeleteHasFavorites = false + showDeleteConfirmation = true + Task { + pendingDeleteHasFavorites = await services.sqlFavoriteManager.hasFavorites(for: targets.map(\.id)) + } + } + func deleteSelectedConnections() { let idsToDelete = Set(connectionsToDelete.map(\.id)) storage.deleteConnections(connectionsToDelete) diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 747841afa..803d49728 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -90,8 +90,7 @@ extension WelcomeWindowView { Divider() Button(role: .destructive) { - vm.connectionsToDelete = connections - vm.showDeleteConfirmation = true + vm.requestDeleteConnections(connections) } label: { Label( String(format: String(localized: "Delete %d Connections"), connections.count), @@ -210,8 +209,7 @@ extension WelcomeWindowView { Divider() Button(role: .destructive) { - vm.connectionsToDelete = [connection] - vm.showDeleteConfirmation = true + vm.requestDeleteConnections([connection]) } label: { Label(String(localized: "Delete"), systemImage: "trash") } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index cd46243bc..096a9c32e 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -54,9 +54,17 @@ struct WelcomeWindowView: View { } } message: { if vm.connectionsToDelete.count == 1, let first = vm.connectionsToDelete.first { - Text("Are you sure you want to delete \"\(first.name)\"?") + if vm.pendingDeleteHasFavorites { + Text("Are you sure you want to delete \"\(first.name)\"? Saved queries linked to this connection will also be deleted.") + } else { + Text("Are you sure you want to delete \"\(first.name)\"?") + } } else { - Text("Are you sure you want to delete \(vm.connectionsToDelete.count) connections? This cannot be undone.") + if vm.pendingDeleteHasFavorites { + Text("Are you sure you want to delete \(vm.connectionsToDelete.count) connections? Saved queries linked to these connections will also be deleted. This cannot be undone.") + } else { + Text("Are you sure you want to delete \(vm.connectionsToDelete.count) connections? This cannot be undone.") + } } } .alert( @@ -354,8 +362,7 @@ struct WelcomeWindowView: View { guard keyPress.modifiers.contains(.command) else { return .ignored } let toDelete = vm.selectedConnections guard !toDelete.isEmpty else { return .ignored } - vm.connectionsToDelete = toDelete - vm.showDeleteConfirmation = true + vm.requestDeleteConnections(toDelete) return .handled } .onKeyPress(characters: .init(charactersIn: "a"), phases: .down) { keyPress in diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index f8dbb24e1..62eba84b1 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -8,14 +8,11 @@ import AppKit import CodeEditSourceEditor import CodeEditTextView -import os import SwiftUI /// Adapts the existing CompletionEngine to CodeEditSourceEditor's suggestion system @MainActor final class SQLCompletionAdapter: CodeSuggestionDelegate { - private static let logger = Logger(subsystem: "com.TablePro", category: "SQLCompletionAdapter") - // MARK: - Properties private struct CompletionSession { @@ -28,7 +25,7 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { var context: CompletionContext } - private var completionEngine: CompletionEngine? + private var completionEngine: CompletionEngine private var favoriteKeywords: [String: (name: String, query: String)] = [:] private var session: CompletionSession? private let debounceNanoseconds: UInt64 = 50_000_000 @@ -36,31 +33,31 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { // MARK: - Initialization init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil) { - if let provider = schemaProvider { - let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] - self.completionEngine = CompletionEngine( - schemaProvider: provider, databaseType: databaseType, - dialect: dialect, statementCompletions: completions - ) - } + self.completionEngine = Self.makeEngine(schemaProvider: schemaProvider, databaseType: databaseType) } /// Update the schema provider (e.g. when connection changes) func updateSchemaProvider(_ provider: SQLSchemaProvider, databaseType: DatabaseType? = nil) { - let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] - self.completionEngine = CompletionEngine( - schemaProvider: provider, databaseType: databaseType, - dialect: dialect, statementCompletions: completions - ) - completionEngine?.updateFavoriteKeywords(favoriteKeywords) + completionEngine = Self.makeEngine(schemaProvider: provider, databaseType: databaseType) + completionEngine.updateFavoriteKeywords(favoriteKeywords) } /// Update favorite keywords for autocomplete expansion func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { favoriteKeywords = keywords - completionEngine?.updateFavoriteKeywords(keywords) + completionEngine.updateFavoriteKeywords(keywords) + } + + private static func makeEngine( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType? + ) -> CompletionEngine { + let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } + let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + return CompletionEngine( + schemaProvider: schemaProvider, databaseType: databaseType, + dialect: dialect, statementCompletions: completions + ) } // MARK: - CodeSuggestionDelegate @@ -74,11 +71,6 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { cursorPosition: CursorPosition, isManualTrigger: Bool ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { - guard let completionEngine else { - Self.logger.debug("Completion skipped: no engine (schema provider was nil at init)") - return nil - } - seedIntermediateSessionIfNeeded(textView: textView, cursorPosition: cursorPosition) do { @@ -161,9 +153,9 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { } private func seedIntermediateSessionIfNeeded(textView: TextViewController, cursorPosition: CursorPosition) { - guard session == nil, let completionEngine else { return } + guard session == nil else { return } - let keywordItems = completionEngine.keywordCompletions() + let keywordItems = completionEngine.keywordCompletions() + completionEngine.allFavoriteItems() guard !keywordItems.isEmpty else { return } let offset = cursorPosition.range.location @@ -193,8 +185,8 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { textView: TextViewController, cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { - guard let context = session?.context, - let provider = completionEngine?.provider else { return nil } + guard let context = session?.context else { return nil } + let provider = completionEngine.provider let offset = cursorPosition.range.location guard let nsText = textView.textView.textStorage?.string as NSString?, @@ -223,7 +215,8 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { textView: TextViewController, cursorPosition: CursorPosition? ) { - guard let entry = item as? SQLSuggestionEntry, + guard !textView.textView.hasMarkedText(), + let entry = item as? SQLSuggestionEntry, let context = session?.context else { return } let replaceRange = SQLTokenBoundary.replacementRange( diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 40b6d5001..2b6017f16 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -1090,6 +1090,28 @@ struct SQLCompletionProviderTests { #expect(!hasFavorite, "Favorites appear only when the typed token matches their keyword") } + @Test("Favorites and keywords complete without a schema provider") + func testFavoriteKeywordWithoutSchemaProvider() async { + let schemalessProvider = SQLCompletionProvider(schemaProvider: nil, databaseType: .mysql) + schemalessProvider.updateFavoriteKeywords(["report": (name: "Daily Report", query: "SELECT * FROM reports")]) + let text = "rep" + let (items, _) = await schemalessProvider.getCompletions(text: text, cursorPosition: text.count) + let favorite = items.first { $0.kind == .favorite } + #expect(favorite?.label == "report", "Favorites need no schema and must work without a connection") + #expect(items.contains { $0.kind == .keyword }, "SQL keywords must also complete without a connection") + } + + @Test("allFavoriteItems returns every favorite for session seeding") + func testAllFavoriteItems() { + provider.updateFavoriteKeywords([ + "report": (name: "Daily Report", query: "SELECT 1"), + "usr": (name: "Users", query: "SELECT 2") + ]) + let items = provider.allFavoriteItems() + #expect(items.count == 2) + #expect(items.allSatisfy { $0.kind == .favorite }) + } + // MARK: - Tables after JOIN (#1646) @Test("JOIN after an ON condition suggests available tables") diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index ae7973378..621270320 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -92,6 +92,81 @@ struct FilterSettingsStorageTests { ) } + @Test("Removing a connection's filters keeps other connections intact") + func removeFiltersForConnection() { + let (storage, directory) = makeStorage() + defer { try? FileManager.default.removeItem(at: directory) } + let deletedConnection = UUID() + let keptConnection = UUID() + let deletedFilters = [TestFixtures.makeTableFilter(column: "a")] + let keptFilters = [TestFixtures.makeTableFilter(column: "b")] + + storage.saveLastFilters( + deletedFilters, for: "users", connectionId: deletedConnection, databaseName: "db", schemaName: nil + ) + storage.saveLastFilters( + keptFilters, for: "users", connectionId: keptConnection, databaseName: "db", schemaName: nil + ) + + storage.removeFilters(for: deletedConnection) + + #expect( + storage.loadLastFilters(for: "users", connectionId: deletedConnection, databaseName: "db", schemaName: nil) + .isEmpty + ) + #expect( + storage.loadLastFilters(for: "users", connectionId: keptConnection, databaseName: "db", schemaName: nil) + == keptFilters + ) + } + + @Test("Batch removal clears filters for every given connection in one pass") + func removeFiltersForMultipleConnections() { + let (storage, directory) = makeStorage() + defer { try? FileManager.default.removeItem(at: directory) } + let first = UUID() + let second = UUID() + let kept = UUID() + for connectionId in [first, second, kept] { + storage.saveLastFilters( + [TestFixtures.makeTableFilter(column: "a")], + for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + } + + storage.removeFilters(for: [first, second]) + + #expect(storage.loadLastFilters(for: "users", connectionId: first, databaseName: "db", schemaName: nil).isEmpty) + #expect(storage.loadLastFilters(for: "users", connectionId: second, databaseName: "db", schemaName: nil).isEmpty) + #expect( + !storage.loadLastFilters(for: "users", connectionId: kept, databaseName: "db", schemaName: nil).isEmpty + ) + } + + @Test("Removed filters stay gone for a fresh storage instance") + func removeFiltersDeletesFiles() { + let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create UserDefaults suite for tests") + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let storage = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + storage.saveLastFilters( + [TestFixtures.makeTableFilter(column: "a")], + for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + + storage.removeFilters(for: connectionId) + + let fresh = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + #expect( + fresh.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty + ) + } + @Test("Saving an empty filter set clears the stored filters") func savingEmptyClearsState() { let (storage, directory) = makeStorage() diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift index 8e84f911e..8c86abf6a 100644 --- a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -215,6 +215,119 @@ struct SQLFavoriteStorageTests { #expect(map.count >= 2) } + @Test("Keyword map without connection returns only global keywords") + func fetchKeywordMapGlobalOnly() async { + let scoped = makeFavorite(name: "Scoped", query: "SELECT 1", keyword: "scoped", connectionId: UUID()) + let global = makeFavorite(name: "Global", query: "SELECT 2", keyword: "global") + + _ = await storage.addFavorite(scoped) + _ = await storage.addFavorite(global) + + let map = await storage.fetchKeywordMap() + #expect(map["global"] != nil) + #expect(map["scoped"] == nil) + } + + // MARK: - Connection Lifecycle + + @Test("Connection cascade removes scoped favorites, keeps global and others") + func deleteFavoritesByConnection() async { + let connectionId = UUID() + let otherConnectionId = UUID() + let scoped = makeFavorite(name: "Scoped", keyword: "sc", connectionId: connectionId) + let other = makeFavorite(name: "Other", connectionId: otherConnectionId) + let global = makeFavorite(name: "Global") + + _ = await storage.addFavorite(scoped) + _ = await storage.addFavorite(other) + _ = await storage.addFavorite(global) + + _ = await storage.deleteFavoritesAndFolders(connectionId: connectionId) + + let remaining = await storage.fetchFavorites() + #expect(!remaining.contains { $0.id == scoped.id }) + #expect(remaining.contains { $0.id == other.id }) + #expect(remaining.contains { $0.id == global.id }) + } + + @Test("Connection cascade removes scoped folders and detaches global favorites inside them") + func deleteFoldersByConnection() async { + let connectionId = UUID() + let scopedFolder = makeFolder(name: "Scoped Folder", connectionId: connectionId) + let otherFolder = makeFolder(name: "Other Folder", connectionId: UUID()) + _ = await storage.addFolder(scopedFolder) + _ = await storage.addFolder(otherFolder) + + let globalInScopedFolder = makeFavorite(name: "Global In Folder", folderId: scopedFolder.id) + _ = await storage.addFavorite(globalInScopedFolder) + + _ = await storage.deleteFavoritesAndFolders(connectionId: connectionId) + + let folders = await storage.fetchFolders() + #expect(!folders.contains { $0.id == scopedFolder.id }) + #expect(folders.contains { $0.id == otherFolder.id }) + + let survivor = await storage.fetchFavorite(id: globalInScopedFolder.id) + #expect(survivor != nil, "A global favorite survives its connection-scoped folder") + #expect(survivor?.folderId == nil, "Its dangling folder reference is cleared") + } + + @Test("Orphan prune removes favorites and folders of dead connections only") + func pruneOrphanedFavorites() async { + let liveConnectionId = UUID() + let deadConnectionId = UUID() + let live = makeFavorite(name: "Live", keyword: "live", connectionId: liveConnectionId) + let dead = makeFavorite(name: "Dead", keyword: "dead", connectionId: deadConnectionId) + let global = makeFavorite(name: "Global", keyword: "glob") + let deadFolder = makeFolder(name: "Dead Folder", connectionId: deadConnectionId) + let liveFolder = makeFolder(name: "Live Folder", connectionId: liveConnectionId) + + _ = await storage.addFavorite(live) + _ = await storage.addFavorite(dead) + _ = await storage.addFavorite(global) + _ = await storage.addFolder(deadFolder) + _ = await storage.addFolder(liveFolder) + + let pruned = await storage.pruneOrphaned(retaining: [liveConnectionId]) + #expect(pruned == 1) + + let remaining = await storage.fetchFavorites() + #expect(remaining.contains { $0.id == live.id }) + #expect(!remaining.contains { $0.id == dead.id }) + #expect(remaining.contains { $0.id == global.id }) + + let folders = await storage.fetchFolders() + #expect(!folders.contains { $0.id == deadFolder.id }) + #expect(folders.contains { $0.id == liveFolder.id }) + } + + @Test("Orphan prune is skipped when no active connections are known") + func pruneSkippedWithoutActiveConnections() async { + let scoped = makeFavorite(name: "Scoped", connectionId: UUID()) + _ = await storage.addFavorite(scoped) + + let pruned = await storage.pruneOrphaned(retaining: []) + #expect(pruned == 0) + + let remaining = await storage.fetchFavorites() + #expect(remaining.contains { $0.id == scoped.id }) + } + + @Test("hasFavorites reflects scoped favorites only") + func hasFavoritesByConnection() async { + let connectionId = UUID() + let emptyConnectionId = UUID() + _ = await storage.addFavorite(makeFavorite(name: "Scoped", connectionId: connectionId)) + _ = await storage.addFavorite(makeFavorite(name: "Global")) + + let scopedResult = await storage.hasFavorites(connectionIds: [connectionId]) + let emptyResult = await storage.hasFavorites(connectionIds: [emptyConnectionId]) + let noneResult = await storage.hasFavorites(connectionIds: []) + #expect(scopedResult) + #expect(!emptyResult) + #expect(!noneResult) + } + // MARK: - FTS5 Search @Test("Search finds favorites by query text") diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 9cca3519f..4abf15914 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -73,6 +73,22 @@ struct QuickSwitcherViewModelTests { #expect(vm.flatItems.allSatisfy { $0.name.localizedCaseInsensitiveContains("u") }) } + @Test("Saved query is found by its keyword") + func savedQueryFoundByKeyword() async throws { + var items = sampleItems() + items.append(QuickSwitcherItem( + id: "favorite_1", + name: "Daily Report", + kind: .savedQuery, + subtitle: "rpt", + payload: "SELECT * FROM reports" + )) + let vm = makeViewModel(items: items) + vm.searchText = "rpt" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.contains { $0.id == "favorite_1" }, "Typing the keyword must surface the saved query") + } + @Test("Browse scope caps at maxResults") func filterCaps() { var items: [QuickSwitcherItem] = [] diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 256113a5a..2e89a9d9b 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -51,7 +51,9 @@ Enter a name, the SQL text, and optionally a keyword and scope. Assign a unique keyword to a favorite (e.g., `selall`). Start typing the keyword in the SQL editor and it shows up in the autocomplete popup as a starred suggestion with the favorite's name. Press Tab or Enter to insert the full SQL. -Keywords must be unique across all favorites in the same scope. +Keywords must be unique across all favorites in the same scope. Typing a keyword in the Quick Switcher (`Shift+Cmd+O`) also finds the saved query. + +A favorite saved with a connection scope belongs to that connection: it appears only there, and deleting the connection deletes its saved queries. Use the **Global** scope for queries you want on every connection. {/* Screenshot: Keyword expansion in autocomplete */}