Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CSV import with single quotes merging rows at a double quote inside a field.
- Binary values in Latin-1 and Windows-1252 SQL dumps imported as different bytes.
- Shortcuts rejecting CSV and JSON files that are not UTF-8.
- Shortcuts Add Row and Add Rows rejecting CSV with CRLF or CR line endings as having no data.
- iOS Info tab showing a DuckDB connection as a server at 127.0.0.1:3306 instead of its file.
- iOS `SELECT * FROM` template writing `LIMIT 100` on SQL Server, Oracle and Redis, and leaving out the selected schema.
- iOS keeping the query history of deleted connections, and the passwords of connections deleted on another device.
- iOS history list showing a repeated query twice until the connection is reopened.
- Redis Cluster through a tunnel failing to connect with advice to set Connection Mode to Cluster.
- Redis `SCAN` typed in a query tab showing one page of keys with no next cursor to continue from.
- etcd `lease revoke`, `auth disable` and user or role deletion skipping confirmation, and list commands gated as writes.
Expand Down
22 changes: 11 additions & 11 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class AppState {
let sshProvider: IOSSSHProvider
let secureStore: any SecureStore
let localDatabaseFiles: LocalDatabaseFileLocator
let queryHistory: QueryHistoryStorage

private let sampleInstaller: SampleDatabaseInstaller
private let libraryPublisher: ConnectionLibraryPublisher
Expand All @@ -65,6 +66,7 @@ final class AppState {
libraryPreferences = ConnectionLibraryPreferences(defaults: defaults)
syncCoordinator = injectedSyncCoordinator ?? IOSSyncCoordinator()
storage = ConnectionPersistence(directory: libraryDirectory)
queryHistory = QueryHistoryStorage(directory: libraryDirectory)
groupStorage = GroupPersistence(directory: libraryDirectory)
tagStorage = TagPersistence(directory: libraryDirectory)
let driverFactory = IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles)
Expand Down Expand Up @@ -141,7 +143,9 @@ final class AppState {
func applySyncedConnections(_ merged: [DatabaseConnection]) {
guard !refuseWriteIfNotReady() else { return }
guard merged != connections else { return }
let removedIds = Set(connections.map(\.id)).subtracting(merged.map(\.id))
persist(connections: merged)
localState.purge(removedIds)
publishLibrary()
}

Expand Down Expand Up @@ -316,12 +320,8 @@ final class AppState {
guard !refuseWriteIfNotReady() else { return }
let removed = connections.filter { ids.contains($0.id) }
guard !removed.isEmpty else { return }
let secrets = ConnectionSecrets(secureStore: secureStore)
for connection in removed {
secrets.delete(for: connection.id)
clearPerConnectionPreferences(for: connection.id)
}
persist(connections: connections.filter { !ids.contains($0.id) })
localState.purge(Set(removed.map(\.id)))
publishLibrary()
for connection in removed where connection.participatesInSync {
syncCoordinator.markDeleted(connection.id)
Expand All @@ -343,12 +343,12 @@ final class AppState {
return true
}

private func clearPerConnectionPreferences(for id: UUID) {
let suffix = id.uuidString
let defaults = UserDefaults.standard
for prefix in ["lastTab.", "lastDB.", "lastSchema.", "lastQuery."] {
defaults.removeObject(forKey: prefix + suffix)
}
private var localState: ConnectionLocalState {
ConnectionLocalState(
secrets: ConnectionSecrets(secureStore: secureStore),
queryHistory: queryHistory,
defaults: .standard
)
}

// MARK: - Groups
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ final class ConnectionCoordinator {

var selectedTab: ConnectedTab = .tables {
didSet {
UserDefaults.standard.set(selectedTab.rawValue, forKey: "lastTab.\(connection.id.uuidString)")
UserDefaults.standard.set(selectedTab.rawValue, forKey: ConnectionDefaultsKey.lastTab.name(for: connection.id))
}
}
var pendingQuery: String?
var pendingTableName: String?
var selectedTable: TableInfo?

private(set) var queryHistory: [QueryHistoryItem] = []
private let historyStorage = QueryHistoryStorage()
private var historyStorage: QueryHistoryStorage { appState.queryHistory }

private let appState: AppState

Expand Down Expand Up @@ -72,13 +72,13 @@ final class ConnectionCoordinator {
// MARK: - Persisted State

func restorePersistedState() {
let key = connection.id.uuidString
if let savedTab = UserDefaults.standard.string(forKey: "lastTab.\(key)"),
let defaults = UserDefaults.standard
if let savedTab = defaults.string(forKey: ConnectionDefaultsKey.lastTab.name(for: connection.id)),
let tab = ConnectedTab(rawValue: savedTab) {
selectedTab = tab
}
activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? ""
activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public"
activeDatabase = defaults.string(forKey: ConnectionDefaultsKey.lastDB.name(for: connection.id)) ?? ""
activeSchema = defaults.string(forKey: ConnectionDefaultsKey.lastSchema.name(for: connection.id)) ?? "public"
}

// MARK: - Connection Lifecycle
Expand Down Expand Up @@ -272,7 +272,7 @@ final class ConnectionCoordinator {
self.session = freshSession
}
activeDatabase = name
UserDefaults.standard.set(name, forKey: "lastDB.\(connection.id.uuidString)")
UserDefaults.standard.set(name, forKey: ConnectionDefaultsKey.lastDB.name(for: connection.id))
if let current = self.session {
self.tables = try await current.driver.fetchTables(schema: nil)
}
Expand All @@ -297,7 +297,7 @@ final class ConnectionCoordinator {
self.session = newSession
self.tables = try await newSession.driver.fetchTables(schema: nil)
activeDatabase = database
UserDefaults.standard.set(database, forKey: "lastDB.\(connection.id.uuidString)")
UserDefaults.standard.set(database, forKey: ConnectionDefaultsKey.lastDB.name(for: connection.id))
await loadSchemas()
} catch {
Self.logger.error("Failed to switch to database \(database, privacy: .public): \(error.localizedDescription, privacy: .public)")
Expand Down Expand Up @@ -329,7 +329,7 @@ final class ConnectionCoordinator {
do {
try await session.driver.switchSchema(to: name)
activeSchema = name
UserDefaults.standard.set(name, forKey: "lastSchema.\(connection.id.uuidString)")
UserDefaults.standard.set(name, forKey: ConnectionDefaultsKey.lastSchema.name(for: connection.id))
self.tables = try await session.driver.fetchTables(schema: name)
} catch {
failureAlertMessage = String(localized: "Failed to switch schema")
Expand Down Expand Up @@ -358,8 +358,8 @@ final class ConnectionCoordinator {
}

func addHistoryItem(_ item: QueryHistoryItem) {
historyStorage.save(item)
queryHistory.append(item)
guard historyStorage.save(item) else { return }
loadHistory()
}

func deleteHistoryItem(_ id: UUID) {
Expand All @@ -368,7 +368,7 @@ final class ConnectionCoordinator {
}

func clearHistory() {
historyStorage.clearAll(for: connection.id)
historyStorage.clearAll(for: [connection.id])
queryHistory = []
}

Expand Down
12 changes: 12 additions & 0 deletions TableProMobile/TableProMobile/Helpers/ConnectionDefaultsKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

nonisolated enum ConnectionDefaultsKey: String, CaseIterable {
case lastTab
case lastDB
case lastSchema
case lastQuery

func name(for connectionId: UUID) -> String {
"\(rawValue).\(connectionId.uuidString)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ import TableProModels

nonisolated enum ConnectionDetailFormatter {
static func detail(for connection: DatabaseConnection) -> String {
switch connection.type {
case .sqlite, .duckdb:
return fileDetail(connection.database)
default:
return networkDetail(for: connection)
}
guard connection.type.isLocalFile else { return networkDetail(for: connection) }
return fileDetail(connection.database)
}

private static func fileDetail(_ path: String) -> String {
Expand Down
25 changes: 25 additions & 0 deletions TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import TableProModels

nonisolated struct ConnectionFileDetail: Equatable, Sendable {
let name: String
let path: String?
}

nonisolated enum ConnectionInfoSection: Equatable, Sendable {
case server
case file(ConnectionFileDetail)
}

nonisolated enum ConnectionInfoContent {
static func section(for connection: DatabaseConnection, fileURL: URL?) -> ConnectionInfoSection {
guard connection.type.isLocalFile else { return .server }
guard connection.database != LocalDatabaseLocation.inMemoryPath else {
return .file(ConnectionFileDetail(name: String(localized: "In Memory"), path: nil))
}
return .file(ConnectionFileDetail(
name: fileURL?.lastPathComponent ?? connection.database,
path: fileURL?.path ?? connection.database
))
}
}
18 changes: 18 additions & 0 deletions TableProMobile/TableProMobile/Helpers/ConnectionLocalState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

nonisolated struct ConnectionLocalState {
let secrets: ConnectionSecrets
let queryHistory: QueryHistoryStorage
let defaults: UserDefaults

func purge(_ connectionIds: Set<UUID>) {
guard !connectionIds.isEmpty else { return }
for connectionId in connectionIds {
secrets.delete(for: connectionId)
for key in ConnectionDefaultsKey.allCases {
defaults.removeObject(forKey: key.name(for: connectionId))
}
}
queryHistory.clearAll(for: connectionIds)
}
}
14 changes: 14 additions & 0 deletions TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ nonisolated extension DatabaseType {
}
}

var isLocalFile: Bool {
switch self {
case .sqlite, .duckdb: true
default: false
}
}

var speaksSQL: Bool {
switch self {
case .redis: false
default: true
}
}

var mobileDisplayName: String {
switch self {
case .mysql: "MySQL"
Expand Down
41 changes: 23 additions & 18 deletions TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,34 @@ nonisolated struct QueryHistoryItem: Identifiable, Codable, Hashable {
nonisolated struct QueryHistoryStorage {
private static let maxEntries = 200

private var fileURL: URL? {
guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true)
try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true)
return appDir.appendingPathComponent("query-history.json")
private let directory: URL

init(directory: URL) {
self.directory = directory
}

private var fileURL: URL {
directory.appendingPathComponent("query-history.json")
}

func save(_ item: QueryHistoryItem) {
@discardableResult
func save(_ item: QueryHistoryItem) -> Bool {
var items = loadAll()
if let last = items.last,
last.query == item.query,
last.connectionId == item.connectionId,
last.wasSuccessful == item.wasSuccessful {
return
return false
}
items.append(item)
if items.count > Self.maxEntries {
items.removeFirst(items.count - Self.maxEntries)
}
writeAll(items)
return writeAll(items)
}

func loadAll() -> [QueryHistoryItem] {
guard let fileURL, let data = try? Data(contentsOf: fileURL),
guard let data = try? Data(contentsOf: fileURL),
let items = try? JSONDecoder().decode([QueryHistoryItem].self, from: data) else {
return []
}
Expand All @@ -80,14 +82,17 @@ nonisolated struct QueryHistoryStorage {
writeAll(items)
}

func clearAll(for connectionId: UUID) {
var items = loadAll()
items.removeAll { $0.connectionId == connectionId }
writeAll(items)
func clearAll(for connectionIds: Set<UUID>) {
let items = loadAll()
let kept = items.filter { !connectionIds.contains($0.connectionId) }
guard kept.count != items.count else { return }
writeAll(kept)
}

private func writeAll(_ items: [QueryHistoryItem]) {
guard let fileURL, let data = try? JSONEncoder().encode(items) else { return }
try? data.write(to: fileURL, options: [.atomic, .completeFileProtection])
@discardableResult
private func writeAll(_ items: [QueryHistoryItem]) -> Bool {
guard let data = try? JSONEncoder().encode(items) else { return false }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return (try? data.write(to: fileURL, options: [.atomic, .completeFileProtection])) != nil
}
}
11 changes: 11 additions & 0 deletions TableProMobile/TableProMobile/Helpers/QueryTemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation
import TableProModels

nonisolated enum QueryTemplate {
static let selectAllRowLimit = 100

static func selectAll(table: String, schema: String?, type: DatabaseType) -> String? {
guard type.speaksSQL else { return nil }
return SQLBuilder.buildSelect(table: table, schema: schema, type: type, limit: selectAllRowLimit, offset: 0)
}
}
Loading
Loading