diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fdef005a..cdeb6c0192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index f977ce5425..70deca10ba 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -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 @@ -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) @@ -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() } @@ -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) @@ -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 diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index c6d1b8ba68..e306a1f1d4 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -26,7 +26,7 @@ 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? @@ -34,7 +34,7 @@ final class ConnectionCoordinator { var selectedTable: TableInfo? private(set) var queryHistory: [QueryHistoryItem] = [] - private let historyStorage = QueryHistoryStorage() + private var historyStorage: QueryHistoryStorage { appState.queryHistory } private let appState: AppState @@ -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 @@ -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) } @@ -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)") @@ -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") @@ -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) { @@ -368,7 +368,7 @@ final class ConnectionCoordinator { } func clearHistory() { - historyStorage.clearAll(for: connection.id) + historyStorage.clearAll(for: [connection.id]) queryHistory = [] } diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionDefaultsKey.swift b/TableProMobile/TableProMobile/Helpers/ConnectionDefaultsKey.swift new file mode 100644 index 0000000000..922a09fdb0 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionDefaultsKey.swift @@ -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)" + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift b/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift index 3eac5dd75d..fbe8ea4d0e 100644 --- a/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift +++ b/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift @@ -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 { diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift b/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift new file mode 100644 index 0000000000..b15e73e152 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift @@ -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 + )) + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionLocalState.swift b/TableProMobile/TableProMobile/Helpers/ConnectionLocalState.swift new file mode 100644 index 0000000000..64e019758d --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionLocalState.swift @@ -0,0 +1,18 @@ +import Foundation + +nonisolated struct ConnectionLocalState { + let secrets: ConnectionSecrets + let queryHistory: QueryHistoryStorage + let defaults: UserDefaults + + func purge(_ connectionIds: Set) { + 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) + } +} diff --git a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index dbde3ea41a..2902fc30e9 100644 --- a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -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" diff --git a/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift b/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift index 4eae7051bd..61b791ab28 100644 --- a/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift +++ b/TableProMobile/TableProMobile/Helpers/QueryHistoryStorage.swift @@ -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 [] } @@ -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) { + 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 } } diff --git a/TableProMobile/TableProMobile/Helpers/QueryTemplate.swift b/TableProMobile/TableProMobile/Helpers/QueryTemplate.swift new file mode 100644 index 0000000000..7fd8f99e12 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/QueryTemplate.swift @@ -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) + } +} diff --git a/TableProMobile/TableProMobile/Intents/RowPayload.swift b/TableProMobile/TableProMobile/Intents/RowPayload.swift index 2b9b5e23e1..4472cc9bfc 100644 --- a/TableProMobile/TableProMobile/Intents/RowPayload.swift +++ b/TableProMobile/TableProMobile/Intents/RowPayload.swift @@ -80,7 +80,7 @@ nonisolated enum RowPayload { } static func parseCSV(_ text: String) throws -> [PayloadRow] { - let records = CSVRecordParser.parse(text) + let records = try csvRecords(in: text) guard let header = records.first, !header.allSatisfy(\.isEmpty) else { throw IntentDataError.csvMissingHeader } @@ -95,6 +95,16 @@ nonisolated enum RowPayload { } } + private static func csvRecords(in text: String) throws -> [[String]] { + let dialect = DelimitedDialect() + let reader = DelimitedFieldReader(dialect: dialect) + return try Array(text.utf8).withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress else { return [] } + let index = try DelimitedRowIndexer.index(buffer, dialect: dialect, contentStart: 0) + return (0.. PayloadRow { var values: [String: PayloadValue] = [:] for (key, value) in dictionary { @@ -127,52 +137,3 @@ nonisolated enum RowPayload { return number.stringValue } } - -nonisolated enum CSVRecordParser { - static func parse(_ text: String) -> [[String]] { - var records: [[String]] = [] - var record: [String] = [] - var field = "" - var inQuotes = false - let characters = Array(text) - var index = 0 - - while index < characters.count { - let character = characters[index] - if inQuotes { - if character == "\"" { - if index + 1 < characters.count, characters[index + 1] == "\"" { - field.append("\"") - index += 1 - } else { - inQuotes = false - } - } else { - field.append(character) - } - } else { - switch character { - case "\"": - inQuotes = true - case ",": - record.append(field) - field = "" - case "\n": - record.append(field) - field = "" - records.append(record) - record = [] - case "\r": - break - default: - field.append(character) - } - } - index += 1 - } - - record.append(field) - records.append(record) - return records - } -} diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index dd9f1222be..dffea11556 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -166,7 +166,7 @@ final class ConnectionFormViewModel { } private func hydrateDatabaseFile(from connection: DatabaseConnection) { - guard connection.type == .sqlite || connection.type == .duckdb else { return } + guard connection.type.isLocalFile else { return } let location = localFiles.location(forStoredPath: connection.database) guard location != .inMemory else { if connection.type == .duckdb { @@ -191,7 +191,7 @@ final class ConnectionFormViewModel { } var isFileBased: Bool { - type == .sqlite || type == .duckdb + type.isLocalFile } var isEditing: Bool { existingConnection != nil } diff --git a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift index 57aae4f34f..bfdf3faed7 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -35,13 +35,14 @@ struct ConnectionInfoView: View { } } - if connection.type != .sqlite { + switch ConnectionInfoContent.section(for: connection, fileURL: databaseFileURL) { + case .server: serverSection if connection.sshEnabled, let ssh = connection.sshConfiguration { sshSection(ssh) } - } else { - sqliteFileSection + case .file(let file): + fileSection(file) } statsSection @@ -115,17 +116,18 @@ struct ConnectionInfoView: View { } @ViewBuilder - private var sqliteFileSection: some View { + private func fileSection(_ file: ConnectionFileDetail) -> some View { Section("File") { - let fileURL = databaseFileURL - LabeledContent("Name", value: fileURL?.lastPathComponent ?? connection.database) - LabeledContent("Path") { - Text(fileURL?.path ?? connection.database) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(2) - .truncationMode(.middle) + LabeledContent("Name", value: file.name) + if let path = file.path { + LabeledContent("Path") { + Text(path) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + } } } } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 9488c7ea31..500fe402e1 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -41,6 +41,7 @@ struct QueryEditorView: View { private var session: ConnectionSession? { coordinator.session } private var tables: [TableInfo] { coordinator.tables } private var databaseType: DatabaseType { coordinator.connection.type } + private var activeSchema: String? { coordinator.supportsSchemas ? coordinator.activeSchema : nil } private var safeModeLevel: SafeModeLevel { coordinator.connection.safeModeLevel } private var connectionId: UUID { coordinator.connection.id } @@ -56,7 +57,7 @@ struct QueryEditorView: View { query = pending coordinator.pendingQuery = nil } else if query.isEmpty { - query = UserDefaults.standard.string(forKey: "lastQuery.\(connectionId.uuidString)") ?? "" + query = UserDefaults.standard.string(forKey: ConnectionDefaultsKey.lastQuery.name(for: connectionId)) ?? "" } } .onChange(of: query) { _, newValue in @@ -64,7 +65,7 @@ struct QueryEditorView: View { saveQueryTask = Task { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } - UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)") + UserDefaults.standard.set(newValue, forKey: ConnectionDefaultsKey.lastQuery.name(for: connectionId)) } } .onDisappear { @@ -354,12 +355,15 @@ struct QueryEditorView: View { Label("History", systemImage: "clock") } - if !tables.isEmpty { + if !tables.isEmpty, databaseType.speaksSQL { Menu { ForEach(tables) { table in - Button(table.name) { - let quoted = SQLBuilder.quoteIdentifier(table.name, for: databaseType) - query = "SELECT * FROM \(quoted) LIMIT 100" + if let template = QueryTemplate.selectAll( + table: table.name, schema: activeSchema, type: databaseType + ) { + Button(table.name) { + query = template + } } } } label: { diff --git a/TableProMobile/TableProMobileTests/ConnectionCoordinatorHistoryTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorHistoryTests.swift new file mode 100644 index 0000000000..3e20144874 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorHistoryTests.swift @@ -0,0 +1,76 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +@Suite("Connection query history list") +struct ConnectionCoordinatorHistoryTests { + private let fixture: AppStateFixture + private let appState: AppState + private let connection = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.example.com") + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false) + #expect(appState.addConnection(connection)) + } + + private func makeCoordinator() -> ConnectionCoordinator { + let coordinator = ConnectionCoordinator(connection: connection, appState: appState) + coordinator.loadHistory() + return coordinator + } + + @Test("Running the same query twice in a row lists it once, as the store keeps it") + func repeatedQueryIsListedOnce() { + let coordinator = makeCoordinator() + + coordinator.addHistoryItem(QueryHistoryItem(query: "SELECT 1", connectionId: connection.id)) + coordinator.addHistoryItem(QueryHistoryItem(query: "SELECT 1", connectionId: connection.id)) + + #expect(coordinator.queryHistory.count == 1) + #expect(coordinator.queryHistory.map(\.id) == appState.queryHistory.load(for: connection.id).map(\.id)) + } + + @Test("The same query failing after it succeeded is listed twice") + func differentOutcomeIsListedAgain() { + let coordinator = makeCoordinator() + + coordinator.addHistoryItem(QueryHistoryItem(query: "SELECT 1", connectionId: connection.id)) + coordinator.addHistoryItem( + QueryHistoryItem(query: "SELECT 1", connectionId: connection.id, wasSuccessful: false, errorMessage: "gone") + ) + + #expect(coordinator.queryHistory.map(\.wasSuccessful) == [true, false]) + } + + @Test("A query past the store's cap drops the oldest entry from the list too") + func listFollowsTheStoreCap() { + for index in 0..<200 { + appState.queryHistory.save(QueryHistoryItem(query: "SELECT \(index)", connectionId: connection.id)) + } + let coordinator = makeCoordinator() + #expect(coordinator.queryHistory.count == 200) + + coordinator.addHistoryItem(QueryHistoryItem(query: "SELECT 200", connectionId: connection.id)) + + #expect(coordinator.queryHistory.count == 200) + #expect(coordinator.queryHistory.first?.query == "SELECT 1") + #expect(coordinator.queryHistory.last?.query == "SELECT 200") + } + + @Test("A history file that cannot be read or written leaves the listed history in place") + func unreadableStoreKeepsTheList() throws { + appState.queryHistory.save(QueryHistoryItem(query: "SELECT 1", connectionId: connection.id)) + let coordinator = makeCoordinator() + let historyFile = fixture.libraryDirectory.appendingPathComponent("query-history.json") + try FileManager.default.removeItem(at: historyFile) + try FileManager.default.createDirectory(at: historyFile, withIntermediateDirectories: false) + try Data("locked".utf8).write(to: historyFile.appendingPathComponent("entry")) + + coordinator.addHistoryItem(QueryHistoryItem(query: "SELECT 2", connectionId: connection.id)) + + #expect(coordinator.queryHistory.map(\.query) == ["SELECT 1"]) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift new file mode 100644 index 0000000000..45b5fae2a8 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Connection info content") +struct ConnectionInfoContentTests { + @Test("A DuckDB file connection gets the File section with its name and path") + func duckDBFile() { + let connection = DatabaseConnection(type: .duckdb, host: "127.0.0.1", port: 3_306, database: "/x/a.duckdb") + + let section = ConnectionInfoContent.section(for: connection, fileURL: URL(fileURLWithPath: "/x/a.duckdb")) + + #expect(section == .file(ConnectionFileDetail(name: "a.duckdb", path: "/x/a.duckdb"))) + } + + @Test("An in-memory DuckDB connection says In Memory instead of a path") + func duckDBInMemory() { + let connection = DatabaseConnection( + type: .duckdb, + host: "127.0.0.1", + port: 3_306, + database: LocalDatabaseLocation.inMemoryPath + ) + + let section = ConnectionInfoContent.section(for: connection, fileURL: nil) + + #expect(section == .file(ConnectionFileDetail(name: String(localized: "In Memory"), path: nil))) + } + + @Test("A SQLite connection gets the File section") + func sqliteFile() { + let connection = DatabaseConnection(type: .sqlite, database: "/var/mobile/Documents/app.sqlite") + + let section = ConnectionInfoContent.section( + for: connection, + fileURL: URL(fileURLWithPath: "/var/mobile/Documents/app.sqlite") + ) + + #expect(section == .file(ConnectionFileDetail(name: "app.sqlite", path: "/var/mobile/Documents/app.sqlite"))) + } + + @Test("A server connection gets the Server section") + func serverConnection() { + let connection = DatabaseConnection(type: .postgresql, host: "db.acme.io", port: 5_432, database: "app") + + #expect(ConnectionInfoContent.section(for: connection, fileURL: nil) == .server) + } + + @Test("Only SQLite and DuckDB are local file engines", arguments: DatabaseType.mobileSupportedTypes) + func localFileEngines(type: DatabaseType) { + #expect(type.isLocalFile == (type == .sqlite || type == .duckdb)) + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift new file mode 100644 index 0000000000..9da62e7aa0 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/DeletedConnectionPurgeTests.swift @@ -0,0 +1,94 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +@Suite("Deleted connection purge") +struct DeletedConnectionPurgeTests { + private let fixture: AppStateFixture + private let deleted = DatabaseConnection(name: "Deleted", type: .postgresql) + private let kept = DatabaseConnection(name: "Kept", type: .postgresql) + + init() throws { + fixture = try AppStateFixture() + } + + private func makeLibrary(secureStore: MockSecureStore = MockSecureStore()) -> AppState { + let state = fixture.makeState(syncEnabled: false, secureStore: secureStore) + #expect(state.addConnection(deleted)) + #expect(state.addConnection(kept)) + state.queryHistory.save(QueryHistoryItem(query: "SELECT card FROM payments", connectionId: deleted.id)) + state.queryHistory.save(QueryHistoryItem(query: "SELECT 1", connectionId: kept.id)) + return state + } + + @Test("Deleting a connection forgets its query history and keeps the others'") + func localDeleteClearsHistory() { + let state = makeLibrary() + + state.removeConnections([deleted.id]) + + #expect(state.queryHistory.load(for: deleted.id).isEmpty) + #expect(state.queryHistory.load(for: kept.id).map(\.query) == ["SELECT 1"]) + } + + @Test("A connection deleted on another device loses its history and secrets here") + func syncedDeleteClearsLocalState() throws { + let secureStore = MockSecureStore() + let state = makeLibrary(secureStore: secureStore) + let deletedPassword = ConnectionSecretKind.password.account(for: deleted.id) + let keptPassword = ConnectionSecretKind.password.account(for: kept.id) + secureStore.seed(deletedPassword, "hunter2") + secureStore.seed(keptPassword, "kept") + + state.applySyncedConnections(state.connections.filter { $0.id != deleted.id }) + + #expect(state.connections.map(\.id) == [kept.id]) + #expect(state.queryHistory.load(for: deleted.id).isEmpty) + #expect(state.queryHistory.load(for: kept.id).map(\.query) == ["SELECT 1"]) + #expect(try secureStore.retrieve(forKey: deletedPassword) == nil) + #expect(try secureStore.retrieve(forKey: keptPassword) == "kept") + } + + @Test("The purge removes the connection's saved tab, database, schema and query, and no other connection's") + func purgeClearsSavedState() { + let history = QueryHistoryStorage(directory: fixture.libraryDirectory) + let purge = ConnectionLocalState( + secrets: ConnectionSecrets(secureStore: MockSecureStore()), + queryHistory: history, + defaults: fixture.defaults + ) + for key in ConnectionDefaultsKey.allCases { + fixture.defaults.set("value", forKey: key.name(for: deleted.id)) + fixture.defaults.set("value", forKey: key.name(for: kept.id)) + } + + purge.purge([deleted.id]) + + for key in ConnectionDefaultsKey.allCases { + #expect(fixture.defaults.string(forKey: key.name(for: deleted.id)) == nil) + #expect(fixture.defaults.string(forKey: key.name(for: kept.id)) == "value") + } + } + + @Test("The saved-state keys keep the names already stored on the device") + func savedStateKeyNames() { + let id = deleted.id.uuidString + #expect(ConnectionDefaultsKey.allCases.map { $0.name(for: deleted.id) } == [ + "lastTab.\(id)", "lastDB.\(id)", "lastSchema.\(id)", "lastQuery.\(id)" + ]) + } + + @Test("A sync merge that deletes nothing leaves every history alone") + func syncWithoutDeletionKeepsHistory() throws { + let state = makeLibrary() + var renamed = try #require(state.connections.first { $0.id == kept.id }) + renamed.name = "Renamed" + + state.applySyncedConnections(state.connections.map { $0.id == kept.id ? renamed : $0 }) + + #expect(state.queryHistory.load(for: deleted.id).count == 1) + #expect(state.queryHistory.load(for: kept.id).count == 1) + } +} diff --git a/TableProMobile/TableProMobileTests/Helpers/QueryTemplateTests.swift b/TableProMobile/TableProMobileTests/Helpers/QueryTemplateTests.swift new file mode 100644 index 0000000000..98014d65a8 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Helpers/QueryTemplateTests.swift @@ -0,0 +1,48 @@ +import Foundation +import TableProConnectionLibrary +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Query editor SELECT template") +struct QueryTemplateTests { + @Test("SQL Server gets OFFSET/FETCH and the schema the app has selected") + func mssql() { + let sql = QueryTemplate.selectAll(table: "t", schema: "sales", type: .mssql) + #expect(sql == "SELECT * FROM [sales].[t] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY") + } + + @Test("Oracle gets FETCH NEXT instead of LIMIT") + func oracle() { + let sql = QueryTemplate.selectAll(table: "EMP", schema: "HR", type: .oracle) + #expect(sql == "SELECT * FROM \"HR\".\"EMP\" ORDER BY 1 OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY") + } + + @Test("MySQL keeps LIMIT") + func mysql() { + let sql = QueryTemplate.selectAll(table: "orders", schema: nil, type: .mysql) + #expect(sql == "SELECT * FROM `orders` LIMIT 100 OFFSET 0") + } + + @Test("PostgreSQL names the selected schema") + func postgresql() { + let sql = QueryTemplate.selectAll(table: "users", schema: "billing", type: .postgresql) + #expect(sql == "SELECT * FROM \"billing\".\"users\" LIMIT 100 OFFSET 0") + } + + @Test("Redis has no SQL template, since its query editor runs commands") + func redis() { + #expect(QueryTemplate.selectAll(table: "session:1", schema: nil, type: .redis) == nil) + } + + @Test("Every iOS engine on the shared non-SQL list gets no SELECT template") + func templateFollowsTheSharedNonSQLList() { + for type in IOSDriverFactory().supportedTypes() { + let hasTemplate = QueryTemplate.selectAll(table: "t", schema: nil, type: type) != nil + #expect( + hasTemplate == SQLDDLFallbackPolicy.allowsGeneratedDDL(databaseTypeId: type.rawValue), + "\(type.rawValue)" + ) + } + } +} diff --git a/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift b/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift index b93e4572ed..4387172a09 100644 --- a/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift +++ b/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift @@ -48,6 +48,39 @@ struct RowPayloadTests { #expect(rows[0].value(for: "note") == .text("says \"hi\"")) } + @Test("parses CSV with CRLF line endings") + func csvCRLF() async throws { + let rows = try await RowPayload.parse(data: "name,age\r\nAda,36\r\n", file: nil) + #expect(rows.count == 1) + #expect(rows[0].value(for: "name") == .text("Ada")) + #expect(rows[0].value(for: "age") == .text("36")) + } + + @Test("keeps a CRLF inside a quoted CSV field") + func csvQuotedCRLF() async throws { + let rows = try await RowPayload.parse(data: "name,note\r\nAda,\"first\r\nsecond\"\r\nGrace,plain\r\n", file: nil) + #expect(rows.count == 2) + #expect(rows[0].value(for: "note") == .text("first\r\nsecond")) + #expect(rows[1].value(for: "name") == .text("Grace")) + #expect(rows[1].value(for: "note") == .text("plain")) + } + + @Test("parses CSV with carriage return line endings") + func csvCarriageReturn() async throws { + let rows = try await RowPayload.parse(data: "name,age\rAda,36\rGrace,40", file: nil) + #expect(rows.count == 2) + #expect(rows[0].value(for: "name") == .text("Ada")) + #expect(rows[1].value(for: "age") == .text("40")) + } + + @Test("splits a CSV field that starts with a combining mark") + func csvCombiningMarkAfterDelimiter() async throws { + let rows = try await RowPayload.parse(data: "name,mark\nAda,\u{0301}x", file: nil) + #expect(rows.count == 1) + #expect(rows[0].value(for: "name") == .text("Ada")) + #expect(rows[0].value(for: "mark") == .text("\u{0301}x")) + } + @Test("parseSingle rejects multiple rows") func parseSingleRejectsMany() async throws { await #expect(throws: IntentDataError.self) {