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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ extension Notification.Name {
// MARK: - SQL Favorites

static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate")
static let saveAsFavoriteRequested = Notification.Name("saveAsFavoriteRequested")

// MARK: - Plugins

Expand Down
23 changes: 8 additions & 15 deletions TablePro/Core/Storage/SQLFavoriteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import Foundation
import os

/// Manages SQL favorites with notifications and sync tracking
internal final class SQLFavoriteManager {
/// Manages SQL favorites with notifications
internal final class SQLFavoriteManager: @unchecked Sendable {
static let shared = SQLFavoriteManager()
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteManager")

Expand All @@ -27,7 +27,6 @@ internal final class SQLFavoriteManager {
func addFavorite(_ favorite: SQLFavorite) async -> Bool {
let result = await storage.addFavorite(favorite)
if result {
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
postUpdateNotification()
}
return result
Expand All @@ -36,7 +35,6 @@ internal final class SQLFavoriteManager {
func updateFavorite(_ favorite: SQLFavorite) async -> Bool {
let result = await storage.updateFavorite(favorite)
if result {
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
postUpdateNotification()
}
return result
Expand All @@ -45,24 +43,22 @@ internal final class SQLFavoriteManager {
func deleteFavorite(id: UUID) async -> Bool {
let result = await storage.deleteFavorite(id: id)
if result {
SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString)
postUpdateNotification()
}
return result
}

func deleteFavorites(ids: [UUID]) async {
for id in ids {
let result = await storage.deleteFavorite(id: id)
if result {
SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString)
}
}
if !ids.isEmpty {
let result = await storage.deleteFavorites(ids: ids)
if result {
postUpdateNotification()
}
}

func fetchFavorite(id: UUID) async -> SQLFavorite? {
await storage.fetchFavorite(id: id)
}

func fetchFavorites(
connectionId: UUID? = nil,
folderId: UUID? = nil,
Expand All @@ -76,7 +72,6 @@ internal final class SQLFavoriteManager {
func addFolder(_ folder: SQLFavoriteFolder) async -> Bool {
let result = await storage.addFolder(folder)
if result {
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
postUpdateNotification()
}
return result
Expand All @@ -85,7 +80,6 @@ internal final class SQLFavoriteManager {
func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool {
let result = await storage.updateFolder(folder)
if result {
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
postUpdateNotification()
}
return result
Expand All @@ -94,7 +88,6 @@ internal final class SQLFavoriteManager {
func deleteFolder(id: UUID) async -> Bool {
let result = await storage.deleteFolder(id: id)
if result {
SyncChangeTracker.shared.markDeleted(.favoriteFolder, id: id.uuidString)
postUpdateNotification()
}
return result
Expand Down
145 changes: 131 additions & 14 deletions TablePro/Core/Storage/SQLFavoriteStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ internal final class SQLFavoriteStorage {

deinit {
if let db = db {
sqlite3_close(db)
sqlite3_close_v2(db)
}
if Self.isRunningTests, let dbPath = dbPath {
try? FileManager.default.removeItem(atPath: dbPath)
Expand Down Expand Up @@ -119,14 +119,75 @@ internal final class SQLFavoriteStorage {
private func migrateIfNeeded() {
let currentVersion = getUserVersion()

// Future migrations go here:
// if currentVersion < 2 {
// execute("ALTER TABLE favorites ADD COLUMN new_col TEXT;")
// }
if currentVersion < 1 {
// Fresh database — tables already created without is_synced, jump to latest version
setUserVersion(2)
return
}

let targetVersion: Int32 = 1
if currentVersion < targetVersion {
setUserVersion(targetVersion)
if currentVersion < 2 {
// Remove is_synced column using rename-recreate-copy pattern
// (SQLite < 3.35.0 doesn't support ALTER TABLE DROP COLUMN)
execute("ALTER TABLE favorites RENAME TO favorites_old")
execute("""
CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY, name TEXT NOT NULL, query TEXT NOT NULL,
keyword TEXT, folder_id TEXT, connection_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
)
""")
execute("""
INSERT INTO favorites SELECT id, name, query, keyword, folder_id, connection_id,
sort_order, created_at, updated_at FROM favorites_old
""")
execute("DROP TABLE favorites_old")

execute("ALTER TABLE folders RENAME TO folders_old")
execute("""
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, connection_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
)
""")
execute("""
INSERT INTO folders SELECT id, name, parent_id, connection_id,
sort_order, created_at, updated_at FROM folders_old
""")
execute("DROP TABLE folders_old")

// Recreate indexes dropped with the old tables
execute("CREATE INDEX IF NOT EXISTS idx_favorites_connection ON favorites(connection_id);")
execute("CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);")
execute("CREATE INDEX IF NOT EXISTS idx_favorites_keyword ON favorites(keyword);")
execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_keyword_scope ON favorites(keyword, connection_id) WHERE keyword IS NOT NULL;")
execute("CREATE INDEX IF NOT EXISTS idx_folders_connection ON folders(connection_id);")
execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);")

// Recreate FTS5 triggers referencing the new table
execute("DROP TRIGGER IF EXISTS favorites_ai;")
execute("DROP TRIGGER IF EXISTS favorites_ad;")
execute("DROP TRIGGER IF EXISTS favorites_au;")
execute("""
CREATE TRIGGER IF NOT EXISTS favorites_ai AFTER INSERT ON favorites BEGIN
INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
END;
""")
execute("""
CREATE TRIGGER IF NOT EXISTS favorites_ad AFTER DELETE ON favorites BEGIN
INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
END;
""")
execute("""
CREATE TRIGGER IF NOT EXISTS favorites_au AFTER UPDATE ON favorites BEGIN
INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
END;
""")

// Rebuild FTS5 index to match new table rowids
execute("INSERT INTO favorites_fts(favorites_fts) VALUES('rebuild');")

setUserVersion(2)
}
}

Expand Down Expand Up @@ -158,8 +219,7 @@ internal final class SQLFavoriteStorage {
connection_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
is_synced INTEGER DEFAULT 0
updated_at REAL NOT NULL
);
"""

Expand All @@ -171,8 +231,7 @@ internal final class SQLFavoriteStorage {
connection_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
is_synced INTEGER DEFAULT 0
updated_at REAL NOT NULL
);
"""

Expand Down Expand Up @@ -225,8 +284,14 @@ internal final class SQLFavoriteStorage {

private func execute(_ sql: String) {
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
sqlite3_step(statement)
let prepareResult = sqlite3_prepare_v2(db, sql, -1, &statement, nil)
if prepareResult == SQLITE_OK {
let stepResult = sqlite3_step(statement)
if stepResult != SQLITE_DONE && stepResult != SQLITE_ROW {
Self.logger.error("sqlite3_step failed (\(stepResult)): \(String(cString: sqlite3_errmsg(self.db)))")
}
} else {
Self.logger.error("sqlite3_prepare_v2 failed (\(prepareResult)): \(String(cString: sqlite3_errmsg(self.db)))")
}
sqlite3_finalize(statement)
}
Expand Down Expand Up @@ -371,6 +436,58 @@ internal final class SQLFavoriteStorage {
}
}

func deleteFavorites(ids: [UUID]) async -> Bool {
guard !ids.isEmpty else { return true }
let idStrings = ids.map { $0.uuidString }
return await performDatabaseWork { [weak self] in
guard let self = self else { return false }

let placeholders = ids.map { _ in "?" }.joined(separator: ",")
let sql = "DELETE FROM favorites WHERE id IN (\(placeholders));"

var statement: OpaquePointer?
guard sqlite3_prepare_v2(self.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, idString) in idStrings.enumerated() {
sqlite3_bind_text(statement, Int32(index + 1), idString, -1, SQLITE_TRANSIENT)
}

let result = sqlite3_step(statement)
if result != SQLITE_DONE {
Self.logger.error("Failed to batch delete favorites: \(String(cString: sqlite3_errmsg(self.db)))")
}
return result == SQLITE_DONE
}
}

func fetchFavorite(id: UUID) async -> SQLFavorite? {
let idString = id.uuidString
return await performDatabaseWork { [weak self] in
guard let self = self else { return nil }

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?
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else {
return nil
}

defer { sqlite3_finalize(statement) }

let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT)

if sqlite3_step(statement) == SQLITE_ROW {
return self.parseFavorite(from: statement)
}
return nil
}
}

func fetchFavorites(
connectionId: UUID? = nil,
folderId: UUID? = nil,
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case duplicateRow
case truncateTable
case previewFKReference
case saveAsFavorite

// View
case toggleTableBrowser
Expand All @@ -98,7 +99,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .manageConnections, .newTab, .openDatabase, .openFile, .switchConnection,
.saveChanges, .saveAs, .previewSQL, .closeTab, .refresh,
.executeQuery, .explainQuery, .formatQuery, .export, .importData, .quickSwitcher,
.previousPage, .nextPage:
.previousPage, .nextPage, .saveAsFavorite:
return .file
case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste,
.delete, .selectAll, .clearSelection, .addRow,
Expand Down Expand Up @@ -148,6 +149,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .duplicateRow: return String(localized: "Duplicate Row")
case .truncateTable: return String(localized: "Truncate Table")
case .previewFKReference: return String(localized: "Preview FK Reference")
case .saveAsFavorite: return String(localized: "Save as Favorite")
case .toggleTableBrowser: return String(localized: "Toggle Table Browser")
case .toggleInspector: return String(localized: "Toggle Inspector")
case .toggleFilters: return String(localized: "Toggle Filters")
Expand Down Expand Up @@ -486,6 +488,7 @@ struct KeyboardSettings: Codable, Equatable {
.duplicateRow: KeyCombo(key: "d", command: true, shift: true),
.truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true),
.previewFKReference: KeyCombo(key: "space", isSpecialKey: true),
.saveAsFavorite: KeyCombo(key: "d", command: true),

// View
.toggleTableBrowser: KeyCombo(key: "0", command: true),
Expand Down
8 changes: 8 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ struct AppMenuCommands: Commands {

Divider()

Button(String(localized: "Save as Favorite")) {
actions?.saveAsFavorite()
}
.optionalKeyboardShortcut(shortcut(for: .saveAsFavorite))
.disabled(!(actions?.canSaveAsFavorite ?? false))

Divider()

Button(String(localized: "Explain with AI")) {
actions?.aiExplainQuery()
}
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Editor/HistoryPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ struct HistoryPanelView: View {
FavoriteEditDialog(
connectionId: connectionId,
favorite: nil,
initialQuery: item.query
initialQuery: item.query,
folders: []
)
}
}
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,14 @@ struct MainEditorContentView: View {
FavoriteEditDialog(
connectionId: connectionId,
favorite: nil,
initialQuery: item.query
initialQuery: item.query,
folders: []
)
}
.onReceive(NotificationCenter.default.publisher(for: .saveAsFavoriteRequested)) { notification in
guard let query = notification.userInfo?["query"] as? String else { return }
favoriteDialogQuery = FavoriteDialogQuery(query: query)
}
.onChange(of: tabManager.tabIds) { _, newIds in
guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty
|| !serverDashboardViewModels.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ extension MainContentCoordinator {
}
}

func saveCurrentQueryAsFavorite() {
guard let tab = tabManager.selectedTab,
tab.tabType == .query else { return }
let query = tab.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return }
NotificationCenter.default.post(
name: .saveAsFavoriteRequested,
object: nil,
userInfo: ["query": query]
)
}

/// Run a favorite's query: uses current tab if empty, otherwise opens a new tab.
func runFavoriteInNewTab(_ favorite: SQLFavorite) {
if tabManager.tabs.isEmpty {
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,15 @@ final class MainContentCommandActions {
coordinator?.openImportDialog()
}

func saveAsFavorite() {
coordinator?.saveCurrentQueryAsFavorite()
}

var canSaveAsFavorite: Bool {
guard let tab = coordinator?.tabManager.selectedTab else { return false }
return tab.tabType == .query && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

func previewSQL() {
coordinator?.handlePreviewSQL(
pendingTruncates: pendingTruncates.wrappedValue,
Expand Down
1 change: 0 additions & 1 deletion TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ final class MainContentCoordinator {
// Removed: showErrorAlert and errorAlertMessage - errors now display inline
var activeSheet: ActiveSheet?
var importFileURL: URL?
var pendingSaveAsFavoriteQuery: String?
var needsLazyLoad = false
var sidebarLoadingState: SidebarLoadingState = .idle

Expand Down
Loading
Loading