Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion TablePro/Core/Autocomplete/CompletionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class CompletionEngine {
// MARK: - Initialization

init(
schemaProvider: SQLSchemaProvider,
schemaProvider: SQLSchemaProvider?,
databaseType: DatabaseType? = nil,
dialect: SQLDialectDescriptor? = nil,
statementCompletions: [CompletionEntry] = []
Expand Down Expand Up @@ -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
Expand Down
39 changes: 23 additions & 16 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
Expand All @@ -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
Expand All @@ -62,7 +62,7 @@ final class SQLCompletionProvider {
}

func retrySchemaIfNeeded() async {
await schemaProvider.retryLoadSchemaIfNeeded()
await schemaProvider?.retryLoadSchemaIfNeeded()
}

// MARK: - Public API
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -456,18 +457,24 @@ 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)

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()
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions TablePro/Core/Storage/FilterSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,34 @@ final class FilterSettingsStorage {
) + ".browse"
}

func removeFilters(for connectionId: UUID) {
removeFilters(for: [connectionId])
}

func removeFilters(for connectionIds: Set<UUID>) {
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 {
Expand Down
15 changes: 15 additions & 0 deletions TablePro/Core/Storage/SQLFavoriteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>) 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)
}
Expand Down
99 changes: 99 additions & 0 deletions TablePro/Core/Storage/SQLFavoriteStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>) -> 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?
Expand Down Expand Up @@ -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 += ";"
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Loading
Loading